@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,111 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ⌘A selects the whole conversation instead of the whole page.
|
|
5
|
+
*
|
|
6
|
+
* The browser default selects the document, which here means the rail, the
|
|
7
|
+
* status bar and the composer alongside the turns — so the copy that follows is
|
|
8
|
+
* unusable and the user trims it by hand.
|
|
9
|
+
*
|
|
10
|
+
* Scoped to the TRANSCRIPT, and only when the caret is not in an editable:
|
|
11
|
+
* inside the composer ⌘A means "select my draft", which is the more specific
|
|
12
|
+
* intent and must win. `useHotkey`'s default policy would take it, because a
|
|
13
|
+
* modifier combo defaults to firing in inputs — hence the explicit `inInput`
|
|
14
|
+
* at the call site.
|
|
15
|
+
*
|
|
16
|
+
* The selection is built over the transcript's message NODES rather than the
|
|
17
|
+
* scroller, so the gaps between turns stay out of it. Those gaps are
|
|
18
|
+
* `select-none` on purpose (see `../ui/message.tsx`): including
|
|
19
|
+
* them fuses two speakers into one block on copy, which is exactly what this
|
|
20
|
+
* feature exists to avoid.
|
|
21
|
+
*
|
|
22
|
+
* It selects what is LOADED, which is the transcript the user is looking at.
|
|
23
|
+
* Fetching older turns first would make a keystroke perform a network read with
|
|
24
|
+
* no way to show progress.
|
|
25
|
+
*
|
|
26
|
+
* Nothing is written to the clipboard: ⌘A selects, and the ⌘C that follows is
|
|
27
|
+
* the user's own gesture.
|
|
28
|
+
*/
|
|
29
|
+
export function selectTranscript(root: HTMLElement | null): boolean {
|
|
30
|
+
if (!root) return false;
|
|
31
|
+
const items = root.querySelectorAll("[data-chat-item]");
|
|
32
|
+
const first = items[0];
|
|
33
|
+
const last = items[items.length - 1];
|
|
34
|
+
if (!first || !last) return false;
|
|
35
|
+
|
|
36
|
+
const selection = window.getSelection();
|
|
37
|
+
if (!selection) return false;
|
|
38
|
+
|
|
39
|
+
const range = document.createRange();
|
|
40
|
+
range.setStartBefore(first);
|
|
41
|
+
range.setEndAfter(last);
|
|
42
|
+
selection.removeAllRanges();
|
|
43
|
+
selection.addRange(range);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** True while the caret sits somewhere the user is typing. */
|
|
48
|
+
export function isEditableTarget(node: EventTarget | null): boolean {
|
|
49
|
+
return (
|
|
50
|
+
node instanceof HTMLElement &&
|
|
51
|
+
(node.isContentEditable ||
|
|
52
|
+
node instanceof HTMLInputElement ||
|
|
53
|
+
node instanceof HTMLTextAreaElement)
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Is this the keystroke, and is it ours to take?
|
|
59
|
+
*
|
|
60
|
+
* Split out so the decision is testable without a DOM: it reads only the
|
|
61
|
+
* modifier flags and whether the target is editable, and every way this
|
|
62
|
+
* feature can misbehave — eating the composer's ⌘A, firing on ⌘⇧A — is
|
|
63
|
+
* decided here.
|
|
64
|
+
*/
|
|
65
|
+
export function claimsSelectAll(event: {
|
|
66
|
+
readonly key: string;
|
|
67
|
+
readonly metaKey: boolean;
|
|
68
|
+
readonly ctrlKey: boolean;
|
|
69
|
+
readonly altKey: boolean;
|
|
70
|
+
readonly shiftKey: boolean;
|
|
71
|
+
readonly editableTarget: boolean;
|
|
72
|
+
}): boolean {
|
|
73
|
+
if (event.key !== "a" || !(event.metaKey || event.ctrlKey)) return false;
|
|
74
|
+
// ⌘⌥A and ⌘⇧A are other commands, not a louder select-all.
|
|
75
|
+
if (event.altKey || event.shiftKey) return false;
|
|
76
|
+
// The composer's own ⌘A is the more specific intent and wins.
|
|
77
|
+
return !event.editableTarget;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Bind ⌘A to {@link selectTranscript} for as long as the surface is mounted.
|
|
82
|
+
*
|
|
83
|
+
* A plain listener rather than `useHotkey`: this must inspect the EVENT TARGET
|
|
84
|
+
* to leave the composer alone, and it must call `preventDefault` only when it
|
|
85
|
+
* actually took the selection — a conditional both of which the wrapper's
|
|
86
|
+
* declarative policy decides up front, before the target is known.
|
|
87
|
+
*/
|
|
88
|
+
export function useSelectAllTranscript(
|
|
89
|
+
surface: () => HTMLElement | null,
|
|
90
|
+
enabled = true,
|
|
91
|
+
): void {
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!enabled) return;
|
|
94
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
95
|
+
const claim = claimsSelectAll({
|
|
96
|
+
key: event.key,
|
|
97
|
+
metaKey: event.metaKey,
|
|
98
|
+
ctrlKey: event.ctrlKey,
|
|
99
|
+
altKey: event.altKey,
|
|
100
|
+
shiftKey: event.shiftKey,
|
|
101
|
+
editableTarget: isEditableTarget(event.target),
|
|
102
|
+
});
|
|
103
|
+
if (!claim) return;
|
|
104
|
+
// Only claim the key once the selection succeeded, so a surface with no
|
|
105
|
+
// transcript still gets the browser's own select-all.
|
|
106
|
+
if (selectTranscript(surface())) event.preventDefault();
|
|
107
|
+
};
|
|
108
|
+
document.addEventListener("keydown", onKeyDown);
|
|
109
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
110
|
+
}, [surface, enabled]);
|
|
111
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Following the bottom, delegated to `use-stick-to-bottom`.
|
|
3
|
+
*
|
|
4
|
+
* This was ours, twice, and both versions had the same class of defect: an
|
|
5
|
+
* INSTANT write to `scrollTop` on every growth. A hard fling landing on the
|
|
6
|
+
* bottom edge then visibly shook — measured at 5–12 direction reversals with an
|
|
7
|
+
* 8–23px swing — because the write, the engine's own settle and the resulting
|
|
8
|
+
* scroll event chase each other inside one frame. Neither a tolerance around
|
|
9
|
+
* the target nor rounding the box's fractional height fixed it; a write-trap
|
|
10
|
+
* proved the hook was not even the one writing during the shake.
|
|
11
|
+
*
|
|
12
|
+
* The library answers it structurally rather than by tuning: it animates toward
|
|
13
|
+
* the bottom with a damped spring, so there is no single frame that jumps, and
|
|
14
|
+
* it remembers the position it last wrote (`ignoreScrollToTop`) so its own echo
|
|
15
|
+
* is never mistaken for the reader scrolling. It also suspends following while
|
|
16
|
+
* a text selection is in progress, which no amount of gesture-watching here
|
|
17
|
+
* would have covered.
|
|
18
|
+
*
|
|
19
|
+
* What stays ours is the SHAPE of the return value: `Transcript` consumes
|
|
20
|
+
* `{ ref, onScroll, isPinned, scrollToBottom }`, and keeping that means the
|
|
21
|
+
* primitive did not have to learn about a second element.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { useCallback, type UIEvent } from "react";
|
|
25
|
+
import { useStickToBottom as useLibraryStickToBottom } from "use-stick-to-bottom";
|
|
26
|
+
|
|
27
|
+
export interface StickToBottom {
|
|
28
|
+
/** The SCROLLER. */
|
|
29
|
+
readonly ref: (node: HTMLDivElement | null) => void;
|
|
30
|
+
/** The element wrapping the rows, which is what actually changes height. */
|
|
31
|
+
readonly contentRef: (node: HTMLDivElement | null) => void;
|
|
32
|
+
readonly onScroll: (event: UIEvent<HTMLDivElement>) => void;
|
|
33
|
+
/** False while the user is reading history — render a "jump to latest" control. */
|
|
34
|
+
readonly isPinned: boolean;
|
|
35
|
+
readonly scrollToBottom: () => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param revision unused, and kept only so callers need not change. The library
|
|
40
|
+
* watches the content element with a ResizeObserver, which sees streamed text
|
|
41
|
+
* grow a row as readily as it sees a row appear — the count was always a
|
|
42
|
+
* proxy for exactly that.
|
|
43
|
+
*/
|
|
44
|
+
export function useStickToBottom(_revision: number): StickToBottom {
|
|
45
|
+
const instance = useLibraryStickToBottom({
|
|
46
|
+
// Growth is ANIMATED. `instant` here is what produced the shake: it lands
|
|
47
|
+
// the view on the target in one frame, so the engine's own settle reads as
|
|
48
|
+
// a bounce rather than as part of a motion.
|
|
49
|
+
resize: "smooth",
|
|
50
|
+
// Arrival is INSTANT, and the asymmetry is the point. Opening a room is not
|
|
51
|
+
// motion the reader is following — there is nothing on screen yet to track —
|
|
52
|
+
// so animating it scrolls a transcript they never asked to watch, every
|
|
53
|
+
// single time they switch. The bottom is simply where the room starts.
|
|
54
|
+
initial: "instant",
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const { scrollRef, contentRef, isAtBottom, scrollToBottom } = instance;
|
|
58
|
+
|
|
59
|
+
const ref = useCallback(
|
|
60
|
+
(node: HTMLDivElement | null) => {
|
|
61
|
+
scrollRef(node);
|
|
62
|
+
},
|
|
63
|
+
[scrollRef],
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const content = useCallback(
|
|
67
|
+
(node: HTMLDivElement | null) => {
|
|
68
|
+
contentRef(node);
|
|
69
|
+
},
|
|
70
|
+
[contentRef],
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// The library listens on the element itself, so nothing has to happen here.
|
|
74
|
+
// Kept because `Transcript` passes it to the scroller and a missing handler
|
|
75
|
+
// would be a silent behaviour change rather than a type error.
|
|
76
|
+
const onScroll = useCallback(() => {}, []);
|
|
77
|
+
|
|
78
|
+
const jump = useCallback(() => {
|
|
79
|
+
void scrollToBottom();
|
|
80
|
+
}, [scrollToBottom]);
|
|
81
|
+
|
|
82
|
+
return { ref, contentRef: content, onScroll, isPinned: isAtBottom, scrollToBottom: jump };
|
|
83
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useTurn } from "../store";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `waiting` — sent, nothing back yet. `working` — text is arriving.
|
|
5
|
+
* `null` — no turn in flight, so nothing to mark.
|
|
6
|
+
*/
|
|
7
|
+
export type TurnActivity = "waiting" | "working" | null;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The live turn's activity, in one place.
|
|
11
|
+
*
|
|
12
|
+
* Derived from `useTurn`, never stored: the two flags it reads are themselves
|
|
13
|
+
* derived from the transcript, and a third copy could disagree with both about
|
|
14
|
+
* whether an answer has started.
|
|
15
|
+
*
|
|
16
|
+
* It exists so the trailing indicator has ONE owner. The alternative — each
|
|
17
|
+
* message row deciding from its own `status` — puts the answer in as many places
|
|
18
|
+
* as there are messages, and every settled row then answers "yes, I finished",
|
|
19
|
+
* which is how a marker ends up stacked once per reply on a single screen.
|
|
20
|
+
*/
|
|
21
|
+
export function useTurnActivity(): TurnActivity {
|
|
22
|
+
const { isStreaming, isPending } = useTurn();
|
|
23
|
+
if (!isStreaming) return null;
|
|
24
|
+
return isPending ? "waiting" : "working";
|
|
25
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single context.
|
|
3
|
+
*
|
|
4
|
+
* Capabilities ride here rather than being threaded as props, which is what
|
|
5
|
+
* lets `<Chat>` stay at seven props while a composer three levels down still
|
|
6
|
+
* knows which features exist. One context, not one per feature: a context per
|
|
7
|
+
* capability would put the count back on the same growth curve the props were
|
|
8
|
+
* on.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createContext } from "react";
|
|
12
|
+
|
|
13
|
+
import type { ChatCapabilities } from "../core/capabilities";
|
|
14
|
+
import type { ChatEvents } from "../core/events";
|
|
15
|
+
import type { ChatStore } from "./store";
|
|
16
|
+
|
|
17
|
+
export interface ChatContextValue {
|
|
18
|
+
readonly store: ChatStore;
|
|
19
|
+
readonly capabilities: ChatCapabilities;
|
|
20
|
+
/**
|
|
21
|
+
* Announcements, as opposed to state. The store answers "what is true now";
|
|
22
|
+
* this says "this just happened" — which a subscriber cannot reconstruct from
|
|
23
|
+
* a snapshot, because two identical states can be reached by different acts.
|
|
24
|
+
*/
|
|
25
|
+
readonly events: ChatEvents;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const ChatContext = createContext<ChatContextValue | null>(null);
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React bindings for the store.
|
|
3
|
+
*
|
|
4
|
+
* The split is deliberate and is the whole performance story: the transcript
|
|
5
|
+
* subscribes to `useMessageIds()` and each row to `useMessage(id)`, so a token
|
|
6
|
+
* arriving in the last message re-renders one row rather than the list. MUI X
|
|
7
|
+
* settled on the same pair, and a single `useMessages()` returning the array
|
|
8
|
+
* cannot be optimised into it afterwards — every consumer would have to change.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useCallback, useContext, useMemo, useSyncExternalStore } from "react";
|
|
12
|
+
|
|
13
|
+
import type { ChatCapabilities } from "../core/capabilities";
|
|
14
|
+
import type { ChatEvents } from "../core/events";
|
|
15
|
+
import type { ChatMessage } from "../core/parts";
|
|
16
|
+
import { ChatContext, type ChatContextValue } from "./context";
|
|
17
|
+
import type { ChatState, ChatStore } from "./store";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Has the in-flight reply produced anything a reader can SEE yet?
|
|
21
|
+
*
|
|
22
|
+
* Two thresholds were tried and both were too early, each by one step:
|
|
23
|
+
*
|
|
24
|
+
* - `streamingId !== null` flips on `message-start`, which opens an EMPTY
|
|
25
|
+
* message;
|
|
26
|
+
* - a non-empty `parts` array flips on `text-start`, which appends a part
|
|
27
|
+
* whose text is `""`.
|
|
28
|
+
*
|
|
29
|
+
* Both leave a measurable silent gap where the indicator is gone and the reply
|
|
30
|
+
* has not appeared — the exact state it exists to prevent. So the question is
|
|
31
|
+
* asked of the CONTENT: a text or reasoning part counts once it has characters,
|
|
32
|
+
* and any other part counts as soon as it exists, because a tool call renders
|
|
33
|
+
* its own header immediately.
|
|
34
|
+
*
|
|
35
|
+
* Returns a boolean rather than the message: `useSyncExternalStore` compares by
|
|
36
|
+
* reference, and a selector minting an object per call re-renders forever.
|
|
37
|
+
*/
|
|
38
|
+
function hasRenderedPart(state: ChatState): boolean {
|
|
39
|
+
const id = state.transcript.streamingId;
|
|
40
|
+
if (id === null) return false;
|
|
41
|
+
const parts = state.transcript.byId[id]?.parts ?? [];
|
|
42
|
+
return parts.some((part) =>
|
|
43
|
+
part.type === "text" || part.type === "reasoning" ? part.text !== "" : true,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function useChatContext(): ChatContextValue {
|
|
48
|
+
const value = useContext(ChatContext);
|
|
49
|
+
if (value === null) {
|
|
50
|
+
throw new Error("chat hooks must be used inside <ChatProvider>");
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function useChatStore(): ChatStore {
|
|
56
|
+
return useChatContext().store;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What this chat can do.
|
|
61
|
+
*
|
|
62
|
+
* A component asks for the capability it needs and renders nothing when it is
|
|
63
|
+
* absent — that check IS the feature flag. There is no second boolean that can
|
|
64
|
+
* disagree with it.
|
|
65
|
+
*/
|
|
66
|
+
export function useCapabilities(): ChatCapabilities {
|
|
67
|
+
return useChatContext().capabilities;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The chat's event bus.
|
|
72
|
+
*
|
|
73
|
+
* Announcements, not state: subscribe in an effect, never read during render.
|
|
74
|
+
* What is TRUE comes from the store — a component that mirrors an event into
|
|
75
|
+
* its own state has made a second owner of a fact the store already holds.
|
|
76
|
+
*
|
|
77
|
+
* The identity is stable for the life of the provider, so an effect may depend
|
|
78
|
+
* on it without re-subscribing.
|
|
79
|
+
*/
|
|
80
|
+
export function useChatEvents(): ChatEvents {
|
|
81
|
+
return useChatContext().events;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Message ids in order.
|
|
86
|
+
*
|
|
87
|
+
* The identity of the array matters: `useSyncExternalStore` compares by
|
|
88
|
+
* reference, so returning a fresh array per call would re-render the transcript
|
|
89
|
+
* on every token. The reducer keeps `ids` stable unless a message is added, so
|
|
90
|
+
* reading it directly is both correct and cheap.
|
|
91
|
+
*/
|
|
92
|
+
export function useMessageIds(): readonly string[] {
|
|
93
|
+
const store = useChatStore();
|
|
94
|
+
return useSyncExternalStore(
|
|
95
|
+
store.subscribe,
|
|
96
|
+
useCallback(() => store.getState().transcript.ids, [store]),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Whether the host's history fetch has answered.
|
|
102
|
+
*
|
|
103
|
+
* `false` with an empty transcript is LOADING, not empty — the distinction the
|
|
104
|
+
* empty state needs and cannot make from the message list alone.
|
|
105
|
+
*/
|
|
106
|
+
export function useHistoryLoaded(): boolean {
|
|
107
|
+
const store = useChatStore();
|
|
108
|
+
return useSyncExternalStore(
|
|
109
|
+
store.subscribe,
|
|
110
|
+
useCallback(() => store.getState().historyLoaded, [store]),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** One message, re-rendering only when that message changes. */
|
|
115
|
+
export function useMessage(id: string): ChatMessage | undefined {
|
|
116
|
+
const store = useChatStore();
|
|
117
|
+
const subscribe = useCallback(
|
|
118
|
+
(listener: () => void) => store.subscribeMessage(id, listener),
|
|
119
|
+
[store, id],
|
|
120
|
+
);
|
|
121
|
+
return useSyncExternalStore(
|
|
122
|
+
subscribe,
|
|
123
|
+
useCallback(() => store.getState().transcript.byId[id], [store, id]),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* When the row BEFORE `id` was created, or `undefined` when it is the first.
|
|
129
|
+
*
|
|
130
|
+
* A number, not the message: a row needs its neighbour only to tell whether the
|
|
131
|
+
* calendar day changed, and subscribing to the whole message would re-render it
|
|
132
|
+
* on every token arriving in the row above.
|
|
133
|
+
*
|
|
134
|
+
* Read from the list rather than passed down, so the day heading survives the
|
|
135
|
+
* memo boundary that keeps a streaming transcript cheap.
|
|
136
|
+
*/
|
|
137
|
+
export function usePreviousMessageTime(id: string): number | undefined {
|
|
138
|
+
const store = useChatStore();
|
|
139
|
+
return useSyncExternalStore(
|
|
140
|
+
store.subscribe,
|
|
141
|
+
useCallback(() => {
|
|
142
|
+
const { ids, byId } = store.getState().transcript;
|
|
143
|
+
const index = ids.indexOf(id);
|
|
144
|
+
if (index <= 0) return undefined;
|
|
145
|
+
return byId[ids[index - 1] as string]?.createdAt;
|
|
146
|
+
}, [store, id]),
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Whether the row BEFORE `id` was spoken by the same author.
|
|
152
|
+
*
|
|
153
|
+
* A BOOLEAN, not the message, for the reason `usePreviousMessageTime` returns a
|
|
154
|
+
* number: a row needs its neighbour only to decide whether to repeat the name,
|
|
155
|
+
* and subscribing to the whole message would re-render it on every token
|
|
156
|
+
* arriving in the row above — the one cost the id/message split exists to avoid.
|
|
157
|
+
*
|
|
158
|
+
* False for the first row, and false whenever either side has no author: a
|
|
159
|
+
* system-authored line and a turn of yours both BREAK a run, so the next speaker
|
|
160
|
+
* is named again rather than reading as the previous one still talking.
|
|
161
|
+
*/
|
|
162
|
+
export function usePreviousAuthorMatches(id: string): boolean {
|
|
163
|
+
const store = useChatStore();
|
|
164
|
+
return useSyncExternalStore(
|
|
165
|
+
store.subscribe,
|
|
166
|
+
useCallback(() => {
|
|
167
|
+
const { ids, byId } = store.getState().transcript;
|
|
168
|
+
const index = ids.indexOf(id);
|
|
169
|
+
if (index <= 0) return false;
|
|
170
|
+
const mine = byId[id]?.authorId;
|
|
171
|
+
const before = byId[ids[index - 1] as string]?.authorId;
|
|
172
|
+
return mine !== undefined && mine === before;
|
|
173
|
+
}, [store, id]),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface TurnState {
|
|
178
|
+
readonly isStreaming: boolean;
|
|
179
|
+
/**
|
|
180
|
+
* A turn is in flight and has produced NOTHING yet.
|
|
181
|
+
*
|
|
182
|
+
* Derived, never stored: a turn is in flight and the reply holds no part yet.
|
|
183
|
+
* A stored flag would be a second source of truth that can disagree with the
|
|
184
|
+
* transcript about whether the answer has started.
|
|
185
|
+
*
|
|
186
|
+
* The threshold is the first PART, not the opening of the message — see
|
|
187
|
+
* `hasRenderedPart`.
|
|
188
|
+
*
|
|
189
|
+
* The gap it names is the common case here, not an edge one — an agent may
|
|
190
|
+
* read files or start a subagent for many seconds before its first token, and
|
|
191
|
+
* during that time the transcript is otherwise silent.
|
|
192
|
+
*/
|
|
193
|
+
readonly isPending: boolean;
|
|
194
|
+
readonly stop: () => void;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function useTurn(): TurnState {
|
|
198
|
+
const store = useChatStore();
|
|
199
|
+
const inFlight = useSyncExternalStore(
|
|
200
|
+
store.subscribe,
|
|
201
|
+
useCallback(() => store.getState().inFlight, [store]),
|
|
202
|
+
);
|
|
203
|
+
const rendered = useSyncExternalStore(
|
|
204
|
+
store.subscribe,
|
|
205
|
+
useCallback(() => hasRenderedPart(store.getState()), [store]),
|
|
206
|
+
);
|
|
207
|
+
return useMemo(
|
|
208
|
+
() => ({
|
|
209
|
+
isStreaming: inFlight !== null,
|
|
210
|
+
isPending: inFlight !== null && !rendered,
|
|
211
|
+
stop: store.stop,
|
|
212
|
+
}),
|
|
213
|
+
[inFlight, rendered, store.stop],
|
|
214
|
+
);
|
|
215
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export { createChatStore } from "./store";
|
|
2
|
+
export type { ChatState, ChatStore, StoreOptions } from "./store";
|
|
3
|
+
export { ChatContext } from "./context";
|
|
4
|
+
export type { ChatContextValue } from "./context";
|
|
5
|
+
export { ChatProvider } from "./provider";
|
|
6
|
+
export type { ChatProviderProps } from "./provider";
|
|
7
|
+
export { ChatSurfacesContext, useChatSurfaces, useSurfacesRegistry } from "./surfaces";
|
|
8
|
+
export { useReloadOnRevision } from "./use-reload-on-revision";
|
|
9
|
+
export type { ChatSurfaces } from "./surfaces";
|
|
10
|
+
export {
|
|
11
|
+
useCapabilities,
|
|
12
|
+
useChatEvents,
|
|
13
|
+
useChatStore,
|
|
14
|
+
useHistoryLoaded,
|
|
15
|
+
useMessage,
|
|
16
|
+
useMessageIds,
|
|
17
|
+
usePreviousMessageTime,
|
|
18
|
+
usePreviousAuthorMatches,
|
|
19
|
+
useTurn,
|
|
20
|
+
} from "./hooks";
|
|
21
|
+
export type { TurnState } from "./hooks";
|
|
22
|
+
export { useDraft } from "./use-draft";
|
|
23
|
+
export type { DraftApi } from "./use-draft";
|
|
24
|
+
export { useQueue } from "./use-queue";
|
|
25
|
+
export type { UseQueueResult } from "./use-queue";
|
|
26
|
+
export { useVoice } from "./use-voice";
|
|
27
|
+
export type { UseVoiceResult } from "./use-voice";
|
|
28
|
+
export { useSpeechLanguage } from "./use-speech-language";
|
|
29
|
+
export type { UseSpeechLanguageResult, SpeechLanguageOptions } from "./use-speech-language";
|
|
30
|
+
export { useRecall } from "./use-recall";
|
|
31
|
+
export type { UseRecallOptions, UseRecallResult } from "./use-recall";
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { useMemo, useRef, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import type { ChatAdapter } from "../core/adapter";
|
|
4
|
+
import type { ChatCapabilities } from "../core/capabilities";
|
|
5
|
+
import { createChatEvents, type ChatEvents } from "../core/events";
|
|
6
|
+
import { ChatContext } from "./context";
|
|
7
|
+
import { createChatStore, type ChatStore } from "./store";
|
|
8
|
+
import { ChatSurfacesContext, useSurfacesRegistry } from "./surfaces";
|
|
9
|
+
|
|
10
|
+
export interface ChatProviderProps {
|
|
11
|
+
readonly adapter: ChatAdapter;
|
|
12
|
+
readonly capabilities?: ChatCapabilities;
|
|
13
|
+
/**
|
|
14
|
+
* Bring your own store to seed history or drive it from outside. Omit and one
|
|
15
|
+
* is created — the common path needs no lifecycle awareness at all.
|
|
16
|
+
*/
|
|
17
|
+
readonly store?: ChatStore;
|
|
18
|
+
readonly children: ReactNode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const noCapabilities: ChatCapabilities = {};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The escape hatch, and the seam every primitive resolves through.
|
|
25
|
+
*
|
|
26
|
+
* `<Chat>` renders this internally, so a host that outgrows the packaged layout
|
|
27
|
+
* drops to primitives without losing state wiring — the thing that usually
|
|
28
|
+
* forces a fork of the whole component.
|
|
29
|
+
*/
|
|
30
|
+
export function ChatProvider({
|
|
31
|
+
adapter,
|
|
32
|
+
capabilities = noCapabilities,
|
|
33
|
+
store,
|
|
34
|
+
children,
|
|
35
|
+
}: ChatProviderProps) {
|
|
36
|
+
// The store is created ONCE and never re-created from a prop change.
|
|
37
|
+
//
|
|
38
|
+
// A `useMemo` keyed on [adapter, capabilities, store] reads as harmless and
|
|
39
|
+
// is not: `capabilities` is an object literal at every realistic call site
|
|
40
|
+
// (`<ChatProvider capabilities={{ think: … }}>`), so it is a new
|
|
41
|
+
// reference on every parent render, and a new store means an EMPTY
|
|
42
|
+
// transcript. The conversation would vanish mid-turn, with no error and
|
|
43
|
+
// nothing in the stack pointing here.
|
|
44
|
+
//
|
|
45
|
+
// A ref, not a memo: `useMemo` is a performance hint React may discard and
|
|
46
|
+
// recompute at will, so it is not a place to put something whose identity is
|
|
47
|
+
// load-bearing. This is the documented distinction, not a style preference.
|
|
48
|
+
//
|
|
49
|
+
// `crypto.randomUUID` and `Date.now` are injected here, at the edge, so the
|
|
50
|
+
// core stays clock-free and testable. This is the only place they appear.
|
|
51
|
+
|
|
52
|
+
// The LIVE adapter, re-read on every send rather than captured once. A host
|
|
53
|
+
// re-binds its adapter when the room changes (one transport per room), so a
|
|
54
|
+
// store holding the first one addresses the room that was
|
|
55
|
+
// open at mount: the start screen's first message landed in the previous
|
|
56
|
+
// conversation instead of creating one. Re-creating the store for it is the
|
|
57
|
+
// wrong fix — that is the defect the paragraph above guards against.
|
|
58
|
+
const liveAdapter = useRef(adapter);
|
|
59
|
+
liveAdapter.current = adapter;
|
|
60
|
+
|
|
61
|
+
const ownStore = useRef<ChatStore | null>(null);
|
|
62
|
+
if (ownStore.current === null && store === undefined) {
|
|
63
|
+
ownStore.current = createChatStore({
|
|
64
|
+
// Indirection, not the prop: the store keeps this object for its lifetime,
|
|
65
|
+
// so reading the ref at call time is what lets the adapter change under a
|
|
66
|
+
// transcript that must survive the change.
|
|
67
|
+
adapter: { sendMessage: (request) => liveAdapter.current.sendMessage(request) },
|
|
68
|
+
makeId: () => crypto.randomUUID(),
|
|
69
|
+
now: () => Date.now(),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Per chat, and by ref for the same reason as the store: a listener that
|
|
74
|
+
// outlived a re-created bus would hold a subscription nothing ever emits to —
|
|
75
|
+
// silent, and indistinguishable from an event that simply did not happen.
|
|
76
|
+
const events = useRef<ChatEvents | null>(null);
|
|
77
|
+
events.current ??= createChatEvents();
|
|
78
|
+
|
|
79
|
+
// A caller that SWAPS its store prop means it: that is the one identity we
|
|
80
|
+
// must follow. Everything else is stable across renders.
|
|
81
|
+
const active = store ?? ownStore.current;
|
|
82
|
+
const value = useMemo(
|
|
83
|
+
() => ({ store: active as ChatStore, capabilities, events: events.current as ChatEvents }),
|
|
84
|
+
[active, capabilities],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// Where the surfaces are, for a host that must put the caret back or scope a
|
|
88
|
+
// selection. Provided here because it spans BOTH — the composer and the
|
|
89
|
+
// transcript — and a host asking is usually outside either.
|
|
90
|
+
const surfaces = useSurfacesRegistry();
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<ChatContext.Provider value={value}>
|
|
94
|
+
<ChatSurfacesContext.Provider value={surfaces}>{children}</ChatSurfacesContext.Provider>
|
|
95
|
+
</ChatContext.Provider>
|
|
96
|
+
);
|
|
97
|
+
}
|