@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,57 @@
|
|
|
1
|
+
import { toolIdentity } from './tool-identity.mjs';
|
|
2
|
+
|
|
3
|
+
const isTool = item => ['tool', 'tool-preview'].includes(item.kind);
|
|
4
|
+
const isBrowser = item => isTool(item) && !item.child && (toolIdentity(item.name).family === 'browser'
|
|
5
|
+
|| ['mcp__computer-use__', 'mcp__plugin_zcode-cua_computer-use__'].some(prefix => item.name?.startsWith(prefix)));
|
|
6
|
+
|
|
7
|
+
/** Response membership is a Host fact, not adjacency or text inference. This
|
|
8
|
+
* read-only projection mirrors the reference CUA flow: pure browser responses
|
|
9
|
+
* share a work stage; mixed responses and final prose stay in the outer flow. */
|
|
10
|
+
export function browserResponseGroups(items, { stageTailIsRunning = false } = {}) {
|
|
11
|
+
const responses = new Map();
|
|
12
|
+
for (const item of items) if (isTool(item) && item.responseId) {
|
|
13
|
+
const value = responses.get(item.responseId) ?? { browser: false, other: false };
|
|
14
|
+
if (isBrowser(item)) value.browser = true; else value.other = true;
|
|
15
|
+
responses.set(item.responseId, value);
|
|
16
|
+
}
|
|
17
|
+
const result = [], identities = new Map(); let active = null;
|
|
18
|
+
const close = () => { if (active) active.active = false; active = null; };
|
|
19
|
+
const append = item => {
|
|
20
|
+
if (!active) {
|
|
21
|
+
const identity = item.responseId ? `activity:browser:response:${item.responseId}`
|
|
22
|
+
: `activity:browser:call:${item.callId ?? item.id}`;
|
|
23
|
+
const occurrence = identities.get(identity) ?? 0;
|
|
24
|
+
identities.set(identity, occurrence + 1);
|
|
25
|
+
// A mixed response can resume browser work after another tool. Distinct
|
|
26
|
+
// stages must not share React keys or disclosure state with one another.
|
|
27
|
+
active = { kind: 'activity-group', id: occurrence ? `${identity}:stage:${occurrence}` : identity,
|
|
28
|
+
family: 'browser', children: [], events: [], active: true,
|
|
29
|
+
failed: 0, uncertain: 0 };
|
|
30
|
+
result.push(active);
|
|
31
|
+
}
|
|
32
|
+
active.events.push(item);
|
|
33
|
+
if (isBrowser(item)) {
|
|
34
|
+
active.children.push(item);
|
|
35
|
+
if (item.status === 'failed') active.failed++;
|
|
36
|
+
if (['uncertain', 'interrupted', 'waiting'].includes(item.status)) active.uncertain++;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
for (const item of items) {
|
|
40
|
+
if (isBrowser(item)) { append(item); continue; }
|
|
41
|
+
if (isTool(item) || item.kind === 'text' && item.role === 'user') { close(); result.push(item); continue; }
|
|
42
|
+
if (item.kind === 'text' || item.kind === 'thought') {
|
|
43
|
+
const classification = item.responseId && responses.get(item.responseId);
|
|
44
|
+
if (classification?.browser && !classification.other) { append(item); continue; }
|
|
45
|
+
// A future tool declaration may classify streaming text/reasoning. Keep
|
|
46
|
+
// it exactly where it is until evidence arrives, without closing an
|
|
47
|
+
// earlier browser stage merely because the new response is incomplete.
|
|
48
|
+
if (item.kind === 'thought' && !classification || item.kind === 'text' && item.responseId && !classification && item.streaming) {
|
|
49
|
+
result.push(item); continue;
|
|
50
|
+
}
|
|
51
|
+
close(); result.push(item); continue;
|
|
52
|
+
}
|
|
53
|
+
result.push(item);
|
|
54
|
+
}
|
|
55
|
+
if (!stageTailIsRunning) close();
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { sanitizeAnsi } from './terminal.mjs';
|
|
2
|
+
|
|
3
|
+
const states = new Set(['starting', 'running', 'completed', 'failed', 'canceled', 'timed_out', 'output_limit', 'unknown', 'unavailable']);
|
|
4
|
+
const statusLabels = { starting: '正在启动', running: '执行中', completed: '已结束', failed: '执行失败', canceled: '已停止',
|
|
5
|
+
timed_out: '执行超时', output_limit: '输出达到限制', unknown: '执行结果未知', unavailable: '输出不可用' };
|
|
6
|
+
const textWindow = text => { const points = Array.from(text); return { text: points.slice(-131072).join(''), omitted: Math.max(0, points.length - 131072) }; };
|
|
7
|
+
|
|
8
|
+
/** Cursor values refer to backend bytes, never the redacted display length.
|
|
9
|
+
* This read-only view does not manufacture Harness events or tool receipts.
|
|
10
|
+
*/
|
|
11
|
+
export function appendCommandOutput(previous, page, request) {
|
|
12
|
+
if (page?.contract !== 'agentkit.studio-command-output/v1' || page.sessionId !== request.sessionId || page.taskId !== request.taskId
|
|
13
|
+
|| typeof page.runId !== 'string' || !states.has(page.status)
|
|
14
|
+
|| page.command !== undefined && (typeof page.command !== 'string' || page.command.length > 32768)
|
|
15
|
+
|| ['stdout', 'stderr'].some(key => typeof page[key] !== 'string' || page[key].length > 65536)
|
|
16
|
+
|| ['stdoutCursor', 'stderrCursor'].some(key => !Number.isSafeInteger(page[key]) || page[key] < (request[key] ?? 0))
|
|
17
|
+
|| previous && (previous.sessionId !== page.sessionId || previous.taskId !== page.taskId
|
|
18
|
+
|| ['stdoutCursor', 'stderrCursor'].some(key => previous[key] !== (request[key] ?? 0)))
|
|
19
|
+
|| !previous && ['stdoutCursor', 'stderrCursor'].some(key => (request[key] ?? 0) !== 0))
|
|
20
|
+
throw Error('命令输出窗口或归属已变化');
|
|
21
|
+
const out = textWindow((previous?.stdout ?? '') + (page.stdoutCursor > (request.stdoutCursor ?? 0) ? page.stdout : ''));
|
|
22
|
+
const err = textWindow((previous?.stderr ?? '') + (page.stderrCursor > (request.stderrCursor ?? 0) ? page.stderr : ''));
|
|
23
|
+
return { ...page, stdout: out.text, stderr: err.text,
|
|
24
|
+
omitted: (previous?.omitted ?? 0) + out.omitted + err.omitted,
|
|
25
|
+
exitCode: ['completed', 'failed', 'canceled', 'timed_out', 'output_limit'].includes(page.status)
|
|
26
|
+
&& page.evidenceIncomplete !== true && Number.isSafeInteger(page.exitCode) ? page.exitCode : null };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Peer-injected React; all docking, transport and disclosure stay in the Host. */
|
|
30
|
+
export function createCommandOutputComponents(React) {
|
|
31
|
+
const { createElement: h, useEffect, useRef, useState } = React;
|
|
32
|
+
function CommandOutputPane({ output, title = '终端输出', error, onCopy, theme = 'dark' }) {
|
|
33
|
+
const viewport = useRef(null), [following, setFollowing] = useState(true);
|
|
34
|
+
useEffect(() => { if (following && viewport.current) viewport.current.scrollTop = viewport.current.scrollHeight; },
|
|
35
|
+
[following, output?.stdout, output?.stderr]);
|
|
36
|
+
const stdout = sanitizeAnsi(output?.stdout ?? ''), stderr = sanitizeAnsi(output?.stderr ?? '');
|
|
37
|
+
const muted = theme === 'light' ? '#64666b' : '#96979b';
|
|
38
|
+
return h('section', { 'data-testid': 'native-command-output-pane', style: { height: '100%', display: 'flex', flexDirection: 'column', minHeight: 0,
|
|
39
|
+
color: theme === 'light' ? '#222' : '#e5e5e5', background: theme === 'light' ? '#fff' : '#141414', padding: 16, boxSizing: 'border-box', fontSize: 13 } },
|
|
40
|
+
h('header', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
|
|
41
|
+
h('strong', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }, title: output?.command || title }, output?.command || title),
|
|
42
|
+
onCopy ? h('button', { type: 'button', 'aria-label': '复制终端输出', onClick: () => onCopy(stdout + (stderr ? '\n' + stderr : '')) }, '复制') : null),
|
|
43
|
+
h('p', { 'data-testid': 'command-output-status', style: { color: muted } }, output ? statusLabels[output.status] : '正在读取输出…',
|
|
44
|
+
output?.exitCode !== null && output?.exitCode !== undefined ? ` · 退出码 ${output.exitCode}` : ''),
|
|
45
|
+
error ? h('p', { role: 'alert' }, error) : null,
|
|
46
|
+
output?.withheld ? h('p', { role: 'status' }, '输出等待展示策略处理') : null,
|
|
47
|
+
output?.evidenceIncomplete ? h('p', { role: 'status' }, '执行证据不完整,不能确认最终结果') : null,
|
|
48
|
+
output?.omitted ? h('p', { role: 'status', style: { color: muted } }, `较早的 ${output.omitted} 个字符已从预览省略`) : null,
|
|
49
|
+
h('div', { ref: viewport, style: { overflow: 'auto', flex: 1, minHeight: 0 }, onScroll: () => {
|
|
50
|
+
const node = viewport.current; if (node) setFollowing(node.scrollHeight - node.scrollTop - node.clientHeight < 12);
|
|
51
|
+
} }, h('pre', { 'data-testid': 'command-output-stdout', style: { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' } }, stdout),
|
|
52
|
+
stderr ? h('pre', { 'data-testid': 'command-output-stderr', 'aria-label': '标准错误输出', style: { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', color: muted } }, stderr) : null),
|
|
53
|
+
output?.outputMayBeTruncated ? h('p', { style: { color: muted } }, '仍有后续输出,或进程输出已达到保留限制') : null,
|
|
54
|
+
!following ? h('button', { type: 'button', onClick: () => setFollowing(true) }, '回到最新') : null);
|
|
55
|
+
}
|
|
56
|
+
return { CommandOutputPane };
|
|
57
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { createHarnessComponents } from "./react-binding.mjs";
|
|
3
|
+
export const { HarnessTimeline } = createHarnessComponents(React);
|
|
4
|
+
export { buildViewModel, styles } from "./react-binding.mjs";
|
|
5
|
+
|
|
6
|
+
import * as ReactDOM from "react-dom";
|
|
7
|
+
import { createStatusPanelComponents } from "./status-panel.mjs";
|
|
8
|
+
export const { ConversationStatusPanel, SubagentDirectory } = createStatusPanelComponents(React, ReactDOM);
|
|
9
|
+
export { statusPanelStyles } from "./status-panel.mjs";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Place committed context notices at their original transcript boundary.
|
|
2
|
+
* Unloaded anchors stay hidden until history arrives, never moved to the end. */
|
|
3
|
+
export function contextTimelineItems(items, markers = []) {
|
|
4
|
+
const boundaries = new Map();
|
|
5
|
+
for (const marker of markers) {
|
|
6
|
+
let index = -1;
|
|
7
|
+
if (marker.after.kind === 'start') {
|
|
8
|
+
index = 0; while (index < items.length && items[index].role === 'user') index++;
|
|
9
|
+
} else {
|
|
10
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
11
|
+
if (marker.after.kind === 'tool' ? items[i].callId === marker.after.id : items[i].sourceMessageId === marker.after.id) { index = i + 1; break; }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
if (index < 0) continue;
|
|
15
|
+
const group = boundaries.get(index) ?? [];
|
|
16
|
+
group.push({ kind: 'context-marker', ...marker }); boundaries.set(index, group);
|
|
17
|
+
}
|
|
18
|
+
return items.flatMap((item, index) => [...boundaries.get(index) ?? [], item]).concat(boundaries.get(items.length) ?? []);
|
|
19
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Independent presentation of a host's content-free, committed context quote. */
|
|
2
|
+
export function createContextStatus(React) {
|
|
3
|
+
const h = React.createElement;
|
|
4
|
+
return function ContextStatus({ value, runId, labels = {} }) {
|
|
5
|
+
if (!value || value.contract !== 'agentkit.native-context-status/v1' || value.runId !== runId) return null;
|
|
6
|
+
const l = { context: 'Context', automatic: 'Automatic compaction', disabled: 'Disabled', trigger: 'Trigger',
|
|
7
|
+
reserved: 'Reserved output', lastRequest: 'Last prepared request (estimated)', unknown: 'Not measured',
|
|
8
|
+
compacting: 'Compacting context…', interrupted: 'Context compaction interrupted', blocked: 'Context budget blocked',
|
|
9
|
+
completed: 'Compactions completed', ...labels };
|
|
10
|
+
const input = Number.isSafeInteger(value.lastRequestInputTokens) && value.lastRequestInputTokens >= 0 ? value.lastRequestInputTokens : null;
|
|
11
|
+
const window = Number.isSafeInteger(value.contextWindow) && value.contextWindow > 0 ? value.contextWindow : null;
|
|
12
|
+
const count = Number.isSafeInteger(value.compactions) && value.compactions >= 0 ? value.compactions : 0;
|
|
13
|
+
const reserved = Number.isSafeInteger(value.reservedOutputTokens) && value.reservedOutputTokens >= 0 ? `${value.reservedOutputTokens.toLocaleString()} tokens` : l.unknown;
|
|
14
|
+
const threshold = Number.isFinite(value.thresholdRatio) ? `${Math.round(value.thresholdRatio * 100)}%` : l.unknown;
|
|
15
|
+
const summary = value.state === 'compacting' ? l.compacting : value.state === 'interrupted' ? l.interrupted
|
|
16
|
+
: value.state === 'blocked' ? l.blocked : `${l.context} · ${input === null ? l.unknown : input.toLocaleString() + (window ? ' / ' + window.toLocaleString() : '')}${count ? ' · ' + l.completed + ' ' + count : ''}`;
|
|
17
|
+
return h('details', { className: 'harness-context-status', 'data-testid': 'context-status', 'data-state': value.state },
|
|
18
|
+
h('summary', { role: value.state === 'compacting' ? 'status' : undefined }, summary),
|
|
19
|
+
h('dl', null,
|
|
20
|
+
h('dt', null, l.lastRequest), h('dd', null, input === null ? l.unknown : `${input.toLocaleString()} tokens`),
|
|
21
|
+
h('dt', null, l.reserved), h('dd', null, reserved),
|
|
22
|
+
h('dt', null, l.automatic), h('dd', null, value.enabled ? `${l.trigger} ${threshold}` : l.disabled),
|
|
23
|
+
h('dt', null, l.completed), h('dd', null, count)));
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export const contextStatusStyles = `
|
|
27
|
+
.harness-context-status { color: var(--harness-fg-muted, #888); font-size: 12px; margin: 12px 0; }
|
|
28
|
+
.harness-context-status > summary { cursor: pointer; }
|
|
29
|
+
.harness-context-status[data-state="compacting"] > summary { animation: harness-context-pulse 1.5s ease-in-out infinite; }
|
|
30
|
+
.harness-context-status dl { display: grid; grid-template-columns: auto 1fr; gap: 4px 16px; margin: 8px 0; }
|
|
31
|
+
.harness-context-status dd { margin: 0; }
|
|
32
|
+
@keyframes harness-context-pulse { 50% { opacity: .5; } }
|
|
33
|
+
@media (prefers-reduced-motion: reduce) { .harness-context-status > summary { animation: none !important; } }
|
|
34
|
+
`;
|
package/src/diff.mjs
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// @kingsoftcloud/agentkit-harness-ui — unified-diff parsing
|
|
2
|
+
//
|
|
3
|
+
// Pure patch parsing for the file-edit renderer. Extracts actual line numbers
|
|
4
|
+
// from `@@ -oldStart,oldCount +newStart,newCount @@` hunk headers (never
|
|
5
|
+
// synthesized), counts additions/deletions, and produces bounded unified and
|
|
6
|
+
// split representations with real gutter line numbers. The full patch is never
|
|
7
|
+
// rendered verbatim; a bounded preview is returned and the host retrieves the
|
|
8
|
+
// full diff via a callback when the user asks for it.
|
|
9
|
+
//
|
|
10
|
+
// No file is read and no write is inferred from input. This module only parses
|
|
11
|
+
// a patch string the host already supplied in a `tool.result` display payload.
|
|
12
|
+
|
|
13
|
+
const HUNK_HEADER_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
14
|
+
const FILE_HEADER_RE = /^(?:diff --git a\/(\S+) b\/\S+|(?:---|\+\+\+) (?:a\/|b\/)?(.+))$/;
|
|
15
|
+
|
|
16
|
+
/** Maximum preview lines rendered inline before the host retrieves the full diff. */
|
|
17
|
+
export const MAX_DIFF_PREVIEW_LINES = 200;
|
|
18
|
+
/** Maximum hunk line number rendered; beyond this the gutter is blanked for safety. */
|
|
19
|
+
export const MAX_DIFF_LINE_NUMBER = Number.MAX_SAFE_INTEGER;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Count added/removed lines in a patch. Hunk headers, file headers, and the
|
|
23
|
+
* `\ No newline` marker are excluded. Returns null when the patch is empty.
|
|
24
|
+
* @param {string} patch
|
|
25
|
+
* @returns {{added: number, removed: number}|null}
|
|
26
|
+
*/
|
|
27
|
+
export function countChangeStat(patch) {
|
|
28
|
+
const text = String(patch || "");
|
|
29
|
+
if (!text) return null;
|
|
30
|
+
let added = 0;
|
|
31
|
+
let removed = 0;
|
|
32
|
+
let inHunk = false;
|
|
33
|
+
for (const raw of text.split("\n")) {
|
|
34
|
+
if (!raw) continue;
|
|
35
|
+
if (raw.startsWith("@@")) { inHunk = true; continue; }
|
|
36
|
+
if (raw.startsWith("diff --git ")) { inHunk = false; continue; }
|
|
37
|
+
if (raw.startsWith("index ")) continue;
|
|
38
|
+
if (!inHunk && (raw.startsWith("--- ") || raw.startsWith("+++ "))) continue;
|
|
39
|
+
if (raw.startsWith("\\ ")) continue;
|
|
40
|
+
if (raw.startsWith("+")) added += 1;
|
|
41
|
+
else if (raw.startsWith("-")) removed += 1;
|
|
42
|
+
}
|
|
43
|
+
if (!added && !removed) return null;
|
|
44
|
+
return { added, removed };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve the file path from a patch's `--- a/path` / `+++ b/path` or
|
|
49
|
+
* `diff --git a/x b/x` headers. Returns "" when no header is present.
|
|
50
|
+
* @param {string} patch
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
export function patchFilePath(patch) {
|
|
54
|
+
const text = String(patch || "");
|
|
55
|
+
if (!text) return "";
|
|
56
|
+
for (const raw of text.split("\n")) {
|
|
57
|
+
if (raw.startsWith("diff --git a/") && raw.includes(" b/")) {
|
|
58
|
+
const rest = raw.slice("diff --git ".length);
|
|
59
|
+
const aEnd = rest.indexOf(" b/");
|
|
60
|
+
if (aEnd > 0) return rest.slice(2, aEnd);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (raw.startsWith("--- ") || raw.startsWith("+++ ")) {
|
|
64
|
+
let p = raw.slice(4).trim();
|
|
65
|
+
const tab = p.indexOf("\t");
|
|
66
|
+
if (tab >= 0) p = p.slice(0, tab).trim();
|
|
67
|
+
if (!p || p === "/dev/null") continue;
|
|
68
|
+
if (p.startsWith("a/") || p.startsWith("b/")) p = p.slice(2);
|
|
69
|
+
return p;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return "";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Infer the edit operation kind from an explicit `operationKind`, an
|
|
77
|
+
* `actionLabel`, or the patch shape (`--- /dev/null` ⇒ write, `+++ /dev/null`
|
|
78
|
+
* ⇒ delete). Falls back to "edit".
|
|
79
|
+
* @param {string} [operationKind]
|
|
80
|
+
* @param {string} [actionLabel]
|
|
81
|
+
* @param {string} [patch]
|
|
82
|
+
* @returns {"write"|"delete"|"edit"}
|
|
83
|
+
*/
|
|
84
|
+
export function inferOperationKind(operationKind, actionLabel, patch) {
|
|
85
|
+
const op = String(operationKind || "").toLowerCase();
|
|
86
|
+
if (op === "write" || op === "create") return "write";
|
|
87
|
+
if (op === "delete" || op === "remove") return "delete";
|
|
88
|
+
const label = String(actionLabel || "").toLowerCase();
|
|
89
|
+
if (label === "write" || label === "create") return "write";
|
|
90
|
+
if (label === "delete" || label === "remove") return "delete";
|
|
91
|
+
const text = String(patch || "");
|
|
92
|
+
if (text) {
|
|
93
|
+
const hasNewNull = text.split("\n").some(l => l.startsWith("+++ /dev/null"));
|
|
94
|
+
const hasOldNull = text.split("\n").some(l => l.startsWith("--- /dev/null"));
|
|
95
|
+
if (hasNewNull && !hasOldNull) return "delete";
|
|
96
|
+
if (hasOldNull && !hasNewNull) return "write";
|
|
97
|
+
}
|
|
98
|
+
return "edit";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Parse a unified diff into bounded hunks carrying REAL line numbers derived
|
|
103
|
+
* from the hunk headers (old side starts at oldStart, new side at newStart;
|
|
104
|
+
* context/removed advance the old gutter, context/added advance the new
|
|
105
|
+
* gutter). Returns both a unified interleaved list and a split pair so the
|
|
106
|
+
* view can render either; the host chooses via options and CSS switches
|
|
107
|
+
* responsively.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} patch
|
|
110
|
+
* @param {{maxLines?: number, maxLineChars?: number}} [opts]
|
|
111
|
+
* @returns {{path: string, added: number, removed: number, truncated: boolean,
|
|
112
|
+
* unified: DiffLine[], split: {old: DiffLine[], new: DiffLine[]}}|null}
|
|
113
|
+
*
|
|
114
|
+
* @typedef {{marker: " " | "+" | "-", text: string, oldLine: number|null, newLine: number|null}} DiffLine
|
|
115
|
+
*/
|
|
116
|
+
export function parseDiffHunks(patch, opts = {}) {
|
|
117
|
+
const text = String(patch || "");
|
|
118
|
+
if (!text) return null;
|
|
119
|
+
const maxLines = opts.maxLines && opts.maxLines > 0 ? Math.floor(opts.maxLines) : MAX_DIFF_PREVIEW_LINES;
|
|
120
|
+
const maxChars = opts.maxLineChars && opts.maxLineChars > 0 ? Math.floor(opts.maxLineChars) : 200;
|
|
121
|
+
const path = patchFilePath(text);
|
|
122
|
+
const lines = text.split("\n");
|
|
123
|
+
const unified = [];
|
|
124
|
+
const splitOld = [];
|
|
125
|
+
const splitNew = [];
|
|
126
|
+
let added = 0;
|
|
127
|
+
let removed = 0;
|
|
128
|
+
let truncated = false;
|
|
129
|
+
let oldLine = null;
|
|
130
|
+
let newLine = null;
|
|
131
|
+
let inHunk = false;
|
|
132
|
+
|
|
133
|
+
for (const raw of lines) {
|
|
134
|
+
if (truncated) break;
|
|
135
|
+
if (raw.startsWith("diff --git ") || raw.startsWith("index ")) continue;
|
|
136
|
+
if (!inHunk && (raw.startsWith("--- ") || raw.startsWith("+++ "))) continue;
|
|
137
|
+
const header = HUNK_HEADER_RE.exec(raw);
|
|
138
|
+
if (header) {
|
|
139
|
+
inHunk = true;
|
|
140
|
+
oldLine = parseLineNumber(header[1], header[2]);
|
|
141
|
+
newLine = parseLineNumber(header[3], header[4]);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
// A bare `@@` (no line ranges) is still a hunk header; the reference
|
|
145
|
+
// accepts `line === "@@"` as a hunk start. Line numbers stay null.
|
|
146
|
+
if (raw === "@@") { inHunk = true; oldLine = null; newLine = null; continue; }
|
|
147
|
+
if (!inHunk || !raw || ![" ", "+", "-", "\\"].includes(raw[0])) continue;
|
|
148
|
+
if (raw.startsWith("\\ ")) continue; // ""
|
|
149
|
+
const marker = raw.startsWith("+") ? "+" : raw.startsWith("-") ? "-" : " ";
|
|
150
|
+
const body = raw.length ? raw.slice(1) : "";
|
|
151
|
+
const lineText = truncateLine(body, maxChars);
|
|
152
|
+
const entry = { marker, text: lineText, oldLine: null, newLine: null };
|
|
153
|
+
if (marker === "+") {
|
|
154
|
+
added += 1;
|
|
155
|
+
entry.newLine = newLine;
|
|
156
|
+
newLine = incLine(newLine);
|
|
157
|
+
} else if (marker === "-") {
|
|
158
|
+
removed += 1;
|
|
159
|
+
entry.oldLine = oldLine;
|
|
160
|
+
oldLine = incLine(oldLine);
|
|
161
|
+
} else {
|
|
162
|
+
entry.oldLine = oldLine;
|
|
163
|
+
entry.newLine = newLine;
|
|
164
|
+
oldLine = incLine(oldLine);
|
|
165
|
+
newLine = incLine(newLine);
|
|
166
|
+
}
|
|
167
|
+
pushBounded(unified, entry, maxLines, () => { truncated = true; });
|
|
168
|
+
pushSplit(splitOld, splitNew, entry, maxLines, () => { truncated = true; });
|
|
169
|
+
}
|
|
170
|
+
if (!unified.length) return null;
|
|
171
|
+
return { path, added, removed, truncated, unified, split: { old: splitOld, new: splitNew } };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function parseLineNumber(start, count) {
|
|
175
|
+
const s = Number.parseInt(start, 10);
|
|
176
|
+
if (!Number.isSafeInteger(s) || s < 0) return null;
|
|
177
|
+
// Real editors number from the start line; a 0-count hunk is a pure insertion
|
|
178
|
+
// whose first body line is `start` (or `start+1` when start is 0). Match the
|
|
179
|
+
// unified-diff convention: line numbers begin at `start`, clamped to >= 1.
|
|
180
|
+
return Math.max(1, s);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function incLine(line) {
|
|
184
|
+
if (line == null) return null;
|
|
185
|
+
const next = line + 1;
|
|
186
|
+
return next > MAX_DIFF_LINE_NUMBER ? null : next;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function truncateLine(text, maxChars) {
|
|
190
|
+
if (!text) return "";
|
|
191
|
+
if (text.length <= maxChars) return text;
|
|
192
|
+
return text.slice(0, Math.max(0, maxChars - 1)) + "…";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function pushBounded(list, entry, maxLines, markTruncated) {
|
|
196
|
+
if (list.length >= maxLines) { markTruncated(); return; }
|
|
197
|
+
list.push(entry);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function pushSplit(oldList, newList, entry, maxLines, markTruncated) {
|
|
201
|
+
// Split view pairs old and new columns; a context line occupies both, an
|
|
202
|
+
// added line only the new column, a removed line only the old column.
|
|
203
|
+
if (entry.marker === "+") {
|
|
204
|
+
if (newList.length >= maxLines) { markTruncated(); return; }
|
|
205
|
+
newList.push(entry);
|
|
206
|
+
if (oldList.length < maxLines) oldList.push(blankSplit(entry.oldLine));
|
|
207
|
+
} else if (entry.marker === "-") {
|
|
208
|
+
if (oldList.length >= maxLines) { markTruncated(); return; }
|
|
209
|
+
oldList.push(entry);
|
|
210
|
+
if (newList.length < maxLines) newList.push(blankSplit(entry.newLine));
|
|
211
|
+
} else {
|
|
212
|
+
if (oldList.length >= maxLines || newList.length >= maxLines) { markTruncated(); return; }
|
|
213
|
+
oldList.push(entry);
|
|
214
|
+
newList.push(entry);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function blankSplit(line) {
|
|
219
|
+
return { marker: " ", text: "", oldLine: line, newLine: line };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Bounded backward-compat preview: a flat list of `{marker, text}` lines, the
|
|
224
|
+
* shape the original `fileDiffPreview` returned. Kept so existing hosts and
|
|
225
|
+
* tests that consume the simple preview still work.
|
|
226
|
+
* @param {string} patch
|
|
227
|
+
* @param {{maxLines?: number}} [opts]
|
|
228
|
+
* @returns {{path: string, lines: {marker: string, text: string}[], truncated: boolean}|null}
|
|
229
|
+
*/
|
|
230
|
+
export function diffPreviewLines(patch, opts = {}) {
|
|
231
|
+
const parsed = parseDiffHunks(patch, opts);
|
|
232
|
+
if (!parsed) return null;
|
|
233
|
+
return {
|
|
234
|
+
path: parsed.path,
|
|
235
|
+
lines: parsed.unified.map(l => ({ marker: l.marker, text: l.text })),
|
|
236
|
+
truncated: parsed.truncated,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export { patchFilePath as _patchFilePath };
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Adapted from ZCode, Apache-2.0; see NOTICE and LICENSE.
|
|
2
|
+
// Revision 3c5b227649371507ddc4eb46ac5133afe6db1cab
|
|
3
|
+
// packages/ui/src/lib/exploreToolCall.ts
|
|
4
|
+
// Reference SHA-256: 24420d687f0e252dc5fb373f8c6238464ae27731dc7ec9282f3bb6da05f0fe3a
|
|
5
|
+
// Only TypeScript erasure and the host-independent identity adapter change.
|
|
6
|
+
// Presentation classification never grants permissions or replay authority.
|
|
7
|
+
import { toolIdentity } from './tool-identity.mjs';
|
|
8
|
+
const families = { execute: 'shell', file: 'file-read', list: 'search', search: 'search',
|
|
9
|
+
'web-search': 'explore', 'web-fetch': 'web-fetch', edit: 'file-write', write: 'file-write', delete: 'file-write' };
|
|
10
|
+
const resolveToolCallIdentity = ({ kind }) => ({ family: families[toolIdentity(kind).family] ?? 'generic' });
|
|
11
|
+
function isPlainRecord(value) {
|
|
12
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
function splitCommandSegments(command) {
|
|
15
|
+
return command
|
|
16
|
+
.split(/&&|\|\||;/g)
|
|
17
|
+
.map((segment) => segment.trim())
|
|
18
|
+
.filter((segment) => segment.length > 0);
|
|
19
|
+
}
|
|
20
|
+
function unwrapShellCommand(command) {
|
|
21
|
+
const trimmed = command.trim();
|
|
22
|
+
const stripWrappingQuotes = (value) => {
|
|
23
|
+
const normalized = value.trim();
|
|
24
|
+
if ((normalized.startsWith('"') && normalized.endsWith('"')) ||
|
|
25
|
+
(normalized.startsWith("'") && normalized.endsWith("'"))) {
|
|
26
|
+
return normalized.slice(1, -1).trim();
|
|
27
|
+
}
|
|
28
|
+
return normalized;
|
|
29
|
+
};
|
|
30
|
+
const shellCommandMatch = trimmed.match(/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-lc\s+([\s\S]+)$/i);
|
|
31
|
+
if (shellCommandMatch?.[1]) {
|
|
32
|
+
return stripWrappingQuotes(shellCommandMatch[1]);
|
|
33
|
+
}
|
|
34
|
+
const powershellCommandMatch = trimmed.match(/^(?:powershell(?:\.exe)?|pwsh(?:\.exe)?)\b[\s\S]*?\s-(?:command|c)\s+([\s\S]+)$/i);
|
|
35
|
+
if (powershellCommandMatch?.[1]) {
|
|
36
|
+
return stripWrappingQuotes(powershellCommandMatch[1]);
|
|
37
|
+
}
|
|
38
|
+
return trimmed;
|
|
39
|
+
}
|
|
40
|
+
function normalizeCommandCandidate(candidate) {
|
|
41
|
+
const unwrapped = unwrapShellCommand(candidate);
|
|
42
|
+
if (unwrapped.length === 0) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
return splitCommandSegments(unwrapped);
|
|
46
|
+
}
|
|
47
|
+
function extractToolCommands(input) {
|
|
48
|
+
const commandCandidates = [];
|
|
49
|
+
const collectFromValue = (value) => {
|
|
50
|
+
if (typeof value === "string") {
|
|
51
|
+
const command = value.trim();
|
|
52
|
+
if (command.length > 0) {
|
|
53
|
+
commandCandidates.push(command);
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (!Array.isArray(value)) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (value.every((item) => typeof item === "string")) {
|
|
61
|
+
const commandParts = value;
|
|
62
|
+
const shellCommandIndex = commandParts.findIndex((part) => part === "-lc");
|
|
63
|
+
if (shellCommandIndex >= 0 && typeof commandParts[shellCommandIndex + 1] === "string") {
|
|
64
|
+
const shellCommand = commandParts[shellCommandIndex + 1].trim();
|
|
65
|
+
if (shellCommand.length > 0) {
|
|
66
|
+
commandCandidates.push(shellCommand);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const joinedCommand = commandParts.join(" ").trim();
|
|
71
|
+
if (joinedCommand.length > 0) {
|
|
72
|
+
commandCandidates.push(joinedCommand);
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
for (const item of value) {
|
|
77
|
+
if (!isPlainRecord(item)) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const parsedCommand = item.cmd;
|
|
81
|
+
if (typeof parsedCommand === "string" && parsedCommand.trim().length > 0) {
|
|
82
|
+
commandCandidates.push(parsedCommand.trim());
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
collectFromValue(input);
|
|
87
|
+
if (!isPlainRecord(input)) {
|
|
88
|
+
return Array.from(new Set(commandCandidates.flatMap((candidate) => normalizeCommandCandidate(candidate))));
|
|
89
|
+
}
|
|
90
|
+
for (const key of ["command", "cmd", "script", "parsed_cmd"]) {
|
|
91
|
+
collectFromValue(input[key]);
|
|
92
|
+
}
|
|
93
|
+
return Array.from(new Set(commandCandidates.flatMap((candidate) => normalizeCommandCandidate(candidate))));
|
|
94
|
+
}
|
|
95
|
+
const EXECUTE_READ_COMMAND_RE = /\b(rg|grep|find|ls|cat|head|tail|wc|stat|pwd|which|readlink|tree|sed\s+-n|get-childitem|gci|dir|get-content|gc|type|select-string|sls|get-location|test-path|resolve-path)\b|^git\s+(status|log|show|diff)\b/i;
|
|
96
|
+
const EXECUTE_WRITE_COMMAND_RE = /\b(sed\s+-i|perl\s+-pi|tee|mv|cp|rm|mkdir|rmdir|touch|truncate|chmod|chown|remove-item|del|erase|set-content|add-content|clear-content|out-file|new-item|move-item|copy-item|rename-item|set-item)\b|^git\s+(add|commit|rm|mv|checkout|switch|restore|reset|clean|revert|cherry-pick|merge|rebase)\b/i;
|
|
97
|
+
const SHELL_REDIRECT_WRITE_RE = /(^|[^\d<])>>?\s*\S|&>\s*\S/i;
|
|
98
|
+
const SHELL_LOOP_RE = /\b(for|while)\b/i;
|
|
99
|
+
export function isShellToolCallAwaitingCommand({ kind, input }) {
|
|
100
|
+
const identity = resolveToolCallIdentity({ kind, input });
|
|
101
|
+
return identity.family === "shell" && extractToolCommands(input).length === 0;
|
|
102
|
+
}
|
|
103
|
+
export function isExploreToolCall({ kind, input }) {
|
|
104
|
+
const identity = resolveToolCallIdentity({ kind, input });
|
|
105
|
+
if (identity.family === "file-write") {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
if (identity.family === "file-read" ||
|
|
109
|
+
identity.family === "search" ||
|
|
110
|
+
identity.family === "web-fetch" ||
|
|
111
|
+
identity.family === "explore") {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
if (identity.family !== "shell") {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
const commands = extractToolCommands(input);
|
|
118
|
+
if (commands.length === 0) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
if (commands.some((command) => EXECUTE_WRITE_COMMAND_RE.test(command))) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
if (commands.some((command) => SHELL_REDIRECT_WRITE_RE.test(command))) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
if (commands.some((command) => EXECUTE_READ_COMMAND_RE.test(command))) {
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
// 有些只读探查会包在 for/while 循环里,例如批量查看 README 或递归扫目录,
|
|
131
|
+
// 这种命令本身不一定以 rg/ls 开头,但仍然属于 explore。
|
|
132
|
+
return commands.some((command) => SHELL_LOOP_RE.test(command) && EXECUTE_READ_COMMAND_RE.test(command));
|
|
133
|
+
}
|
|
134
|
+
export function isExecuteToolCall({ kind, input }) {
|
|
135
|
+
const identity = resolveToolCallIdentity({ kind, input });
|
|
136
|
+
return (identity.family === "shell" &&
|
|
137
|
+
!isShellToolCallAwaitingCommand({ kind, input }) &&
|
|
138
|
+
!isExploreToolCall({ kind, input }));
|
|
139
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const byteLength = text => new TextEncoder().encode(text).length;
|
|
2
|
+
function validPage(page) {
|
|
3
|
+
if (page?.contract !== 'agentkit.native-child-file/v1' || page.workspace !== 'child' || page.readOnly !== true
|
|
4
|
+
|| typeof page.path !== 'string' || !page.path || page.path.length > 1024 || /[\0\\]/.test(page.path)
|
|
5
|
+
|| !page.path.split('/').every(part => part && part !== '.' && part !== '..')
|
|
6
|
+
|| !Number.isSafeInteger(page.offset) || page.offset < 0 || !Number.isSafeInteger(page.totalBytes)
|
|
7
|
+
|| page.totalBytes < page.offset || typeof page.text !== 'string' || page.text.length > 65536 || byteLength(page.text) > 65536
|
|
8
|
+
|| !/^sha256:[a-f0-9]{64}$/.test(page.pageDigest ?? '')) return false;
|
|
9
|
+
const end = page.offset + byteLength(page.text);
|
|
10
|
+
return end <= page.totalBytes && (page.nextOffset === null ? end === page.totalBytes
|
|
11
|
+
: Number.isSafeInteger(page.nextOffset) && page.nextOffset === end && end > page.offset && end < page.totalBytes);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A current read-only observation, separate from immutable tool results.
|
|
15
|
+
* The Host owns scope, loading and cursors; this view renders one real page.
|
|
16
|
+
*/
|
|
17
|
+
export function createFilePreviewComponents(React) {
|
|
18
|
+
const h = React.createElement;
|
|
19
|
+
function FilePreviewPane({ page, loading = false, error = '', onNext, onPrevious, onCopy, labels = {}, theme = 'system' }) {
|
|
20
|
+
const visible = validPage(page) && !loading && !error;
|
|
21
|
+
const muted = theme === 'light' ? '#64666b' : '#96979b';
|
|
22
|
+
const button = { type: 'button', style: { font: 'inherit', cursor: 'pointer' } };
|
|
23
|
+
return h('section', { 'data-testid': 'harness-file-preview', 'aria-busy': loading,
|
|
24
|
+
style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0, boxSizing: 'border-box', padding: 16,
|
|
25
|
+
fontSize: 13, color: theme === 'system' ? 'CanvasText' : theme === 'light' ? '#222' : '#e5e5e5',
|
|
26
|
+
background: theme === 'system' ? 'Canvas' : theme === 'light' ? '#fff' : '#141414' } },
|
|
27
|
+
h('header', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
|
|
28
|
+
h('strong', { title: visible ? page.path : undefined,
|
|
29
|
+
style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
|
|
30
|
+
visible ? page.path : labels.unavailable || 'File preview'),
|
|
31
|
+
onCopy ? h('button', { ...button, 'data-testid': 'file-preview-copy', disabled: !visible,
|
|
32
|
+
onClick: () => { if (visible) onCopy(page.text); } }, labels.copy || 'Copy page') : null),
|
|
33
|
+
h('p', { style: { color: muted } }, labels.readOnly || 'Read-only workspace observation'),
|
|
34
|
+
loading ? h('p', { role: 'status' }, labels.loading || 'Reading file…') : error
|
|
35
|
+
? h('p', { role: 'alert' }, error) : !visible ? h('p', { role: 'alert' }, labels.unavailable || 'File observation unavailable') : null,
|
|
36
|
+
visible ? h(React.Fragment, null,
|
|
37
|
+
h('p', { 'data-testid': 'file-preview-range', style: { color: muted } },
|
|
38
|
+
`${page.offset}–${page.offset + byteLength(page.text)} / ${page.totalBytes} ${labels.bytes || 'bytes'}`),
|
|
39
|
+
h('div', { style: { flex: 1, minHeight: 0, overflow: 'auto' } },
|
|
40
|
+
h('pre', { 'data-testid': 'file-preview-text', style: { margin: 0, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere',
|
|
41
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Consolas, monospace', lineHeight: 1.6 } }, page.text)),
|
|
42
|
+
h('p', { style: { color: muted } }, labels.observation || 'Current file data, not a tool receipt or task verification.')) : null,
|
|
43
|
+
h('nav', { style: { display: 'flex', gap: 8, marginTop: 12 } },
|
|
44
|
+
onPrevious ? h('button', { ...button, 'data-testid': 'file-preview-previous', disabled: loading,
|
|
45
|
+
onClick: () => { if (!loading) onPrevious(); } }, labels.previous || 'Previous page') : null,
|
|
46
|
+
onNext ? h('button', { ...button, 'data-testid': 'file-preview-next', disabled: !visible || page.nextOffset === null,
|
|
47
|
+
onClick: () => { if (visible && page.nextOffset !== null) onNext(page.nextOffset); } }, labels.next || 'Next page') : null));
|
|
48
|
+
}
|
|
49
|
+
return { FilePreviewPane };
|
|
50
|
+
}
|