@kingsoftcloud/agentkit-harness-ui 0.0.0-stage → 0.0.1

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.
Files changed (56) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +116 -0
  3. package/README.md +292 -2
  4. package/markdown/build/build-markdown.cjs +66 -0
  5. package/markdown/dist/THIRD_PARTY_LICENSES.txt +2993 -0
  6. package/markdown/dist/markdown.css +1 -0
  7. package/markdown/dist/markdown.mjs +325 -0
  8. package/markdown/source/markdown-factory.mjs +613 -0
  9. package/markdown/source/markdown.css +288 -0
  10. package/markdown/source/table-factory.mjs +1588 -0
  11. package/markdown/source/table.css +487 -0
  12. package/package.json +96 -4
  13. package/src/activity-groups.mjs +74 -0
  14. package/src/browser-response-groups.mjs +57 -0
  15. package/src/command-output.mjs +57 -0
  16. package/src/components.mjs +9 -0
  17. package/src/context-markers.mjs +19 -0
  18. package/src/context-status.mjs +34 -0
  19. package/src/diff.mjs +240 -0
  20. package/src/explore-tool-call.mjs +139 -0
  21. package/src/file-preview.mjs +50 -0
  22. package/src/host/native-a2a.mjs +90 -0
  23. package/src/host/native-child-file-pane.mjs +49 -0
  24. package/src/host/native-child-pane.mjs +94 -0
  25. package/src/host/native-command-pane.mjs +64 -0
  26. package/src/host/native-run-panel.mjs +202 -0
  27. package/src/host/session-task-events.mjs +247 -0
  28. package/src/host/state/native-a2a.mjs +122 -0
  29. package/src/host/state/native-child-observation.mjs +35 -0
  30. package/src/host/state/native-children.mjs +93 -0
  31. package/src/host/state/native-outcome.mjs +35 -0
  32. package/src/host/state/native-recovery.mjs +187 -0
  33. package/src/host/state/native-runtime-route.mjs +26 -0
  34. package/src/host/state/native-session-stop.mjs +40 -0
  35. package/src/host/state/native-status-panel.mjs +23 -0
  36. package/src/host/state/native-task-read-model.mjs +89 -0
  37. package/src/host/state/task-links.mjs +40 -0
  38. package/src/host/task-timeline.mjs +185 -0
  39. package/src/index.mjs +52 -0
  40. package/src/input-preview.mjs +44 -0
  41. package/src/inspector.mjs +40 -0
  42. package/src/project.mjs +132 -0
  43. package/src/protocol.mjs +362 -0
  44. package/src/react-binding.mjs +486 -0
  45. package/src/reducer.mjs +355 -0
  46. package/src/renderers.mjs +644 -0
  47. package/src/status-panel.mjs +1156 -0
  48. package/src/styles.mjs +278 -0
  49. package/src/summary-playback.mjs +39 -0
  50. package/src/summary.mjs +160 -0
  51. package/src/terminal.mjs +157 -0
  52. package/src/tool-detail.mjs +208 -0
  53. package/src/tool-error.mjs +33 -0
  54. package/src/tool-identity.mjs +40 -0
  55. package/src/view.mjs +364 -0
  56. 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 };