feat(ai-chat): role cards start the chat and show role identity #121

Merged
vvzvlad merged 1 commits from feat/ai-chat-role-cards-ux into develop 2026-06-21 16:28:51 +03:00
7 changed files with 115 additions and 142 deletions

View File

@@ -1144,6 +1144,7 @@
"Minimize": "Minimize",
"Current context size": "Current context size",
"AI agent": "AI agent",
"Take a look at the current document": "Take a look at the current document",
"AI agent is typing…": "AI agent is typing…",
"{{name}} is typing…": "{{name}} is typing…",
"Send": "Send",

View File

@@ -669,6 +669,7 @@
"AI Answer": "Ответ ИИ",
"Ask AI": "Спросить ИИ",
"AI agent": "AI-агент",
"Take a look at the current document": "Посмотри текущий документ",
"AI agent is typing…": "AI-агент печатает…",
"{{name}} is typing…": "{{name}} печатает…",
"Agent role": "Роль агента",

View File

@@ -37,7 +37,6 @@ 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,
@@ -147,18 +146,6 @@ export default function AiChatWindow() {
[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);
@@ -192,8 +179,11 @@ export default function AiChatWindow() {
setActiveChatId(chatId);
setHistoryOpen(false);
setDraft("");
// Reset the card-picked role so a stale pick can't leak into the existing
// chat's header/assistant-name (which prefers the chat's persisted role).
setSelectedRoleId(null);
},
[setActiveChatId, setDraft],
[setActiveChatId, setDraft, setSelectedRoleId],
);
// After a turn finishes, refresh the chat list. For a brand-new chat (no id
@@ -213,6 +203,18 @@ export default function AiChatWindow() {
);
const canExport = !!activeChatId && !!messageRows && messageRows.length > 0;
// The role to display in the header and as the assistant's name. Prefer the
// persisted role of an existing chat (chat-list JOIN); fall back to the role
// picked via a card click for a brand-new or just-adopted chat. selectChat
// resets selectedRoleId, so this fallback never leaks into an unrelated chat.
const currentRole = useMemo<{ name: string; emoji: string | null } | null>(() => {
if (activeChat?.roleName) {
return { name: activeChat.roleName, emoji: activeChat.roleEmoji ?? null };
}
const picked = enabledRoles.find((r) => r.id === selectedRoleId);
return picked ? { name: picked.name, emoji: picked.emoji } : null;
}, [activeChat, enabledRoles, selectedRoleId]);
// Build a Markdown export from the already-loaded persisted rows (no network
// call) and copy it to the clipboard. The "Copied" notification is the
// feedback.
@@ -444,12 +446,13 @@ export default function AiChatWindow() {
{t("AI chat")}
</span>
{/* Role badge for the active chat (emoji + name). Shown only when the
chat is bound to a role that still exists. */}
{activeChat?.roleName && (
{/* Role badge (emoji + name). Shows the persisted role of an existing
chat, or the role picked via a card for a brand-new chat. Hidden for
a universal (no-role) chat. */}
{currentRole && (
<span className={classes.badge} title={t("Agent role")}>
{activeChat.roleEmoji ? `${activeChat.roleEmoji} ` : ""}
{activeChat.roleName}
{currentRole.emoji ? `${currentRole.emoji} ` : ""}
{currentRole.name}
</span>
)}
@@ -552,9 +555,9 @@ export default function AiChatWindow() {
</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. */}
(colored role cards centered in the empty window) by ChatThread
itself — clicking a card starts the chat with that role. Once the
chat exists, its role is fixed and shown as a header badge instead. */}
{/* body: active chat thread */}
<div className={classes.body}>
@@ -570,7 +573,11 @@ export default function AiChatWindow() {
openPage={openPage}
// Honoured only for a new chat; null = universal assistant.
roleId={activeChatId === null ? selectedRoleId : null}
emptyState={roleCardsNode}
// Role cards are the new-chat empty-state; offered only when this
// is a brand-new chat. Clicking a card starts the chat with it.
roles={activeChatId === null ? enabledRoles : undefined}
onRolePicked={(role) => setSelectedRoleId(role.id)}
assistantName={currentRole?.name}
onTurnFinished={onTurnFinished}
/>
)}

View File

@@ -1,4 +1,4 @@
import { ReactNode, useMemo, useRef } from "react";
import { useMemo, useRef } from "react";
import { generateId } from "ai";
import { Alert, Box, Stack } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
@@ -7,7 +7,11 @@ import { useChat, type UIMessage } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import MessageList from "@/features/ai-chat/components/message-list.tsx";
import ChatInput from "@/features/ai-chat/components/chat-input.tsx";
import { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
import RoleCards from "@/features/ai-chat/components/role-cards.tsx";
import {
IAiChatMessageRow,
IAiRole,
} from "@/features/ai-chat/types/ai-chat.types.ts";
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
@@ -29,9 +33,15 @@ 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;
/** Enabled roles for the new-chat empty state (only meaningful when
* `chatId === null`). Rendered as the colored role cards. */
roles?: IAiRole[];
/** Notify the parent which role was picked via a card, so it can update the
* header badge / assistant name for the brand-new chat. */
onRolePicked?: (role: IAiRole) => void;
/** Display name for the assistant label / typing line (the role name);
* forwarded to MessageList. Absent => the generic "AI agent". */
assistantName?: string;
/** Called when a turn finishes; the parent refreshes the chat list and, for
* a new chat, adopts the freshly created chat id. */
onTurnFinished: () => void;
@@ -69,7 +79,9 @@ export default function ChatThread({
initialRows,
openPage,
roleId,
emptyState,
roles,
onRolePicked,
assistantName,
onTurnFinished,
}: ChatThreadProps) {
const { t } = useTranslation();
@@ -163,12 +175,27 @@ export default function ChatThread({
const isStreaming = status === "submitted" || status === "streaming";
// Clicking a role card both binds the role to THIS new chat and immediately
// starts the conversation. roleIdRef is set synchronously here because the
// parent's selectedRoleId state update would only reach roleIdRef on the next
// render — after this synchronous sendMessage has already read it.
const handleRolePick = (role: IAiRole): void => {
roleIdRef.current = role.id;
onRolePicked?.(role);
sendMessage({ text: t("Take a look at the current document") });
};
const showRoleCards = chatId === null && (roles?.length ?? 0) > 0;
const roleCardsEmptyState = showRoleCards ? (
<RoleCards roles={roles ?? []} onPick={handleRolePick} />
) : undefined;
return (
<Box className={classes.panel}>
<MessageList
messages={messages}
isStreaming={isStreaming}
emptyState={emptyState}
emptyState={roleCardsEmptyState}
assistantName={assistantName}
/>
{error && (

View File

@@ -20,9 +20,9 @@
align-items: center;
justify-content: center;
gap: 4px;
min-width: 100px;
max-width: 130px;
min-height: 72px;
min-width: 140px;
max-width: 180px;
min-height: 90px;
padding: 12px 10px;
border-radius: var(--mantine-radius-md);
border: 2px solid transparent;
@@ -39,28 +39,15 @@
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);
/* The description: small and slightly muted, inheriting the card's color. We
reduce opacity instead of using Mantine's `c="dimmed"` so it doesn't clash
with the card's inline color. */
.description {
opacity: 0.8;
line-height: 1.3;
}

View File

@@ -22,10 +22,6 @@ beforeAll(() => {
});
});
// 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",
@@ -43,57 +39,33 @@ const roles: IAiRole[] = [
},
];
function renderCards(
selectedRoleId: string | null,
onSelect = vi.fn(),
) {
function renderCards(onPick = vi.fn()) {
render(
<MantineProvider>
<RoleCards
roles={roles}
selectedRoleId={selectedRoleId}
onSelect={onSelect}
/>
<RoleCards roles={roles} onPick={onPick} />
</MantineProvider>,
);
return onSelect;
return onPick;
}
describe("RoleCards", () => {
it("renders a Universal assistant card plus one card per role", () => {
renderCards(null);
expect(screen.getByText("Universal assistant")).toBeDefined();
it("renders one card per role with name, emoji, and description", () => {
renderCards();
expect(screen.getByText("Pirate")).toBeDefined();
expect(screen.getByText("Talks like a 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("does NOT render a Universal assistant card", () => {
renderCards();
expect(screen.queryByText("Universal assistant")).toBeNull();
});
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);
it("calls onPick with the role object when a card is clicked", () => {
const onPick = renderCards();
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);
expect(onPick).toHaveBeenCalledWith(roles[0]);
});
});

View File

@@ -1,98 +1,76 @@
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. */
/** The enabled roles to render (one card each). */
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;
/** Called with the picked role when a card is clicked. The parent starts the
* chat with this role (binds it and sends the opening message). */
onPick: (role: IAiRole) => 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.
* the layout. The card shows the emoji (if any), the role name, and a small
* dimmed description line (if any).
*/
function RoleCard({
color,
label,
name,
emoji,
title,
selected,
description,
onClick,
}: {
color: string;
label: string;
name: string;
emoji?: string | null;
title?: string;
selected: boolean;
description?: string | null;
onClick: () => void;
}) {
return (
<UnstyledButton
className={`${classes.card}${selected ? ` ${classes.selected}` : ""}`}
className={classes.card}
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}
title={description ?? name}
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 size="sm" fw={600} lineClamp={2}>
{name}
</Text>
{description && (
<Text size="xs" lineClamp={3} className={classes.description}>
{description}
</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.
* Colored role cards rendered as the empty-state of a brand-new chat. There is
* no Universal assistant card — the universal assistant is the implicit default
* the user gets by simply typing into the composer without picking a card.
* Clicking a card immediately STARTS the chat with that role (the parent binds
* the role to the new chat and sends the opening message).
*/
export default function RoleCards({
roles,
selectedRoleId,
onSelect,
}: RoleCardsProps) {
const { t } = useTranslation();
export default function RoleCards({ roles, onPick }: RoleCardsProps) {
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}
name={role.name}
emoji={role.emoji}
title={role.description ?? role.name}
selected={selectedRoleId === role.id}
onClick={() => onSelect(role.id)}
description={role.description}
onClick={() => onPick(role)}
/>
))}
</div>