feat(ai-chat): role-selection cards as new-chat empty-state

Replace the new-chat <Select label="Agent role"> picker with colored role
cards rendered as the empty-state of a brand-new chat (centered in the window),
per docs/backlog/ai-chat-role-cards-empty-state.md. Clicking a card selects that
identity; sending without a pick falls back to the Universal assistant; the
cards disappear once the chat is non-empty. Purely client-side — the existing
selectedAiRoleIdAtom + roleId request wiring (server role fixation on chat
creation) is unchanged.

- new RoleCards rendered through the existing emptyState prop chain
  (AiChatWindow -> ChatThread -> MessageList); MessageList already supported it.
- Universal assistant card (gray, value null, default-selected) + one card per
  enabled role, color cycled from a 10-name Mantine palette via the pure
  roleCardColor() helper; theme-aware CSS vars (light/-light-color/-filled).
- each card is an UnstyledButton with aria-pressed for a11y + testability.
- tests: role-card-color (palette cycling, negative-safe) + role-cards.test.tsx
  (render, emoji/name, selection highlight, click -> onSelect). 9 tests green,
  client tsc clean.

Verified live in-browser: cards (not a Select) show for a new chat; selecting
Пират binds the chat to that role end-to-end (badge + pirate reply); no pick =>
Universal; cards vanish after the first message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-21 06:32:16 +03:00
parent e5bc82c7f1
commit 19cd73a5aa
8 changed files with 343 additions and 190 deletions

View File

@@ -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 (
<Box className={classes.panel}>
<MessageList messages={messages} isStreaming={isStreaming} />
<MessageList
messages={messages}
isStreaming={isStreaming}
emptyState={emptyState}
/>
{error && (
<Alert