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:
@@ -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",
|
||||||
|
|||||||
@@ -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": "Роль агента",
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user