@theoremjs/react 0.3.4-next.389.gecc7333 → 0.3.4-next.401.gec4c582
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/README.md +2 -1
- package/package.json +3 -3
- package/ui/ChatTranscript.js +93 -12
- package/ui/TheoremChat.d.ts +4 -2
package/README.md
CHANGED
|
@@ -180,6 +180,7 @@ The server keeps the conversation in its `conversationStore`. The browser never
|
|
|
180
180
|
|
|
181
181
|
- when the message reaches the server
|
|
182
182
|
- each time the server saves the reply, while the reply runs and when it ends
|
|
183
|
+
- with `undefined` when the server can no longer open the ref you resumed. The chat starts a new conversation with no error, so forget the ref.
|
|
183
184
|
|
|
184
185
|
Keep the latest ref. To resume the conversation, pass it as `conversation`. The component reads this value once, when it mounts. It then draws the conversation as the server keeps it, including a reply that waits for a gate.
|
|
185
186
|
|
|
@@ -187,7 +188,7 @@ Keep the latest ref. To resume the conversation, pass it as `conversation`. The
|
|
|
187
188
|
<TheoremChat
|
|
188
189
|
endpoint="/api/theorem"
|
|
189
190
|
conversation={savedRef}
|
|
190
|
-
onConversationChange={(ref) => saveRef(ref)}
|
|
191
|
+
onConversationChange={(ref) => (ref ? saveRef(ref) : forgetRef())}
|
|
191
192
|
/>
|
|
192
193
|
```
|
|
193
194
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@theoremjs/react",
|
|
3
|
-
"version": "0.3.4-next.
|
|
3
|
+
"version": "0.3.4-next.401.gec4c582",
|
|
4
4
|
"description": "React bindings for Theorem: headless hooks + transport, an Astryx chat UI, and a Web-standard server handler.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -60,8 +60,8 @@
|
|
|
60
60
|
"@stylexjs/stylex": "0.19.0",
|
|
61
61
|
"react": "^19.0.0",
|
|
62
62
|
"react-dom": "^19.0.0",
|
|
63
|
-
"@theoremjs/agents": "^0.3.4-next.
|
|
64
|
-
"@theoremjs/web": "^0.3.4-next.
|
|
63
|
+
"@theoremjs/agents": "^0.3.4-next.401.gec4c582",
|
|
64
|
+
"@theoremjs/web": "^0.3.4-next.401.gec4c582"
|
|
65
65
|
},
|
|
66
66
|
"peerDependenciesMeta": {
|
|
67
67
|
"@astryxdesign/core": {
|
package/ui/ChatTranscript.js
CHANGED
|
@@ -121,17 +121,91 @@ function CopyAndTime(props) {
|
|
|
121
121
|
const time = _jsx(MessageTime, { at: props.at });
|
|
122
122
|
return (_jsxs(HStack, { gap: 1, vAlign: "center", children: [props.sender === 'user' ? time : copy, props.sender === 'user' ? copy : time] }));
|
|
123
123
|
}
|
|
124
|
+
/** How long the newest message keeps its copy button, time and status in view after it lands or its status changes. */
|
|
125
|
+
const CHROME_FRESH_MS = 4_000;
|
|
126
|
+
const CHROME_FADE = 'opacity 160ms ease';
|
|
127
|
+
/** A press on a control inside the message, which does its own thing rather than show the details. */
|
|
128
|
+
function isControl(target) {
|
|
129
|
+
return (target instanceof Element &&
|
|
130
|
+
target.closest('a, button, input, textarea, [role="button"]') !== null);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Whether a message shows its copy button, time and status: while it is hovered, while focus is
|
|
134
|
+
* inside it, after a tap or hold until the next tap, and for a few seconds when it is the newest
|
|
135
|
+
* message or its status changes.
|
|
136
|
+
*/
|
|
137
|
+
function useChromeReveal(isNewest, status) {
|
|
138
|
+
const [hovered, setHovered] = useState(false);
|
|
139
|
+
const [focused, setFocused] = useState(false);
|
|
140
|
+
const [tapped, setTapped] = useState(false);
|
|
141
|
+
const fresh = useFresh(isNewest, status);
|
|
142
|
+
// why: A hold opens the context menu and may end in a pointerup; that pointerup must not hide it again.
|
|
143
|
+
const held = useRef(false);
|
|
144
|
+
const handlers = {
|
|
145
|
+
onPointerEnter: (event) => {
|
|
146
|
+
if (event.pointerType === 'mouse')
|
|
147
|
+
setHovered(true);
|
|
148
|
+
},
|
|
149
|
+
onPointerLeave: (event) => {
|
|
150
|
+
if (event.pointerType === 'mouse')
|
|
151
|
+
setHovered(false);
|
|
152
|
+
},
|
|
153
|
+
onPointerUp: (event) => {
|
|
154
|
+
if (event.pointerType === 'mouse' || isControl(event.target))
|
|
155
|
+
return;
|
|
156
|
+
if (held.current)
|
|
157
|
+
held.current = false;
|
|
158
|
+
else
|
|
159
|
+
setTapped((open) => !open);
|
|
160
|
+
},
|
|
161
|
+
onContextMenu: () => {
|
|
162
|
+
held.current = true;
|
|
163
|
+
setTapped(true);
|
|
164
|
+
},
|
|
165
|
+
onFocus: () => setFocused(true),
|
|
166
|
+
onBlur: (event) => {
|
|
167
|
+
if (!event.currentTarget.contains(event.relatedTarget))
|
|
168
|
+
setFocused(false);
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
return { revealed: hovered || focused || tapped || fresh, handlers };
|
|
172
|
+
}
|
|
173
|
+
/** Whether a message has just landed, or its status just changed: true for a few seconds after. */
|
|
174
|
+
function useFresh(isNewest, status) {
|
|
175
|
+
// why: Only the last prompt carries a status, and it can mount with one when its reply commits.
|
|
176
|
+
// why: A count, so a status that lands while the details already show starts their seconds over.
|
|
177
|
+
const [fresh, setFresh] = useState(isNewest || status !== undefined ? 1 : 0);
|
|
178
|
+
const [seenStatus, setSeenStatus] = useState(status);
|
|
179
|
+
// why: A message that stops being the last prompt loses its status; only a new one is news.
|
|
180
|
+
if (status !== seenStatus) {
|
|
181
|
+
setSeenStatus(status);
|
|
182
|
+
if (status !== undefined)
|
|
183
|
+
setFresh((count) => count + 1);
|
|
184
|
+
}
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
if (fresh === 0)
|
|
187
|
+
return;
|
|
188
|
+
const timer = setTimeout(() => setFresh(0), CHROME_FRESH_MS);
|
|
189
|
+
return () => clearTimeout(timer);
|
|
190
|
+
}, [fresh]);
|
|
191
|
+
return fresh > 0;
|
|
192
|
+
}
|
|
124
193
|
/** A message's copy button (outermost), time and status; a failure says why on hovering its status. */
|
|
125
194
|
function MessageChrome(props) {
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
return (_jsx(ChatMessageMetadata
|
|
129
|
-
// why: The row's first slot is the one nearest the margin.
|
|
130
|
-
, {
|
|
195
|
+
const notice = turnNotice(props.error, props.stopped);
|
|
196
|
+
return (_jsx(ChatMessageMetadata, { style: { opacity: props.revealed ? 1 : 0, transition: CHROME_FADE },
|
|
131
197
|
// why: The row's first slot is the one nearest the margin.
|
|
132
198
|
timestamp: _jsx(CopyAndTime, { sender: props.sender, at: props.at, copyText: props.copyText }),
|
|
133
199
|
// why: Astryx's statuses are a closed set, so these ride the footer.
|
|
134
|
-
footer:
|
|
200
|
+
footer: notice ?? (props.usage ? _jsx(Usage, { tokens: props.usage }) : undefined), status: notice === undefined ? props.status : undefined }));
|
|
201
|
+
}
|
|
202
|
+
/** Why a turn failed or stopped short, said where its status goes. */
|
|
203
|
+
function turnNotice(error, stopped) {
|
|
204
|
+
if (error !== undefined)
|
|
205
|
+
return _jsx(Failed, { reason: error });
|
|
206
|
+
if (stopped !== undefined)
|
|
207
|
+
return _jsx(Stopped, { label: stopped });
|
|
208
|
+
return undefined;
|
|
135
209
|
}
|
|
136
210
|
function dataUrl(mimeType, data) {
|
|
137
211
|
return data === undefined ? undefined : `data:${mimeType};base64,${data}`;
|
|
@@ -348,6 +422,11 @@ const THOUGHT_MARKDOWN = {
|
|
|
348
422
|
paragraph: ({ children }) => (_jsx(Text, { size: "sm", color: "secondary", display: "block", as: "p", children: children })),
|
|
349
423
|
heading: ({ children }) => (_jsx(Text, { size: "sm", color: "secondary", weight: "semibold", display: "block", as: "p", children: children })),
|
|
350
424
|
};
|
|
425
|
+
// why: Lists and other blocks Markdown draws itself take no `components`; the thought's tokens quiet them too.
|
|
426
|
+
const THOUGHT_TEXT = {
|
|
427
|
+
'--text-body-size': 'var(--text-supporting-size)',
|
|
428
|
+
'--color-text-primary': 'var(--color-text-secondary)',
|
|
429
|
+
};
|
|
351
430
|
function TraceList({ items, streaming }) {
|
|
352
431
|
const t = useLabels();
|
|
353
432
|
const showUsage = useContext(ShowUsage);
|
|
@@ -365,7 +444,7 @@ function TraceList({ items, streaming }) {
|
|
|
365
444
|
continue;
|
|
366
445
|
}
|
|
367
446
|
flush();
|
|
368
|
-
rows.push(_jsx(StreamedMarkdown, { text: item.text, streaming: streaming && i === items.length - 1, density: "compact", components: THOUGHT_MARKDOWN }, item.id));
|
|
447
|
+
rows.push(_jsx("div", { style: THOUGHT_TEXT, children: _jsx(StreamedMarkdown, { text: item.text, streaming: streaming && i === items.length - 1, density: "compact", components: THOUGHT_MARKDOWN }) }, item.id));
|
|
369
448
|
}
|
|
370
449
|
flush();
|
|
371
450
|
return _jsx(VStack, { gap: 2, children: rows });
|
|
@@ -436,29 +515,31 @@ function PendingImage(props) {
|
|
|
436
515
|
}
|
|
437
516
|
function AssistantTurn(props) {
|
|
438
517
|
const t = useLabels();
|
|
518
|
+
const chrome = useChromeReveal(props.isNewest === true);
|
|
439
519
|
const showUsage = useContext(ShowUsage);
|
|
440
520
|
const elapsedMs = useTurnElapsed(props.streaming, props.startedAt, props.workedMs);
|
|
441
521
|
const { trace, gatedTools, body, hasTrace } = composeAssistantTurn(props.blocks);
|
|
442
522
|
const rows = bodyRows(t, body);
|
|
443
523
|
const status = workStatusLabel(t, workStatus({ streaming: props.streaming, hasTrace, elapsedMs }));
|
|
444
524
|
const copyText = assistantTurnCopyText(body.length > 0 ? body : props.blocks);
|
|
445
|
-
return (_jsx(ChatMessage, { sender: "assistant", name: props.handle, metadata: props.streaming ? undefined : (_jsx(MessageChrome, { sender: "assistant", at: props.at, copyText: copyText, error: props.error, stopped: props.stopped, usage: showUsage ? props.usage : undefined })), children: _jsxs(VStack, { gap: 3, width: "100%", children: [_jsx(TurnStatus, { status: status, trace: trace, hasTrace: hasTrace, streaming: props.streaming }), gatedTools.map((block) => (_jsx(GateCard, { block: block, handle: props.handle, handlers: props.handlers }, block.id))), keyedByContent(rows, (row) => (row.kind === 'gallery' ? 'gallery' : row.block.kind)).map(({ item: row, index: i, key }) => (_jsx(BodyRowView
|
|
525
|
+
return (_jsx(ChatMessage, { sender: "assistant", name: props.handle, ...chrome.handlers, metadata: props.streaming ? undefined : (_jsx(MessageChrome, { sender: "assistant", at: props.at, copyText: copyText, error: props.error, stopped: props.stopped, usage: showUsage ? props.usage : undefined, revealed: chrome.revealed })), children: _jsxs(VStack, { gap: 3, width: "100%", children: [_jsx(TurnStatus, { status: status, trace: trace, hasTrace: hasTrace, streaming: props.streaming }), gatedTools.map((block) => (_jsx(GateCard, { block: block, handle: props.handle, handlers: props.handlers }, block.id))), keyedByContent(rows, (row) => (row.kind === 'gallery' ? 'gallery' : row.block.kind)).map(({ item: row, index: i, key }) => (_jsx(BodyRowView
|
|
446
526
|
// why: Not the block id: the committed turn re-mints ids, and a
|
|
447
527
|
// remount would cut the text reveal short.
|
|
448
528
|
, { row: row, streaming: props.streaming && i === rows.length - 1, imageOutput: props.imageOutput }, key))), _jsx(PendingImage, { imageOutput: props.imageOutput, streaming: props.streaming, rows: rows })] }) }));
|
|
449
529
|
}
|
|
450
530
|
function UserTurn(props) {
|
|
451
531
|
const t = useLabels();
|
|
532
|
+
const reveal = useChromeReveal(props.isNewest === true, props.status);
|
|
452
533
|
const copyText = props.blocks
|
|
453
534
|
.map((block) => transcriptBlockCopyText(t, block))
|
|
454
535
|
.filter(Boolean)
|
|
455
536
|
.join('\n\n');
|
|
456
|
-
const chrome = (_jsx(MessageChrome, { sender: "user", at: props.at, copyText: copyText, status: props.status, stopped: props.stopped, error: props.error }));
|
|
537
|
+
const chrome = (_jsx(MessageChrome, { sender: "user", at: props.at, copyText: copyText, status: props.status, stopped: props.stopped, error: props.error, revealed: reveal.revealed }));
|
|
457
538
|
// why: Astryx: metadata goes on the last bubble, or on the message when the last
|
|
458
539
|
// content is unbubbled (an attachment or voice note).
|
|
459
540
|
const last = props.blocks.at(-1);
|
|
460
541
|
const lastIsBubble = last?.kind === 'user-text';
|
|
461
|
-
return (_jsx(ChatMessage, { sender: "user", metadata: lastIsBubble ? undefined : chrome, children: props.blocks.map((block) => (_jsx(UserBlock, { block: block, metadata: block === last && lastIsBubble ? chrome : undefined }, block.id))) }));
|
|
542
|
+
return (_jsx(ChatMessage, { sender: "user", ...reveal.handlers, metadata: lastIsBubble ? undefined : chrome, children: props.blocks.map((block) => (_jsx(UserBlock, { block: block, metadata: block === last && lastIsBubble ? chrome : undefined }, block.id))) }));
|
|
462
543
|
}
|
|
463
544
|
/** Why a turn failed: its error block's message. */
|
|
464
545
|
function failureOf(group) {
|
|
@@ -491,7 +572,7 @@ function ChatTranscriptBody({ blocks, handle, streaming = false, delivery, onToo
|
|
|
491
572
|
const next = groups[index + 1];
|
|
492
573
|
const error = isBareFailure(next) ? failureOf(next) : undefined;
|
|
493
574
|
const status = index === lastUser ? (delivery ?? undefined) : undefined;
|
|
494
|
-
return (_jsx(UserTurn, { blocks: group.blocks, at: promptTime(groups, index, timeOf), status: status, stopped: stopText(group.stop, group.blocks, labels), error: error }, group.key));
|
|
575
|
+
return (_jsx(UserTurn, { blocks: group.blocks, at: promptTime(groups, index, timeOf), status: status, stopped: stopText(group.stop, group.blocks, labels), error: error, isNewest: index === groups.length - 1 }, group.key));
|
|
495
576
|
};
|
|
496
577
|
const turnOf = (group, index) => {
|
|
497
578
|
if (group.kind === 'user')
|
|
@@ -507,7 +588,7 @@ function ChatTranscriptBody({ blocks, handle, streaming = false, delivery, onToo
|
|
|
507
588
|
});
|
|
508
589
|
// why: A reply is dated when it last stopped: a reply that just finished reads "now".
|
|
509
590
|
const at = endedAt ?? timeOf(key);
|
|
510
|
-
return (_jsx(AssistantTurn, { ...turn, ...timing, blocks: group.blocks, streaming: live, at: at, error: failureOf(group), stopped: stopText(group.stop, group.blocks, labels), usage: group.usage }, key));
|
|
591
|
+
return (_jsx(AssistantTurn, { ...turn, ...timing, blocks: group.blocks, streaming: live, at: at, error: failureOf(group), stopped: stopText(group.stop, group.blocks, labels), usage: group.usage, isNewest: index === groups.length - 1 }, key));
|
|
511
592
|
};
|
|
512
593
|
const turns = groups.flatMap((group, index) => {
|
|
513
594
|
const label = dividers?.[group.key];
|
package/ui/TheoremChat.d.ts
CHANGED
|
@@ -86,9 +86,11 @@ export type TheoremChatProps = {
|
|
|
86
86
|
initialText?: string;
|
|
87
87
|
/**
|
|
88
88
|
* Called with each new ref the host issues for the conversation: once the message posts, and
|
|
89
|
-
* again as the reply's runs are saved. Keep the latest to resume the chat later.
|
|
89
|
+
* again as the reply's runs are saved. Keep the latest to resume the chat later. `undefined`
|
|
90
|
+
* when the host can no longer open the ref the chat resumed: the chat starts a new one, so
|
|
91
|
+
* forget it.
|
|
90
92
|
*/
|
|
91
|
-
onConversationChange?: (ref: string) => void;
|
|
93
|
+
onConversationChange?: (ref: string | undefined) => void;
|
|
92
94
|
/** Lets the host send a message as the composer would; see {@link TheoremChatHandle}. */
|
|
93
95
|
chatRef?: Ref<TheoremChatHandle>;
|
|
94
96
|
/**
|