@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +37 -4
  2. package/README.md +29 -4
  3. package/dist/{cli-ui-embed-D-3DvyTN.js → cli-ui-embed-D9xgur2y.js} +18376 -18648
  4. package/dist/{cli-ui-index-C34qFb4u.js → cli-ui-index-WBtxmw23.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-DojbZPiE.js → cli-ui-jszip.min-CU3ZzoXb.js} +1 -1
  6. package/dist/{cli-ui-jszip.min-BObcE2nn.js → cli-ui-jszip.min-DdU0X3VJ.js} +2 -2
  7. package/dist/{cli-ui-xlsx-JVLtx0qR.js → cli-ui-xlsx-Dx6T7qhC.js} +1 -1
  8. package/dist/cli-ui.css +1 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +1 -1
  11. package/src/board/ChangesPane.tsx +10 -4
  12. package/src/board/CliBoard.tsx +48 -16
  13. package/src/board/FileViewer.tsx +34 -1
  14. package/src/board/diffRefresh.ts +63 -0
  15. package/src/board/linkage.ts +68 -0
  16. package/src/board/pathResolve.ts +108 -0
  17. package/src/chat/CliChat.tsx +105 -182
  18. package/src/chat/Composer.tsx +4 -3
  19. package/src/core/conversation.ts +16 -4
  20. package/src/core/store.ts +120 -40
  21. package/src/core/types.ts +2 -0
  22. package/src/msglist/CliMsgList.tsx +3 -2
  23. package/src/operator/node/AgentConnect.tsx +19 -15
  24. package/src/operator/node/AppearancePanel.tsx +1 -1
  25. package/src/operator/node/BranchNavigator.tsx +1 -1
  26. package/src/operator/node/BrowserPane.tsx +1 -1
  27. package/src/operator/node/Chat.tsx +16 -261
  28. package/src/operator/node/CommandPalette.tsx +1 -1
  29. package/src/operator/node/Composer.tsx +1 -1
  30. package/src/operator/node/Layout.tsx +1 -1
  31. package/src/operator/node/Markdown.tsx +6 -36
  32. package/src/operator/node/Modal.tsx +1 -1
  33. package/src/operator/node/OnboardingWizard.tsx +9 -5
  34. package/src/operator/node/Orchestrator.tsx +1 -1
  35. package/src/operator/node/ProjectLanding.tsx +1 -1
  36. package/src/operator/node/ProjectsColumn.tsx +1 -1
  37. package/src/operator/node/ProviderAuth.tsx +12 -10
  38. package/src/operator/node/RunSwitch.tsx +1 -1
  39. package/src/operator/node/Services.tsx +1 -1
  40. package/src/operator/node/SessionRail.tsx +27 -8
  41. package/src/operator/node/SessionView.tsx +1 -1
  42. package/src/operator/node/SettingsView.tsx +1 -1
  43. package/src/operator/node/TaskBoard.tsx +1 -1
  44. package/src/operator/node/TaskChanges.tsx +34 -5
  45. package/src/operator/node/TaskHero.tsx +1 -1
  46. package/src/operator/node/TasksColumn.tsx +1 -1
  47. package/src/operator/node/Transcript.tsx +10 -237
  48. package/src/operator/node/Welcome.tsx +1 -1
  49. package/src/operator/node/WorktreePrune.tsx +1 -1
  50. package/src/operator/node/boardInteraction.ts +6 -3
  51. package/src/operator/node/boardState.ts +12 -13
  52. package/src/operator/node/fileLinks.ts +4 -38
  53. package/src/operator/node/format.ts +12 -104
  54. package/src/operator/node/github.tsx +10 -8
  55. package/src/operator/node/modals.tsx +15 -13
  56. package/src/operator/node/recheckOnReturn.ts +28 -0
  57. package/src/operator/node/shared.tsx +10 -50
  58. package/src/operator/node/useTaskStream.ts +2 -2
  59. package/src/operator.base.css +3 -0
  60. package/src/operator.css +3 -0
  61. package/src/styles.css +5 -0
  62. package/src/ui/Markdown.tsx +36 -21
  63. package/src/ui/Transcript.tsx +8 -5
  64. package/src/ui/fileLinks.ts +6 -3
  65. package/src/ui/format.ts +11 -5
  66. package/src/ui/shared.tsx +11 -7
  67. package/src/ui/transcriptModel.ts +40 -9
  68. package/src/ui/useTranscriptWindow.ts +253 -0
  69. package/src/operator/node/boundedPoll.ts +0 -47
  70. package/src/operator/node/icons.tsx +0 -88
  71. 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 "./icons";
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 clean=path.replace(/^\.\//,'').replace(/\/+$/,'');if(!clean)return;
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 "./icons";
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 "./icons";
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 "./icons";
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 — refetch when a turn finishes so a
197
- // just-written change appears without a manual Refresh (same trigger the
198
- // SyncBanner uses). Only on the running→idle transition; mount already loads.
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) load();
222
+ if (wasRunning.current && !running) setSettle((n) => n + 1);
202
223
  wasRunning.current = running;
203
- }, [running, load]);
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,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { Icon } from "./icons";
3
+ import { Icon } from "../../ui/icons";
4
4
  import { SLABEL, type TaskRow, type ProjectRow } from "./types";
5
5
  import { StatusDot } from "./shared";
6
6
 
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useState } from "react";
4
- import { Icon } from "./icons";
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, useId, useState } from "react";
9
- import type { ToolData, ToolPeek, AskQuestion, AskAnswers, InputRequest } from "./protocol/types";
10
- import { Icon } from "./icons";
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, diffCls, splitAttachments, type MsgAttachment } from "./format";
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 "./transcriptModel";
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} onShowBrowser={onShowBrowser} /></div>;
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 { useState } from "react";
4
- import { Icon } from "./icons";
4
+ import { Icon } from "../../ui/icons";
5
5
  import { Modal } from "./Modal";
6
6
 
7
7
  // Coach marks + the post-merge nudge for the built-in "Welcome" tutorial project
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useCallback, useEffect, useMemo, useState } from "react";
4
- import { Icon } from "./icons";
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
- const surfaces=new Set<(target:OperatorBoardTarget)=>boolean>();
14
- export function registerOperatorBoard(surface:(target:OperatorBoardTarget)=>boolean):()=>void {surfaces.add(surface);return()=>{surfaces.delete(surface);};}
15
- export function openOperatorBoard(target:OperatorBoardTarget):boolean {for(const surface of [...surfaces].reverse())if(surface(target))return true;return false;}
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, reportBoardOwner, activateBoardOwner, selectedBoardOwner } from '../../board/context';
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
- const owners=new Map<string,Map<string,BoardView>>();
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.get(taskId);
28
- const views=active?.size?[...active]:[['',readBoardState(taskId)] as const];
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=reportBoardOwner(sources,owner,{...view,history});owners.set(taskId,sources);
59
- if(selected)publishBoardState(taskId,{...selected[1],history});
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 sources=owners.get(taskId);if(!sources)return;
63
- const selected=activateBoardOwner(sources,owner);if(!selected)return;
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[1],history});
66
+ publishBoardState(taskId,{...selected,history});
67
67
  }
68
68
  export function releaseBoardState(taskId:string,owner:string):void {
69
- const sources=owners.get(taskId);sources?.delete(owner);if(!sources?.size)owners.delete(taskId);
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
  }