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 1a150242..ce853435 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
@@ -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 ? (
+
+ ) : undefined;
const { data: messageRows, isLoading: messagesLoading } =
useAiChatMessagesQuery(activeChatId ?? undefined);
@@ -537,28 +551,10 @@ export default function AiChatWindow() {
)}
- {/* 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 && (
-
-
- )}
+ {/* 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 (
+
+ );
+}
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 `