@kingsoftcloud/agentkit-harness-ui 0.0.0-stage → 0.0.2
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 +201 -0
- package/NOTICE +116 -0
- package/README.md +292 -2
- package/markdown/build/build-markdown.cjs +66 -0
- package/markdown/dist/THIRD_PARTY_LICENSES.txt +2993 -0
- package/markdown/dist/markdown.css +1 -0
- package/markdown/dist/markdown.mjs +325 -0
- package/markdown/source/markdown-factory.mjs +613 -0
- package/markdown/source/markdown.css +288 -0
- package/markdown/source/table-factory.mjs +1588 -0
- package/markdown/source/table.css +487 -0
- package/package.json +96 -4
- package/src/activity-groups.mjs +74 -0
- package/src/browser-response-groups.mjs +57 -0
- package/src/command-output.mjs +57 -0
- package/src/components.mjs +9 -0
- package/src/context-markers.mjs +19 -0
- package/src/context-status.mjs +34 -0
- package/src/diff.mjs +240 -0
- package/src/explore-tool-call.mjs +139 -0
- package/src/file-preview.mjs +50 -0
- package/src/host/native-a2a.mjs +90 -0
- package/src/host/native-child-file-pane.mjs +49 -0
- package/src/host/native-child-pane.mjs +94 -0
- package/src/host/native-command-pane.mjs +64 -0
- package/src/host/native-run-panel.mjs +202 -0
- package/src/host/session-task-events.mjs +247 -0
- package/src/host/state/native-a2a.mjs +122 -0
- package/src/host/state/native-child-observation.mjs +35 -0
- package/src/host/state/native-children.mjs +93 -0
- package/src/host/state/native-outcome.mjs +35 -0
- package/src/host/state/native-recovery.mjs +187 -0
- package/src/host/state/native-runtime-route.mjs +26 -0
- package/src/host/state/native-session-stop.mjs +40 -0
- package/src/host/state/native-status-panel.mjs +23 -0
- package/src/host/state/native-task-read-model.mjs +89 -0
- package/src/host/state/task-links.mjs +40 -0
- package/src/host/task-timeline.mjs +185 -0
- package/src/index.mjs +52 -0
- package/src/input-preview.mjs +44 -0
- package/src/inspector.mjs +40 -0
- package/src/project.mjs +132 -0
- package/src/protocol.mjs +362 -0
- package/src/react-binding.mjs +486 -0
- package/src/reducer.mjs +355 -0
- package/src/renderers.mjs +644 -0
- package/src/status-panel.mjs +1156 -0
- package/src/styles.mjs +278 -0
- package/src/summary-playback.mjs +39 -0
- package/src/summary.mjs +160 -0
- package/src/terminal.mjs +157 -0
- package/src/tool-detail.mjs +208 -0
- package/src/tool-error.mjs +33 -0
- package/src/tool-identity.mjs +40 -0
- package/src/view.mjs +364 -0
- package/src/work-segments.mjs +41 -0
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
// @kingsoftcloud/agentkit-harness-ui — React binding
|
|
2
|
+
//
|
|
3
|
+
// Thin adapter over the pure view model. All rendering logic lives in
|
|
4
|
+
// view.mjs + renderers.mjs; this module maps the plain element tree to
|
|
5
|
+
// React.createElement and owns the presentation-only state the view needs:
|
|
6
|
+
// - which thoughts/tools are expanded (never written back to reducer state)
|
|
7
|
+
// - desktop thought auto-collapse: a thought that stops streaming collapses
|
|
8
|
+
// back to its summary UNLESS the user opened it themselves
|
|
9
|
+
// - scroll-reader preservation: streaming thought bodies and execute output
|
|
10
|
+
// auto-follow the bottom, pause when the user scrolls up, and resume when
|
|
11
|
+
// they scroll back — matching the desktop reasoning/execute viewports
|
|
12
|
+
// - the streaming latest-line summary scrolls to its trailing edge
|
|
13
|
+
//
|
|
14
|
+
// Host actions (open file, copy, open terminal, retrieve full diff, jump to
|
|
15
|
+
// latest) are carried as `data-action` attributes on the function-free tree and
|
|
16
|
+
// dispatched from a single delegated click handler on the root, so the view
|
|
17
|
+
// stays serializable and testable with node:test.
|
|
18
|
+
//
|
|
19
|
+
// React is a peer dependency. The pure modules do not import React and run in
|
|
20
|
+
// plain Node.
|
|
21
|
+
|
|
22
|
+
import { buildViewModel } from "./view.mjs";
|
|
23
|
+
import { styles } from "./styles.mjs";
|
|
24
|
+
import { visibleItems } from "./project.mjs";
|
|
25
|
+
|
|
26
|
+
import { createSummaryPlayback } from './summary-playback.mjs';
|
|
27
|
+
|
|
28
|
+
const FOLLOW_THRESHOLD_PX = 8;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Stateful React timeline. Presentation-only state (expanded thoughts/tools,
|
|
32
|
+
* follow flag) is never written back to the reducer state.
|
|
33
|
+
*
|
|
34
|
+
* @param {{
|
|
35
|
+
* state: object,
|
|
36
|
+
* className?: string,
|
|
37
|
+
* workspaceRoot?: string,
|
|
38
|
+
* labels?: object,
|
|
39
|
+
* renderText?: (text: string, meta: {streaming: boolean}) => ReactNode,
|
|
40
|
+
* initiallyExpandedThoughts?: string[],
|
|
41
|
+
* initiallyExpandedTools?: string[],
|
|
42
|
+
* autoFollow?: boolean,
|
|
43
|
+
* styleSheet?: string | null,
|
|
44
|
+
* onJumpLatest?: () => void,
|
|
45
|
+
* onOpenFile?: (target: {path: string, kind: string, callId?: string}) => void,
|
|
46
|
+
* onCopy?: (text: string) => void,
|
|
47
|
+
* onOpenTerminal?: (target: {callId: string, cwd?: string}) => void,
|
|
48
|
+
* onRetrieveFullDiff?: (target: {callId: string, path: string}) => void,
|
|
49
|
+
* }} props
|
|
50
|
+
*/
|
|
51
|
+
// ── scroll mask helpers (reasoning.tsx / scrollMask.ts semantics) ───────────
|
|
52
|
+
// Pure functions over scroll metrics so the mask state is testable without a
|
|
53
|
+
// DOM. The mask itself is a static linear-gradient (no motion), so it stays
|
|
54
|
+
// under prefers-reduced-motion; only the auto-follow pin is direct (never
|
|
55
|
+
// smooth), satisfying reduced-motion there too.
|
|
56
|
+
const MASK_EDGE_PX = 24;
|
|
57
|
+
const MASK_THRESHOLD_PX = 1;
|
|
58
|
+
|
|
59
|
+
/** Resolve the top/bottom fade-mask state for a vertical scroll viewport. */
|
|
60
|
+
export function resolveVerticalMaskState({ clientHeight, scrollHeight, scrollTop }, previous) {
|
|
61
|
+
const maxScrollTop = Math.max(0, scrollHeight - clientHeight);
|
|
62
|
+
const next = {
|
|
63
|
+
showTop: maxScrollTop > MASK_THRESHOLD_PX && scrollTop > MASK_THRESHOLD_PX,
|
|
64
|
+
showBottom: maxScrollTop > MASK_THRESHOLD_PX && scrollTop < maxScrollTop - MASK_THRESHOLD_PX,
|
|
65
|
+
};
|
|
66
|
+
return previous?.showTop === next.showTop && previous?.showBottom === next.showBottom ? previous : next;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** CSS mask-image style for the vertical (reasoning body) fade. */
|
|
70
|
+
export function verticalMaskStyle({ showTop, showBottom }) {
|
|
71
|
+
if (!showTop && !showBottom) return undefined;
|
|
72
|
+
const topStop = showTop
|
|
73
|
+
? `transparent 0px, black ${MASK_EDGE_PX}px`
|
|
74
|
+
: `black 0px, black ${MASK_EDGE_PX}px`;
|
|
75
|
+
const bottomStop = showBottom
|
|
76
|
+
? `black calc(100% - ${MASK_EDGE_PX}px), transparent 100%`
|
|
77
|
+
: `black calc(100% - ${MASK_EDGE_PX}px), black 100%`;
|
|
78
|
+
const image = `linear-gradient(to bottom, ${topStop}, ${bottomStop})`;
|
|
79
|
+
return { WebkitMaskImage: image, maskImage: image, WebkitMaskRepeat: "no-repeat", maskRepeat: "no-repeat", WebkitMaskSize: "100% 100%", maskSize: "100% 100%" };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** CSS mask-image style for the horizontal (streaming summary) fade. */
|
|
83
|
+
export function horizontalMaskStyle(overflowing) {
|
|
84
|
+
if (!overflowing) return undefined;
|
|
85
|
+
const image = "linear-gradient(to right, transparent 0, black 16px, black calc(100% - 16px), transparent 100%)";
|
|
86
|
+
return { WebkitMaskImage: image, maskImage: image, WebkitMaskRepeat: "no-repeat", maskRepeat: "no-repeat", WebkitMaskSize: "100% 100%", maskSize: "100% 100%" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function createHarnessComponents(React) {
|
|
90
|
+
const { createElement, useState, useCallback, useRef, useEffect, useMemo, Fragment } = React;
|
|
91
|
+
|
|
92
|
+
function HarnessTimeline(props) {
|
|
93
|
+
const {
|
|
94
|
+
state, className, workspaceRoot, labels, locale, showPlanDock, theme = "system", renderText, initiallyExpandedThoughts,
|
|
95
|
+
initiallyExpandedTools, autoFollow, styleSheet, onJumpLatest, onOpenFile, onCopy,
|
|
96
|
+
onOpenTerminal, onRetrieveFullDiff, onInspectTool, onOpenSubagent,
|
|
97
|
+
} = props || {};
|
|
98
|
+
const [thoughtOpen, setThoughtOpen] = useState(() => new Map((initiallyExpandedThoughts || []).map(id => [id, true])));
|
|
99
|
+
const [toolOpen, setToolOpen] = useState(() => new Map((initiallyExpandedTools || []).map(id => [id, true])));
|
|
100
|
+
const [historyOpen, setHistoryOpen] = useState(() => new Map());
|
|
101
|
+
const [following, setFollowing] = useState(true);
|
|
102
|
+
const [reducedMotion, setReducedMotion] = useState(false);
|
|
103
|
+
const scrollRef = useRef(null);
|
|
104
|
+
const userToggledThoughts = useRef(new Set());
|
|
105
|
+
const prevStreaming = useRef(new Map());
|
|
106
|
+
|
|
107
|
+
// prefers-reduced-motion: the reasoning scroll-follow keeps pinning to the
|
|
108
|
+
// bottom (direct scrollTop, never smooth), and the fade masks stay (they
|
|
109
|
+
// are static gradients, not motion). We only record the preference so the
|
|
110
|
+
// tree can expose it for CSS and skip any motion-only flourishes.
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (typeof window !== "object" || typeof window.matchMedia !== "function") return;
|
|
113
|
+
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
114
|
+
const update = () => setReducedMotion(Boolean(query.matches));
|
|
115
|
+
update();
|
|
116
|
+
if (typeof query.addEventListener === "function") {
|
|
117
|
+
query.addEventListener("change", update);
|
|
118
|
+
return () => query.removeEventListener("change", update);
|
|
119
|
+
}
|
|
120
|
+
query.addListener(update);
|
|
121
|
+
return () => query.removeListener(update);
|
|
122
|
+
}, []);
|
|
123
|
+
|
|
124
|
+
// Root auto-follow: stay pinned to the bottom while streaming, surface a
|
|
125
|
+
// jump button when the user scrolls up. Opt-in via `autoFollow`.
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (!autoFollow) return;
|
|
128
|
+
const node = scrollRef.current;
|
|
129
|
+
if (!node || typeof node.addEventListener !== "function") return;
|
|
130
|
+
const onScroll = () => {
|
|
131
|
+
const atBottom = node.scrollHeight - node.scrollTop - node.clientHeight < FOLLOW_THRESHOLD_PX;
|
|
132
|
+
setFollowing(atBottom);
|
|
133
|
+
};
|
|
134
|
+
node.addEventListener("scroll", onScroll, { passive: true });
|
|
135
|
+
return () => node.removeEventListener("scroll", onScroll);
|
|
136
|
+
}, [autoFollow]);
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (autoFollow && following && scrollRef.current) {
|
|
140
|
+
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
141
|
+
}
|
|
142
|
+
}, [state, following, autoFollow]);
|
|
143
|
+
|
|
144
|
+
// Desktop thought auto-collapse: when a thought transitions streaming → done
|
|
145
|
+
// and the user has NOT manually toggled it, collapse it back to the summary.
|
|
146
|
+
// A thought the user opened themselves stays open. This runs after each state
|
|
147
|
+
// update, reconciling thoughtOpen from the observed streaming history.
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
const items = visibleItems(state);
|
|
150
|
+
const next = new Map(thoughtOpen);
|
|
151
|
+
let changed = false;
|
|
152
|
+
for (const item of items) {
|
|
153
|
+
if (item.kind !== "thought") continue;
|
|
154
|
+
const was = prevStreaming.current.get(item.id) ?? false;
|
|
155
|
+
const now = Boolean(item.streaming);
|
|
156
|
+
if (was && !now && !userToggledThoughts.current.has(item.id)) {
|
|
157
|
+
if (next.get(item.id) !== false) { next.set(item.id, false); changed = true; }
|
|
158
|
+
}
|
|
159
|
+
prevStreaming.current.set(item.id, now);
|
|
160
|
+
}
|
|
161
|
+
if (changed) setThoughtOpen(next);
|
|
162
|
+
}, [state, thoughtOpen]);
|
|
163
|
+
|
|
164
|
+
// Delegated click handler on the root. `<details>` fires `toggle`; button
|
|
165
|
+
// clicks bubble to `click`. The view tree carries no functions.
|
|
166
|
+
const handleClick = useCallback((event) => {
|
|
167
|
+
const target = event.target;
|
|
168
|
+
if (!target || !target.closest) return;
|
|
169
|
+
// Host action buttons carry data-action.
|
|
170
|
+
const actionEl = target.closest("[data-action]");
|
|
171
|
+
if (actionEl) {
|
|
172
|
+
const action = actionEl.getAttribute("data-action");
|
|
173
|
+
if (action === "jump-latest") {
|
|
174
|
+
if (onJumpLatest) onJumpLatest();
|
|
175
|
+
else if (scrollRef.current) {
|
|
176
|
+
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
177
|
+
setFollowing(true);
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (action === "open-file" && onOpenFile) {
|
|
182
|
+
onOpenFile({
|
|
183
|
+
path: actionEl.getAttribute("data-path") || "",
|
|
184
|
+
kind: actionEl.getAttribute("data-kind") || "file",
|
|
185
|
+
callId: actionEl.getAttribute("data-call-id") || undefined,
|
|
186
|
+
});
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (action === "copy" && onCopy) {
|
|
190
|
+
onCopy(actionEl.getAttribute("data-copy-text") || "");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (action === "open-terminal" && onOpenTerminal) {
|
|
194
|
+
onOpenTerminal({
|
|
195
|
+
callId: actionEl.getAttribute("data-call-id") || "",
|
|
196
|
+
cwd: actionEl.getAttribute("data-cwd") || undefined,
|
|
197
|
+
});
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (action === "retrieve-diff" && onRetrieveFullDiff) {
|
|
201
|
+
onRetrieveFullDiff({
|
|
202
|
+
callId: actionEl.getAttribute("data-call-id") || "",
|
|
203
|
+
path: actionEl.getAttribute("data-path") || "",
|
|
204
|
+
});
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (action === 'open-subagent' && onOpenSubagent) {
|
|
208
|
+
const callId = actionEl.getAttribute('data-call-id');
|
|
209
|
+
const child = state.items.find(item => item.callId === callId)?.child;
|
|
210
|
+
if (child) onOpenSubagent({ callId, ...child });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (action === "inspect-tool" && onInspectTool) {
|
|
214
|
+
onInspectTool({ callId: actionEl.getAttribute("data-call-id") || "" });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// Disclosure toggles (thought/tool summaries).
|
|
219
|
+
const history = target.closest('summary[data-testid="work-history-toggle"]');
|
|
220
|
+
if (history) {
|
|
221
|
+
event.preventDefault(); const details = history.parentElement, id = details.getAttribute('data-item-id');
|
|
222
|
+
setHistoryOpen(previous => new Map(previous).set(id, !details.open)); return;
|
|
223
|
+
}
|
|
224
|
+
const summary = target.closest('summary[data-testid="thought-toggle"], summary[data-testid="tool-toggle"]');
|
|
225
|
+
if (summary) {
|
|
226
|
+
const details = summary.parentElement;
|
|
227
|
+
const itemId = details.getAttribute("data-item-id");
|
|
228
|
+
const callId = details.getAttribute("data-call-id");
|
|
229
|
+
event.preventDefault();
|
|
230
|
+
if (itemId) {
|
|
231
|
+
userToggledThoughts.current.add(itemId);
|
|
232
|
+
setThoughtOpen(prev => { const m = new Map(prev); m.set(itemId, !details.open); return m; });
|
|
233
|
+
} else if (callId) {
|
|
234
|
+
setToolOpen(prev => { const m = new Map(prev); m.set(callId, !details.open); return m; });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}, [state, onJumpLatest, onOpenFile, onCopy, onOpenTerminal, onRetrieveFullDiff, onInspectTool, onOpenSubagent]);
|
|
238
|
+
|
|
239
|
+
const rootProps = {
|
|
240
|
+
ref: scrollRef,
|
|
241
|
+
onClick: handleClick,
|
|
242
|
+
className: className ? `harness-timeline-shell ${className}` : "harness-timeline-shell",
|
|
243
|
+
"data-reduced-motion": reducedMotion ? "true" : "false",
|
|
244
|
+
"data-theme": theme,
|
|
245
|
+
};
|
|
246
|
+
const callbacks = useMemo(() => ({ onOpenFile, onCopy, onOpenTerminal, onRetrieveFullDiff, onInspectTool, onOpenSubagent }),
|
|
247
|
+
[onOpenFile, onCopy, onOpenTerminal, onRetrieveFullDiff, onInspectTool, onOpenSubagent]);
|
|
248
|
+
const tree = buildViewModel(state, {
|
|
249
|
+
thoughtOpen, toolOpen, historyOpen, labels, locale, showPlanDock, workspaceRoot, following, rootProps, ...callbacks,
|
|
250
|
+
});
|
|
251
|
+
const rendered = renderTree(tree, renderText, callbacks, React, reducedMotion);
|
|
252
|
+
if (styleSheet === null) return rendered;
|
|
253
|
+
return createElement(Fragment, null,
|
|
254
|
+
createElement("style", { "data-harness-ui": true }, styleSheet === undefined ? styles : styleSheet),
|
|
255
|
+
rendered);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Recursively map a plain view-model node to a React element. Nodes that own
|
|
261
|
+
* scroll-reader behavior (streaming thought bodies, execute output, streaming
|
|
262
|
+
* summary) are wrapped in small behavior components that attach their own
|
|
263
|
+
* refs/effects; everything else maps straight through.
|
|
264
|
+
*/
|
|
265
|
+
function renderTree(node, renderText, callbacks, React, reducedMotion) {
|
|
266
|
+
if (node == null || node === false) return null;
|
|
267
|
+
if (typeof node !== "object") return node;
|
|
268
|
+
const { createElement } = React;
|
|
269
|
+
const testid = node.props?.["data-testid"];
|
|
270
|
+
const props = node.props || {};
|
|
271
|
+
|
|
272
|
+
if (testid === "execute-command") {
|
|
273
|
+
return createElement(CommandBox, {
|
|
274
|
+
key: props["data-call-id"], type: node.type, props,
|
|
275
|
+
renderChildren: (node.children || []).map(c => renderTree(c, renderText, callbacks, React, reducedMotion)),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Streaming thought body: auto-follow bottom, pause on user scroll-up, and
|
|
280
|
+
// apply a vertical fade mask (top/bottom) when content overflows — matches
|
|
281
|
+
// the desktop reasoning content viewport (reasoning.tsx + scrollMask).
|
|
282
|
+
if (testid === "thought-body") {
|
|
283
|
+
return createElement(FollowBox, {
|
|
284
|
+
key: props["data-item-id"],
|
|
285
|
+
streaming: props["data-streaming"] === "true",
|
|
286
|
+
axis: "vertical",
|
|
287
|
+
mask: "vertical",
|
|
288
|
+
reducedMotion,
|
|
289
|
+
renderChildren: (node.children || []).map(c => renderTree(c, renderText, callbacks, React, reducedMotion)),
|
|
290
|
+
type: node.type,
|
|
291
|
+
props: node.props,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
// Execute output: same auto-follow/freeze semantics while streaming.
|
|
295
|
+
if (testid === "bash-output-preview-full" || testid === "bash-result-output") {
|
|
296
|
+
return createElement(FollowBox, {
|
|
297
|
+
key: props["data-call-id"] || testid,
|
|
298
|
+
streaming: props["data-streaming"] === "true",
|
|
299
|
+
axis: "vertical",
|
|
300
|
+
freezeWhileReading: true,
|
|
301
|
+
mask: "vertical",
|
|
302
|
+
renderChildren: (node.children || []).map(c => renderTree(c, renderText, callbacks, React, reducedMotion)),
|
|
303
|
+
type: node.type,
|
|
304
|
+
props: node.props,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
// Streaming latest-line summary: keep the trailing edge in view as it grows
|
|
308
|
+
// and apply a horizontal fade mask when the single line overflows its track.
|
|
309
|
+
if (testid === "thought-preview") {
|
|
310
|
+
return createElement(ScrollEnd, {
|
|
311
|
+
key: props["data-item-id"] || testid,
|
|
312
|
+
reducedMotion,
|
|
313
|
+
renderChildren: (node.children || []).map(c => renderTree(c, renderText, callbacks, React, reducedMotion)),
|
|
314
|
+
type: node.type,
|
|
315
|
+
props: node.props,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const isText = testid === "text-block" && props["data-role"] !== "user";
|
|
320
|
+
const text = node.children?.[0]?.children?.[0];
|
|
321
|
+
const children = isText && renderText ? [renderText(String(text ?? ""), { streaming: props["data-streaming"] === "true" })]
|
|
322
|
+
: (node.children || []).map(child => renderTree(child, renderText, callbacks, React, reducedMotion));
|
|
323
|
+
return createElement(node.type, { key: props["data-item-id"] ?? props["data-call-id"], ...props }, ...children);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── scroll mask helpers (reasoning.tsx / scrollMask.ts semantics) ───────────
|
|
327
|
+
// The pure mask-state resolvers and style builders live at module scope (see
|
|
328
|
+
// exports above) so they are testable without React. The behavior components
|
|
329
|
+
// below consume them directly.
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* A scroll viewport that auto-follows its trailing edge while content grows,
|
|
333
|
+
* and yields to the reader when they scroll away. While `streaming` is true and
|
|
334
|
+
* the user is at the bottom, each content change re-pins to the bottom; once
|
|
335
|
+
* the user scrolls up, following stops until they scroll back to the bottom.
|
|
336
|
+
* When `mask` is "vertical" it also reports the top/bottom fade mask state via
|
|
337
|
+
* `data-reasoning-scroll-mask` and a mask style. Honors prefers-reduced-motion
|
|
338
|
+
* (direct scrollTop, never smooth; the mask is a static gradient).
|
|
339
|
+
*/
|
|
340
|
+
function CommandBox({ renderChildren, type, props }) {
|
|
341
|
+
const ref = useRef(null);
|
|
342
|
+
const [expanded, setExpanded] = useState(false);
|
|
343
|
+
const [overflowing, setOverflowing] = useState(false);
|
|
344
|
+
useEffect(() => {
|
|
345
|
+
const node = ref.current;
|
|
346
|
+
if (!node || expanded) return;
|
|
347
|
+
const measure = () => setOverflowing(node.scrollHeight > node.clientHeight + 1);
|
|
348
|
+
measure();
|
|
349
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
350
|
+
const observer = new ResizeObserver(measure);
|
|
351
|
+
observer.observe(node);
|
|
352
|
+
return () => observer.disconnect();
|
|
353
|
+
}, [renderChildren, expanded]);
|
|
354
|
+
return React.createElement(type, props,
|
|
355
|
+
React.createElement("div", { ref, className: "harness-command-viewport", "data-expanded": String(expanded), tabIndex: expanded ? 0 : undefined }, renderChildren[0]),
|
|
356
|
+
overflowing || expanded ? React.createElement("button", {
|
|
357
|
+
type: "button", className: "harness-command-toggle", "aria-expanded": expanded,
|
|
358
|
+
onClick: () => { if (ref.current) ref.current.scrollTop = 0; setExpanded(value => !value); },
|
|
359
|
+
}, props[expanded ? "data-collapse-label" : "data-expand-label"]) : null,
|
|
360
|
+
...renderChildren.slice(1));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function FollowBox({ streaming, axis, mask, freezeWhileReading = false, reducedMotion, renderChildren, type, props }) {
|
|
364
|
+
const ref = useRef(null);
|
|
365
|
+
const following = useRef(true);
|
|
366
|
+
const prevTop = useRef(0);
|
|
367
|
+
const hasStreamed = useRef(streaming);
|
|
368
|
+
const [frozen, setFrozen] = useState(null);
|
|
369
|
+
const [maskState, setMaskState] = useState({ showTop: false, showBottom: false });
|
|
370
|
+
const isFollowing = freezeWhileReading ? frozen === null : following.current;
|
|
371
|
+
const display = frozen ?? renderChildren;
|
|
372
|
+
|
|
373
|
+
useEffect(() => {
|
|
374
|
+
const node = ref.current;
|
|
375
|
+
if (!node) return;
|
|
376
|
+
if (streaming) hasStreamed.current = true;
|
|
377
|
+
if ((freezeWhileReading ? hasStreamed.current : streaming) && isFollowing) {
|
|
378
|
+
if (axis === "vertical") node.scrollTop = node.scrollHeight;
|
|
379
|
+
else node.scrollLeft = node.scrollWidth;
|
|
380
|
+
prevTop.current = node.scrollTop;
|
|
381
|
+
}
|
|
382
|
+
if (mask === "vertical") {
|
|
383
|
+
setMaskState(previous => resolveVerticalMaskState({ clientHeight: node.clientHeight, scrollHeight: node.scrollHeight, scrollTop: node.scrollTop }, previous));
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
useEffect(() => {
|
|
388
|
+
const node = ref.current;
|
|
389
|
+
if (!node || mask !== "vertical" || typeof ResizeObserver === "undefined") return;
|
|
390
|
+
const sync = () => {
|
|
391
|
+
setMaskState(previous => resolveVerticalMaskState({ clientHeight: node.clientHeight, scrollHeight: node.scrollHeight, scrollTop: node.scrollTop }, previous));
|
|
392
|
+
};
|
|
393
|
+
sync();
|
|
394
|
+
const observer = new ResizeObserver(sync);
|
|
395
|
+
observer.observe(node);
|
|
396
|
+
return () => observer.disconnect();
|
|
397
|
+
}, [mask]);
|
|
398
|
+
|
|
399
|
+
const onScroll = (event) => {
|
|
400
|
+
const el = event.currentTarget;
|
|
401
|
+
const atEnd = axis === "vertical"
|
|
402
|
+
? el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD_PX
|
|
403
|
+
: el.scrollWidth - el.scrollLeft - el.clientWidth <= FOLLOW_THRESHOLD_PX;
|
|
404
|
+
if (freezeWhileReading && hasStreamed.current && isFollowing && el.scrollTop < prevTop.current && !atEnd) {
|
|
405
|
+
setFrozen(display);
|
|
406
|
+
} else if (freezeWhileReading && !isFollowing && atEnd) {
|
|
407
|
+
setFrozen(null);
|
|
408
|
+
} else if (!freezeWhileReading && following.current && el.scrollTop < prevTop.current && !atEnd) {
|
|
409
|
+
following.current = false;
|
|
410
|
+
} else if (!freezeWhileReading && !following.current && atEnd) {
|
|
411
|
+
following.current = true;
|
|
412
|
+
}
|
|
413
|
+
prevTop.current = el.scrollTop;
|
|
414
|
+
if (mask === "vertical") {
|
|
415
|
+
setMaskState(previous => resolveVerticalMaskState({ clientHeight: el.clientHeight, scrollHeight: el.scrollHeight, scrollTop: el.scrollTop }, previous));
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
// Reset following when a stream (re)starts so a fresh block pins again.
|
|
420
|
+
useEffect(() => { if (streaming) following.current = true; }, [streaming]);
|
|
421
|
+
|
|
422
|
+
const scrollProps = { ref, onScroll, "data-following": isFollowing ? "true" : "false" };
|
|
423
|
+
if (mask === "vertical") {
|
|
424
|
+
const maskData = maskState.showTop && maskState.showBottom ? "both"
|
|
425
|
+
: maskState.showTop ? "top"
|
|
426
|
+
: maskState.showBottom ? "bottom" : "none";
|
|
427
|
+
scrollProps["data-reasoning-scroll-mask"] = maskData;
|
|
428
|
+
const ms = verticalMaskStyle(maskState);
|
|
429
|
+
if (ms) scrollProps.style = { ...(props.style || {}), ...ms };
|
|
430
|
+
}
|
|
431
|
+
return React.createElement(type, { ...props, ...scrollProps }, ...display);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** A horizontal viewport that keeps its trailing edge visible as text grows,
|
|
435
|
+
* and reports a horizontal fade mask when the line overflows its track. */
|
|
436
|
+
function ScrollEnd({ reducedMotion, renderChildren, type, props }) {
|
|
437
|
+
const initial = { key: props['data-line-key'], text: props['data-line-text'] };
|
|
438
|
+
const [frame, setFrame] = useState(() => ({ current: initial, previous: null, animate: false }));
|
|
439
|
+
const playback = useRef(null);
|
|
440
|
+
useEffect(() => {
|
|
441
|
+
playback.current = createSummaryPlayback(initial, next => setFrame(previous => next.refresh
|
|
442
|
+
? { ...previous, current: next.current } : next));
|
|
443
|
+
return () => { playback.current.dispose(); playback.current = null; };
|
|
444
|
+
}, []);
|
|
445
|
+
useEffect(() => { playback.current?.update(initial, !reducedMotion); }, [initial.key, initial.text, reducedMotion]);
|
|
446
|
+
const ref = useRef(null);
|
|
447
|
+
const [overflowing, setOverflowing] = useState(false);
|
|
448
|
+
useEffect(() => {
|
|
449
|
+
const node = ref.current;
|
|
450
|
+
if (!node) return;
|
|
451
|
+
node.scrollLeft = node.scrollWidth;
|
|
452
|
+
const next = node.scrollWidth > node.clientWidth + 1;
|
|
453
|
+
setOverflowing(prev => (prev === next ? prev : next));
|
|
454
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
455
|
+
const sync = () => {
|
|
456
|
+
node.scrollLeft = node.scrollWidth;
|
|
457
|
+
setOverflowing(node.scrollWidth > node.clientWidth + 1);
|
|
458
|
+
};
|
|
459
|
+
const observer = new ResizeObserver(sync);
|
|
460
|
+
observer.observe(node);
|
|
461
|
+
return () => observer.disconnect();
|
|
462
|
+
});
|
|
463
|
+
const maskData = overflowing ? "both" : "none";
|
|
464
|
+
const ms = horizontalMaskStyle(overflowing);
|
|
465
|
+
const scrollProps = { ref, "data-reasoning-streaming-mask": maskData };
|
|
466
|
+
if (ms) scrollProps.style = { ...(props.style || {}), ...ms };
|
|
467
|
+
return React.createElement(type, { ...props, ...scrollProps },
|
|
468
|
+
React.createElement('span', { className: 'harness-summary-roll', 'data-animated': String(frame.animate) },
|
|
469
|
+
frame.animate && frame.previous ? React.createElement('span', {
|
|
470
|
+
key: 'out:' + frame.previous.key, className: 'harness-summary-out', 'aria-hidden': true,
|
|
471
|
+
}, frame.previous.text) : null,
|
|
472
|
+
React.createElement('span', { key: frame.current.key, className: frame.animate ? 'harness-summary-in' : '' }, frame.current.text)));
|
|
473
|
+
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
function SubagentPane({ state, loading, error, omittedRows = 0, omittedChars = 0, labels = {}, ...timeline }) {
|
|
478
|
+
return React.createElement('section', { className: 'harness-subagent-pane', 'data-testid': 'subagent-pane', 'aria-readonly': true },
|
|
479
|
+
error ? React.createElement('p', { role: 'alert' }, error) : loading ? React.createElement('p', { role: 'status' }, labels.childSyncing || 'Syncing') : null,
|
|
480
|
+
omittedRows || omittedChars ? React.createElement('p', { className: 'harness-subagent-window-notice' }, labels.childOmitted || 'Earlier rows or long fields are omitted in this observation window.') : null,
|
|
481
|
+
state ? React.createElement(HarnessTimeline, { state, labels, ...timeline }) : null);
|
|
482
|
+
}
|
|
483
|
+
return { HarnessTimeline, SubagentPane };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export { buildViewModel, styles };
|