diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index 70353fee..fc39a8d9 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -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",
diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json
index c5cfd007..4c40c4bf 100644
--- a/apps/client/public/locales/ru-RU/translation.json
+++ b/apps/client/public/locales/ru-RU/translation.json
@@ -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": "Роль агента",
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 ce853435..ecd0dcad 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
@@ -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 ? (
-
- ) : 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")}
- {/* 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 && (
- {activeChat.roleEmoji ? `${activeChat.roleEmoji} ` : ""}
- {activeChat.roleName}
+ {currentRole.emoji ? `${currentRole.emoji} ` : ""}
+ {currentRole.name}
)}
@@ -552,9 +555,9 @@ export default function AiChatWindow() {
{/* 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 */}
@@ -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}
/>
)}
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 0aaa1c9b..75418986 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 { 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 ? (
+
+ ) : undefined;
+
return (
{error && (
diff --git a/apps/client/src/features/ai-chat/components/role-cards.module.css b/apps/client/src/features/ai-chat/components/role-cards.module.css
index 9d1af715..b47dfd95 100644
--- a/apps/client/src/features/ai-chat/components/role-cards.module.css
+++ b/apps/client/src/features/ai-chat/components/role-cards.module.css
@@ -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;
}
diff --git a/apps/client/src/features/ai-chat/components/role-cards.test.tsx b/apps/client/src/features/ai-chat/components/role-cards.test.tsx
index ff977571..dda72aea 100644
--- a/apps/client/src/features/ai-chat/components/role-cards.test.tsx
+++ b/apps/client/src/features/ai-chat/components/role-cards.test.tsx
@@ -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(
-
+
,
);
- 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]);
});
});
diff --git a/apps/client/src/features/ai-chat/components/role-cards.tsx b/apps/client/src/features/ai-chat/components/role-cards.tsx
index dc10ae32..2551bdbf 100644
--- a/apps/client/src/features/ai-chat/components/role-cards.tsx
+++ b/apps/client/src/features/ai-chat/components/role-cards.tsx
@@ -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 (
- {selected && (
-
-
-
- )}
{emoji && {emoji}}
-
- {label}
+
+ {name}
+ {description && (
+
+ {description}
+
+ )}
);
}
/**
- * 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 (
- {/* Universal assistant: neutral gray, value null, highlighted by default. */}
- onSelect(null)}
- />
{roles.map((role, index) => (
onSelect(role.id)}
+ description={role.description}
+ onClick={() => onPick(role)}
/>
))}