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:
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
100
apps/client/src/features/ai-chat/components/role-cards.tsx
Normal file
100
apps/client/src/features/ai-chat/components/role-cards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
25
apps/client/src/features/ai-chat/utils/role-card-color.ts
Normal file
25
apps/client/src/features/ai-chat/utils/role-card-color.ts
Normal 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];
|
||||
}
|
||||
@@ -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 ключ локали).
|
||||
Reference in New Issue
Block a user