Merge pull request '#113 feat(ai-chat): role-selection cards empty-state' (#113) from feat/ai-chat-role-cards into develop

This commit is contained in:
claude_code
2026-06-21 14:31:11 +03:00
8 changed files with 343 additions and 190 deletions

View File

@@ -6,7 +6,7 @@ import {
useRef,
useState,
} from "react";
import { Group, Loader, Select, Tooltip } from "@mantine/core";
import { Group, Loader, Tooltip } from "@mantine/core";
import {
IconArrowsDiagonal,
IconCheck,
@@ -37,6 +37,7 @@ 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,
@@ -145,6 +146,19 @@ export default function AiChatWindow() {
() => (roles ?? []).filter((r) => r.enabled === true),
[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 ? (
<RoleCards
roles={enabledRoles}
selectedRoleId={selectedRoleId}
onSelect={setSelectedRoleId}
/>
) : undefined;
const { data: messageRows, isLoading: messagesLoading } =
useAiChatMessagesQuery(activeChatId ?? undefined);
@@ -537,28 +551,10 @@ export default function AiChatWindow() {
)}
</div>
{/* Role picker — only for a NEW chat (before it is created). Once the
chat exists, its role is fixed and shown as a header badge instead.
Defaults to "Universal assistant" (no role). */}
{activeChatId === null && (enabledRoles?.length ?? 0) > 0 && (
<div style={{ padding: "4px 8px 0" }}>
<Select
size="xs"
label={t("Agent role")}
value={selectedRoleId ?? ""}
onChange={(value) => 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}`,
})),
]}
/>
</div>
)}
{/* 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 */}
<div className={classes.body}>
@@ -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}
/>
)}

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

View File

@@ -0,0 +1,66 @@
/* Layout only — per-card colors are injected inline via Mantine CSS vars. */
.container {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-content: center;
gap: 10px;
/* Cap the height so a large number of roles scrolls instead of blowing out
the empty chat area. */
max-height: 100%;
overflow-y: auto;
padding: 8px;
}
.card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 100px;
max-width: 130px;
min-height: 72px;
padding: 12px 10px;
border-radius: var(--mantine-radius-md);
border: 2px solid transparent;
cursor: pointer;
text-align: center;
transition:
transform 120ms ease,
box-shadow 120ms ease,
border-color 120ms ease;
}
.card:hover {
transform: translateY(-2px);
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);
}

View File

@@ -0,0 +1,99 @@
import { describe, it, expect, vi, beforeAll } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import RoleCards from "./role-cards";
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
// MantineProvider reads window.matchMedia (color scheme) on mount, which jsdom
// does not implement. Provide a minimal stub so the provider can render.
beforeAll(() => {
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(
<MantineProvider>
<RoleCards
roles={roles}
selectedRoleId={selectedRoleId}
onSelect={onSelect}
/>
</MantineProvider>,
);
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);
});
});

View File

@@ -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 (
<UnstyledButton
className={`${classes.card}${selected ? ` ${classes.selected}` : ""}`}
style={{
backgroundColor: `var(--mantine-color-${color}-light)`,
color: `var(--mantine-color-${color}-light-color)`,
// The selected-ring color (used by the CSS module).
["--role-card-border" as string]: `var(--mantine-color-${color}-filled)`,
}}
title={title}
aria-pressed={selected}
onClick={onClick}
>
{selected && (
<span className={classes.checkBadge}>
<IconCheck size={11} />
</span>
)}
{emoji && <span className={classes.emoji}>{emoji}</span>}
<Text size="sm" fw={500} lineClamp={2}>
{label}
</Text>
</UnstyledButton>
);
}
/**
* 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 (
<div className={classes.container}>
{/* Universal assistant: neutral gray, value null, highlighted by default. */}
<RoleCard
color="gray"
label={t("Universal assistant")}
selected={selectedRoleId === null}
onClick={() => onSelect(null)}
/>
{roles.map((role, index) => (
<RoleCard
key={role.id}
color={roleCardColor(index)}
label={role.name}
emoji={role.emoji}
title={role.description ?? role.name}
selected={selectedRoleId === role.id}
onClick={() => onSelect(role.id)}
/>
))}
</div>
);
}

View File

@@ -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");
});
});

View File

@@ -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-<name>-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];
}

View File

@@ -1,165 +0,0 @@
# Выбор agent role карточками в пустом окне чата (вместо выпадающего списка)
Контекст: при создании нового чата identity (agent role) выбирается из
выпадающего списка Mantine `<Select>`. Просьба: заменить список на **карточки
разных цветов с названием identity по центру пустого окна чата**. Клик по
карточке применяет роль; если пользователь карточку не нажал и просто написал
сообщение — срабатывает дефолтный Universal assistant.
Скриншот текущего поведения приложил пользователь: «Agent role» + раскрытый
список (Universal assistant ✓, Пират, Дедушка).
## Как сейчас устроен выбор роли (цепочка)
1. Picker рисуется только для нового чата (`activeChatId === null`), когда есть
включённые роли, как `<Select label="Agent role">`:
`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`
(`<Center>` + `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`.
То есть для карточек есть эмодзи и название (описание опционально).
## Желаемое поведение
- Вместо `<Select>` — карточки разных цветов по центру пустого окна чата.
- Каждая карточка = identity (роль), отдельный цвет, по центру эмодзи + имя.
- Отдельная карточка **Universal assistant** (дефолт), подсвечена по умолчанию.
- Клик по карточке выбирает/применяет identity (визуальная подсветка выбранной).
- Если ни одна карточка не нажата и пользователь отправил сообщение → роль `null`
→ Universal assistant (текущая дефолтная ветка сервера).
- После отправки первого сообщения карточки исчезают (чат больше не пуст).
## Ключевое архитектурное решение
Рисовать карточки **как empty-state** окна чата через уже существующий проп
`emptyState` у `MessageList`, а НЕ отдельным блоком над полем ввода. Почему так:
- «посреди пустого окна чата» получается само: `MessageList` оборачивает
`emptyState` в `<Center>` (`message-list.tsx:130-140`).
- «не нажал и написал сообщение → дефолт» получается само: как только
`messages.length > 0`, empty-state (и карточки) не рендерится, а
`selectedRoleId` остаётся `null` → Universal assistant. Никакой логики
«сбросить выбор при отправке» не нужно.
- Состояние выбора остаётся в том же `selectedAiRoleIdAtom`, поэтому вся
серверная обвязка (`roleId` в body, фиксация роли при создании чата) **не
меняется** — изменения чисто фронтовые.
Поток: `AiChatWindow` собирает узел карточек → новый проп `emptyState` у
`ChatThread` → форвард в `MessageList`.
## Состав изменений
1. **Новый компонент `role-cards.tsx`** (+ `role-cards.module.css`),
`apps/client/src/features/ai-chat/components/`:
- Пропсы: `roles: IAiRole[]`, `selectedRoleId: string | null`,
`onSelect: (id: string | null) => void`.
- Рендер: контейнер карточек с переносом (flex-wrap), по центру:
- первая карточка — Universal assistant (значение `null`), нейтрально-серая,
подсвечена когда `selectedRoleId === null`;
- по карточке на каждую роль: цвет по индексу, по центру эмодзи (если есть)
+ имя; подсвечена когда `selectedRoleId === r.id`.
- Карточка — `UnstyledButton` (доступность + темизация Mantine). Клик →
`onSelect(value)`. Выбранная — более яркий бордер/кольцо + галочка.
- Цвета — фиксированная палитра имён Mantine, циклично по индексу:
`blue, grape, teal, orange, pink, cyan, lime, indigo, red, violet`.
Через theme-aware CSS-переменные (корректны и в светлой, и в тёмной теме):
фон `var(--mantine-color-${c}-light)`, текст
`var(--mantine-color-${c}-light-color)`, бордер выбранной
`var(--mantine-color-${c}-filled)`. Universal — `gray`.
- Раскладка (размер карточек ~100–130px, отступы, hover, кольцо выбора,
прокрутка при большом числе ролей) — в CSS-модуле; цвет инжектится инлайн.
2. **`ai-chat-window.tsx`**:
- Удалить блок `<Select>` (`:543-561`) и импорт `Select` (`:9`, используется
только там — проверить, что `Group/Loader/Tooltip` остаются нужны).
- Собрать узел карточек только когда `activeChatId === null &&
enabledRoles.length > 0`, иначе `undefined`.
- Передать его в `<ChatThread emptyState={...} />` (`:570-578`). Существующее
`roleId={...}` без изменений.
3. **`chat-thread.tsx`**:
- Добавить необязательный проп `emptyState?: ReactNode` (импорт `ReactNode`)
и форварднуть в `<MessageList emptyState={...} />` (`: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 ? (
<RoleCards
roles={enabledRoles}
selectedRoleId={selectedRoleId}
onSelect={setSelectedRoleId}
/>
) : undefined;
// ...
<ChatThread
...
roleId={activeChatId === null ? selectedRoleId : null}
emptyState={roleCardsNode}
/>
```
## Краевые случаи
- **Нет включённых ролей** → карточки не показываем (`emptyState = undefined`),
остаётся обычный дефолтный текст empty-state.
- **Существующий чат** (`activeChatId !== null`) → карточек нет; роль уже
зафиксирована и показана бейджем в шапке (`ai-chat-window.tsx:433-440`).
- **Сброс выбора** при «New chat» уже делается (`setSelectedRoleId(null)`,
`startNewChat`) — поведение сохраняется.
- **Много ролей** → контейнер с переносом и прокруткой, чтобы не ломать пустую
область чата.
- **Тёмная тема** → за счёт `-light`/`-filled` переменных Mantine цвета
корректны в обеих темах.
- **Эмодзи нет** → карточка показывает только имя (как сейчас в `<Select>`:
`r.emoji ? ... : ''`).
## Локализация
Новых ключей не требуется: переиспользуем существующие `t("Agent role")` и
`t("Universal assistant")` (есть в `apps/client/public/locales/en-US/translation.json:1220-1221`;
остальные локали падают на ключ — как сейчас у `<Select>`). Если решим добавить
подпись-подсказку (например «или просто начните печатать») — это один новый ключ
в `en-US/translation.json`; по умолчанию в объём не закладываю.
## Режим работы при реализации
Изменение нетривиальное (новый компонент + логика выбора/цветов + интеграция с
empty-state), поэтому — делегирование кодеру с обязательным последующим ревью
(`review` subagent), затем верификация перечитыванием файлов.
## Открытые вопросы (решить перед/во время реализации)
- [ ] Нужна ли карточка Universal assistant отдельной плиткой, или достаточно
«ничего не выбрано = дефолт»? Предлагается отдельная карточка (явный
возврат к дефолту после клика по роли) — подтвердить.
- [ ] Показывать ли `description` роли на карточке (есть в picker-view) или
только эмодзи + имя? По умолчанию — только эмодзи + имя, описание в `title`.
- [ ] Нужна ли подпись-подсказка над карточками (тогда +1 ключ локали).