diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 70353fee..fc39a8d9 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1144,6 +1144,7 @@ "Minimize": "Minimize", "Current context size": "Current context size", "AI agent": "AI agent", + "Take a look at the current document": "Take a look at the current document", "AI agent is typing…": "AI agent is typing…", "{{name}} is typing…": "{{name}} is typing…", "Send": "Send", diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index c5cfd007..4c40c4bf 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -669,6 +669,7 @@ "AI Answer": "Ответ ИИ", "Ask AI": "Спросить ИИ", "AI agent": "AI-агент", + "Take a look at the current document": "Посмотри текущий документ", "AI agent is typing…": "AI-агент печатает…", "{{name}} is typing…": "{{name}} печатает…", "Agent role": "Роль агента", diff --git a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx index ce853435..ecd0dcad 100644 --- a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx +++ b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx @@ -37,7 +37,6 @@ import { } from "@/features/ai-chat/queries/ai-chat-query.ts"; import ConversationList from "@/features/ai-chat/components/conversation-list.tsx"; import ChatThread from "@/features/ai-chat/components/chat-thread.tsx"; -import RoleCards from "@/features/ai-chat/components/role-cards.tsx"; import { buildChatMarkdown } from "@/features/ai-chat/utils/chat-markdown.ts"; import { shouldCollapseOnOutsidePointer, @@ -147,18 +146,6 @@ export default function AiChatWindow() { [roles], ); - // Role cards become the empty-state ONLY for a brand-new chat that has roles. - // Once the chat has messages, MessageList no longer renders the empty-state, - // so `selectedRoleId` staying null naturally falls back to Universal assistant - // (no "reset on send" logic needed). - const roleCardsNode = - activeChatId === null && enabledRoles.length > 0 ? ( - - ) : undefined; const { data: messageRows, isLoading: messagesLoading } = useAiChatMessagesQuery(activeChatId ?? undefined); @@ -192,8 +179,11 @@ export default function AiChatWindow() { setActiveChatId(chatId); setHistoryOpen(false); setDraft(""); + // Reset the card-picked role so a stale pick can't leak into the existing + // chat's header/assistant-name (which prefers the chat's persisted role). + setSelectedRoleId(null); }, - [setActiveChatId, setDraft], + [setActiveChatId, setDraft, setSelectedRoleId], ); // After a turn finishes, refresh the chat list. For a brand-new chat (no id @@ -213,6 +203,18 @@ export default function AiChatWindow() { ); const canExport = !!activeChatId && !!messageRows && messageRows.length > 0; + // The role to display in the header and as the assistant's name. Prefer the + // persisted role of an existing chat (chat-list JOIN); fall back to the role + // picked via a card click for a brand-new or just-adopted chat. selectChat + // resets selectedRoleId, so this fallback never leaks into an unrelated chat. + const currentRole = useMemo<{ name: string; emoji: string | null } | null>(() => { + if (activeChat?.roleName) { + return { name: activeChat.roleName, emoji: activeChat.roleEmoji ?? null }; + } + const picked = enabledRoles.find((r) => r.id === selectedRoleId); + return picked ? { name: picked.name, emoji: picked.emoji } : null; + }, [activeChat, enabledRoles, selectedRoleId]); + // Build a Markdown export from the already-loaded persisted rows (no network // call) and copy it to the clipboard. The "Copied" notification is the // feedback. @@ -444,12 +446,13 @@ export default function AiChatWindow() { {t("AI chat")} - {/* Role badge for the active chat (emoji + name). Shown only when the - chat is bound to a role that still exists. */} - {activeChat?.roleName && ( + {/* Role badge (emoji + name). Shows the persisted role of an existing + chat, or the role picked via a card for a brand-new chat. Hidden for + a universal (no-role) chat. */} + {currentRole && ( - {activeChat.roleEmoji ? `${activeChat.roleEmoji} ` : ""} - {activeChat.roleName} + {currentRole.emoji ? `${currentRole.emoji} ` : ""} + {currentRole.name} )} @@ -552,9 +555,9 @@ export default function AiChatWindow() { {/* The role picker for a NEW chat is rendered as the chat's empty-state - (colored role cards centered in the empty window); see roleCardsNode - above. Once the chat exists, its role is fixed and shown as a header - badge instead. */} + (colored role cards centered in the empty window) by ChatThread + itself — clicking a card starts the chat with that role. Once the + chat exists, its role is fixed and shown as a header badge instead. */} {/* body: active chat thread */}
@@ -570,7 +573,11 @@ export default function AiChatWindow() { openPage={openPage} // Honoured only for a new chat; null = universal assistant. roleId={activeChatId === null ? selectedRoleId : null} - emptyState={roleCardsNode} + // Role cards are the new-chat empty-state; offered only when this + // is a brand-new chat. Clicking a card starts the chat with it. + roles={activeChatId === null ? enabledRoles : undefined} + onRolePicked={(role) => setSelectedRoleId(role.id)} + assistantName={currentRole?.name} onTurnFinished={onTurnFinished} /> )} diff --git a/apps/client/src/features/ai-chat/components/chat-thread.tsx b/apps/client/src/features/ai-chat/components/chat-thread.tsx index 0aaa1c9b..75418986 100644 --- a/apps/client/src/features/ai-chat/components/chat-thread.tsx +++ b/apps/client/src/features/ai-chat/components/chat-thread.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useMemo, useRef } from "react"; +import { useMemo, useRef } from "react"; import { generateId } from "ai"; import { Alert, Box, Stack } from "@mantine/core"; import { IconAlertTriangle } from "@tabler/icons-react"; @@ -7,7 +7,11 @@ import { useChat, type UIMessage } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import MessageList from "@/features/ai-chat/components/message-list.tsx"; import ChatInput from "@/features/ai-chat/components/chat-input.tsx"; -import { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts"; +import RoleCards from "@/features/ai-chat/components/role-cards.tsx"; +import { + IAiChatMessageRow, + IAiRole, +} from "@/features/ai-chat/types/ai-chat.types.ts"; import { describeChatError } from "@/features/ai-chat/utils/error-message.ts"; import classes from "@/features/ai-chat/components/ai-chat.module.css"; @@ -29,9 +33,15 @@ interface ChatThreadProps { * in the request body so the server persists it on chat creation; ignored by * the server for existing chats (the role is read from the chat row). */ roleId?: string | null; - /** Content shown when the transcript is empty (forwarded to MessageList). - * Used by the new-chat window to render the colored role cards. */ - emptyState?: ReactNode; + /** Enabled roles for the new-chat empty state (only meaningful when + * `chatId === null`). Rendered as the colored role cards. */ + roles?: IAiRole[]; + /** Notify the parent which role was picked via a card, so it can update the + * header badge / assistant name for the brand-new chat. */ + onRolePicked?: (role: IAiRole) => void; + /** Display name for the assistant label / typing line (the role name); + * forwarded to MessageList. Absent => the generic "AI agent". */ + assistantName?: string; /** Called when a turn finishes; the parent refreshes the chat list and, for * a new chat, adopts the freshly created chat id. */ onTurnFinished: () => void; @@ -69,7 +79,9 @@ export default function ChatThread({ initialRows, openPage, roleId, - emptyState, + roles, + onRolePicked, + assistantName, onTurnFinished, }: ChatThreadProps) { const { t } = useTranslation(); @@ -163,12 +175,27 @@ export default function ChatThread({ const isStreaming = status === "submitted" || status === "streaming"; + // Clicking a role card both binds the role to THIS new chat and immediately + // starts the conversation. roleIdRef is set synchronously here because the + // parent's selectedRoleId state update would only reach roleIdRef on the next + // render — after this synchronous sendMessage has already read it. + const handleRolePick = (role: IAiRole): void => { + roleIdRef.current = role.id; + onRolePicked?.(role); + sendMessage({ text: t("Take a look at the current document") }); + }; + const showRoleCards = chatId === null && (roles?.length ?? 0) > 0; + const roleCardsEmptyState = showRoleCards ? ( + + ) : undefined; + return ( {error && ( diff --git a/apps/client/src/features/ai-chat/components/role-cards.module.css b/apps/client/src/features/ai-chat/components/role-cards.module.css index 9d1af715..b47dfd95 100644 --- a/apps/client/src/features/ai-chat/components/role-cards.module.css +++ b/apps/client/src/features/ai-chat/components/role-cards.module.css @@ -20,9 +20,9 @@ align-items: center; justify-content: center; gap: 4px; - min-width: 100px; - max-width: 130px; - min-height: 72px; + min-width: 140px; + max-width: 180px; + min-height: 90px; padding: 12px 10px; border-radius: var(--mantine-radius-md); border: 2px solid transparent; @@ -39,28 +39,15 @@ box-shadow: var(--mantine-shadow-sm); } -/* Selected: brighter/thicker ring. The ring color comes from the inline - `--role-card-border` var so it matches the card's palette color. */ -.card.selected { - border-color: var(--role-card-border); - box-shadow: 0 0 0 1px var(--role-card-border); -} - .emoji { font-size: 22px; line-height: 1; } -.checkBadge { - position: absolute; - top: 4px; - right: 4px; - display: flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - border-radius: 50%; - color: var(--mantine-color-white); - background: var(--role-card-border); +/* The description: small and slightly muted, inheriting the card's color. We + reduce opacity instead of using Mantine's `c="dimmed"` so it doesn't clash + with the card's inline color. */ +.description { + opacity: 0.8; + line-height: 1.3; } diff --git a/apps/client/src/features/ai-chat/components/role-cards.test.tsx b/apps/client/src/features/ai-chat/components/role-cards.test.tsx index ff977571..dda72aea 100644 --- a/apps/client/src/features/ai-chat/components/role-cards.test.tsx +++ b/apps/client/src/features/ai-chat/components/role-cards.test.tsx @@ -22,10 +22,6 @@ beforeAll(() => { }); }); -// react-i18next without an I18nextProvider returns the key verbatim, so -// t("Universal assistant") renders as "Universal assistant" — exactly the label -// we assert on below. - const roles: IAiRole[] = [ { id: "r1", @@ -43,57 +39,33 @@ const roles: IAiRole[] = [ }, ]; -function renderCards( - selectedRoleId: string | null, - onSelect = vi.fn(), -) { +function renderCards(onPick = vi.fn()) { render( - + , ); - return onSelect; + return onPick; } describe("RoleCards", () => { - it("renders a Universal assistant card plus one card per role", () => { - renderCards(null); - expect(screen.getByText("Universal assistant")).toBeDefined(); + it("renders one card per role with name, emoji, and description", () => { + renderCards(); expect(screen.getByText("Pirate")).toBeDefined(); + expect(screen.getByText("Talks like a pirate")).toBeDefined(); expect(screen.getByText("Grandpa")).toBeDefined(); // The emoji is shown for the role that has one. expect(screen.getByText("🏴‍☠️")).toBeDefined(); }); - it("highlights the Universal card when nothing is selected", () => { - renderCards(null); - const universal = screen.getByText("Universal assistant").closest("button"); - expect(universal?.getAttribute("aria-pressed")).toBe("true"); - const pirate = screen.getByText("Pirate").closest("button"); - expect(pirate?.getAttribute("aria-pressed")).toBe("false"); + it("does NOT render a Universal assistant card", () => { + renderCards(); + expect(screen.queryByText("Universal assistant")).toBeNull(); }); - it("highlights a role card when that role is selected", () => { - renderCards("r1"); - const universal = screen.getByText("Universal assistant").closest("button"); - expect(universal?.getAttribute("aria-pressed")).toBe("false"); - const pirate = screen.getByText("Pirate").closest("button"); - expect(pirate?.getAttribute("aria-pressed")).toBe("true"); - }); - - it("calls onSelect with the role id when a role card is clicked", () => { - const onSelect = renderCards(null); + it("calls onPick with the role object when a card is clicked", () => { + const onPick = renderCards(); fireEvent.click(screen.getByText("Pirate")); - expect(onSelect).toHaveBeenCalledWith("r1"); - }); - - it("calls onSelect with null when the Universal card is clicked", () => { - const onSelect = renderCards("r1"); - fireEvent.click(screen.getByText("Universal assistant")); - expect(onSelect).toHaveBeenCalledWith(null); + expect(onPick).toHaveBeenCalledWith(roles[0]); }); }); diff --git a/apps/client/src/features/ai-chat/components/role-cards.tsx b/apps/client/src/features/ai-chat/components/role-cards.tsx index dc10ae32..2551bdbf 100644 --- a/apps/client/src/features/ai-chat/components/role-cards.tsx +++ b/apps/client/src/features/ai-chat/components/role-cards.tsx @@ -1,98 +1,76 @@ import { UnstyledButton, Text } from "@mantine/core"; -import { IconCheck } from "@tabler/icons-react"; -import { useTranslation } from "react-i18next"; import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts"; import { roleCardColor } from "@/features/ai-chat/utils/role-card-color.ts"; import classes from "@/features/ai-chat/components/role-cards.module.css"; interface RoleCardsProps { - /** The enabled roles to render (one card each), after the Universal card. */ + /** The enabled roles to render (one card each). */ roles: IAiRole[]; - /** The currently selected role id; null = Universal assistant (the default). */ - selectedRoleId: string | null; - /** Called with the picked role id, or null for the Universal assistant card. */ - onSelect: (id: string | null) => void; + /** Called with the picked role when a card is clicked. The parent starts the + * chat with this role (binds it and sends the opening message). */ + onPick: (role: IAiRole) => void; } /** * One role card. Colors are injected inline via theme-aware Mantine CSS vars so * they render correctly in both light and dark themes; the CSS module owns only - * the layout. The selected card gets a brighter ring (`--role-card-border`) plus - * a small check badge, and carries `aria-pressed` for a11y/testing. + * the layout. The card shows the emoji (if any), the role name, and a small + * dimmed description line (if any). */ function RoleCard({ color, - label, + name, emoji, - title, - selected, + description, onClick, }: { color: string; - label: string; + name: string; emoji?: string | null; - title?: string; - selected: boolean; + description?: string | null; onClick: () => void; }) { return ( - {selected && ( - - - - )} {emoji && {emoji}} - - {label} + + {name} + {description && ( + + {description} + + )} ); } /** - * Colored role cards rendered as the empty-state of a brand-new chat. Clicking a - * card selects that identity; the first (gray) card returns to the default - * Universal assistant. Selection state lives in the parent atom, so when the - * chat is no longer empty these cards are simply not rendered and the existing - * server wiring is unchanged. + * Colored role cards rendered as the empty-state of a brand-new chat. There is + * no Universal assistant card — the universal assistant is the implicit default + * the user gets by simply typing into the composer without picking a card. + * Clicking a card immediately STARTS the chat with that role (the parent binds + * the role to the new chat and sends the opening message). */ -export default function RoleCards({ - roles, - selectedRoleId, - onSelect, -}: RoleCardsProps) { - const { t } = useTranslation(); - +export default function RoleCards({ roles, onPick }: RoleCardsProps) { return (
- {/* Universal assistant: neutral gray, value null, highlighted by default. */} - onSelect(null)} - /> {roles.map((role, index) => ( onSelect(role.id)} + description={role.description} + onClick={() => onPick(role)} /> ))}