@xgspz/cli-ui 0.6.14 → 0.6.16
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/CHANGELOG.md +37 -4
- package/README.md +29 -4
- package/dist/{cli-ui-embed-D-3DvyTN.js → cli-ui-embed-D9xgur2y.js} +18376 -18648
- package/dist/{cli-ui-index-C34qFb4u.js → cli-ui-index-WBtxmw23.js} +2 -2
- package/dist/{cli-ui-jszip.min-DojbZPiE.js → cli-ui-jszip.min-CU3ZzoXb.js} +1 -1
- package/dist/{cli-ui-jszip.min-BObcE2nn.js → cli-ui-jszip.min-DdU0X3VJ.js} +2 -2
- package/dist/{cli-ui-xlsx-JVLtx0qR.js → cli-ui-xlsx-Dx6T7qhC.js} +1 -1
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +1 -1
- package/src/board/ChangesPane.tsx +10 -4
- package/src/board/CliBoard.tsx +48 -16
- package/src/board/FileViewer.tsx +34 -1
- package/src/board/diffRefresh.ts +63 -0
- package/src/board/linkage.ts +68 -0
- package/src/board/pathResolve.ts +108 -0
- package/src/chat/CliChat.tsx +105 -182
- package/src/chat/Composer.tsx +4 -3
- package/src/core/conversation.ts +16 -4
- package/src/core/store.ts +120 -40
- package/src/core/types.ts +2 -0
- package/src/msglist/CliMsgList.tsx +3 -2
- package/src/operator/node/AgentConnect.tsx +19 -15
- package/src/operator/node/AppearancePanel.tsx +1 -1
- package/src/operator/node/BranchNavigator.tsx +1 -1
- package/src/operator/node/BrowserPane.tsx +1 -1
- package/src/operator/node/Chat.tsx +16 -261
- package/src/operator/node/CommandPalette.tsx +1 -1
- package/src/operator/node/Composer.tsx +1 -1
- package/src/operator/node/Layout.tsx +1 -1
- package/src/operator/node/Markdown.tsx +6 -36
- package/src/operator/node/Modal.tsx +1 -1
- package/src/operator/node/OnboardingWizard.tsx +9 -5
- package/src/operator/node/Orchestrator.tsx +1 -1
- package/src/operator/node/ProjectLanding.tsx +1 -1
- package/src/operator/node/ProjectsColumn.tsx +1 -1
- package/src/operator/node/ProviderAuth.tsx +12 -10
- package/src/operator/node/RunSwitch.tsx +1 -1
- package/src/operator/node/Services.tsx +1 -1
- package/src/operator/node/SessionRail.tsx +27 -8
- package/src/operator/node/SessionView.tsx +1 -1
- package/src/operator/node/SettingsView.tsx +1 -1
- package/src/operator/node/TaskBoard.tsx +1 -1
- package/src/operator/node/TaskChanges.tsx +34 -5
- package/src/operator/node/TaskHero.tsx +1 -1
- package/src/operator/node/TasksColumn.tsx +1 -1
- package/src/operator/node/Transcript.tsx +10 -237
- package/src/operator/node/Welcome.tsx +1 -1
- package/src/operator/node/WorktreePrune.tsx +1 -1
- package/src/operator/node/boardInteraction.ts +6 -3
- package/src/operator/node/boardState.ts +12 -13
- package/src/operator/node/fileLinks.ts +4 -38
- package/src/operator/node/format.ts +12 -104
- package/src/operator/node/github.tsx +10 -8
- package/src/operator/node/modals.tsx +15 -13
- package/src/operator/node/recheckOnReturn.ts +28 -0
- package/src/operator/node/shared.tsx +10 -50
- package/src/operator/node/useTaskStream.ts +2 -2
- package/src/operator.base.css +3 -0
- package/src/operator.css +3 -0
- package/src/styles.css +5 -0
- package/src/ui/Markdown.tsx +36 -21
- package/src/ui/Transcript.tsx +8 -5
- package/src/ui/fileLinks.ts +6 -3
- package/src/ui/format.ts +11 -5
- package/src/ui/shared.tsx +11 -7
- package/src/ui/transcriptModel.ts +40 -9
- package/src/ui/useTranscriptWindow.ts +253 -0
- package/src/operator/node/boundedPoll.ts +0 -47
- package/src/operator/node/icons.tsx +0 -88
- package/src/operator/node/transcriptModel.ts +0 -129
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
import { subscribeWorkspaceFiles } from "../../board/fileWatch";
|
|
3
3
|
import { retainFileScope } from "../../board/fileScope";
|
|
4
4
|
|
|
5
|
-
import { useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
5
|
+
import { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
6
6
|
import { createPortal } from "react-dom";
|
|
7
|
-
import { Icon } from "
|
|
7
|
+
import { Icon } from "../../ui/icons";
|
|
8
8
|
import TaskChanges, { type ResolveResult } from "./TaskChanges";
|
|
9
9
|
import { BrowserPane } from "./BrowserPane";
|
|
10
10
|
import { fmtTokens } from "./format";
|
|
11
11
|
import { clientFeatures } from "./protocol/features";
|
|
12
12
|
import type { ServiceInfo } from "./protocol/types";
|
|
13
|
-
import type { ProjectRow, TaskRow } from "./types";
|
|
13
|
+
import type { Msg, ProjectRow, TaskRow } from "./types";
|
|
14
14
|
|
|
15
15
|
import { clientId } from "../../core/id";
|
|
16
16
|
import { useBoardState, readBoardState, visitBoardFile, reportBoardState, releaseBoardState, activateBoardState } from "./boardState";
|
|
@@ -19,16 +19,17 @@ import { useBoardPanels } from "../../board/panels";
|
|
|
19
19
|
import { PanelControls, PanelTab, type InlineBoardLayout } from "../../board/PanelControls";
|
|
20
20
|
import { FilesPane } from "../../board/FilesPane";
|
|
21
21
|
import { FileViewer } from "../../board/FileViewer";
|
|
22
|
+
import { pathHints, resolveMissingPath } from "../../board/pathResolve";
|
|
22
23
|
import { useBoardUpload } from "../../board/useBoardUpload";
|
|
23
24
|
import { getOperatorBoardBackend } from "./boardBackend";
|
|
24
25
|
import { useOperatorContextEvents, useOperatorFileWatch } from "./boardEvents";
|
|
25
26
|
import { OperatorBoardContext, registerOperatorBoard, OperatorBoardQuoteContext } from "./boardInteraction";
|
|
26
27
|
import type { Quote } from "./composerDraft";
|
|
27
28
|
import type { OperatorBoardOptions, OperatorBoardRequest } from "./boardInteraction";
|
|
28
|
-
type OpenFile = { name?: string; reloadKey?:number; taskId: string; path: string; version: number; mtime?: number; size?: number };
|
|
29
|
+
type OpenFile = { name?: string; reloadKey?:number; taskId: string; path: string; version: number; mtime?: number; size?: number; /** Completed from this relative path the agent wrote. */ resolvedFrom?: string };
|
|
29
30
|
const fileKey = (file: Pick<OpenFile,"taskId"|"path">) => `file:${file.taskId}:${file.path}`;
|
|
30
31
|
const LABEL:Record<string,string>={files:"工作区",changes:"改动",context:"上下文",preview:"预览",browser:"浏览器"};
|
|
31
|
-
type Session = { n: number; summaryBefore: string | null };
|
|
32
|
+
type Session = { n: number; summaryBefore: string | null; /** SessionView passes buildSessions(messages): path hints for file completion. */ messages?: Msg[] };
|
|
32
33
|
|
|
33
34
|
// The live URL a project's dev server is reachable at when no registered
|
|
34
35
|
// service reports one — local dev hits the project's stable port directly.
|
|
@@ -155,6 +156,19 @@ export function SessionRail({ project, task, sessions, running, onResolveWithAI,
|
|
|
155
156
|
useOperatorContextEvents(task.id,!parentBoard,callbacks);
|
|
156
157
|
|
|
157
158
|
const backend=useMemo(()=>getOperatorBoardBackend(),[]);
|
|
159
|
+
const sessionsRef=useRef(sessions);sessionsRef.current=sessions;
|
|
160
|
+
// Same completion as the center board: hints from this task's own conversation and visited files.
|
|
161
|
+
const resolveMissing=useCallback((path:string,signal:AbortSignal)=>{
|
|
162
|
+
const seen=(readBoardState(task.id)?.history??[]).filter(f=>(f.sourceId??task.id)===task.id).map(f=>f.path);
|
|
163
|
+
return resolveMissingPath(backend,task.id,path,pathHints(sessionsRef.current.flatMap(s=>s.messages??[]),seen),signal);
|
|
164
|
+
},[backend,task.id]);
|
|
165
|
+
const replaceFile=(file:OpenFile,to:string)=>{
|
|
166
|
+
aliases.current.set(fileKey(file),to);
|
|
167
|
+
const id=fileKey({taskId:file.taskId,path:to}),resolvedFrom=file.resolvedFrom??file.path;
|
|
168
|
+
setFiles(previous=>previous.some(f=>fileKey(f)===id)?previous.filter(f=>fileKey(f)!==fileKey(file)):previous.map(f=>fileKey(f)===fileKey(file)?{taskId:file.taskId,path:to,version:0,resolvedFrom}:f));
|
|
169
|
+
visitBoardFile(task.id,{sourceId:file.taskId,path:to});
|
|
170
|
+
setTab(id);
|
|
171
|
+
};
|
|
158
172
|
const [restored,setRestored]=useState(false);
|
|
159
173
|
const storageKey=`cli-ui:operator-files:${task.id}`;
|
|
160
174
|
useEffect(()=>{
|
|
@@ -209,9 +223,13 @@ export function SessionRail({ project, task, sessions, running, onResolveWithAI,
|
|
|
209
223
|
return () => stops.forEach(stop => stop());
|
|
210
224
|
}, [backend, task.id, watchFiles, contentVisible, openedScope, directorySource]);
|
|
211
225
|
const current=useRef(active);current.current=active;
|
|
226
|
+
// A relative path the agent wrote against a subfolder → the existing file it was
|
|
227
|
+
// completed to (board/pathResolve). Clicking the same link again opens that one.
|
|
228
|
+
const aliases=useRef(new Map<string,string>());
|
|
212
229
|
const openFile=(taskId:string,path:string)=>{
|
|
213
230
|
if(!available.includes('files'))return;
|
|
214
|
-
const
|
|
231
|
+
const requested=path.replace(/^\.\//,'').replace(/\/+$/,'');if(!requested)return;
|
|
232
|
+
const clean=aliases.current.get(fileKey({taskId,path:requested}))??requested;
|
|
215
233
|
const id=fileKey({taskId,path:clean});
|
|
216
234
|
activateBoardState(task.id,owner);
|
|
217
235
|
visitBoardFile(task.id,{sourceId:taskId,path:clean});
|
|
@@ -277,7 +295,7 @@ export function SessionRail({ project, task, sessions, running, onResolveWithAI,
|
|
|
277
295
|
{!active&&<div className="cli-board-empty">看板模块已全部关闭。通过右上角“管理看板模块”重新打开。</div>}
|
|
278
296
|
{active==='changes'&&<>
|
|
279
297
|
{task.fork_commit&&<div className="fork-merge-note">基于「{task.fork_source_title}」派生。合并到 {project.branch} 时,也会包含来源分支尚未合并的改动。</div>}
|
|
280
|
-
<TaskChanges taskId={task.id} running={running} prUrl={task.pr_url} onMerged={onMerged} onPrCreated={onPrCreated} onResolveWithAI={async id=>{const result=await onResolveWithAI(id);if(result.ok&&!result.merged)onSwitchToChat();return result;}}/>
|
|
298
|
+
<TaskChanges taskId={task.id} running={running} refreshKey={tick} prUrl={task.pr_url} onMerged={onMerged} onPrCreated={onPrCreated} onResolveWithAI={async id=>{const result=await onResolveWithAI(id);if(result.ok&&!result.merged)onSwitchToChat();return result;}}/>
|
|
281
299
|
</>}
|
|
282
300
|
{contentVisible&&active==='files'&&<>
|
|
283
301
|
{folderTask!==task.id&&<div className="fork-merge-note cli-source-workspace"><span>正在浏览来源任务的工作区。</span><button className="btn btn-ghost btn-sm" onClick={()=>{setFolderTask(task.id);setDir('');}}>返回当前工作区</button></div>}
|
|
@@ -289,7 +307,8 @@ export function SessionRail({ project, task, sessions, running, onResolveWithAI,
|
|
|
289
307
|
onOpenPath={path=>openFile(file.taskId,path)}
|
|
290
308
|
actions={quote?[{label:'引用到对话',onClick:()=>quote({id:`file:${file.taskId}:${file.path}`,sourceTaskId:file.taskId,author:`文件 ${file.path}`,text:`${file.taskId===task.id?'当前工作区':`来源任务 ${file.taskId} 的工作区`}文件 ${file.path}`})}]:[]}
|
|
291
309
|
onQuoteText={quote?(text, context)=>quote({id:`selection:${file.taskId}:${file.path}`,sourceTaskId:file.taskId,author:`${file.taskId===task.id?"当前工作区":`来源任务 ${file.taskId} 的工作区`}文件 ${file.path}`,text:context?`文件修订:${context.revision}\n${text}`:text}):undefined}
|
|
292
|
-
onBack={()=>{setFolderTask(file.taskId);setDir(file.path.includes('/')?file.path.slice(0,file.path.lastIndexOf('/')):'');panels.show('files');setTab('files');}}
|
|
310
|
+
onBack={()=>{setFolderTask(file.taskId);setDir(file.path.includes('/')?file.path.slice(0,file.path.lastIndexOf('/')):'');panels.show('files');setTab('files');}}
|
|
311
|
+
resolveMissing={file.taskId===task.id?resolveMissing:undefined} onResolved={to=>replaceFile(file,to)} resolvedFrom={file.resolvedFrom}/>
|
|
293
312
|
</>}
|
|
294
313
|
{active==='preview'&&features.livePreview&&<PreviewPane project={project}/>}
|
|
295
314
|
{active==='context'&&<ContextPane task={task} sessions={sessions} running={running} onClear={onClear}/>}
|
|
@@ -5,7 +5,7 @@ import type { Status, Priority, AskQuestion, AskAnswers } from "./protocol/types
|
|
|
5
5
|
import { OperatorBoardContext, OperatorBoardQuoteContext, type OperatorBoardOptions, type OperatorBoardRequest, type OperatorBoardTarget } from "./boardInteraction";
|
|
6
6
|
import { readDraft, writeDraft, type Quote } from "./composerDraft";
|
|
7
7
|
import { clientFeatures } from "./protocol/features";
|
|
8
|
-
import { Icon } from "
|
|
8
|
+
import { Icon } from "../../ui/icons";
|
|
9
9
|
import { type ResolveResult } from "./TaskChanges";
|
|
10
10
|
import { fmtTokens, fmtCost, fmtJobCost, isAwaiting, buildSessions, usageSplit, costDisplay } from "./format";
|
|
11
11
|
import {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from "react";
|
|
4
|
-
import { Icon } from "
|
|
4
|
+
import { Icon } from "../../ui/icons";
|
|
5
5
|
import { DEFAULT_SETTINGS, modelOptions, reasoningOptions, permissionOptions, type Settings, type AgentsBundle } from "./types";
|
|
6
6
|
import { savedCustomModels, validModelId, withCustomModels } from "./protocol/customModels";
|
|
7
7
|
import { capsFor, agentLabel, agentStateHint } from "./agents";
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, type ReactNode } from "react";
|
|
4
4
|
import type { Status } from "./protocol/types";
|
|
5
|
-
import { Icon } from "
|
|
5
|
+
import { Icon } from "../../ui/icons";
|
|
6
6
|
import { isAwaiting, relTime } from "./format";
|
|
7
7
|
import { SEARCH_MIN, type ProjectRow, type TaskRow, type AgentsBundle, type TaskView } from "./types";
|
|
8
8
|
import { agentLabel } from "./agents";
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import { Skel, ErrNote } from "./shared";
|
|
5
5
|
import { diffLineKinds, hunkLines } from "../../board/diffLines";
|
|
6
|
+
import { useDiffRefreshSignal } from "../../board/diffRefresh";
|
|
7
|
+
import { observeLiveMessages } from "./liveStream";
|
|
6
8
|
|
|
7
9
|
interface DiffFile {
|
|
8
10
|
path: string;
|
|
@@ -133,9 +135,13 @@ const FileDiff = memo(function FileDiff({
|
|
|
133
135
|
);
|
|
134
136
|
});
|
|
135
137
|
|
|
138
|
+
/** 几个刷新信号挤在一起时(节点 turn_end + 运行状态变成空闲)合成一次读:diff 在节点上要跑一组 git 命令。 */
|
|
139
|
+
const REFRESH_COALESCE_MS = 250;
|
|
140
|
+
|
|
136
141
|
export default function TaskChanges({
|
|
137
142
|
taskId,
|
|
138
143
|
running,
|
|
144
|
+
refreshKey = 0,
|
|
139
145
|
prUrl,
|
|
140
146
|
onMerged,
|
|
141
147
|
onPrCreated,
|
|
@@ -143,6 +149,8 @@ export default function TaskChanges({
|
|
|
143
149
|
}: {
|
|
144
150
|
taskId: string;
|
|
145
151
|
running?: boolean;
|
|
152
|
+
/** 工作区文件有变化(看板的文件事件):节流重读。 */
|
|
153
|
+
refreshKey?: number;
|
|
146
154
|
prUrl?: string; // GitHub PR already opened from this branch ("" / undefined = none)
|
|
147
155
|
onMerged?: () => void;
|
|
148
156
|
onPrCreated?: (url: string) => void;
|
|
@@ -193,14 +201,35 @@ export default function TaskChanges({
|
|
|
193
201
|
load();
|
|
194
202
|
}, [taskId, load]);
|
|
195
203
|
|
|
196
|
-
// The diff moves while the agent works
|
|
197
|
-
//
|
|
198
|
-
//
|
|
204
|
+
// The diff moves while the agent works. Refresh from events only (no polling), with the
|
|
205
|
+
// same rules as the center board (useDiffRefreshSignal):
|
|
206
|
+
// · every node round end (turn_end on the task's live stream — queued messages can keep
|
|
207
|
+
// `running` true across rounds, so running→idle alone misses them) and running→idle;
|
|
208
|
+
// · a finished tool (tool_result: it may have written files) or a workspace file event —
|
|
209
|
+
// throttled to one read per DIFF_LIVE_GAP_MS, always one more after the last change;
|
|
210
|
+
// · signals that arrive while the page is hidden are replayed once when it is shown again.
|
|
211
|
+
// Mount already loads. The live observer rides the existing task stream; it opens nothing.
|
|
212
|
+
const [settle, setSettle] = useState(0);
|
|
213
|
+
const [toolRuns, setToolRuns] = useState(0);
|
|
214
|
+
useEffect(() => observeLiveMessages(`/api/tasks/${taskId}/messages`, (raw) => {
|
|
215
|
+
let type: unknown;
|
|
216
|
+
try { type = (JSON.parse(raw) as { type?: unknown } | null)?.type; } catch { return; }
|
|
217
|
+
if (type === "turn_end") setSettle((n) => n + 1);
|
|
218
|
+
else if (type === "tool_result") setToolRuns((n) => n + 1);
|
|
219
|
+
}), [taskId]);
|
|
199
220
|
const wasRunning = useRef(running);
|
|
200
221
|
useEffect(() => {
|
|
201
|
-
if (wasRunning.current && !running)
|
|
222
|
+
if (wasRunning.current && !running) setSettle((n) => n + 1);
|
|
202
223
|
wasRunning.current = running;
|
|
203
|
-
}, [running
|
|
224
|
+
}, [running]);
|
|
225
|
+
const signal = useDiffRefreshSignal(settle, toolRuns + refreshKey);
|
|
226
|
+
const lastSignal = useRef(signal);
|
|
227
|
+
useEffect(() => {
|
|
228
|
+
if (lastSignal.current === signal) return;
|
|
229
|
+
lastSignal.current = signal;
|
|
230
|
+
const timer = setTimeout(() => { void load(); }, REFRESH_COALESCE_MS);
|
|
231
|
+
return () => clearTimeout(timer);
|
|
232
|
+
}, [signal, load]);
|
|
204
233
|
|
|
205
234
|
// Track which file is at the top of the scroll area to highlight it in the list.
|
|
206
235
|
useEffect(() => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from "react";
|
|
4
|
-
import { Icon } from "
|
|
4
|
+
import { Icon } from "../../ui/icons";
|
|
5
5
|
import { isAwaiting, relTime } from "./format";
|
|
6
6
|
import { SLABEL, AWAIT_LABEL, SEARCH_MIN, type ProjectRow, type TaskRow, type AgentsBundle, type TaskView } from "./types";
|
|
7
7
|
import { agentLabel } from "./agents";
|
|
@@ -5,13 +5,13 @@ import { useContext } from "react";
|
|
|
5
5
|
import { OperatorBoardContext } from "./boardInteraction";
|
|
6
6
|
import { FileLinkContext } from "./fileLinks";
|
|
7
7
|
|
|
8
|
-
import { memo,
|
|
9
|
-
import type {
|
|
10
|
-
import { Icon } from "
|
|
8
|
+
import { memo, useState } from "react";
|
|
9
|
+
import type { AskQuestion, AskAnswers } from "./protocol/types";
|
|
10
|
+
import { Icon } from "../../ui/icons";
|
|
11
11
|
import { Markdown } from "./Markdown";
|
|
12
12
|
import { splitMessage } from "../../core/blocks";
|
|
13
13
|
import { withoutBoardContext } from "../../board/context";
|
|
14
|
-
import { clockTime,
|
|
14
|
+
import { clockTime, splitAttachments, type MsgAttachment } from "./format";
|
|
15
15
|
import { CONTEXT_OVERFLOW_NOTICE } from "./protocol/promptLimits";
|
|
16
16
|
import { AUTH_EXPIRED_NOTICE } from "./protocol/authFailure";
|
|
17
17
|
import { USAGE_LIMIT_NOTICE } from "./protocol/usageLimit";
|
|
@@ -19,237 +19,22 @@ import { APPROVAL_BLOCKED_NOTICE } from "./protocol/approvalFailure";
|
|
|
19
19
|
import type { Msg } from "./types";
|
|
20
20
|
import { clientFeatures } from "./protocol/features";
|
|
21
21
|
import { Avatar } from "./shared";
|
|
22
|
-
import { toolDataOf } from "
|
|
22
|
+
import { toolDataOf } from "../../ui/transcriptModel";
|
|
23
23
|
import { ReferenceCard } from "../../ui/ReferenceCard";
|
|
24
|
+
// Tool / question / browser-input cards and the /clear break are the shared ones
|
|
25
|
+
// (ui/Transcript); this file only draws the node's message rows around them.
|
|
26
|
+
import { AskView, InputView, ToolView, SessionBreak } from "../../ui/Transcript";
|
|
27
|
+
export { SessionBreak };
|
|
24
28
|
import { splitQuotedMessage } from "../../ui/reference";
|
|
25
29
|
|
|
26
|
-
// The always-visible "peek" tier — Claude Code's `⎿` line. Counts show no
|
|
27
|
-
// content; diffs/snippets show a capped hunk with a clickable "+N more" that
|
|
28
|
-
// opens the full body. TodoWrite renders its checklist inline.
|
|
29
|
-
function PeekView({ peek, expandable, onExpand }: { peek: ToolPeek; expandable: boolean; onExpand: () => void }) {
|
|
30
|
-
const corner = <span className="tcorner">⎿</span>;
|
|
31
|
-
if (peek.kind === "count") {
|
|
32
|
-
return (
|
|
33
|
-
<button className="tpeek tpeek-count" style={{ cursor: expandable ? "pointer" : "default" }} onClick={() => expandable && onExpand()}>
|
|
34
|
-
{corner}<span className="tpeek-txt">{peek.text}</span>
|
|
35
|
-
{expandable && <span className="tpeek-more">展开</span>}
|
|
36
|
-
</button>
|
|
37
|
-
);
|
|
38
|
-
}
|
|
39
|
-
if (peek.kind === "todos") {
|
|
40
|
-
return (
|
|
41
|
-
<div className="tpeek tpeek-todos">
|
|
42
|
-
{peek.items.map((t, i) => (
|
|
43
|
-
<div className={`tdo ${t.status}`} key={i}>
|
|
44
|
-
<span className="tdo-box">{t.status === "completed" ? "✔" : t.status === "in_progress" ? "▣" : "▢"}</span>
|
|
45
|
-
<span className="tdo-txt">{t.text}</span>
|
|
46
|
-
</div>
|
|
47
|
-
))}
|
|
48
|
-
</div>
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
if (peek.kind === "diff") {
|
|
52
|
-
return (
|
|
53
|
-
<div className="tpeek tpeek-diff">
|
|
54
|
-
<div className="tpeek-sum">{corner}<span className="dstat add">+{peek.added}</span><span className="dstat del">−{peek.removed}</span>{peek.label && <span className="tpeek-txt">{peek.label}</span>}</div>
|
|
55
|
-
<pre className="tpeek-pre diff">{peek.lines.map((l, i) => <div className={`dl ${diffCls(l.sign)}`} key={i}>{l.sign} {l.text}</div>)}</pre>
|
|
56
|
-
{peek.truncated ? <button className="tpeek-more btn-link" onClick={onExpand}>还有 {peek.truncated} 行</button> : null}
|
|
57
|
-
</div>
|
|
58
|
-
);
|
|
59
|
-
}
|
|
60
|
-
// lines (Bash output)
|
|
61
|
-
return (
|
|
62
|
-
<div className="tpeek tpeek-lines">
|
|
63
|
-
{peek.label && <div className="tpeek-sum">{corner}<span className="tpeek-txt">{peek.label}</span></div>}
|
|
64
|
-
<pre className="tpeek-pre">{peek.lines.join("\n") || "(无输出)"}</pre>
|
|
65
|
-
{peek.truncated ? <button className="tpeek-more btn-link" onClick={onExpand}>还有 {peek.truncated} 行</button> : null}
|
|
66
|
-
</div>
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
30
|
|
|
70
|
-
function ToolView({ data }: { data: ToolData }) {
|
|
71
|
-
const [open, setOpen] = useState<boolean | null>(null);
|
|
72
|
-
const bodyId = useId();
|
|
73
|
-
const hasDiff = !!data.diff?.length;
|
|
74
|
-
const expandable = !!(data.detail || hasDiff || data.result !== undefined);
|
|
75
|
-
// Failures surface their output automatically, like Claude Code.
|
|
76
|
-
const showBody = open ?? (!!data.isError && data.result !== undefined);
|
|
77
|
-
return (
|
|
78
|
-
<div className="tool">
|
|
79
|
-
<button className="tool-h" title={data.title} aria-expanded={expandable ? showBody : undefined} aria-controls={expandable ? bodyId : undefined} style={{ cursor: expandable ? "pointer" : "default" }} onClick={() => expandable && setOpen(!showBody)}>
|
|
80
|
-
{expandable && <span className={`tchev ${showBody ? "open" : ""}`}>{Icon.chevRight()}</span>}
|
|
81
|
-
<span className="tg">{data.title}</span>
|
|
82
|
-
{data.result !== undefined && <span className={data.isError ? "tx" : "tcheck"}>{data.isError ? Icon.x() : Icon.check()}</span>}
|
|
83
|
-
</button>
|
|
84
|
-
{data.peek && !showBody && <PeekView peek={data.peek} expandable={expandable} onExpand={() => setOpen(true)} />}
|
|
85
|
-
{showBody && (
|
|
86
|
-
<div className="tool-body" id={bodyId}>
|
|
87
|
-
{data.detail && <pre className="tool-pre">{data.detail}</pre>}
|
|
88
|
-
{hasDiff && (
|
|
89
|
-
<pre className="tool-pre diff">{data.diff!.map((l, i) => <div className={`dl ${diffCls(l.sign)}`} key={i}>{l.sign} {l.text}</div>)}</pre>
|
|
90
|
-
)}
|
|
91
|
-
{data.result !== undefined && (
|
|
92
|
-
<>
|
|
93
|
-
{(data.detail || hasDiff) && <div className="tool-divider">结果</div>}
|
|
94
|
-
<pre className={`tool-pre ${data.isError ? "err" : ""}`}>{data.result || "(无输出)"}</pre>
|
|
95
|
-
</>
|
|
96
|
-
)}
|
|
97
|
-
</div>
|
|
98
|
-
)}
|
|
99
|
-
</div>
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
31
|
|
|
103
|
-
// Interactive AskUserQuestion card: option pickers (+ an "Other" free-text per
|
|
104
|
-
// question) while pending; a read-only summary once answered.
|
|
105
|
-
function AskView({ data, agentLabel, onAnswer }: { data: ToolData; agentLabel: string; onAnswer: (answers: AskAnswers) => void }) {
|
|
106
|
-
const questions = data.ask?.questions ?? [];
|
|
107
|
-
const existing = data.ask?.answers;
|
|
108
|
-
const [state, setState] = useState(() => questions.map(() => ({ picked: [] as string[], other: "" })));
|
|
109
|
-
const [submitted, setSubmitted] = useState(false);
|
|
110
|
-
|
|
111
|
-
if (existing) {
|
|
112
|
-
return (
|
|
113
|
-
<div className="ask answered">
|
|
114
|
-
<div className="ask-head">{Icon.spark()} 你的回答</div>
|
|
115
|
-
{questions.map((q, i) => (
|
|
116
|
-
<div className="ask-q" key={i}>
|
|
117
|
-
<div className="ask-qh"><span className="ask-chip">{q.header}</span>{q.question}</div>
|
|
118
|
-
<div className="ask-picked">{(existing[i] ?? []).join("、") || "—"}</div>
|
|
119
|
-
</div>
|
|
120
|
-
))}
|
|
121
|
-
</div>
|
|
122
|
-
);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const toggle = (qi: number, label: string, multi: boolean) =>
|
|
126
|
-
setState((s) => s.map((st, i) => {
|
|
127
|
-
if (i !== qi) return st;
|
|
128
|
-
if (multi) {
|
|
129
|
-
const has = st.picked.includes(label);
|
|
130
|
-
return { ...st, picked: has ? st.picked.filter((l) => l !== label) : [...st.picked, label] };
|
|
131
|
-
}
|
|
132
|
-
return { picked: [label], other: "" }; // single-select replaces, clears Other
|
|
133
|
-
}));
|
|
134
|
-
const setOther = (qi: number, v: string) =>
|
|
135
|
-
setState((s) => s.map((st, i) => (i === qi ? (questions[i].multiSelect ? { ...st, other: v } : { picked: [], other: v }) : st)));
|
|
136
|
-
|
|
137
|
-
const answers: AskAnswers = state.map((st) => [...st.picked, ...(st.other.trim() ? [st.other.trim()] : [])]);
|
|
138
|
-
const complete = answers.every((a) => a.length > 0);
|
|
139
|
-
const submit = () => { if (complete && !submitted) { setSubmitted(true); onAnswer(answers); } };
|
|
140
|
-
|
|
141
|
-
return (
|
|
142
|
-
<div className="ask">
|
|
143
|
-
<div className="ask-head">{Icon.spark()} {agentLabel} 需要你的输入</div>
|
|
144
|
-
{questions.map((q, i) => (
|
|
145
|
-
<div className="ask-q" key={i}>
|
|
146
|
-
<div className="ask-qh"><span className="ask-chip">{q.header}</span>{q.question}{q.multiSelect && <span className="ask-multi">可多选</span>}</div>
|
|
147
|
-
<div className="ask-opts">
|
|
148
|
-
{q.options.map((o) => (
|
|
149
|
-
<button key={o.label} className={`ask-opt ${state[i].picked.includes(o.label) ? "on" : ""}`} onClick={() => toggle(i, o.label, !!q.multiSelect)} disabled={submitted}>
|
|
150
|
-
<span className="ask-opt-l">{o.label}</span>
|
|
151
|
-
{o.description && <span className="ask-opt-d">{o.description}</span>}
|
|
152
|
-
</button>
|
|
153
|
-
))}
|
|
154
|
-
<input className="ask-other" placeholder="其他…" value={state[i].other} disabled={submitted} onChange={(e) => setOther(i, e.target.value)} />
|
|
155
|
-
</div>
|
|
156
|
-
</div>
|
|
157
|
-
))}
|
|
158
|
-
<div className="ask-foot">
|
|
159
|
-
<button className="btn btn-accent btn-sm" onClick={submit} disabled={!complete || submitted}>{submitted ? "发送中…" : "发送回答"}</button>
|
|
160
|
-
</div>
|
|
161
|
-
</div>
|
|
162
|
-
);
|
|
163
|
-
}
|
|
164
32
|
|
|
165
33
|
/** Ask the session rail to show this task's browser (SessionRail / SessionView listen). */
|
|
166
34
|
export function showTaskBrowser(taskId?: string): void {
|
|
167
35
|
window.dispatchEvent(new CustomEvent("orch:show-browser", {detail:{taskId}}));
|
|
168
36
|
}
|
|
169
37
|
|
|
170
|
-
// browser_ask_user card (lib/browserInput.ts): what the agent needs to carry on
|
|
171
|
-
// in the browser — codes, accounts, passwords, choices — typed or picked right
|
|
172
|
-
// here while the browser waits on the page. Passwords are masked and never kept.
|
|
173
|
-
function InputView({ input, agentLabel, onSubmit, onShowBrowser }: { onShowBrowser?:()=>void; input: InputRequest; agentLabel: string; onSubmit?: (id: string, values: string[]) => Promise<string | null> }) {
|
|
174
|
-
const [vals, setVals] = useState<string[]>(() => input.fields.map(() => ""));
|
|
175
|
-
const [shown, setShown] = useState<boolean[]>(() => input.fields.map(() => false));
|
|
176
|
-
const [busy, setBusy] = useState(false);
|
|
177
|
-
const [err, setErr] = useState<string | null>(null);
|
|
178
|
-
const browser = clientFeatures().browser;
|
|
179
|
-
|
|
180
|
-
if (input.status !== "pending") {
|
|
181
|
-
return (
|
|
182
|
-
<div className="ask answered">
|
|
183
|
-
<div className="ask-head">{Icon.lock()} {input.status === "answered" ? "你提供的信息" : "已取消 —— 这一轮被停止了"}</div>
|
|
184
|
-
<div className="ask-q"><div className="ask-qh">{input.message}</div></div>
|
|
185
|
-
{input.status === "answered" && input.fields.map((f, i) => (
|
|
186
|
-
<div className="ask-q" key={i}>
|
|
187
|
-
<div className="ask-qh"><span className="ask-chip">{f.label}</span></div>
|
|
188
|
-
<div className="ask-picked">{f.secret ? "••••••(只交给浏览器脚本,没有保存,也没有发给模型)" : input.values?.[i] ?? "—"}</div>
|
|
189
|
-
</div>
|
|
190
|
-
))}
|
|
191
|
-
</div>
|
|
192
|
-
);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const set = (i: number, v: string) => setVals((a) => a.map((x, j) => (j === i ? v : x)));
|
|
196
|
-
const complete = vals.every((v) => v.trim());
|
|
197
|
-
const submit = async () => {
|
|
198
|
-
if (!complete || busy || !onSubmit) return;
|
|
199
|
-
setBusy(true);
|
|
200
|
-
setErr(null);
|
|
201
|
-
try {
|
|
202
|
-
const e = await onSubmit(input.id, vals.map((v, i) => input.fields[i].secret ? v : v.trim()));
|
|
203
|
-
if (e) { setErr(e); setBusy(false); }
|
|
204
|
-
} catch (error) {
|
|
205
|
-
setErr(error instanceof Error ? error.message : "提交失败,请重试");
|
|
206
|
-
setBusy(false);
|
|
207
|
-
}
|
|
208
|
-
};
|
|
209
|
-
return (
|
|
210
|
-
<div className="ask">
|
|
211
|
-
<div className="ask-head">{Icon.lock()} {agentLabel} 需要你提供信息 · 浏览器正停在这一页等待</div>
|
|
212
|
-
<div className="ask-q"><div className="ask-qh" style={{ whiteSpace: "pre-wrap" }}>{input.message}</div></div>
|
|
213
|
-
{input.fields.map((f, i) => (
|
|
214
|
-
<div className="ask-q" key={i}>
|
|
215
|
-
<div className="ask-qh"><span className="ask-chip">{f.label}</span>{f.secret && <span className="ask-multi">保密</span>}</div>
|
|
216
|
-
<div className="ask-opts">
|
|
217
|
-
{f.options?.map((o) => (
|
|
218
|
-
<button key={o} className={`ask-opt ${vals[i] === o ? "on" : ""}`} onClick={() => set(i, o)} disabled={busy}>
|
|
219
|
-
<span className="ask-opt-l">{o}</span>
|
|
220
|
-
</button>
|
|
221
|
-
))}
|
|
222
|
-
<div className="input-row">
|
|
223
|
-
<input
|
|
224
|
-
className="ask-other"
|
|
225
|
-
type={f.secret && !shown[i] ? "password" : "text"}
|
|
226
|
-
autoComplete={f.secret ? "new-password" : "off"}
|
|
227
|
-
placeholder={f.options?.length ? "其他…" : f.placeholder || `输入${f.label}`}
|
|
228
|
-
value={f.options?.includes(vals[i]) ? "" : vals[i]}
|
|
229
|
-
disabled={busy}
|
|
230
|
-
autoFocus={i === 0 && !f.options?.length}
|
|
231
|
-
onChange={(e) => set(i, e.target.value)}
|
|
232
|
-
onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) void submit(); }}
|
|
233
|
-
/>
|
|
234
|
-
{f.secret && (
|
|
235
|
-
<button className="btn btn-ghost btn-sm" type="button" onClick={() => setShown((a) => a.map((x, j) => (j === i ? !x : x)))}>
|
|
236
|
-
{shown[i] ? "隐藏" : "显示"}
|
|
237
|
-
</button>
|
|
238
|
-
)}
|
|
239
|
-
</div>
|
|
240
|
-
</div>
|
|
241
|
-
</div>
|
|
242
|
-
))}
|
|
243
|
-
<div className="ask-foot" style={{ gap: 8, alignItems: "center" }}>
|
|
244
|
-
{err && <span className="input-err">{err}</span>}
|
|
245
|
-
{input.fields.some((f) => f.secret) && !err && <span className="input-note">密码只交给浏览器脚本,不保存、不发给模型</span>}
|
|
246
|
-
<span style={{ flex: 1 }} />
|
|
247
|
-
{browser && <button className="btn btn-line btn-sm" onClick={onShowBrowser ?? (()=>showTaskBrowser())}>查看浏览器</button>}
|
|
248
|
-
<button className="btn btn-accent btn-sm" onClick={() => void submit()} disabled={!complete || busy}>{busy ? "发送中…" : "提交"}</button>
|
|
249
|
-
</div>
|
|
250
|
-
</div>
|
|
251
|
-
);
|
|
252
|
-
}
|
|
253
38
|
|
|
254
39
|
// The transcript lines browser_handoff / a page prompt write (lib/browserHandoff.ts,
|
|
255
40
|
// lib/browser.ts — both start with 🖐): the user has to act in the page itself. Buttons to open the live browser and to hand
|
|
@@ -351,7 +136,7 @@ export const MessageView = memo(function MessageView({ m, initial, hideWho, runn
|
|
|
351
136
|
if (child.subagentMessage) return null;
|
|
352
137
|
if (child.subagent) return <SubagentEntry info={child.subagent} generation={m.generation} />;
|
|
353
138
|
if (data.input) {
|
|
354
|
-
return <div className="msg msg-tool" inert={readOnly||undefined}><InputView input={data.input} agentLabel={agentLabel} onSubmit={onInput}
|
|
139
|
+
return <div className="msg msg-tool" inert={readOnly||undefined}><InputView input={data.input} agentLabel={agentLabel} onSubmit={onInput} onBrowser={clientFeatures().browser ? onShowBrowser ?? (() => showTaskBrowser()) : undefined} unavailableNote={null} /></div>;
|
|
355
140
|
}
|
|
356
141
|
if (data.ask) {
|
|
357
142
|
return <div className="msg msg-tool" inert={readOnly||undefined}><AskView data={data} agentLabel={agentLabel} onAnswer={(answers) => onAnswer?.(data.ask?.id || m.toolId || "", data.ask?.questions ?? [], answers)} /></div>;
|
|
@@ -471,15 +256,3 @@ export const MessageView = memo(function MessageView({ m, initial, hideWho, runn
|
|
|
471
256
|
);
|
|
472
257
|
});
|
|
473
258
|
|
|
474
|
-
export function SessionBreak({ summary }: { summary: string }) {
|
|
475
|
-
return (
|
|
476
|
-
<div className="sbreak">
|
|
477
|
-
<span className="ln" />
|
|
478
|
-
<div className="card">
|
|
479
|
-
<div className="cl">{Icon.clear()} 上下文已清空 · 摘要已保存</div>
|
|
480
|
-
<div className="ct">{summary}</div>
|
|
481
|
-
</div>
|
|
482
|
-
<span className="ln" />
|
|
483
|
-
</div>
|
|
484
|
-
);
|
|
485
|
-
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
4
|
-
import { Icon } from "
|
|
4
|
+
import { Icon } from "../../ui/icons";
|
|
5
5
|
import { jget, jsend } from "./api";
|
|
6
6
|
import { LoadNote, ErrNote } from "./shared";
|
|
7
7
|
import { fmtBytes } from "./format";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createContext } from 'react';
|
|
2
|
+
import { createBoardSurfaces } from '../../board/linkage';
|
|
2
3
|
import type { WorkspaceHandlers } from '../../core/provider';
|
|
3
4
|
import type { CliEvent } from '../../core/store';
|
|
4
5
|
import type { Quote } from './composerDraft';
|
|
@@ -10,6 +11,8 @@ export const OperatorBoardContext = createContext<((target: OperatorBoardTarget)
|
|
|
10
11
|
|
|
11
12
|
export const OperatorBoardQuoteContext = createContext<((quote:Quote)=>void)|null>(null);
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
// The page's board receivers: the same registry the center store uses (board/linkage),
|
|
15
|
+
// one instance for the node workspace. Last registered is asked first.
|
|
16
|
+
const surfaces=createBoardSurfaces<OperatorBoardTarget>();
|
|
17
|
+
export function registerOperatorBoard(surface:(target:OperatorBoardTarget)=>boolean):()=>void {return surfaces.register(surface);}
|
|
18
|
+
export function openOperatorBoard(target:OperatorBoardTarget):boolean {return surfaces.open(target);}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { useSyncExternalStore } from 'react';
|
|
2
2
|
import type { BoardFile, BoardView } from '../../board/workspace';
|
|
3
|
-
import { rememberBoardFile, cloneBoardView
|
|
3
|
+
import { rememberBoardFile, cloneBoardView } from '../../board/context';
|
|
4
|
+
import { createBoardOwners } from '../../board/linkage';
|
|
4
5
|
import { notifyCallback } from '../../core/callbacks';
|
|
5
6
|
const cache=new Map<string,{stamp:string;view:BoardView|null}>();
|
|
6
7
|
const listeners=new Map<string,Set<()=>void>>();
|
|
7
|
-
|
|
8
|
+
// Which of a task's boards is in effect: the same owner rules as the center store (board/linkage).
|
|
9
|
+
const owners=createBoardOwners();
|
|
8
10
|
// Mounted directory identity is transient; it must not become retained browsing history.
|
|
9
11
|
const directorySources=new Map<string,Map<string,string>>();
|
|
10
12
|
const key=(id:string)=>`cli-ui:operator-board:${id}`;
|
|
@@ -24,8 +26,8 @@ export function readBoardState(taskId:string):BoardView|null {
|
|
|
24
26
|
}
|
|
25
27
|
/** Internal: background refresh covers every visible owner, not just the selected board. */
|
|
26
28
|
export function readVisibleBoardSources(taskId:string):string[] {
|
|
27
|
-
const active=owners.
|
|
28
|
-
const views=active
|
|
29
|
+
const active=owners.entries(taskId);
|
|
30
|
+
const views=active.length?active:[['',readBoardState(taskId)] as const];
|
|
29
31
|
const sources=new Set<string>();
|
|
30
32
|
for(const [owner,view] of views){
|
|
31
33
|
if(!view||view.visible===false)continue;
|
|
@@ -51,24 +53,21 @@ export function visitBoardFile(taskId:string,file:BoardFile):void {
|
|
|
51
53
|
publishBoardState(taskId,{...current,history:recent([...(current.history??[]),file])});
|
|
52
54
|
}
|
|
53
55
|
export function reportBoardState(taskId:string,owner:string,view:BoardView,directorySource?:string):void {
|
|
54
|
-
const sources=owners.get(taskId)??new Map<string,BoardView>();
|
|
55
56
|
if(directorySource){const directories=directorySources.get(taskId)??new Map<string,string>();directories.set(owner,directorySource);directorySources.set(taskId,directories);}
|
|
56
57
|
else{const directories=directorySources.get(taskId);directories?.delete(owner);if(!directories?.size)directorySources.delete(taskId);}
|
|
57
58
|
const history=readBoardState(taskId)?.history??view.history??[];
|
|
58
|
-
const selected=
|
|
59
|
-
if(selected)publishBoardState(taskId,{...selected
|
|
59
|
+
const selected=owners.report(taskId,owner,{...view,history});
|
|
60
|
+
if(selected)publishBoardState(taskId,{...selected,history});
|
|
60
61
|
}
|
|
61
62
|
export function activateBoardState(taskId:string,owner:string):void {
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
const previous=readBoardState(taskId),file=selected[1].current;
|
|
63
|
+
const selected=owners.activate(taskId,owner);if(!selected)return;
|
|
64
|
+
const previous=readBoardState(taskId),file=selected.current;
|
|
65
65
|
const history=file?rememberBoardFile(previous?.history??[],file):previous?.history??[];
|
|
66
|
-
publishBoardState(taskId,{...selected
|
|
66
|
+
publishBoardState(taskId,{...selected,history});
|
|
67
67
|
}
|
|
68
68
|
export function releaseBoardState(taskId:string,owner:string):void {
|
|
69
|
-
const
|
|
69
|
+
const remaining=owners.report(taskId,owner,null);
|
|
70
70
|
const directories=directorySources.get(taskId);directories?.delete(owner);if(!directories?.size)directorySources.delete(taskId);
|
|
71
71
|
const previous=readBoardState(taskId);if(!previous)return;
|
|
72
|
-
const remaining=sources?.size?selectedBoardOwner(sources)?.[1]:null;
|
|
73
72
|
publishBoardState(taskId,remaining?{...remaining,history:previous.history,visible:remaining.visible!==false}:{...previous,visible:false});
|
|
74
73
|
}
|