feat(ai-chat): role cards start the chat and show role identity
Rework the new-chat role-card empty state: - Remove the "Universal assistant" card; universal assistant is now the implicit default the user gets by typing without picking a card. - Show each role's description on its card (under the emoji and name). - Clicking a card immediately starts the chat: it binds the role to the new chat and sends the default opening prompt "Take a look at the current document" (one click, no separate select step). roleIdRef is set synchronously before sendMessage so the create request carries the role. - Show the current role's name in the window header badge and as the assistant's display name (transcript label + "… is typing…"), falling back to "AI agent" for a role-less chat. selectChat resets the picked role so it cannot leak into an unrelated existing chat. - Add the "Take a look at the current document" i18n key (en-US, ru-RU). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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": "Роль агента",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user