@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.
- package/LICENSE +21 -0
- package/README.md +124 -0
- package/package.json +95 -0
- package/src/composer/composer.tsx +100 -0
- package/src/composer/context.ts +33 -0
- package/src/composer/controls/button.tsx +104 -0
- package/src/composer/controls/language-menu.tsx +123 -0
- package/src/composer/controls/send.tsx +83 -0
- package/src/composer/controls/think.tsx +50 -0
- package/src/composer/controls/tools.tsx +130 -0
- package/src/composer/controls/tooltip.tsx +10 -0
- package/src/composer/controls/voice.tsx +266 -0
- package/src/composer/field/field.tsx +140 -0
- package/src/composer/field/input.tsx +214 -0
- package/src/composer/field/keys.ts +44 -0
- package/src/composer/field/use-autosize.ts +134 -0
- package/src/composer/focus-field.ts +27 -0
- package/src/composer/index.ts +86 -0
- package/src/composer/stories/field.tsx +112 -0
- package/src/composer/stories/harness.tsx +127 -0
- package/src/composer/tools/menu.tsx +129 -0
- package/src/composer/tools/rows.tsx +180 -0
- package/src/composer/tools/use-menu-nav.ts +66 -0
- package/src/composer/tools/use-recent-tools.ts +30 -0
- package/src/composer/tray/banner.tsx +157 -0
- package/src/composer/tray/command-progress.tsx +116 -0
- package/src/composer/tray/picked-tools.tsx +104 -0
- package/src/composer/tray/queue.tsx +202 -0
- package/src/composer/tray/use-replace-text.ts +58 -0
- package/src/composer/use-draft-persistence.ts +79 -0
- package/src/content/index.ts +16 -0
- package/src/content/media-content.tsx +38 -0
- package/src/content/response.tsx +221 -0
- package/src/core/adapter.ts +29 -0
- package/src/core/capabilities.ts +286 -0
- package/src/core/chunks.ts +66 -0
- package/src/core/content/incomplete-math.ts +118 -0
- package/src/core/content/index.ts +9 -0
- package/src/core/draft-storage.ts +62 -0
- package/src/core/events.ts +88 -0
- package/src/core/focus/index.ts +10 -0
- package/src/core/focus/roving.ts +56 -0
- package/src/core/group-parts.ts +80 -0
- package/src/core/index.ts +34 -0
- package/src/core/links/index.ts +33 -0
- package/src/core/links/prose.ts +191 -0
- package/src/core/links/scheme.ts +98 -0
- package/src/core/links/tool-id.ts +26 -0
- package/src/core/media/allowed-src.ts +138 -0
- package/src/core/media/block.ts +254 -0
- package/src/core/media/index.ts +22 -0
- package/src/core/media/kinds.ts +85 -0
- package/src/core/outbound.ts +86 -0
- package/src/core/parts.ts +169 -0
- package/src/core/queue/index.ts +15 -0
- package/src/core/queue/queue.ts +100 -0
- package/src/core/recall/boundary.ts +77 -0
- package/src/core/recall/from-transcript.ts +47 -0
- package/src/core/recall/index.ts +23 -0
- package/src/core/recall/ladder.ts +171 -0
- package/src/core/reload-decision.ts +36 -0
- package/src/core/scroll/index.ts +17 -0
- package/src/core/scroll/missed.ts +106 -0
- package/src/core/scroll/sent.ts +54 -0
- package/src/core/text-replace.ts +20 -0
- package/src/core/tool-name.ts +31 -0
- package/src/core/tool-picks-set.ts +48 -0
- package/src/core/tool-picks.ts +142 -0
- package/src/core/tool-prompt.ts +47 -0
- package/src/core/tools/capability.ts +34 -0
- package/src/core/tools/catalogue.ts +109 -0
- package/src/core/tools/index.ts +17 -0
- package/src/core/tools/layout.ts +44 -0
- package/src/core/tools/nav.ts +32 -0
- package/src/core/tools/rank.ts +60 -0
- package/src/core/tools/recent.ts +39 -0
- package/src/core/transcript.ts +301 -0
- package/src/core/voice/earcons.ts +58 -0
- package/src/core/voice/index.ts +26 -0
- package/src/core/voice/language-search.ts +62 -0
- package/src/core/voice/languages.ts +267 -0
- package/src/core/voice/resolve-language.ts +93 -0
- package/src/core/voice/session.ts +196 -0
- package/src/core/voice/web-speech.ts +170 -0
- package/src/i18n/index.ts +87 -0
- package/src/i18n/locales/ar.ts +97 -0
- package/src/i18n/locales/da.ts +90 -0
- package/src/i18n/locales/de.ts +90 -0
- package/src/i18n/locales/en.ts +90 -0
- package/src/i18n/locales/es.ts +90 -0
- package/src/i18n/locales/fr.ts +90 -0
- package/src/i18n/locales/it.ts +90 -0
- package/src/i18n/locales/ja.ts +83 -0
- package/src/i18n/locales/ko.ts +83 -0
- package/src/i18n/locales/nl.ts +90 -0
- package/src/i18n/locales/no.ts +90 -0
- package/src/i18n/locales/pl.ts +96 -0
- package/src/i18n/locales/ptBR.ts +90 -0
- package/src/i18n/locales/ru.ts +96 -0
- package/src/i18n/locales/sv.ts +90 -0
- package/src/i18n/locales/tr.ts +89 -0
- package/src/i18n/locales/zh.ts +83 -0
- package/src/i18n/plural.ts +62 -0
- package/src/i18n/types.ts +257 -0
- package/src/index.ts +18 -0
- package/src/primitives/index.ts +43 -0
- package/src/primitives/message.tsx +85 -0
- package/src/primitives/part-registry.tsx +44 -0
- package/src/primitives/stories-harness.tsx +118 -0
- package/src/primitives/suggestions.tsx +112 -0
- package/src/primitives/tool-call.tsx +145 -0
- package/src/primitives/tooltip.tsx +48 -0
- package/src/primitives/transcript.tsx +285 -0
- package/src/primitives/use-dismiss.ts +100 -0
- package/src/primitives/use-missed-count.ts +30 -0
- package/src/primitives/use-overflows.ts +69 -0
- package/src/primitives/use-return-on-send.ts +29 -0
- package/src/primitives/use-roving-focus.ts +69 -0
- package/src/primitives/use-select-all-transcript.ts +111 -0
- package/src/primitives/use-stick-to-bottom.ts +83 -0
- package/src/primitives/use-turn-activity.ts +25 -0
- package/src/store/context.ts +28 -0
- package/src/store/hooks.ts +215 -0
- package/src/store/index.ts +31 -0
- package/src/store/provider.tsx +97 -0
- package/src/store/store.ts +394 -0
- package/src/store/subscribers.ts +55 -0
- package/src/store/surfaces.ts +174 -0
- package/src/store/use-draft.ts +168 -0
- package/src/store/use-queue.ts +61 -0
- package/src/store/use-recall.ts +264 -0
- package/src/store/use-reload-on-revision.ts +67 -0
- package/src/store/use-speech-language.ts +84 -0
- package/src/store/use-voice.ts +116 -0
- package/src/styles/block.css +128 -0
- package/src/styles/composer.css +449 -0
- package/src/styles/index.css +47 -0
- package/src/styles/markdown.css +368 -0
- package/src/styles/surface.css +102 -0
- package/src/styles/tool.css +128 -0
- package/src/styles/turn-mark.css +97 -0
- package/src/testing/index.ts +15 -0
- package/src/testing/mock-adapter.ts +69 -0
- package/src/testing/mock-tools.ts +101 -0
- package/src/testing/mock-voice.ts +81 -0
- package/src/ui/chat-composer.tsx +286 -0
- package/src/ui/chat-reply-chip.tsx +42 -0
- package/src/ui/chat-suggestions.tsx +87 -0
- package/src/ui/chat-transcript.tsx +157 -0
- package/src/ui/chat.tsx +197 -0
- package/src/ui/collapsible-body.tsx +180 -0
- package/src/ui/collapsible-row.tsx +81 -0
- package/src/ui/day-divider.tsx +49 -0
- package/src/ui/index.ts +54 -0
- package/src/ui/jump-to-latest.tsx +60 -0
- package/src/ui/link-chip.tsx +73 -0
- package/src/ui/message-actions.tsx +163 -0
- package/src/ui/message-quote.tsx +47 -0
- package/src/ui/message.tsx +295 -0
- package/src/ui/pending.tsx +85 -0
- package/src/ui/renderers/data.tsx +63 -0
- package/src/ui/renderers/reasoning.tsx +73 -0
- package/src/ui/renderers/tool.tsx +176 -0
- package/src/ui/turn-mark.tsx +31 -0
- package/src/utils/day-heading.ts +46 -0
- package/src/utils/error-message.ts +10 -0
- package/src/utils/index.ts +12 -0
- package/src/utils/markdown-text.ts +175 -0
- package/src/widgets/index.ts +14 -0
- 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
|
+
}
|