@spzxg/cli-ui 0.6.6 → 0.6.8

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 (79) hide show
  1. package/CHANGELOG.md +40 -1
  2. package/README.md +90 -21
  3. package/dist/cli-ui-embed-Be9_d9jB.js +49691 -0
  4. package/dist/{cli-ui-index-b8wF7wrN.js → cli-ui-index-CzNfiynu.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-B0uLrCHg.js → cli-ui-jszip.min-BL0mSekN.js} +2 -2
  6. package/dist/{cli-ui-jszip.min-ggP8OokP.js → cli-ui-jszip.min-DjCIAfse.js} +1 -1
  7. package/dist/{cli-ui-xlsx-DEDdHrJJ.js → cli-ui-xlsx-CscI4xk0.js} +1 -1
  8. package/dist/cli-ui.css +1 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +3 -1
  11. package/src/board/CliBoard.tsx +128 -100
  12. package/src/board/FileViewer.tsx +259 -132
  13. package/src/board/FilesPane.tsx +129 -40
  14. package/src/board/HtmlReadingPreview.tsx +52 -0
  15. package/src/board/MarkdownPreview.tsx +134 -0
  16. package/src/board/PanelControls.tsx +19 -7
  17. package/src/board/fileKind.ts +138 -0
  18. package/src/board/fileOps.ts +9 -2
  19. package/src/board/fileScope.ts +29 -0
  20. package/src/board/fileSync.ts +343 -0
  21. package/src/board/fileSyncCache.ts +250 -0
  22. package/src/board/fileSyncProcessor.ts +71 -0
  23. package/src/board/fileSyncQueue.ts +56 -0
  24. package/src/board/fileSyncTransport.ts +85 -0
  25. package/src/board/fileWatch.ts +177 -76
  26. package/src/board/folderDownload.ts +39 -5
  27. package/src/board/frameQuote.ts +10 -8
  28. package/src/board/htmlReading.ts +148 -0
  29. package/src/board/textLoad.ts +14 -39
  30. package/src/board/useBoardUpload.tsx +51 -0
  31. package/src/board/useBoardVisibility.ts +35 -0
  32. package/src/board/workspace.ts +38 -6
  33. package/src/chat/CliChat.tsx +121 -51
  34. package/src/chat/Composer.tsx +54 -22
  35. package/src/chat/GroupChat.tsx +113 -0
  36. package/src/chat/RunControls.tsx +143 -46
  37. package/src/chat/draft.ts +12 -4
  38. package/src/chat/useExecutionConfig.ts +93 -0
  39. package/src/components.tsx +1 -1
  40. package/src/core/blocks.ts +2 -1
  41. package/src/core/client.ts +65 -61
  42. package/src/core/config.ts +6 -2
  43. package/src/core/documentUpload.ts +196 -0
  44. package/src/core/execution.ts +24 -0
  45. package/src/core/store.ts +81 -20
  46. package/src/core/types.ts +3 -1
  47. package/src/index.ts +4 -0
  48. package/src/msglist/CliMsgList.tsx +9 -2
  49. package/src/operator/node/Chat.tsx +11 -9
  50. package/src/operator/node/Composer.tsx +103 -75
  51. package/src/operator/node/Orchestrator.tsx +1 -1
  52. package/src/operator/node/SessionRail.tsx +71 -32
  53. package/src/operator/node/SessionView.tsx +31 -28
  54. package/src/operator/node/Transcript.tsx +21 -9
  55. package/src/operator/node/boardBackend.ts +0 -19
  56. package/src/operator/node/boardEvents.ts +21 -3
  57. package/src/operator/node/boardState.ts +18 -1
  58. package/src/operator/node/boardWorkspace.ts +4 -1
  59. package/src/operator/node/composerDraft.ts +6 -2
  60. package/src/operator/node/format.ts +4 -2
  61. package/src/operator/node/liveStream.ts +14 -2
  62. package/src/operator/node/useTaskStream.ts +3 -1
  63. package/src/operator.base.css +150 -43
  64. package/src/operator.css +150 -43
  65. package/src/styles.css +253 -39
  66. package/src/ui/Markdown.tsx +2 -2
  67. package/src/ui/Popover.tsx +25 -12
  68. package/src/ui/QueuedMessages.tsx +69 -0
  69. package/src/ui/ReferenceCard.tsx +28 -0
  70. package/src/ui/TextBlock.tsx +6 -4
  71. package/src/ui/Transcript.tsx +20 -10
  72. package/src/ui/UploadProgress.tsx +46 -0
  73. package/src/ui/WorkspaceDrawer.tsx +64 -0
  74. package/src/ui/WorkspaceTabs.tsx +60 -0
  75. package/src/ui/format.ts +3 -1
  76. package/src/ui/reference.ts +40 -0
  77. package/src/ui/useChatScrollbar.ts +29 -0
  78. package/src/ui/useComposerAutosize.ts +54 -0
  79. package/dist/cli-ui-embed-BAqonuoD.js +0 -45952
@@ -5,7 +5,6 @@ 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 { toolDataOf } from "./transcriptModel";
9
8
  import { Icon } from "./icons";
10
9
  import { type ResolveResult } from "./TaskChanges";
11
10
  import { fmtTokens, fmtCost, fmtJobCost, isAwaiting, buildSessions, usageSplit, costDisplay } from "./format";
@@ -27,8 +26,11 @@ import { registerOperatorBoard } from "./boardInteraction";
27
26
  import { useConfigurationBusy } from "../../chat/configuration";
28
27
  import type { SessionRail as NodeSessionRail } from "./SessionRail";
29
28
  const SessionRail=(props:ComponentProps<typeof NodeSessionRail>)=><CliBoard operator={props}/>;
30
- import { ColResize, ColRail } from "./Layout";
29
+ import { ColResize } from "./Layout";
30
+ import { WorkspaceTabs } from "../../ui/WorkspaceTabs";
31
+ import { WorkspaceDrawer, useWorkspaceDrawer } from "../../ui/WorkspaceDrawer";
31
32
  import { jget, downloadExport } from "./api";
33
+ import { resolveChatConfig, type CliChatConfig } from "../../core/config";
32
34
 
33
35
  // Non-blocking banner shown when a reopened task's worktree is behind its base
34
36
  // branch. Computed (read-only) on open; the actual git op fires only when the user
@@ -91,8 +93,9 @@ function SyncBanner({ taskId, running, pinned, onResolveWithAI, onSwitchToChat }
91
93
  }
92
94
 
93
95
  export function SessionView(props: Parameters<typeof SessionViewInner>[0]) { return <SessionViewInner key={props.task.id} {...props}/>; }
94
- function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents, messages, running, blockedBy, transcriptLoading, older=0, onLoadOlder, onSend, onStart, onStop, onClear, clearConfirming, onConfirmClear, onCancelClear, onEdit, onReconnect, onSetStatus, onSetPriority, onSetModel, onSetAgent, onSetReasoning, onSetPermission, onSetSendContext, onResolveWithAI, onMerged, onPrCreated, onAnswer, onCancelQueued, onBack, mobile, railW, onRailWidth, onRailReset, railCollapsed, onRailCollapse, onRailExpand, board }: {
96
+ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents, messages, running, blockedBy, transcriptLoading, older=0, onLoadOlder, onSend, onStart, onStop, onClear, clearConfirming, onConfirmClear, onCancelClear, onEdit, onReconnect, onSetStatus, onSetPriority, onSetModel, onSetAgent, onSetReasoning, onSetPermission, onSetSendContext, onResolveWithAI, onMerged, onPrCreated, onAnswer, onCancelQueued, onBack, mobile, railW, onRailWidth, onRailReset, railCollapsed, onRailCollapse, onRailExpand, board, composer }: {
95
97
  board?: OperatorBoardOptions;
98
+ composer?: CliChatConfig["composer"];
96
99
  tasks: TaskRow[]; onSelectTask: (id: string) => void; onFork: (text: string, sourceIds: string[], requestId: string) => Promise<boolean>;
97
100
  project: ProjectRow; task: TaskRow; agents: AgentsBundle; messages: Msg[]; running: boolean; blockedBy?: string[]; transcriptLoading?: boolean;
98
101
  older?: number; onLoadOlder?: (beforeApply?: () => void) => Promise<boolean>;
@@ -134,21 +137,24 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
134
137
  return () => observer.disconnect();
135
138
  }, [railW]);
136
139
  const singlePane = mobile || compact;
140
+ const drawerMode = useWorkspaceDrawer(sessionRef, !!mobile);
141
+ const tabMode = !drawerMode && (singlePane || railCollapsed);
142
+ const [tabsHost, setTabsHost] = useState<HTMLDivElement|null>(null);
137
143
  const [statusOpen, setStatusOpen] = useState(false);
138
144
  const [moreOpen, setMoreOpen] = useState(false);
139
145
  const [view, setView] = useState<"chat" | "changes">("chat");
140
146
  const [boardRequest,setBoardRequest]=useState<OperatorBoardRequest|null>(null);
141
147
  const boardSequence=useRef(0);
142
148
  const acknowledgeBoardRequest=useCallback((sequence:number)=>setBoardRequest(previous=>previous?.sequence===sequence?null:previous),[]);
143
- const routing=useRef({singlePane,railCollapsed,onRailExpand,task,tasks,board});
144
- routing.current={singlePane,railCollapsed,onRailExpand,task,tasks,board};
149
+ const routing=useRef({tabMode,drawerMode,task,tasks,board});
150
+ routing.current={tabMode,drawerMode,task,tasks,board};
145
151
  const openBoard=useCallback((target:OperatorBoardTarget)=>{
146
152
  const context=routing.current;
147
153
  if(target.kind==='browser'&&(target.taskId!==context.task.id||!clientFeatures().browser))return false;
148
154
  if(target.kind==='file'&&(!target.path||![context.task,...context.tasks].some(t=>t.id===target.taskId&&t.project_id===context.task.project_id)))return false;
149
155
  if(context.board?.availableTabs&&!context.board.availableTabs.includes(target.kind==='file'?'files':'browser'))return false;
150
156
  setBoardRequest({sequence:++boardSequence.current,target});
151
- if(context.singlePane)setView('changes');else if(context.railCollapsed)context.onRailExpand();
157
+ if(context.tabMode||context.drawerMode)setView('changes');
152
158
  return true;
153
159
  },[]);
154
160
  useEffect(()=>registerOperatorBoard(target=>target.taskId===task.id?openBoard(target):false),[task.id,openBoard]);
@@ -159,10 +165,9 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
159
165
  const quoteFromBoard=useCallback((quote:Quote)=>{
160
166
  const context=routing.current;
161
167
  writeDraft(context.task.id,{...readDraft(context.task.id),quote});
162
- if(context.singlePane)setView('chat');
168
+ if(context.tabMode||context.drawerMode)setView('chat');
163
169
  requestAnimationFrame(()=>sessionRef.current?.querySelector<HTMLTextAreaElement>('.composer textarea')?.focus({preventScroll:true}));
164
170
  },[]);
165
- const activity=useMemo(()=>{const tool=[...messages].reverse().find(m=>m.role==='tool');return `${messages.length}:${tool?.id??''}:${tool?toolDataOf(tool).result!==undefined:false}`;},[messages]);
166
171
  const [clearEstimate, setClearEstimate] = useState<InternalUsageEstimate | null>(null);
167
172
  const sessions = useMemo(() => buildSessions(messages), [messages]);
168
173
  const hasSession = task.started === 1 || messages.length > 0 || !!task.parent_task_id;
@@ -178,7 +183,8 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
178
183
  // Run-control pickers + feature gates come from this task's agent capabilities,
179
184
  // never a hardcoded list — so the options always match the agent it runs under.
180
185
  const caps = capsFor(agents, task.agent);
181
- const controls = { task, caps, agents:agents.agents.map(a=>({id:a.id,label:a.label,disabled:!a.authenticated||a.installed===false})), onSetAgent, defaults: findAgent(agents, task.agent)?.defaults, disabled: running, onSetModel, onSetReasoning, onSetPermission };
186
+ const composerConfig = resolveChatConfig({ composer }).composer;
187
+ const controls = { task, caps, agents:agents.agents.map(a=>({id:a.id,label:a.label,disabled:!a.authenticated||a.installed===false})), onSetAgent, defaults: findAgent(agents, task.agent)?.defaults, disabled: running, onSetModel, onSetReasoning, onSetPermission, showAgent: composerConfig.agentSelector, showPermission: composerConfig.executionMode };
182
188
  // Usage chip: tokens split into fresh work vs re-read cache (the raw total is
183
189
  // mostly cache reads and wildly overstates what ran), and a dollar figure whose
184
190
  // presentation follows how this agent is signed in — a subscription login's
@@ -193,7 +199,7 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
193
199
  <div className={`session${singlePane ? " compact-session" : ""}`} ref={sessionRef}>
194
200
  <div className="sess-head">
195
201
  {onBack && <button className="mobile-back" onClick={onBack} title="返回任务列表" aria-label="返回任务列表">{Icon.chevRight({ style: { transform: "rotate(180deg)" } })}</button>}
196
- <div className="sh-main">
202
+ {hasSession&&tabMode ? <WorkspaceTabs title={task.title} subtitle={project.name} chatActive={view==='chat'} onChat={()=>setView('chat')} onTabsHost={setTabsHost}/> : <div className="sh-main">
197
203
  <div className="crumb">
198
204
  <span className="pic" style={{ width: 16, height: 16, borderRadius: 5, background: project.color, display: "grid", placeItems: "center", color: "#fff", fontSize: 9, fontWeight: 700 }}>{project.name[0]}</span>
199
205
  <span className="session-project-name" title={project.name}>{project.name}</span>
@@ -203,8 +209,9 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
203
209
  <span className="sht-text" title={task.title}>{task.title}</span>
204
210
  <button className="icon-btn sh-edit" title="查看并编辑任务详情" aria-label="查看并编辑任务详情" onClick={onEdit}>{Icon.edit()}</button>
205
211
  </div>
206
- </div>
212
+ </div>}
207
213
  <div className="sh-tools">
214
+ {hasSession&&drawerMode&&<button className="icon-btn workspace-open" title="打开工作区" aria-label="打开工作区" aria-haspopup="dialog" aria-expanded={view==='changes'} onClick={()=>setView('changes')}>{Icon.folder()}</button>}
208
215
  <div style={{ position: "relative" }}>
209
216
  <button className={`status-ctl ${awaiting ? "awaiting" : ""}`} onClick={(e) => { e.stopPropagation(); setStatusOpen((o) => !o); setMoreOpen(false); }}>
210
217
  <StatusDot status={task.status} running={running} awaiting={awaiting} />
@@ -227,6 +234,7 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
227
234
  <button className="icon-btn session-more" aria-label="更多任务操作" aria-expanded={moreOpen} onClick={(e) => { e.stopPropagation(); setMoreOpen(!moreOpen); setStatusOpen(false); }}>{Icon.dots()}</button>
228
235
  {moreOpen && <Popover onClose={() => setMoreOpen(false)}>
229
236
  <div className="pop-sec">{agentLabel(agents, task.agent)} · {fmtTokens(usage.fresh)} tokens{cost.show && task.cost_usd > 0 ? ` · ${cost.approx ? "~" : ""}${fmtCost(task.cost_usd)}` : ""}</div>
237
+ <div className="pop-note session-origin" title={task.work_branch || ""}>{Icon.git()}<span>{task.parent_task_id ? `来自 ${task.fork_source_title || "来源任务"}` : "独立任务分支"}</span></div>
230
238
  <button className="pop-item" onClick={() => { setMoreOpen(false); onEdit(); }}>{Icon.edit()} 编辑任务详情</button>
231
239
  <button className="pop-item" onClick={() => { setMoreOpen(false); downloadExport(`/api/tasks/${task.id}/export`); }}>{Icon.download()} 导出执行过程</button>
232
240
  {task.pr_url && <a className="pop-item" href={task.pr_url} target="_blank" rel="noreferrer">{Icon.github()} 打开 PR{prNum ? ` #${prNum}` : ""}</a>}
@@ -239,14 +247,6 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
239
247
  </div>
240
248
  </div>
241
249
 
242
- {hasSession && <div className="session-viewbar">
243
- {singlePane ? <div className="viewseg">
244
- <button className={`viewseg-btn ${view === "chat" ? "on" : ""}`} onClick={() => setView("chat")}>对话</button>
245
- <button className={`viewseg-btn ${view === "changes" ? "on" : ""}`} onClick={() => setView("changes")}>看板</button>
246
- </div> : <span className="session-agent-label">{agentLabel(agents, task.agent)}</span>}
247
- <span className="session-branch" title={task.work_branch || ""}>{Icon.git()} {task.parent_task_id ? `来自 ${task.fork_source_title}` : "独立任务分支"}</span>
248
- </div>}
249
-
250
250
  {hasSession && (
251
251
  <SyncBanner taskId={task.id} running={running} pinned={!!task.fork_commit} onResolveWithAI={onResolveWithAI} onSwitchToChat={() => setView("chat")} />
252
252
  )}
@@ -262,24 +262,27 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
262
262
  )}
263
263
 
264
264
  {!hasSession ? (
265
- <><div className="hero-controls"><RunControls {...controls} /></div><TaskHero task={task} project={project} onStart={onStart} onEdit={onEdit} onSetSendContext={onSetSendContext} running={running || configBusy} blockedBy={blockedBy} /></>
265
+ <>{composerConfig.show && <div className="hero-controls"><RunControls {...controls} /></div>}<TaskHero task={task} project={project} onStart={onStart} onEdit={onEdit} onSetSendContext={onSetSendContext} running={running || configBusy} blockedBy={blockedBy} /></>
266
266
  ) : (
267
267
  // Keep the chat at the same React position across all widths. Moving
268
268
  // it between different parents remounted the composer during resize
269
269
  // and could drop a keystroke, an upload, or an in-flight setting save.
270
- <div className={singlePane ? "sess-responsive" : "sess-split"} style={singlePane ? undefined : { gridTemplateColumns: railCollapsed ? "minmax(0,1fr) 30px" : `minmax(0,1fr) 0px ${railW}px` }}>
271
- <div className={`sess-main${singlePane ? " compact-chat" : ""}`} hidden={singlePane && view !== "chat"}><Chat project={project} task={task} tasks={tasks} agents={agents} messages={messages}
270
+ <div className={tabMode||drawerMode ? "sess-responsive" : "sess-split"} style={tabMode||drawerMode ? undefined : { gridTemplateColumns: `minmax(0,1fr) 0px ${railW}px` }}>
271
+ <div className={`sess-main${tabMode||drawerMode ? " compact-chat" : ""}`} data-chat-layout={tabMode||drawerMode?"centered":"wide"} hidden={tabMode && view !== "chat"}><Chat project={project} task={task} tasks={tasks} agents={agents} messages={messages}
272
272
  running={running} blockedBy={blockedBy} transcriptLoading={transcriptLoading} older={older} onLoadOlder={onLoadOlder}
273
- controls={controls} configBusy={configBusy} active={!singlePane || view === "chat"}
273
+ controls={controls} configBusy={configBusy} composer={composerConfig} active={!tabMode&&!drawerMode || view === "chat"}
274
274
  onSelectTask={onSelectTask} onFork={onFork} onSend={onSend} onStop={onStop}
275
275
  onStart={onStart} onEdit={onEdit} onClear={onClear} onSetSendContext={onSetSendContext}
276
276
  onReconnect={onReconnect} onAnswer={onAnswer} onCancelQueued={onCancelQueued} /></div>
277
- {!singlePane && !railCollapsed && <ColResize side="right" min={RAIL_W.min} max={RAIL_W.max} onWidth={onRailWidth} onReset={onRailReset} />}
278
- {!singlePane && railCollapsed ? <ColRail label="看板" right onExpand={onRailExpand} /> : (!singlePane || view === "changes") && (
279
- <SessionRail {...board} openRequest={boardRequest} onRequestHandled={acknowledgeBoardRequest} activity={activity} project={project} task={task} sessions={sessions} running={running}
277
+ {!tabMode&&!drawerMode && <ColResize side="right" min={RAIL_W.min} max={RAIL_W.max} onWidth={onRailWidth} onReset={onRailReset} />}
278
+ <WorkspaceDrawer enabled={drawerMode} open={view==='changes'} onClose={()=>setView('chat')}>
279
+ <SessionRail {...board} openRequest={boardRequest} onRequestHandled={acknowledgeBoardRequest} project={project} task={task} sessions={sessions} running={running}
280
280
  onResolveWithAI={onResolveWithAI} onMerged={onMerged} onPrCreated={onPrCreated} onClear={onClear}
281
- onCollapse={singlePane ? () => setView("chat") : onRailCollapse} onSwitchToChat={() => setView("chat")} />
282
- )}
281
+ inlineLayout={tabMode?{tabsHost,active:view==='changes',onActivate:()=>setView('changes'),onChat:()=>setView('chat')}:undefined}
282
+ visible={!drawerMode||view==='changes'}
283
+ onExpand={tabMode&&!singlePane?onRailExpand:undefined}
284
+ onCollapse={tabMode||drawerMode ? () => setView("chat") : onRailCollapse} onSwitchToChat={() => setView("chat")} />
285
+ </WorkspaceDrawer>
283
286
  </div>
284
287
  )}
285
288
  </div>
@@ -3,7 +3,7 @@ import { useContext } from "react";
3
3
  import { OperatorBoardContext } from "./boardInteraction";
4
4
  import { FileLinkContext } from "./fileLinks";
5
5
 
6
- import { memo, useState } from "react";
6
+ import { memo, useId, useState } from "react";
7
7
  import type { ToolData, ToolPeek, AskQuestion, AskAnswers, InputRequest } from "./protocol/types";
8
8
  import { Icon } from "./icons";
9
9
  import { Markdown } from "./Markdown";
@@ -18,6 +18,8 @@ import type { Msg } from "./types";
18
18
  import { clientFeatures } from "./protocol/features";
19
19
  import { Avatar } from "./shared";
20
20
  import { toolDataOf } from "./transcriptModel";
21
+ import { ReferenceCard } from "../../ui/ReferenceCard";
22
+ import { splitQuotedMessage } from "../../ui/reference";
21
23
 
22
24
  // The always-visible "peek" tier — Claude Code's `⎿` line. Counts show no
23
25
  // content; diffs/snippets show a capped hunk with a clickable "+N more" that
@@ -64,21 +66,22 @@ function PeekView({ peek, expandable, onExpand }: { peek: ToolPeek; expandable:
64
66
  }
65
67
 
66
68
  function ToolView({ data }: { data: ToolData }) {
67
- const [open, setOpen] = useState(false);
69
+ const [open, setOpen] = useState<boolean | null>(null);
70
+ const bodyId = useId();
68
71
  const hasDiff = !!data.diff?.length;
69
72
  const expandable = !!(data.detail || hasDiff || data.result !== undefined);
70
73
  // Failures surface their output automatically, like Claude Code.
71
- const showBody = open || (!!data.isError && data.result !== undefined);
74
+ const showBody = open ?? (!!data.isError && data.result !== undefined);
72
75
  return (
73
76
  <div className="tool">
74
- <button className="tool-h" style={{ cursor: expandable ? "pointer" : "default" }} onClick={() => expandable && setOpen((o) => !o)}>
77
+ <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)}>
75
78
  {expandable && <span className={`tchev ${showBody ? "open" : ""}`}>{Icon.chevRight()}</span>}
76
79
  <span className="tg">{data.title}</span>
77
80
  {data.result !== undefined && <span className={data.isError ? "tx" : "tcheck"}>{data.isError ? Icon.x() : Icon.check()}</span>}
78
81
  </button>
79
82
  {data.peek && !showBody && <PeekView peek={data.peek} expandable={expandable} onExpand={() => setOpen(true)} />}
80
83
  {showBody && (
81
- <div className="tool-body">
84
+ <div className="tool-body" id={bodyId}>
82
85
  {data.detail && <pre className="tool-pre">{data.detail}</pre>}
83
86
  {hasDiff && (
84
87
  <pre className="tool-pre diff">{data.diff!.map((l, i) => <div className={`dl ${diffCls(l.sign)}`} key={i}>{l.sign} {l.text}</div>)}</pre>
@@ -280,6 +283,7 @@ function HandoffLine({ text, onBrowser, onShowBrowser, readOnly }: { readOnly?:b
280
283
  // (click opens full size) and text-file chips (a big paste diverted to a file;
281
284
  // click opens it). Both are served from the task's uploads dir.
282
285
  function AttachmentStrip({ items }: { items: MsgAttachment[] }) {
286
+ const open = useContext(OperatorBoardContext), scope = useContext(FileLinkContext);
283
287
  if (!items.length) return null;
284
288
  return (
285
289
  <div className="msg-attachments">
@@ -290,8 +294,11 @@ function AttachmentStrip({ items }: { items: MsgAttachment[] }) {
290
294
  <img src={a.url} alt="附件图片" loading="lazy" />
291
295
  </a>
292
296
  ) : (
293
- <a key={i} href={a.url} target="_blank" rel="noreferrer" className="file-chip" title={`打开 ${a.name}`}>
294
- {Icon.clip()} <span>附件文件</span>
297
+ <a key={i} href={a.url} target="_blank" rel="noreferrer" className="file-chip" title={`打开 ${a.name}`} onClick={event => {
298
+ const taskId = /\/api\/tasks\/([^/]+)\/uploads\//.exec(a.url)?.[1] ?? scope?.taskId;
299
+ if (taskId && event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey && open?.({ kind: "file", taskId: decodeURIComponent(taskId), path: a.path })) event.preventDefault();
300
+ }}>
301
+ {Icon.clip()} <span>{a.name}</span>
295
302
  </a>
296
303
  )
297
304
  )}
@@ -310,6 +317,11 @@ function AutomaticBoardBackground({content}:{content:string}) {
310
317
  return <details className="cli-auto-background"><summary>自动看板背景</summary>{blocks.map((part,index)=>part.kind==='block'?<pre key={index}>{part.block.text}</pre>:null)}</details>;
311
318
  }
312
319
 
320
+ function QuotedUserText({ text }: { text: string }) {
321
+ const quoted = splitQuotedMessage(text);
322
+ return quoted ? <><ReferenceCard title={quoted.quote.author} text={quoted.quote.text} renderContent={value => <Markdown>{value}</Markdown>} />{quoted.text && <Markdown>{quoted.text}</Markdown>}</> : <Markdown>{text}</Markdown>;
323
+ }
324
+
313
325
  export const MessageView = memo(function MessageView({ m, initial, hideWho, running, agent, agentLabel = "智能体", onAnswer, onInput, onBrowser, onCancelQueued, onClear, onReconnect, onRetry, onQuote, readOnly }: { readOnly?: boolean; onQuote?: (m: Msg, selected?: string) => void; m: Msg; initial: boolean; hideWho: boolean; running?: boolean; agent?: string | null; agentLabel?: string; onAnswer?: (askId: string, questions: AskQuestion[], answers: AskAnswers) => void; onInput?: (inputId: string, values: string[]) => Promise<string | null>; onBrowser?: (action: "handback") => Promise<string | null>; onCancelQueued?: (pendingId: string) => void; onClear?: () => void; onReconnect?: () => void; onRetry?: (msgId: string) => void }) {
314
326
  const openBoard=useContext(OperatorBoardContext);
315
327
  const fileScope=useContext(FileLinkContext);
@@ -323,7 +335,7 @@ export const MessageView = memo(function MessageView({ m, initial, hideWho, runn
323
335
  <div className="msg user queued">
324
336
  <div className="who"><Avatar who="user" /> 你<span className="badge queued-badge">排队中</span>{m.ts != null && <span className="msg-time">{clockTime(m.ts)}</span>}</div>
325
337
  <div className="msg-body">
326
- {text && <Markdown>{text}</Markdown>}
338
+ {text && <QuotedUserText text={text} />}
327
339
  <AutomaticBoardBackground content={m.content}/>
328
340
  <AttachmentStrip items={attachments} />
329
341
  {onCancelQueued && <button className="queued-x" title="从队列中移除" aria-label="从队列中移除" onClick={() => onCancelQueued(m.id)}>{Icon.x()}</button>}
@@ -440,7 +452,7 @@ export const MessageView = memo(function MessageView({ m, initial, hideWho, runn
440
452
  )}
441
453
  <div className="msg-body">
442
454
  {initial && <div className="initial-tag">{Icon.spark()} 已随项目上下文发送</div>}
443
- {text && <Markdown>{text}</Markdown>}
455
+ {text && (isUser ? <QuotedUserText text={text} /> : <Markdown>{text}</Markdown>)}
444
456
  {isUser&&<AutomaticBoardBackground content={m.content}/>}
445
457
  <AttachmentStrip items={attachments} />
446
458
  </div>
@@ -24,25 +24,6 @@ export function createOperatorBoardBackend(fetcher: typeof fetch = (...args) =>
24
24
  },
25
25
  fileUrl(id, path, options) { const url = taskFileApiUrl(id, path); return options?.download ? `${url}${url.includes('?') ? '&' : '?'}download=1` : url; },
26
26
  fileLinkBase: async id => `/api/tasks/${encodeURIComponent(id)}/files`,
27
- watchFiles(id,handlers) {
28
- let stopped=false,ended=false;
29
- const disrupted=(reason:string)=>{if(!stopped&&!ended)handlers.onEvent({type:'resync',reason,complete:false});};
30
- const subscription=openLive(`/api/tasks/${encodeURIComponent(id)}/files/events`,{
31
- onMessage:data=>{
32
- if(stopped||ended)return;
33
- try{const event=JSON.parse(data);if(['snapshot','changes','resync'].includes(event.type))handlers.onEvent(event);}catch{/* Ignore invalid event. */}
34
- },
35
- onEnd:code=>{
36
- if(stopped||ended)return;
37
- if(code===0||code===408||code===429||(code>=500&&code!==501)){disrupted(code===429?'文件监听暂时限流,正在等待恢复':'文件监听连接中断,正在重连');return;}
38
- ended=true;
39
- const message=code===401?'文件监听登录已过期,请重新登录':code===403?'文件监听被拒绝,请检查任务权限或登录':code===404?'未找到任务或节点不支持文件事件接口':`文件监听请求失败(HTTP ${code})`;
40
- handlers.onClose(new CenterError(message,`HTTP_${code}`,code));
41
- },
42
- onError:error=>disrupted(error.message),
43
- });
44
- return()=>{stopped=true;subscription.close();};
45
- },
46
27
  browserEvents(id,handlers) {
47
28
  let stopped=false,ended=false;
48
29
  const disrupted=(error:CenterError)=>{if(!stopped&&!ended)handlers.onError?.(error);};
@@ -4,16 +4,34 @@ import type { WorkspaceHandlers } from '../../core/provider';
4
4
  import type { WorkspaceChangeEvent } from '../../core/types';
5
5
  import { cloneBoardView } from '../../board/context';
6
6
  import type { CliEvent } from '../../core/store';
7
- import { cloneWorkspaceChange, subscribeWorkspaceFiles } from '../../board/fileWatch';
7
+ import { cloneWorkspaceChange, subscribeWorkspaceFiles, retryWorkspaceFiles, type WorkspaceRefreshOptions } from '../../board/fileWatch';
8
8
  import { getOperatorBoardBackend } from './boardBackend';
9
- import { readBoardState, subscribeBoardState } from './boardState';
9
+ import { readBoardState, readVisibleBoardSources, subscribeBoardState } from './boardState';
10
+ import { observeLiveMessages } from './liveStream';
11
+ export function refreshOperatorWorkspaceFiles(taskId:string,options?:WorkspaceRefreshOptions):void {
12
+ const backend=getOperatorBoardBackend(),sources=new Set([taskId,...readVisibleBoardSources(taskId)]);
13
+ for(const source of sources)options?retryWorkspaceFiles(backend,source,options):retryWorkspaceFiles(backend,source);
14
+ }
15
+ const observedTasks=new Map<string,{refs:number;stop:()=>void}>();
16
+ function observeTaskFiles(taskId:string):()=>void {
17
+ let entry=observedTasks.get(taskId);
18
+ if(!entry){
19
+ entry={refs:0,stop:observeLiveMessages(`/api/tasks/${taskId}/messages`,data=>{
20
+ try{const event=JSON.parse(data);if(event.type==='turn_end'||event.type==='snapshot')refreshOperatorWorkspaceFiles(taskId);}catch{/* Ignore unrelated/malformed stream payloads. */}
21
+ })};observedTasks.set(taskId,entry);
22
+ }
23
+ const owned=entry;owned.refs++;let active=true;
24
+ return()=>{if(!active)return;active=false;if(--owned.refs===0){observedTasks.delete(taskId);owned.stop();}};
25
+ }
10
26
  export type OperatorBoardHandlers=WorkspaceHandlers&{onEvent?:(event:CliEvent)=>void};
11
27
  export function useOperatorFileWatch(taskId:string,enabled:boolean,handlers:OperatorBoardHandlers={},onChange?:(event:WorkspaceChangeEvent)=>void,intervalMs?:number){
12
28
  const current=useRef({handlers,onChange});current.current={handlers,onChange};
13
29
  useEffect(()=>{
14
30
  if(!enabled)return;
15
- return subscribeWorkspaceFiles(getOperatorBoardBackend(),taskId,event=>{current.current.onChange?.(event);notifyCallback(current.current.handlers.onFilesChange,cloneWorkspaceChange(event));notifyCallback(current.current.handlers.onEvent,cloneWorkspaceChange(event));},
31
+ const unobserve=observeTaskFiles(taskId);
32
+ const unsubscribe=subscribeWorkspaceFiles(getOperatorBoardBackend(),taskId,event=>{current.current.onChange?.(event);notifyCallback(current.current.handlers.onFilesChange,cloneWorkspaceChange(event));notifyCallback(current.current.handlers.onEvent,cloneWorkspaceChange(event));},
16
33
  {intervalMs,onStatus:status=>{notifyCallback(current.current.handlers.onFilesWatch,{...status});notifyCallback(current.current.handlers.onEvent,{...status});}});
34
+ return()=>{unobserve();unsubscribe();};
17
35
  },[taskId,enabled,intervalMs]);
18
36
  }
19
37
  export function useOperatorContextEvents(taskId:string,enabled:boolean,handlers:OperatorBoardHandlers={}){
@@ -5,6 +5,8 @@ import { notifyCallback } from '../../core/callbacks';
5
5
  const cache=new Map<string,{stamp:string;view:BoardView|null}>();
6
6
  const listeners=new Map<string,Set<()=>void>>();
7
7
  const owners=new Map<string,Map<string,BoardView>>();
8
+ // Mounted directory identity is transient; it must not become retained browsing history.
9
+ const directorySources=new Map<string,Map<string,string>>();
8
10
  const key=(id:string)=>`cli-ui:operator-board:${id}`;
9
11
  const stamp=(id:string)=>{try{return sessionStorage.getItem(key(id))??'';}catch{return 'blocked';}};
10
12
  const validFile=(value:unknown):value is BoardFile=>!!value&&typeof value==='object'&&typeof (value as BoardFile).path==='string'&&!!(value as BoardFile).path&&((value as BoardFile).sourceId===undefined||typeof (value as BoardFile).sourceId==='string');
@@ -20,6 +22,18 @@ export function readBoardState(taskId:string):BoardView|null {
20
22
  }catch{/* Broken preferences cannot stop the workspace. */}
21
23
  cache.set(taskId,{stamp:saved,view});return view;
22
24
  }
25
+ /** Internal: background refresh covers every visible owner, not just the selected board. */
26
+ export function readVisibleBoardSources(taskId:string):string[] {
27
+ const active=owners.get(taskId);
28
+ const views=active?.size?[...active]:[['',readBoardState(taskId)] as const];
29
+ const sources=new Set<string>();
30
+ for(const [owner,view] of views){
31
+ if(!view||view.visible===false)continue;
32
+ for(const file of view.opened??[])sources.add(file.sourceId??taskId);
33
+ const directory=directorySources.get(taskId)?.get(owner);if(directory)sources.add(directory);
34
+ }
35
+ return [...sources];
36
+ }
23
37
  export function subscribeBoardState(taskId:string,listener:()=>void):()=>void {
24
38
  const set=listeners.get(taskId)??new Set<()=>void>();set.add(listener);listeners.set(taskId,set);return()=>{set.delete(listener);if(!set.size)listeners.delete(taskId);};
25
39
  }
@@ -36,8 +50,10 @@ export function visitBoardFile(taskId:string,file:BoardFile):void {
36
50
  const current=readBoardState(taskId)??{tab:'files',dir:'',file:null,files:[]};
37
51
  publishBoardState(taskId,{...current,history:recent([...(current.history??[]),file])});
38
52
  }
39
- export function reportBoardState(taskId:string,owner:string,view:BoardView):void {
53
+ export function reportBoardState(taskId:string,owner:string,view:BoardView,directorySource?:string):void {
40
54
  const sources=owners.get(taskId)??new Map<string,BoardView>();
55
+ if(directorySource){const directories=directorySources.get(taskId)??new Map<string,string>();directories.set(owner,directorySource);directorySources.set(taskId,directories);}
56
+ else{const directories=directorySources.get(taskId);directories?.delete(owner);if(!directories?.size)directorySources.delete(taskId);}
41
57
  const history=readBoardState(taskId)?.history??view.history??[];
42
58
  const selected=reportBoardOwner(sources,owner,{...view,history});owners.set(taskId,sources);
43
59
  if(selected)publishBoardState(taskId,{...selected[1],history});
@@ -51,6 +67,7 @@ export function activateBoardState(taskId:string,owner:string):void {
51
67
  }
52
68
  export function releaseBoardState(taskId:string,owner:string):void {
53
69
  const sources=owners.get(taskId);sources?.delete(owner);if(!sources?.size)owners.delete(taskId);
70
+ const directories=directorySources.get(taskId);directories?.delete(owner);if(!directories?.size)directorySources.delete(taskId);
54
71
  const previous=readBoardState(taskId);if(!previous)return;
55
72
  const remaining=sources?.size?selectedBoardOwner(sources)?.[1]:null;
56
73
  publishBoardState(taskId,remaining?{...remaining,history:previous.history,visible:remaining.visible!==false}:{...previous,visible:false});
@@ -1,12 +1,15 @@
1
+ import type { WorkspaceRefreshOptions } from '../../board/fileWatch';
1
2
  import { createWorkspace } from '../../board/workspace';
2
3
  import { cloneBoardView } from '../../board/context';
3
4
  import { getOperatorBoardBackend } from './boardBackend';
4
5
  import { readBoardState } from './boardState';
5
6
  import { openOperatorBoard } from './boardInteraction';
7
+ import { refreshOperatorWorkspaceFiles } from './boardEvents';
6
8
  /** Host API for the node workspace. Uses the same cloud-facing file API as the board. */
7
9
  export function createOperatorWorkspace(taskId:string){
8
- return createWorkspace({backend:getOperatorBoardBackend(),key:taskId,
10
+ const workspace=createWorkspace({backend:getOperatorBoardBackend(),key:taskId,
9
11
  view:()=>{const view=readBoardState(taskId);return view?.visible?cloneBoardView(view):undefined;},
10
12
  context:()=>{const view=readBoardState(taskId);return view?cloneBoardView(view):null;},
11
13
  open:path=>openOperatorBoard({kind:'file',taskId,path})});
14
+ return {...workspace,refresh:(options?:WorkspaceRefreshOptions)=>refreshOperatorWorkspaceFiles(taskId,options)};
12
15
  }
@@ -1,5 +1,9 @@
1
- export type Quote = { id: string; author: string; text: string };
1
+ import type { UploadPhase } from "../../core/documentUpload";
2
+
3
+ export type Quote = { id: string; author: string; text: string; sourceTaskId?: string };
2
4
  export type Attachment = {
5
+ progress?: number;
6
+ phase?: UploadPhase; bytes?: number; size?: number;
3
7
  key: string; kind: "image" | "file"; name: string; preview: string; path: string;
4
8
  status: "uploading" | "ready" | "error"; error?: string;
5
9
  };
@@ -15,7 +19,7 @@ export function readDraft(id: string): ComposerDraft {
15
19
  const raw = localStorage.getItem(key(id));
16
20
  if (raw) {
17
21
  const d = JSON.parse(raw) as ComposerDraft;
18
- if (typeof d.text === "string" && Array.isArray(d.attachments)) return { ...d, attachments: d.attachments.map((a) => a.status === "uploading" ? { ...a, preview: "", status: "error", error: "上传已中断,请移除后重新添加。" } : a) };
22
+ if (typeof d.text === "string" && Array.isArray(d.attachments)) return { ...d, attachments: d.attachments.map((a) => a.status === "uploading" ? { ...a, preview: "", status: "error", error: "上传已中断,请重新选择文件。" } : a) };
19
23
  }
20
24
  return { ...emptyDraft(), text: localStorage.getItem(`orch:draft:${id}`) ?? "" };
21
25
  } catch { return emptyDraft(); }
@@ -279,9 +279,11 @@ export function splitAttachments(content: string): { text: string; attachments:
279
279
  for (const line of content.split("\n")) {
280
280
  const m = ATTACHMENT_RE.exec(line.trim());
281
281
  const parts = m ? m[2].split(/[\\/]/).filter(Boolean) : [];
282
- if (m && parts.length >= 3 && parts[parts.length - 3] === "uploads") {
282
+ const upload = parts.length >= 3 && parts[parts.length - 3] === "uploads";
283
+ const managed = parts.length >= 4 && parts[parts.length - 4] === ".workspace" && parts[parts.length - 3] === "attachments";
284
+ if (m && (upload || managed) && parts.slice(-2).every(part => part !== "." && part !== "..")) {
283
285
  const [taskId, file] = parts.slice(-2);
284
- attachments.push({ path: m[2], url: `/api/tasks/${taskId}/uploads/${file}`, kind: m[1] === "image" ? "image" : "file", name: file });
286
+ attachments.push({ path: m[2], url: `/api/tasks/${encodeURIComponent(taskId)}/uploads/${encodeURIComponent(file)}`, kind: m[1] === "image" ? "image" : "file", name: file });
285
287
  } else {
286
288
  kept.push(line);
287
289
  }
@@ -45,6 +45,18 @@ const BACKOFF_MS = [500, 1000, 2000, 4000, 8000];
45
45
  const PROBE_MS = 30_000;
46
46
 
47
47
  const subs = new Map<number, Sub>();
48
+ const observers = new Map<string, Set<(data: string) => void>>();
49
+ /** Observe an existing route without opening a connection or server subscription. */
50
+ export function observeLiveMessages(path: string, listener: (data: string) => void): () => void {
51
+ const key = path.split("?")[0];
52
+ const listeners = observers.get(key) ?? new Set(); observers.set(key, listeners); listeners.add(listener);
53
+ return () => { listeners.delete(listener); if (!listeners.size) observers.delete(key); };
54
+ }
55
+ function deliver(s: Sub, data: string) {
56
+ if (subs.get(s.id) !== s) return;
57
+ for (const listener of [...observers.get(s.url.split("?")[0]) ?? []]) notifyCallback(listener, data);
58
+ if (subs.get(s.id) === s) s.h.onMessage(data);
59
+ }
48
60
  let nextId = 1;
49
61
  let mode: "ws" | "sse" | null = null;
50
62
  let ws: WebSocket | null = null;
@@ -91,7 +103,7 @@ function openEventSource(s: Sub) {
91
103
  const es = new EventSource(s.url);
92
104
  const active = () => subs.get(s.id) === s && s.es === es;
93
105
  es.onopen = () => { if (!active()) return; if (s.retry) { clearTimeout(s.retry); s.retry = undefined; } s.fails = 0; s.h.onOpen?.(); };
94
- es.onmessage = (event) => { if (active()) s.h.onMessage(event.data); };
106
+ es.onmessage = (event) => { if (active()) deliver(s, event.data); };
95
107
  es.onerror = () => {
96
108
  if (!active()) return;
97
109
  notifyCallback(s.h.onError, new Error("实时事件连接中断,正在等待恢复"));
@@ -203,7 +215,7 @@ function onFrame(text: string) {
203
215
  const nl = text.indexOf("\n");
204
216
  if (nl <= 0) return;
205
217
  const s = subs.get(Number(text.slice(0, nl)));
206
- if (s) s.h.onMessage(text.slice(nl + 1));
218
+ if (s) deliver(s, text.slice(nl + 1));
207
219
  }
208
220
 
209
221
  function connect() {
@@ -232,12 +232,14 @@ export function useTaskStream({ selTask, selProjRef, agentsRef, setTaskRunning,
232
232
  setOlderByTask(drop);
233
233
  for (const t of evicted) { delete openAsksRef.current[t]; delete loadingOlderRef.current[t]; }
234
234
  }
235
+ let active = true;
235
236
  const sub = openLive(`/api/tasks/${id}/messages?tail=${SNAPSHOT_TAIL}`, {
236
237
  onMessage: (data) => {
238
+ if (!active) return;
237
239
  try { handleStreamEventRef.current(id, JSON.parse(data) as TaskStreamEvent); } catch {}
238
240
  },
239
241
  });
240
- return () => sub.close();
242
+ return () => { active = false; sub.close(); };
241
243
  }, [selTask]);
242
244
 
243
245
  // Page in the next batch of earlier messages for a task (the transcript calls