@djangocfg/widget-assistant 0.3.13 → 0.3.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/widget-assistant",
3
- "version": "0.3.13",
3
+ "version": "0.3.15",
4
4
  "description": "A site assistant on top of @djangocfg/widget-chat: the SSE wire and session layer, a persistent host, docked column, floating button and sheet, dictation, Help and 17 built-in locales",
5
5
  "keywords": [
6
6
  "chat",
@@ -44,19 +44,19 @@
44
44
  "test": "vitest run"
45
45
  },
46
46
  "dependencies": {
47
- "@djangocfg/widget-chat": "^0.1.29",
47
+ "@djangocfg/widget-chat": "^0.1.31",
48
48
  "zustand": "^5.0.16"
49
49
  },
50
50
  "peerDependencies": {
51
- "@djangocfg/i18n": "^2.1.628",
52
- "@djangocfg/ui-core": "^2.1.628",
51
+ "@djangocfg/i18n": "^2.1.635",
52
+ "@djangocfg/ui-core": "^2.1.635",
53
53
  "lucide-react": "^1.68.0",
54
54
  "react": "^19.0.0"
55
55
  },
56
56
  "devDependencies": {
57
- "@djangocfg/i18n": "^2.1.628",
58
- "@djangocfg/typescript-config": "^2.1.628",
59
- "@djangocfg/ui-core": "^2.1.628",
57
+ "@djangocfg/i18n": "^2.1.635",
58
+ "@djangocfg/typescript-config": "^2.1.635",
59
+ "@djangocfg/ui-core": "^2.1.635",
60
60
  "@storybook/react-vite": "^10.6.5",
61
61
  "@types/node": "^25.9.5",
62
62
  "@types/react": "19.3.0",
@@ -20,7 +20,6 @@ import { useAssistantLocale } from './i18n';
20
20
  import { bundledEarcons } from './composer/earcons';
21
21
  import { createWholeTranscriptSpeech } from './composer/wholeTranscript';
22
22
  import { AssistantComposer } from './composer/AssistantComposer';
23
- import { ComposerHelp } from './composer/ComposerHelp';
24
23
  import { MessageFooter } from './messages/MessageFooter';
25
24
  import { NoticeLine } from './messages/NoticeLine';
26
25
 
@@ -36,7 +35,10 @@ export interface SpeechLanguage {
36
35
  interface Props {
37
36
  adapter: ChatAdapter;
38
37
  /** Example prompts shown in an empty chat. */
39
- suggestions: string[];
38
+ /** The starter rows: a prompt, or `{ prompt, icon }` with an icon key (`SUGGESTION_ICON_KEYS`). */
39
+ suggestions: (string | { prompt: string; icon?: string })[];
40
+ /** What the empty room shows above the starter rows: `<AssistantGreeting title="…" mark={…} />`, or any node. Absent: the widget's plain empty line. */
41
+ greeting?: ReactNode;
40
42
  /** The room on screen: `id` changes when the thread is reloaded (reconcile, a new or another session), which reseeds the transcript. */
41
43
  conversation?: { id?: string; history: readonly ChatMessage[] };
42
44
  /** What the unsent draft is kept under: the session's own id, so each session keeps its draft across reseeds. Absent: the conversation `id`. */
@@ -67,7 +69,7 @@ const NO_RENDERERS: Readonly<Record<string, DataPartRenderer>> = {};
67
69
  * recall of sent prompts with the arrow keys, an unsent draft that survives a reload). The widget's packaged layout shows only a bare composer, so the layout
68
70
  * is composed here from its own primitives: every behaviour is the widget's, nothing is re-implemented.
69
71
  */
70
- export function AssistantChat({ adapter, suggestions, conversation = EMPTY_ROOM, draftRoom, dataRenderers = NO_RENDERERS, speech: given, sounds, messageSounds, notice, onRetry, focusWhen, refocus, className }: Props) {
72
+ export function AssistantChat({ adapter, suggestions, greeting, conversation = EMPTY_ROOM, draftRoom, dataRenderers = NO_RENDERERS, speech: given, sounds, messageSounds, notice, onRetry, focusWhen, refocus, className }: Props) {
71
73
  const renderers = useMemo(
72
74
  () => ({
73
75
  text: ({ part }: PartRenderProps) => (part.type === 'text' ? <Response>{part.text}</Response> : null),
@@ -89,7 +91,7 @@ export function AssistantChat({ adapter, suggestions, conversation = EMPTY_ROOM,
89
91
  language.current = speech?.value;
90
92
  const voice = useMemo(() => createWholeTranscriptSpeech(() => language.current ?? (typeof navigator === 'undefined' ? 'en-US' : navigator.language)), []);
91
93
  const capabilities = useMemo<ChatCapabilities>(
92
- () => ({ suggestions: { items: suggestions.map((prompt, index) => ({ id: `s${index}`, title: prompt, prompt })) }, ...(voice ? { voice } : {}), ...(sounds === false ? {} : { voiceSounds: sounds ?? bundledEarcons }), ...(messageSounds === undefined ? {} : { sounds: messageSounds }) }),
94
+ () => ({ suggestions: { items: suggestions.map((row, index) => { const item = typeof row === 'string' ? { prompt: row } : row; return { id: `s${index}`, title: item.prompt, prompt: item.prompt, ...('icon' in item && item.icon ? { icon: item.icon } : {}) }; }) }, ...(voice ? { voice } : {}), ...(sounds === false ? {} : { voiceSounds: sounds ?? bundledEarcons }), ...(messageSounds === undefined ? {} : { sounds: messageSounds }) }),
93
95
  [suggestions, voice, sounds, messageSounds],
94
96
  );
95
97
  const slots = useMemo(
@@ -99,7 +101,6 @@ export function AssistantChat({ adapter, suggestions, conversation = EMPTY_ROOM,
99
101
  footer: (
100
102
  <>
101
103
  {notice ? <NoticeLine notice={notice} onRetry={onRetry} /> : null}
102
- <ComposerHelp />
103
104
  </>
104
105
  ),
105
106
  }),
@@ -117,7 +118,7 @@ export function AssistantChat({ adapter, suggestions, conversation = EMPTY_ROOM,
117
118
  kind: 'custom',
118
119
  children: (
119
120
  <>
120
- <ChatTranscript footer={(message) => <MessageFooter message={message} />} />
121
+ <ChatTranscript {...(greeting === undefined ? {} : { empty: greeting })} footer={(message) => <MessageFooter message={message} />} />
121
122
  <AssistantComposer speech={speech} draftRoom={draftRoom ?? conversation.id ?? 'assistant'} focusWhen={focusWhen} refocus={refocus} />
122
123
  </>
123
124
  ),
@@ -3,6 +3,7 @@
3
3
  import { ChatComposer } from '@djangocfg/widget-chat';
4
4
 
5
5
  import { ComposerBehaviour } from './ComposerBehaviour';
6
+ import { suggestionIcon } from './suggestionIcons';
6
7
  import { useSentPrompts } from './useSentPrompts';
7
8
 
8
9
  interface Props {
@@ -15,5 +16,5 @@ interface Props {
15
16
  /** The widget's composer with its recall fed from the live transcript and our behaviour mounted inside. Must sit inside the chat. */
16
17
  export function AssistantComposer({ speech, draftRoom, focusWhen, refocus }: Props) {
17
18
  const history = useSentPrompts();
18
- return <ChatComposer history={history} speechLanguage={speech} draftRoom={draftRoom} toolbarEnd={<ComposerBehaviour focusWhen={focusWhen} refocus={refocus} />} />;
19
+ return <ChatComposer history={history} speechLanguage={speech} draftRoom={draftRoom} stacked suggestionIcon={suggestionIcon} toolbarEnd={<ComposerBehaviour focusWhen={focusWhen} refocus={refocus} />} />;
19
20
  }
@@ -3,7 +3,7 @@
3
3
  import { useCallback, useEffect, useRef } from 'react';
4
4
 
5
5
  import { useHotkey } from '@djangocfg/ui-core/hooks';
6
- import { useComposer } from '@djangocfg/widget-chat';
6
+ import { useComposer, useTurn } from '@djangocfg/widget-chat';
7
7
 
8
8
  import { useCaretFocus } from './useCaretFocus';
9
9
  import { clearPendingPrompt, usePendingPrompt } from './handoff';
@@ -53,7 +53,8 @@ export function ComposerBehaviour({ focusWhen = false, refocus = false }: Props)
53
53
  const recording = useRecording(nodes);
54
54
  useHeightFloor(nodes, recording);
55
55
  useFocusArea(nodes, marker, refocus);
56
- useCaretFocus(nodes, { focusWhen, refocus, recording, onSend: composer.onSend });
56
+ const { isStreaming } = useTurn();
57
+ useCaretFocus(nodes, { focusWhen, refocus, recording, streaming: isStreaming, onSend: composer.onSend });
57
58
 
58
59
  // Waits for the words still being recognised, then commits them into the draft; sending reads the draft after that.
59
60
  const stop = useGracefulStop(nodes, recording, useCallback(() => latest.current.draft.text, []));
@@ -0,0 +1,22 @@
1
+ import { BarChart3, FileText, Filter, ListChecks, PenLine, Search, Sparkles, Wand2 } from 'lucide-react';
2
+ import type { ReactNode } from 'react';
3
+
4
+ /** The starter rows' glyphs, by key. A row names one with `{ prompt, icon: 'search' }`; an unknown key draws none. */
5
+ const ICONS = {
6
+ sparkles: Sparkles,
7
+ search: Search,
8
+ summarize: FileText,
9
+ write: PenLine,
10
+ tidy: ListChecks,
11
+ chart: BarChart3,
12
+ filter: Filter,
13
+ agent: Wand2,
14
+ } as const;
15
+
16
+ export type SuggestionIconKey = keyof typeof ICONS;
17
+ export const SUGGESTION_ICON_KEYS = Object.keys(ICONS) as SuggestionIconKey[];
18
+
19
+ export function suggestionIcon(key: string): ReactNode {
20
+ const Icon = (ICONS as Record<string, (typeof ICONS)[SuggestionIconKey]>)[key];
21
+ return Icon ? <Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden /> : null;
22
+ }
@@ -8,22 +8,50 @@ interface Options {
8
8
  /** A keyboard device: after a send the caret returns to the field, or to the microphone when the send ended a dictation (so Enter starts the next one). */
9
9
  refocus: boolean;
10
10
  recording: boolean;
11
+ /** A reply is streaming. When it ends (keyboard devices) the caret returns to the field, so the next question can be typed at once. */
12
+ streaming?: boolean;
11
13
  /** The draft's send listener registry. */
12
14
  onSend: (listener: () => void) => () => void;
13
15
  }
14
16
 
15
17
  /** Where the caret is after the panel opens and after a send. Both are for keyboards only: on a phone either would raise the keyboard over the answer. */
16
- export function useCaretFocus({ mic, field }: ComposerNodes, { focusWhen, refocus, recording, onSend }: Options): void {
18
+ export function useCaretFocus({ mic, field }: ComposerNodes, { focusWhen, refocus, recording, streaming = false, onSend }: Options): void {
17
19
  const wasOn = useRef(focusWhen);
18
20
  useEffect(() => {
21
+ let frame = 0;
19
22
  if (focusWhen && !wasOn.current) {
20
- const node = field();
21
- node?.focus();
22
- node?.setSelectionRange(node.value.length, node.value.length);
23
+ // The column mounts a render AFTER the flag flips and the chat is still parked in a hidden element until then: focusing now is a silent no-op.
24
+ // Retry per frame until the field is laid out (it is on screen), for at most ~1 s.
25
+ let tries = 0;
26
+ const attempt = () => {
27
+ const node = field();
28
+ if (node && node.getClientRects().length > 0) {
29
+ node.focus({ preventScroll: true });
30
+ node.setSelectionRange(node.value.length, node.value.length);
31
+ } else if (++tries < 60) {
32
+ frame = requestAnimationFrame(attempt);
33
+ }
34
+ };
35
+ attempt();
23
36
  }
24
37
  wasOn.current = focusWhen;
38
+ return () => cancelAnimationFrame(frame);
25
39
  }, [focusWhen, field]);
26
40
 
41
+ // The reply finished streaming: the caret goes back to the field. The send's own refocus is too early (the answer has not started yet),
42
+ // and the focus may have wandered off meanwhile (the stop button, a link inside the answer).
43
+ const wasStreaming = useRef(streaming);
44
+ useEffect(() => {
45
+ const ended = wasStreaming.current && !streaming;
46
+ wasStreaming.current = streaming;
47
+ if (!ended || !refocus) return;
48
+ const id = window.setTimeout(() => {
49
+ const node = field();
50
+ if (node && !typingElsewhere(node) && node.getClientRects().length > 0) node.focus({ preventScroll: true });
51
+ }, 0);
52
+ return () => window.clearTimeout(id);
53
+ }, [streaming, refocus, field]);
54
+
27
55
  // Read through a ref because the send listener runs before the widget ends the dictation.
28
56
  const dictating = useRef(false);
29
57
  useEffect(() => {
@@ -47,3 +75,10 @@ export function useCaretFocus({ mic, field }: ComposerNodes, { focusWhen, refocu
47
75
  });
48
76
  }, [refocus, onSend, mic, field]);
49
77
  }
78
+
79
+ /** True when the user parked the caret in some other text field; the end of a reply must not pull it away from there. */
80
+ function typingElsewhere(field: HTMLTextAreaElement | null): boolean {
81
+ const active = document.activeElement;
82
+ if (!(active instanceof HTMLElement) || active === field) return false;
83
+ return active.isContentEditable || active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement;
84
+ }
@@ -0,0 +1,47 @@
1
+ 'use client';
2
+
3
+ import { useMemo } from 'react';
4
+
5
+ import { AssistantChat } from '../../AssistantChat';
6
+ import { scriptedTransport } from '../../stream/scriptedTransport';
7
+ import type { ThreadApi } from '../../stream/sessions';
8
+ import { useSession, type SessionCopy } from '../../stream/useSession';
9
+ import { DEMO_THREAD, demoScript } from './thread';
10
+ import { DEMO_RENDERERS } from './widgets';
11
+
12
+ const COPY: SessionCopy = {
13
+ messages: { rejected: () => 'The demo cannot send this.', streamError: () => 'Something went wrong.', failed: 'Something went wrong.' },
14
+ status: { thinking: 'Thinking…', searching: 'Searching…', answering: 'Writing the answer…' },
15
+ failedText: 'Something went wrong.',
16
+ };
17
+
18
+ export const DEMO_SUGGESTIONS = ['Give me a quick overview', 'Show the tasks as a table', 'What is blocked?'];
19
+
20
+ /**
21
+ * Demo mode: the assistant with no server. The long transcript is restored as stored history, every new message is answered by a script through the
22
+ * real adapter and session (status, reasoning, streamed text, widgets), and the widgets are the production slot mechanism. It is for stories,
23
+ * screenshots and for a product to see what an answer can hold before it writes its own renderers.
24
+ */
25
+ export function DemoAssistant({ empty = false, className }: { empty?: boolean; className?: string }) {
26
+ const transport = useMemo(() => scriptedTransport(demoScript), []);
27
+ const api = useMemo<ThreadApi>(() => {
28
+ const thread = empty ? { id: 'demo', messages: [] } : DEMO_THREAD;
29
+ return {
30
+ list: async () => [{ id: 'demo', title: 'Weekly overview', last_message_at: '2026-11-06T09:00:00Z', message_count: thread.messages.length }],
31
+ create: async () => ({ id: 'demo', title: '', last_message_at: '2026-11-06T09:00:00Z', message_count: 0 }),
32
+ load: async () => thread,
33
+ };
34
+ }, [empty]);
35
+ const session = useSession({ transport, api, getContext: () => undefined, copy: COPY });
36
+ if (session.thread.kind !== 'ready') return null;
37
+ return (
38
+ <AssistantChat
39
+ adapter={session.adapter}
40
+ dataRenderers={DEMO_RENDERERS}
41
+ suggestions={DEMO_SUGGESTIONS}
42
+ conversation={{ id: session.thread.id, history: session.thread.history }}
43
+ draftRoom="demo"
44
+ className={className}
45
+ />
46
+ );
47
+ }
@@ -0,0 +1,68 @@
1
+ import type { StoredConversation, StoredMessage, StoredPart } from '../../wire/stored';
2
+ import type { ScriptStep } from '../../stream/scriptedTransport';
3
+
4
+ /**
5
+ * The demo transcript: one long read that shows every kind of message an answer can hold, in the order a real working session produces them.
6
+ * Abstract on purpose: no real people, no real data.
7
+ */
8
+
9
+ let n = 0;
10
+ const at = (minutes: number) => new Date(Date.UTC(2026, 10, 6, 9, 0) + minutes * 60_000).toISOString();
11
+ const message = (role: 'user' | 'assistant', text: string, parts: StoredPart[] = []): StoredMessage => {
12
+ n += 1;
13
+ return { id: `demo-${n}`, seq: n, role, text, parts, status: 'done', error_code: null, created_at: at(n * 2) };
14
+ };
15
+ const user = (text: string) => message('user', text);
16
+ const bot = message.bind(null, 'assistant');
17
+
18
+ export const DEMO_THREAD: StoredConversation = {
19
+ id: 'demo',
20
+ messages: [
21
+ user('Give me a quick overview of the project and what changed this week.'),
22
+ bot(
23
+ '## Weekly overview\n\nThe project is **on track**. Three things moved this week:\n\n1. The onboarding flow was shortened from five steps to three.\n2. Search now answers in under 200 ms.\n3. Two blocked tasks were unblocked.\n\n> Nothing here needs a decision from you yet.',
24
+ [{ kind: 'metrics', data: { items: [{ label: 'Tasks done', value: '24', delta: '+6 this week' }, { label: 'Open issues', value: '9', delta: '−3' }, { label: 'Response time', value: '184 ms', delta: '−41 ms' }], progress: { label: 'Milestone 3', value: 72 } } }],
25
+ ),
26
+ user('Show me the tasks as a table.'),
27
+ bot('Here are the open items, sorted by priority.', [
28
+ { kind: 'table', data: { columns: ['Task', 'Owner', 'Status', 'Due'], rows: [
29
+ { cells: ['Shorten the onboarding', 'Team A', 'Done', 'Nov 3'], tag: { column: 2, tone: 'green' } },
30
+ { cells: ['Search latency budget', 'Team B', 'In review', 'Nov 8'], tag: { column: 2, tone: 'blue' } },
31
+ { cells: ['Migrate the old exports', 'Team C', 'Blocked', 'Nov 12'], tag: { column: 2, tone: 'red' } },
32
+ { cells: ['Write the release notes', 'Team A', 'Not started', 'Nov 14'], tag: { column: 2, tone: 'gray' } },
33
+ ] } },
34
+ ]),
35
+ user('Why is the export migration blocked?'),
36
+ bot(
37
+ 'It waits on a schema decision. The old exports store dates as text, the new ones as timestamps. A short script handles both:\n\n```ts\nconst toTimestamp = (value: string | number) =>\n typeof value === \'number\' ? value : Date.parse(value);\n```\n\nIf you agree with timestamps, I can prepare the change for review.',
38
+ [{ kind: 'choices', data: { prompt: 'What should I do?', options: ['Prepare the change', 'Show me the old schema', 'Not now'] } }],
39
+ ),
40
+ user('Prepare the change.'),
41
+ bot('Done. Review the proposal below; nothing is applied until you press Apply.', [
42
+ { kind: 'steps', data: { title: 'What I did', steps: [{ label: 'Read the old export schema', state: 'done' }, { label: 'Wrote the conversion', state: 'done' }, { label: 'Checked 1 200 sample rows', state: 'done' }, { label: 'Wait for your approval', state: 'running' }] } },
43
+ { kind: 'approval', data: { summary: 'Store export dates as timestamps', changes: [{ path: 'export.created_at', from: 'text', to: 'timestamp' }, { path: 'export.updated_at', from: 'text', to: 'timestamp' }, { path: 'export.version', to: '2' }] } },
44
+ ]),
45
+ user('Is there anything to watch while I wait?'),
46
+ bot('Two recordings explain the new flow, and the summary is attached as a file.', [
47
+ { kind: 'media', data: { items: [{ id: 'v1', title: 'The three-step onboarding', meta: 'Walkthrough · 2026', duration: '4:12', hue: 210 }, { id: 'v2', title: 'How search ranks results', meta: 'Explainer · 2026', duration: '7:40', hue: 150 }] } },
48
+ { kind: 'files', data: { files: [{ name: 'weekly-summary.pdf', size: '184 KB' }, { name: 'export-schema.json', size: '12 KB' }] } },
49
+ ]),
50
+ ],
51
+ };
52
+
53
+ const words = (text: string): ScriptStep[] => text.split(/(?<=\s)/).map((delta) => ({ frame: { type: 'text_delta', delta }, after: 35 }));
54
+ const payload = (kind: string, data: unknown): ScriptStep => ({ frame: { type: 'ui_payload', kind, data }, after: 250 });
55
+
56
+ /** Any new message streams a reply that uses the live path: status, reasoning, text, then two widgets. */
57
+ export const demoScript = (): ScriptStep[] => [
58
+ { frame: { type: 'run', version: 1, thread_id: 'demo', message_id: `live-${Date.now()}`, client_id: null, seq: null } },
59
+ { frame: { type: 'status', phase: 'thinking' }, after: 300 },
60
+ { frame: { type: 'reasoning_delta', delta: 'Checking the open tasks and the latest changes. ' }, after: 200 },
61
+ { frame: { type: 'reasoning_delta', delta: 'The export migration is the only blocker.' }, after: 200 },
62
+ { frame: { type: 'reasoning_end', elapsed_ms: 1400, tokens: 38 }, after: 200 },
63
+ { frame: { type: 'status', phase: 'answering' }, after: 200 },
64
+ ...words('Here is where things stand right now. The export migration is still the only blocker, and everything else is moving.\n\n- Review is open for the search budget\n- Release notes are not started\n\n'),
65
+ payload('steps', { title: 'Progress', steps: [{ label: 'Onboarding', state: 'done' }, { label: 'Search budget', state: 'running' }, { label: 'Release notes', state: 'todo' }] }),
66
+ payload('choices', { options: ['Show the blocker', 'Summarize again'] }),
67
+ { frame: { type: 'done', finish_reason: 'stop', tokens_in: null, tokens_out: null, cost_usd: null, replayed: false } },
68
+ ];
@@ -0,0 +1,190 @@
1
+ 'use client';
2
+
3
+ import { Check, CircleDashed, FileText, Loader2, Play, X } from 'lucide-react';
4
+ import { useState, type ReactNode } from 'react';
5
+
6
+ import { Badge, Button, Progress, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@djangocfg/ui-core/components';
7
+
8
+ import type { DataPartRenderer } from '../../AssistantChat';
9
+ import { askAssistant } from '../../composer/handoff';
10
+
11
+ /**
12
+ * The widgets of the demo transcript, one per kind of structured part an answer can carry. They are the pattern a product copies: a payload shape,
13
+ * a guard that returns `undefined` for a malformed one (the widget then draws its own fallback), and a card made of ready ui-core parts.
14
+ * Every card is a block of the same width and rhythm, so a long answer reads as one column.
15
+ */
16
+
17
+ const CARD = 'my-2 rounded-lg border bg-card p-3 text-sm';
18
+ const isObject = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;
19
+
20
+ /* Approval: the `json_patch` / docman pattern. Nothing happens by rendering; Apply is a click. */
21
+ export interface ApprovalData { summary: string; changes: { path: string; from?: string; to: string }[] }
22
+
23
+ function Approval({ data }: { data: ApprovalData }) {
24
+ const [state, setState] = useState<'open' | 'applied' | 'dismissed'>('open');
25
+ return (
26
+ <div className={CARD}>
27
+ <p className="font-medium">{data.summary}</p>
28
+ <ul className="mt-2 space-y-1 text-xs">
29
+ {data.changes.map((change) => (
30
+ <li key={change.path} className="flex items-baseline gap-2">
31
+ <code className="text-muted-foreground">{change.path}</code>
32
+ {change.from ? <span className="text-muted-foreground line-through">{change.from}</span> : null}
33
+ {change.from ? <span className="text-muted-foreground">→</span> : null}
34
+ <span>{change.to}</span>
35
+ </li>
36
+ ))}
37
+ </ul>
38
+ <div className="mt-3 flex items-center gap-2">
39
+ {state === 'open' ? (
40
+ <>
41
+ <Button size="sm" onClick={() => setState('applied')}><Check /> Apply</Button>
42
+ <Button size="sm" variant="ghost" onClick={() => setState('dismissed')}>Dismiss</Button>
43
+ </>
44
+ ) : (
45
+ <span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
46
+ {state === 'applied' ? <Check className="size-3.5" /> : <X className="size-3.5" />}
47
+ {state === 'applied' ? 'Applied' : 'Dismissed'}
48
+ </span>
49
+ )}
50
+ </div>
51
+ </div>
52
+ );
53
+ }
54
+
55
+ /* Media: the gptkino pattern, a poster row that opens the item. Fixed height, so hydrating it never moves the transcript. */
56
+ export interface MediaData { items: { id: string; title: string; meta: string; duration: string; hue: number }[] }
57
+
58
+ function Media({ data }: { data: MediaData }) {
59
+ return (
60
+ <div className="my-2 space-y-2">
61
+ {data.items.map((item) => (
62
+ <a key={item.id} href="#" onClick={(event) => event.preventDefault()} className="flex h-[62px] items-center gap-3 rounded-lg border bg-card p-1.5 no-underline outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring">
63
+ <span className="relative flex h-full w-24 shrink-0 items-center justify-center overflow-hidden rounded-md text-white" style={{ background: `linear-gradient(135deg, hsl(${item.hue} 45% 38%), hsl(${(item.hue + 40) % 360} 40% 22%))` }}>
64
+ <Play className="size-4 fill-current" />
65
+ <span className="absolute bottom-0.5 right-1 text-[10px] tabular-nums">{item.duration}</span>
66
+ </span>
67
+ <span className="min-w-0">
68
+ <span className="block truncate text-sm font-medium text-foreground">{item.title}</span>
69
+ <span className="block truncate text-xs text-muted-foreground">{item.meta}</span>
70
+ </span>
71
+ </a>
72
+ ))}
73
+ </div>
74
+ );
75
+ }
76
+
77
+ /* Steps: what the assistant did or is doing. */
78
+ export interface StepsData { title?: string; steps: { label: string; state: 'done' | 'running' | 'todo' }[] }
79
+
80
+ function Steps({ data }: { data: StepsData }) {
81
+ return (
82
+ <div className={CARD}>
83
+ {data.title ? <p className="mb-2 font-medium">{data.title}</p> : null}
84
+ <ul className="space-y-1.5">
85
+ {data.steps.map((step) => (
86
+ <li key={step.label} className="flex items-center gap-2">
87
+ {step.state === 'done' ? <Check className="size-4 text-green-600" /> : step.state === 'running' ? <Loader2 className="size-4 animate-spin text-muted-foreground" /> : <CircleDashed className="size-4 text-muted-foreground" />}
88
+ <span className={step.state === 'todo' ? 'text-muted-foreground' : undefined}>{step.label}</span>
89
+ </li>
90
+ ))}
91
+ </ul>
92
+ </div>
93
+ );
94
+ }
95
+
96
+ /* Metrics: a few figures and one progress bar. */
97
+ export interface MetricsData { items: { label: string; value: string; delta?: string }[]; progress?: { label: string; value: number } }
98
+
99
+ function Metrics({ data }: { data: MetricsData }) {
100
+ return (
101
+ <div className={CARD}>
102
+ <div className="grid grid-cols-3 gap-3">
103
+ {data.items.map((item) => (
104
+ <div key={item.label}>
105
+ <p className="text-xs text-muted-foreground">{item.label}</p>
106
+ <p className="text-lg font-semibold tabular-nums">{item.value}</p>
107
+ {item.delta ? <p className="text-xs text-muted-foreground">{item.delta}</p> : null}
108
+ </div>
109
+ ))}
110
+ </div>
111
+ {data.progress ? (
112
+ <div className="mt-3 space-y-1">
113
+ <p className="flex justify-between text-xs text-muted-foreground"><span>{data.progress.label}</span><span className="tabular-nums">{data.progress.value}%</span></p>
114
+ <Progress value={data.progress.value} />
115
+ </div>
116
+ ) : null}
117
+ </div>
118
+ );
119
+ }
120
+
121
+ /* Table: rows with tag cells, the Notion database look. */
122
+ export interface TableData { columns: string[]; rows: { cells: string[]; tag?: { column: number; tone: 'gray' | 'blue' | 'green' | 'yellow' | 'red' | 'purple' } }[] }
123
+
124
+ function DataTable({ data }: { data: TableData }) {
125
+ return (
126
+ <div className="my-2 overflow-hidden rounded-lg border">
127
+ <Table frame={false} grid className="w-full">
128
+ <TableHeader>
129
+ <TableRow>{data.columns.map((column) => <TableHead key={column}>{column}</TableHead>)}</TableRow>
130
+ </TableHeader>
131
+ <TableBody>
132
+ {data.rows.map((row) => (
133
+ <TableRow key={row.cells.join('|')}>
134
+ {row.cells.map((cell, index) => (
135
+ <TableCell key={`${index}-${cell}`}>{row.tag?.column === index ? <Badge variant={row.tag.tone}>{cell}</Badge> : cell}</TableCell>
136
+ ))}
137
+ </TableRow>
138
+ ))}
139
+ </TableBody>
140
+ </Table>
141
+ </div>
142
+ );
143
+ }
144
+
145
+ /* Files: attachments the answer produced. */
146
+ export interface FilesData { files: { name: string; size: string }[] }
147
+
148
+ function Files({ data }: { data: FilesData }) {
149
+ return (
150
+ <div className="my-2 flex flex-wrap gap-2">
151
+ {data.files.map((file) => (
152
+ <span key={file.name} className="inline-flex items-center gap-2 rounded-lg border bg-card px-2.5 py-1.5 text-xs">
153
+ <FileText className="size-4 text-muted-foreground" />
154
+ <span className="font-medium">{file.name}</span>
155
+ <span className="text-muted-foreground">{file.size}</span>
156
+ </span>
157
+ ))}
158
+ </div>
159
+ );
160
+ }
161
+
162
+ /* Choices: quick replies. A click sends the label as the next message. */
163
+ export interface ChoicesData { prompt?: string; options: string[] }
164
+
165
+ function Choices({ data }: { data: ChoicesData }) {
166
+ return (
167
+ <div className="my-2 space-y-1.5">
168
+ {data.prompt ? <p className="text-xs text-muted-foreground">{data.prompt}</p> : null}
169
+ <div className="flex flex-wrap gap-2">
170
+ {data.options.map((option) => (
171
+ <Button key={option} size="sm" variant="outline" onClick={() => askAssistant(option)}>{option}</Button>
172
+ ))}
173
+ </div>
174
+ </div>
175
+ );
176
+ }
177
+
178
+ const guard = <T,>(check: (data: Record<string, unknown>) => boolean, draw: (data: T) => ReactNode): DataPartRenderer => (data) => (isObject(data) && check(data) ? draw(data as T) : undefined);
179
+
180
+ /** What the demo draws, by `kind`. Pass it as `dataRenderers`. */
181
+ export const DEMO_RENDERERS: Readonly<Record<string, DataPartRenderer>> = {
182
+ approval: guard<ApprovalData>((d) => typeof d.summary === 'string' && Array.isArray(d.changes), (d) => <Approval data={d} />),
183
+ media: guard<MediaData>((d) => Array.isArray(d.items), (d) => <Media data={d} />),
184
+ steps: guard<StepsData>((d) => Array.isArray(d.steps), (d) => <Steps data={d} />),
185
+ metrics: guard<MetricsData>((d) => Array.isArray(d.items), (d) => <Metrics data={d} />),
186
+ table: guard<TableData>((d) => Array.isArray(d.columns) && Array.isArray(d.rows), (d) => <DataTable data={d} />),
187
+ files: guard<FilesData>((d) => Array.isArray(d.files), (d) => <Files data={d} />),
188
+ choices: guard<ChoicesData>((d) => Array.isArray(d.options), (d) => <Choices data={d} />),
189
+ };
190
+ export const DEMO_PAYLOAD_KINDS: readonly string[] = Object.keys(DEMO_RENDERERS);
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ export { askAssistant } from './composer/handoff';
5
5
  export { ChatDock } from './shell/ChatDock';
6
6
  export { AssistantFrame, type FrameLabels } from './shell/AssistantFrame';
7
7
  export { AssistantFab, type FabHint } from './shell/AssistantFab';
8
+ export { AssistantGreeting, type AssistantGreetingProps } from './shell/AssistantGreeting';
8
9
  export { AssistantLocked } from './shell/AssistantLocked';
9
10
  export { createAssistantUi, useMediaQuery, type UiOptions, type UiState, type UiStorage } from './shell/panelState';
10
11
  export { AssistantHostProvider, AssistantPortal, AssistantSlot, AssistantHeaderActions } from './Host';
@@ -34,3 +35,8 @@ export { decodeFrame, WIRE_VERSION } from './wire/frames';
34
35
  export type { ChatContext, Frame, Phase, StreamErrorCode } from './wire/frames';
35
36
  export type { StoredConversation, StoredMessage, StoredPart, StoredStatus, StoredThread } from './wire/stored';
36
37
  export { citationsRenderer, KB_RENDERERS, KB_PAYLOAD_KINDS, type Citation } from './examples/KbAssistant';
38
+ export { DemoAssistant, DEMO_SUGGESTIONS } from './examples/demo/DemoAssistant';
39
+ export { DEMO_RENDERERS, DEMO_PAYLOAD_KINDS } from './examples/demo/widgets';
40
+ export { DEMO_THREAD, demoScript } from './examples/demo/thread';
41
+ export { SUGGESTION_ICON_KEYS, suggestionIcon, type SuggestionIconKey } from './composer/suggestionIcons';
42
+ export { AssistantMenu, type AssistantMenuItem, type AssistantMenuProps } from './menu/AssistantMenu';
@@ -0,0 +1,90 @@
1
+ 'use client';
2
+
3
+ import { Check, MoreHorizontal } from 'lucide-react';
4
+ import type { ComponentType, ReactNode } from 'react';
5
+
6
+ import {
7
+ Button,
8
+ DropdownMenu,
9
+ DropdownMenuContent,
10
+ DropdownMenuItem,
11
+ DropdownMenuSeparator,
12
+ DropdownMenuTrigger,
13
+ Tooltip,
14
+ TooltipContent,
15
+ TooltipProvider,
16
+ TooltipTrigger,
17
+ } from '@djangocfg/ui-core/components';
18
+
19
+ export interface AssistantMenuItem {
20
+ id: string;
21
+ label: string;
22
+ icon?: ComponentType<{ className?: string }>;
23
+ onSelect: () => void;
24
+ /** A destructive row (Delete): drawn in the destructive colour. */
25
+ danger?: boolean;
26
+ /** A choice among siblings (a view mode): the current one shows a check at the end. */
27
+ checked?: boolean;
28
+ disabled?: boolean;
29
+ }
30
+
31
+ export interface AssistantMenuProps {
32
+ /** Groups of rows; a hairline separates the groups. */
33
+ sections: AssistantMenuItem[][];
34
+ /** The quiet last line ("Last updated just now"). */
35
+ footer?: ReactNode;
36
+ /** Name of the trigger (tooltip and accessible name). */
37
+ label: string;
38
+ }
39
+
40
+ /**
41
+ * The conversation's "..." menu, for the frame's or the dock's `actions` slot: a round ghost trigger and a panel of icon rows in groups, the way Notion
42
+ * lays out a chat's actions (link, pin, rename, archive, delete, then where the chat lives). The host supplies the rows and what each does; the
43
+ * widget owns only the look, so one menu serves a column, a dock or a full page. Rows follow the menu tokens (`--menu-*`).
44
+ */
45
+ export function AssistantMenu({ sections, footer, label }: AssistantMenuProps) {
46
+ return (
47
+ <TooltipProvider delayDuration={300}>
48
+ <DropdownMenu>
49
+ <Tooltip>
50
+ <TooltipTrigger asChild>
51
+ <DropdownMenuTrigger asChild>
52
+ <Button variant="ghost" size="icon" aria-label={label} className="rounded-full" data-testid="assistant-menu">
53
+ <MoreHorizontal className="size-4" aria-hidden="true" />
54
+ </Button>
55
+ </DropdownMenuTrigger>
56
+ </TooltipTrigger>
57
+ <TooltipContent>{label}</TooltipContent>
58
+ </Tooltip>
59
+ <DropdownMenuContent align="end" sideOffset={4} className="w-56">
60
+ {sections.map((items, index) => (
61
+ <div key={items.map((item) => item.id).join('|')}>
62
+ {index > 0 ? <DropdownMenuSeparator /> : null}
63
+ {items.map((item) => {
64
+ const Icon = item.icon;
65
+ return (
66
+ <DropdownMenuItem
67
+ key={item.id}
68
+ disabled={item.disabled}
69
+ onSelect={item.onSelect}
70
+ className={`gap-2.5 ${item.danger ? 'text-destructive focus:text-destructive' : ''}`}
71
+ >
72
+ {Icon ? <Icon className="size-4 shrink-0" /> : null}
73
+ <span className="flex-1">{item.label}</span>
74
+ {item.checked ? <Check className="size-4 shrink-0" aria-hidden="true" /> : null}
75
+ </DropdownMenuItem>
76
+ );
77
+ })}
78
+ </div>
79
+ ))}
80
+ {footer ? (
81
+ <>
82
+ <DropdownMenuSeparator />
83
+ <p className="px-2 py-1.5 text-xs text-muted-foreground">{footer}</p>
84
+ </>
85
+ ) : null}
86
+ </DropdownMenuContent>
87
+ </DropdownMenu>
88
+ </TooltipProvider>
89
+ );
90
+ }
@@ -2,11 +2,14 @@
2
2
 
3
3
  import { Check, Copy } from 'lucide-react';
4
4
 
5
- import { MessageActions, useChatCopy, useCopyAction, type ChatMessage } from '@djangocfg/widget-chat';
5
+ import { MessageActions, MessageTime, useChatCopy, useMessageIds, useCopyAction, type ChatMessage } from '@djangocfg/widget-chat';
6
6
 
7
- /** The row under each message: copy. (Retry and the rest are the host's inventory, added here when the backend can honour them.) */
7
+ /** The row under each message: copy, and the time it was sent (shown with the actions). (Retry and the rest are the host's inventory, added here when the backend can honour them.) */
8
8
  export function MessageFooter({ message }: { message: ChatMessage }) {
9
9
  const copy = useChatCopy().message;
10
+ // Only the newest message keeps its actions in view; every other row shows them on hover or focus, so the transcript stays quiet.
11
+ const ids = useMessageIds();
12
+ const last = ids[ids.length - 1] === message.id;
10
13
  const action = useCopyAction(message, {
11
14
  label: copy.copy,
12
15
  copiedLabel: copy.copied,
@@ -21,5 +24,10 @@ export function MessageFooter({ message }: { message: ChatMessage }) {
21
24
  }
22
25
  },
23
26
  });
24
- return <MessageActions message={message} actions={action ? [action] : []} />;
27
+ return (
28
+ <div className={`flex items-center gap-2 ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}>
29
+ <MessageActions message={message} actions={action ? [action] : []} className={last ? 'opacity-100' : 'opacity-0'} />
30
+ <MessageTime message={message} className={last ? 'opacity-100' : undefined} />
31
+ </div>
32
+ );
25
33
  }
@@ -6,6 +6,7 @@ import { ChatPanelHeader, PANEL_TITLE_CLASS, PanelCloseButton } from '@djangocfg
6
6
 
7
7
  import { AssistantSlot, useHeaderActions } from '../Host';
8
8
  import { useAssistantCopy } from '../i18n';
9
+ import { AssistantHelp } from './AssistantHelp';
9
10
 
10
11
  export interface FrameLabels {
11
12
  /** Name of the column and its heading (the host's brand). */
@@ -125,9 +126,9 @@ function Column({ width, shown, minWidth, maxWidth, defaultWidth, onWidthChange,
125
126
  };
126
127
 
127
128
  return (
128
- // The clip: as wide as the page has given so far, the column inside keeps its full width and is anchored right, so it slides in instead of squeezing.
129
- <div className="assistant-clip sticky top-0 flex h-dvh shrink-0 justify-end self-start overflow-hidden" data-shown={shown ? 'true' : 'false'}>
130
- <aside aria-label={labels.title} style={{ width }} className="assistant-column relative flex h-full shrink-0 flex-col border-l bg-background">
129
+ // The clip: as wide as the page has given so far, and it owns the divider, so the hairline is there from the first frame and travels with the leading edge (a border on the column would be clipped away until the very end). the column inside keeps its full width and is anchored right, so it slides in instead of squeezing.
130
+ <div className="assistant-clip sticky top-0 box-border flex h-dvh shrink-0 justify-end self-start overflow-hidden border-l" data-shown={shown ? 'true' : 'false'}>
131
+ <aside aria-label={labels.title} style={{ width }} className="assistant-column relative flex h-full shrink-0 flex-col bg-background">
131
132
  <div
132
133
  role="separator"
133
134
  aria-orientation="vertical"
@@ -143,7 +144,7 @@ function Column({ width, shown, minWidth, maxWidth, defaultWidth, onWidthChange,
143
144
  />
144
145
  <ChatPanelHeader
145
146
  heading={<h2 className={PANEL_TITLE_CLASS}>{labels.title}</h2>}
146
- actions={actions ?? published}
147
+ actions={<><AssistantHelp />{actions ?? published}</>}
147
148
  close={<PanelCloseButton label={labels.close ?? copy.close} onClick={onClose} />}
148
149
  />
149
150
  {content ? <div className="flex min-h-0 flex-1 flex-col">{content}</div> : <AssistantSlot className="min-h-0 flex-1" />}
@@ -0,0 +1,21 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ export interface AssistantGreetingProps {
4
+ /** The line over the starter rows ("How can I help you today?"). */
5
+ title: ReactNode;
6
+ /** A round mark above it: an avatar, a logo, an icon. Absent: none. */
7
+ mark?: ReactNode;
8
+ }
9
+
10
+ /**
11
+ * The empty room's greeting, shown in the transcript until the first message: a round mark and one line, left-aligned to
12
+ * sit over the starter rows, near the composer where the eye already is (the Notion AI arrangement).
13
+ */
14
+ export function AssistantGreeting({ title, mark }: AssistantGreetingProps) {
15
+ return (
16
+ <div className="flex h-full min-h-0 flex-col justify-end px-3 pb-2">
17
+ {mark ? <div className="mb-3 flex size-12 items-center justify-center overflow-hidden rounded-full bg-muted text-foreground">{mark}</div> : null}
18
+ <h3 className="text-[length:var(--greeting-font-size,1.0625rem)] font-semibold leading-tight text-foreground">{title}</h3>
19
+ </div>
20
+ );
21
+ }
@@ -0,0 +1,49 @@
1
+ 'use client';
2
+
3
+ import { CircleHelp } from 'lucide-react';
4
+
5
+ import { Button, Kbd, Popover, PopoverContent, PopoverTrigger } from '@djangocfg/ui-core/components';
6
+ import { formatHotkey, useIsTouch } from '@djangocfg/ui-core/hooks';
7
+
8
+ import { useAssistantCopy } from '../i18n';
9
+ import { DICTATION_TOGGLE } from '../composer/keys';
10
+
11
+ /** The keys the composer answers to, in the order a person needs them. `combo` is a hotkey string, drawn for the viewer's system (⌘ on Apple, Ctrl elsewhere). */
12
+ function useRows() {
13
+ const copy = useAssistantCopy();
14
+ return [
15
+ { combo: DICTATION_TOGGLE, text: copy.helpDictate },
16
+ { combo: 'enter', text: copy.helpSend },
17
+ { combo: 'escape', text: copy.helpStop },
18
+ { combo: 'arrowup', text: copy.helpRecall },
19
+ { combo: 'shift+enter', text: copy.helpNewline },
20
+ ];
21
+ }
22
+
23
+ /** A round "?" in the assistant's header, before its other actions; pressing it opens the short list of keys the composer answers to. */
24
+ export function AssistantHelp() {
25
+ const copy = useAssistantCopy();
26
+ const rows = useRows();
27
+ const touch = useIsTouch();
28
+ // The keys are a desktop keyboard's: a phone has none of them.
29
+ if (touch) return null;
30
+ return (
31
+ <Popover>
32
+ <PopoverTrigger asChild>
33
+ <Button variant="ghost" size="icon" className="rounded-full" aria-label={copy.helpLabel} title={copy.helpLabel}>
34
+ <CircleHelp className="size-4" aria-hidden />
35
+ </Button>
36
+ </PopoverTrigger>
37
+ <PopoverContent side="bottom" align="end" className="w-72 p-3">
38
+ <ul className="space-y-2">
39
+ {rows.map(({ combo, text }) => (
40
+ <li key={combo} className="flex items-start justify-between gap-3 text-sm">
41
+ <span className="text-muted-foreground">{text}</span>
42
+ <Kbd className="shrink-0">{formatHotkey(combo)}</Kbd>
43
+ </li>
44
+ ))}
45
+ </ul>
46
+ </PopoverContent>
47
+ </Popover>
48
+ );
49
+ }
@@ -7,6 +7,7 @@ import { useIsMobile } from '@djangocfg/ui-core/hooks';
7
7
  import { ChatPanelHeader, ChatSheet, PANEL_TITLE_CLASS, PanelCloseButton } from '@djangocfg/widget-chat';
8
8
 
9
9
  import { useHeaderActions } from '../Host';
10
+ import { AssistantHelp } from './AssistantHelp';
10
11
  import { useAssistantCopy } from '../i18n';
11
12
 
12
13
  interface Props {
@@ -44,7 +45,7 @@ export function ChatDock({ open, onOpenChange, children: chat, title, contextLin
44
45
  <ChatPanelHeader
45
46
  heading={<SidePanel.Title className={PANEL_TITLE_CLASS}>{title}</SidePanel.Title>}
46
47
  description={contextLine ? <SidePanel.Description>{contextLine}</SidePanel.Description> : undefined}
47
- actions={shown}
48
+ actions={<><AssistantHelp />{shown}</>}
48
49
  close={<PanelCloseButton label={closeLabel ?? copy.close} onClick={() => onOpenChange(false)} />}
49
50
  />
50
51
  <SidePanel.Body>{chat}</SidePanel.Body>
@@ -36,25 +36,29 @@
36
36
 
37
37
  .assistant-clip {
38
38
  width: var(--assistant-w);
39
+ }
40
+
41
+ /* Only the content fades. The clip's own divider must be visible from the first frame of opening and until the last of closing. */
42
+ .assistant-clip .assistant-column {
39
43
  opacity: 0;
40
44
  }
41
45
 
42
- .assistant-clip[data-shown="true"] {
46
+ .assistant-clip[data-shown="true"] .assistant-column {
43
47
  opacity: 1;
44
48
  }
45
49
 
46
- .assistant-frame[data-moving="true"] .assistant-clip {
50
+ .assistant-frame[data-moving="true"] .assistant-column {
47
51
  transition: opacity calc(var(--assistant-ms) * 0.7) ease-out;
48
52
  }
49
53
 
50
54
  /* Closing: the fade finishes early so the column does not look like it is dragged off. */
51
- .assistant-frame[data-moving="true"] .assistant-clip[data-shown="false"] {
55
+ .assistant-frame[data-moving="true"] .assistant-clip[data-shown="false"] .assistant-column {
52
56
  transition-duration: calc(var(--assistant-ms) * 0.45);
53
57
  }
54
58
 
55
59
  @media (prefers-reduced-motion: reduce) {
56
60
  .assistant-frame[data-moving="true"],
57
- .assistant-frame[data-moving="true"] .assistant-clip {
61
+ .assistant-frame[data-moving="true"] .assistant-column {
58
62
  transition: none;
59
63
  }
60
64
  }
@@ -1,49 +0,0 @@
1
- 'use client';
2
-
3
- import { Kbd, Popover, PopoverContent, PopoverTrigger } from '@djangocfg/ui-core/components';
4
- import { formatHotkey, useIsTouch } from '@djangocfg/ui-core/hooks';
5
-
6
- import { useAssistantCopy } from '../i18n';
7
- import { DICTATION_TOGGLE } from './keys';
8
-
9
- /** The keys the composer answers to, in the order a person needs them. `combo` is a hotkey string, drawn for the viewer's system (⌘ on Apple, Ctrl elsewhere). */
10
- function useRows() {
11
- const copy = useAssistantCopy();
12
- return [
13
- { combo: DICTATION_TOGGLE, text: copy.helpDictate },
14
- { combo: 'enter', text: copy.helpSend },
15
- { combo: 'escape', text: copy.helpStop },
16
- { combo: 'arrowup', text: copy.helpRecall },
17
- { combo: 'shift+enter', text: copy.helpNewline },
18
- ];
19
- }
20
-
21
- /** A quiet "Help" under the composer; pressing it opens the short list of keys. */
22
- export function ComposerHelp() {
23
- const copy = useAssistantCopy();
24
- const rows = useRows();
25
- const touch = useIsTouch();
26
- // The keys are a desktop keyboard's: a phone has none of them.
27
- if (touch) return null;
28
- return (
29
- <div className="px-4 pb-1 text-end">
30
- <Popover>
31
- <PopoverTrigger asChild>
32
- <button type="button" className="rounded px-1 text-xs text-muted-foreground underline-offset-2 outline-none hover:text-foreground hover:underline focus-visible:ring-2 focus-visible:ring-ring">
33
- {copy.helpLabel}
34
- </button>
35
- </PopoverTrigger>
36
- <PopoverContent side="top" align="end" className="w-72 p-3">
37
- <ul className="space-y-2">
38
- {rows.map(({ combo, text }) => (
39
- <li key={combo} className="flex items-start justify-between gap-3 text-sm">
40
- <span className="text-muted-foreground">{text}</span>
41
- <Kbd className="shrink-0">{formatHotkey(combo)}</Kbd>
42
- </li>
43
- ))}
44
- </ul>
45
- </PopoverContent>
46
- </Popover>
47
- </div>
48
- );
49
- }