@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.
- package/CHANGELOG.md +55 -0
- package/README.md +109 -30
- package/dist/cli-ui-embed-CB7ibAtF.js +49558 -0
- package/dist/{cli-ui-index-D4qfPHMd.js → cli-ui-index-BmwOLWFI.js} +2 -2
- package/dist/{cli-ui-jszip.min-DC9898pC.js → cli-ui-jszip.min-BKNOJnhm.js} +1 -1
- package/dist/{cli-ui-jszip.min-DpEl0G1z.js → cli-ui-jszip.min-Cq0WjNYb.js} +2 -2
- package/dist/{cli-ui-xlsx-zs7oEtPj.js → cli-ui-xlsx-Sl7ypvSe.js} +1 -1
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +11 -8
- package/src/board/BrowserPane.tsx +198 -53
- package/src/board/CliBoard.tsx +128 -100
- package/src/board/FileViewer.tsx +276 -136
- package/src/board/FilesPane.tsx +129 -40
- package/src/board/HtmlReadingPreview.tsx +52 -0
- package/src/board/MarkdownPreview.tsx +134 -0
- package/src/board/PanelControls.tsx +19 -7
- package/src/board/fileKind.ts +138 -0
- package/src/board/fileOps.ts +9 -2
- package/src/board/fileScope.ts +29 -0
- package/src/board/fileSync.ts +343 -0
- package/src/board/fileSyncCache.ts +250 -0
- package/src/board/fileSyncProcessor.ts +71 -0
- package/src/board/fileSyncQueue.ts +56 -0
- package/src/board/fileSyncTransport.ts +85 -0
- package/src/board/fileWatch.ts +177 -70
- package/src/board/folderDownload.ts +39 -5
- package/src/board/frameQuote.ts +12 -10
- package/src/board/htmlReading.ts +148 -0
- package/src/board/textLoad.ts +18 -41
- package/src/board/useBoardUpload.tsx +51 -0
- package/src/board/useBoardVisibility.ts +35 -0
- package/src/board/workspace.ts +53 -11
- package/src/chat/CliChat.tsx +105 -55
- package/src/chat/Composer.tsx +55 -21
- package/src/chat/GroupChat.tsx +113 -0
- package/src/chat/RunControls.tsx +132 -42
- package/src/chat/draft.ts +5 -2
- package/src/core/blocks.ts +17 -5
- package/src/core/client.ts +88 -19
- package/src/core/conversation.ts +1 -0
- package/src/core/documentUpload.ts +196 -0
- package/src/core/provider.tsx +7 -0
- package/src/core/store.ts +134 -36
- package/src/core/types.ts +31 -10
- package/src/index.ts +3 -0
- package/src/layout/split.ts +67 -30
- package/src/msglist/CliMsgList.tsx +18 -5
- package/src/operator/node/Chat.tsx +7 -8
- package/src/operator/node/Composer.tsx +99 -72
- package/src/operator/node/Orchestrator.tsx +1 -1
- package/src/operator/node/SessionRail.tsx +71 -32
- package/src/operator/node/SessionView.tsx +25 -25
- package/src/operator/node/Transcript.tsx +28 -11
- package/src/operator/node/boardBackend.ts +6 -23
- package/src/operator/node/boardEvents.ts +21 -3
- package/src/operator/node/boardState.ts +18 -1
- package/src/operator/node/boardWorkspace.ts +4 -1
- package/src/operator/node/composerDraft.ts +6 -2
- package/src/operator/node/format.ts +4 -2
- package/src/operator/node/liveStream.ts +14 -2
- package/src/operator/node/protocol/types.ts +2 -2
- package/src/operator/node/useTaskStream.ts +8 -5
- package/src/operator.base.css +148 -37
- package/src/operator.css +148 -37
- package/src/styles.css +274 -32
- package/src/ui/Markdown.tsx +2 -2
- package/src/ui/Popover.tsx +32 -14
- package/src/ui/QueuedMessages.tsx +69 -0
- package/src/ui/ReferenceCard.tsx +28 -0
- package/src/ui/TextBlock.tsx +6 -4
- package/src/ui/Transcript.tsx +27 -12
- package/src/ui/UploadProgress.tsx +46 -0
- package/src/ui/WorkspaceDrawer.tsx +64 -0
- package/src/ui/WorkspaceTabs.tsx +60 -0
- package/src/ui/format.ts +3 -1
- package/src/ui/reference.ts +40 -0
- package/src/ui/useChatScrollbar.ts +29 -0
- package/src/ui/useComposerAutosize.ts +54 -0
- 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:"
|
|
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,
|
|
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
|
|
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:
|
|
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)
|
|
263
|
+
if(active===id){const next=shown.find(item=>item!==id)??'';setTab(next);if(!next)inlineLayout?.onChat();}
|
|
230
264
|
};
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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'
|
|
247
|
-
|
|
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
|
-
|
|
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
|
|
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({
|
|
144
|
-
routing.current={
|
|
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.
|
|
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.
|
|
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={
|
|
271
|
-
<div className={`sess-main${
|
|
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={!
|
|
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
|
-
{!
|
|
278
|
-
|
|
279
|
-
<SessionRail {...board} openRequest={boardRequest} onRequestHandled={acknowledgeBoardRequest}
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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 && <
|
|
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(
|
|
72
|
-
const
|
|
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
|
-
|
|
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});
|