@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,285 @@
|
|
|
1
|
+
import { memo, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useChatSurfaces,
|
|
5
|
+
useHistoryLoaded,
|
|
6
|
+
useMessage,
|
|
7
|
+
useMessageIds,
|
|
8
|
+
useTurn,
|
|
9
|
+
} from "../store";
|
|
10
|
+
import { useMissedCount } from "./use-missed-count";
|
|
11
|
+
import { useReturnOnSend } from "./use-return-on-send";
|
|
12
|
+
import { useRovingFocus } from "./use-roving-focus";
|
|
13
|
+
import { useStickToBottom } from "./use-stick-to-bottom";
|
|
14
|
+
import { useTurnActivity, type TurnActivity } from "./use-turn-activity";
|
|
15
|
+
|
|
16
|
+
export interface TranscriptProps {
|
|
17
|
+
/** Render one message. Called with the id; the row reads the message itself. */
|
|
18
|
+
readonly children: (id: string) => ReactNode;
|
|
19
|
+
/** Shown when there are no messages — an empty transcript is a real state. */
|
|
20
|
+
readonly empty?: ReactNode;
|
|
21
|
+
readonly className?: string;
|
|
22
|
+
/**
|
|
23
|
+
* The accessible name of the log. Copy is the host's — no locales here.
|
|
24
|
+
*
|
|
25
|
+
* Without it `role="log"` is an unnamed landmark: a screen reader user moving
|
|
26
|
+
* between regions hears "log" with nothing to distinguish it.
|
|
27
|
+
*/
|
|
28
|
+
readonly label?: string;
|
|
29
|
+
/**
|
|
30
|
+
* What to say when a reply starts and when it ends — the ONLY two strings a
|
|
31
|
+
* screen reader hears per turn.
|
|
32
|
+
*
|
|
33
|
+
* Absent means no announcements at all, which is the correct default for a
|
|
34
|
+
* host that already announces elsewhere: a second live region would make
|
|
35
|
+
* every reply be read twice.
|
|
36
|
+
*/
|
|
37
|
+
readonly announce?: {
|
|
38
|
+
readonly streaming: string;
|
|
39
|
+
readonly complete: string;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Rendered when the user has scrolled away from the bottom, with a callback
|
|
43
|
+
* that returns them to it.
|
|
44
|
+
*
|
|
45
|
+
* A render prop rather than a built-in button, because the control is styled
|
|
46
|
+
* and worded by the host — and because a transcript that decides its own
|
|
47
|
+
* chrome is how a primitive turns back into 83 props.
|
|
48
|
+
*
|
|
49
|
+
* `missed` is how many replies arrived since the reader left, or **null when
|
|
50
|
+
* there is nothing honest to show** — a fresh unpin, or a conversation opened
|
|
51
|
+
* already scrolled up. Null and 0 are different states; only a real arrival
|
|
52
|
+
* deserves a number, so a host must render no badge rather than an empty one.
|
|
53
|
+
*/
|
|
54
|
+
readonly jumpToLatest?: (props: {
|
|
55
|
+
readonly onClick: () => void;
|
|
56
|
+
readonly missed: number | null;
|
|
57
|
+
}) => ReactNode;
|
|
58
|
+
/**
|
|
59
|
+
* Rendered after the last row for as long as a turn is in flight, given what
|
|
60
|
+
* that turn is currently doing.
|
|
61
|
+
*
|
|
62
|
+
* `waiting` — sent, nothing back yet. `working` — text is arriving. It ends at
|
|
63
|
+
* turn-end, so a host can carry one indicator across both phases instead of
|
|
64
|
+
* showing something during the silence and nothing during the answer.
|
|
65
|
+
*
|
|
66
|
+
* In the transcript rather than beside the composer because that is where the
|
|
67
|
+
* answer will appear, so the promise of one belongs there too. It also rides
|
|
68
|
+
* inside the scroll container, which is what makes the autoscroll treat it as
|
|
69
|
+
* new content and keep the view pinned to the bottom.
|
|
70
|
+
*
|
|
71
|
+
* Never a message: it has no id and never enters the store, so it cannot leak
|
|
72
|
+
* into stored history as a message nobody sent.
|
|
73
|
+
*
|
|
74
|
+
* ONE slot rather than one per phase, and ONE owner rather than a decision
|
|
75
|
+
* repeated in every row: a message that reports its own completion reports it
|
|
76
|
+
* forever, so the indicator stacks up once per reply on a single screen.
|
|
77
|
+
*
|
|
78
|
+
* A render prop, like `jumpToLatest`, because the copy and the chrome are the
|
|
79
|
+
* host's — this package carries no locales.
|
|
80
|
+
*/
|
|
81
|
+
readonly pending?: (activity: TurnActivity) => ReactNode;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The message list.
|
|
86
|
+
*
|
|
87
|
+
* Not virtualized, deliberately. Virtualization fights streaming (heights
|
|
88
|
+
* change on every token), autoscroll, and find-in-page, and the transcripts
|
|
89
|
+
* a chat room shows are hundreds of messages, not tens of thousands. The
|
|
90
|
+
* measurement comes first: if a real transcript stutters, that is when this
|
|
91
|
+
* decision gets revisited — `@rules/architecture.md`.
|
|
92
|
+
*/
|
|
93
|
+
export function Transcript({
|
|
94
|
+
children,
|
|
95
|
+
empty,
|
|
96
|
+
className,
|
|
97
|
+
label,
|
|
98
|
+
announce,
|
|
99
|
+
jumpToLatest,
|
|
100
|
+
pending,
|
|
101
|
+
}: TranscriptProps) {
|
|
102
|
+
const ids = useMessageIds();
|
|
103
|
+
const historyLoaded = useHistoryLoaded();
|
|
104
|
+
const activity = useTurnActivity();
|
|
105
|
+
const { ref, contentRef, onScroll, isPinned, scrollToBottom } = useStickToBottom(ids.length);
|
|
106
|
+
const missed = useMissedCount(ids, isPinned);
|
|
107
|
+
useReturnOnSend(ids, scrollToBottom);
|
|
108
|
+
const roving = useRovingFocus(ids.length);
|
|
109
|
+
const transition = useTurnAnnouncement(announce);
|
|
110
|
+
const waiting = activity !== null && pending !== undefined ? pending(activity) : null;
|
|
111
|
+
const surfaces = useChatSurfaces();
|
|
112
|
+
// Two owners of one node: the scroll machinery needs it, and a host needs it
|
|
113
|
+
// to scope a selection to the conversation. Both callbacks, so this composes
|
|
114
|
+
// without either learning about the other.
|
|
115
|
+
const attach = useCallback(
|
|
116
|
+
(node: HTMLDivElement | null) => {
|
|
117
|
+
ref(node);
|
|
118
|
+
surfaces?.registerTranscript(node);
|
|
119
|
+
},
|
|
120
|
+
[ref, surfaces],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// Two things suppress the empty state, both because it would be a LIE.
|
|
124
|
+
//
|
|
125
|
+
// `waiting`: a first turn sent into a fresh room is not an empty transcript,
|
|
126
|
+
// and "no messages yet" under a prompt the user just sent says the opposite
|
|
127
|
+
// of what happened.
|
|
128
|
+
//
|
|
129
|
+
// `historyLoaded`: an unanswered fetch is not an empty room. Without it a
|
|
130
|
+
// conversation with 90 replies opened on "no messages yet" for the duration
|
|
131
|
+
// of the load.
|
|
132
|
+
if (ids.length === 0 && empty !== undefined && waiting === null && historyLoaded) {
|
|
133
|
+
return <div className={className}>{empty}</div>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<>
|
|
138
|
+
{/*
|
|
139
|
+
Three elements: outer clips, scroller fills it, content grows.
|
|
140
|
+
|
|
141
|
+
Merging the outer two paints the scroller from its first frame, which is
|
|
142
|
+
at the TOP — the library cannot know the bottom before it measures. Cost
|
|
143
|
+
a ~33ms flash of old history on entering every room.
|
|
144
|
+
*/}
|
|
145
|
+
<div className={`grid overflow-hidden ${className ?? ""}`}>
|
|
146
|
+
<div
|
|
147
|
+
ref={attach}
|
|
148
|
+
onScroll={onScroll}
|
|
149
|
+
onKeyDown={roving.onKeyDown}
|
|
150
|
+
className="h-full overflow-y-auto"
|
|
151
|
+
// `role="log"` names what this is and implies `aria-relevant="additions"`
|
|
152
|
+
// — additions only, never text mutations.
|
|
153
|
+
//
|
|
154
|
+
// Never spell `aria-relevant="additions text"` here: `text` asks the
|
|
155
|
+
// screen reader to announce changes to text NODES inside the region,
|
|
156
|
+
// and a streamed reply rewrites its text node on every delta, so the
|
|
157
|
+
// whole answer is read out token by token. None of the surveyed
|
|
158
|
+
// libraries sets `aria-relevant` at all — the announcement belongs on a
|
|
159
|
+
// separate node, below.
|
|
160
|
+
role="log"
|
|
161
|
+
aria-label={label}
|
|
162
|
+
aria-live="polite"
|
|
163
|
+
>
|
|
164
|
+
{/*
|
|
165
|
+
The measured element, and the one the reading column now caps.
|
|
166
|
+
|
|
167
|
+
The library needs a child whose height IS the content's, separate from
|
|
168
|
+
the scroller whose height is the viewport's — that difference is what
|
|
169
|
+
it observes. `chat-column` moves here with it: the rule caps direct
|
|
170
|
+
children, and the rows are this element's children now.
|
|
171
|
+
*/}
|
|
172
|
+
{/* `pb-10` on the CONTENT, not the scroller: it must be part of the
|
|
173
|
+
measured height, or the follow stops a gap short of the composer.
|
|
174
|
+
The right inset keeps text off the scrollbar, which runs at the
|
|
175
|
+
pane's own edge; the host sizes it, so a phone can widen it to match
|
|
176
|
+
its other margins. */}
|
|
177
|
+
{/* The rhythm between messages is declared HERE, on the element that
|
|
178
|
+
owns the rows. Without it the only thing parting two rows is
|
|
179
|
+
whatever they happen to render: a speaker line is omitted for a
|
|
180
|
+
consecutive same-author turn, so those pairs sat flush while their
|
|
181
|
+
neighbours kept the line's 28px. Spacing that depends on optional
|
|
182
|
+
content is not spacing. */}
|
|
183
|
+
<div ref={contentRef} className="chat-column chat-inset-r flex flex-col gap-4 pt-4 pb-10">
|
|
184
|
+
{ids.map((id, index) => (
|
|
185
|
+
<div key={id} {...roving.itemProps(index)}>
|
|
186
|
+
<MessageRow id={id}>{children}</MessageRow>
|
|
187
|
+
</div>
|
|
188
|
+
))}
|
|
189
|
+
{waiting}
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
{/*
|
|
194
|
+
Anchored to the SCROLLER's bottom, not the chat's: the chat's last grid
|
|
195
|
+
row is the composer, so a control positioned against it lands on top of
|
|
196
|
+
the input. `sticky` inside the scroll container would ride the content;
|
|
197
|
+
this sits in a zero-height strip at the scroller's own lower edge.
|
|
198
|
+
|
|
199
|
+
`pointer-events-none` on the strip so it never eats a click aimed at the
|
|
200
|
+
last message; the control inside opts back in.
|
|
201
|
+
*/}
|
|
202
|
+
{!isPinned && jumpToLatest !== undefined && (
|
|
203
|
+
<div className="pointer-events-none relative h-0 [&>*]:pointer-events-auto">
|
|
204
|
+
{jumpToLatest({ onClick: scrollToBottom, missed })}
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
<TurnAnnouncer text={transition} />
|
|
208
|
+
</>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The two words a screen reader hears per turn.
|
|
214
|
+
*
|
|
215
|
+
* Only TRANSITIONS — never the streamed content — so this can never spam.
|
|
216
|
+
* `role="status"` is an implicit polite, atomic live region: atomic means the
|
|
217
|
+
* whole node is re-read on change, which is right for a one-line status and
|
|
218
|
+
* wrong for a transcript.
|
|
219
|
+
*
|
|
220
|
+
* Always rendered, even when empty: a live region created at the moment of the
|
|
221
|
+
* event is not announced, because assistive tech must have been observing the
|
|
222
|
+
* node beforehand.
|
|
223
|
+
*/
|
|
224
|
+
function TurnAnnouncer({ text }: { readonly text: string }) {
|
|
225
|
+
return (
|
|
226
|
+
<div
|
|
227
|
+
role="status"
|
|
228
|
+
style={{
|
|
229
|
+
position: "absolute",
|
|
230
|
+
width: 1,
|
|
231
|
+
height: 1,
|
|
232
|
+
margin: -1,
|
|
233
|
+
padding: 0,
|
|
234
|
+
overflow: "hidden",
|
|
235
|
+
// `clip` over `display:none` or `visibility:hidden`: those two remove
|
|
236
|
+
// the node from the accessibility tree entirely, taking the
|
|
237
|
+
// announcement with them.
|
|
238
|
+
clip: "rect(0 0 0 0)",
|
|
239
|
+
whiteSpace: "nowrap",
|
|
240
|
+
border: 0,
|
|
241
|
+
}}
|
|
242
|
+
>
|
|
243
|
+
{text}
|
|
244
|
+
</div>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The transition string for the current turn, or "" when there is nothing to say. */
|
|
249
|
+
function useTurnAnnouncement(announce: TranscriptProps["announce"]): string {
|
|
250
|
+
const { isStreaming } = useTurn();
|
|
251
|
+
const [text, setText] = useState("");
|
|
252
|
+
const wasStreaming = useRef(false);
|
|
253
|
+
|
|
254
|
+
useEffect(() => {
|
|
255
|
+
const was = wasStreaming.current;
|
|
256
|
+
wasStreaming.current = isStreaming;
|
|
257
|
+
// Only on a CHANGE. Re-running on every render would re-announce, and an
|
|
258
|
+
// atomic region re-reads its whole contents each time it is written.
|
|
259
|
+
if (announce === undefined || was === isStreaming) return;
|
|
260
|
+
// "complete" is not announced on mount — `was` starts false, so the first
|
|
261
|
+
// transition it can report is false→true.
|
|
262
|
+
setText(isStreaming ? announce.streaming : announce.complete);
|
|
263
|
+
}, [announce, isStreaming]);
|
|
264
|
+
|
|
265
|
+
return text;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
interface MessageRowProps {
|
|
269
|
+
readonly id: string;
|
|
270
|
+
readonly children: (id: string) => ReactNode;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* One row, memoized on its id.
|
|
275
|
+
*
|
|
276
|
+
* `useMessage(id)` subscribes to that message alone, so a token arriving in the
|
|
277
|
+
* last row re-renders one component. Without this pair the list re-renders per
|
|
278
|
+
* token and the cost grows with transcript length — the stutter that reads as
|
|
279
|
+
* "the chat is laggy" rather than as a rendering bug.
|
|
280
|
+
*/
|
|
281
|
+
const MessageRow = memo(function MessageRow({ id, children }: MessageRowProps) {
|
|
282
|
+
const message = useMessage(id);
|
|
283
|
+
if (message === undefined) return null;
|
|
284
|
+
return <>{children(id)}</>;
|
|
285
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { useEffect, useRef, type RefObject } from "react";
|
|
2
|
+
|
|
3
|
+
export interface UseDismissOptions {
|
|
4
|
+
/** Nothing is listened for while this is false — a closed surface costs nothing. */
|
|
5
|
+
readonly open: boolean;
|
|
6
|
+
readonly onDismiss: () => void;
|
|
7
|
+
/**
|
|
8
|
+
* Elements that must NOT count as outside.
|
|
9
|
+
*
|
|
10
|
+
* Plural because a popover has at least two: the panel and the trigger that
|
|
11
|
+
* opened it. Omitting the trigger makes a press on it dismiss and re-open in
|
|
12
|
+
* the same gesture, so the surface never closes by its own button.
|
|
13
|
+
*/
|
|
14
|
+
readonly refs: readonly RefObject<HTMLElement | null>[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The smallest shape the rules need. Structural, so a test needs no DOM. */
|
|
18
|
+
interface NodeLike {
|
|
19
|
+
contains(other: unknown): boolean;
|
|
20
|
+
}
|
|
21
|
+
interface KeyLike {
|
|
22
|
+
readonly key: string;
|
|
23
|
+
stopPropagation(): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Is this press outside every element that counts as inside?
|
|
28
|
+
*
|
|
29
|
+
* Extracted so the rule can be tested without a DOM: the workspace has no
|
|
30
|
+
* jsdom, and the alternative — a regex over the hook's source — cannot tell
|
|
31
|
+
* `contains` on two refs from `contains` on one, which is the exact bug that
|
|
32
|
+
* makes a trigger dismiss and reopen in a single gesture.
|
|
33
|
+
*/
|
|
34
|
+
export function pressIsOutside(
|
|
35
|
+
target: unknown,
|
|
36
|
+
refs: readonly { readonly current: NodeLike | null }[],
|
|
37
|
+
): boolean {
|
|
38
|
+
return !refs.some((ref) => ref.current?.contains(target) === true);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Escape dismisses, and STOPS THERE.
|
|
43
|
+
*
|
|
44
|
+
* The `stopPropagation` is the load-bearing half. Chat panes and dialogs close
|
|
45
|
+
* on Escape too, so without it one press closes the menu AND whatever hosts it
|
|
46
|
+
* — the menu appears to work while quietly taking the surface behind it down.
|
|
47
|
+
* Returns whether the key dismissed, so the caller stays a one-liner.
|
|
48
|
+
*/
|
|
49
|
+
export function escapeDismisses(event: KeyLike): boolean {
|
|
50
|
+
if (event.key !== "Escape") return false;
|
|
51
|
+
event.stopPropagation();
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Outside press and Escape close a floating surface.
|
|
57
|
+
*
|
|
58
|
+
* A primitive rather than a per-menu handler: this package carries NO popover
|
|
59
|
+
* dependency, so every surface that floats has to implement these two rules, and
|
|
60
|
+
* a hand-written one degrades quietly. That already happened once — the language
|
|
61
|
+
* picker shipped as a plain element that only its own trigger could close, which
|
|
62
|
+
* looks identical in a screenshot and behaves like a trap.
|
|
63
|
+
*
|
|
64
|
+
* Two details beyond the pure rules above:
|
|
65
|
+
*
|
|
66
|
+
* - **`pointerdown`, not `click`.** A click fires after the press has already
|
|
67
|
+
* moved focus and possibly unmounted what was under the pointer, so the
|
|
68
|
+
* dismissal lands a frame late and can be swallowed by a re-render.
|
|
69
|
+
* - **Capture phase.** A row inside the surface may `stopPropagation` for its
|
|
70
|
+
* own reasons; dismissal must not depend on other handlers being polite.
|
|
71
|
+
*/
|
|
72
|
+
export function useDismiss({ open, onDismiss, refs }: UseDismissOptions): void {
|
|
73
|
+
// Read in the listeners, so a changing callback or ref list never re-binds
|
|
74
|
+
// them mid-gesture — a re-bind between pointerdown and keydown drops events.
|
|
75
|
+
const dismiss = useRef(onDismiss);
|
|
76
|
+
dismiss.current = onDismiss;
|
|
77
|
+
const inside = useRef(refs);
|
|
78
|
+
inside.current = refs;
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!open) return undefined;
|
|
82
|
+
|
|
83
|
+
const onPointerDown = (event: PointerEvent): void => {
|
|
84
|
+
if (!pressIsOutside(event.target, inside.current)) return;
|
|
85
|
+
dismiss.current();
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const onKeyDown = (event: KeyboardEvent): void => {
|
|
89
|
+
if (!escapeDismisses(event)) return;
|
|
90
|
+
dismiss.current();
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
document.addEventListener("pointerdown", onPointerDown, true);
|
|
94
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
95
|
+
return () => {
|
|
96
|
+
document.removeEventListener("pointerdown", onPointerDown, true);
|
|
97
|
+
document.removeEventListener("keydown", onKeyDown, true);
|
|
98
|
+
};
|
|
99
|
+
}, [open]);
|
|
100
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useMemo, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
import { MissedCount, missedRows } from "../core/scroll";
|
|
4
|
+
import { useChatStore } from "../store";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* How many replies arrived while the reader was scrolled away.
|
|
8
|
+
*
|
|
9
|
+
* Reads roles imperatively off the store instead of subscribing to the message
|
|
10
|
+
* array. A `useMessages()` selector would re-render the transcript on every
|
|
11
|
+
* token — the exact cost the `useMessageIds()` / `useMessage(id)` split exists
|
|
12
|
+
* to avoid (`store/hooks.ts`). Here it is free: the count can only change when
|
|
13
|
+
* `ids` changes, and the transcript already re-renders then.
|
|
14
|
+
*
|
|
15
|
+
* @param ids the transcript's message ids, in order
|
|
16
|
+
* @param pinned whether the viewport is following the bottom
|
|
17
|
+
*/
|
|
18
|
+
export function useMissedCount(ids: readonly string[], pinned: boolean): number | null {
|
|
19
|
+
const store = useChatStore();
|
|
20
|
+
const counter = useRef<MissedCount | null>(null);
|
|
21
|
+
counter.current ??= new MissedCount();
|
|
22
|
+
|
|
23
|
+
return useMemo(() => {
|
|
24
|
+
const missed = counter.current;
|
|
25
|
+
if (missed === null) return null;
|
|
26
|
+
const byId = store.getState().transcript.byId;
|
|
27
|
+
missed.observe(pinned, missedRows(ids, byId));
|
|
28
|
+
return missed.value();
|
|
29
|
+
}, [store, ids, pinned]);
|
|
30
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does this element have more content than the height it is allowed?
|
|
3
|
+
*
|
|
4
|
+
* Answers the question by measuring the thing itself rather than by estimating
|
|
5
|
+
* from what is inside it. A message is an array of parts, not a string: an image
|
|
6
|
+
* or a table contributes almost no characters at a large height, and a code
|
|
7
|
+
* fence keeps its characters while losing its shape. Any character count is
|
|
8
|
+
* therefore a proxy for a proxy, and the element already knows the answer.
|
|
9
|
+
*
|
|
10
|
+
* Reports only. It never writes a height — the clamp is a CSS `max-height` the
|
|
11
|
+
* caller owns, which is what makes the measurement honest: while collapsed the
|
|
12
|
+
* element IS clipped, so `scrollHeight > clientHeight` is exactly the question.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useCallback, useLayoutEffect, useState, type RefObject } from "react";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param ref the clamped element — the one carrying `max-height`.
|
|
19
|
+
* @param expanded whether the clamp is currently lifted. Load-bearing: see the
|
|
20
|
+
* collapse-first note below.
|
|
21
|
+
* @param deps a value that changes when the content does, so a streamed message
|
|
22
|
+
* re-measures as it grows.
|
|
23
|
+
*/
|
|
24
|
+
export function useOverflows(
|
|
25
|
+
ref: RefObject<HTMLElement | null>,
|
|
26
|
+
expanded: boolean,
|
|
27
|
+
deps: unknown,
|
|
28
|
+
): boolean {
|
|
29
|
+
const [overflows, setOverflows] = useState(false);
|
|
30
|
+
|
|
31
|
+
const measure = useCallback(() => {
|
|
32
|
+
const node = ref.current;
|
|
33
|
+
if (node === null) return;
|
|
34
|
+
|
|
35
|
+
// Collapse first. `scrollHeight` and `clientHeight` are equal while the
|
|
36
|
+
// clamp is lifted, so measuring in the expanded state reports "fits" for
|
|
37
|
+
// every message and the control disappears the moment it is used. The
|
|
38
|
+
// composer's autosize carries the same ordering for the same reason.
|
|
39
|
+
//
|
|
40
|
+
// Removing the property rather than setting a value returns the element to
|
|
41
|
+
// whatever the stylesheet says, so this never has to know the budget.
|
|
42
|
+
const lifted = expanded;
|
|
43
|
+
const previous = node.style.maxHeight;
|
|
44
|
+
if (lifted) node.style.maxHeight = "";
|
|
45
|
+
|
|
46
|
+
setOverflows(node.scrollHeight > node.clientHeight);
|
|
47
|
+
|
|
48
|
+
if (lifted) node.style.maxHeight = previous;
|
|
49
|
+
}, [ref, expanded]);
|
|
50
|
+
|
|
51
|
+
// A layout effect, not an effect: the trigger must be present in the same
|
|
52
|
+
// paint as the clipped text, or a long message shows one frame with no way to
|
|
53
|
+
// open it.
|
|
54
|
+
useLayoutEffect(measure, [measure, deps]);
|
|
55
|
+
|
|
56
|
+
// Content can also change height without the deps changing — a font loads, an
|
|
57
|
+
// image decodes, the window narrows and the text rewraps. The element itself
|
|
58
|
+
// is the only reliable signal for those.
|
|
59
|
+
useLayoutEffect(() => {
|
|
60
|
+
const node = ref.current;
|
|
61
|
+
if (node === null) return;
|
|
62
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
63
|
+
const observer = new ResizeObserver(measure);
|
|
64
|
+
observer.observe(node);
|
|
65
|
+
return () => observer.disconnect();
|
|
66
|
+
}, [ref, measure]);
|
|
67
|
+
|
|
68
|
+
return overflows;
|
|
69
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sending returns the reader to the bottom.
|
|
3
|
+
*
|
|
4
|
+
* The React binding only; the decision is `core/scroll`'s `isOwnSend`, tested
|
|
5
|
+
* without a DOM. What is here is the one thing that cannot be pure — remembering
|
|
6
|
+
* the previous list across renders.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useEffect, useRef } from "react";
|
|
10
|
+
|
|
11
|
+
import { isOwnSend } from "../core/scroll";
|
|
12
|
+
import { useChatStore } from "../store";
|
|
13
|
+
|
|
14
|
+
export function useReturnOnSend(ids: readonly string[], scrollToBottom: () => void): void {
|
|
15
|
+
const store = useChatStore();
|
|
16
|
+
// A ref, not state: nothing renders from it, and it must be read and written
|
|
17
|
+
// inside the same effect pass.
|
|
18
|
+
const seen = useRef<readonly string[] | null>(null);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const previous = seen.current;
|
|
22
|
+
seen.current = ids;
|
|
23
|
+
// Read through the store rather than from a prop: the roles live beside the
|
|
24
|
+
// ids in one state, so passing them in would let a caller hand over a list
|
|
25
|
+
// and a role map that disagree.
|
|
26
|
+
const roleOf = (id: string) => store.getState().transcript.byId[id]?.role;
|
|
27
|
+
if (isOwnSend(previous, ids, roleOf)) scrollToBottom();
|
|
28
|
+
}, [ids, store, scrollToBottom]);
|
|
29
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DOM half of roving tabindex. The index arithmetic lives in
|
|
3
|
+
* `core/focus/roving.ts`, where it is tested.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately DOM-driven rather than list-driven: rows are rendered by the
|
|
6
|
+
* HOST, so this hook cannot know their shape. It reads the elements marked with
|
|
7
|
+
* `data-chat-item` at the moment a key is pressed, which also keeps it correct
|
|
8
|
+
* while the transcript streams.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useCallback, useState, type KeyboardEvent } from "react";
|
|
12
|
+
|
|
13
|
+
import { CHAT_ITEM_ATTR, clampIndex, isRovingKey, nextIndex } from "../core/focus";
|
|
14
|
+
|
|
15
|
+
export interface RovingFocus {
|
|
16
|
+
readonly onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
|
|
17
|
+
/** Spread onto each row. Only the active row is tabbable. */
|
|
18
|
+
readonly itemProps: (index: number) => {
|
|
19
|
+
readonly tabIndex: number;
|
|
20
|
+
readonly [CHAT_ITEM_ATTR]: string;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param count how many rows there are. Needed because messages arrive while
|
|
26
|
+
* the user reads: an active index that outlives its row would leave EVERY row
|
|
27
|
+
* at `tabIndex={-1}`, so the transcript stops being reachable by Tab and
|
|
28
|
+
* nothing about it looks broken.
|
|
29
|
+
*/
|
|
30
|
+
export function useRovingFocus(count: number): RovingFocus {
|
|
31
|
+
// State, not a ref: `tabIndex` is rendered output, so moving the active row
|
|
32
|
+
// has to re-render the rows. A ref would move focus once and leave every
|
|
33
|
+
// row's tabIndex frozen at its initial value.
|
|
34
|
+
const [active, setActive] = useState(0);
|
|
35
|
+
const current = clampIndex(active, count);
|
|
36
|
+
|
|
37
|
+
const onKeyDown = useCallback(
|
|
38
|
+
(event: KeyboardEvent<HTMLElement>) => {
|
|
39
|
+
if (!isRovingKey(event.key)) return;
|
|
40
|
+
|
|
41
|
+
const items = Array.from(
|
|
42
|
+
event.currentTarget.querySelectorAll<HTMLElement>(`[${CHAT_ITEM_ATTR}]`),
|
|
43
|
+
);
|
|
44
|
+
const next = nextIndex(event.key, clampIndex(active, items.length), items.length);
|
|
45
|
+
// `null` means the key moves nowhere — at the last row, ArrowDown must
|
|
46
|
+
// still scroll the PAGE, so it is left to the browser rather than
|
|
47
|
+
// swallowed by a preventDefault that changes nothing.
|
|
48
|
+
if (next === null) return;
|
|
49
|
+
|
|
50
|
+
const target = items[next];
|
|
51
|
+
if (target === undefined) return;
|
|
52
|
+
|
|
53
|
+
event.preventDefault();
|
|
54
|
+
setActive(next);
|
|
55
|
+
target.focus();
|
|
56
|
+
},
|
|
57
|
+
[active],
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const itemProps = useCallback(
|
|
61
|
+
(index: number) => ({
|
|
62
|
+
tabIndex: index === current ? 0 : -1,
|
|
63
|
+
[CHAT_ITEM_ATTR]: "",
|
|
64
|
+
}),
|
|
65
|
+
[current],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return { onKeyDown, itemProps };
|
|
69
|
+
}
|