Files
gitmost/apps/client/src/features/ai-chat/components/conversation-list.tsx
T
claude_code 7ce1a24f82 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>
2026-06-22 16:16:26 +03:00

204 lines
6.1 KiB
TypeScript

import { useState } from "react";
import {
ActionIcon,
Box,
Group,
Loader,
Menu,
Text,
TextInput,
} from "@mantine/core";
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import {
useAiChatsQuery,
useDeleteAiChatMutation,
useRenameAiChatMutation,
} from "@/features/ai-chat/queries/ai-chat-query.ts";
import { IAiChat } from "@/features/ai-chat/types/ai-chat.types.ts";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
/**
* The dimmed second line of a chat row: how long ago the chat was created and
* the document it was created in. Its own component so the self-updating
* `useTimeAgo` hook is called per row legally (hooks cannot run inside `.map()`).
*/
function ChatMetaLine({
createdAt,
pageTitle,
}: {
createdAt: string;
pageTitle?: string | null;
}) {
const { t } = useTranslation();
const ago = useTimeAgo(createdAt);
// e.g. "2 hours ago · Onboarding guide" / "2 hours ago · No document"
return (
<Text size="xs" c="dimmed" lineClamp={1}>
{ago} · {pageTitle || t("No document")}
</Text>
);
}
interface ConversationListProps {
activeChatId: string | null;
onSelect: (chatId: string) => void;
}
/**
* The user's chat history. Selecting a chat opens it; rename is inline; delete
* is confirmed. A brand-new (unsaved) chat is not in this list until the server
* persists it on the first message.
*/
export default function ConversationList({
activeChatId,
onSelect,
}: ConversationListProps) {
const { t } = useTranslation();
const { data, isLoading } = useAiChatsQuery();
const renameMutation = useRenameAiChatMutation();
const deleteMutation = useDeleteAiChatMutation();
const [editingId, setEditingId] = useState<string | null>(null);
const [draftTitle, setDraftTitle] = useState("");
const startRename = (chat: IAiChat): void => {
setEditingId(chat.id);
setDraftTitle(chat.title ?? "");
};
const commitRename = (chatId: string): void => {
const title = draftTitle.trim();
setEditingId(null);
if (title) renameMutation.mutate({ chatId, title });
};
const confirmDelete = (chatId: string): void => {
modals.openConfirmModal({
title: t("Delete this chat?"),
centered: true,
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => deleteMutation.mutate(chatId),
});
};
if (isLoading) {
return (
<Group justify="center" py="sm">
<Loader size="sm" />
</Group>
);
}
const chats = data?.items ?? [];
if (chats.length === 0) {
return (
<Text size="sm" c="dimmed" py="xs">
{t("No chats yet.")}
</Text>
);
}
return (
<Box>
{chats.map((chat) => {
const isActive = chat.id === activeChatId;
if (editingId === chat.id) {
return (
<Box key={chat.id} px="xs" py={4}>
<TextInput
size="xs"
value={draftTitle}
autoFocus
onChange={(e) => setDraftTitle(e.currentTarget.value)}
onBlur={() => commitRename(chat.id)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commitRename(chat.id);
} else if (e.key === "Escape") {
setEditingId(null);
}
}}
/>
</Box>
);
}
return (
<Group
key={chat.id}
justify="space-between"
wrap="nowrap"
px="xs"
py={6}
className={clsx(
classes.conversationItem,
isActive && classes.conversationItemActive,
)}
role="button"
tabIndex={0}
onClick={() => onSelect(chat.id)}
onKeyDown={(e) => {
// Activate on Enter/Space like a native button; the inner menu
// button stops propagation so its own keys never reach this row.
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect(chat.id);
}
}}
>
<Box style={{ flex: 1, minWidth: 0 }}>
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
{chat.roleName && (
<Text
size="sm"
span
title={chat.roleName}
style={{ flex: "none" }}
>
{chat.roleEmoji || "🤖"}
</Text>
)}
<Text size="sm" lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
{chat.title || t("Untitled chat")}
</Text>
</Group>
<ChatMetaLine createdAt={chat.createdAt} pageTitle={chat.pageTitle} />
</Box>
<Menu shadow="md" width={180} position="bottom-end">
<Menu.Target>
<ActionIcon
variant="subtle"
color="gray"
aria-label={t("Chat menu")}
onClick={(e) => e.stopPropagation()}
>
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown onClick={(e) => e.stopPropagation()}>
<Menu.Item
leftSection={<IconEdit size={14} />}
onClick={() => startRename(chat)}
>
{t("Rename")}
</Menu.Item>
<Menu.Item
color="red"
leftSection={<IconTrash size={14} />}
onClick={() => confirmDelete(chat.id)}
>
{t("Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
);
})}
</Box>
);
}