@spzxg/cli-ui 0.6.5 → 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 (80) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +109 -30
  3. package/dist/cli-ui-embed-CB7ibAtF.js +49558 -0
  4. package/dist/{cli-ui-index-D4qfPHMd.js → cli-ui-index-BmwOLWFI.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-DC9898pC.js → cli-ui-jszip.min-BKNOJnhm.js} +1 -1
  6. package/dist/{cli-ui-jszip.min-DpEl0G1z.js → cli-ui-jszip.min-Cq0WjNYb.js} +2 -2
  7. package/dist/{cli-ui-xlsx-zs7oEtPj.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 +11 -8
  11. package/src/board/BrowserPane.tsx +198 -53
  12. package/src/board/CliBoard.tsx +128 -100
  13. package/src/board/FileViewer.tsx +276 -136
  14. package/src/board/FilesPane.tsx +129 -40
  15. package/src/board/HtmlReadingPreview.tsx +52 -0
  16. package/src/board/MarkdownPreview.tsx +134 -0
  17. package/src/board/PanelControls.tsx +19 -7
  18. package/src/board/fileKind.ts +138 -0
  19. package/src/board/fileOps.ts +9 -2
  20. package/src/board/fileScope.ts +29 -0
  21. package/src/board/fileSync.ts +343 -0
  22. package/src/board/fileSyncCache.ts +250 -0
  23. package/src/board/fileSyncProcessor.ts +71 -0
  24. package/src/board/fileSyncQueue.ts +56 -0
  25. package/src/board/fileSyncTransport.ts +85 -0
  26. package/src/board/fileWatch.ts +177 -70
  27. package/src/board/folderDownload.ts +39 -5
  28. package/src/board/frameQuote.ts +12 -10
  29. package/src/board/htmlReading.ts +148 -0
  30. package/src/board/textLoad.ts +18 -41
  31. package/src/board/useBoardUpload.tsx +51 -0
  32. package/src/board/useBoardVisibility.ts +35 -0
  33. package/src/board/workspace.ts +53 -11
  34. package/src/chat/CliChat.tsx +105 -55
  35. package/src/chat/Composer.tsx +55 -21
  36. package/src/chat/GroupChat.tsx +113 -0
  37. package/src/chat/RunControls.tsx +132 -42
  38. package/src/chat/draft.ts +5 -2
  39. package/src/core/blocks.ts +17 -5
  40. package/src/core/client.ts +88 -19
  41. package/src/core/conversation.ts +1 -0
  42. package/src/core/documentUpload.ts +196 -0
  43. package/src/core/provider.tsx +7 -0
  44. package/src/core/store.ts +134 -36
  45. package/src/core/types.ts +31 -10
  46. package/src/index.ts +3 -0
  47. package/src/layout/split.ts +67 -30
  48. package/src/msglist/CliMsgList.tsx +18 -5
  49. package/src/operator/node/Chat.tsx +7 -8
  50. package/src/operator/node/Composer.tsx +99 -72
  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 +25 -25
  54. package/src/operator/node/Transcript.tsx +28 -11
  55. package/src/operator/node/boardBackend.ts +6 -23
  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/protocol/types.ts +2 -2
  63. package/src/operator/node/useTaskStream.ts +8 -5
  64. package/src/operator.base.css +148 -37
  65. package/src/operator.css +148 -37
  66. package/src/styles.css +274 -32
  67. package/src/ui/Markdown.tsx +2 -2
  68. package/src/ui/Popover.tsx +32 -14
  69. package/src/ui/QueuedMessages.tsx +69 -0
  70. package/src/ui/ReferenceCard.tsx +28 -0
  71. package/src/ui/TextBlock.tsx +6 -4
  72. package/src/ui/Transcript.tsx +27 -12
  73. package/src/ui/UploadProgress.tsx +46 -0
  74. package/src/ui/WorkspaceDrawer.tsx +64 -0
  75. package/src/ui/WorkspaceTabs.tsx +60 -0
  76. package/src/ui/format.ts +3 -1
  77. package/src/ui/reference.ts +40 -0
  78. package/src/ui/useChatScrollbar.ts +29 -0
  79. package/src/ui/useComposerAutosize.ts +54 -0
  80. package/dist/cli-ui-embed-BmLN2Jjg.js +0 -43290
@@ -1,6 +1,9 @@
1
1
  "use client";
2
+ import { subscribeWorkspaceFiles } from "../../board/fileWatch";
3
+ import { retainFileScope } from "../../board/fileScope";
2
4
 
3
5
  import { useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
6
+ import { createPortal } from "react-dom";
4
7
  import { Icon } from "./icons";
5
8
  import TaskChanges, { type ResolveResult } from "./TaskChanges";
6
9
  import { BrowserPane } from "./BrowserPane";
@@ -13,17 +16,18 @@ import { clientId } from "../../core/id";
13
16
  import { useBoardState, readBoardState, visitBoardFile, reportBoardState, releaseBoardState, activateBoardState } from "./boardState";
14
17
  import type { BoardView } from "../../board/workspace";
15
18
  import { useBoardPanels } from "../../board/panels";
16
- import { PanelControls, PanelTab } from "../../board/PanelControls";
19
+ import { PanelControls, PanelTab, type InlineBoardLayout } from "../../board/PanelControls";
17
20
  import { FilesPane } from "../../board/FilesPane";
18
21
  import { FileViewer } from "../../board/FileViewer";
22
+ import { useBoardUpload } from "../../board/useBoardUpload";
19
23
  import { getOperatorBoardBackend } from "./boardBackend";
20
24
  import { useOperatorContextEvents, useOperatorFileWatch } from "./boardEvents";
21
25
  import { OperatorBoardContext, registerOperatorBoard, OperatorBoardQuoteContext } from "./boardInteraction";
22
26
  import type { Quote } from "./composerDraft";
23
27
  import type { OperatorBoardOptions, OperatorBoardRequest } from "./boardInteraction";
24
- type OpenFile = { reloadKey?:number; taskId: string; path: string; version: number; mtime?: number; size?: number };
28
+ type OpenFile = { name?: string; reloadKey?:number; taskId: string; path: string; version: number; mtime?: number; size?: number };
25
29
  const fileKey = (file: Pick<OpenFile,"taskId"|"path">) => `file:${file.taskId}:${file.path}`;
26
- const LABEL:Record<string,string>={files:"文件",changes:"改动",context:"上下文",preview:"预览",browser:"浏览器"};
30
+ const LABEL:Record<string,string>={files:"工作区",changes:"改动",context:"上下文",preview:"预览",browser:"浏览器"};
27
31
  type Session = { n: number; summaryBefore: string | null };
28
32
 
29
33
  // The live URL a project's dev server is reachable at when no registered
@@ -119,15 +123,21 @@ function ContextPane({ task, sessions, running, onClear }: { task: TaskRow; sess
119
123
  }
120
124
 
121
125
  export function SessionRail({ project, task, sessions, running, onResolveWithAI, onMerged, onPrCreated, onClear, onCollapse, onSwitchToChat,
122
- tabs, availableTabs, defaultTab, autoContext, watchFiles = true, fileWatchIntervalMs, onFilesChange, onFilesWatch, onBoardContextChange, onEvent, openRequest, onRequestHandled, activity, onQuote }: OperatorBoardOptions & {
126
+ tabs, availableTabs, defaultTab, autoContext, watchFiles = true, fileWatchIntervalMs, onFilesChange, onFilesWatch, onBoardContextChange, onEvent, openRequest, onRequestHandled, onQuote, inlineLayout, onExpand, visible = true }: OperatorBoardOptions & {
123
127
  project: ProjectRow; task: TaskRow; sessions: Session[]; running: boolean;
124
128
  onResolveWithAI: (taskId: string) => Promise<ResolveResult>; onMerged?: () => void;
125
129
  onPrCreated?: (url: string) => void; onClear: () => void; onCollapse: () => void; onSwitchToChat: () => void;
126
130
  openRequest?: OperatorBoardRequest | null; onRequestHandled?: (sequence:number)=>void; activity?: string; onQuote?: (quote:Quote)=>void;
131
+ inlineLayout?: InlineBoardLayout;
132
+ onExpand?: () => void;
133
+ visible?: boolean;
127
134
  }) {
128
135
  const contextQuote=useContext(OperatorBoardQuoteContext);
129
136
  const parentBoard=useContext(OperatorBoardContext);
130
- const quote=onQuote??contextQuote;
137
+ const inlineRef=useRef(inlineLayout);inlineRef.current=inlineLayout;
138
+ const sendQuote=onQuote??contextQuote;
139
+ const quote=sendQuote ? (value:Quote)=>{sendQuote(value);inlineLayout?.onChat();} : undefined;
140
+ const contentVisible=visible&&(!inlineLayout||inlineLayout.active);
131
141
  const features=clientFeatures();
132
142
  const available=(availableTabs ?? ['files','changes','context','browser','preview']).filter(id=>id in LABEL).filter(id=>id!=='browser'||features.browser).filter(id=>id!=='preview'||features.livePreview);
133
143
  const defaults=tabs ?? ['files',...(features.browser?['browser']:[])];
@@ -167,13 +177,37 @@ export function SessionRail({ project, task, sessions, running, onResolveWithAI,
167
177
  const fileTabs=available.includes('files')?files.map(fileKey):[];
168
178
  const shown=[...panels.open,...fileTabs];
169
179
  const active=shown.includes(tab)?tab:shown[0]??'';
180
+ const [toolbarHost, setToolbarHost] = useState<HTMLSpanElement | null>(null);
170
181
  const tabsRef=useRef<HTMLDivElement>(null);
171
182
  useLayoutEffect(()=>{
172
183
  const reveal=()=>{const box=tabsRef.current,item=box?.querySelector<HTMLElement>('.rail-tab.on');if(!box||!item)return;
173
184
  const outer=box.getBoundingClientRect(),inner=item.getBoundingClientRect();if(inner.left<outer.left)box.scrollLeft+=inner.left-outer.left;else if(inner.right>outer.right)box.scrollLeft+=inner.right-outer.right;};
174
185
  reveal();if(typeof ResizeObserver==='undefined')return;const observer=new ResizeObserver(reveal);if(tabsRef.current)observer.observe(tabsRef.current);return()=>observer.disconnect();
175
- },[active,shown.length]);
186
+ },[active,shown.length,inlineLayout?.active,inlineLayout?.tabsHost]);
176
187
  const file=files.find(f=>fileKey(f)===active);
188
+ // Every opened tab participates, including a file from a source task. Only
189
+ // already-read preview ranges are synchronized; closed files are not prefetched.
190
+ const openedScope = JSON.stringify(available.includes('files') ? files.map(({ taskId, path }) => ({ taskId, path })) : []);
191
+ const directorySource = active === 'files' ? folderTask : task.id;
192
+ useEffect(() => {
193
+ if (!watchFiles || !contentVisible) return;
194
+ const grouped = new Map<string, string[]>();
195
+ for (const file of JSON.parse(openedScope) as Array<{ taskId: string; path: string }>) {
196
+ grouped.set(file.taskId, [...(grouped.get(file.taskId) ?? []), file.path]);
197
+ }
198
+ if (!grouped.has(directorySource)) grouped.set(directorySource, []);
199
+ const stops: Array<() => void> = [];
200
+ for (const [source, paths] of grouped) {
201
+ stops.push(retainFileScope(backend, source, { paths, dirs: [] }));
202
+ if (source !== task.id) stops.push(subscribeWorkspaceFiles(backend, source, event => {
203
+ setTick(value => value + 1);
204
+ const changed = new Set(event.changes.map(change => change.path));
205
+ setFiles(previous => previous.map(file => file.taskId === source && changed.has(file.path)
206
+ ? { ...file, version: file.version + 1, reloadKey: (file.reloadKey ?? 0) + 1 } : file));
207
+ }));
208
+ }
209
+ return () => stops.forEach(stop => stop());
210
+ }, [backend, task.id, watchFiles, contentVisible, openedScope, directorySource]);
177
211
  const current=useRef(active);current.current=active;
178
212
  const openFile=(taskId:string,path:string)=>{
179
213
  if(!available.includes('files'))return;
@@ -187,14 +221,17 @@ export function SessionRail({ project, task, sessions, running, onResolveWithAI,
187
221
  const next=[...previous,{taskId,path:clean,version:0}];
188
222
  if(next.length>8){const index=next.findIndex(f=>fileKey(f)!==current.current&&fileKey(f)!==id);next.splice(index<0?0:index,1);}
189
223
  return next;
190
- });setTab(id);
224
+ });setTab(id);inlineRef.current?.onActivate();
191
225
  };
226
+ const canUpload=available.includes('files')&&!!(task.started||task.worktree_path);
227
+ const uploader=useMemo(()=>canUpload&&backend.uploadFile ? (file:File,options:Parameters<NonNullable<typeof backend.uploadFile>>[2])=>backend.uploadFile!(task.id,file,options) : undefined,[backend,task.id,canUpload]);
228
+ const upload=useBoardUpload(task.id,uploader,receipt=>{if(receipt.path)openFile(task.id,receipt.path);});
192
229
  useEffect(()=>{
193
230
  if(parentBoard)return;
194
231
  return registerOperatorBoard(target=>{
195
232
  if(target.taskId!==task.id)return false;
196
233
  if(target.kind==='file'){if(!available.includes('files'))return false;openFile(target.taskId,target.path);return true;}
197
- if(!panels.show('browser'))return false;setTab('browser');return true;
234
+ if(!panels.show('browser'))return false;setTab('browser');inlineRef.current?.onActivate();return true;
198
235
  });
199
236
  },[task.id,parentBoard,available.join(','),panels.show]);
200
237
  const seen=useRef<number|null>(null);
@@ -203,58 +240,60 @@ export function SessionRail({ project, task, sessions, running, onResolveWithAI,
203
240
  seen.current=openRequest.sequence;
204
241
  const target=openRequest.target;
205
242
  if(target.kind==='file')openFile(target.taskId,target.path);
206
- else if(target.taskId===task.id&&panels.show('browser'))setTab('browser');
243
+ else if(target.taskId===task.id&&panels.show('browser')){setTab('browser');inlineLayout?.onActivate();}
207
244
  onRequestHandled?.(openRequest.sequence);
208
245
  },[openRequest,restored]); // Requests originate in the owning session, including when this panel was collapsed.
209
- const lastRefresh=useRef(0);
210
- useEffect(()=>{const timer=setTimeout(()=>{lastRefresh.current=Date.now();setTick(n=>n+1);},Math.max(0,700-(Date.now()-lastRefresh.current)));return()=>clearTimeout(timer);},[activity,task.updated_at,running]);
211
- useEffect(()=>{if(!running)return;const timer=setInterval(()=>{if(document.visibilityState==='visible')setTick(n=>n+1);},15000);return()=>clearInterval(timer);},[running]);
212
246
  useEffect(()=>{
213
247
  if(!file)return;let alive=true;
214
248
  void backend.stat(file.taskId,file.path).then(stat=>{
215
249
  if(!alive||stat.type!=='file')return;
216
- setFiles(previous=>previous.map(f=>fileKey(f)===fileKey(file)&&f.mtime!==stat.mtime?{...f,mtime:stat.mtime,size:stat.size,version:f.mtime===undefined?f.version:f.version+1}:f));
250
+ setFiles(previous=>previous.map(f=>fileKey(f)===fileKey(file)&&(f.mtime!==stat.mtime||f.name!==stat.name)?{...f,name:stat.name,mtime:stat.mtime,size:stat.size,version:f.mtime===undefined?f.version:f.version+1}:f));
217
251
  },()=>{if(alive)setFiles(previous=>previous.map(f=>fileKey(f)===fileKey(file)&&f.mtime!==-1?{...f,mtime:-1,version:f.version+1}:f));});
218
252
  return()=>{alive=false;};
219
253
  },[backend,active,tick]);
220
254
  const boardView:BoardView={tab:active,dir,file:file?.path??null,files:files.map(file=>file.path),
221
255
  current:file?{path:file.path,sourceId:file.taskId}:null,opened:files.map(file=>({path:file.path,sourceId:file.taskId})),
222
- history:boardState?.history??[],browser:active==='browser'?browserPage:null,visible:true,autoContext:autoContext!==false};
256
+ history:boardState?.history??[],browser:active==='browser'?browserPage:null,visible:contentVisible,autoContext:autoContext!==false};
223
257
  const viewSignature=JSON.stringify(boardView);
224
- useEffect(()=>{if(restored)reportBoardState(task.id,owner,boardView);},[task.id,owner,restored,viewSignature]); // Metadata changes only; file refresh counters do not claim a new view.
258
+ useEffect(()=>{if(restored)reportBoardState(task.id,owner,boardView,directorySource);},[task.id,owner,restored,viewSignature,directorySource]); // Metadata changes only; file refresh counters do not claim a new view.
225
259
  useEffect(()=>()=>releaseBoardState(task.id,owner),[task.id,owner]);
226
- const label=(id:string)=>LABEL[id]??files.find(f=>fileKey(f)===id)?.path.split('/').pop()??id;
260
+ const label=(id:string)=>LABEL[id]??files.find(f=>fileKey(f)===id)?.name??files.find(f=>fileKey(f)===id)?.path.split('/').pop()??id;
227
261
  const close=(id:string)=>{
228
262
  if(id.startsWith('file:'))setFiles(previous=>previous.filter(f=>fileKey(f)!==id));else panels.close(id);
229
- if(active===id)setTab(shown.find(item=>item!==id)??'');
263
+ if(active===id){const next=shown.find(item=>item!==id)??'';setTab(next);if(!next)inlineLayout?.onChat();}
230
264
  };
231
- return <aside className="sess-rail" aria-label="任务看板" onPointerDown={()=>activateBoardState(task.id,owner)} onClickCapture={()=>activateBoardState(task.id,owner)} onFocusCapture={()=>activateBoardState(task.id,owner)}>
232
- <div className="rail-tabs">
233
- <div className="operator-file-tabs" ref={tabsRef} role="tablist" aria-label="看板模块">
234
- {shown.map(id=><PanelTab key={id} id={id} label={label(id)} active={active===id} onSelect={()=>{const opened=files.find(file=>fileKey(file)===id);if(opened)visitBoardFile(task.id,{path:opened.path,sourceId:opened.taskId});setTab(id);}} onClose={()=>close(id)}/>)}
265
+ const railTabs=<div className="rail-tabs">
266
+ <div className="operator-file-tabs" ref={tabsRef} role={inlineLayout?undefined:"tablist"} aria-label={inlineLayout?undefined:"看板模块"}>
267
+ {shown.map(id=><PanelTab key={id} id={id} label={label(id)} title={files.find(file=>fileKey(file)===id)?.path} active={active===id&&(!inlineLayout||inlineLayout.active)} onSelect={()=>{const opened=files.find(file=>fileKey(file)===id);if(opened)visitBoardFile(task.id,{path:opened.path,sourceId:opened.taskId});setTab(id);inlineLayout?.onActivate();}} onClose={()=>close(id)}/>)}
235
268
  </div>
236
- <PanelControls available={available} open={panels.open} label={label} onShow={id=>{panels.show(id);setTab(id);}} onClose={close}/>
237
- <button className="rail-collapse" title="刷新看板" aria-label="刷新看板" onClick={()=>setTick(n=>n+1)}>{Icon.restore()}</button>
238
- <button className="rail-collapse" onClick={onCollapse} title="隐藏面板" aria-label="隐藏看板">{Icon.chevRight()}</button>
239
- </div>
269
+ <PanelControls toolbarRef={setToolbarHost} available={available} open={panels.open} label={label} onShow={id=>{panels.show(id);setTab(id);inlineLayout?.onActivate();}} onClose={close}
270
+ upload={available.includes('files')?{onChoose:upload.choose,disabled:upload.busy||!uploader,hint:canUpload?undefined:'启动会话并创建工作区后可上传文件'}:undefined}/>
271
+ {inlineLayout ? onExpand&&<button className="rail-collapse" onClick={onExpand} title="展开右侧看板" aria-label="展开看板">{Icon.chevRight()}</button> : <button className="rail-collapse" onClick={onCollapse} title="隐藏面板" aria-label="隐藏看板">{Icon.chevRight()}</button>}
272
+ </div>;
273
+ return <aside className={`sess-rail${inlineLayout?' sess-rail-inline':''}`} aria-label="任务看板" hidden={!contentVisible} style={!contentVisible?{display:'none'}:undefined} onPointerDown={()=>activateBoardState(task.id,owner)} onClickCapture={()=>activateBoardState(task.id,owner)} onFocusCapture={()=>activateBoardState(task.id,owner)}>
274
+ {inlineLayout ? inlineLayout.tabsHost&&createPortal(railTabs,inlineLayout.tabsHost) : railTabs}
275
+ {upload.input}{upload.progress}
240
276
  <div className="rail-scroll">
241
277
  {!active&&<div className="cli-board-empty">看板模块已全部关闭。通过右上角“管理看板模块”重新打开。</div>}
242
278
  {active==='changes'&&<>
243
279
  {task.fork_commit&&<div className="fork-merge-note">基于「{task.fork_source_title}」派生。合并到 {project.branch} 时,也会包含来源分支尚未合并的改动。</div>}
244
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;}}/>
245
281
  </>}
246
- {active==='files'&&<FilesPane backend={backend} jobId={folderTask} dir={dir} onDir={setDir} onOpen={entry=>openFile(folderTask,entry.path)} refreshKey={tick}/>}
247
- {file&&<>
282
+ {contentVisible&&active==='files'&&<>
283
+ {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>}
284
+ <FilesPane toolbarHost={toolbarHost} backend={backend} jobId={folderTask} dir={dir} onDir={setDir} onOpen={entry=>openFile(folderTask,entry.path)} refreshKey={tick}/>
285
+ </>}
286
+ {contentVisible&&file&&<>
248
287
  {file.taskId!==task.id&&<div className="fork-merge-note">正在查看来源任务的文件,不会切换当前执行分支。</div>}
249
- <FileViewer key={fileKey(file)} backend={backend} jobId={file.taskId} path={file.path} size={file.size} mtime={file.mtime} version={file.version} reloadKey={file.reloadKey}
288
+ <FileViewer toolbarHost={toolbarHost} key={fileKey(file)} backend={backend} jobId={file.taskId} path={file.path} displayName={file.name} size={file.size} mtime={file.mtime} version={file.version} reloadKey={file.reloadKey}
250
289
  onOpenPath={path=>openFile(file.taskId,path)}
251
- actions={quote?[{label:'引用到对话',onClick:()=>quote({id:`file:${file.taskId}:${file.path}`,author:`文件 ${file.path}`,text:`${file.taskId===task.id?'当前工作区':`来源任务 ${file.taskId} 的工作区`}文件 ${file.path}`})}]:[]}
252
- onQuoteText={quote?text=>quote({id:`selection:${file.taskId}:${file.path}`,author:`${file.taskId===task.id?"当前工作区":`来源任务 ${file.taskId} 的工作区`}文件 ${file.path}`,text}):undefined}
253
- lead={<button className="cli-fv-back" title="返回文件列表" onClick={()=>{setFolderTask(file.taskId);setDir(file.path.includes('/')?file.path.slice(0,file.path.lastIndexOf('/')):'');panels.show('files');setTab('files');}}>‹</button>}/>
290
+ 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
+ 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');}}/>
254
293
  </>}
255
294
  {active==='preview'&&features.livePreview&&<PreviewPane project={project}/>}
256
295
  {active==='context'&&<ContextPane task={task} sessions={sessions} running={running} onClear={onClear}/>}
257
- {active==='browser'&&features.browser&&<BrowserPane key={task.id} taskId={task.id} onPage={setBrowserPage} onQuote={quote?page=>quote({id:`browser:${task.id}`,author:"浏览器页面",text:`${page.title?`${page.title} — `:""}${page.url}`}):undefined} onQuoteText={quote?(text,page)=>quote({id:`browser-selection:${task.id}`,author:`网页 ${page.title||page.url}`,text}):undefined}/>}
296
+ {active==='browser'&&features.browser&&contentVisible&&<BrowserPane key={task.id} taskId={task.id} onPage={setBrowserPage} onQuote={quote?page=>quote({id:`browser:${task.id}`,sourceTaskId:task.id,author:"浏览器页面",text:`${page.title?`${page.title} — `:""}${page.url}`}):undefined} onQuoteText={quote?(text,page)=>quote({id:`browser-selection:${task.id}`,sourceTaskId:task.id,author:`网页 ${page.title||page.url}`,text}):undefined}/>}
258
297
  </div>
259
298
  </aside>;
260
299
  }
@@ -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>
@@ -193,8 +196,13 @@ function InputView({ input, agentLabel, onSubmit, onShowBrowser }: { onShowBrows
193
196
  if (!complete || busy || !onSubmit) return;
194
197
  setBusy(true);
195
198
  setErr(null);
196
- const e = await onSubmit(input.id, vals.map((v) => v.trim()));
197
- if (e) { setErr(e); setBusy(false); }
199
+ try {
200
+ const e = await onSubmit(input.id, vals.map((v, i) => input.fields[i].secret ? v : v.trim()));
201
+ if (e) { setErr(e); setBusy(false); }
202
+ } catch (error) {
203
+ setErr(error instanceof Error ? error.message : "提交失败,请重试");
204
+ setBusy(false);
205
+ }
198
206
  };
199
207
  return (
200
208
  <div className="ask">
@@ -275,6 +283,7 @@ function HandoffLine({ text, onBrowser, onShowBrowser, readOnly }: { readOnly?:b
275
283
  // (click opens full size) and text-file chips (a big paste diverted to a file;
276
284
  // click opens it). Both are served from the task's uploads dir.
277
285
  function AttachmentStrip({ items }: { items: MsgAttachment[] }) {
286
+ const open = useContext(OperatorBoardContext), scope = useContext(FileLinkContext);
278
287
  if (!items.length) return null;
279
288
  return (
280
289
  <div className="msg-attachments">
@@ -285,8 +294,11 @@ function AttachmentStrip({ items }: { items: MsgAttachment[] }) {
285
294
  <img src={a.url} alt="附件图片" loading="lazy" />
286
295
  </a>
287
296
  ) : (
288
- <a key={i} href={a.url} target="_blank" rel="noreferrer" className="file-chip" title={`打开 ${a.name}`}>
289
- {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>
290
302
  </a>
291
303
  )
292
304
  )}
@@ -305,6 +317,11 @@ function AutomaticBoardBackground({content}:{content:string}) {
305
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>;
306
318
  }
307
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
+
308
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 }) {
309
326
  const openBoard=useContext(OperatorBoardContext);
310
327
  const fileScope=useContext(FileLinkContext);
@@ -318,7 +335,7 @@ export const MessageView = memo(function MessageView({ m, initial, hideWho, runn
318
335
  <div className="msg user queued">
319
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>
320
337
  <div className="msg-body">
321
- {text && <Markdown>{text}</Markdown>}
338
+ {text && <QuotedUserText text={text} />}
322
339
  <AutomaticBoardBackground content={m.content}/>
323
340
  <AttachmentStrip items={attachments} />
324
341
  {onCancelQueued && <button className="queued-x" title="从队列中移除" aria-label="从队列中移除" onClick={() => onCancelQueued(m.id)}>{Icon.x()}</button>}
@@ -435,7 +452,7 @@ export const MessageView = memo(function MessageView({ m, initial, hideWho, runn
435
452
  )}
436
453
  <div className="msg-body">
437
454
  {initial && <div className="initial-tag">{Icon.spark()} 已随项目上下文发送</div>}
438
- {text && <Markdown>{text}</Markdown>}
455
+ {text && (isUser ? <QuotedUserText text={text} /> : <Markdown>{text}</Markdown>)}
439
456
  {isUser&&<AutomaticBoardBackground content={m.content}/>}
440
457
  <AttachmentStrip items={attachments} />
441
458
  </div>
@@ -15,7 +15,7 @@ export function createOperatorBoardBackend(fetcher: typeof fetch = (...args) =>
15
15
  suffix=suffix.replace(/^files\/raw/,'files');
16
16
  return fetcher(`/api/tasks/${target[1]}/${suffix}${url.search}`, init);
17
17
  } });
18
- const viewers=new Map<string,WebSocket>();
18
+ const viewers=new Map<string,{taskId:string;socket:WebSocket}>();
19
19
  return { ...client.board!, directLinks: true, browserLive:undefined,
20
20
  async info(id) {
21
21
  const response=await fetcher(`/api/tasks/${encodeURIComponent(id)}`,{cache:'no-store'});
@@ -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);};
@@ -60,7 +41,7 @@ export function createOperatorBoardBackend(fetcher: typeof fetch = (...args) =>
60
41
  browserFrames(id,handlers) {
61
42
  const socket=new WebSocket(`${location.protocol==='https:'?'wss:':'ws:'}//${location.host}/browser/${encodeURIComponent(id)}/stream`);
62
43
  const viewer=clientId();let stopped=false;
63
- viewers.set(viewer,socket);
44
+ viewers.set(viewer,{taskId:id,socket});
64
45
  const timeout=setTimeout(()=>{if(socket.readyState!==WebSocket.OPEN)socket.close();},10000);
65
46
  socket.onopen=()=>{clearTimeout(timeout);if(!stopped)handlers.onViewer(viewer);};
66
47
  socket.onmessage=event=>{if(stopped)return;try{handlers.onMessage(JSON.parse(String(event.data)) as BrowserMessage);}catch{/* Next frame can recover. */}};
@@ -68,8 +49,10 @@ export function createOperatorBoardBackend(fetcher: typeof fetch = (...args) =>
68
49
  socket.onclose=event=>{clearTimeout(timeout);viewers.delete(viewer);if(!stopped)handlers.onClose?.(new CenterError(event.reason||'浏览器画面连接已断开,正在重连','NETWORK',0));};
69
50
  return()=>{stopped=true;clearTimeout(timeout);viewers.delete(viewer);socket.close();};
70
51
  },
71
- async browserInput(_id,viewer,events) {
72
- const socket=viewers.get(viewer);
52
+ async browserInput(id,viewer,events) {
53
+ const entry=viewers.get(viewer);
54
+ if(entry&&entry.taskId!==id)throw new CenterError('浏览器连接属于另一个任务,请重新连接','HTTP_409',409);
55
+ const socket=entry?.socket;
73
56
  if(!socket||socket.readyState!==WebSocket.OPEN)throw new CenterError('浏览器画面尚未连接,请稍后重试','NETWORK',0);
74
57
  for(const event of events)socket.send(JSON.stringify(event));
75
58
  },
@@ -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});