From 19cd73a5aaaea5789c72265a3a9c14035121227c Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 06:32:16 +0300 Subject: [PATCH] feat(ai-chat): role-selection cards as new-chat empty-state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the new-chat setSelectedRoleId(value || null)} - allowDeselect={false} - comboboxProps={{ withinPortal: true }} - data={[ - { value: "", label: t("Universal assistant") }, - ...enabledRoles.map((r) => ({ - value: r.id, - label: `${r.emoji ? `${r.emoji} ` : ""}${r.name}`, - })), - ]} - /> - - )} + {/* 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. */} {/* body: active chat thread */}
@@ -574,6 +570,7 @@ export default function AiChatWindow() { openPage={openPage} // Honoured only for a new chat; null = universal assistant. roleId={activeChatId === null ? selectedRoleId : null} + emptyState={roleCardsNode} 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 b1ff59e5..0aaa1c9b 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 { useMemo, useRef } from "react"; +import { ReactNode, useMemo, useRef } from "react"; import { generateId } from "ai"; import { Alert, Box, Stack } from "@mantine/core"; import { IconAlertTriangle } from "@tabler/icons-react"; @@ -29,6 +29,9 @@ 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; /** Called when a turn finishes; the parent refreshes the chat list and, for * a new chat, adopts the freshly created chat id. */ onTurnFinished: () => void; @@ -66,6 +69,7 @@ export default function ChatThread({ initialRows, openPage, roleId, + emptyState, onTurnFinished, }: ChatThreadProps) { const { t } = useTranslation(); @@ -161,7 +165,11 @@ export default function ChatThread({ return ( - + {error && ( { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }), + }); +}); + +// 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", + name: "Pirate", + emoji: "🏴‍☠️", + description: "Talks like a pirate", + enabled: true, + }, + { + id: "r2", + name: "Grandpa", + emoji: null, + description: null, + enabled: true, + }, +]; + +function renderCards( + selectedRoleId: string | null, + onSelect = vi.fn(), +) { + render( + + + , + ); + return onSelect; +} + +describe("RoleCards", () => { + it("renders a Universal assistant card plus one card per role", () => { + renderCards(null); + expect(screen.getByText("Universal assistant")).toBeDefined(); + expect(screen.getByText("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("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); + 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); + }); +}); diff --git a/apps/client/src/features/ai-chat/components/role-cards.tsx b/apps/client/src/features/ai-chat/components/role-cards.tsx new file mode 100644 index 00000000..dc10ae32 --- /dev/null +++ b/apps/client/src/features/ai-chat/components/role-cards.tsx @@ -0,0 +1,100 @@ +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. */ + 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; +} + +/** + * 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. + */ +function RoleCard({ + color, + label, + emoji, + title, + selected, + onClick, +}: { + color: string; + label: string; + emoji?: string | null; + title?: string; + selected: boolean; + onClick: () => void; +}) { + return ( + + {selected && ( + + + + )} + {emoji && {emoji}} + + {label} + + + ); +} + +/** + * 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. + */ +export default function RoleCards({ + roles, + selectedRoleId, + onSelect, +}: RoleCardsProps) { + const { t } = useTranslation(); + + return ( +
+ {/* Universal assistant: neutral gray, value null, highlighted by default. */} + onSelect(null)} + /> + {roles.map((role, index) => ( + onSelect(role.id)} + /> + ))} +
+ ); +} diff --git a/apps/client/src/features/ai-chat/utils/role-card-color.test.ts b/apps/client/src/features/ai-chat/utils/role-card-color.test.ts new file mode 100644 index 00000000..b26f7d1b --- /dev/null +++ b/apps/client/src/features/ai-chat/utils/role-card-color.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from "vitest"; +import { ROLE_CARD_PALETTE, roleCardColor } from "./role-card-color"; + +describe("roleCardColor", () => { + it("has a 10-color palette", () => { + expect(ROLE_CARD_PALETTE).toHaveLength(10); + }); + + it("maps index 0 to the first palette color (blue)", () => { + expect(roleCardColor(0)).toBe("blue"); + expect(roleCardColor(1)).toBe("grape"); + }); + + it("wraps around at the end of the palette", () => { + expect(roleCardColor(10)).toBe("blue"); + expect(roleCardColor(11)).toBe("grape"); + }); + + it("is safe for negative indices", () => { + expect(roleCardColor(-1)).toBe("violet"); + expect(roleCardColor(-10)).toBe("blue"); + }); +}); diff --git a/apps/client/src/features/ai-chat/utils/role-card-color.ts b/apps/client/src/features/ai-chat/utils/role-card-color.ts new file mode 100644 index 00000000..f3c79cd4 --- /dev/null +++ b/apps/client/src/features/ai-chat/utils/role-card-color.ts @@ -0,0 +1,25 @@ +// Fixed Mantine color palette for the new-chat role cards. Cards cycle through +// these names by index; the colors are applied via theme-aware Mantine CSS vars +// (`--mantine-color--light` etc.) so they are correct in both themes. +// Universal assistant uses neutral `gray` separately (not part of this palette). +export const ROLE_CARD_PALETTE = [ + "blue", + "grape", + "teal", + "orange", + "pink", + "cyan", + "lime", + "indigo", + "red", + "violet", +] as const; + +/** + * Pick a palette color name for a role card by its index. Cycles through the + * palette and is safe for negative indices. + */ +export function roleCardColor(index: number): string { + const len = ROLE_CARD_PALETTE.length; + return ROLE_CARD_PALETTE[((index % len) + len) % len]; +} diff --git a/docs/backlog/ai-chat-role-cards-empty-state.md b/docs/backlog/ai-chat-role-cards-empty-state.md deleted file mode 100644 index e6610ce7..00000000 --- a/docs/backlog/ai-chat-role-cards-empty-state.md +++ /dev/null @@ -1,165 +0,0 @@ -# Выбор agent role карточками в пустом окне чата (вместо выпадающего списка) - -Контекст: при создании нового чата identity (agent role) выбирается из -выпадающего списка Mantine ``: - `apps/client/src/features/ai-chat/components/ai-chat-window.tsx:543-561`. - Значение `""` → «Universal assistant» (роль `null`); остальные опции — - `enabledRoles` (эмодзи + имя). -2. Список включённых ролей фильтруется клиентом из всех живых ролей: - `ai-chat-window.tsx:144-147` (`enabledRoles = roles.filter(r => r.enabled)`). - Источник — `useAiRolesQuery(windowOpen)` - (`apps/client/src/features/ai-chat/queries/ai-chat-query.ts:131-137`). -3. Выбранный id хранится в jotai-атоме: - `apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts:23` - (`selectedAiRoleIdAtom`, `null` = Universal assistant). Сбрасывается в `null` - при «New chat»: `ai-chat-window.tsx:168-174` (`startNewChat`). -4. Выбранный id прокидывается в тред и уходит в теле первого запроса: - `ai-chat-window.tsx:570-578` (`roleId={activeChatId === null ? selectedRoleId : null}`) - → `apps/client/src/features/ai-chat/components/chat-thread.tsx:95-96, 128-138` - (`roleIdRef` → `prepareSendMessagesRequest` кладёт `roleId` в body). - Сервер учитывает `roleId` ТОЛЬКО при создании чата и фиксирует роль навсегда; - для существующего чата роль читается из строки чата (бейдж в шапке окна: - `ai-chat-window.tsx:433-440`). -5. Пустая область чата сейчас — бледный текст по центру: - `apps/client/src/features/ai-chat/components/message-list.tsx:130-140` - (`
` + `emptyState ?? t("Ask the AI agent anything...")`). - Важно: `MessageList` УЖЕ принимает произвольный `emptyState: ReactNode` - (`message-list.tsx:10-33, 64-70`) — этим пользуется публичный шэр. - -Данные роли в picker-представлении (доступны не-админам): -`id, name, emoji, description, enabled` — -`apps/server/src/core/ai-chat/roles/ai-agent-roles.service.ts:35-41, 164-173`. -То есть для карточек есть эмодзи и название (описание опционально). - -## Желаемое поведение - -- Вместо `` (`:543-561`) и импорт `Select` (`:9`, используется - только там — проверить, что `Group/Loader/Tooltip` остаются нужны). - - Собрать узел карточек только когда `activeChatId === null && - enabledRoles.length > 0`, иначе `undefined`. - - Передать его в `` (`:570-578`). Существующее - `roleId={...}` без изменений. - -3. **`chat-thread.tsx`**: - - Добавить необязательный проп `emptyState?: ReactNode` (импорт `ReactNode`) - и форварднуть в `` (`:164`). - -4. **`message-list.tsx`** — без изменений (проп `emptyState` уже поддержан). - -Иллюстративный набросок (НЕ финальный код), `AiChatWindow`: - -```tsx -// Role cards become the empty-state ONLY for a brand-new chat that has roles. -const roleCardsNode = - activeChatId === null && enabledRoles.length > 0 ? ( - - ) : undefined; -// ... - -``` - -## Краевые случаи - -- **Нет включённых ролей** → карточки не показываем (`emptyState = undefined`), - остаётся обычный дефолтный текст empty-state. -- **Существующий чат** (`activeChatId !== null`) → карточек нет; роль уже - зафиксирована и показана бейджем в шапке (`ai-chat-window.tsx:433-440`). -- **Сброс выбора** при «New chat» уже делается (`setSelectedRoleId(null)`, - `startNewChat`) — поведение сохраняется. -- **Много ролей** → контейнер с переносом и прокруткой, чтобы не ломать пустую - область чата. -- **Тёмная тема** → за счёт `-light`/`-filled` переменных Mantine цвета - корректны в обеих темах. -- **Эмодзи нет** → карточка показывает только имя (как сейчас в ``). Если решим добавить -подпись-подсказку (например «или просто начните печатать») — это один новый ключ -в `en-US/translation.json`; по умолчанию в объём не закладываю. - -## Режим работы при реализации - -Изменение нетривиальное (новый компонент + логика выбора/цветов + интеграция с -empty-state), поэтому — делегирование кодеру с обязательным последующим ревью -(`review` subagent), затем верификация перечитыванием файлов. - -## Открытые вопросы (решить перед/во время реализации) - -- [ ] Нужна ли карточка Universal assistant отдельной плиткой, или достаточно - «ничего не выбрано = дефолт»? Предлагается отдельная карточка (явный - возврат к дефолту после клика по роли) — подтвердить. -- [ ] Показывать ли `description` роли на карточке (есть в picker-view) или - только эмодзи + имя? По умолчанию — только эмодзи + имя, описание в `title`. -- [ ] Нужна ли подпись-подсказка над карточками (тогда +1 ключ локали).