@djangocfg/widget-chat 0.1.1

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.
Files changed (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -0
  3. package/package.json +95 -0
  4. package/src/composer/composer.tsx +100 -0
  5. package/src/composer/context.ts +33 -0
  6. package/src/composer/controls/button.tsx +104 -0
  7. package/src/composer/controls/language-menu.tsx +123 -0
  8. package/src/composer/controls/send.tsx +83 -0
  9. package/src/composer/controls/think.tsx +50 -0
  10. package/src/composer/controls/tools.tsx +130 -0
  11. package/src/composer/controls/tooltip.tsx +10 -0
  12. package/src/composer/controls/voice.tsx +266 -0
  13. package/src/composer/field/field.tsx +140 -0
  14. package/src/composer/field/input.tsx +214 -0
  15. package/src/composer/field/keys.ts +44 -0
  16. package/src/composer/field/use-autosize.ts +134 -0
  17. package/src/composer/focus-field.ts +27 -0
  18. package/src/composer/index.ts +86 -0
  19. package/src/composer/stories/field.tsx +112 -0
  20. package/src/composer/stories/harness.tsx +127 -0
  21. package/src/composer/tools/menu.tsx +129 -0
  22. package/src/composer/tools/rows.tsx +180 -0
  23. package/src/composer/tools/use-menu-nav.ts +66 -0
  24. package/src/composer/tools/use-recent-tools.ts +30 -0
  25. package/src/composer/tray/banner.tsx +157 -0
  26. package/src/composer/tray/command-progress.tsx +116 -0
  27. package/src/composer/tray/picked-tools.tsx +104 -0
  28. package/src/composer/tray/queue.tsx +202 -0
  29. package/src/composer/tray/use-replace-text.ts +58 -0
  30. package/src/composer/use-draft-persistence.ts +79 -0
  31. package/src/content/index.ts +16 -0
  32. package/src/content/media-content.tsx +38 -0
  33. package/src/content/response.tsx +221 -0
  34. package/src/core/adapter.ts +29 -0
  35. package/src/core/capabilities.ts +286 -0
  36. package/src/core/chunks.ts +66 -0
  37. package/src/core/content/incomplete-math.ts +118 -0
  38. package/src/core/content/index.ts +9 -0
  39. package/src/core/draft-storage.ts +62 -0
  40. package/src/core/events.ts +88 -0
  41. package/src/core/focus/index.ts +10 -0
  42. package/src/core/focus/roving.ts +56 -0
  43. package/src/core/group-parts.ts +80 -0
  44. package/src/core/index.ts +34 -0
  45. package/src/core/links/index.ts +33 -0
  46. package/src/core/links/prose.ts +191 -0
  47. package/src/core/links/scheme.ts +98 -0
  48. package/src/core/links/tool-id.ts +26 -0
  49. package/src/core/media/allowed-src.ts +138 -0
  50. package/src/core/media/block.ts +254 -0
  51. package/src/core/media/index.ts +22 -0
  52. package/src/core/media/kinds.ts +85 -0
  53. package/src/core/outbound.ts +86 -0
  54. package/src/core/parts.ts +169 -0
  55. package/src/core/queue/index.ts +15 -0
  56. package/src/core/queue/queue.ts +100 -0
  57. package/src/core/recall/boundary.ts +77 -0
  58. package/src/core/recall/from-transcript.ts +47 -0
  59. package/src/core/recall/index.ts +23 -0
  60. package/src/core/recall/ladder.ts +171 -0
  61. package/src/core/reload-decision.ts +36 -0
  62. package/src/core/scroll/index.ts +17 -0
  63. package/src/core/scroll/missed.ts +106 -0
  64. package/src/core/scroll/sent.ts +54 -0
  65. package/src/core/text-replace.ts +20 -0
  66. package/src/core/tool-name.ts +31 -0
  67. package/src/core/tool-picks-set.ts +48 -0
  68. package/src/core/tool-picks.ts +142 -0
  69. package/src/core/tool-prompt.ts +47 -0
  70. package/src/core/tools/capability.ts +34 -0
  71. package/src/core/tools/catalogue.ts +109 -0
  72. package/src/core/tools/index.ts +17 -0
  73. package/src/core/tools/layout.ts +44 -0
  74. package/src/core/tools/nav.ts +32 -0
  75. package/src/core/tools/rank.ts +60 -0
  76. package/src/core/tools/recent.ts +39 -0
  77. package/src/core/transcript.ts +301 -0
  78. package/src/core/voice/earcons.ts +58 -0
  79. package/src/core/voice/index.ts +26 -0
  80. package/src/core/voice/language-search.ts +62 -0
  81. package/src/core/voice/languages.ts +267 -0
  82. package/src/core/voice/resolve-language.ts +93 -0
  83. package/src/core/voice/session.ts +196 -0
  84. package/src/core/voice/web-speech.ts +170 -0
  85. package/src/i18n/index.ts +87 -0
  86. package/src/i18n/locales/ar.ts +97 -0
  87. package/src/i18n/locales/da.ts +90 -0
  88. package/src/i18n/locales/de.ts +90 -0
  89. package/src/i18n/locales/en.ts +90 -0
  90. package/src/i18n/locales/es.ts +90 -0
  91. package/src/i18n/locales/fr.ts +90 -0
  92. package/src/i18n/locales/it.ts +90 -0
  93. package/src/i18n/locales/ja.ts +83 -0
  94. package/src/i18n/locales/ko.ts +83 -0
  95. package/src/i18n/locales/nl.ts +90 -0
  96. package/src/i18n/locales/no.ts +90 -0
  97. package/src/i18n/locales/pl.ts +96 -0
  98. package/src/i18n/locales/ptBR.ts +90 -0
  99. package/src/i18n/locales/ru.ts +96 -0
  100. package/src/i18n/locales/sv.ts +90 -0
  101. package/src/i18n/locales/tr.ts +89 -0
  102. package/src/i18n/locales/zh.ts +83 -0
  103. package/src/i18n/plural.ts +62 -0
  104. package/src/i18n/types.ts +257 -0
  105. package/src/index.ts +18 -0
  106. package/src/primitives/index.ts +43 -0
  107. package/src/primitives/message.tsx +85 -0
  108. package/src/primitives/part-registry.tsx +44 -0
  109. package/src/primitives/stories-harness.tsx +118 -0
  110. package/src/primitives/suggestions.tsx +112 -0
  111. package/src/primitives/tool-call.tsx +145 -0
  112. package/src/primitives/tooltip.tsx +48 -0
  113. package/src/primitives/transcript.tsx +285 -0
  114. package/src/primitives/use-dismiss.ts +100 -0
  115. package/src/primitives/use-missed-count.ts +30 -0
  116. package/src/primitives/use-overflows.ts +69 -0
  117. package/src/primitives/use-return-on-send.ts +29 -0
  118. package/src/primitives/use-roving-focus.ts +69 -0
  119. package/src/primitives/use-select-all-transcript.ts +111 -0
  120. package/src/primitives/use-stick-to-bottom.ts +83 -0
  121. package/src/primitives/use-turn-activity.ts +25 -0
  122. package/src/store/context.ts +28 -0
  123. package/src/store/hooks.ts +215 -0
  124. package/src/store/index.ts +31 -0
  125. package/src/store/provider.tsx +97 -0
  126. package/src/store/store.ts +394 -0
  127. package/src/store/subscribers.ts +55 -0
  128. package/src/store/surfaces.ts +174 -0
  129. package/src/store/use-draft.ts +168 -0
  130. package/src/store/use-queue.ts +61 -0
  131. package/src/store/use-recall.ts +264 -0
  132. package/src/store/use-reload-on-revision.ts +67 -0
  133. package/src/store/use-speech-language.ts +84 -0
  134. package/src/store/use-voice.ts +116 -0
  135. package/src/styles/block.css +128 -0
  136. package/src/styles/composer.css +449 -0
  137. package/src/styles/index.css +47 -0
  138. package/src/styles/markdown.css +368 -0
  139. package/src/styles/surface.css +102 -0
  140. package/src/styles/tool.css +128 -0
  141. package/src/styles/turn-mark.css +97 -0
  142. package/src/testing/index.ts +15 -0
  143. package/src/testing/mock-adapter.ts +69 -0
  144. package/src/testing/mock-tools.ts +101 -0
  145. package/src/testing/mock-voice.ts +81 -0
  146. package/src/ui/chat-composer.tsx +286 -0
  147. package/src/ui/chat-reply-chip.tsx +42 -0
  148. package/src/ui/chat-suggestions.tsx +87 -0
  149. package/src/ui/chat-transcript.tsx +157 -0
  150. package/src/ui/chat.tsx +197 -0
  151. package/src/ui/collapsible-body.tsx +180 -0
  152. package/src/ui/collapsible-row.tsx +81 -0
  153. package/src/ui/day-divider.tsx +49 -0
  154. package/src/ui/index.ts +54 -0
  155. package/src/ui/jump-to-latest.tsx +60 -0
  156. package/src/ui/link-chip.tsx +73 -0
  157. package/src/ui/message-actions.tsx +163 -0
  158. package/src/ui/message-quote.tsx +47 -0
  159. package/src/ui/message.tsx +295 -0
  160. package/src/ui/pending.tsx +85 -0
  161. package/src/ui/renderers/data.tsx +63 -0
  162. package/src/ui/renderers/reasoning.tsx +73 -0
  163. package/src/ui/renderers/tool.tsx +176 -0
  164. package/src/ui/turn-mark.tsx +31 -0
  165. package/src/utils/day-heading.ts +46 -0
  166. package/src/utils/error-message.ts +10 -0
  167. package/src/utils/index.ts +12 -0
  168. package/src/utils/markdown-text.ts +175 -0
  169. package/src/widgets/index.ts +14 -0
  170. package/src/widgets/media.tsx +239 -0
@@ -0,0 +1,127 @@
1
+ import { CornerDownLeft, Mic, MicOff, Square } from "lucide-react";
2
+
3
+ import type { ChatCapabilities } from "../../core";
4
+ import type { StreamChunk } from "../../core/chunks";
5
+ import { ChatProvider } from "../../store";
6
+ import { mockAdapter } from "../../testing";
7
+ import { Composer } from "../composer";
8
+ import { ComposerQueue } from "../tray/queue";
9
+ import { ComposerSend } from "../controls/send";
10
+ import { ComposerVoice } from "../controls/voice";
11
+ import { Field } from "./field";
12
+
13
+ /**
14
+ * The composer workbench.
15
+ *
16
+ * Separate from `primitives/stories-harness.tsx` rather than a variant of it:
17
+ * that one auto-sends on mount to produce a transcript, which is the opposite
18
+ * of what these stories need. Here nothing is sent until someone types, because
19
+ * the states worth seeing — a menu tracking the caret, a chip tray, the send
20
+ * button flipping to Stop — only exist while a human is mid-input.
21
+ *
22
+ * Copy is passed in as English literals. This package carries no locales
23
+ * (`ComposerInput`'s `label` is documented as host copy), so a workbench that
24
+ * reached for i18n would be inventing a dependency the components refuse.
25
+ */
26
+
27
+ const surface = "relative grid gap-2 rounded-lg border border-border bg-background p-2";
28
+ const chips = "flex flex-wrap gap-1";
29
+ // A FIXED height (`h-7`), not padding-derived: with `py-1` the height came from
30
+ // the content, so a chip carrying a 24px thumbnail grew taller than a plain one
31
+ // and the tray went ragged — visible the moment an image and a PDF sat side by
32
+ // side. The row now sets the height and the thumbnail (`h-full`) fits itself
33
+ // into it. `overflow-hidden` keeps the picture inside the rounded corner.
34
+ const chip =
35
+ "flex h-7 items-center gap-1 overflow-hidden rounded border border-border bg-muted pl-2 pr-1 text-xs";
36
+ // `rounded-control` — ui-core's `--radius-control`, the app's one corner for
37
+ // interactive controls. Matches what `ui/chat.tsx` ships, so the workbench and
38
+ // the packaged chat cannot drift on geometry.
39
+ const sendButton =
40
+ "grid size-8 shrink-0 place-items-center rounded-control bg-primary text-primary-foreground disabled:opacity-40";
41
+ // The mic is secondary: same geometry, no fill until it is recording. Filling it
42
+ // like Send would give two primary actions and no way to tell which one sends.
43
+ const voiceButton =
44
+ "grid size-8 shrink-0 place-items-center rounded-control text-muted-foreground hover:bg-muted aria-pressed:bg-destructive aria-pressed:text-destructive-foreground";
45
+ // A row, not two grid children: the buttons share one cell at the end of the
46
+ // composer, and `justify-self-end` on each would stack them in the same column.
47
+ const toolbar = "flex items-center justify-end gap-1";
48
+
49
+ export interface ComposerHarnessProps {
50
+ /** Chunks the mock adapter replays once a turn is sent. */
51
+ readonly script?: readonly StreamChunk[];
52
+ readonly delayMs?: number;
53
+ readonly capabilities?: ChatCapabilities;
54
+ /** Seed the ↑ ladder, OLDEST first. It also grows as prompts are sent. */
55
+ readonly history?: readonly string[];
56
+ /** Seed the field, for states that need text without typing. */
57
+ readonly initialText?: string;
58
+ /**
59
+ * Persist the draft under this room, as a host does.
60
+ *
61
+ * Absent ⇒ no persistence, which is every other story: a shared workbench key
62
+ * would let one story's leftover text appear in the next one.
63
+ */
64
+ readonly draftRoom?: string;
65
+ /**
66
+ * Send the seeded text on mount, so a story can PARK mid-turn.
67
+ *
68
+ * Off by default, which is the whole reason this workbench is separate from
69
+ * the transcript one: the composer's states exist while someone is typing,
70
+ * and a harness that always sent would clear the field before any of them
71
+ * could be seen. `Streaming` is the one story that needs the opposite, and a
72
+ * doc comment claiming that state while the button renders idle is only
73
+ * catchable in the browser: every assertion here reads the source rather than
74
+ * the running button.
75
+ */
76
+ readonly sendOnMount?: boolean;
77
+ }
78
+
79
+ export function ComposerHarness({
80
+ script = [],
81
+ delayMs = 40,
82
+ capabilities,
83
+ history,
84
+ initialText,
85
+ draftRoom,
86
+ sendOnMount = false,
87
+ }: ComposerHarnessProps) {
88
+ return (
89
+ <ChatProvider
90
+ adapter={mockAdapter({ script, delayMs })}
91
+ capabilities={capabilities}
92
+ >
93
+ <Composer className={surface}>
94
+ <ComposerQueue
95
+ className="rounded border border-border bg-muted/40 px-2 py-1 text-xs"
96
+ itemClassName="flex items-center justify-between gap-2 py-0.5"
97
+ />
98
+ <Field
99
+ history={history}
100
+ initialText={initialText}
101
+ draftRoom={draftRoom}
102
+ sendOnMount={sendOnMount}
103
+ />
104
+ <div className={toolbar}>
105
+ <ComposerVoice className={voiceButton}>
106
+ {(recording) =>
107
+ recording ? (
108
+ <MicOff className="size-4" aria-hidden />
109
+ ) : (
110
+ <Mic className="size-4" aria-hidden />
111
+ )
112
+ }
113
+ </ComposerVoice>
114
+ <ComposerSend className={sendButton}>
115
+ {(streaming) =>
116
+ streaming ? (
117
+ <Square className="size-3.5 fill-current" aria-hidden />
118
+ ) : (
119
+ <CornerDownLeft className="size-4" aria-hidden />
120
+ )
121
+ }
122
+ </ComposerSend>
123
+ </div>
124
+ </Composer>
125
+ </ChatProvider>
126
+ );
127
+ }
@@ -0,0 +1,129 @@
1
+ import { useMemo, useState, type ChangeEvent, type ReactNode } from "react";
2
+
3
+ import { filterToolCatalogue, recentTools, type ToolCatalogEntry } from "../../core";
4
+ import { ToolRows } from "./rows";
5
+ import { useMenuNav } from "./use-menu-nav";
6
+ import { useRecentTools } from "./use-recent-tools";
7
+
8
+ export interface ToolMenuProps {
9
+ readonly entries: readonly ToolCatalogEntry[];
10
+ readonly onPick: (entry: ToolCatalogEntry) => void;
11
+ /** Ids already staged. A row for one of these UNPICKS it. */
12
+ readonly picked?: ReadonlySet<string>;
13
+ /** Copy is the host's — this surface carries no locales. */
14
+ readonly label: string;
15
+ readonly searchLabel: string;
16
+ readonly emptyLabel: string;
17
+ readonly recentLabel: string;
18
+ readonly forgetLabel: string;
19
+ readonly icon?: (key: string) => ReactNode;
20
+ readonly categoryLabel?: (category: string) => string;
21
+ readonly className?: string;
22
+ }
23
+
24
+ // Below this the filter has nothing to narrow, and its autofocus would take the
25
+ // first keystroke from a user who opened a short menu to click.
26
+ const FILTER_FROM = 8;
27
+
28
+ /** Marks the recents group, which is not one of the registry's categories. */
29
+ const RECENT = "\u001frecent";
30
+
31
+ /** The arsenal sheet: a filter above what this agent can do. */
32
+ export function ToolMenu({
33
+ entries,
34
+ onPick,
35
+ picked,
36
+ label,
37
+ searchLabel,
38
+ emptyLabel,
39
+ recentLabel,
40
+ forgetLabel,
41
+ icon,
42
+ categoryLabel,
43
+ className,
44
+ }: ToolMenuProps) {
45
+ const [query, setQuery] = useState("");
46
+ const recent = useRecentTools();
47
+
48
+ const groups = useMemo(() => {
49
+ const found = filterToolCatalogue(entries, query);
50
+ // Recents lead only at REST. Once the user types they have said what they
51
+ // want, and a habit group above the answer buries it — the same defect as
52
+ // an unranked filter, one layer up.
53
+ if (query.trim() !== "") return found;
54
+
55
+ const rows = recentTools(entries, recent.ids);
56
+ if (rows.length === 0) return found;
57
+
58
+ // A tool appears ONCE. Left in both places it is two rows the keyboard must
59
+ // walk and two nodes any query matches, and the pair disagree about what
60
+ // their trailing column means — the recent one offers forget, the catalogue
61
+ // one a check.
62
+ const lifted = new Set(rows.map((row) => row.id));
63
+ const rest = found
64
+ .map((group) => ({
65
+ category: group.category,
66
+ entries: group.entries.filter((entry) => !lifted.has(entry.id)),
67
+ }))
68
+ .filter((group) => group.entries.length > 0);
69
+
70
+ return [{ category: RECENT, entries: rows }, ...rest];
71
+ }, [entries, query, recent.ids]);
72
+
73
+ // The rows as the eye reads them: arrow keys move through one list, not
74
+ // through groups, so the index and the render must flatten identically.
75
+ const flat = useMemo(() => groups.flatMap((group) => group.entries), [groups]);
76
+
77
+ const commit = (entry: ToolCatalogEntry): void => {
78
+ recent.remember(entry.id);
79
+ onPick(entry);
80
+ };
81
+
82
+ const nav = useMenuNav({
83
+ count: flat.length,
84
+ onCommit: (index) => {
85
+ const entry = flat[index];
86
+ if (entry !== undefined) commit(entry);
87
+ },
88
+ });
89
+
90
+ const searchable = entries.length >= FILTER_FROM;
91
+ const onQuery = (event: ChangeEvent<HTMLInputElement>): void => setQuery(event.target.value);
92
+
93
+ return (
94
+ <div className={className} onKeyDown={nav.onKeyDown}>
95
+ {searchable ? (
96
+ <input
97
+ type="search"
98
+ value={query}
99
+ onChange={onQuery}
100
+ aria-label={searchLabel}
101
+ className="border-border w-full border-b bg-transparent px-3 py-2 text-sm outline-none"
102
+ // The sheet is reached by a deliberate press and exists to be searched.
103
+ autoFocus
104
+ />
105
+ ) : null}
106
+ {groups.length === 0 ? (
107
+ // `status`, not a row: an empty result is not pickable, and a menu that
108
+ // renders nothing reads as broken.
109
+ <p role="status" className="text-muted-foreground px-3 py-2 text-sm">
110
+ {emptyLabel}
111
+ </p>
112
+ ) : (
113
+ <ToolRows
114
+ groups={groups}
115
+ onPick={commit}
116
+ picked={picked}
117
+ label={label}
118
+ icon={icon}
119
+ categoryLabel={(category) => (category === RECENT ? recentLabel : categoryLabel?.(category) ?? category)}
120
+ activeIndex={nav.navigated ? nav.activeIndex : -1}
121
+ onHover={nav.setActiveIndex}
122
+ recentCategory={RECENT}
123
+ onForget={recent.forget}
124
+ forgetLabel={forgetLabel}
125
+ />
126
+ )}
127
+ </div>
128
+ );
129
+ }
@@ -0,0 +1,180 @@
1
+ import { Check, X } from "lucide-react";
2
+ import { useEffect, useRef, type ReactNode } from "react";
3
+
4
+ import type { ToolCatalogEntry, ToolCatalogGroup } from "../../core";
5
+
6
+ export interface ToolRowsProps {
7
+ readonly groups: readonly ToolCatalogGroup[];
8
+ readonly onPick: (entry: ToolCatalogEntry) => void;
9
+ /** Ids already staged on the draft. A row for one of these UNPICKS it. */
10
+ readonly picked?: ReadonlySet<string>;
11
+ /** The list's accessible name. Host copy — no locales below `ui/`. */
12
+ readonly label: string;
13
+ /** Glyph for an entry's icon KEY — this package ships no icon library. */
14
+ readonly icon?: (key: string) => ReactNode;
15
+ /** The heading for a category key. Absent ⇒ the raw key, which is honest. */
16
+ readonly categoryLabel?: (category: string) => string;
17
+ /** Index into the FLATTENED rows, or -1 when the keyboard has not moved. */
18
+ readonly activeIndex?: number;
19
+ /** Pointer entering a row adopts it, so the two input modes share one cursor. */
20
+ readonly onHover?: (index: number) => void;
21
+ /** The synthetic group whose rows carry a forget control. */
22
+ readonly recentCategory?: string;
23
+ readonly onForget?: (id: string) => void;
24
+ readonly forgetLabel?: string;
25
+ }
26
+
27
+ /**
28
+ * The catalogue as grouped rows.
29
+ *
30
+ * A menu, not a listbox: nothing holds a value, and a listbox may not contain
31
+ * the `<button>` each row is (axe `aria-required-children`). Grouped because a
32
+ * flat list of forty tools gives a screen reader nothing to navigate by.
33
+ *
34
+ * Rows are numbered ACROSS groups, matching how arrow keys move: the keyboard
35
+ * walks what the eye reads, not the group structure under it.
36
+ */
37
+ export function ToolRows({
38
+ groups,
39
+ onPick,
40
+ picked,
41
+ label,
42
+ icon,
43
+ categoryLabel,
44
+ activeIndex = -1,
45
+ onHover,
46
+ recentCategory,
47
+ onForget,
48
+ forgetLabel,
49
+ }: ToolRowsProps) {
50
+ let row = -1;
51
+
52
+ return (
53
+ <div role="menu" aria-label={label} className="max-h-72 overflow-y-auto py-1">
54
+ {groups.map((group) => {
55
+ const heading = categoryLabel?.(group.category) ?? group.category;
56
+ const forgettable = recentCategory !== undefined && group.category === recentCategory;
57
+ return (
58
+ <div key={group.category} role="group" aria-label={heading}>
59
+ <p className="text-muted-foreground px-3 pt-2 pb-1 text-xs font-medium">{heading}</p>
60
+ {group.entries.map((entry) => {
61
+ row += 1;
62
+ const index = row;
63
+ return (
64
+ <ToolRow
65
+ key={`${group.category}:${entry.id}`}
66
+ entry={entry}
67
+ onPick={onPick}
68
+ checked={picked?.has(entry.id) ?? false}
69
+ icon={icon}
70
+ active={index === activeIndex}
71
+ onHover={onHover === undefined ? undefined : () => onHover(index)}
72
+ forgettable={forgettable}
73
+ onForget={forgettable ? onForget : undefined}
74
+ forgetLabel={forgetLabel}
75
+ />
76
+ );
77
+ })}
78
+ </div>
79
+ );
80
+ })}
81
+ </div>
82
+ );
83
+ }
84
+
85
+ interface ToolRowProps {
86
+ readonly entry: ToolCatalogEntry;
87
+ readonly onPick: (entry: ToolCatalogEntry) => void;
88
+ readonly checked: boolean;
89
+ readonly icon?: (key: string) => ReactNode;
90
+ readonly active: boolean;
91
+ readonly onHover?: () => void;
92
+ /** In the recents group: the trailing column carries forget, not a check. */
93
+ readonly forgettable: boolean;
94
+ readonly onForget?: (id: string) => void;
95
+ readonly forgetLabel?: string;
96
+ }
97
+
98
+ /**
99
+ * One row, which TOGGLES rather than adds.
100
+ *
101
+ * `menuitemcheckbox`, not `menuitem`: the row carries state a reader must be
102
+ * able to hear, and `aria-checked` is the only way to say "already staged"
103
+ * without inventing copy. Disabling it instead would leave the chip's `×` as
104
+ * the only way back — two places for one decision.
105
+ *
106
+ * The forget control is a SIBLING, not a nested button: a button inside a
107
+ * button is invalid HTML, and browsers resolve it by dropping one of them.
108
+ */
109
+ function ToolRow({
110
+ entry,
111
+ onPick,
112
+ checked,
113
+ icon,
114
+ active,
115
+ onHover,
116
+ forgettable,
117
+ onForget,
118
+ forgetLabel,
119
+ }: ToolRowProps) {
120
+ const glyph = entry.icon === undefined ? null : icon?.(entry.icon);
121
+ const rowRef = useRef<HTMLDivElement>(null);
122
+
123
+ // `nearest` scrolls the minimum: a visible row does not move, and walking the
124
+ // list never jumps the pane.
125
+ useEffect(() => {
126
+ if (active) rowRef.current?.scrollIntoView({ block: "nearest" });
127
+ }, [active]);
128
+
129
+ return (
130
+ <div
131
+ ref={rowRef}
132
+ /* Presentational: an unroled wrapper would break the menu's structure. */
133
+ role="none"
134
+ className="composer-tool-row relative flex items-stretch"
135
+ data-active={active ? "" : undefined}
136
+ onMouseEnter={onHover}
137
+ >
138
+ <button
139
+ type="button"
140
+ role="menuitemcheckbox"
141
+ aria-checked={checked}
142
+ onClick={() => onPick(entry)}
143
+ className="flex w-full items-start gap-2.5 px-3 py-1.5 text-left"
144
+ >
145
+ {glyph === null ? null : (
146
+ <span className="text-muted-foreground mt-0.5 shrink-0" aria-hidden>
147
+ {glyph}
148
+ </span>
149
+ )}
150
+ <span className="flex min-w-0 flex-col">
151
+ <span className="truncate text-sm">{entry.name}</span>
152
+ {/* Two lines, not `truncate`: one line cuts the half that tells two
153
+ neighbouring tools apart. */}
154
+ <span className="text-muted-foreground line-clamp-2 text-xs">{entry.summary}</span>
155
+ </span>
156
+ {/* Holds its column whether drawn or not, or the text shifts on every
157
+ toggle. Forget lands in this same column — one geometry, not two. */}
158
+ <span className="mt-0.5 ml-auto size-3.5 shrink-0" aria-hidden>
159
+ {checked && !forgettable ? <Check className="size-3.5" /> : null}
160
+ </span>
161
+ </button>
162
+ {/* One column, one meaning: a check where it is staged, forget where it
163
+ is recent. Never both. */}
164
+ {onForget === undefined ? null : (
165
+ <button
166
+ type="button"
167
+ /* A `menu` accepts only menu items (axe `aria-required-children`). */
168
+ role="menuitem"
169
+ aria-label={forgetLabel}
170
+ onClick={() => onForget(entry.id)}
171
+ /* Absolute over the column the check reserves: a control that appears
172
+ on hover must not reflow the row. Appearance is `composer.css`. */
173
+ className="composer-tool-forget absolute top-1.5 right-3"
174
+ >
175
+ <X className="size-3.5" />
176
+ </button>
177
+ )}
178
+ </div>
179
+ );
180
+ }
@@ -0,0 +1,66 @@
1
+ import { useCallback, useEffect, useState, type KeyboardEvent } from "react";
2
+
3
+ import { NO_ROW, nextRow, stepForKey } from "../../core";
4
+
5
+ export interface MenuNav {
6
+ /** Index into the flattened rows, or `NO_ROW` before the user navigates. */
7
+ readonly activeIndex: number;
8
+ /** True once a key moved the selection — drives `aria-activedescendant`. */
9
+ readonly navigated: boolean;
10
+ readonly onKeyDown: (event: KeyboardEvent) => void;
11
+ readonly setActiveIndex: (index: number) => void;
12
+ }
13
+
14
+ export interface MenuNavOptions {
15
+ /** How many rows are currently rendered, across all groups. */
16
+ readonly count: number;
17
+ /** Commit the row at this index. */
18
+ readonly onCommit: (index: number) => void;
19
+ readonly onEscape?: () => void;
20
+ }
21
+
22
+ /**
23
+ * React binding over `core/tools/nav`. Where the cursor lands is that module's;
24
+ * this owns only the event and the state holding it.
25
+ *
26
+ * Held keys are the BROWSER's auto-repeat, deliberately: it already fires
27
+ * `keydown` repeatedly at the rate the user set in their OS, so a timer here
28
+ * would ignore that preference and race with it. `event.repeat` is not filtered.
29
+ *
30
+ * The index resets whenever the row count changes — every keystroke in the
31
+ * filter rebuilds the list, and an index held across that points at a different
32
+ * tool than the one the user was looking at.
33
+ */
34
+ export function useMenuNav({ count, onCommit, onEscape }: MenuNavOptions): MenuNav {
35
+ const [activeIndex, setActiveIndex] = useState(NO_ROW);
36
+
37
+ useEffect(() => setActiveIndex(NO_ROW), [count]);
38
+
39
+ const onKeyDown = useCallback(
40
+ (event: KeyboardEvent) => {
41
+ if (event.key === "Escape") {
42
+ onEscape?.();
43
+ return;
44
+ }
45
+ if (event.key === "Enter") {
46
+ // Enter before any navigation is not a pick: the user is still typing,
47
+ // and committing row 0 on it commits a row the user never read.
48
+ if (activeIndex < 0) return;
49
+ event.preventDefault();
50
+ onCommit(activeIndex);
51
+ return;
52
+ }
53
+
54
+ const step = stepForKey(event.key);
55
+ const next = nextRow(activeIndex, step, count);
56
+ if (next === NO_ROW) return;
57
+ // The filter input owns the caret; without this, ArrowUp jumps it to the
58
+ // start of the query while the selection also moves.
59
+ event.preventDefault();
60
+ setActiveIndex(next);
61
+ },
62
+ [activeIndex, count, onCommit, onEscape],
63
+ );
64
+
65
+ return { activeIndex, navigated: activeIndex >= 0, onKeyDown, setActiveIndex };
66
+ }
@@ -0,0 +1,30 @@
1
+ import { useLocalStorage } from "@djangocfg/ui-core/hooks";
2
+ import { useCallback } from "react";
3
+
4
+ import { forgetTool, rememberTool } from "../../core";
5
+
6
+ const STORAGE_KEY = "djc:chat:recent-tools";
7
+
8
+ export interface RecentTools {
9
+ readonly ids: readonly string[];
10
+ readonly remember: (id: string) => void;
11
+ readonly forget: (id: string) => void;
12
+ }
13
+
14
+ /**
15
+ * The tools this user reaches for, newest first, across sessions.
16
+ *
17
+ * IDs only — names are resolved at read time, so a withdrawn tool cannot render
18
+ * a dead row. One key across every room: the habit is the person's.
19
+ */
20
+ export function useRecentTools(): RecentTools {
21
+ const [ids, setIds] = useLocalStorage<readonly string[]>(STORAGE_KEY, []);
22
+
23
+ const remember = useCallback(
24
+ (id: string) => setIds((current) => rememberTool(current, id)),
25
+ [setIds],
26
+ );
27
+ const forget = useCallback((id: string) => setIds((current) => forgetTool(current, id)), [setIds]);
28
+
29
+ return { ids, remember, forget };
30
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * A notice block ABOVE the composer box: a limit approaching, a plan to review.
3
+ *
4
+ * Above rather than inside, which is what separates it from `ComposerQueue`.
5
+ * The queue describes the DRAFT and belongs within the same rounded surface;
6
+ * this describes the account or the session and would read as part of the
7
+ * message if it shared that surface.
8
+ *
9
+ * Form adapted from vercel/ai-elements `checkpoint.tsx` and `confirmation.tsx`
10
+ * (Apache-2.0, submodule @ 6a9d5b1): composed parts rather than one component
11
+ * with a prop per feature. Their `Alert`-based shell and shadcn `Button` are
12
+ * NOT copied — this package styles against `ui-core` tokens and ships its own
13
+ * button, so only the composition survives the crossing.
14
+ *
15
+ * Composition is the whole point here, not taste. The predecessor exposed
16
+ * `icon`, `title`, `description`, `actions[]`, `variant`, `onDismiss` and
17
+ * `className` — seven props, and a caller wanting a second line or a link had
18
+ * no route but an eighth. Parts leave that to the caller's JSX.
19
+ *
20
+ * Copy is the HOST's: nothing here reads the dictionary, because a banner's
21
+ * words are always about the host's own situation (an allowance, a plan, a
22
+ * quota). `dismissLabel` is the exception and is required — an icon-only
23
+ * control needs an accessible name in all 17 locales, and a hardcoded English
24
+ * one is what this package exists to stop shipping.
25
+ */
26
+
27
+ import type { ReactNode } from "react";
28
+ import { X } from "lucide-react";
29
+
30
+ /** Tints the ICON only; the surface stays neutral so a notice never shouts. */
31
+ export type ComposerBannerTone = "info" | "warning" | "upgrade";
32
+
33
+ const ICON_TINT: Record<ComposerBannerTone, string> = {
34
+ info: "text-muted-foreground",
35
+ warning: "text-destructive",
36
+ upgrade: "text-primary",
37
+ };
38
+
39
+ export interface ComposerBannerProps {
40
+ readonly children: ReactNode;
41
+ readonly className?: string;
42
+ }
43
+
44
+ /** The surface. Lay out its parts in the order they should read. */
45
+ export function ComposerBanner({ children, className }: ComposerBannerProps) {
46
+ return (
47
+ <div
48
+ // `role="status"`, not `alert`: every use is ambient. An alert interrupts,
49
+ // and a limit approaching is not worth taking focus for.
50
+ role="status"
51
+ data-slot="composer-banner"
52
+ className={`flex w-full items-start gap-3 rounded-2xl border border-border bg-muted/60 px-3.5 py-3 text-sm text-foreground ${className ?? ""}`}
53
+ >
54
+ {children}
55
+ </div>
56
+ );
57
+ }
58
+
59
+ export interface ComposerBannerIconProps {
60
+ readonly children: ReactNode;
61
+ readonly tone?: ComposerBannerTone;
62
+ }
63
+
64
+ export function ComposerBannerIcon({
65
+ children,
66
+ tone = "info",
67
+ }: ComposerBannerIconProps) {
68
+ return (
69
+ <span
70
+ className={`mt-0.5 flex shrink-0 [&_svg]:size-5 ${ICON_TINT[tone]}`}
71
+ aria-hidden
72
+ >
73
+ {children}
74
+ </span>
75
+ );
76
+ }
77
+
78
+ /** Title and body. `min-w-0` so a long word truncates instead of pushing the
79
+ * actions off the row. */
80
+ export function ComposerBannerBody({ children, className }: ComposerBannerProps) {
81
+ return (
82
+ <div className={`min-w-0 flex-1 ${className ?? ""}`}>{children}</div>
83
+ );
84
+ }
85
+
86
+ export function ComposerBannerTitle({ children }: { readonly children: ReactNode }) {
87
+ return <p className="font-semibold leading-snug">{children}</p>;
88
+ }
89
+
90
+ export function ComposerBannerDescription({
91
+ children,
92
+ }: {
93
+ readonly children: ReactNode;
94
+ }) {
95
+ return <p className="mt-0.5 text-xs text-muted-foreground">{children}</p>;
96
+ }
97
+
98
+ /** Holds the buttons. Two is the practical limit — a notice offering three
99
+ * routes is a dialog wearing a banner's clothes. */
100
+ export function ComposerBannerActions({ children }: { readonly children: ReactNode }) {
101
+ return <div className="flex shrink-0 items-center gap-1.5">{children}</div>;
102
+ }
103
+
104
+ export interface ComposerBannerActionProps {
105
+ readonly children: ReactNode;
106
+ readonly onClick: () => void;
107
+ /** `primary` fills; `outline` (default) stays bordered and neutral. */
108
+ readonly variant?: "primary" | "outline";
109
+ }
110
+
111
+ const ACTION_CLASS: Record<
112
+ NonNullable<ComposerBannerActionProps["variant"]>,
113
+ string
114
+ > = {
115
+ primary: "bg-primary text-primary-foreground hover:bg-primary/90",
116
+ outline:
117
+ "border border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground",
118
+ };
119
+
120
+ export function ComposerBannerAction({
121
+ children,
122
+ onClick,
123
+ variant = "outline",
124
+ }: ComposerBannerActionProps) {
125
+ return (
126
+ <button
127
+ type="button"
128
+ onClick={onClick}
129
+ className={`inline-flex h-8 cursor-pointer items-center justify-center rounded-lg px-3 text-xs font-medium transition-colors focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none ${ACTION_CLASS[variant]}`}
130
+ >
131
+ {children}
132
+ </button>
133
+ );
134
+ }
135
+
136
+ export interface ComposerBannerDismissProps {
137
+ readonly onDismiss: () => void;
138
+ /** Required: an icon-only control with no name is invisible to a reader. */
139
+ readonly label: string;
140
+ }
141
+
142
+ export function ComposerBannerDismiss({
143
+ onDismiss,
144
+ label,
145
+ }: ComposerBannerDismissProps) {
146
+ return (
147
+ <button
148
+ type="button"
149
+ onClick={onDismiss}
150
+ aria-label={label}
151
+ title={label}
152
+ className="-mt-1 -mr-1 inline-flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none"
153
+ >
154
+ <X className="size-4" aria-hidden />
155
+ </button>
156
+ );
157
+ }