Merge pull request 'feat(ai-chat): role cards start the chat and show role identity' (#121) from feat/ai-chat-role-cards-ux into develop

Reviewed-on: #121
This commit was merged in pull request #121.
This commit is contained in:
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", "Minimize": "Minimize",
"Current context size": "Current context size", "Current context size": "Current context size",
"AI agent": "AI agent", "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…", "AI agent is typing…": "AI agent is typing…",
"{{name}} is typing…": "{{name}} is typing…", "{{name}} is typing…": "{{name}} is typing…",
"Send": "Send", "Send": "Send",

View File

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

View File

@@ -37,7 +37,6 @@ import {
} from "@/features/ai-chat/queries/ai-chat-query.ts"; } from "@/features/ai-chat/queries/ai-chat-query.ts";
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx"; import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
import ChatThread from "@/features/ai-chat/components/chat-thread.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 { buildChatMarkdown } from "@/features/ai-chat/utils/chat-markdown.ts";
import { import {
shouldCollapseOnOutsidePointer, shouldCollapseOnOutsidePointer,
@@ -147,18 +146,6 @@ export default function AiChatWindow() {
[roles], [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 } = const { data: messageRows, isLoading: messagesLoading } =
useAiChatMessagesQuery(activeChatId ?? undefined); useAiChatMessagesQuery(activeChatId ?? undefined);
@@ -192,8 +179,11 @@ export default function AiChatWindow() {
setActiveChatId(chatId); setActiveChatId(chatId);
setHistoryOpen(false); setHistoryOpen(false);
setDraft(""); 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 // 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; 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 // Build a Markdown export from the already-loaded persisted rows (no network
// call) and copy it to the clipboard. The "Copied" notification is the // call) and copy it to the clipboard. The "Copied" notification is the
// feedback. // feedback.
@@ -444,12 +446,13 @@ export default function AiChatWindow() {
{t("AI chat")} {t("AI chat")}
</span> </span>
{/* Role badge for the active chat (emoji + name). Shown only when the {/* Role badge (emoji + name). Shows the persisted role of an existing
chat is bound to a role that still exists. */} chat, or the role picked via a card for a brand-new chat. Hidden for
{activeChat?.roleName && ( a universal (no-role) chat. */}
{currentRole && (
<span className={classes.badge} title={t("Agent role")}> <span className={classes.badge} title={t("Agent role")}>
{activeChat.roleEmoji ? `${activeChat.roleEmoji} ` : ""} {currentRole.emoji ? `${currentRole.emoji} ` : ""}
{activeChat.roleName} {currentRole.name}
</span> </span>
)} )}
@@ -552,9 +555,9 @@ export default function AiChatWindow() {
</div> </div>
{/* The role picker for a NEW chat is rendered as the chat's empty-state {/* 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 (colored role cards centered in the empty window) by ChatThread
above. Once the chat exists, its role is fixed and shown as a header itself — clicking a card starts the chat with that role. Once the
badge instead. */} chat exists, its role is fixed and shown as a header badge instead. */}
{/* body: active chat thread */} {/* body: active chat thread */}
<div className={classes.body}> <div className={classes.body}>
@@ -570,7 +573,11 @@ export default function AiChatWindow() {
openPage={openPage} openPage={openPage}
// Honoured only for a new chat; null = universal assistant. // Honoured only for a new chat; null = universal assistant.
roleId={activeChatId === null ? selectedRoleId : null} 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} onTurnFinished={onTurnFinished}
/> />
)} )}

View File

@@ -1,4 +1,4 @@
import { ReactNode, useMemo, useRef } from "react"; import { useMemo, useRef } from "react";
import { generateId } from "ai"; import { generateId } from "ai";
import { Alert, Box, Stack } from "@mantine/core"; import { Alert, Box, Stack } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react"; import { IconAlertTriangle } from "@tabler/icons-react";
@@ -7,7 +7,11 @@ import { useChat, type UIMessage } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai"; import { DefaultChatTransport } from "ai";
import MessageList from "@/features/ai-chat/components/message-list.tsx"; import MessageList from "@/features/ai-chat/components/message-list.tsx";
import ChatInput from "@/features/ai-chat/components/chat-input.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 { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css"; 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 * 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). */ * the server for existing chats (the role is read from the chat row). */
roleId?: string | null; roleId?: string | null;
/** Content shown when the transcript is empty (forwarded to MessageList). /** Enabled roles for the new-chat empty state (only meaningful when
* Used by the new-chat window to render the colored role cards. */ * `chatId === null`). Rendered as the colored role cards. */
emptyState?: ReactNode; 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 /** Called when a turn finishes; the parent refreshes the chat list and, for
* a new chat, adopts the freshly created chat id. */ * a new chat, adopts the freshly created chat id. */
onTurnFinished: () => void; onTurnFinished: () => void;
@@ -69,7 +79,9 @@ export default function ChatThread({
initialRows, initialRows,
openPage, openPage,
roleId, roleId,
emptyState, roles,
onRolePicked,
assistantName,
onTurnFinished, onTurnFinished,
}: ChatThreadProps) { }: ChatThreadProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -163,12 +175,27 @@ export default function ChatThread({
const isStreaming = status === "submitted" || status === "streaming"; 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 ( return (
<Box className={classes.panel}> <Box className={classes.panel}>
<MessageList <MessageList
messages={messages} messages={messages}
isStreaming={isStreaming} isStreaming={isStreaming}
emptyState={emptyState} emptyState={roleCardsEmptyState}
assistantName={assistantName}
/> />
{error && ( {error && (

View File

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

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[] = [ const roles: IAiRole[] = [
{ {
id: "r1", id: "r1",
@@ -43,57 +39,33 @@ const roles: IAiRole[] = [
}, },
]; ];
function renderCards( function renderCards(onPick = vi.fn()) {
selectedRoleId: string | null,
onSelect = vi.fn(),
) {
render( render(
<MantineProvider> <MantineProvider>
<RoleCards <RoleCards roles={roles} onPick={onPick} />
roles={roles}
selectedRoleId={selectedRoleId}
onSelect={onSelect}
/>
</MantineProvider>, </MantineProvider>,
); );
return onSelect; return onPick;
} }
describe("RoleCards", () => { describe("RoleCards", () => {
it("renders a Universal assistant card plus one card per role", () => { it("renders one card per role with name, emoji, and description", () => {
renderCards(null); renderCards();
expect(screen.getByText("Universal assistant")).toBeDefined();
expect(screen.getByText("Pirate")).toBeDefined(); expect(screen.getByText("Pirate")).toBeDefined();
expect(screen.getByText("Talks like a pirate")).toBeDefined();
expect(screen.getByText("Grandpa")).toBeDefined(); expect(screen.getByText("Grandpa")).toBeDefined();
// The emoji is shown for the role that has one. // The emoji is shown for the role that has one.
expect(screen.getByText("🏴‍☠️")).toBeDefined(); expect(screen.getByText("🏴‍☠️")).toBeDefined();
}); });
it("highlights the Universal card when nothing is selected", () => { it("does NOT render a Universal assistant card", () => {
renderCards(null); renderCards();
const universal = screen.getByText("Universal assistant").closest("button"); expect(screen.queryByText("Universal assistant")).toBeNull();
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", () => { it("calls onPick with the role object when a card is clicked", () => {
renderCards("r1"); const onPick = renderCards();
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")); fireEvent.click(screen.getByText("Pirate"));
expect(onSelect).toHaveBeenCalledWith("r1"); expect(onPick).toHaveBeenCalledWith(roles[0]);
});
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

@@ -1,98 +1,76 @@
import { UnstyledButton, Text } from "@mantine/core"; 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 { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
import { roleCardColor } from "@/features/ai-chat/utils/role-card-color.ts"; import { roleCardColor } from "@/features/ai-chat/utils/role-card-color.ts";
import classes from "@/features/ai-chat/components/role-cards.module.css"; import classes from "@/features/ai-chat/components/role-cards.module.css";
interface RoleCardsProps { interface RoleCardsProps {
/** The enabled roles to render (one card each), after the Universal card. */ /** The enabled roles to render (one card each). */
roles: IAiRole[]; roles: IAiRole[];
/** The currently selected role id; null = Universal assistant (the default). */ /** Called with the picked role when a card is clicked. The parent starts the
selectedRoleId: string | null; * chat with this role (binds it and sends the opening message). */
/** Called with the picked role id, or null for the Universal assistant card. */ onPick: (role: IAiRole) => void;
onSelect: (id: string | null) => void;
} }
/** /**
* One role card. Colors are injected inline via theme-aware Mantine CSS vars so * 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 * 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 * the layout. The card shows the emoji (if any), the role name, and a small
* a small check badge, and carries `aria-pressed` for a11y/testing. * dimmed description line (if any).
*/ */
function RoleCard({ function RoleCard({
color, color,
label, name,
emoji, emoji,
title, description,
selected,
onClick, onClick,
}: { }: {
color: string; color: string;
label: string; name: string;
emoji?: string | null; emoji?: string | null;
title?: string; description?: string | null;
selected: boolean;
onClick: () => void; onClick: () => void;
}) { }) {
return ( return (
<UnstyledButton <UnstyledButton
className={`${classes.card}${selected ? ` ${classes.selected}` : ""}`} className={classes.card}
style={{ style={{
backgroundColor: `var(--mantine-color-${color}-light)`, backgroundColor: `var(--mantine-color-${color}-light)`,
color: `var(--mantine-color-${color}-light-color)`, 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} title={description ?? name}
aria-pressed={selected}
onClick={onClick} onClick={onClick}
> >
{selected && (
<span className={classes.checkBadge}>
<IconCheck size={11} />
</span>
)}
{emoji && <span className={classes.emoji}>{emoji}</span>} {emoji && <span className={classes.emoji}>{emoji}</span>}
<Text size="sm" fw={500} lineClamp={2}> <Text size="sm" fw={600} lineClamp={2}>
{label} {name}
</Text> </Text>
{description && (
<Text size="xs" lineClamp={3} className={classes.description}>
{description}
</Text>
)}
</UnstyledButton> </UnstyledButton>
); );
} }
/** /**
* Colored role cards rendered as the empty-state of a brand-new chat. Clicking a * Colored role cards rendered as the empty-state of a brand-new chat. There is
* card selects that identity; the first (gray) card returns to the default * no Universal assistant card — the universal assistant is the implicit default
* Universal assistant. Selection state lives in the parent atom, so when the * the user gets by simply typing into the composer without picking a card.
* chat is no longer empty these cards are simply not rendered and the existing * Clicking a card immediately STARTS the chat with that role (the parent binds
* server wiring is unchanged. * the role to the new chat and sends the opening message).
*/ */
export default function RoleCards({ export default function RoleCards({ roles, onPick }: RoleCardsProps) {
roles,
selectedRoleId,
onSelect,
}: RoleCardsProps) {
const { t } = useTranslation();
return ( return (
<div className={classes.container}> <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) => ( {roles.map((role, index) => (
<RoleCard <RoleCard
key={role.id} key={role.id}
color={roleCardColor(index)} color={roleCardColor(index)}
label={role.name} name={role.name}
emoji={role.emoji} emoji={role.emoji}
title={role.description ?? role.name} description={role.description}
selected={selectedRoleId === role.id} onClick={() => onPick(role)}
onClick={() => onSelect(role.id)}
/> />
))} ))}
</div> </div>