@spzxg/cli-ui 0.6.6 → 0.6.7

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 (75) hide show
  1. package/CHANGELOG.md +33 -1
  2. package/README.md +79 -20
  3. package/dist/cli-ui-embed-CB7ibAtF.js +49558 -0
  4. package/dist/{cli-ui-index-b8wF7wrN.js → cli-ui-index-BmwOLWFI.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-ggP8OokP.js → cli-ui-jszip.min-BKNOJnhm.js} +1 -1
  6. package/dist/{cli-ui-jszip.min-B0uLrCHg.js → cli-ui-jszip.min-Cq0WjNYb.js} +2 -2
  7. package/dist/{cli-ui-xlsx-DEDdHrJJ.js → cli-ui-xlsx-Sl7ypvSe.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 +86 -46
  34. package/src/chat/Composer.tsx +52 -20
  35. package/src/chat/GroupChat.tsx +113 -0
  36. package/src/chat/RunControls.tsx +132 -42
  37. package/src/chat/draft.ts +5 -2
  38. package/src/core/blocks.ts +2 -1
  39. package/src/core/client.ts +44 -57
  40. package/src/core/documentUpload.ts +196 -0
  41. package/src/core/store.ts +71 -14
  42. package/src/core/types.ts +1 -1
  43. package/src/index.ts +3 -0
  44. package/src/msglist/CliMsgList.tsx +9 -2
  45. package/src/operator/node/Chat.tsx +7 -8
  46. package/src/operator/node/Composer.tsx +99 -72
  47. package/src/operator/node/Orchestrator.tsx +1 -1
  48. package/src/operator/node/SessionRail.tsx +71 -32
  49. package/src/operator/node/SessionView.tsx +25 -25
  50. package/src/operator/node/Transcript.tsx +21 -9
  51. package/src/operator/node/boardBackend.ts +0 -19
  52. package/src/operator/node/boardEvents.ts +21 -3
  53. package/src/operator/node/boardState.ts +18 -1
  54. package/src/operator/node/boardWorkspace.ts +4 -1
  55. package/src/operator/node/composerDraft.ts +6 -2
  56. package/src/operator/node/format.ts +4 -2
  57. package/src/operator/node/liveStream.ts +14 -2
  58. package/src/operator/node/useTaskStream.ts +3 -1
  59. package/src/operator.base.css +141 -38
  60. package/src/operator.css +141 -38
  61. package/src/styles.css +248 -34
  62. package/src/ui/Markdown.tsx +2 -2
  63. package/src/ui/Popover.tsx +25 -12
  64. package/src/ui/QueuedMessages.tsx +69 -0
  65. package/src/ui/ReferenceCard.tsx +28 -0
  66. package/src/ui/TextBlock.tsx +6 -4
  67. package/src/ui/Transcript.tsx +20 -10
  68. package/src/ui/UploadProgress.tsx +46 -0
  69. package/src/ui/WorkspaceDrawer.tsx +64 -0
  70. package/src/ui/WorkspaceTabs.tsx +60 -0
  71. package/src/ui/format.ts +3 -1
  72. package/src/ui/reference.ts +40 -0
  73. package/src/ui/useChatScrollbar.ts +29 -0
  74. package/src/ui/useComposerAutosize.ts +54 -0
  75. 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,7 +26,9 @@ 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";
32
33
 
33
34
  // Non-blocking banner shown when a reopened task's worktree is behind its base
@@ -134,21 +135,24 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
134
135
  return () => observer.disconnect();
135
136
  }, [railW]);
136
137
  const singlePane = mobile || compact;
138
+ const drawerMode = useWorkspaceDrawer(sessionRef, !!mobile);
139
+ const tabMode = !drawerMode && (singlePane || railCollapsed);
140
+ const [tabsHost, setTabsHost] = useState<HTMLDivElement|null>(null);
137
141
  const [statusOpen, setStatusOpen] = useState(false);
138
142
  const [moreOpen, setMoreOpen] = useState(false);
139
143
  const [view, setView] = useState<"chat" | "changes">("chat");
140
144
  const [boardRequest,setBoardRequest]=useState<OperatorBoardRequest|null>(null);
141
145
  const boardSequence=useRef(0);
142
146
  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};
147
+ const routing=useRef({tabMode,drawerMode,task,tasks,board});
148
+ routing.current={tabMode,drawerMode,task,tasks,board};
145
149
  const openBoard=useCallback((target:OperatorBoardTarget)=>{
146
150
  const context=routing.current;
147
151
  if(target.kind==='browser'&&(target.taskId!==context.task.id||!clientFeatures().browser))return false;
148
152
  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
153
  if(context.board?.availableTabs&&!context.board.availableTabs.includes(target.kind==='file'?'files':'browser'))return false;
150
154
  setBoardRequest({sequence:++boardSequence.current,target});
151
- if(context.singlePane)setView('changes');else if(context.railCollapsed)context.onRailExpand();
155
+ if(context.tabMode||context.drawerMode)setView('changes');
152
156
  return true;
153
157
  },[]);
154
158
  useEffect(()=>registerOperatorBoard(target=>target.taskId===task.id?openBoard(target):false),[task.id,openBoard]);
@@ -159,10 +163,9 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
159
163
  const quoteFromBoard=useCallback((quote:Quote)=>{
160
164
  const context=routing.current;
161
165
  writeDraft(context.task.id,{...readDraft(context.task.id),quote});
162
- if(context.singlePane)setView('chat');
166
+ if(context.tabMode||context.drawerMode)setView('chat');
163
167
  requestAnimationFrame(()=>sessionRef.current?.querySelector<HTMLTextAreaElement>('.composer textarea')?.focus({preventScroll:true}));
164
168
  },[]);
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
169
  const [clearEstimate, setClearEstimate] = useState<InternalUsageEstimate | null>(null);
167
170
  const sessions = useMemo(() => buildSessions(messages), [messages]);
168
171
  const hasSession = task.started === 1 || messages.length > 0 || !!task.parent_task_id;
@@ -193,7 +196,7 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
193
196
  <div className={`session${singlePane ? " compact-session" : ""}`} ref={sessionRef}>
194
197
  <div className="sess-head">
195
198
  {onBack && <button className="mobile-back" onClick={onBack} title="返回任务列表" aria-label="返回任务列表">{Icon.chevRight({ style: { transform: "rotate(180deg)" } })}</button>}
196
- <div className="sh-main">
199
+ {hasSession&&tabMode ? <WorkspaceTabs title={task.title} subtitle={project.name} chatActive={view==='chat'} onChat={()=>setView('chat')} onTabsHost={setTabsHost}/> : <div className="sh-main">
197
200
  <div className="crumb">
198
201
  <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
202
  <span className="session-project-name" title={project.name}>{project.name}</span>
@@ -203,8 +206,9 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
203
206
  <span className="sht-text" title={task.title}>{task.title}</span>
204
207
  <button className="icon-btn sh-edit" title="查看并编辑任务详情" aria-label="查看并编辑任务详情" onClick={onEdit}>{Icon.edit()}</button>
205
208
  </div>
206
- </div>
209
+ </div>}
207
210
  <div className="sh-tools">
211
+ {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
212
  <div style={{ position: "relative" }}>
209
213
  <button className={`status-ctl ${awaiting ? "awaiting" : ""}`} onClick={(e) => { e.stopPropagation(); setStatusOpen((o) => !o); setMoreOpen(false); }}>
210
214
  <StatusDot status={task.status} running={running} awaiting={awaiting} />
@@ -227,6 +231,7 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
227
231
  <button className="icon-btn session-more" aria-label="更多任务操作" aria-expanded={moreOpen} onClick={(e) => { e.stopPropagation(); setMoreOpen(!moreOpen); setStatusOpen(false); }}>{Icon.dots()}</button>
228
232
  {moreOpen && <Popover onClose={() => setMoreOpen(false)}>
229
233
  <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>
234
+ <div className="pop-note session-origin" title={task.work_branch || ""}>{Icon.git()}<span>{task.parent_task_id ? `来自 ${task.fork_source_title || "来源任务"}` : "独立任务分支"}</span></div>
230
235
  <button className="pop-item" onClick={() => { setMoreOpen(false); onEdit(); }}>{Icon.edit()} 编辑任务详情</button>
231
236
  <button className="pop-item" onClick={() => { setMoreOpen(false); downloadExport(`/api/tasks/${task.id}/export`); }}>{Icon.download()} 导出执行过程</button>
232
237
  {task.pr_url && <a className="pop-item" href={task.pr_url} target="_blank" rel="noreferrer">{Icon.github()} 打开 PR{prNum ? ` #${prNum}` : ""}</a>}
@@ -239,14 +244,6 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
239
244
  </div>
240
245
  </div>
241
246
 
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
247
  {hasSession && (
251
248
  <SyncBanner taskId={task.id} running={running} pinned={!!task.fork_commit} onResolveWithAI={onResolveWithAI} onSwitchToChat={() => setView("chat")} />
252
249
  )}
@@ -267,19 +264,22 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
267
264
  // Keep the chat at the same React position across all widths. Moving
268
265
  // it between different parents remounted the composer during resize
269
266
  // 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}
267
+ <div className={tabMode||drawerMode ? "sess-responsive" : "sess-split"} style={tabMode||drawerMode ? undefined : { gridTemplateColumns: `minmax(0,1fr) 0px ${railW}px` }}>
268
+ <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
269
  running={running} blockedBy={blockedBy} transcriptLoading={transcriptLoading} older={older} onLoadOlder={onLoadOlder}
273
- controls={controls} configBusy={configBusy} active={!singlePane || view === "chat"}
270
+ controls={controls} configBusy={configBusy} active={!tabMode&&!drawerMode || view === "chat"}
274
271
  onSelectTask={onSelectTask} onFork={onFork} onSend={onSend} onStop={onStop}
275
272
  onStart={onStart} onEdit={onEdit} onClear={onClear} onSetSendContext={onSetSendContext}
276
273
  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}
274
+ {!tabMode&&!drawerMode && <ColResize side="right" min={RAIL_W.min} max={RAIL_W.max} onWidth={onRailWidth} onReset={onRailReset} />}
275
+ <WorkspaceDrawer enabled={drawerMode} open={view==='changes'} onClose={()=>setView('chat')}>
276
+ <SessionRail {...board} openRequest={boardRequest} onRequestHandled={acknowledgeBoardRequest} project={project} task={task} sessions={sessions} running={running}
280
277
  onResolveWithAI={onResolveWithAI} onMerged={onMerged} onPrCreated={onPrCreated} onClear={onClear}
281
- onCollapse={singlePane ? () => setView("chat") : onRailCollapse} onSwitchToChat={() => setView("chat")} />
282
- )}
278
+ inlineLayout={tabMode?{tabsHost,active:view==='changes',onActivate:()=>setView('changes'),onChat:()=>setView('chat')}:undefined}
279
+ visible={!drawerMode||view==='changes'}
280
+ onExpand={tabMode&&!singlePane?onRailExpand:undefined}
281
+ onCollapse={tabMode||drawerMode ? () => setView("chat") : onRailCollapse} onSwitchToChat={() => setView("chat")} />
282
+ </WorkspaceDrawer>
283
283
  </div>
284
284
  )}
285
285
  </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