@theoremjs/react 0.3.4-next.389.gecc7333 → 0.3.4-next.403.g5de27f3

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 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.389.gecc7333",
3
+ "version": "0.3.4-next.403.g5de27f3",
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.389.gecc7333",
64
- "@theoremjs/web": "^0.3.4-next.389.gecc7333"
63
+ "@theoremjs/agents": "^0.3.4-next.403.g5de27f3",
64
+ "@theoremjs/web": "^0.3.4-next.403.g5de27f3"
65
65
  },
66
66
  "peerDependenciesMeta": {
67
67
  "@astryxdesign/core": {
@@ -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 usage = props.usage ? _jsx(Usage, { tokens: props.usage }) : undefined;
127
- const aside = props.error !== undefined ? (_jsx(Failed, { reason: props.error })) : props.stopped !== undefined ? (_jsx(Stopped, { label: props.stopped })) : (usage);
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: aside, status: props.error !== undefined || props.stopped !== undefined ? undefined : props.status }));
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];
@@ -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
  /**