@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,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The composer's draft.
|
|
3
|
+
*
|
|
4
|
+
* The paste handler is returned rather than attached to `document`: a global
|
|
5
|
+
* listener fires for every editable on the page, which is how a paste into an
|
|
6
|
+
* unrelated field started reaching the chat.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
useCallback,
|
|
11
|
+
useMemo,
|
|
12
|
+
useRef,
|
|
13
|
+
useState,
|
|
14
|
+
type ClipboardEvent as ReactClipboardEvent,
|
|
15
|
+
} from "react";
|
|
16
|
+
|
|
17
|
+
import { emptyDraft, isEmpty, type Draft } from "../core/outbound";
|
|
18
|
+
import { stripToolMarkers, type ToolPick } from "../core/tool-picks";
|
|
19
|
+
import { addToolPicks, removeToolPick, toggleToolPick } from "../core/tool-picks-set";
|
|
20
|
+
import { useCapabilities, useChatStore } from "./hooks";
|
|
21
|
+
|
|
22
|
+
export interface DraftApi {
|
|
23
|
+
readonly draft: Draft;
|
|
24
|
+
readonly setText: (text: string) => void;
|
|
25
|
+
/** Aim this draft at a message, or `undefined` to answer nothing. */
|
|
26
|
+
readonly setReplyTo: (messageId: string | undefined) => void;
|
|
27
|
+
/** Stage a picked tool, or unstage it when already staged. */
|
|
28
|
+
readonly toggleTool: (pick: ToolPick) => void;
|
|
29
|
+
readonly removePickedTool: (id: string) => void;
|
|
30
|
+
readonly canSend: boolean;
|
|
31
|
+
readonly send: () => void;
|
|
32
|
+
/**
|
|
33
|
+
* Register something to end when the draft is sent; returns a disposer.
|
|
34
|
+
*
|
|
35
|
+
* Registration rather than a direct call: the voice session is built by
|
|
36
|
+
* `ComposerVoice` from a capability the host may not supply, and owning it
|
|
37
|
+
* here would mint a microphone for every composer.
|
|
38
|
+
*
|
|
39
|
+
* This is the seam every send door shares — the button, the form's submit,
|
|
40
|
+
* and Enter, which `field/input.tsx` handles itself. A hook on one leaves the
|
|
41
|
+
* others sending while the engine listens on.
|
|
42
|
+
*/
|
|
43
|
+
readonly onSend: (listener: () => void) => () => void;
|
|
44
|
+
/**
|
|
45
|
+
* Wire to the composer's own `onPaste`. Returns true when it consumed the
|
|
46
|
+
* event, so a host can tell "we handled it" from "let the editor insert".
|
|
47
|
+
*/
|
|
48
|
+
readonly handlePaste: (event: ClipboardEvent | ReactClipboardEvent) => boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function useDraft(): DraftApi {
|
|
52
|
+
const store = useChatStore();
|
|
53
|
+
const { tools } = useCapabilities();
|
|
54
|
+
const [draft, setDraft] = useState<Draft>(emptyDraft);
|
|
55
|
+
|
|
56
|
+
const setText = useCallback((text: string) => {
|
|
57
|
+
setDraft((current) => ({ ...current, text }));
|
|
58
|
+
}, []);
|
|
59
|
+
|
|
60
|
+
// One verb both ways: `undefined` clears. Two would let a surface set a
|
|
61
|
+
// target it has no matching way to drop.
|
|
62
|
+
const setReplyTo = useCallback((messageId: string | undefined) => {
|
|
63
|
+
setDraft((current) => {
|
|
64
|
+
if (messageId === undefined) {
|
|
65
|
+
const { replyTo: _dropped, ...rest } = current;
|
|
66
|
+
return rest;
|
|
67
|
+
}
|
|
68
|
+
return { ...current, replyTo: messageId };
|
|
69
|
+
});
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
const toggleTool = useCallback((pick: ToolPick) => {
|
|
73
|
+
setDraft((current) => ({
|
|
74
|
+
...current,
|
|
75
|
+
pickedTools: toggleToolPick(current.pickedTools ?? [], pick),
|
|
76
|
+
}));
|
|
77
|
+
}, []);
|
|
78
|
+
|
|
79
|
+
const removePickedTool = useCallback((id: string) => {
|
|
80
|
+
setDraft((current) => ({
|
|
81
|
+
...current,
|
|
82
|
+
pickedTools: removeToolPick(current.pickedTools ?? [], id),
|
|
83
|
+
}));
|
|
84
|
+
}, []);
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A paste that carried tool markers: the prose goes in, the tools become
|
|
88
|
+
* chips.
|
|
89
|
+
*
|
|
90
|
+
* The text is APPENDED rather than spliced at the caret. A marker describes
|
|
91
|
+
* the whole turn (`appendToolMarkers` puts it at the end for the same
|
|
92
|
+
* reason), and the pasted prose arrives as one block, so dropping it mid-word
|
|
93
|
+
* would rearrange a sentence the user did not edit.
|
|
94
|
+
*/
|
|
95
|
+
const insertPastedTools = useCallback((text: string, picks: readonly ToolPick[]) => {
|
|
96
|
+
setDraft((current) => {
|
|
97
|
+
const body = current.text.trim();
|
|
98
|
+
const joined = text === "" ? body : body === "" ? text : `${body} ${text}`;
|
|
99
|
+
return {
|
|
100
|
+
...current,
|
|
101
|
+
text: joined,
|
|
102
|
+
pickedTools: addToolPicks(current.pickedTools ?? [], picks),
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
107
|
+
const handlePaste = useCallback(
|
|
108
|
+
(event: ClipboardEvent | ReactClipboardEvent): boolean => {
|
|
109
|
+
const data = "clipboardData" in event ? event.clipboardData : null;
|
|
110
|
+
if (data === null) return false;
|
|
111
|
+
|
|
112
|
+
// Tool markers are the only thing a paste is read for. Everything else is
|
|
113
|
+
// left to the browser, which inserts it as the text it is.
|
|
114
|
+
const marked = stripToolMarkers(data.getData("text/plain"));
|
|
115
|
+
const restored = marked.ids
|
|
116
|
+
.map((id) => tools?.entries.find((entry) => entry.id === id))
|
|
117
|
+
.filter((entry) => entry !== undefined)
|
|
118
|
+
.map((entry) => ({ id: entry.id, label: entry.name, icon: entry.icon }));
|
|
119
|
+
if (restored.length === 0) return false;
|
|
120
|
+
|
|
121
|
+
// Before the state update, and unconditionally on the path we consume: a
|
|
122
|
+
// contentEditable host (ProseMirror) inserts the text itself otherwise,
|
|
123
|
+
// and the user gets it twice.
|
|
124
|
+
event.preventDefault();
|
|
125
|
+
insertPastedTools(marked.text, restored);
|
|
126
|
+
return true;
|
|
127
|
+
},
|
|
128
|
+
[tools, insertPastedTools],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// A ref, not state: registering must not re-render the composer, and `send`
|
|
132
|
+
// must see the current set rather than the one its closure captured.
|
|
133
|
+
const sendListeners = useRef(new Set<() => void>());
|
|
134
|
+
|
|
135
|
+
const onSend = useCallback((listener: () => void) => {
|
|
136
|
+
sendListeners.current.add(listener);
|
|
137
|
+
return () => {
|
|
138
|
+
sendListeners.current.delete(listener);
|
|
139
|
+
};
|
|
140
|
+
}, []);
|
|
141
|
+
|
|
142
|
+
const send = useCallback(() => {
|
|
143
|
+
// Guarded here as well as at the button: Enter reaches this directly
|
|
144
|
+
// (`field/input.tsx`), so gating only `canSend` would send an empty draft.
|
|
145
|
+
if (isEmpty(draft)) return;
|
|
146
|
+
// Before the send and the clear, so a listener sees the draft it is ending
|
|
147
|
+
// against. No arguments and no return: a listener ends an engine, it does
|
|
148
|
+
// not mutate the draft.
|
|
149
|
+
for (const listener of sendListeners.current) listener();
|
|
150
|
+
void store.send(draft);
|
|
151
|
+
setDraft(emptyDraft);
|
|
152
|
+
}, [draft, store]);
|
|
153
|
+
|
|
154
|
+
return useMemo(
|
|
155
|
+
() => ({
|
|
156
|
+
draft,
|
|
157
|
+
setText,
|
|
158
|
+
setReplyTo,
|
|
159
|
+
toggleTool,
|
|
160
|
+
removePickedTool,
|
|
161
|
+
canSend: !isEmpty(draft),
|
|
162
|
+
send,
|
|
163
|
+
onSend,
|
|
164
|
+
handlePaste,
|
|
165
|
+
}),
|
|
166
|
+
[draft, setText, setReplyTo, toggleTool, removePickedTool, send, onSend, handlePaste],
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
import type { Draft } from "../core/outbound";
|
|
4
|
+
import type { QueuedPrompt } from "../core/queue";
|
|
5
|
+
import { useChatEvents, useChatStore } from "./hooks";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* React binding for the prompt queue.
|
|
9
|
+
*
|
|
10
|
+
* Thin, like `useMentions` and `useVoice`: the ordering and the empty rules live
|
|
11
|
+
* in `core/queue`, tested without a DOM. This subscribes.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface UseQueueResult {
|
|
15
|
+
readonly items: readonly QueuedPrompt[];
|
|
16
|
+
readonly count: number;
|
|
17
|
+
readonly remove: (id: string) => void;
|
|
18
|
+
/** Replace a queued draft; editing it to empty removes the entry. */
|
|
19
|
+
readonly edit: (id: string, draft: Draft) => void;
|
|
20
|
+
/** Remove and return one queued draft, for editing it in the composer. */
|
|
21
|
+
readonly take: (id: string) => Draft | undefined;
|
|
22
|
+
/** Cut the current turn short and release this prompt now. */
|
|
23
|
+
readonly sendNow: (id: string) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useQueue(): UseQueueResult {
|
|
27
|
+
const store = useChatStore();
|
|
28
|
+
// Announced from here rather than from the store: the store is clock-free
|
|
29
|
+
// pure core, and handing it the bus would give it a second way to notify
|
|
30
|
+
// beside `notify` — one that carries no snapshot and nothing can replay.
|
|
31
|
+
const events = useChatEvents();
|
|
32
|
+
const queue = useSyncExternalStore(
|
|
33
|
+
store.subscribe,
|
|
34
|
+
useCallback(() => store.getState().queue, [store]),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// Keyed on the store alone, never on the queue — the defect fixed in
|
|
38
|
+
// `use-mentions.ts`. A host putting `remove` in an effect's dependency array
|
|
39
|
+
// would otherwise re-run it on every enqueue, forever.
|
|
40
|
+
const actions = useMemo(
|
|
41
|
+
() => ({
|
|
42
|
+
remove: (id: string) => store.removeQueued(id),
|
|
43
|
+
edit: (id: string, draft: Draft) => store.editQueued(id, draft),
|
|
44
|
+
take: (id: string) => store.takeQueued(id),
|
|
45
|
+
sendNow: (id: string) => {
|
|
46
|
+
// Announced only when the prompt actually MOVED past a running turn.
|
|
47
|
+
// With nothing in flight `sendQueuedNow` just sends it, and reporting a
|
|
48
|
+
// promotion there would describe an interruption that never happened.
|
|
49
|
+
const wasStreaming = store.getState().inFlight !== null;
|
|
50
|
+
store.sendQueuedNow(id);
|
|
51
|
+
if (wasStreaming) events.emit("queue:promoted", { id });
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
[store, events],
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
return useMemo(
|
|
58
|
+
() => ({ items: queue.items, count: queue.items.length, ...actions }),
|
|
59
|
+
[queue, actions],
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { useCallback, useRef, useState, type KeyboardEvent, type RefObject } from "react";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
atFirstLine,
|
|
5
|
+
atLastLine,
|
|
6
|
+
bufferIsRecallOwned,
|
|
7
|
+
recallNext,
|
|
8
|
+
recallPrevious,
|
|
9
|
+
REST,
|
|
10
|
+
type RecallLadder,
|
|
11
|
+
type RecallState,
|
|
12
|
+
} from "../core/recall";
|
|
13
|
+
import type { Draft } from "../core/outbound";
|
|
14
|
+
import { useChatEvents } from "./hooks";
|
|
15
|
+
import { useQueue } from "./use-queue";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* ↑/↓ prompt recall, bound to the textarea.
|
|
19
|
+
*
|
|
20
|
+
* Deliberately thin, the same split `useMentions` makes: every rule lives in
|
|
21
|
+
* `core/recall` where it is tested in milliseconds without a DOM, and this file
|
|
22
|
+
* does three things a pure function cannot — read the caret out of the live
|
|
23
|
+
* node, write the buffer, and claim the key.
|
|
24
|
+
*
|
|
25
|
+
* ## Why the state is a ref and not `useState`
|
|
26
|
+
*
|
|
27
|
+
* Nothing here RENDERS from the ladder position. The only visible effect of a
|
|
28
|
+
* press is the text, which the composer store already owns, so state would buy a
|
|
29
|
+
* re-render per keystroke and nothing else. It also has to be readable
|
|
30
|
+
* synchronously inside the keydown handler: with `useState` the second of two
|
|
31
|
+
* fast presses would read the first press's stale value and walk the same rung
|
|
32
|
+
* twice.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export interface UseRecallOptions {
|
|
36
|
+
/** Sent prompts, OLDEST first. The host owns storage — see below. */
|
|
37
|
+
readonly entries: readonly string[];
|
|
38
|
+
/** The live textarea, for the caret. Recall is refused while it is null. */
|
|
39
|
+
readonly input: RefObject<HTMLTextAreaElement | null>;
|
|
40
|
+
/**
|
|
41
|
+
* The composer's draft, passed in rather than read from its context.
|
|
42
|
+
*
|
|
43
|
+
* `store/` may not import `composer/` (`layers.test.ts`), and that boundary is
|
|
44
|
+
* right here for a reason beyond the gate: recall needs the TEXT, not the
|
|
45
|
+
* composer. Taking it as data keeps the hook usable by any surface that
|
|
46
|
+
* holds a draft.
|
|
47
|
+
*/
|
|
48
|
+
readonly draft: Draft;
|
|
49
|
+
/** Writes the recalled entry into the buffer. */
|
|
50
|
+
readonly setText: (value: string) => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface UseRecallResult {
|
|
54
|
+
/**
|
|
55
|
+
* Feed this to `ComposerInput.onKeyDown`.
|
|
56
|
+
*
|
|
57
|
+
* Calls `preventDefault()` only when it actually navigates, so an arrow that
|
|
58
|
+
* should move the caret still does — that veto is the whole protocol.
|
|
59
|
+
*/
|
|
60
|
+
readonly onKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
|
|
61
|
+
/**
|
|
62
|
+
* The prompt Tab would insert, or `null` when the ladder is at rest.
|
|
63
|
+
*
|
|
64
|
+
* The one piece of recall that RENDERS, and therefore the one piece held as
|
|
65
|
+
* state rather than in the ref below.
|
|
66
|
+
*/
|
|
67
|
+
readonly preview: string | null;
|
|
68
|
+
/** Drop the ladder back to rest. Call after a send. */
|
|
69
|
+
readonly reset: () => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Where the ladder COMES FROM is the host's, and the two sources are not
|
|
74
|
+
* equivalent.
|
|
75
|
+
*
|
|
76
|
+
* A host may persist what was typed, or derive the ladder from the user turns
|
|
77
|
+
* already in the transcript. Deriving is cheaper and
|
|
78
|
+
* needs no storage, but it cannot recall a prompt that was sent and never
|
|
79
|
+
* landed, and it is empty until history loads — so an empty ladder there is a
|
|
80
|
+
* boundary, not a fault.
|
|
81
|
+
*
|
|
82
|
+
* Either way it stays off the wire: what you typed is a local editing
|
|
83
|
+
* convenience, not conversation state, and must not sync between devices or
|
|
84
|
+
* outlive the device it was typed on. This hook takes the ladder as data and
|
|
85
|
+
* owns neither choice.
|
|
86
|
+
*/
|
|
87
|
+
export function useRecall({ entries, input, draft, setText }: UseRecallOptions): UseRecallResult {
|
|
88
|
+
// Queued prompts are a rung of the same ladder: written but not answered, so
|
|
89
|
+
// nearer in time than anything sent. Read from the store rather than taken as
|
|
90
|
+
// an option — the queue is the package's own state and a host passing its own
|
|
91
|
+
// copy would be a second source of truth for it.
|
|
92
|
+
const { items, take } = useQueue();
|
|
93
|
+
const events = useChatEvents();
|
|
94
|
+
const state = useRef<RecallState>(REST);
|
|
95
|
+
// The ONE thing recall renders. State, unlike the position beside it: a
|
|
96
|
+
// preview that lived in a ref would move without repainting, which is the
|
|
97
|
+
// whole feature.
|
|
98
|
+
const [preview, setPreview] = useState<string | null>(null);
|
|
99
|
+
// The same value, readable SYNCHRONOUSLY. The handler decides whether Tab is
|
|
100
|
+
// recall's or the browser's, and a `useState` read there is one render stale
|
|
101
|
+
// — so the first Tab after an arrow would fall through and move focus out of
|
|
102
|
+
// the composer instead of inserting.
|
|
103
|
+
const walking = useRef<string | null>(null);
|
|
104
|
+
walking.current = preview;
|
|
105
|
+
// Which queued entry the preview came from, or null when the rung is a sent
|
|
106
|
+
// prompt. Read on Tab: a queued prompt is TAKEN and a sent one is copied, and
|
|
107
|
+
// the text alone cannot tell them apart — the same words may sit on both rungs.
|
|
108
|
+
const previewQueuedId = useRef<string | null>(null);
|
|
109
|
+
|
|
110
|
+
// Read through refs so the handler identity is stable: it goes onto
|
|
111
|
+
// `ComposerInput`, and a new function every render would defeat its
|
|
112
|
+
// `useCallback` for no benefit.
|
|
113
|
+
const latest = useRef({ entries, draft, items });
|
|
114
|
+
latest.current = { entries, draft, items };
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Typing abandons the walk.
|
|
118
|
+
*
|
|
119
|
+
* A preview left hanging while the user writes something new invites a Tab
|
|
120
|
+
* that destroys it. Keyed on the draft becoming NON-EMPTY rather than on a
|
|
121
|
+
* keystroke, because that is the condition — recall only walks on an empty
|
|
122
|
+
* buffer, so any text at all means the user has started authoring.
|
|
123
|
+
*
|
|
124
|
+
* Derived during render rather than in an effect: an effect would paint one
|
|
125
|
+
* frame with a stale preview over text that is already there.
|
|
126
|
+
*/
|
|
127
|
+
if (preview !== null && draft.text !== "") {
|
|
128
|
+
state.current = REST;
|
|
129
|
+
walking.current = null;
|
|
130
|
+
previewQueuedId.current = null;
|
|
131
|
+
setPreview(null);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const reset = useCallback(() => {
|
|
135
|
+
state.current = REST;
|
|
136
|
+
previewQueuedId.current = null;
|
|
137
|
+
setPreview(null);
|
|
138
|
+
}, []);
|
|
139
|
+
|
|
140
|
+
const onKeyDown = useCallback(
|
|
141
|
+
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
142
|
+
// Another handler already claimed it — an open mention menu owns the
|
|
143
|
+
// arrows while it is showing, and it renders over this same textarea.
|
|
144
|
+
if (event.defaultPrevented) return;
|
|
145
|
+
|
|
146
|
+
const node = input.current;
|
|
147
|
+
|
|
148
|
+
// Tab and Escape belong to recall ONLY while a preview is showing.
|
|
149
|
+
//
|
|
150
|
+
// The Tab guard is an accessibility rule, not a preference: a Tab that is
|
|
151
|
+
// always swallowed makes the composer a keyboard trap with no way out. So
|
|
152
|
+
// both keys fall through untouched at rest, and Tab keeps moving focus.
|
|
153
|
+
if (walking.current !== null) {
|
|
154
|
+
if (event.key === "Tab") {
|
|
155
|
+
event.preventDefault();
|
|
156
|
+
const text = walking.current;
|
|
157
|
+
const queuedId = previewQueuedId.current;
|
|
158
|
+
state.current = REST;
|
|
159
|
+
previewQueuedId.current = null;
|
|
160
|
+
setPreview(null);
|
|
161
|
+
// A queued prompt is TAKEN, a sent one copied. It has not been seen by
|
|
162
|
+
// anyone, so leaving the entry behind would send it twice: once from
|
|
163
|
+
// the queue and once as the edit the user is about to make. A sent
|
|
164
|
+
// turn already happened, and rewriting it is a separate, destructive
|
|
165
|
+
// operation that stays explicit.
|
|
166
|
+
// Through `latest`, like every other queue read in this handler: the
|
|
167
|
+
// callback is not re-created per keystroke, so a closed-over `items`
|
|
168
|
+
// would be whatever the queue held when it was built.
|
|
169
|
+
const taken =
|
|
170
|
+
queuedId === null
|
|
171
|
+
? undefined
|
|
172
|
+
: latest.current.items.find((item) => item.id === queuedId);
|
|
173
|
+
if (queuedId !== null) take(queuedId);
|
|
174
|
+
setText(text);
|
|
175
|
+
// After BOTH steps, never between them. The queue's own edit path
|
|
176
|
+
// orders them the other way round on purpose, so this must stay a
|
|
177
|
+
// notification rather than the mechanism either path relies on.
|
|
178
|
+
if (queuedId !== null && taken !== undefined) {
|
|
179
|
+
events.emit("queue:taken", { id: queuedId, draft: taken.draft });
|
|
180
|
+
}
|
|
181
|
+
// Caret at the END: the user is about to edit or resend, and a caret
|
|
182
|
+
// at 0 puts every keystroke in front of what they just inserted.
|
|
183
|
+
// Deferred because React writes the value on the next commit.
|
|
184
|
+
queueMicrotask(() => node?.setSelectionRange(text.length, text.length));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (event.key === "Escape") {
|
|
188
|
+
// Nothing to restore: the draft was never written to. That is the
|
|
189
|
+
// point of the design — `stashedDraft` used to exist for this.
|
|
190
|
+
//
|
|
191
|
+
// The queued entry is left ALONE: Escape abandons the walk, and a
|
|
192
|
+
// prompt the user only looked at must stay in the queue.
|
|
193
|
+
event.preventDefault();
|
|
194
|
+
event.stopPropagation();
|
|
195
|
+
state.current = REST;
|
|
196
|
+
previewQueuedId.current = null;
|
|
197
|
+
setPreview(null);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
|
|
203
|
+
// A chord is the OS's or the host's: cmd+Up is "top of document" on macOS,
|
|
204
|
+
// and shift+Up extends a selection.
|
|
205
|
+
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
|
|
206
|
+
|
|
207
|
+
if (node === null) return;
|
|
208
|
+
|
|
209
|
+
const { entries: sent, draft: current, items: queued } = latest.current;
|
|
210
|
+
const caret = { start: node.selectionStart, end: node.selectionEnd };
|
|
211
|
+
|
|
212
|
+
// The cheapest and most absolute rule first: text the user typed is
|
|
213
|
+
// theirs, and no caret position makes an arrow navigate away from it.
|
|
214
|
+
//
|
|
215
|
+
// `lastRecalled` is passed as `null` DELIBERATELY, and the argument is
|
|
216
|
+
// now vestigial rather than merely unused: recall no longer writes to the
|
|
217
|
+
// buffer, so whatever is in it is always the user's. The rule that
|
|
218
|
+
// survives is the original half — a non-empty draft does not navigate —
|
|
219
|
+
// and passing a remembered value could only ever weaken it.
|
|
220
|
+
if (!bufferIsRecallOwned(current.text, null)) return;
|
|
221
|
+
|
|
222
|
+
const ladder: RecallLadder = {
|
|
223
|
+
sent,
|
|
224
|
+
queued: queued.map((item) => item.draft.text),
|
|
225
|
+
};
|
|
226
|
+
const up = event.key === "ArrowUp";
|
|
227
|
+
// The boundary decides whether this press is "move the caret within a
|
|
228
|
+
// multi-line draft" or "leave the buffer" — zsh's rule, not "is the
|
|
229
|
+
// composer empty".
|
|
230
|
+
if (up ? !atFirstLine(current.text, caret) : !atLastLine(current.text, caret)) return;
|
|
231
|
+
|
|
232
|
+
const move = up
|
|
233
|
+
? recallPrevious(state.current, ladder, current.text)
|
|
234
|
+
: recallNext(state.current, ladder);
|
|
235
|
+
|
|
236
|
+
// Nothing to move to: leave the key alone rather than swallowing it, so
|
|
237
|
+
// ArrowUp in an empty composer with no history behaves like a textarea.
|
|
238
|
+
if (move.text === null) return;
|
|
239
|
+
|
|
240
|
+
event.preventDefault();
|
|
241
|
+
state.current = move.state;
|
|
242
|
+
// PREVIEW, never write. Walking must not touch the buffer: a full-buffer
|
|
243
|
+
// rewrite per press reflows the composer, so the line being read moves
|
|
244
|
+
// while it is read — and it makes "did recall eat what I was typing?"
|
|
245
|
+
// answerable by accident. Tab above is the only path into the draft.
|
|
246
|
+
//
|
|
247
|
+
// Landing back on the draft rung ends the walk: the ladder returns
|
|
248
|
+
// `stashedDraft`, which is the user's own text, and previewing that would
|
|
249
|
+
// offer to insert what is already there.
|
|
250
|
+
// The queue's LAST entry is the only queued rung — `core/recall` says so,
|
|
251
|
+
// and this must read the same end or Tab would take a different prompt
|
|
252
|
+
// than the one previewed.
|
|
253
|
+
const { items: queuedNow } = latest.current;
|
|
254
|
+
previewQueuedId.current =
|
|
255
|
+
move.state.position.kind === "queued"
|
|
256
|
+
? (queuedNow[queuedNow.length - 1]?.id ?? null)
|
|
257
|
+
: null;
|
|
258
|
+
setPreview(move.state.position.kind === "draft" ? null : move.text);
|
|
259
|
+
},
|
|
260
|
+
[input, setText, take, events],
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
return { onKeyDown, preview, reset };
|
|
264
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
import { reloadDecision } from "../core/reload-decision";
|
|
4
|
+
import { useTurn } from "./hooks";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Re-read the room when something outside this tab changed it.
|
|
8
|
+
*
|
|
9
|
+
* The caller supplies a REVISION — a number that moves when the room moved —
|
|
10
|
+
* and a `reload` that re-fetches history. `reloadDecision` owns the rule; this
|
|
11
|
+
* owns the refs, because the decision needs values a render cannot hold: what
|
|
12
|
+
* revision was last acted on, and whether a change is owed.
|
|
13
|
+
*
|
|
14
|
+
* A number rather than the events themselves, because the package must not
|
|
15
|
+
* depend on the host's bus or its stores. What "the room moved" MEANS is the
|
|
16
|
+
* host's to decide — a server hint, a reconnect, a manual refresh — and all this
|
|
17
|
+
* needs is that the number changed.
|
|
18
|
+
*
|
|
19
|
+
* `room` is optional and identifies WHICH room the revision counts for. A host
|
|
20
|
+
* that mounts this hook once per room can omit it; one that keeps a single
|
|
21
|
+
* mount across rooms must pass it, or two rooms' counters are compared as if
|
|
22
|
+
* they were one room's.
|
|
23
|
+
*/
|
|
24
|
+
export function useReloadOnRevision(
|
|
25
|
+
revision: number,
|
|
26
|
+
reload: () => void,
|
|
27
|
+
room?: string,
|
|
28
|
+
): void {
|
|
29
|
+
const { isStreaming } = useTurn();
|
|
30
|
+
// Seeded with the opening revision: a room has just read its history, so the
|
|
31
|
+
// first value is not a change.
|
|
32
|
+
const seen = useRef(revision);
|
|
33
|
+
const deferred = useRef(false);
|
|
34
|
+
// Which room `seen` describes. Revisions are per-room counters, so comparing
|
|
35
|
+
// one room's against another's is meaningless — and not merely noise: a room
|
|
36
|
+
// left at 3 followed by a fresh room reading 0 counts as a change and fires a
|
|
37
|
+
// reload over the history that room just fetched. That used to be impossible
|
|
38
|
+
// because the host remounted this hook per room; it is possible now, so the
|
|
39
|
+
// re-seed has to be explicit.
|
|
40
|
+
const seenRoom = useRef(room);
|
|
41
|
+
if (seenRoom.current !== room) {
|
|
42
|
+
seenRoom.current = room;
|
|
43
|
+
seen.current = revision;
|
|
44
|
+
deferred.current = false;
|
|
45
|
+
}
|
|
46
|
+
// Read through a ref so a caller may pass an inline arrow without re-arming
|
|
47
|
+
// the effect on every render.
|
|
48
|
+
const reloadRef = useRef(reload);
|
|
49
|
+
reloadRef.current = reload;
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const decision = reloadDecision({
|
|
53
|
+
revision,
|
|
54
|
+
seen: seen.current,
|
|
55
|
+
streaming: isStreaming,
|
|
56
|
+
deferred: deferred.current,
|
|
57
|
+
});
|
|
58
|
+
seen.current = revision;
|
|
59
|
+
if (decision === "ignore") return;
|
|
60
|
+
if (decision === "defer") {
|
|
61
|
+
deferred.current = true;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
deferred.current = false;
|
|
65
|
+
reloadRef.current();
|
|
66
|
+
}, [revision, isStreaming]);
|
|
67
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import { resolveLanguage } from "../core";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Which language dictation listens for, and how it is remembered.
|
|
7
|
+
*
|
|
8
|
+
* Global, not per-conversation: a language is a property of the SPEAKER, not of
|
|
9
|
+
* the chat they are in. The archive stored it the same way.
|
|
10
|
+
*/
|
|
11
|
+
const STORAGE_KEY = "djc.chat.speech-language";
|
|
12
|
+
|
|
13
|
+
export interface UseSpeechLanguageResult {
|
|
14
|
+
/** The BCP-47 tag in force. Never empty. */
|
|
15
|
+
readonly language: string;
|
|
16
|
+
/** Whether the user chose it, as opposed to it being resolved for them. */
|
|
17
|
+
readonly explicit: boolean;
|
|
18
|
+
readonly setLanguage: (tag: string) => void;
|
|
19
|
+
/** Forget the pick and go back to resolving. */
|
|
20
|
+
readonly clear: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SpeechLanguageOptions {
|
|
24
|
+
/**
|
|
25
|
+
* The host UI's locale, as an ISO subtag.
|
|
26
|
+
*
|
|
27
|
+
* A TIE-BREAKER, never the source: it is consulted only when the browser also
|
|
28
|
+
* lists that language as spoken. Pinning speech to the app locale is a known
|
|
29
|
+
* regression — an English UI then cannot dictate Russian.
|
|
30
|
+
*/
|
|
31
|
+
readonly uiLocale?: string | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function read(): string | undefined {
|
|
35
|
+
// Guarded: a package that assumes `localStorage` breaks in SSR and in a
|
|
36
|
+
// sandboxed iframe, both of which throw rather than returning null.
|
|
37
|
+
try {
|
|
38
|
+
return globalThis.localStorage?.getItem(STORAGE_KEY) ?? undefined;
|
|
39
|
+
} catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function write(tag: string | null): void {
|
|
45
|
+
try {
|
|
46
|
+
if (tag === null) globalThis.localStorage?.removeItem(STORAGE_KEY);
|
|
47
|
+
else globalThis.localStorage?.setItem(STORAGE_KEY, tag);
|
|
48
|
+
} catch {
|
|
49
|
+
// A pick that cannot be persisted still applies to this session. Failing
|
|
50
|
+
// the change because storage is unavailable would be worse than forgetting.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function useSpeechLanguage(options: SpeechLanguageOptions = {}): UseSpeechLanguageResult {
|
|
55
|
+
const [picked, setPicked] = useState<string | undefined>(read);
|
|
56
|
+
|
|
57
|
+
const language = useMemo(
|
|
58
|
+
() =>
|
|
59
|
+
resolveLanguage({
|
|
60
|
+
picked,
|
|
61
|
+
uiLocale: options.uiLocale,
|
|
62
|
+
// Read here rather than in `resolveLanguage`, which stays pure so the
|
|
63
|
+
// priority order can be tested without faking globals.
|
|
64
|
+
browserLanguages: typeof navigator === "undefined" ? undefined : navigator.languages,
|
|
65
|
+
browserLanguage: typeof navigator === "undefined" ? undefined : navigator.language,
|
|
66
|
+
}),
|
|
67
|
+
[picked, options.uiLocale],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const setLanguage = useCallback((tag: string) => {
|
|
71
|
+
write(tag);
|
|
72
|
+
setPicked(tag);
|
|
73
|
+
}, []);
|
|
74
|
+
|
|
75
|
+
const clear = useCallback(() => {
|
|
76
|
+
write(null);
|
|
77
|
+
setPicked(undefined);
|
|
78
|
+
}, []);
|
|
79
|
+
|
|
80
|
+
return useMemo(
|
|
81
|
+
() => ({ language, explicit: picked !== undefined, setLanguage, clear }),
|
|
82
|
+
[language, picked, setLanguage, clear],
|
|
83
|
+
);
|
|
84
|
+
}
|