@spexcode/transcript-ui 0.7.0-next.12 → 0.7.0-next.14

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.
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
2
3
  import { useTranscriptUi } from './context.js';
3
4
  import { Caret } from './icons.js';
4
5
  import { Quote } from './Quote.js';
@@ -18,17 +19,46 @@ export function QuotedTurn({ turn }) {
18
19
  const envelope = parseEnvelope(turn.text || '', envelopes);
19
20
  return _jsx(Quote, { who: envelope.who, ts: envelope.at ?? turn.at, text: envelope.body, className: "tx-quote-nested" });
20
21
  }
22
+ // A FOLD IS A MOVEMENT, and a fold that is only a re-render cannot move: React drops the work the instant a
23
+ // segment folds — when the agent speaks, or when the next message ends the stretch — so the page arrives at the
24
+ // row without ever travelling to it. This keeps the outgoing content mounted for exactly one duration so the
25
+ // collapse is visible, and it is the same hook that opens and shuts the fold's own body. Two details are load
26
+ // bearing. The phase is decided during the render that flips, not in an effect: an effect runs after paint, so
27
+ // the reader would see the jump first and the animation afterwards. And the flag can never outlive its timer,
28
+ // because a flag that survived would leave a second copy of the work standing on the page.
29
+ //
30
+ // The duration lives in both layers by necessity: the keyframe is CSS (`--tx-dur-fold`) and the unmount is JS.
31
+ // They are the same number, and this is the one place the JS half says it.
32
+ const FOLD_MS = 170;
33
+ function useFold(open) {
34
+ const [was, setWas] = useState(open);
35
+ const [phase, setPhase] = useState('');
36
+ if (was !== open) {
37
+ setWas(open);
38
+ setPhase(open ? 'in' : 'out');
39
+ }
40
+ useEffect(() => {
41
+ if (!phase)
42
+ return undefined;
43
+ const timer = setTimeout(() => setPhase(''), FOLD_MS);
44
+ return () => clearTimeout(timer);
45
+ }, [phase]);
46
+ return [phase === 'out', phase === 'in'];
47
+ }
21
48
  export function WorkSegmentView({ segment, openIds, onToggle, live }) {
22
49
  const { labels, vocabulary } = useTranscriptUi();
23
50
  const id = `seg:${segment.work[0]?.id || segment.answer?.id}`;
24
51
  const open = openIds.has(id);
52
+ const [folding] = useFold(!segment.folded); // the work is on its way behind the row
53
+ const [shutting, opening] = useFold(open); // the row's own disclosure
25
54
  const kinds = runKinds(segment.work.flatMap((turn) => turn.tools ?? []), vocabulary);
26
55
  const foldedCalls = segment.work.reduce((n, turn) => n + (turn.tools?.length || 0), 0);
27
56
  // a fold must not hide a failure: the row counts the calls whose harness recorded one
28
57
  const failedCalls = segment.work.reduce((n, turn) => n + (turn.tools?.filter((tool) => tool.outcome).length || 0), 0);
29
58
  // history folds its runs; the work in progress (a live segment's calls after its newest prose) does not
30
59
  const history = !segment.now || !!segment.answer;
31
- return _jsxs(_Fragment, { children: [segment.folded ? (_jsxs("div", { className: `tx-work${failedCalls ? ' is-failed' : ''}`, children: [_jsxs("button", { type: "button", className: "tx-work-row", "aria-expanded": open, onClick: () => onToggle(id), children: [_jsx("span", { className: "tx-work-lead", children: labels.toolUses(foldedCalls) }), kinds && _jsx("span", { className: "tx-work-detail", children: kinds }), failedCalls > 0 && _jsx("span", { className: "tx-tool-outcome is-failed", children: labels.failedCount(failedCalls) }), _jsx(Caret, { open: open, className: "tx-work-caret" })] }), open && _jsx("div", { className: "tx-work-body", children: segment.work.map((turn) => _jsx(TurnBody, { turn: turn, openIds: openIds, onToggle: onToggle, live: live }, turn.id)) })] })) : segment.work.map((turn) => _jsx(TurnBody, { turn: turn, openIds: openIds, onToggle: onToggle, live: live, fold: history }, turn.id)), segment.answer && _jsx(TurnBody, { turn: segment.answer, openIds: openIds, onToggle: onToggle, live: live, fold: !segment.now }), segment.after.map((turn) => _jsx(TurnBody, { turn: turn, openIds: openIds, onToggle: onToggle, live: live, fold: !segment.now }, turn.id))] });
60
+ const process = (fold) => segment.work.map((turn) => _jsx(TurnBody, { turn: turn, openIds: openIds, onToggle: onToggle, live: live, fold: fold }, turn.id));
61
+ return _jsxs(_Fragment, { children: [segment.folded ? (_jsxs("div", { className: `tx-work${failedCalls ? ' is-failed' : ''}${folding ? ' is-folding' : ''}`, children: [_jsxs("button", { type: "button", className: "tx-work-row", "aria-expanded": open, onClick: () => onToggle(id), children: [_jsx("span", { className: "tx-work-lead", children: labels.toolUses(foldedCalls) }), kinds && _jsx("span", { className: "tx-work-detail", children: kinds }), failedCalls > 0 && _jsx("span", { className: "tx-tool-outcome is-failed", children: labels.failedCount(failedCalls) }), _jsx(Caret, { open: open, className: "tx-work-caret" })] }), folding && _jsx("div", { className: "tx-fold is-closing", children: _jsx("div", { className: "tx-flow", children: process(!live) }) }), (open || shutting) && _jsx("div", { className: `tx-fold${opening ? ' is-opening' : ''}${shutting ? ' is-closing' : ''}`, children: _jsx("div", { className: "tx-work-body", children: process(true) }) })] })) : process(history), segment.answer && _jsx(TurnBody, { turn: segment.answer, openIds: openIds, onToggle: onToggle, live: live, fold: !segment.now }), segment.after.map((turn) => _jsx(TurnBody, { turn: turn, openIds: openIds, onToggle: onToggle, live: live, fold: !segment.now }, turn.id))] });
32
62
  }
33
63
  export function SegmentView({ segment, ...rest }) {
34
64
  if (segment.kind === 'quote')
package/dist/segments.js CHANGED
@@ -37,12 +37,14 @@ export function segments(turns, options = {}) {
37
37
  const answer = run[lead]?.text ? run[lead] : null;
38
38
  const work = answer ? run.slice(0, lead) : run;
39
39
  const after = answer ? run.slice(lead + 1) : [];
40
- // THE FOLD DECIDES ON WHAT IT WILL HIDE, which is the WORK's calls not the segment's. `calls` counts the
41
- // whole run, answer included, and a run whose calls all sit on its answer turn hides none of them: deciding
42
- // on that total drew a fold row reading "0 tool uses" over prose, a row naming something it did not stand
43
- // for. The row counts the hidden calls, so the decision must count the same ones.
40
+ // HOW BUSY THE STRETCH WAS decides whether to collapse it; WHAT THE COLLAPSE HIDES is what the row counts.
41
+ // Those are two different quantities and both belong: `calls` is the whole run, answer included, and it is
42
+ // the honest measure of "was this worth folding". But a run whose calls ALL sit on its answer turn hides
43
+ // none of them, and folding it anyway drew a row reading "0 tool uses" over prose — a control naming
44
+ // something it did not stand for. So the threshold stays on the run, and the fold requires that there be
45
+ // something to hide.
44
46
  const hidden = work.reduce((n, turn) => n + (turn.tools?.length || 0), 0);
45
- out.push({ kind: 'work', work, answer, after, calls, folded: fold === 'segments' && hidden >= runMin && work.length > 0, now: false });
47
+ out.push({ kind: 'work', work, answer, after, calls, folded: fold === 'segments' && calls >= runMin && hidden > 0, now: false });
46
48
  run = [];
47
49
  };
48
50
  for (const turn of turns) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/transcript-ui",
3
- "version": "0.7.0-next.12",
3
+ "version": "0.7.0-next.14",
4
4
  "type": "module",
5
5
  "description": "React components that draw a normalized agent transcript — the person quoted, the agent as the page, a tool call as a sentence, the work folded behind its answer — with the fold, the prose renderer, the tool vocabulary, the labels and the design tokens all tunable.",
6
6
  "files": [
@@ -24,7 +24,7 @@
24
24
  "test": "npm run build && tsx --test src/*.test.tsx"
25
25
  },
26
26
  "dependencies": {
27
- "@spexcode/transcript": "0.7.0-next.12"
27
+ "@spexcode/transcript": "0.7.0-next.14"
28
28
  },
29
29
  "peerDependencies": {
30
30
  "react": "^18.2.0 || ^19.0.0"
package/styles.css CHANGED
@@ -25,6 +25,8 @@
25
25
  --tx-radius: var(--radius, 6px);
26
26
  --tx-space-1: 4px; --tx-space-2: 6px; --tx-space-3: 8px; --tx-space-4: 12px; --tx-space-5: 16px; --tx-space-6: 22px;
27
27
  --tx-dur-rise: var(--dur-rise, 120ms);
28
+ /* a fold is the same gesture the host's own panels fold with, so it borrows that duration before its own default */
29
+ --tx-dur-fold: var(--dur-fold, var(--dur-panel, 170ms));
28
30
  min-width: 0;
29
31
  color: var(--tx-ink);
30
32
  }
@@ -59,7 +61,7 @@
59
61
  .tx-quote-more { position: absolute; right: 10px; bottom: 6px; z-index: 1; padding: 0; color: var(--tx-blue); background: none; border: 0; font: inherit; font-family: var(--tx-ui-font); font-size: var(--tx-type-caption); cursor: pointer; }
60
62
 
61
63
  /* THE WORK SEGMENT'S ONE LINE: a bounded sentence — the count, the kinds, the chevron trailing. */
62
- .tx-work { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; }
64
+ .tx-work { display: flex; flex-direction: column; align-items: flex-start; }
63
65
  .tx-work-row {
64
66
  display: inline-flex; align-items: baseline; gap: 7px; max-width: 100%; padding: var(--tx-space-2) var(--tx-space-3); margin-left: calc(var(--tx-space-3) * -1);
65
67
  color: var(--tx-muted); background: none; border: 0; border-radius: var(--tx-radius);
@@ -68,7 +70,22 @@
68
70
  .tx-work-row:hover { background: var(--tx-wash-hover); color: var(--tx-ink2); }
69
71
  .tx-work-lead { flex: none; color: var(--tx-ink2); font-weight: var(--tx-weight-medium); }
70
72
  .tx-work-detail { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
71
- .tx-work-body { align-self: stretch; display: flex; flex-direction: column; gap: 14px; padding-left: 10px; border-left: 1px solid var(--tx-edge); }
73
+ .tx-work-body { display: flex; flex-direction: column; gap: 14px; padding-left: 10px; border-left: 1px solid var(--tx-edge); }
74
+
75
+ /* THE FOLD TRAVELS TO ITS ROW. A stretch of work folds when the agent speaks and when the next message ends
76
+ the stretch, and it opens and shuts again under the reader's own hand; all three are the same movement, so
77
+ they are one wrapper. The height it travels is the content's, which nothing here measures: a one-row grid
78
+ going `1fr → 0fr` is how CSS reaches an auto height on its own. The outgoing copy is held by
79
+ [[transcript-view]] for exactly one duration and cannot outlive it. The space under the row travels with
80
+ the collapse (measured: as the column's `gap`, or as any margin outside the animated box, those last pixels
81
+ survive the movement and snap shut a frame after it), so the fold ends at the row's own height exactly. */
82
+ .tx-fold { display: grid; align-self: stretch; min-width: 0; margin-top: var(--tx-space-3); }
83
+ .tx-fold > * { min-height: 0; overflow: hidden; }
84
+ .tx-fold.is-opening { animation: tx-unfold var(--tx-dur-fold) ease; }
85
+ .tx-fold.is-closing { animation: tx-fold var(--tx-dur-fold) ease forwards; pointer-events: none; }
86
+ .tx-work.is-folding > .tx-work-row { animation: tx-rise var(--tx-dur-fold) ease; }
87
+ @keyframes tx-unfold { from { grid-template-rows: 0fr; margin-top: 0; opacity: 0; } to { grid-template-rows: 1fr; opacity: 1; } }
88
+ @keyframes tx-fold { from { grid-template-rows: 1fr; opacity: 1; } to { grid-template-rows: 0fr; margin-top: 0; opacity: 0; } }
72
89
 
73
90
  /* A TOOL CALL IS A SENTENCE, not a card: inline-flex, exactly as wide as what it says, capped so a long shell
74
91
  command never stretches the row into a full-width bar. The verb IS the status: no tick, no badge. */
@@ -116,7 +133,11 @@
116
133
  .tx-live.is-speaking > .tx-say:last-child .tx-say-text > :first-child > :last-child::after { content: '▍'; margin-left: 2px; color: var(--tx-muted); animation: tx-caret 1s steps(2, start) infinite; }
117
134
  @keyframes tx-caret { to { visibility: hidden; } }
118
135
  @media (prefers-reduced-motion: reduce) {
119
- .tx-live .tx-say-text, .tx-live.is-speaking > .tx-say:last-child .tx-say-text > :first-child > :last-child::after, .tx-spin, .tx-caret { animation: none; transition: none; }
136
+ .tx-live .tx-say-text, .tx-live.is-speaking > .tx-say:last-child .tx-say-text > :first-child > :last-child::after, .tx-spin, .tx-caret,
137
+ .tx-fold, .tx-fold.is-opening, .tx-fold.is-closing, .tx-work.is-folding > .tx-work-row { animation: none; transition: none; }
138
+ /* the fold still folds — it just arrives: the copy on its way out is already at nothing, and the JS half
139
+ that holds it drops it on the same timer, so no motion and nothing lingers */
140
+ .tx-fold.is-closing { grid-template-rows: 0fr; margin-top: 0; opacity: 0; }
120
141
  }
121
142
 
122
143
  /* A NARROW PANE: the quote widens and shows its own time, because there is no ruler beside the flow. */