@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.
- package/dist/TranscriptView.js +31 -1
- package/dist/segments.js +7 -5
- package/package.json +2 -2
- package/styles.css +24 -3
package/dist/TranscriptView.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
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' &&
|
|
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.
|
|
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.
|
|
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;
|
|
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 {
|
|
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
|
|
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. */
|