@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.
- 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,208 @@
|
|
|
1
|
+
// @kingsoftcloud/agentkit-harness-ui — tool call detail blocks
|
|
2
|
+
//
|
|
3
|
+
// One stable bounded detail block per call identity. Presentation consumes
|
|
4
|
+
// host-supplied input only; it never reads files or executes commands.
|
|
5
|
+
|
|
6
|
+
import { truncateText, redactValue, boundedDetailLines } from "./summary.mjs";
|
|
7
|
+
|
|
8
|
+
/** Maximum detail lines for a single tool block (safety + size contract). */
|
|
9
|
+
export const MAX_TOOL_DETAIL_LINES = 4;
|
|
10
|
+
const MAX_LINE_CHARS = 120;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Stable action title for a tool. Known tools replace the generic lifecycle
|
|
14
|
+
* title with a human audit title; unknown/MCP tools fall back to the tool name.
|
|
15
|
+
* Never prefixed with `Tool` and never carries lifecycle status text.
|
|
16
|
+
* @param {string} name
|
|
17
|
+
* @param {Record<string, unknown>} [input]
|
|
18
|
+
* @returns {string}
|
|
19
|
+
*/
|
|
20
|
+
export function toolTitle(name, input = {}) {
|
|
21
|
+
const tool = String(name || "tool");
|
|
22
|
+
const path = typeof input.path === "string" ? input.path
|
|
23
|
+
: typeof input.file_path === "string" ? input.file_path
|
|
24
|
+
: typeof input.path === "undefined" && typeof input.file_path === "string" ? input.file_path
|
|
25
|
+
: "";
|
|
26
|
+
const nativeTitles = { read_workspace_file: '读取', write_workspace_file: '写入', edit_workspace_file: '修改',
|
|
27
|
+
apply_workspace_patch: '应用补丁', list_workspace_files: '列出文件', search_workspace_files: '搜索文件', run_command: '运行命令' };
|
|
28
|
+
if (nativeTitles[tool]) return `${nativeTitles[tool]}${path ? ` ${workspaceRelative(path)}` : ''}`;
|
|
29
|
+
switch (tool) {
|
|
30
|
+
case "Read":
|
|
31
|
+
return path ? `Read ${workspaceRelative(path)}` : "Read";
|
|
32
|
+
case "Edit":
|
|
33
|
+
case "Write":
|
|
34
|
+
return path ? `${tool} ${workspaceRelative(path)}` : tool;
|
|
35
|
+
case "Glob":
|
|
36
|
+
return patternTitle("Glob", input);
|
|
37
|
+
case "Grep":
|
|
38
|
+
return patternTitle("Grep", input);
|
|
39
|
+
case "WebFetch":
|
|
40
|
+
case "WebSearch":
|
|
41
|
+
return urlTitle(tool, input);
|
|
42
|
+
default:
|
|
43
|
+
return tool;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function patternTitle(tool, input) {
|
|
48
|
+
const pattern = typeof input.pattern === "string" ? input.pattern : "";
|
|
49
|
+
return pattern ? `${tool} ${truncateText(pattern, { maxLines: 1, maxChars: 60 })}` : tool;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function urlTitle(tool, input) {
|
|
53
|
+
const url = typeof input.url === "string" ? input.url : "";
|
|
54
|
+
return url ? `${tool} ${truncateText(url, { maxLines: 1, maxChars: 80 })}` : tool;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Workspace-relative path when inside the workspace, absolute otherwise. The
|
|
59
|
+
* workspace root is host-supplied (the UI does not detect it); when no root is
|
|
60
|
+
* configured the path is shown unchanged so the host stays the source of truth.
|
|
61
|
+
* @param {string} path
|
|
62
|
+
* @param {string} [workspaceRoot]
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
export function workspaceRelative(path, workspaceRoot) {
|
|
66
|
+
const p = String(path || "");
|
|
67
|
+
if (!workspaceRoot) return p;
|
|
68
|
+
const root = workspaceRoot.endsWith("/") ? workspaceRoot : workspaceRoot + "/";
|
|
69
|
+
if (p === workspaceRoot || p === workspaceRoot.replace(/\/$/, "")) return ".";
|
|
70
|
+
if (p.startsWith(root)) return p.slice(root.length);
|
|
71
|
+
return p;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Bounded detail lines for a tool input. Known tools prefer human audit fields;
|
|
76
|
+
* unknown/MCP tools get a generic redacted preview capped at
|
|
77
|
+
* MAX_TOOL_DETAIL_LINES. Large content blobs are never dumped verbatim.
|
|
78
|
+
* @param {string} name
|
|
79
|
+
* @param {Record<string, unknown>} [input]
|
|
80
|
+
* @param {{workspaceRoot?: string, maxLines?: number, maxChars?: number}} [opts]
|
|
81
|
+
* @returns {{key: string, value: string}[]}
|
|
82
|
+
*/
|
|
83
|
+
export function toolDetailLines(name, input = {}, opts = {}) {
|
|
84
|
+
const tool = String(name || "tool");
|
|
85
|
+
const maxLines = opts.maxLines && opts.maxLines > 0 ? Math.min(opts.maxLines, MAX_TOOL_DETAIL_LINES) : MAX_TOOL_DETAIL_LINES;
|
|
86
|
+
const maxChars = opts.maxChars && opts.maxChars > 0 ? opts.maxChars : MAX_LINE_CHARS;
|
|
87
|
+
const detail = knownToolDetail(tool, input, opts.workspaceRoot, maxLines, maxChars);
|
|
88
|
+
if (detail) return detail;
|
|
89
|
+
return boundedDetailLines(input, { maxLines, maxChars });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function knownToolDetail(tool, input, workspaceRoot, maxLines, maxChars) {
|
|
93
|
+
const pushBounded = (rows, key, value) => {
|
|
94
|
+
if (rows.length >= maxLines) return rows;
|
|
95
|
+
if (value == null || value === "") return rows;
|
|
96
|
+
rows.push({ key, value: truncateText(String(value), { maxLines: 1, maxChars }) });
|
|
97
|
+
return rows;
|
|
98
|
+
};
|
|
99
|
+
let rows = [];
|
|
100
|
+
switch (tool) {
|
|
101
|
+
case "Bash":
|
|
102
|
+
pushBounded(rows, "command", input.command);
|
|
103
|
+
pushBounded(rows, "cwd", input.cwd);
|
|
104
|
+
if (input.timeout != null) pushBounded(rows, "timeout", input.timeout);
|
|
105
|
+
if (input.background) pushBounded(rows, "background", "true");
|
|
106
|
+
return rows.slice(0, maxLines);
|
|
107
|
+
case "Read":
|
|
108
|
+
pushBounded(rows, "offset", input.offset);
|
|
109
|
+
pushBounded(rows, "limit", input.limit);
|
|
110
|
+
pushBounded(rows, "pages", input.pages);
|
|
111
|
+
return rows.slice(0, maxLines);
|
|
112
|
+
case "Write":
|
|
113
|
+
pushBounded(rows, "offset", input.offset);
|
|
114
|
+
pushBounded(rows, "limit", input.limit);
|
|
115
|
+
pushBounded(rows, "pages", input.pages);
|
|
116
|
+
pushBounded(rows, "replace_all", input.replace_all);
|
|
117
|
+
return rows.slice(0, maxLines);
|
|
118
|
+
case "Edit":
|
|
119
|
+
// file_path is in the title; old_string/new_string are the audit surface
|
|
120
|
+
// but are summarized, not dumped. replace_all is omitted (already audited
|
|
121
|
+
// via the diff), matching the reference Edit rule.
|
|
122
|
+
return [];
|
|
123
|
+
case "Grep":
|
|
124
|
+
pushBounded(rows, "path", input.path ? workspaceRelative(input.path, workspaceRoot) : input.path);
|
|
125
|
+
pushBounded(rows, "glob", input.glob);
|
|
126
|
+
pushBounded(rows, "output_mode", input.output_mode);
|
|
127
|
+
pushBounded(rows, "type", input.type);
|
|
128
|
+
return rows.slice(0, maxLines);
|
|
129
|
+
case "Glob":
|
|
130
|
+
pushBounded(rows, "path", input.path ? workspaceRelative(input.path, workspaceRoot) : input.path);
|
|
131
|
+
return rows.slice(0, maxLines);
|
|
132
|
+
case "WebFetch":
|
|
133
|
+
pushBounded(rows, "prompt", typeof input.prompt === "string" ? input.prompt : "");
|
|
134
|
+
return rows.slice(0, maxLines);
|
|
135
|
+
case "Agent":
|
|
136
|
+
pushBounded(rows, "description", input.description);
|
|
137
|
+
pushBounded(rows, "subagent_type", input.subagent_type);
|
|
138
|
+
if (input.background) pushBounded(rows, "background", "true");
|
|
139
|
+
return rows.slice(0, maxLines);
|
|
140
|
+
case "Skill":
|
|
141
|
+
pushBounded(rows, "skill", input.skill);
|
|
142
|
+
return rows.slice(0, maxLines);
|
|
143
|
+
case "TodoWrite":
|
|
144
|
+
return todoDetail(input, maxLines, maxChars);
|
|
145
|
+
default:
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function todoDetail(input, maxLines, maxChars) {
|
|
151
|
+
const todos = Array.isArray(input.todos) ? input.todos : [];
|
|
152
|
+
if (!todos.length) return [];
|
|
153
|
+
const inProgress = todos.find(t => t && t.status === "in_progress");
|
|
154
|
+
const rows = [];
|
|
155
|
+
rows.push({ key: "todos", value: String(todos.length) });
|
|
156
|
+
if (inProgress && rows.length < maxLines) {
|
|
157
|
+
rows.push({ key: "in_progress", value: truncateText(String(inProgress.content || ""), { maxLines: 1, maxChars }) });
|
|
158
|
+
}
|
|
159
|
+
return rows.slice(0, maxLines);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Bounded file_diff preview from a `tool.result` display payload. Returns null
|
|
164
|
+
* when the display is not a supported file_diff. The preview keeps a small set
|
|
165
|
+
* of hunk lines with `+`/`-` markers and a path summary; the full patch is
|
|
166
|
+
* never rendered verbatim.
|
|
167
|
+
* @param {Record<string, unknown>} [display]
|
|
168
|
+
* @param {{maxLines?: number}} [opts]
|
|
169
|
+
* @returns {{path: string, lines: {marker: string, text: string}[], truncated: boolean}|null}
|
|
170
|
+
*/
|
|
171
|
+
export function fileDiffPreview(display, opts = {}) {
|
|
172
|
+
if (!display || display.kind !== "file_diff") return null;
|
|
173
|
+
const maxLines = opts.maxLines && opts.maxLines > 0 ? Math.floor(opts.maxLines) : 8;
|
|
174
|
+
const path = String(display.path || "");
|
|
175
|
+
const patch = typeof display.patch === "string" ? display.patch : "";
|
|
176
|
+
const lines = [];
|
|
177
|
+
let truncated = false;
|
|
178
|
+
for (const raw of patch.split("\n")) {
|
|
179
|
+
if (lines.length >= maxLines) { truncated = true; break; }
|
|
180
|
+
if (!raw) continue;
|
|
181
|
+
if (raw.startsWith("@@")) continue;
|
|
182
|
+
if (raw.startsWith("+++") || raw.startsWith("---")) continue;
|
|
183
|
+
const marker = raw.startsWith("+") ? "+" : raw.startsWith("-") ? "-" : " ";
|
|
184
|
+
lines.push({ marker, text: truncateText(raw.slice(1), { maxLines: 1, maxChars: MAX_LINE_CHARS }) });
|
|
185
|
+
}
|
|
186
|
+
if (patch && !lines.length) {
|
|
187
|
+
lines.push({ marker: " ", text: "(no viewable lines)" });
|
|
188
|
+
}
|
|
189
|
+
return { path, lines, truncated };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Redact an error message for user-facing display. Keeps the message concise
|
|
194
|
+
* and bounded; never exposes values that may carry secrets in the raw error.
|
|
195
|
+
* @param {string} error
|
|
196
|
+
* @param {{maxChars?: number}} [opts]
|
|
197
|
+
* @returns {string}
|
|
198
|
+
*/
|
|
199
|
+
export function redactError(error, opts = {}) {
|
|
200
|
+
const maxChars = opts.maxChars && opts.maxChars > 0 ? opts.maxChars : 200;
|
|
201
|
+
const value = String(error || "");
|
|
202
|
+
// The error color/placement already expresses the type; keep the message
|
|
203
|
+
// itself, bounded. Strip common secret patterns defensively.
|
|
204
|
+
const cleaned = value.replace(/(token|secret|password|api[_-]?key|authorization)\s*[=:]\s*\S+/gi, "$1=[redacted]");
|
|
205
|
+
return truncateText(cleaned, { maxLines: 3, maxChars });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export { redactValue };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { redactError } from './tool-detail.mjs';
|
|
2
|
+
|
|
3
|
+
const summaries = {
|
|
4
|
+
invalid_delivery_scope: '交付范围声明无效,尚未更新',
|
|
5
|
+
web_definition_mismatch: '网页工具的接入配置不一致,尚未执行',
|
|
6
|
+
web_search_not_configured: '网页搜索服务尚未配置',
|
|
7
|
+
capture_limit_exceeded: '工作区变更清单超出观测上限,命令未执行',
|
|
8
|
+
command_policy_blocked: '命令不符合当前执行规则,尚未执行',
|
|
9
|
+
policy_denied: '当前权限规则未允许这次操作',
|
|
10
|
+
command_evidence_incomplete: '命令回执尚不完整,需要核对执行结果',
|
|
11
|
+
studio_native_browser_unavailable: '本地浏览器不可用,尚未完成网页验证',
|
|
12
|
+
child_capability_unavailable: '该子智能体缺少任务所需工具,尚未启动',
|
|
13
|
+
child_capability_request_invalid: '子任务所需工具声明无效,尚未启动',
|
|
14
|
+
child_role_unavailable: '所选子智能体不可用,尚未启动',
|
|
15
|
+
canceled: '操作已停止',
|
|
16
|
+
selector_ambiguous: '选择器匹配多个元素,请选择唯一目标',
|
|
17
|
+
selector_not_found: '未找到指定元素,请核对当前页面',
|
|
18
|
+
selector_not_visible: '指定元素当前不可见',
|
|
19
|
+
selector_not_actionable: '指定元素当前无法交互',
|
|
20
|
+
selector_resolution_failed: '无法解析或读取指定目标',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Detailed evidence remains expandable; compact rows do not dump wire JSON. */
|
|
24
|
+
export function toolErrorSummary(error, { locale } = {}) {
|
|
25
|
+
let value;
|
|
26
|
+
try { value = typeof error === 'string' ? JSON.parse(error) : error; } catch { /* plain error */ }
|
|
27
|
+
if (value && typeof value === 'object') {
|
|
28
|
+
const code = value.error_type ?? value.code ?? value.error_code ?? value.error?.code;
|
|
29
|
+
if (String(locale ?? '').startsWith('zh')) return summaries[code] ?? '工具调用失败,点击查看具体原因';
|
|
30
|
+
return 'Tool call failed; expand for details';
|
|
31
|
+
}
|
|
32
|
+
return redactError(error);
|
|
33
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Protocol identity, presentation family and placement have one owner. These
|
|
2
|
+
* descriptors never change execution names or confer tool permissions. */
|
|
3
|
+
const groups = [
|
|
4
|
+
['delivery', 'diagnostics', '交付范围', ['declare_deliverables']],
|
|
5
|
+
['execute', 'conversation', '终端', ['Bash', 'bash', 'execute', 'run_command', 'Execute', 'ExecuteCommand', 'execute_command', 'RunCommand', 'shell', 'Shell']],
|
|
6
|
+
['file', 'conversation', '读取', ['Read', 'read', 'read_workspace_file']],
|
|
7
|
+
['validation', 'conversation', '检查文件', ['lint_workspace_file']],
|
|
8
|
+
['list', 'conversation', '列出文件', ['Glob', 'glob', 'List', 'list_workspace_files']],
|
|
9
|
+
['search', 'conversation', '搜索文件', ['Grep', 'grep', 'search_workspace_files', 'Search', 'search']],
|
|
10
|
+
['edit', 'conversation', '编辑', ['Edit', 'edit_workspace_file', 'multi_edit_workspace_file', 'MultiEdit', 'edit', 'StrReplace', 'Patch', 'apply_workspace_patch']],
|
|
11
|
+
['write', 'conversation', '写入', ['Write', 'write', 'write_workspace_file', 'write_workspace_files']],
|
|
12
|
+
['delete', 'conversation', '删除', ['delete_workspace_file']],
|
|
13
|
+
['agent', 'conversation', '子智能体', ['native_start_child', 'native_delegate', 'Agent', 'Task', 'subagent']],
|
|
14
|
+
['plan', 'panel', '更新任务', ['TodoWrite', 'TodoRead', 'todo_write', 'todo_read', 'update_plan']],
|
|
15
|
+
['environment', 'diagnostics', '运行环境', ['sandbox_status', 'workspace_status', 'component_status']],
|
|
16
|
+
['web-search', 'conversation', '网页搜索', ['web_search', 'WebSearch']],
|
|
17
|
+
['web-fetch', 'conversation', '网页抓取', ['web_fetch', 'WebFetch']],
|
|
18
|
+
['browser', 'conversation', '浏览器', ['browser_inspect_workspace']],
|
|
19
|
+
['skill', 'conversation', '技能', ['Skill', 'skill', 'skill_load', 'load_skill', 'skill_read', 'skill_disclose', 'preview_skill', 'execute_skills', 'skill_read_manifest', 'skill_read_instructions', 'skill_read_resource']],
|
|
20
|
+
['skill-catalog', 'conversation', '搜索技能', ['skill_catalog', 'skill_search', 'search_skills', 'list_skills', 'list_skill_spaces']],
|
|
21
|
+
['tool-search', 'conversation', '查找工具', ['tool_search', 'ToolSearch']],
|
|
22
|
+
['tool-call', 'conversation', '调用工具', ['DeferredToolCall']],
|
|
23
|
+
['command-output', 'conversation', '终端输出', ['read_command_output']],
|
|
24
|
+
['command-stop', 'conversation', '停止命令', ['stop_command']],
|
|
25
|
+
['child-control', 'conversation', '子任务', ['native_read_child', 'native_wait_child', 'native_cancel_child', 'native_merge_child']],
|
|
26
|
+
['child-report', 'conversation', '向主任务汇报', ['RespondToCoordinator']],
|
|
27
|
+
['artifact-list', 'conversation', '查看产物', ['ArtifactList']],
|
|
28
|
+
['artifact-read', 'conversation', '读取产物', ['ArtifactRead']],
|
|
29
|
+
['artifact-search', 'conversation', '搜索产物', ['ArtifactSearch']],
|
|
30
|
+
];
|
|
31
|
+
const identities = new Map(groups.flatMap(([family, placement, title, names]) => names.map(name => [name, Object.freeze({ family, placement, title })])));
|
|
32
|
+
const unknown = Object.freeze({ family: 'generic', placement: 'conversation', title: null });
|
|
33
|
+
export function toolIdentity(name) { return identities.get(String(name ?? '')) ?? unknown; }
|
|
34
|
+
export function toolConversationVisible(item) {
|
|
35
|
+
return toolIdentity(item.name).placement === 'conversation'
|
|
36
|
+
|| ['failed', 'uncertain', 'interrupted', 'waiting'].includes(item.status);
|
|
37
|
+
}
|
|
38
|
+
export function localizedToolTitle(name, fallback, locale) {
|
|
39
|
+
return String(locale ?? '').startsWith('zh') ? toolIdentity(name).title ?? fallback : fallback;
|
|
40
|
+
}
|
package/src/view.mjs
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { latestSummaryLine } from './summary-playback.mjs';
|
|
2
|
+
// @kingsoftcloud/agentkit-harness-ui — pure view model
|
|
3
|
+
//
|
|
4
|
+
// Produces a serializable element tree (React-element-shaped plain objects)
|
|
5
|
+
// from reducer state, with stable `data-testid` selectors and accessibility
|
|
6
|
+
// attributes. Carries the "no invented thinking or success" invariants:
|
|
7
|
+
// - a thought block exists only when the reducer created one from a non-empty
|
|
8
|
+
// provider delta; `thinking.start` alone never produces a visible item.
|
|
9
|
+
// - no token/character counts are ever rendered for thinking.
|
|
10
|
+
// - lifecycle/verification status belongs to the host controls, not an extra
|
|
11
|
+
// transcript footer; no task state synthesizes a closing status message.
|
|
12
|
+
// - tool errors are redacted and bounded; tool detail blocks are bounded and
|
|
13
|
+
// redacted and never dump large content verbatim.
|
|
14
|
+
//
|
|
15
|
+
// The tree holds no functions, so it is fully testable with node:test. The
|
|
16
|
+
// React binding in components.mjs maps this tree to React.createElement and
|
|
17
|
+
// attaches delegated event handlers on the root.
|
|
18
|
+
|
|
19
|
+
import { visibleItems, isWaitingForFirstToken, planProgress, DEFAULT_LABELS } from "./project.mjs";
|
|
20
|
+
import { latestNonEmptyLine, truncateText } from "./summary.mjs";
|
|
21
|
+
import { renderTool } from "./renderers.mjs";
|
|
22
|
+
import { activityGroups, activityGroupSummary } from './activity-groups.mjs';
|
|
23
|
+
import { conversationWorkSegments, completedWorkLabel } from './work-segments.mjs';
|
|
24
|
+
|
|
25
|
+
export const ELEMENT = Symbol.for("agentkit.harness-ui.element");
|
|
26
|
+
|
|
27
|
+
/** @returns {boolean} */
|
|
28
|
+
export function isElement(node) {
|
|
29
|
+
return node != null && typeof node === "object" && node.$$typeof === ELEMENT;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Create a plain element node. Null/false children and empty arrays are
|
|
34
|
+
* flattened out, matching JSX semantics, so conditional rendering stays terse.
|
|
35
|
+
*/
|
|
36
|
+
export function h(type, props, ...children) {
|
|
37
|
+
const kids = [];
|
|
38
|
+
for (const child of children) {
|
|
39
|
+
if (child == null || child === false) continue;
|
|
40
|
+
if (Array.isArray(child)) {
|
|
41
|
+
for (const c of child) {
|
|
42
|
+
if (c == null || c === false) continue;
|
|
43
|
+
kids.push(c);
|
|
44
|
+
}
|
|
45
|
+
} else kids.push(child);
|
|
46
|
+
}
|
|
47
|
+
return { $$typeof: ELEMENT, type, props: props == null ? {} : props, children: kids };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── inline SVG icons ───────────────────────────────────────────────────────
|
|
51
|
+
// Reimplemented from the Lucide icon set (ISC; see NOTICE) as plain `h` nodes so
|
|
52
|
+
// the function-free view tree carries real vector glyphs instead of emoji. No
|
|
53
|
+
// source is copied from the Lucide package; the path data is the published
|
|
54
|
+
// geometric description of each icon. Each svg is sized by CSS (`.harness-icon`).
|
|
55
|
+
const SVG_ATTRS = {
|
|
56
|
+
viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2,
|
|
57
|
+
strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", focusable: "false",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Build an icon element from a list of shape nodes. */
|
|
61
|
+
function icon(shapes, className) {
|
|
62
|
+
return h("svg", { ...SVG_ATTRS, className: `harness-icon${className ? ` ${className}` : ""}` }, ...shapes);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Brain icon — the reasoning/thought kind marker (lucide `brain`). */
|
|
66
|
+
export function brainIcon(className) {
|
|
67
|
+
return icon([
|
|
68
|
+
h("path", { d: "M12 18V5" }),
|
|
69
|
+
h("path", { d: "M15 13a4.17 4.17 0 0 1-3-4 4.17 4.17 0 0 1-3 4" }),
|
|
70
|
+
h("path", { d: "M17.598 6.5A3 3 0 1 0 12 5a3 3 0 1 0-5.598 1.5" }),
|
|
71
|
+
h("path", { d: "M17.997 5.125a4 4 0 0 1 2.526 5.77" }),
|
|
72
|
+
h("path", { d: "M18 18a4 4 0 0 0 2-7.464" }),
|
|
73
|
+
h("path", { d: "M19.967 17.483A4 4 0 1 1 12 18a4 4 0 1 1-7.967-.517" }),
|
|
74
|
+
h("path", { d: "M6 18a4 4 0 0 1-2-7.464" }),
|
|
75
|
+
h("path", { d: "M6.003 5.125a4 4 0 0 0-2.526 5.77" }),
|
|
76
|
+
], className);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Disclosure chevron (lucide `chevron-right`); rotated to point down when open. */
|
|
80
|
+
export function chevronIcon(className) {
|
|
81
|
+
return icon([h("path", { d: "m9 18 6-6-6-6" })], className);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Terminal/execute kind icon (lucide `square-terminal`). */
|
|
85
|
+
export function terminalIcon(className) {
|
|
86
|
+
return icon([
|
|
87
|
+
h("path", { d: "m7 11 2-2-2-2" }),
|
|
88
|
+
h("path", { d: "M11 13h4" }),
|
|
89
|
+
h("rect", { width: 18, height: 18, x: 3, y: 3, rx: 2, ry: 2 }),
|
|
90
|
+
], className);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** File-read kind icon (lucide `file-text`). */
|
|
94
|
+
export function fileTextIcon(className) {
|
|
95
|
+
return icon([
|
|
96
|
+
h("path", { d: "M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" }),
|
|
97
|
+
h("path", { d: "M14 2v4a2 2 0 0 0 2 2h4" }),
|
|
98
|
+
h("path", { d: "M10 9H8" }),
|
|
99
|
+
h("path", { d: "M16 13H8" }),
|
|
100
|
+
h("path", { d: "M16 17H8" }),
|
|
101
|
+
], className);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Search/list kind icon (lucide `search`). */
|
|
105
|
+
export function searchIcon(className) {
|
|
106
|
+
return icon([
|
|
107
|
+
h("path", { d: "m21 21-4.34-4.34" }),
|
|
108
|
+
h("circle", { cx: 11, cy: 11, r: 8 }),
|
|
109
|
+
], className);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Edit/write kind icon (lucide `pencil-line`). */
|
|
113
|
+
export function pencilIcon(className) {
|
|
114
|
+
return icon([
|
|
115
|
+
h("path", { d: "M13 21h8" }),
|
|
116
|
+
h("path", { d: "m15 5 4 4" }),
|
|
117
|
+
h("path", { d: "M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" }),
|
|
118
|
+
], className);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Default/unknown tool kind icon (lucide `wrench`). */
|
|
122
|
+
export function wrenchIcon(className) {
|
|
123
|
+
return icon([
|
|
124
|
+
h("path", { d: "M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z" }),
|
|
125
|
+
], className);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function skillIcon(className) {
|
|
129
|
+
return icon([h('path', { d: 'm15 4 5 5M4 20l-1-1a1 1 0 0 1 0-1L16 5a2 2 0 0 1 3 3L6 21a1 1 0 0 1-1 0Z' }),
|
|
130
|
+
h('path', { d: 'M5 3v4M3 5h4M19 14v4M17 16h4' })], className);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function globeIcon(className) {
|
|
134
|
+
return icon([h('circle', { cx: 12, cy: 12, r: 10 }), h('path', { d: 'M2 12h20M12 2a17 17 0 0 1 0 20 17 17 0 0 1 0-20' })], className);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const DEFAULT_VIEW_OPTIONS = Object.freeze({
|
|
138
|
+
thoughtOpen: null, // Map<itemId, boolean> overrides (default: collapsed)
|
|
139
|
+
toolOpen: null, // Map<callId, boolean> overrides (default: open while running)
|
|
140
|
+
labels: DEFAULT_LABELS,
|
|
141
|
+
workspaceRoot: null,
|
|
142
|
+
following: true, // when false, a "jump to latest" button is rendered
|
|
143
|
+
ariaLabel: "Task timeline",
|
|
144
|
+
emptyState: null, // plain element to render when there is no content at all
|
|
145
|
+
summaryMaxChars: 160,
|
|
146
|
+
diffMaxLines: 200,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Build the timeline view model.
|
|
151
|
+
* @param {object} state reducer state
|
|
152
|
+
* @param {Partial<typeof DEFAULT_VIEW_OPTIONS> & { rootProps?: object }} [options]
|
|
153
|
+
*/
|
|
154
|
+
export function buildViewModel(state, options = {}) {
|
|
155
|
+
const opts = { ...DEFAULT_VIEW_OPTIONS, ...options };
|
|
156
|
+
// Merge labels with defaults so a host passing a partial labels object (or
|
|
157
|
+
// none) still gets the stable fallbacks; an explicit undefined from a
|
|
158
|
+
// destructure must not clobber the defaults.
|
|
159
|
+
opts.labels = { ...DEFAULT_LABELS, ...(options.labels || {}) };
|
|
160
|
+
const items = visibleItems(state);
|
|
161
|
+
const plan = planProgress(state);
|
|
162
|
+
const rootChildren = [];
|
|
163
|
+
if (plan && opts.showPlanDock !== false) rootChildren.push(planDock(plan, opts));
|
|
164
|
+
rootChildren.push(timelineList(state, items, opts));
|
|
165
|
+
return h("section", {
|
|
166
|
+
className: "harness-timeline-shell",
|
|
167
|
+
"data-testid": "harness-timeline",
|
|
168
|
+
"data-has-user-input": String(items.some(item => item.kind === 'text' && item.role === 'user')),
|
|
169
|
+
role: "log",
|
|
170
|
+
"aria-label": opts.ariaLabel,
|
|
171
|
+
"aria-busy": isWaitingForFirstToken(state) ? "true" : "false",
|
|
172
|
+
...(opts.rootProps || {}),
|
|
173
|
+
}, ...rootChildren);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function timelineList(state, items, opts) {
|
|
177
|
+
const kids = [];
|
|
178
|
+
if (isWaitingForFirstToken(state)) kids.push(waitingPlaceholder(opts));
|
|
179
|
+
const segments = conversationWorkSegments(state, items);
|
|
180
|
+
for (const [index, segment] of segments.entries()) {
|
|
181
|
+
kids.push(...renderTimelineItems(segment.inputs, opts));
|
|
182
|
+
if (segment.collapsible) {
|
|
183
|
+
const open = Boolean(opts.historyOpen?.get(segment.id));
|
|
184
|
+
kids.push(h('details', { open, className: 'harness-work-history', 'data-testid': 'work-history', 'data-item-id': segment.id },
|
|
185
|
+
h('summary', { className: 'harness-work-history-summary', 'data-testid': 'work-history-toggle' },
|
|
186
|
+
h('span', {}, completedWorkLabel(state, opts.locale)), chevronIcon('harness-work-history-chevron')),
|
|
187
|
+
open ? h('div', { className: 'harness-work-items harness-work-history-content', 'data-testid': 'work-history-content' },
|
|
188
|
+
...renderTimelineItems(segment.process, opts)) : null));
|
|
189
|
+
} else if (segment.process.length) kids.push(h('div', { className: 'harness-work-items', 'data-testid': 'work-process' },
|
|
190
|
+
...renderTimelineItems(segment.process, opts, state.taskState === 'running'
|
|
191
|
+
&& index === segments.length - 1 && segment.following.length === 0)));
|
|
192
|
+
kids.push(...renderTimelineItems(segment.following, opts));
|
|
193
|
+
}
|
|
194
|
+
if (!kids.length && opts.emptyState) kids.push(opts.emptyState);
|
|
195
|
+
if (opts.following === false) kids.push(jumpToLatest(opts));
|
|
196
|
+
return h("div", { className: "harness-timeline", "data-testid": "harness-timeline-list" }, ...kids);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function renderTimelineItems(items, opts, stageTailIsRunning = false, enableBrowserGrouping = true) {
|
|
200
|
+
const kids = [];
|
|
201
|
+
for (const item of activityGroups(items, { stageTailIsRunning, enableBrowserGrouping })) {
|
|
202
|
+
if (item.kind === "text") kids.push(textBlock(item, opts));
|
|
203
|
+
else if (item.kind === "thought") kids.push(thoughtBlock(item, opts));
|
|
204
|
+
else if (item.kind === "tool") kids.push(toolBlock(item, opts));
|
|
205
|
+
else if (item.kind === 'activity-group') kids.push(activityGroupBlock(item, opts));
|
|
206
|
+
else if (item.kind === 'context-marker') kids.push(contextMarker(item, opts));
|
|
207
|
+
else if (item.kind === 'tool-preview') kids.push(renderTool(item, opts));
|
|
208
|
+
}
|
|
209
|
+
return kids;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function activityGroupBlock(group, opts) {
|
|
213
|
+
const { title, detail } = activityGroupSummary(group, opts.locale);
|
|
214
|
+
const open = Boolean(opts.toolOpen?.get(group.id));
|
|
215
|
+
const icon = group.family === 'browser' ? globeIcon : group.family === 'terminal' ? terminalIcon : group.family === 'changes' ? pencilIcon : searchIcon;
|
|
216
|
+
return h('section', { className: 'harness-activity-group', 'data-testid': 'activity-group', 'data-item-id': group.id,
|
|
217
|
+
'data-family': group.family, 'data-streaming': String(group.active) },
|
|
218
|
+
h('details', { open, className: 'harness-activity-details', 'data-testid': 'activity-details', 'data-call-id': group.id },
|
|
219
|
+
h('summary', { className: 'harness-activity-summary', 'data-testid': 'tool-toggle' }, icon('harness-tool-kind-icon'),
|
|
220
|
+
h('span', { className: group.active ? 'harness-tool-title-streaming' : undefined }, title),
|
|
221
|
+
h('span', { 'aria-hidden': true }, '·'),
|
|
222
|
+
h('span', { 'data-testid': 'activity-counts' }, detail), chevronIcon('harness-tool-chevron')),
|
|
223
|
+
h('div', { className: 'harness-activity-children' }, group.family === 'browser'
|
|
224
|
+
? renderTimelineItems(group.events, opts, false, false) : group.children.map(item => toolBlock(item, opts)))));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function contextMarker(item, opts) {
|
|
228
|
+
const fallback = { running: 'Compacting context', completed: item.origin === 'auto' ? 'Context automatically compressed' : 'Context compressed',
|
|
229
|
+
failed: 'Context compaction failed', interrupted: 'Context compaction interrupted', noop: 'Context is already up to date' };
|
|
230
|
+
const label = opts.labels['context' + item.status[0].toUpperCase() + item.status.slice(1)] || fallback[item.status];
|
|
231
|
+
return h('div', { className: 'harness-context-marker', 'data-testid': 'context-marker', 'data-item-id': item.id,
|
|
232
|
+
'data-status': item.status, role: 'status' },
|
|
233
|
+
h('span', { className: 'harness-context-rule', 'aria-hidden': true }),
|
|
234
|
+
item.status === 'running' ? null : h('svg', { viewBox: '0 0 24 24', width: 14, height: 14, fill: 'none', stroke: 'currentColor',
|
|
235
|
+
strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true },
|
|
236
|
+
h('rect', { x: 2, y: 3, width: 20, height: 5, rx: 1 }),
|
|
237
|
+
h('path', { d: 'M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8' }), h('path', { d: 'M10 12h4' })),
|
|
238
|
+
h('span', { className: item.status === 'running' ? 'harness-thought-label-streaming' : undefined }, label),
|
|
239
|
+
h('span', { className: 'harness-context-rule', 'aria-hidden': true }));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function waitingPlaceholder(opts) {
|
|
243
|
+
return h("div", {
|
|
244
|
+
className: "harness-thinking-placeholder",
|
|
245
|
+
role: "status",
|
|
246
|
+
"data-testid": "task-waiting",
|
|
247
|
+
"aria-live": "polite",
|
|
248
|
+
}, h("span", { className: "harness-shimmer" }, opts.labels.thinking));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function textBlock(item, opts) {
|
|
252
|
+
const streaming = Boolean(item.streaming);
|
|
253
|
+
const text = item.text || "";
|
|
254
|
+
return h("div", {
|
|
255
|
+
className: item.role === "user" ? "harness-user-message" : "harness-text",
|
|
256
|
+
"data-role": item.role || "assistant",
|
|
257
|
+
"data-testid": "text-block",
|
|
258
|
+
"data-item-id": item.id,
|
|
259
|
+
"data-streaming": streaming ? "true" : "false",
|
|
260
|
+
}, h("span", { className: "harness-text-content" }, text));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function thoughtBlock(item, opts) {
|
|
264
|
+
const streaming = Boolean(item.streaming);
|
|
265
|
+
const userOpen = opts.thoughtOpen ? Boolean(opts.thoughtOpen.get(item.id)) : null;
|
|
266
|
+
// Desktop thought semantics (reasoning.tsx, defaultOpen=false): a thought is
|
|
267
|
+
// collapsed by default, even while streaming. While streaming and collapsed
|
|
268
|
+
// it shows a live latest-line summary; the user opens it to read the full
|
|
269
|
+
// reasoning. When streaming ends the binding auto-collapses it again UNLESS
|
|
270
|
+
// the user opened it themselves — see data-auto-collapse-key below.
|
|
271
|
+
const open = userOpen != null ? userOpen : false;
|
|
272
|
+
const text = item.text || "";
|
|
273
|
+
const hasText = text.trim().length > 0;
|
|
274
|
+
const label = thoughtLabel(item, opts);
|
|
275
|
+
const summaryKids = [
|
|
276
|
+
brainIcon("harness-thought-icon"),
|
|
277
|
+
h("span", {
|
|
278
|
+
className: streaming ? "harness-thought-label harness-thought-label-streaming" : "harness-thought-label",
|
|
279
|
+
role: streaming ? "status" : undefined,
|
|
280
|
+
"data-testid": "thought-label",
|
|
281
|
+
}, label),
|
|
282
|
+
];
|
|
283
|
+
// Collapsed streaming thought shows the latest non-empty line as a live,
|
|
284
|
+
// horizontally-scrolling summary (matches the desktop reasoning trigger). The
|
|
285
|
+
// binding pins the trailing edge and applies a fade mask when it overflows.
|
|
286
|
+
if (!open && streaming && hasText) {
|
|
287
|
+
summaryKids.push(h("span", {
|
|
288
|
+
className: "harness-thought-preview",
|
|
289
|
+
"data-testid": "thought-preview",
|
|
290
|
+
"data-reasoning-streaming-line": "true",
|
|
291
|
+
"data-line-key": latestSummaryLine(text).key,
|
|
292
|
+
"data-line-text": latestSummaryLine(text).text,
|
|
293
|
+
"data-reasoning-streaming-mask": "none",
|
|
294
|
+
"aria-live": "polite",
|
|
295
|
+
}, h("span", {
|
|
296
|
+
className: "harness-thought-preview-text",
|
|
297
|
+
"data-reasoning-streaming-text": "true",
|
|
298
|
+
}, latestSummaryLine(text).text)));
|
|
299
|
+
}
|
|
300
|
+
// Disclosure chevron (hidden until hover when collapsed, rotated when open).
|
|
301
|
+
summaryKids.push(chevronIcon("harness-thought-chevron"));
|
|
302
|
+
const body = (open && hasText)
|
|
303
|
+
? [h("div", {
|
|
304
|
+
className: "harness-thought-body",
|
|
305
|
+
"data-testid": "thought-body",
|
|
306
|
+
"data-streaming": streaming ? "true" : "false",
|
|
307
|
+
"data-following": "true",
|
|
308
|
+
"data-reasoning-scroll-mask": "none",
|
|
309
|
+
}, text)]
|
|
310
|
+
: [];
|
|
311
|
+
return h("section", {
|
|
312
|
+
className: "harness-thought",
|
|
313
|
+
"data-testid": "thought-block",
|
|
314
|
+
"data-item-id": item.id,
|
|
315
|
+
"data-streaming": streaming ? "true" : "false",
|
|
316
|
+
"data-auto-collapse-key": streaming ? `streaming:${item.id}` : `done:${item.id}`,
|
|
317
|
+
}, h("details", {
|
|
318
|
+
open,
|
|
319
|
+
className: "harness-thought-details",
|
|
320
|
+
"data-testid": "thought-details",
|
|
321
|
+
"data-item-id": item.id,
|
|
322
|
+
"aria-expanded": open,
|
|
323
|
+
}, h("summary", { className: "harness-thought-summary", "data-testid": "thought-toggle" }, ...summaryKids), ...body));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function thoughtLabel(item, opts) {
|
|
327
|
+
const l = opts.labels || {};
|
|
328
|
+
if (item.streaming) return l.thinking || "Thinking…";
|
|
329
|
+
// Host-supplied real duration only; never invented. No duration ⇒ bare label.
|
|
330
|
+
if (item.duration != null) return `${l.thought || "Thought"} · ${item.duration}s`;
|
|
331
|
+
return l.thought || "Thought";
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function toolBlock(item, opts) {
|
|
335
|
+
// Dispatch to the renderer registry (execute / read / edit / default). Each
|
|
336
|
+
// renderer owns its summary, body, and host-action data attributes; this
|
|
337
|
+
// function is now a thin seam so the view stays a single projection.
|
|
338
|
+
return renderTool(item, opts);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function planDock(plan, opts) {
|
|
342
|
+
const kids = [
|
|
343
|
+
h("span", { className: "harness-plan-count", "data-testid": "plan-count" }, `${plan.completed}/${plan.total}`),
|
|
344
|
+
h("span", { className: "harness-plan-label", "data-testid": "plan-label" }, opts.labels.plan || "Plan"),
|
|
345
|
+
];
|
|
346
|
+
if (plan.inProgress) kids.push(h("span", {
|
|
347
|
+
className: "harness-plan-current",
|
|
348
|
+
"data-testid": "plan-current",
|
|
349
|
+
}, truncateText(plan.inProgress.content || "", { maxLines: 1, maxChars: opts.summaryMaxChars })));
|
|
350
|
+
return h("section", {
|
|
351
|
+
className: "harness-plan-dock",
|
|
352
|
+
"data-testid": "plan-dock",
|
|
353
|
+
"aria-label": "Task plan",
|
|
354
|
+
}, ...kids);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function jumpToLatest(opts) {
|
|
358
|
+
return h("button", {
|
|
359
|
+
type: "button",
|
|
360
|
+
className: "harness-jump-latest",
|
|
361
|
+
"data-testid": "jump-latest",
|
|
362
|
+
"data-action": "jump-latest",
|
|
363
|
+
}, opts.labels.jumpLatest || "Jump to latest");
|
|
364
|
+
}
|