@spzxg/cli-ui 0.6.2 → 0.6.5

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 (131) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +137 -11
  3. package/dist/cli-ui-embed-BmLN2Jjg.js +43290 -0
  4. package/dist/{cli-ui-index-BY5i3wId.js → cli-ui-index-D4qfPHMd.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-BZsUwI-s.js → cli-ui-jszip.min-DC9898pC.js} +1 -1
  6. package/dist/{cli-ui-jszip.min-m01fexHW.js → cli-ui-jszip.min-DpEl0G1z.js} +2 -2
  7. package/dist/{cli-ui-xlsx-CpWicJLT.js → cli-ui-xlsx-zs7oEtPj.js} +1 -1
  8. package/dist/cli-ui.css +32 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +14 -8
  11. package/src/board/BrowserPane.tsx +160 -74
  12. package/src/board/ChangesPane.tsx +2 -2
  13. package/src/board/CliBoard.tsx +79 -50
  14. package/src/board/FileViewer.tsx +32 -22
  15. package/src/board/FilesPane.tsx +32 -15
  16. package/src/board/PanelControls.tsx +28 -0
  17. package/src/board/blocks.tsx +5 -5
  18. package/src/board/context.ts +65 -0
  19. package/src/board/fileOps.ts +26 -3
  20. package/src/board/fileWatch.ts +88 -0
  21. package/src/board/folderDownload.ts +30 -14
  22. package/src/board/panels.ts +33 -0
  23. package/src/board/textLoad.ts +2 -2
  24. package/src/board/workspace.ts +38 -9
  25. package/src/chat/CliChat.tsx +73 -30
  26. package/src/chat/Composer.tsx +41 -41
  27. package/src/chat/RunControls.tsx +99 -0
  28. package/src/chat/configuration.ts +15 -0
  29. package/src/chat/draft.ts +41 -0
  30. package/src/chat/export.ts +2 -2
  31. package/src/components.tsx +13 -0
  32. package/src/core/blocks.ts +2 -2
  33. package/src/core/callbacks.ts +8 -0
  34. package/src/core/client.ts +8 -6
  35. package/src/core/conversation.ts +3 -3
  36. package/src/core/id.ts +15 -0
  37. package/src/core/identity.ts +33 -0
  38. package/src/core/provider.tsx +24 -14
  39. package/src/core/store.ts +83 -26
  40. package/src/core/types.ts +34 -0
  41. package/src/elements.tsx +135 -29
  42. package/src/embed.ts +6 -8
  43. package/src/index.ts +24 -17
  44. package/src/layout/CliSplit.tsx +1 -1
  45. package/src/msglist/CliMsgList.tsx +10 -10
  46. package/src/operator/branchFlow.ts +35 -0
  47. package/src/operator/index.ts +9 -0
  48. package/src/operator/liveStream.ts +1 -0
  49. package/src/operator/node/AgentConnect.tsx +624 -0
  50. package/src/operator/node/AppearancePanel.tsx +35 -0
  51. package/src/operator/node/BranchHistory.tsx +67 -0
  52. package/src/operator/node/BranchNavigator.tsx +36 -0
  53. package/src/operator/node/BrowserPane.tsx +128 -0
  54. package/src/operator/node/BrowserPopup.tsx +22 -0
  55. package/src/operator/node/Chat.tsx +517 -0
  56. package/src/operator/node/CommandPalette.tsx +213 -0
  57. package/src/operator/node/Composer.tsx +287 -0
  58. package/src/operator/node/InsightsView.tsx +831 -0
  59. package/src/operator/node/Layout.tsx +190 -0
  60. package/src/operator/node/Markdown.tsx +61 -0
  61. package/src/operator/node/Modal.tsx +179 -0
  62. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  63. package/src/operator/node/OnboardingWizard.tsx +250 -0
  64. package/src/operator/node/Orchestrator.tsx +634 -0
  65. package/src/operator/node/ProjectLanding.tsx +86 -0
  66. package/src/operator/node/ProjectsColumn.tsx +137 -0
  67. package/src/operator/node/ProviderAuth.tsx +561 -0
  68. package/src/operator/node/RunControls.tsx +2 -0
  69. package/src/operator/node/RunSwitch.tsx +113 -0
  70. package/src/operator/node/Services.tsx +245 -0
  71. package/src/operator/node/SessionRail.tsx +260 -0
  72. package/src/operator/node/SessionView.tsx +288 -0
  73. package/src/operator/node/SettingsView.tsx +690 -0
  74. package/src/operator/node/TaskBoard.tsx +352 -0
  75. package/src/operator/node/TaskChanges.tsx +530 -0
  76. package/src/operator/node/TaskHero.tsx +50 -0
  77. package/src/operator/node/TasksColumn.tsx +200 -0
  78. package/src/operator/node/Terminal.tsx +143 -0
  79. package/src/operator/node/Transcript.tsx +463 -0
  80. package/src/operator/node/ViewFile.tsx +79 -0
  81. package/src/operator/node/Welcome.tsx +101 -0
  82. package/src/operator/node/WorktreePrune.tsx +333 -0
  83. package/src/operator/node/agents.ts +58 -0
  84. package/src/operator/node/api.ts +40 -0
  85. package/src/operator/node/boardBackend.ts +80 -0
  86. package/src/operator/node/boardEvents.ts +22 -0
  87. package/src/operator/node/boardInteraction.ts +15 -0
  88. package/src/operator/node/boardState.ts +57 -0
  89. package/src/operator/node/boardWorkspace.ts +12 -0
  90. package/src/operator/node/branchFlow.ts +1 -0
  91. package/src/operator/node/composerDraft.ts +52 -0
  92. package/src/operator/node/fileLinks.ts +81 -0
  93. package/src/operator/node/format.ts +290 -0
  94. package/src/operator/node/github.tsx +234 -0
  95. package/src/operator/node/icons.tsx +88 -0
  96. package/src/operator/node/liveStream.ts +270 -0
  97. package/src/operator/node/modals.tsx +720 -0
  98. package/src/operator/node/navHistory.ts +70 -0
  99. package/src/operator/node/persist.ts +26 -0
  100. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  101. package/src/operator/node/protocol/agents/types.ts +394 -0
  102. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  103. package/src/operator/node/protocol/authFailure.ts +66 -0
  104. package/src/operator/node/protocol/customModels.ts +23 -0
  105. package/src/operator/node/protocol/events.ts +13 -0
  106. package/src/operator/node/protocol/features.ts +70 -0
  107. package/src/operator/node/protocol/internalUsage.ts +1 -0
  108. package/src/operator/node/protocol/promptLimits.ts +52 -0
  109. package/src/operator/node/protocol/storageRetention.ts +13 -0
  110. package/src/operator/node/protocol/types.ts +332 -0
  111. package/src/operator/node/protocol/usageLimit.ts +72 -0
  112. package/src/operator/node/shared.tsx +89 -0
  113. package/src/operator/node/transcriptModel.ts +129 -0
  114. package/src/operator/node/types.ts +296 -0
  115. package/src/operator/node/useGlobalEvents.ts +97 -0
  116. package/src/operator/node/useOrchestrator.ts +696 -0
  117. package/src/operator/node/usePrefs.ts +114 -0
  118. package/src/operator/node/useRecaps.ts +91 -0
  119. package/src/operator/node/useTaskStream.ts +279 -0
  120. package/src/operator.base.css +1798 -0
  121. package/src/operator.css +186 -16
  122. package/src/styles.css +20 -0
  123. package/src/ui/Markdown.tsx +7 -2
  124. package/src/ui/Popover.tsx +77 -0
  125. package/src/ui/TextBlock.tsx +3 -3
  126. package/src/ui/Transcript.tsx +10 -8
  127. package/src/ui/fileLinks.ts +6 -0
  128. package/src/ui/format.ts +1 -1
  129. package/src/ui/shared.tsx +1 -1
  130. package/src/ui/transcriptModel.ts +2 -2
  131. package/dist/cli-ui-embed-6IJ_jRVh.js +0 -41223
@@ -1,7 +1,7 @@
1
1
  // 工作区文件的基础操作(不依赖界面):可直接打开的地址、下载到本地、读成 Blob。
2
2
  // 看板、文件预览和给业务方的工作区接口(workspace.ts)共用。
3
- import { fileUrlFromBase, type BoardBackend } from "../core/client.js";
4
- import { extOf, fmtSize } from "./fileKind.js";
3
+ import { fileUrlFromBase, type BoardBackend } from "../core/client";
4
+ import { extOf, fmtSize } from "./fileKind";
5
5
 
6
6
  /** 只能先 fetch 成 blob 时(中枢太旧):超过这么大的不在浏览器里拼下载。 */
7
7
  const BLOB_DOWNLOAD_MAX = 300 * 1024 * 1024;
@@ -26,6 +26,29 @@ export async function directFileUrl(backend: BoardBackend, jobId: string, path:
26
26
  return linkBase ? fileUrlFromBase(linkBase, path, download) : null;
27
27
  }
28
28
 
29
+ export class FileReadLimitError extends Error {
30
+ constructor(maxBytes:number){super(`文件超过当前读取上限(${fmtSize(maxBytes)}),请使用直接下载链接`);this.name="FileReadLimitError";}
31
+ }
32
+ /** Bound bytes even with stale metadata; cancellation must settle callers when a custom backend ignores AbortSignal. */
33
+ export async function readBoundedBuffer(backend:BoardBackend,jobId:string,path:string,maxBytes:number,options:{signal?:AbortSignal;onBytes?:(bytes:number)=>void}={}):Promise<ArrayBuffer> {
34
+ const ctl=new AbortController();let limit:FileReadLimitError|undefined;
35
+ const abort=()=>ctl.abort();options.signal?.addEventListener("abort",abort,{once:true});
36
+ if(options.signal?.aborted)ctl.abort();
37
+ let rejectAbort:(()=>void)|undefined;
38
+ try{
39
+ if(ctl.signal.aborted)throw new DOMException("已取消","AbortError");
40
+ const cancelled=new Promise<never>((_resolve,reject)=>{rejectAbort=()=>reject(limit??new DOMException("已取消","AbortError"));ctl.signal.addEventListener("abort",rejectAbort,{once:true});});
41
+ const result=await Promise.race([backend.readBuffer(jobId,path,{signal:ctl.signal,onBytes:bytes=>{
42
+ if(ctl.signal.aborted)return;
43
+ if(bytes>maxBytes){limit=new FileReadLimitError(maxBytes);ctl.abort();return;}
44
+ options.onBytes?.(bytes);
45
+ }}),cancelled]);
46
+ if(ctl.signal.aborted)throw limit??new DOMException("已取消","AbortError");
47
+ if(result.byteLength>maxBytes)throw new FileReadLimitError(maxBytes);
48
+ return result;
49
+ }finally{options.signal?.removeEventListener("abort",abort);if(rejectAbort)ctl.signal.removeEventListener("abort",rejectAbort);ctl.abort();}
50
+ }
51
+
29
52
  /** 把 Blob 存到本地(浏览器下载)。 */
30
53
  export function saveBlob(blob: Blob, name: string) {
31
54
  const url = URL.createObjectURL(blob);
@@ -51,5 +74,5 @@ export async function downloadFile(backend: BoardBackend, jobId: string, path: s
51
74
  const url = await directFileUrl(backend, jobId, path, true);
52
75
  if (url) { clickDownload(url, name); return; }
53
76
  if (size !== undefined && size > BLOB_DOWNLOAD_MAX) throw new Error(`文件较大(${fmtSize(size)}),当前接入方式不能在浏览器里下载,请升级任务中心`);
54
- saveBlob(new Blob([await backend.readBuffer(jobId, path)]), name);
77
+ saveBlob(new Blob([await readBoundedBuffer(backend, jobId, path, BLOB_DOWNLOAD_MAX)]), name);
55
78
  }
@@ -0,0 +1,88 @@
1
+ import { CenterError, type BoardBackend } from '../core/client';
2
+ import { notifyCallback } from '../core/callbacks';
3
+ import type { FileEntry, WorkspaceChangeEvent, WorkspaceFileBatch, WorkspaceFileChange, WorkspaceWatchStatus } from '../core/types';
4
+ import { listFolder } from './folderDownload';
5
+ export interface WorkspaceWatchOptions { intervalMs?:number; onStatus?:(status:WorkspaceWatchStatus)=>void }
6
+ type Listener={change:(event:WorkspaceChangeEvent)=>void;status?:WorkspaceWatchOptions['onStatus']};
7
+ type Entry={listeners:Set<Listener>;stop:()=>void;retry?:()=>void;status:WorkspaceWatchStatus};
8
+ export function cloneWorkspaceChange(event:WorkspaceChangeEvent):WorkspaceChangeEvent {return {...event,changes:event.changes.map(change=>({...change,file:change.file?{...change.file}:undefined,previous:change.previous?{...change.previous}:undefined}))};}
9
+ const registries=new WeakMap<BoardBackend,Map<string,Entry>>();
10
+ const MAX_TRACKED_FILES=100000;
11
+ /** Internal: a new lease in an aggregating store can retry a terminal stream. */
12
+ export function retryWorkspaceFiles(backend:BoardBackend,jobId:string):void {registries.get(backend)?.get(jobId)?.retry?.();}
13
+ /** One transport per backend/workspace; independent of the active directory or file tab. */
14
+ export function subscribeWorkspaceFiles(backend:BoardBackend,jobId:string,listener:(event:WorkspaceChangeEvent)=>void,options:WorkspaceWatchOptions={}):()=>void {
15
+ let entries=registries.get(backend);if(!entries){entries=new Map();registries.set(backend,entries);}
16
+ let entry=entries.get(jobId);const subscription={change:listener,status:options.onStatus};
17
+ if(!entry){
18
+ const mode=backend.watchFiles?'events':'poll',coverage=mode==='events'?'workspace':'listed-files';
19
+ entry={listeners:new Set(),stop:()=>{},status:{type:'files_watch',jobId,state:'connecting',mode,coverage,complete:false}};entries.set(jobId,entry);
20
+ const owned=entry;let alive=true,baseline:Map<string,FileEntry>|null=null,limited=false,blocked=false,stopTransport:(()=>void)|null=null;
21
+ let timer:ReturnType<typeof setTimeout>|undefined,controller:AbortController|null=null,attempt=0,generation=0;
22
+ const status=(state:WorkspaceWatchStatus['state'],complete=false,error?:string)=>{
23
+ if(!alive)return;owned.status={type:'files_watch',jobId,state,mode,coverage,complete:mode==='events'&&complete&&!limited,...(error?{error}:{})};
24
+ for(const item of [...owned.listeners])notifyCallback(item.status,{...owned.status});
25
+ };
26
+ const publish=(changes:WorkspaceFileChange[],batch:WorkspaceFileBatch,resync=false)=>{
27
+ if(!alive||!changes.length)return;
28
+ const event:WorkspaceChangeEvent={type:'files_changed',jobId,mode,coverage,complete:mode==='events'&&batch.complete&&!limited,changes,revision:batch.revision,...(resync?{resync:true}:{})};
29
+ for(const item of [...owned.listeners])notifyCallback(item.change,cloneWorkspaceChange(event));
30
+ };
31
+ const accept=(batch:WorkspaceFileBatch)=>{
32
+ if(!alive)return;
33
+ if(batch.type==='resync'){if(batch.reason==='workspace_unavailable'&&baseline===null)baseline=new Map();status('reconnecting',false,batch.reason);return;}
34
+ if(batch.type==='snapshot'){
35
+ const next=new Map<string,FileEntry>();let overflow=false;const changes:WorkspaceFileChange[]=[];
36
+ for(const file of batch.files)if(file.type==='file'){if(next.has(file.path)||next.size<MAX_TRACKED_FILES)next.set(file.path,{...file});else overflow=true;}
37
+ if(baseline){
38
+ for(const [path,file] of next){const previous=baseline.get(path);if(!previous)changes.push({kind:'created',path,file});else if(previous.mtime!==file.mtime||previous.size!==file.size||previous.changeToken!==file.changeToken)changes.push({kind:'modified',path,file,previous});}
39
+ if(batch.complete&&!overflow)for(const [path,previous] of baseline)if(!next.has(path))changes.push({kind:'deleted',path,previous});
40
+ }
41
+ // Once tracking overflows, only a complete, bounded snapshot restores
42
+ // completeness. Partial snapshots never prove that missing files vanished.
43
+ if(batch.complete){baseline=next;limited=overflow;}
44
+ else{baseline??=new Map();for(const [path,file] of next){if(baseline.has(path)||baseline.size<MAX_TRACKED_FILES)baseline.set(path,file);else overflow=true;}limited||=overflow;}
45
+ publish(changes,batch,true);
46
+ }else{
47
+ baseline??=new Map();for(const change of batch.changes){if(change.kind==='deleted')baseline.delete(change.path);else if(change.file){if(baseline.has(change.path)||baseline.size<MAX_TRACKED_FILES)baseline.set(change.path,{...change.file});else limited=true;}}
48
+ // Native change events are authoritative even when size/mtime are unchanged.
49
+ publish(batch.changes,batch);
50
+ }
51
+ attempt=0;status('ready',batch.complete);
52
+ };
53
+ const interval=Math.max(1000,options.intervalMs??15000);
54
+ const scan=async()=>{
55
+ if(!alive)return;controller=new AbortController();const signal=controller.signal;
56
+ try{const files=await listFolder(backend,jobId,'',{signal,maxFiles:10000,maxBytes:Infinity});if(alive&&!signal.aborted)accept({type:'snapshot',files:files.map(file=>({...file,name:file.path.split('/').pop()??file.path,type:'file' as const})),complete:true});}
57
+ catch(error){if(alive&&!signal.aborted){attempt++;status('error',false,error instanceof Error?error.message:String(error));}}
58
+ finally{controller=null;if(alive)timer=setTimeout(()=>{timer=undefined;void scan();},Math.min(120000,interval*Math.max(1,attempt)));}
59
+ };
60
+ const connect=()=>{
61
+ if(!alive||!backend.watchFiles)return;const token=++generation;let finished=false,cleanup:(()=>void)|null=null;
62
+ blocked=false;
63
+ status(attempt?'reconnecting':'connecting');
64
+ if(!alive||token!==generation)return;
65
+ // Retirement can precede watchFiles returning its disposer. Clearing the
66
+ // owned handle first also makes reentrant cancellation release it once.
67
+ const retire=()=>{finished=true;if(stopTransport===retire)stopTransport=null;const dispose=cleanup;cleanup=null;notifyCallback(dispose??undefined,undefined);};
68
+ const close=(error:unknown)=>{
69
+ if(!alive||finished||token!==generation)return;retire();if(!alive||token!==generation)return;const code=error instanceof CenterError?error.status:0;
70
+ blocked=[400,404,405,410,501].includes(code);const slow=[401,403,429].includes(code);
71
+ status(blocked||slow?'error':'reconnecting',false,error instanceof Error?error.message:undefined);
72
+ if(!alive||token!==generation||blocked)return;
73
+ const delay=slow?30000:Math.min(30000,1000*2**Math.min(attempt++,5));
74
+ timer=setTimeout(()=>{timer=undefined;connect();},delay);
75
+ };
76
+ stopTransport=retire;
77
+ try{cleanup=backend.watchFiles(jobId,{onEvent:batch=>{if(alive&&!finished&&token===generation)accept(batch);},onClose:close});if(finished||!alive||token!==generation)retire();}catch(error){close(error);}
78
+ };
79
+ const wake=()=>{if(!alive)return;if(typeof document!=='undefined'&&document.visibilityState==='hidden')return;if(timer){clearTimeout(timer);timer=undefined;}if(mode==='poll'){if(!controller)void scan();}else{const token=++generation,retire=stopTransport;stopTransport=null;retire?.();if(alive&&token===generation)connect();}};
80
+ owned.retry=()=>{if(blocked)wake();};
81
+ if(typeof window!=='undefined'){window.addEventListener('online',wake);document.addEventListener('visibilitychange',wake);}
82
+ owned.stop=()=>{alive=false;generation++;if(timer)clearTimeout(timer);controller?.abort();stopTransport?.();if(typeof window!=='undefined'){window.removeEventListener('online',wake);document.removeEventListener('visibilitychange',wake);}};
83
+ // Defer initialization until the first listener has been registered.
84
+ queueMicrotask(()=>{if(alive){if(mode==='events')connect();else void scan();}});
85
+ }
86
+ const owned=entry;owned.listeners.add(subscription);notifyCallback(subscription.status,{...owned.status});owned.retry?.();
87
+ let active=true;return()=>{if(!active)return;active=false;owned.listeners.delete(subscription);if(!owned.listeners.size){if(entries!.get(jobId)===owned)entries!.delete(jobId);owned.stop();}};
88
+ }
@@ -2,10 +2,10 @@
2
2
  // 有上限:文件数、总大小(读进内存再打包);超了直接报出来,让用户进到更小的目录或单个下载,不会把页面撑爆。
3
3
  // 弱网 / 节点忙:每个请求失败了按退避重试几次;某个文件一直读不到(或读的时候被任务删了)就跳过,
4
4
  // 照样把其余的打包,失败的清单放进压缩包里的 FAILED_LIST_NAME,并告诉调用方。取消会中止正在传的请求。
5
- import { CenterError, type BoardBackend } from "../core/client.js";
6
- import { fmtSize } from "./fileKind.js";
7
- import { saveBlob } from "./fileOps.js";
8
- import { buildZip, type ZipEntry } from "./zip.js";
5
+ import { CenterError, type BoardBackend } from "../core/client";
6
+ import { fmtSize } from "./fileKind";
7
+ import { saveBlob, readBoundedBuffer, FileReadLimitError } from "./fileOps";
8
+ import { buildZip, type ZipEntry } from "./zip";
9
9
 
10
10
  export const FOLDER_MAX_FILES = 2000;
11
11
  export const FOLDER_MAX_BYTES = 300 * 1024 * 1024;
@@ -17,7 +17,7 @@ const BACKOFF_MS = [1_000, 3_000];
17
17
  export const FAILED_LIST_NAME = "_下载失败的文件.txt";
18
18
 
19
19
  export type FolderProgress = { phase: "listing" | "reading" | "zipping"; files: number; total: number; bytes: number; totalBytes?: number; failed?: number; retrying?: string };
20
- export type FolderResult = { files: number; failed: Array<{ path: string; reason: string }> };
20
+ export type FolderResult = { files: number; failed: Array<{ path: string; reason: string }>; /** ZIP failure report path, when generated; may differ to preserve a user file. */ failedListName?:string };
21
21
  export type FolderZip = FolderResult & { blob: Blob; name: string };
22
22
  export type ListOptions = { signal?: AbortSignal; backoff?: number[]; maxFiles?: number; maxBytes?: number };
23
23
 
@@ -43,15 +43,19 @@ function sleep(ms: number, signal?: AbortSignal) {
43
43
  export async function withRetry<T>(fn: () => Promise<T>, signal?: AbortSignal, onRetry?: (attempt: number, error: unknown) => void, backoff = BACKOFF_MS): Promise<T> {
44
44
  for (let attempt = 1; ; attempt++) {
45
45
  if (signal?.aborted) throw abortError();
46
+ let stop:(()=>void)|undefined;
46
47
  try {
47
- return await fn();
48
+ const cancelled=new Promise<never>((_resolve,reject)=>{stop=()=>reject(abortError());signal?.addEventListener("abort",stop,{once:true});});
49
+ const value=await Promise.race([fn(),cancelled]);
50
+ if(signal?.aborted)throw abortError();
51
+ return value;
48
52
  } catch (error) {
49
53
  if (isAbort(error) || signal?.aborted) throw abortError();
50
- if (attempt >= ATTEMPTS || !isTransient(error)) throw error;
54
+ if (attempt >= ATTEMPTS || error instanceof FileReadLimitError || !isTransient(error)) throw error;
51
55
  onRetry?.(attempt, error);
52
56
  const base = backoff[Math.min(attempt - 1, backoff.length - 1)] ?? 0;
53
57
  await sleep(Math.round(base * (0.7 + Math.random() * 0.6)), signal);
54
- }
58
+ } finally {if(stop)signal?.removeEventListener("abort",stop);}
55
59
  }
56
60
  }
57
61
 
@@ -89,13 +93,21 @@ export type FolderOptions = { onProgress?: (p: FolderProgress) => void; signal?:
89
93
 
90
94
  /** 打包下载 dir("" = 整个工作区)到本地。返回打了几个文件、哪些没读到。 */
91
95
  export async function downloadFolder(backend: BoardBackend, jobId: string, dir: string, opts: FolderOptions = {}): Promise<FolderResult> {
92
- const { blob, name, files, failed } = await zipFolder(backend, jobId, dir, opts);
96
+ const { blob, name, files, failed, failedListName } = await zipFolder(backend, jobId, dir, opts);
97
+ if(opts.signal?.aborted)throw abortError();
93
98
  saveBlob(blob, name);
94
- return { files, failed };
99
+ return { files, failed, ...(failedListName?{failedListName}:{}) };
95
100
  }
96
101
 
97
102
  /** 把 dir("" = 整个工作区)打成 ZIP,返回 Blob(不下载;业务方可以自己上传到服务器)。 */
98
103
  export async function zipFolder(backend: BoardBackend, jobId: string, dir: string, opts: FolderOptions = {}): Promise<FolderZip> {
104
+ const ctl=new AbortController(),abort=()=>ctl.abort();let limit:FileReadLimitError|undefined;
105
+ opts.signal?.addEventListener("abort",abort,{once:true});if(opts.signal?.aborted)ctl.abort();
106
+ try{return await zipFolderRun(backend,jobId,dir,{...opts,signal:ctl.signal},()=>{limit=new FileReadLimitError(FOLDER_MAX_BYTES);ctl.abort();});}
107
+ catch(error){throw limit??error;}
108
+ finally{opts.signal?.removeEventListener("abort",abort);ctl.abort();}
109
+ }
110
+ async function zipFolderRun(backend: BoardBackend, jobId: string, dir: string, opts: FolderOptions,exceeded:()=>void): Promise<FolderZip> {
99
111
  const { onProgress, signal } = opts;
100
112
  onProgress?.({ phase: "listing", files: 0, total: 0, bytes: 0 });
101
113
  const files = await listFolder(backend, jobId, dir, { signal, backoff: opts.backoff });
@@ -123,7 +135,7 @@ export async function zipFolder(backend: BoardBackend, jobId: string, dir: strin
123
135
  const f = files[i];
124
136
  try {
125
137
  const buffer = await withRetry(
126
- () => { got[i] = 0; return backend.readBuffer(jobId, f.path, { signal, onBytes: (n) => { got[i] = n; report(); } }); },
138
+ () => { got[i] = 0; return readBoundedBuffer(backend,jobId,f.path,FOLDER_MAX_BYTES,{ signal, onBytes: (n) => { got[i] = n; if(got.reduce((a,b)=>a+b,0)>FOLDER_MAX_BYTES){exceeded();return;} report(); } }); },
127
139
  signal,
128
140
  () => { retrying = f.path; report(true); },
129
141
  opts.backoff,
@@ -131,7 +143,7 @@ export async function zipFolder(backend: BoardBackend, jobId: string, dir: strin
131
143
  const data = new Uint8Array(buffer);
132
144
  got[i] = data.length;
133
145
  // 任务在写、文件比列的时候大了很多:总量还是要守住上限。
134
- if (got.reduce((a, b) => a + b, 0) > FOLDER_MAX_BYTES * 1.1) throw new Error(`读到的总大小超过 ${fmtSize(FOLDER_MAX_BYTES)}(文件还在变大),请进到更小的目录或单个下载`);
146
+ if (got.reduce((a, b) => a + b, 0) > FOLDER_MAX_BYTES) throw new Error(`读到的总大小超过 ${fmtSize(FOLDER_MAX_BYTES)}(文件还在变大),请进到更小的目录或单个下载`);
135
147
  // ZIP 里的路径相对下载的目录(文件夹名本身作为 ZIP 文件名)。
136
148
  entries[i] = { path: f.path.startsWith(base) ? f.path.slice(base.length) : f.path, data, mtime: f.mtime };
137
149
  } catch (error) {
@@ -151,11 +163,15 @@ export async function zipFolder(backend: BoardBackend, jobId: string, dir: strin
151
163
  const ok = entries.filter((e): e is ZipEntry => !!e);
152
164
  if (!ok.length) throw new Error(`${failed.length} 个文件都没读到:${failed[0]?.reason ?? "未知原因"}`);
153
165
  onProgress?.({ phase: "zipping", files: done, total: files.length, bytes: got.reduce((a, b) => a + b, 0), totalBytes, failed: failed.length });
166
+ if(signal?.aborted)throw abortError();
167
+ let failedListName:string|undefined;
154
168
  if (failed.length) {
169
+ const names=new Set(ok.map(entry=>entry.path));failedListName=FAILED_LIST_NAME;
170
+ for(let suffix=1;names.has(failedListName);suffix++)failedListName=`_下载失败的文件 (${suffix}).txt`;
155
171
  const list = ["下面这些文件没有下载下来(其余文件都在压缩包里):", "", ...failed.map((f) => `${f.path}\t${f.reason}`), ""].join("\n");
156
- ok.push({ path: FAILED_LIST_NAME, data: new TextEncoder().encode(list) as Uint8Array<ArrayBuffer> });
172
+ ok.push({ path: failedListName, data: new TextEncoder().encode(list) as Uint8Array<ArrayBuffer> });
157
173
  }
158
174
  const blob = buildZip(ok);
159
175
  const name = opts.zipName ?? `${(dir.split("/").filter(Boolean).pop() || "工作区")}.zip`;
160
- return { blob, name, files: ok.length - (failed.length ? 1 : 0), failed };
176
+ return { blob, name, files: ok.length - (failed.length ? 1 : 0), failed, ...(failedListName?{failedListName}:{}) };
161
177
  }
@@ -0,0 +1,33 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useState } from 'react';
2
+ const useLayout = typeof window === 'undefined' ? useEffect : useLayoutEffect;
3
+ /** Initial visibility is separate from capability. Closing a panel never disables its backend. */
4
+ export function useBoardPanels(scope: string | null, available: readonly string[], initial: readonly string[]) {
5
+ const allowed = [...new Set(available)];
6
+ const defaults = [...new Set(initial)].filter(id => allowed.includes(id));
7
+ const signature = JSON.stringify([allowed, defaults]);
8
+ const key = scope ? `cli-ui:panels:${scope}:${signature}` : null;
9
+ const [state, setState] = useState<{ key: string | null; open: string[] }>({ key, open: defaults });
10
+ const current = state.key === key ? state.open.filter(id => allowed.includes(id)) : defaults;
11
+ useLayout(() => {
12
+ let open = defaults;
13
+ try {
14
+ const saved = key ? JSON.parse(sessionStorage.getItem(key) ?? 'null') : null;
15
+ if (Array.isArray(saved)) open = [...new Set(saved.filter((id): id is string => typeof id === 'string' && allowed.includes(id)))];
16
+ } catch { /* Restricted storage does not disable panel controls. */ }
17
+ setState({ key, open });
18
+ }, [key, signature]); // eslint-disable-line react-hooks/exhaustive-deps
19
+ useEffect(() => {
20
+ if (key && state.key === key) try { sessionStorage.setItem(key, JSON.stringify(state.open)); } catch { /* In-memory only. */ }
21
+ }, [key, state]);
22
+ const set = useCallback((id: string, open: boolean) => {
23
+ if (!allowed.includes(id)) return false;
24
+ setState(previous => {
25
+ const list = previous.key === key ? previous.open : defaults;
26
+ return { key, open: open ? [...new Set([...list, id])] : list.filter(item => item !== id) };
27
+ });
28
+ return true;
29
+ }, [key, signature]); // eslint-disable-line react-hooks/exhaustive-deps
30
+ const show=useCallback((id:string)=>set(id,true),[set]);
31
+ const close=useCallback((id:string)=>set(id,false),[set]);
32
+ return { open: current, available: allowed, show, close };
33
+ }
@@ -5,8 +5,8 @@
5
5
  // 这一小段和手里的末尾一模一样就说明文件只是往后追加了,只取新增的部分接上;对不上(文件被改写、变短了)才整个重读。
6
6
  // 只对 UTF-8 文件这样做(GBK 的字节边界不好对齐,整个重读)。
7
7
 
8
- import type { BoardBackend } from "../core/client.js";
9
- import { decodeText } from "../core/textDecode.js";
8
+ import type { BoardBackend } from "../core/client";
9
+ import { decodeText } from "../core/textDecode";
10
10
 
11
11
  /** 往前多取这么多字节,用来确认文件只是追加。 */
12
12
  export const OVERLAP = 256;
@@ -11,11 +11,12 @@
11
11
  *
12
12
  * 数据都经中枢读执行节点(和看板一样),弱网时按网络错误(CenterError,code = "NETWORK")失败,可以重试。
13
13
  */
14
- import type { BoardBackend } from "../core/client.js";
15
- import type { BoardInfo, FileEntry } from "../core/types.js";
16
- import { directFileUrl, downloadFile, mimeOf } from "./fileOps.js";
17
- import { fmtSize } from "./fileKind.js";
18
- import { downloadFolder, listFolder, zipFolder, type FolderOptions, type FolderResult, type FolderZip } from "./folderDownload.js";
14
+ import { subscribeWorkspaceFiles, type WorkspaceWatchOptions } from "./fileWatch";
15
+ import type { BoardBackend } from "../core/client";
16
+ import type { BoardInfo, FileEntry, WorkspaceChangeEvent } from "../core/types";
17
+ import { directFileUrl, downloadFile, mimeOf } from "./fileOps";
18
+ import { fmtSize } from "./fileKind";
19
+ import { downloadFolder, listFolder, zipFolder, type FolderOptions, type FolderResult, type FolderZip } from "./folderDownload";
19
20
 
20
21
  /** readText 最多读这么大(更大的用 readFile)。 */
21
22
  export const WORKSPACE_TEXT_MAX = 20 * 1024 * 1024;
@@ -24,6 +25,8 @@ export const WORKSPACE_FILE_MAX = 500 * 1024 * 1024;
24
25
  /** listAll 最多列这么多个文件。 */
25
26
  export const WORKSPACE_LIST_MAX = 10_000;
26
27
 
28
+ export interface BoardFile { path: string; sourceId?: string }
29
+
27
30
  /** 看板现在的样子(看板组件报告给仓库;没有看板时没有)。 */
28
31
  export interface BoardView {
29
32
  /** 当前页签:files / changes / browser、"file:<路径>"(打开的文件)或自己加的页签 id。 */
@@ -34,6 +37,14 @@ export interface BoardView {
34
37
  file: string | null;
35
38
  /** 开着的文件页签。 */
36
39
  files: string[];
40
+ /** 当前文件身份;切到目录/浏览器时为 null。sourceId 保留来源任务。 */
41
+ current?: BoardFile | null;
42
+ opened?: BoardFile[];
43
+ /** 最近真正访问过的文件(关闭页签后保留,最多40条)。 */
44
+ history?: BoardFile[];
45
+ browser?: { url: string; title: string } | null;
46
+ visible?: boolean;
47
+ autoContext?: boolean;
37
48
  }
38
49
 
39
50
  export interface CliWorkspace {
@@ -41,6 +52,11 @@ export interface CliWorkspace {
41
52
  readonly jobId: string;
42
53
  /** 看板概况:派发到节点没有、节点是否在线、有没有浏览器。 */
43
54
  info(): Promise<BoardInfo>;
55
+ stat(path: string): ReturnType<BoardBackend["stat"]>;
56
+ /** 订阅文件变更。初始快照只建立基线;返回取消函数。 */
57
+ watch(listener: (event: WorkspaceChangeEvent) => void, options?: WorkspaceWatchOptions): () => void;
58
+ /** 包括当前文件、打开页签、最近浏览和显示状态的背景快照。 */
59
+ context(): BoardView | null;
44
60
  /** 列一个目录(不递归)。 */
45
61
  list(dir?: string): Promise<FileEntry[]>;
46
62
  /** 列 dir 下所有文件(递归,只含文件)。 */
@@ -70,6 +86,8 @@ export function createWorkspace(deps: {
70
86
  key: string;
71
87
  view: () => BoardView | undefined;
72
88
  open: (path: string) => boolean;
89
+ context?: () => BoardView | null;
90
+ watch?: (listener:(event:WorkspaceChangeEvent)=>void,options?:WorkspaceWatchOptions)=>()=>void;
73
91
  }): CliWorkspace {
74
92
  const { key } = deps;
75
93
  const backend = () => {
@@ -80,6 +98,9 @@ export function createWorkspace(deps: {
80
98
  return {
81
99
  jobId: key,
82
100
  info: () => backend().info(key),
101
+ stat: path => backend().stat(key, clean(path) || "."),
102
+ watch: (listener, options) => deps.watch ? deps.watch(listener,options) : subscribeWorkspaceFiles(backend(), key, listener, options),
103
+ context: () => deps.context?.() ?? deps.view() ?? null,
83
104
  async list(dir = "") {
84
105
  const res = await backend().stat(key, clean(dir) || ".");
85
106
  if (res.type !== "dir") throw new Error(`「${dir}」不是目录`);
@@ -96,23 +117,31 @@ export function createWorkspace(deps: {
96
117
  },
97
118
  async readFile(path, opts = {}) {
98
119
  const p = clean(path);
120
+ const max = opts.maxBytes ?? WORKSPACE_FILE_MAX;
121
+ if(!Number.isFinite(max)||max<0)throw new RangeError("maxBytes 必须是非负有限字节数");
99
122
  // 太大的读到一半就停:整个读进内存会把页面撑爆。
100
123
  const ctl = new AbortController();
101
124
  const stop = () => ctl.abort();
102
125
  opts.signal?.addEventListener("abort", stop, { once: true });
126
+ if(opts.signal?.aborted)ctl.abort();
103
127
  let tooBig = false;
104
- const max = opts.maxBytes ?? WORKSPACE_FILE_MAX;
128
+ let rejectAbort:(()=>void)|undefined;
105
129
  try {
106
- const buffer = await backend().readBuffer(key, p, {
130
+ if(ctl.signal.aborted)throw new DOMException("已取消", "AbortError");
131
+ const aborted=new Promise<never>((_resolve,reject)=>{rejectAbort=()=>reject(new DOMException("已取消", "AbortError"));ctl.signal.addEventListener("abort",rejectAbort,{once:true});});
132
+ const buffer = await Promise.race([backend().readBuffer(key, p, {
107
133
  signal: ctl.signal,
108
- onBytes: (n) => { if (n > max) { tooBig = true; ctl.abort(); } else opts.onProgress?.(n); },
109
- });
134
+ onBytes: (n) => { if(ctl.signal.aborted)return; if (n > max) { tooBig = true; ctl.abort(); } else opts.onProgress?.(n); },
135
+ }),aborted]);
136
+ if(buffer.byteLength>max){tooBig=true;throw new Error("文件超过读取上限");}
137
+ if(ctl.signal.aborted)throw new DOMException("已取消", "AbortError");
110
138
  return new Blob([buffer], { type: mimeOf(p) });
111
139
  } catch (error) {
112
140
  if (tooBig) throw new Error(`文件超过 ${fmtSize(max)},请用 fileUrl / download(浏览器流式下载,不占内存)`);
113
141
  throw error;
114
142
  } finally {
115
143
  opts.signal?.removeEventListener("abort", stop);
144
+ if(rejectAbort)ctl.signal.removeEventListener("abort",rejectAbort);
116
145
  }
117
146
  },
118
147
  async fileUrl(path, opts = {}) {
@@ -1,27 +1,36 @@
1
1
  // cli-chat:对话。界面取自 Operator 的会话视图(SessionView 的对话部分 + Composer),
2
2
  // 数据来自 center 的对话任务(mode=conversation):实时流里的节点转录按 Operator 的规则渲染,
3
3
  // 每条消息是中枢的一轮,排队 / 执行 / 结束都跟 center 保持一致。
4
- import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
5
- import { CliErrorBoundary } from "../ui/ErrorBoundary.js";
6
- import type { CliEvent, CliStore, CliStoreOptions } from "../core/store.js";
7
- import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider, type ResultHandlers } from "../core/provider.js";
8
- import { boardContextBlock, serializeBlock, viewingBlock } from "../core/blocks.js";
9
- import { convKey, emptyConv, isChatTask, isNewInJob, isRunning, parseConvKey, runningTurn, TERMINAL_JOB, viewMessages } from "../core/conversation.js";
10
- import type { AskAnswers, AskQuestion, CenterTask, Msg } from "../core/types.js";
11
- import { Icon } from "../ui/icons.js";
12
- import { agentLabel as labelOf, fmtTokens, formatAnswersText, modelLabel } from "../ui/format.js";
13
- import { AgentBadge, Avatar, Skel, StatusDot, type DotCls } from "../ui/shared.js";
14
- import { HANDOFF_NOTICE_PREFIX, MessageView, SessionBreak, type MessageHandlers } from "../ui/Transcript.js";
15
- import { RENDER_MAX, RENDER_STEP, RENDER_WINDOW, buildTranscriptItems, hasOpenAsk, toolDataOf } from "../ui/transcriptModel.js";
16
- import { Composer, type ComposerOptions } from "./Composer.js";
17
- import { fillTemplate, refShown, resolveChatConfig, type CliChatConfig } from "../core/config.js";
18
- import { exportMarkdown } from "./export.js";
19
- import { FileLinkContext, FileOpenContext } from "../ui/fileLinks.js";
20
- import { FileViewer } from "../board/FileViewer.js";
21
- import { BrowserPane } from "../board/BrowserPane.js";
4
+ import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type CSSProperties } from "react";
5
+ import { CliErrorBoundary } from "../ui/ErrorBoundary";
6
+ import type { CliEvent, CliStore, CliStoreOptions } from "../core/store";
7
+ import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider, type ResultHandlers } from "../core/provider";
8
+ import { formatBoardContext, withoutBoardContext } from "../board/context";
9
+ import { MAX_MESSAGE_CHARS, charCount, boardContextBlock, serializeBlock, viewingBlock } from "../core/blocks";
10
+ import { convKey, emptyConv, isChatTask, isNewInJob, isRunning, parseConvKey, runningTurn, TERMINAL_JOB, viewMessages } from "../core/conversation";
11
+ import type { AskAnswers, AskQuestion, CenterTask, Msg } from "../core/types";
12
+ import { Icon } from "../ui/icons";
13
+ import { agentLabel as labelOf, fmtTokens, formatAnswersText, modelLabel } from "../ui/format";
14
+ import { AgentBadge, Avatar, Skel, StatusDot, type DotCls } from "../ui/shared";
15
+ import { HANDOFF_NOTICE_PREFIX, MessageView, SessionBreak, type MessageHandlers } from "../ui/Transcript";
16
+ import { RENDER_MAX, RENDER_STEP, RENDER_WINDOW, buildTranscriptItems, hasOpenAsk, toolDataOf } from "../ui/transcriptModel";
17
+ import { Composer, type ComposerOptions } from "./Composer";
18
+ import { fillTemplate, refShown, resolveChatConfig, type CliChatConfig } from "../core/config";
19
+ import { exportMarkdown } from "./export";
20
+ import { FileLinkContext, FileOpenContext } from "../ui/fileLinks";
21
+ import { FileViewer } from "../board/FileViewer";
22
+ import { BrowserPane } from "../board/BrowserPane";
22
23
 
23
24
  /** onTurnEnd / onConversationEnd:浏览器里从实时流算出的结果通知,不依赖 center 的 callback.url。 */
24
25
  export interface CliChatProps extends CliStoreOptions, ResultHandlers {
26
+ /** Integration slots share this transcript/composer; they do not create another chat implementation. */
27
+ slots?: { beforeMessages?: ReactNode; afterMessages?: ReactNode; composerToolbar?: ReactNode; beforeComposer?: ReactNode };
28
+ /** Saving execution settings blocks send until persistence completes. */
29
+ configurationBusy?: boolean;
30
+ onClear?: () => void;
31
+ /** 每条消息自动带入看板当前文件、页签和最近浏览;默认 true。 */
32
+ passiveContext?: boolean;
33
+
25
34
  /**
26
35
  * 显示哪个对话(center 的 job_id)。不给就跟共享仓库的选中走(和 cli-msglist 联动);都没有 = 新对话。
27
36
  * 也可以直接给对话键 "job_id#task_id"(cli-msglist 的 onSelect、job_change 事件的 key 就是它)。
@@ -120,7 +129,7 @@ function RunningIndicator({ agent, agentName, startedAt, lastActivityRef }: { ag
120
129
  );
121
130
  }
122
131
 
123
- function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", color = "#b8613f", readOnly: readOnlyProp, allowClose = true, config, theme = "light", className, style, onEvent, onTurnEnd, onConversationEnd, onCallback, inProvider, fileUrl, fileOpen = "auto", newConversation, createConversation, businessIdFor }: CliChatProps & { inProvider: boolean }) {
132
+ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, jobId: jobIdProp, taskId: taskIdProp, title = "对话", color = "#b8613f", readOnly: readOnlyProp, allowClose = true, config, theme = "light", className, style, onEvent, onTurnEnd, onConversationEnd, onCallback, inProvider, fileUrl, fileOpen = "auto", newConversation, createConversation, businessIdFor }: CliChatProps & { inProvider: boolean }) {
124
133
  useComponentEvents(onEvent, { onTurnEnd, onConversationEnd, onCallback }, inProvider);
125
134
  // 配置按内容比较:宿主每次渲染传新对象也不会让消息行全部重渲染。
126
135
  const configKey = JSON.stringify(config ?? {});
@@ -269,6 +278,9 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
269
278
  // 本组件的新建参数随每次发送带上:放进共享 Provider 时也用自己的,不依赖仓库是谁建的。
270
279
  const sendOpts = useRef({ newConversation, createConversation, businessIdFor, select: !controlled });
271
280
  sendOpts.current = { newConversation, createConversation, businessIdFor, select: !controlled };
281
+ const [skipPassive,setSkipPassive]=useState(false);
282
+ const skipPassiveRef=useRef(false);skipPassiveRef.current=skipPassive;
283
+ useEffect(()=>setSkipPassive(false),[key]);
272
284
  const send = useCallback(async (text: string) => {
273
285
  // 一次性任务:发给正在看的那个子任务(接着它聊)。
274
286
  const id = latest.current.conv?.batch ? latest.current.runKey : latest.current.key;
@@ -276,26 +288,33 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
276
288
  // 看板上正在看的文件缺省也带上一次(已经明确引用了的不重复),单独一块「看板正在看」:转录里按 config.refs.viewing 显示或不显示。
277
289
  const refKey = latest.current.key;
278
290
  const refs = refKey ? store.takeRefs(refKey) : [];
279
- const viewing = refKey ? store.takeViewing(refKey) : null;
291
+ const passiveView=refKey?store.getState().boardContext[refKey]:null;
292
+ const passive=passiveContext&&!skipPassiveRef.current&&passiveView?formatBoardContext(passiveView,refKey??undefined):'';
293
+ const viewing = !passive && refKey ? store.takeViewing(refKey) : null;
280
294
  const blocks = [
295
+ ...(passive ? [{name:"看板正在看",kind:"viewing" as const,text:passive}] : []),
281
296
  ...(viewing && !refs.some((r) => r.id === viewing.id) ? [viewingBlock(viewing.text)] : []),
282
297
  ...(refs.length ? [boardContextBlock(refs.map((r) => r.text))] : []),
283
298
  ];
284
299
  const full = blocks.length ? [text, ...blocks.map(serializeBlock)].join("\n\n") : text;
285
300
  let target: string;
286
301
  try {
302
+ if(charCount(full)>MAX_MESSAGE_CHARS)throw new Error("加上看板背景后消息过长,请精简内容或取消本次自动附带。");
287
303
  target = await store.send(id ?? null, full, sendOpts.current);
288
304
  } catch (error) {
289
305
  if (refKey) { store.restoreRefs(refKey, refs); store.restoreViewing(refKey, viewing); }
290
306
  throw error;
291
307
  }
308
+ if(latest.current.key===refKey)setSkipPassive(false);
292
309
  if (target !== id) go(target);
293
- }, [store, go]);
310
+ }, [store, go, passiveContext]);
311
+ const passiveView=useCliState(state=>key?state.boardContext[key]:undefined);
312
+ const passiveText=passiveContext&&passiveView?formatBoardContext(passiveView,key??undefined):'';
294
313
  const refs = useCliState((s) => (key ? s.refs[key] : undefined));
295
314
  const viewingState = useCliState((s) => (key ? s.viewing[key] : undefined));
296
315
  const viewingRef = viewingState && !viewingState.off && !refs?.some((r) => r.id === viewingState.ref.id) ? viewingState.ref : null;
297
316
  // 输入框上方显示哪些(config.refs):不显示的照样随消息带上。正在看的缺省不显示。
298
- const viewing = viewingRef && refShown(cfg, "viewing") ? viewingRef : null;
317
+ const viewing = !passiveText && viewingRef && refShown(cfg, "viewing") ? viewingRef : null;
299
318
  const shownRefs = refs?.filter((r) => refShown(cfg, r.kind));
300
319
  // 业务方补充的背景(store.appendContext):随下一条消息带上,输入框上方显示出来,让用户知道会带上什么。
301
320
  const contextItems = useCliState((s) => s.context[key ?? ""]);
@@ -324,6 +343,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
324
343
  return () => window.removeEventListener("keydown", onKey);
325
344
  }, [popup]);
326
345
  const handlers = useMemo<MessageHandlers>(() => ({
346
+ onQuote: readOnly ? undefined : (m,selected) => {const id=latest.current.key;if(id) store.addRef(id,{id:`message:${m.id}`,kind:"message",label:m.role==='user'?'引用你的消息':'引用智能体消息',text:selected||(m.role==='user'?withoutBoardContext(m.content):m.content)});},
327
347
  // 与 Operator 的 answerQuestion 一致:先把卡片标成已答,再把回答发出去(center 已结束提问那一轮,回答就是下一条消息)。
328
348
  onAnswer: readOnlyProp || (readOnly && !(batch && store.client.answerAsk)) ? undefined : async (askId: string, questions: AskQuestion[], answers: AskAnswers) => {
329
349
  const id = latest.current.key;
@@ -361,7 +381,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
361
381
  onRetry: readOnly ? undefined : (msgId: string) => {
362
382
  const list = latest.current.messages;
363
383
  const at = list.findIndex((m) => m.id === msgId);
364
- for (let j = (at === -1 ? list.length : at) - 1; j >= 0; j--) if (list[j].role === "user") { void report(send(list[j].content), "重试没有发出去").catch(() => undefined); return; }
384
+ for (let j = (at === -1 ? list.length : at) - 1; j >= 0; j--) if (list[j].role === "user") { void report(send(withoutBoardContext(list[j].content)), "重试没有发出去").catch(() => undefined); return; }
365
385
  },
366
386
  }), [readOnly, readOnlyProp, batch, chat, send, store, go, report, fileOpen]);
367
387
  const stop = useCallback(async () => {
@@ -369,7 +389,19 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
369
389
  const turn = c ? runningTurn(c) : null;
370
390
  if (id && turn) await report(store.cancelTurn(id, turn.turn_id), "停止失败");
371
391
  }, [store, report]);
372
- const [closing, setClosing] = useState<"idle" | "confirm" | "busy">("idle");
392
+ const [closeAction, setCloseAction] = useState<{ key: string; stage: "confirm" | "busy" } | null>(null);
393
+ const closing = closeAction?.key === key ? closeAction.stage : "idle";
394
+ useEffect(() => setCloseAction(null), [key]);
395
+ const confirmClose = () => {
396
+ if (!key || closeAction?.key !== key || closeAction.stage !== "confirm") return;
397
+ const pending = { key, stage: "busy" as const };
398
+ setCloseAction(pending);
399
+ void store.close(key).then(() => {
400
+ if (latest.current.key === pending.key) setActionError(null);
401
+ }, (error: unknown) => {
402
+ if (latest.current.key === pending.key) setActionError(`结束对话失败:${error instanceof Error ? error.message : String(error)}`);
403
+ }).finally(() => setCloseAction(current => current === pending ? null : current));
404
+ };
373
405
 
374
406
  // ── 运行计时(与 Operator 一致:本页看着开始的轮次按本机时钟,进来时已在跑的按那条用户消息的时间) ──
375
407
  const lastUserTs = useMemo(() => { for (let i = messages.length - 1; i >= 0; i--) if (messages[i].role === "user") return messages[i].ts ?? null; return null; }, [messages]);
@@ -614,7 +646,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
614
646
  {Icon.download()}<span>{older > 0 ? "导出已加载" : "导出"}</span>
615
647
  </button>}
616
648
  {h.close && key && !isNew && allowClose && !readOnly && !ended && !batch && (
617
- <button className="btn btn-line btn-sm" onClick={() => setClosing("confirm")} disabled={closing === "busy"} title={taskId ? "只结束这个对话:之后它不再接收消息,正在跑的一轮会被中止;同一任务里的其他对话不受影响" : "结束这个对话:之后不再接收消息,正在跑的一轮会被中止"}>{Icon.stop()} 结束对话</button>
649
+ <button className="btn btn-line btn-sm" onClick={() => key && setCloseAction({ key, stage: "confirm" })} disabled={closing === "busy"} title={taskId ? "只结束这个对话:之后它不再接收消息,正在跑的一轮会被中止;同一任务里的其他对话不受影响" : "结束这个对话:之后不再接收消息,正在跑的一轮会被中止"}>{Icon.stop()} 结束对话</button>
618
650
  )}
619
651
  </div>
620
652
  </div>}
@@ -623,8 +655,8 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
623
655
  <div className="clear-confirm">
624
656
  <span>结束这个对话?结束后不能再发消息{running ? ",正在执行的一轮会被中止" : ""}{taskId && manyConvs ? ";同一任务里的其他对话不受影响" : ""}。</span>
625
657
  <span className="spacer" />
626
- <button className="btn btn-ghost btn-sm" onClick={() => setClosing("idle")} disabled={closing === "busy"}>取消</button>
627
- <button className="btn btn-accent btn-sm" disabled={closing === "busy"} onClick={() => { setClosing("busy"); void report(store.close(key), "结束对话失败").catch(() => undefined).finally(() => setClosing("idle")); }}>{closing === "busy" ? "正在结束…" : "确认结束"}</button>
658
+ <button className="btn btn-ghost btn-sm" onClick={() => setCloseAction(null)} disabled={closing === "busy"}>取消</button>
659
+ <button className="btn btn-accent btn-sm" disabled={closing === "busy"} onClick={confirmClose}>{closing === "busy" ? "正在结束…" : "确认结束"}</button>
628
660
  </div>
629
661
  )}
630
662
  {actionError && <div className="sync-banner conflict"><span className="sync-msg">⚠ {actionError}</span><span className="sync-spacer" /><button className="btn btn-ghost btn-sm" onClick={() => setActionError(null)}>知道了</button></div>}
@@ -634,6 +666,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
634
666
  <div className="transcript-wrap">
635
667
  <div className="transcript" ref={scrollRef} onScroll={onScroll}>
636
668
  <div className="tw">
669
+ {slots?.beforeMessages}
637
670
  {(!key || isNew) && cfg.empty.show && (
638
671
  <div className="hero cli-hero">
639
672
  <div className="h-ic">{Icon.bolt()}</div>
@@ -698,6 +731,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
698
731
  {ended && cfg.transcript.status && (
699
732
  <div className="session-label"><span className="ln" />{batch ? statusText : <>{REASON_TEXT[closedReason ?? ""] ?? "对话已结束"}{!finalEnd ? " · 再发消息会开一个新对话接着聊" : ""}</>}<span className="ln" /></div>
700
733
  )}
734
+ {slots?.afterMessages}
701
735
  {continuedBy && (
702
736
  <div className="load-older"><button className="btn btn-line btn-sm" onClick={() => go(continuedBy)}>转到接续的新对话</button></div>
703
737
  )}
@@ -737,15 +771,24 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
737
771
  ))}
738
772
  </div>
739
773
  )}
774
+ {showComposer && passiveText && passiveView && <div className="cli-board-background" role="note" aria-label="看板浏览背景">
775
+ <details><summary>看板背景 · {(passiveView.current?.path??passiveView.file)?.split('/').pop()??'未选中文件'} · 打开 {(passiveView.opened??passiveView.files).length} · 最近浏览 {passiveView.history?.length??0}</summary>
776
+ <div>{passiveView.visible===false?'看板已收起,将附带上次选中的文件及浏览记录。':'将附带当前文件、打开页签和最近浏览记录,仅发送路径等信息。'}</div>
777
+ <ul>{(passiveView.history??[]).map((file,index)=><li key={`${file.sourceId}:${file.path}:${index}`}>{file.path}{file.sourceId&&file.sourceId!==key?'(来源任务)':''}</li>)}</ul>
778
+ </details>
779
+ <label><input type="checkbox" checked={!skipPassive} onChange={event=>setSkipPassive(!event.target.checked)} />下条消息带入</label>
780
+ </div>}
781
+ {slots?.beforeComposer}
740
782
  {showComposer && (
741
783
  <Composer
742
784
  key={key ?? "new"}
743
- draft={key ?? `new:${instanceId}`}
785
+ draft={store.draftScope ? `${store.draftScope}:${key ?? `new:${instanceId}`}` : key ?? `new:${instanceId}`}
744
786
  placeholder={fillTemplate(key && !isNew ? cfg.composer.placeholder : cfg.composer.placeholderNew, { title: convTitle, agent: agentText })}
745
- disabled={composerDisabled}
787
+ disabled={composerDisabled || !!configurationBusy}
746
788
  disabledHint={cfg.composer.placeholderDisabled ? fillTemplate(cfg.composer.placeholderDisabled, { title: convTitle, agent: agentText }) : disabledHint}
747
789
  running={running || batchPending}
748
- onSend={send}
790
+ toolbar={slots?.composerToolbar}
791
+ onSend={async text => { if (text.trim() === "/clear" && onClear) { onClear(); return; } await send(text); }}
749
792
  onStop={cfg.composer.stop && key && conv && runningTurn(conv) ? stop : undefined}
750
793
  taskControl={cfg.composer.stop && !readOnlyProp && controlTask && jobId && store.client.pauseTask && store.client.resumeTask ? {
751
794
  paused: controlTask.status === "paused",