feat(ai-chat): show creation time and origin document in chat list

Each chat row in the AI-chat history now shows a dimmed second line with
how long ago the chat was created and the document it was created in
("N ago / <document>", or "No document" when started outside a page).

Server:
- New migration: nullable ai_chats.page_id (FK pages.id, ON DELETE SET NULL).
- Capture the origin page at chat creation from the client-supplied openPage,
  but validate it first: it must be a real page in the same workspace that the
  user may read (PageAccessService.validateCanView), else null. This keeps the
  "openPage.id is attacker-controllable but harmless" invariant - preventing a
  cross-workspace/cross-space page-title leak and a post-hijack FK crash.
- findByCreator left-joins pages (scoped by workspace, defense-in-depth) and
  returns pageTitle.

Client:
- IAiChat gains pageId/pageTitle; ConversationList renders a ChatMetaLine
  (useTimeAgo + origin document) as a dimmed second line.
- Add i18n key "No document" (en-US, ru-RU).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude_code
2026-06-22 16:16:26 +03:00
parent 89ac8fa37b
commit 7ce1a24f82
9 changed files with 119 additions and 10 deletions
@@ -0,0 +1,18 @@
import { type Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// The document a chat was created in (the user's open page at first message).
// Informational provenance shown in the chat-history list. NULL => the chat
// was started outside any document. ON DELETE SET NULL: a hard-deleted page
// degrades the chat to "no document" instead of breaking it.
await db.schema
.alterTable('ai_chats')
.addColumn('page_id', 'uuid', (col) =>
col.references('pages.id').onDelete('set null'),
)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('ai_chats').dropColumn('page_id').execute();
}
@@ -43,10 +43,21 @@ export class AiChatRepo {
.on('aiAgentRoles.deletedAt', 'is', null)
.on('aiAgentRoles.enabled', '=', true),
)
// Left-join the origin page for its title (provenance shown in the list).
// Scoped to the chat's workspace as defense-in-depth so a page id can only
// ever surface a same-workspace title. No deletedAt filter: a soft-deleted
// page keeps showing its historical title; a hard-deleted page already
// nulls aiChats.pageId via the FK.
.leftJoin('pages', (join) =>
join
.onRef('pages.id', '=', 'aiChats.pageId')
.onRef('pages.workspaceId', '=', 'aiChats.workspaceId'),
)
.selectAll('aiChats')
.select([
'aiAgentRoles.name as roleName',
'aiAgentRoles.emoji as roleEmoji',
'pages.title as pageTitle',
])
.where('aiChats.creatorId', '=', creatorId)
.where('aiChats.workspaceId', '=', workspaceId)
+3
View File
@@ -575,6 +575,9 @@ export interface AiChats {
// chat to universal instead of breaking it. Resolved from this column on every
// turn — NOT from the request body.
roleId: string | null;
// The document the chat was created in (open page at first message). NULL =>
// started outside any document. ON DELETE SET NULL on the page FK.
pageId: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
deletedAt: Timestamp | null;