@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,295 @@
|
|
|
1
|
+
import { cn } from "@djangocfg/ui-core/lib";
|
|
2
|
+
import { formatDayOfMonth } from "@djangocfg/widget-kit";
|
|
3
|
+
import { MessageQuote } from "./message-quote";
|
|
4
|
+
|
|
5
|
+
import type { ChatMessage } from "../core/parts";
|
|
6
|
+
import { useChatCopy } from "../i18n";
|
|
7
|
+
import { Message as HeadlessMessage, MessageContent as HeadlessMessageContent } from "../primitives";
|
|
8
|
+
import { usePreviousMessageTime } from "../store";
|
|
9
|
+
import { dayHeadingFor } from "../utils";
|
|
10
|
+
import { CollapsibleRow } from "./collapsible-row";
|
|
11
|
+
import { DayDivider } from "./day-divider";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The styled message row: a bubble for the user, open text for the reply.
|
|
15
|
+
*
|
|
16
|
+
* That asymmetry is the whole design, and it is Vercel's — verified against
|
|
17
|
+
* `@sources/vercel-ai-elements/.../message.tsx`, which bubbles `is-user` only
|
|
18
|
+
* and leaves `is-assistant` as bare `text-foreground`. It is right for a reason
|
|
19
|
+
* worth stating: a bubble is a frame, and a frame around a long reply that
|
|
20
|
+
* contains code, a diff and a plan competes with the content it holds. The user
|
|
21
|
+
* turn is short and needs to be findable while scanning; the reply is the
|
|
22
|
+
* product and gets the page.
|
|
23
|
+
*
|
|
24
|
+
* Role is declared ONCE, on the wrapper, as `is-user` / `is-assistant`, and
|
|
25
|
+
* every descendant reads it through `group-[.is-user]:`. The alternative —
|
|
26
|
+
* passing `from` down to each styled piece — is the shape that grows a prop per
|
|
27
|
+
* level, which is exactly what the 83-prop predecessor did.
|
|
28
|
+
*
|
|
29
|
+
* We differ from Vercel in three places, each measured rather than preferred:
|
|
30
|
+
*
|
|
31
|
+
* 1. **No `from` prop.** Their `<Message from={...}>` exists because their
|
|
32
|
+
* message is a prop; ours comes from the store and already carries `role`.
|
|
33
|
+
* A prop here would be a second writer for a value we hold.
|
|
34
|
+
* 2. **The measure is on the reply TEXT, not the container.** Long prose needs
|
|
35
|
+
* a line length near 72ch to stay readable, but a diff, a plan or a table
|
|
36
|
+
* needs the full column. Capping the container would cap the widgets too, so
|
|
37
|
+
* the cap lives on `MessageProse`, which a part renderer wraps around text
|
|
38
|
+
* and around nothing else.
|
|
39
|
+
* 3. **`min-w-0` + `break-words` on the bubble.** A pasted absolute path is one
|
|
40
|
+
* unbreakable token; without these it distends the flex column and pushes
|
|
41
|
+
* the transcript sideways. Users paste paths constantly.
|
|
42
|
+
* 4. **The bubble carries its own cap**, in `ch` — the reply's unit, so the two
|
|
43
|
+
* measures are comparable. `%` would re-couple it to the container and `px`
|
|
44
|
+
* breaks when the host resizes the chat text.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
export interface ChatMessageViewProps {
|
|
48
|
+
readonly id: string;
|
|
49
|
+
readonly className?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Rendered under the parts, inside the frame — the action row's seam.
|
|
52
|
+
*
|
|
53
|
+
* A slot rather than an `actions` prop threaded through: WHICH actions exist
|
|
54
|
+
* is the host's, and passing them through here would make this component a
|
|
55
|
+
* courier for a list it never reads. The host receives the message it is
|
|
56
|
+
* acting on, which is everything an action needs to be built.
|
|
57
|
+
*/
|
|
58
|
+
readonly footer?: (message: ChatMessage) => React.ReactNode;
|
|
59
|
+
/**
|
|
60
|
+
* Rendered ABOVE the row, outside the frame — the speaker's name line.
|
|
61
|
+
*
|
|
62
|
+
* Above and outside deliberately: attribution is a caption on a turn, not a
|
|
63
|
+
* change to the turn's frame. A room needs to say who is talking; a
|
|
64
|
+
* one-to-one chat's speaker is the contact on screen, so its host passes
|
|
65
|
+
* nothing and the row is byte-identical to before this slot existed.
|
|
66
|
+
*
|
|
67
|
+
* A slot for the same reason `footer` is one: WHO a speaker is resolves
|
|
68
|
+
* against a roster this package does not hold, and returning null for a turn
|
|
69
|
+
* that wears no line is the host's call.
|
|
70
|
+
*/
|
|
71
|
+
readonly header?: (message: ChatMessage) => React.ReactNode;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A row in the transcript, resolved from the store by id.
|
|
76
|
+
*
|
|
77
|
+
* `<Transcript>{(id) => <ChatMessageView id={id} />}</Transcript>` is the whole
|
|
78
|
+
* wiring. The headless `<Message>` does the lookup and the memoization; this
|
|
79
|
+
* adds the frame.
|
|
80
|
+
*/
|
|
81
|
+
export function ChatMessageView({ id, className, footer, header }: ChatMessageViewProps) {
|
|
82
|
+
return (
|
|
83
|
+
<HeadlessMessage id={id}>
|
|
84
|
+
{(message, parts) => (
|
|
85
|
+
<>
|
|
86
|
+
<MessageDayDivider id={id} createdAt={message.createdAt} />
|
|
87
|
+
{header?.(message)}
|
|
88
|
+
{/* Above the row, with the speaker line: a quote says what this turn
|
|
89
|
+
answers, which is context for the row rather than part of it. */}
|
|
90
|
+
<MessageQuote message={message} />
|
|
91
|
+
<CollapsibleRow
|
|
92
|
+
message={message}
|
|
93
|
+
parts={parts}
|
|
94
|
+
className={className}
|
|
95
|
+
footer={footer?.(message)}
|
|
96
|
+
/>
|
|
97
|
+
</>
|
|
98
|
+
)}
|
|
99
|
+
</HeadlessMessage>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The day heading above a row, when that row opens a new calendar day.
|
|
105
|
+
*
|
|
106
|
+
* Rendered from INSIDE the row rather than from the transcript's map: the list
|
|
107
|
+
* subscribes to ids alone so a token re-renders one component, and reading
|
|
108
|
+
* every message's time up there would re-render the whole list per token.
|
|
109
|
+
*
|
|
110
|
+
* The clock is read once per row and not memoized on purpose — a transcript
|
|
111
|
+
* open across midnight relabels on its next render, which is the honest answer
|
|
112
|
+
* and costs nothing next to what a timer would.
|
|
113
|
+
*/
|
|
114
|
+
function MessageDayDivider({ id, createdAt }: { id: string; createdAt: number }) {
|
|
115
|
+
const copy = useChatCopy().transcript;
|
|
116
|
+
const previous = usePreviousMessageTime(id);
|
|
117
|
+
const heading = dayHeadingFor(createdAt, previous, Date.now());
|
|
118
|
+
if (heading === undefined) return null;
|
|
119
|
+
const label =
|
|
120
|
+
heading === "today"
|
|
121
|
+
? copy.today
|
|
122
|
+
: heading === "yesterday"
|
|
123
|
+
? copy.yesterday
|
|
124
|
+
: formatDayOfMonth(createdAt);
|
|
125
|
+
// `sv-SE` is ISO-8601 by locale, which is what `<time datetime>` requires —
|
|
126
|
+
// and it is a FORMAT choice, never the reader's locale.
|
|
127
|
+
const dateTime = new Date(createdAt).toLocaleDateString("sv-SE");
|
|
128
|
+
return <DayDivider label={label} dateTime={dateTime} />;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface ChatMessageFrameProps {
|
|
132
|
+
readonly message: ChatMessage;
|
|
133
|
+
readonly className?: string;
|
|
134
|
+
readonly children: React.ReactNode;
|
|
135
|
+
/**
|
|
136
|
+
* Rendered after the bubble, inside the group.
|
|
137
|
+
*
|
|
138
|
+
* Inside the group because the action row reveals on `group-hover`; outside
|
|
139
|
+
* the bubble because a control sitting on the user's background would read as
|
|
140
|
+
* part of what they said, and because the bubble's `select-text` would put a
|
|
141
|
+
* button's label into a copied turn.
|
|
142
|
+
*/
|
|
143
|
+
readonly footer?: React.ReactNode;
|
|
144
|
+
/**
|
|
145
|
+
* The footer's opposite end — a second POSITION, not a second slot's worth of
|
|
146
|
+
* chrome.
|
|
147
|
+
*
|
|
148
|
+
* One position forces everything into one row in source order, so a control
|
|
149
|
+
* that belongs beside the actions rather than after them has nowhere to go.
|
|
150
|
+
* Taken from the reference's `MessageToolbar`, which is a `justify-between`
|
|
151
|
+
* row the host fills from either end.
|
|
152
|
+
*
|
|
153
|
+
* Absent by default, and when absent the footer keeps its own alignment
|
|
154
|
+
* rather than being centred by an empty sibling.
|
|
155
|
+
*/
|
|
156
|
+
readonly footerEnd?: React.ReactNode;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The frame around already-rendered parts.
|
|
161
|
+
*
|
|
162
|
+
* Split from the lookup because a subagent's messages live inside a tool part,
|
|
163
|
+
* not in the store — the same reason `MessageContent` exists beside `Message`
|
|
164
|
+
* one layer down. A nested thread gets the same bubble without a second,
|
|
165
|
+
* subtly-different implementation.
|
|
166
|
+
*
|
|
167
|
+
* `system` shares the assistant's open-text treatment: it is not the user
|
|
168
|
+
* speaking, and giving it a third visual identity would ask the reader to learn
|
|
169
|
+
* one more thing for a role they see rarely.
|
|
170
|
+
*/
|
|
171
|
+
export function ChatMessageFrame({
|
|
172
|
+
message,
|
|
173
|
+
className,
|
|
174
|
+
children,
|
|
175
|
+
footer,
|
|
176
|
+
footerEnd,
|
|
177
|
+
}: ChatMessageFrameProps) {
|
|
178
|
+
const isUser = message.role === "user";
|
|
179
|
+
const failed = message.status.state === "failed";
|
|
180
|
+
const hasToolbar = footer !== undefined || footerEnd !== undefined;
|
|
181
|
+
// Both ends filled: hold them apart. One end: send it to the speaker's side.
|
|
182
|
+
//
|
|
183
|
+
// The single-end case cannot be left unset. The footer does carry a
|
|
184
|
+
// `justify-end` of its own, but it is a 28px-wide box and justifying inside
|
|
185
|
+
// it moves nothing — the 768px toolbar row is what actually places it, and
|
|
186
|
+
// unset means `normal`, which is the left edge. So a user's copy control sat
|
|
187
|
+
// under the far side of the transcript from the bubble it copies.
|
|
188
|
+
const bothEnds = footer !== undefined && footerEnd !== undefined;
|
|
189
|
+
const toolbarJustify = bothEnds
|
|
190
|
+
? "justify-between"
|
|
191
|
+
: isUser
|
|
192
|
+
? "justify-end"
|
|
193
|
+
: "justify-start";
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<div
|
|
197
|
+
// The anchor a jump-to-message resolves. A data attribute rather than an
|
|
198
|
+
// `id`: ids are document-global and a transcript is not the only thing on
|
|
199
|
+
// the page. See `ChatSurfaces.revealMessage`.
|
|
200
|
+
data-message-id={message.id}
|
|
201
|
+
data-role={message.role}
|
|
202
|
+
data-status={message.status.state}
|
|
203
|
+
className={cn(
|
|
204
|
+
// `select-none` here, `select-text` on each part below. Both halves are
|
|
205
|
+
// load-bearing: the padding and the gap BETWEEN two messages must not
|
|
206
|
+
// be selectable, or a drag bridges one turn into the next and a copy
|
|
207
|
+
// returns two speakers fused into one block. Putting `select-text` on
|
|
208
|
+
// the bubble instead makes its own padding selectable and fuses them
|
|
209
|
+
// exactly the same way.
|
|
210
|
+
"group flex w-full max-w-full select-none flex-col gap-2",
|
|
211
|
+
isUser ? "is-user items-end" : "is-assistant items-start",
|
|
212
|
+
className,
|
|
213
|
+
)}
|
|
214
|
+
>
|
|
215
|
+
<div
|
|
216
|
+
className={cn(
|
|
217
|
+
// min-w-0 is what lets a long token wrap instead of widening the row.
|
|
218
|
+
//
|
|
219
|
+
// NO `text-sm` here, deliberately. It looks like a harmless default
|
|
220
|
+
// and is not: `sm` is the CONTROL scale, and setting it on the row
|
|
221
|
+
// breaks inheritance for everything below — so a host that sizes its
|
|
222
|
+
// chat surface finds the transcript ignoring it while the composer
|
|
223
|
+
// obeys, which is the two-scales defect the size token exists to
|
|
224
|
+
// prevent. Measured: surface 15px, this row 12px, in the same chat.
|
|
225
|
+
//
|
|
226
|
+
// The row inherits. A host that wants a size sets it on the surface,
|
|
227
|
+
// once, for both.
|
|
228
|
+
"flex min-w-0 max-w-full flex-col gap-2",
|
|
229
|
+
// Selection opts back in on the CONTENT column, and it is `*:` — every
|
|
230
|
+
// direct child, whatever the registry rendered — rather than a list of
|
|
231
|
+
// known branches. The archive's regression is the reason: for a long
|
|
232
|
+
// time only its markdown leaf opted in, so tool calls, their output,
|
|
233
|
+
// typed blocks and attachments could not be copied AT ALL. A registry
|
|
234
|
+
// makes that failure certain, because the parts are not ours to
|
|
235
|
+
// enumerate. Containment is unchanged: the column's own padding and
|
|
236
|
+
// the `gap-2` between parts stay dead, so a drag still cannot bridge
|
|
237
|
+
// two messages.
|
|
238
|
+
"[&>*]:select-text",
|
|
239
|
+
// `w-fit` keeps a short turn short, the cap bounds the long end —
|
|
240
|
+
// either alone gets one of the two shapes wrong. Narrower than the
|
|
241
|
+
// reply's 72ch, so gutter separates ask from answer at any viewport.
|
|
242
|
+
"group-[.is-user]:w-fit group-[.is-user]:max-w-[52ch]",
|
|
243
|
+
"group-[.is-user]:rounded-2xl group-[.is-user]:bg-secondary",
|
|
244
|
+
"group-[.is-user]:px-4 group-[.is-user]:py-2.5 group-[.is-user]:text-secondary-foreground",
|
|
245
|
+
"group-[.is-user]:break-words",
|
|
246
|
+
// The reply is not a bubble: full column, page text colour.
|
|
247
|
+
"group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground",
|
|
248
|
+
failed && "border border-destructive/40 bg-destructive/5 px-3 py-2",
|
|
249
|
+
)}
|
|
250
|
+
>
|
|
251
|
+
{children}
|
|
252
|
+
</div>
|
|
253
|
+
{/*
|
|
254
|
+
The toolbar sits in flow under the bubble, and most of the height it
|
|
255
|
+
takes is given back here rather than by floating it: an overlaid row
|
|
256
|
+
does not read as belonging to the message it acts on.
|
|
257
|
+
|
|
258
|
+
`-mb-2` (8px), NOT the toolbar's full 28px. Giving back the whole height
|
|
259
|
+
made the row's box end ABOVE the toolbar, so the transcript's gap was
|
|
260
|
+
measured from there and the toolbar overlapped the next message by 2px
|
|
261
|
+
wherever no speaker line stood between them — closer to the turn it does
|
|
262
|
+
not act on than to the one it does. 8px is the largest give-back that
|
|
263
|
+
keeps every toolbar nearer its own text; measured, not chosen.
|
|
264
|
+
|
|
265
|
+
`gap-2` above adds 8px between the bubble and the toolbar, which stays —
|
|
266
|
+
the toolbar must not touch the bubble.
|
|
267
|
+
*/}
|
|
268
|
+
{hasToolbar && (
|
|
269
|
+
<div className={cn("-mb-2 flex w-full items-center gap-4", toolbarJustify)}>
|
|
270
|
+
{footer}
|
|
271
|
+
{footerEnd}
|
|
272
|
+
</div>
|
|
273
|
+
)}
|
|
274
|
+
</div>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The readable measure for model prose.
|
|
280
|
+
*
|
|
281
|
+
* ~72ch, applied by the TEXT part renderer — never by the row. A plan, a diff
|
|
282
|
+
* or a table registered for its own part type keeps the full column, because
|
|
283
|
+
* truncating a table to prose width makes it unreadable in a different way.
|
|
284
|
+
*
|
|
285
|
+
* `max-w-none` on the user side: a bubble is already narrow, and a second cap
|
|
286
|
+
* inside it produces a ragged short column against a wide bubble edge.
|
|
287
|
+
*/
|
|
288
|
+
export function MessageProse({ children, className }: {
|
|
289
|
+
readonly children: React.ReactNode;
|
|
290
|
+
readonly className?: string;
|
|
291
|
+
}) {
|
|
292
|
+
return (
|
|
293
|
+
<div className={cn("max-w-[72ch] group-[.is-user]:max-w-none", className)}>{children}</div>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { memo, useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import { TurnMark } from "./turn-mark";
|
|
4
|
+
|
|
5
|
+
export interface PendingIndicatorProps {
|
|
6
|
+
/**
|
|
7
|
+
* What the wait is for — "Thinking…", say. Optional: the dots alone already
|
|
8
|
+
* say "something is happening", and a label is the host's copy.
|
|
9
|
+
*/
|
|
10
|
+
readonly label?: string;
|
|
11
|
+
readonly className?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Rendered beside the label once the wait passes `elapsedAfterMs`, given the
|
|
14
|
+
* whole seconds waited.
|
|
15
|
+
*
|
|
16
|
+
* Absent ⇒ no counter, which is the right default: a number invites watching
|
|
17
|
+
* it. It earns its place only in the long tail, where an agent thinking for
|
|
18
|
+
* forty seconds is otherwise indistinguishable from a hung one.
|
|
19
|
+
*/
|
|
20
|
+
readonly elapsed?: (seconds: number) => string;
|
|
21
|
+
/** How long to stay quiet before the counter appears. */
|
|
22
|
+
readonly elapsedAfterMs?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DEFAULT_ELAPSED_AFTER_MS = 5_000;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The "your turn was sent, nothing has come back yet" row.
|
|
29
|
+
*
|
|
30
|
+
* Carries `TurnMark` in its waiting state — the same shape that leads the reply
|
|
31
|
+
* once text starts and rests under it when the turn ends. Three bouncing dots
|
|
32
|
+
* lived here before, and their fault was being a dead end: they said "busy",
|
|
33
|
+
* then vanished, so the end of an answer was marked by nothing at all.
|
|
34
|
+
*
|
|
35
|
+
* `aria-live="off"` on the row: the transcript already announces turn start and
|
|
36
|
+
* end through one polite region, and a second live region here would make every
|
|
37
|
+
* reply be announced twice.
|
|
38
|
+
*/
|
|
39
|
+
function PendingIndicatorRaw({
|
|
40
|
+
label,
|
|
41
|
+
className,
|
|
42
|
+
elapsed,
|
|
43
|
+
elapsedAfterMs = DEFAULT_ELAPSED_AFTER_MS,
|
|
44
|
+
}: PendingIndicatorProps) {
|
|
45
|
+
const seconds = useElapsedSeconds(elapsed !== undefined, elapsedAfterMs);
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className={className} aria-live="off">
|
|
49
|
+
<TurnMark state="waiting" />
|
|
50
|
+
{label === undefined ? null : <span>{label}</span>}
|
|
51
|
+
{seconds === null || elapsed === undefined ? null : (
|
|
52
|
+
<span className="tabular-nums">{elapsed(seconds)}</span>
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const PendingIndicator = memo(PendingIndicatorRaw);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whole seconds since this row mounted, or `null` while under the threshold.
|
|
62
|
+
*
|
|
63
|
+
* Mount time is the start, and that is exact rather than approximate: the row
|
|
64
|
+
* is rendered only while the turn is pending, so it mounts when the wait begins
|
|
65
|
+
* and unmounts on the first part.
|
|
66
|
+
*
|
|
67
|
+
* The interval only runs when a counter was asked for — an unused indicator
|
|
68
|
+
* must not tick once per second forever behind a background tab.
|
|
69
|
+
*/
|
|
70
|
+
function useElapsedSeconds(enabled: boolean, afterMs: number): number | null {
|
|
71
|
+
const startedAt = useRef<number>(0);
|
|
72
|
+
const [seconds, setSeconds] = useState<number | null>(null);
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!enabled) return;
|
|
76
|
+
startedAt.current = Date.now();
|
|
77
|
+
const id = setInterval(() => {
|
|
78
|
+
const waited = Date.now() - startedAt.current;
|
|
79
|
+
setSeconds(waited < afterMs ? null : Math.floor(waited / 1_000));
|
|
80
|
+
}, 1_000);
|
|
81
|
+
return () => clearInterval(id);
|
|
82
|
+
}, [enabled, afterMs]);
|
|
83
|
+
|
|
84
|
+
return seconds;
|
|
85
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { isMediaKind, readMediaBlock } from "../../core";
|
|
4
|
+
import { renderMediaContent } from "../../content";
|
|
5
|
+
import type { PartRenderProps } from "../../primitives";
|
|
6
|
+
import { useCapabilities } from "../../store";
|
|
7
|
+
import { MediaWidget, type MediaRendererProps } from "../../widgets";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Typed blocks the agent authored.
|
|
11
|
+
*
|
|
12
|
+
* ONE renderer for every `data` part, switching on `kind`, rather than one
|
|
13
|
+
* registry entry per kind. The registry is keyed on `part.type`, and `kind` is
|
|
14
|
+
* an open set a host grows. An unknown kind renders nothing, which is the
|
|
15
|
+
* honest outcome: a client one build behind its backend stays silent instead
|
|
16
|
+
* of drawing a placeholder for content it cannot read.
|
|
17
|
+
*
|
|
18
|
+
* The parsers own the degradation. Each takes `unknown` and returns null on a
|
|
19
|
+
* malformed payload, so nothing here guards a shape twice.
|
|
20
|
+
*/
|
|
21
|
+
export function DataRenderer({ part }: PartRenderProps) {
|
|
22
|
+
const capabilities = useCapabilities();
|
|
23
|
+
const media = capabilities.media;
|
|
24
|
+
if (part.type !== "data") return null;
|
|
25
|
+
|
|
26
|
+
// The eleven media kinds arrive under their OWN kind name, not a `media`
|
|
27
|
+
// wrapper — a host passes a built-in block through whole.
|
|
28
|
+
if (isMediaKind(part.kind)) {
|
|
29
|
+
// Absent capability ⇒ nothing renders. A media src is a URL the model chose,
|
|
30
|
+
// and a host that cannot say which same-origin paths are its own has no way
|
|
31
|
+
// to gate the request the browser would make.
|
|
32
|
+
if (media === undefined) return null;
|
|
33
|
+
const block = readMediaBlock(part.data, media.sameOriginPrefixes);
|
|
34
|
+
// Gated centrally, once: a rejected src leaves `readMediaBlock` as null
|
|
35
|
+
// rather than reaching a renderer that would draw a broken element.
|
|
36
|
+
if (block === null) return null;
|
|
37
|
+
// The capability types these `unknown` because core imports no React; this
|
|
38
|
+
// is the React file that narrows them.
|
|
39
|
+
const renderPlayer = media.renderPlayer as
|
|
40
|
+
| ((b: typeof block) => ReactNode)
|
|
41
|
+
| undefined;
|
|
42
|
+
// Forwarded for the same reason as the player: a picture the agent produced
|
|
43
|
+
// must open in the host's viewer like every other image in the app.
|
|
44
|
+
const renderImage = media.renderImage as MediaRendererProps["renderImage"];
|
|
45
|
+
// Supplied by the package, NOT by the capability beside it. The player and
|
|
46
|
+
// the viewer are the host's because only the host owns them; a diagram is
|
|
47
|
+
// drawn from the block's own text by a renderer this package depends on, so
|
|
48
|
+
// routing it through the capability would ask every host to opt in to a
|
|
49
|
+
// decision none of them can make differently. That asymmetry is deliberate:
|
|
50
|
+
// a capability answers "does this host have one", and here the answer
|
|
51
|
+
// cannot vary.
|
|
52
|
+
return (
|
|
53
|
+
<MediaWidget
|
|
54
|
+
block={block}
|
|
55
|
+
renderContent={renderMediaContent}
|
|
56
|
+
renderPlayer={renderPlayer}
|
|
57
|
+
renderImage={renderImage}
|
|
58
|
+
/>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { ChevronRight } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
import { formatDurationMs } from "@djangocfg/widget-kit";
|
|
4
|
+
|
|
5
|
+
import { useChatCopy, useChatLanguage, plural } from "../../i18n";
|
|
6
|
+
import type { PartRenderProps } from "../../primitives";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The model's thinking, as a disclosure that states which state it is in.
|
|
10
|
+
*
|
|
11
|
+
* Three states, because the block has three genuinely different things to say
|
|
12
|
+
* and one label for all of them says none:
|
|
13
|
+
*
|
|
14
|
+
* - **arriving** — "Reasoning…", and open, because the thought is the only
|
|
15
|
+
* thing on screen and hiding it leaves the turn looking stalled;
|
|
16
|
+
* - **settled** — "Reasoning", collapsed, with the duration when the engine
|
|
17
|
+
* measured one;
|
|
18
|
+
* - **counted** — no words at all, so the token figure IS the summary.
|
|
19
|
+
*
|
|
20
|
+
* The last one is why this renders on a part with an empty `text`: a provider
|
|
21
|
+
* can ship the thinking block with its words stripped, and a block keyed on
|
|
22
|
+
* text alone would render nothing on the engine most likely to be running.
|
|
23
|
+
*/
|
|
24
|
+
export function ReasoningRenderer({ part }: PartRenderProps) {
|
|
25
|
+
// Before the early return: hooks cannot be conditional, and a renderer is
|
|
26
|
+
// called for every part type.
|
|
27
|
+
const copy = useChatCopy().message;
|
|
28
|
+
const language = useChatLanguage();
|
|
29
|
+
if (part.type !== "reasoning") return null;
|
|
30
|
+
|
|
31
|
+
const streaming = part.streaming === true;
|
|
32
|
+
const hasText = part.text !== "";
|
|
33
|
+
// Nothing to disclose and nothing to count: an empty box promising a thought.
|
|
34
|
+
if (!hasText && part.tokens === undefined && !streaming) return null;
|
|
35
|
+
|
|
36
|
+
const summary = streaming
|
|
37
|
+
? copy.reasoningLive
|
|
38
|
+
: hasText
|
|
39
|
+
? withDuration(copy.reasoning, part.elapsedMs)
|
|
40
|
+
: plural(copy.reasoningTokens, part.tokens ?? 0, language);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
// Open while it streams: the thought is the only visible progress, and a
|
|
44
|
+
// collapsed one reads as a stalled turn. Sealed blocks collapse, because by
|
|
45
|
+
// then the answer is what the reader wants.
|
|
46
|
+
<details
|
|
47
|
+
className="chat-disclosure text-muted-foreground text-[length:var(--chat-text-small)]"
|
|
48
|
+
open={streaming}
|
|
49
|
+
>
|
|
50
|
+
<summary className="cursor-pointer items-center gap-1">
|
|
51
|
+
<span className="chat-disclosure-chevron">
|
|
52
|
+
<ChevronRight className="size-3.5" aria-hidden />
|
|
53
|
+
</span>
|
|
54
|
+
{summary}
|
|
55
|
+
</summary>
|
|
56
|
+
{hasText ? <div className="pt-1 whitespace-pre-wrap">{part.text}</div> : null}
|
|
57
|
+
</details>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Append the measured duration, when there is one.
|
|
63
|
+
*
|
|
64
|
+
* Formatting is `lib-utils`' `formatDurationMs`, not a local rule: that module
|
|
65
|
+
* records why one spelling matters — a formatter per surface makes the same
|
|
66
|
+
* fact read differently depending on which pane is open. An absent or
|
|
67
|
+
* non-positive measurement leaves the label alone rather than printing a zero,
|
|
68
|
+
* which would claim the model thought for no time at all.
|
|
69
|
+
*/
|
|
70
|
+
function withDuration(label: string, elapsedMs: number | undefined): string {
|
|
71
|
+
if (elapsedMs === undefined || elapsedMs <= 0) return label;
|
|
72
|
+
return `${label} · ${formatDurationMs(elapsedMs)}`;
|
|
73
|
+
}
|