@spzxg/cli-ui 0.6.6 → 0.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +33 -1
  2. package/README.md +79 -20
  3. package/dist/cli-ui-embed-CB7ibAtF.js +49558 -0
  4. package/dist/{cli-ui-index-b8wF7wrN.js → cli-ui-index-BmwOLWFI.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-ggP8OokP.js → cli-ui-jszip.min-BKNOJnhm.js} +1 -1
  6. package/dist/{cli-ui-jszip.min-B0uLrCHg.js → cli-ui-jszip.min-Cq0WjNYb.js} +2 -2
  7. package/dist/{cli-ui-xlsx-DEDdHrJJ.js → cli-ui-xlsx-Sl7ypvSe.js} +1 -1
  8. package/dist/cli-ui.css +1 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +3 -1
  11. package/src/board/CliBoard.tsx +128 -100
  12. package/src/board/FileViewer.tsx +259 -132
  13. package/src/board/FilesPane.tsx +129 -40
  14. package/src/board/HtmlReadingPreview.tsx +52 -0
  15. package/src/board/MarkdownPreview.tsx +134 -0
  16. package/src/board/PanelControls.tsx +19 -7
  17. package/src/board/fileKind.ts +138 -0
  18. package/src/board/fileOps.ts +9 -2
  19. package/src/board/fileScope.ts +29 -0
  20. package/src/board/fileSync.ts +343 -0
  21. package/src/board/fileSyncCache.ts +250 -0
  22. package/src/board/fileSyncProcessor.ts +71 -0
  23. package/src/board/fileSyncQueue.ts +56 -0
  24. package/src/board/fileSyncTransport.ts +85 -0
  25. package/src/board/fileWatch.ts +177 -76
  26. package/src/board/folderDownload.ts +39 -5
  27. package/src/board/frameQuote.ts +10 -8
  28. package/src/board/htmlReading.ts +148 -0
  29. package/src/board/textLoad.ts +14 -39
  30. package/src/board/useBoardUpload.tsx +51 -0
  31. package/src/board/useBoardVisibility.ts +35 -0
  32. package/src/board/workspace.ts +38 -6
  33. package/src/chat/CliChat.tsx +86 -46
  34. package/src/chat/Composer.tsx +52 -20
  35. package/src/chat/GroupChat.tsx +113 -0
  36. package/src/chat/RunControls.tsx +132 -42
  37. package/src/chat/draft.ts +5 -2
  38. package/src/core/blocks.ts +2 -1
  39. package/src/core/client.ts +44 -57
  40. package/src/core/documentUpload.ts +196 -0
  41. package/src/core/store.ts +71 -14
  42. package/src/core/types.ts +1 -1
  43. package/src/index.ts +3 -0
  44. package/src/msglist/CliMsgList.tsx +9 -2
  45. package/src/operator/node/Chat.tsx +7 -8
  46. package/src/operator/node/Composer.tsx +99 -72
  47. package/src/operator/node/Orchestrator.tsx +1 -1
  48. package/src/operator/node/SessionRail.tsx +71 -32
  49. package/src/operator/node/SessionView.tsx +25 -25
  50. package/src/operator/node/Transcript.tsx +21 -9
  51. package/src/operator/node/boardBackend.ts +0 -19
  52. package/src/operator/node/boardEvents.ts +21 -3
  53. package/src/operator/node/boardState.ts +18 -1
  54. package/src/operator/node/boardWorkspace.ts +4 -1
  55. package/src/operator/node/composerDraft.ts +6 -2
  56. package/src/operator/node/format.ts +4 -2
  57. package/src/operator/node/liveStream.ts +14 -2
  58. package/src/operator/node/useTaskStream.ts +3 -1
  59. package/src/operator.base.css +141 -38
  60. package/src/operator.css +141 -38
  61. package/src/styles.css +248 -34
  62. package/src/ui/Markdown.tsx +2 -2
  63. package/src/ui/Popover.tsx +25 -12
  64. package/src/ui/QueuedMessages.tsx +69 -0
  65. package/src/ui/ReferenceCard.tsx +28 -0
  66. package/src/ui/TextBlock.tsx +6 -4
  67. package/src/ui/Transcript.tsx +20 -10
  68. package/src/ui/UploadProgress.tsx +46 -0
  69. package/src/ui/WorkspaceDrawer.tsx +64 -0
  70. package/src/ui/WorkspaceTabs.tsx +60 -0
  71. package/src/ui/format.ts +3 -1
  72. package/src/ui/reference.ts +40 -0
  73. package/src/ui/useChatScrollbar.ts +29 -0
  74. package/src/ui/useComposerAutosize.ts +54 -0
  75. package/dist/cli-ui-embed-BAqonuoD.js +0 -45952
@@ -5,6 +5,9 @@ import type {
5
5
  BatchStreamSnapshot, BrowserFramesState, NewConversationOptions, SendResult, StreamEnd, StreamItem, StreamSnapshot, WorkspaceFileBatch,
6
6
  } from "./types";
7
7
  import { decodeText } from "./textDecode";
8
+ import { createDocumentUploader, extractDocumentReferences, documentRequest, isTaskUploadPath, type UploadDocument, type DocumentUploadOptions, type DocumentReceipt } from "./documentUpload";
9
+ import { syncFile, type FileSyncTransport } from "../board/fileSync";
10
+ import { createFileSyncTransport } from "../board/fileSyncTransport";
8
11
 
9
12
  /**
10
13
  * center 服务(cli-task-center)对话任务接口的浏览器端客户端。组件的数据全部从这里来。
@@ -92,6 +95,8 @@ const ERROR_TEXT: Record<string, string> = {
92
95
  * 带 ? 的是可选能力:后端不提供,界面就按 Operator 在「没有这个能力」时的样子显示(例如提问的回答改走下一条消息)。
93
96
  */
94
97
  export interface ConversationBackend {
98
+ /** Upload original HTML/Markdown without embedding document bytes in a message. */
99
+ uploadDocument?: UploadDocument;
95
100
  baseUrl: string;
96
101
  getJob(jobId: string): Promise<CenterJob>;
97
102
  /** taskId:只要对话任务里这一个对话的轮次。 */
@@ -143,6 +148,15 @@ export interface ConversationBackend {
143
148
  * 方法里的 jobId 也可以是对话键("job_x#task_y"):看对话任务里那个对话的文件、改动和浏览器。
144
149
  */
145
150
  export interface BoardBackend {
151
+ /** Save an original file to this conversation without sending a message or starting the agent. */
152
+ uploadFile?(jobId: string, file: File, options: DocumentUploadOptions): Promise<DocumentReceipt>;
153
+ /** Authorized input directory of this task; browsing it never starts an agent turn. */
154
+ uploadDirectory?(jobId: string, signal?: AbortSignal): Promise<string | null>;
155
+ /** Immutable revisions and content-addressed blocks; refreshes reuse validated local blocks. */
156
+ fileSync?: FileSyncTransport;
157
+ /** One directory page only. No recursive scan or file body prefetch. */
158
+ listFiles?(jobId: string, dir: string, options?: { cursor?: string; limit?: number; signal?: AbortSignal }): Promise<DirListing>;
159
+ pollFiles?(jobId: string, scope: { paths: string[]; dirs: string[]; cursor?: string }, signal?: AbortSignal): Promise<WorkspaceFileBatch & { cursor?: string; directories: Array<{ path: string; revision: string | null }> }>;
146
160
  /** fileUrl 给出的地址浏览器能直接打开(不需要额外请求头)。false 时看板先 fetch 再显示。 */
147
161
  directLinks: boolean;
148
162
  info(jobId: string): Promise<BoardInfo>;
@@ -286,6 +300,7 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
286
300
 
287
301
  return {
288
302
  baseUrl: base,
303
+ uploadDocument: createDocumentUploader(doFetch, `${base}/api/file-sync`, headers),
289
304
  getJob: (jobId: string) => request<CenterJob>(`/api/jobs/${id(jobId)}`),
290
305
  listTurns: (jobId: string, query: { afterSeq?: number; limit?: number; taskId?: string } = {}) => {
291
306
  const params = new URLSearchParams();
@@ -326,7 +341,8 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
326
341
  ...(opts.callback ? { callback: opts.callback } : {}),
327
342
  tasks: [{
328
343
  title,
329
- prompt: firstMessage,
344
+ prompt: extractDocumentReferences(firstMessage).text,
345
+ ...(extractDocumentReferences(firstMessage).attachments ? { attachments: extractDocumentReferences(firstMessage).attachments } : {}),
330
346
  ...(opts.businessId ? { business_id: opts.businessId } : {}),
331
347
  ...(opts.agent ? { agent: opts.agent } : {}),
332
348
  ...(opts.timeoutSeconds ? { timeout_seconds: opts.timeoutSeconds } : {}),
@@ -339,7 +355,7 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
339
355
  /** 带 business_id 时 center 按它去重,网络超时可以放心重发同一条。 */
340
356
  sendMessage: (jobId: string, text: string, opts: { businessId?: string; timeoutSeconds?: number; taskId?: string } = {}) =>
341
357
  post<SendResult>(`/api/jobs/${id(jobId)}/messages`, {
342
- text,
358
+ ...extractDocumentReferences(text),
343
359
  ...(opts.taskId ? { task_id: opts.taskId } : {}),
344
360
  ...(opts.businessId ? { business_id: opts.businessId } : {}),
345
361
  ...(opts.timeoutSeconds ? { timeout_seconds: opts.timeoutSeconds } : {}),
@@ -347,7 +363,7 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
347
363
  continueTask: async (jobId: string, taskId: string, text: string, opts: { title?: string; businessId?: string } = {}) => {
348
364
  const result = await post<{ task_ids: string[] }>(`/api/jobs`, {
349
365
  job_id: jobId, mode: "conversation",
350
- tasks: [{ title: opts.title || firstLine(text, 60), prompt: text, continue_from: taskId, ...(opts.businessId ? { business_id: opts.businessId } : {}) }],
366
+ tasks: [{ title: opts.title || firstLine(text, 60), prompt: extractDocumentReferences(text).text, ...(extractDocumentReferences(text).attachments ? { attachments: extractDocumentReferences(text).attachments } : {}), continue_from: taskId, ...(opts.businessId ? { business_id: opts.businessId } : {}) }],
351
367
  });
352
368
  return { task_id: result.task_ids[0] };
353
369
  },
@@ -356,7 +372,7 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
356
372
  /** 同一个 business_id 重发只开一个对话。 */
357
373
  openConversation: (jobId: string, text: string, opts: { title?: string; agent?: string; browser?: boolean; businessId?: string; timeoutSeconds?: number } = {}) =>
358
374
  post<SendResult>(`/api/jobs/${id(jobId)}/conversations`, {
359
- text,
375
+ ...extractDocumentReferences(text),
360
376
  ...(opts.title ? { title: opts.title } : {}),
361
377
  ...(opts.agent ? { agent: opts.agent } : {}),
362
378
  ...(opts.browser !== undefined ? { browser: opts.browser } : {}),
@@ -534,31 +550,6 @@ function boardPath(key: string, rest: string, query = "") {
534
550
  return `/api/jobs/${job}/${rest}${params ? `?${params}` : ""}`;
535
551
  }
536
552
 
537
- /** Reject a malformed manifest as a whole: silently dropping entries could invent deletions. */
538
- function isWorkspaceFileBatch(value: unknown): value is WorkspaceFileBatch {
539
- if (!value || typeof value !== "object") return false;
540
- const batch = value as Record<string, unknown>;
541
- if (typeof batch.complete !== "boolean" || (batch.revision !== undefined && typeof batch.revision !== "string")) return false;
542
- const file = (value: unknown) => {
543
- if (!value || typeof value !== "object") return false;
544
- const entry = value as Record<string, unknown>;
545
- return typeof entry.name === "string" && typeof entry.path === "string" && !!entry.path
546
- && (entry.type === "file" || entry.type === "dir")
547
- && typeof entry.size === "number" && Number.isFinite(entry.size) && entry.size >= 0
548
- && typeof entry.mtime === "number" && Number.isFinite(entry.mtime)
549
- && (entry.changeToken === undefined || typeof entry.changeToken === "string");
550
- };
551
- if (batch.type === "snapshot") return Array.isArray(batch.files) && batch.files.every(file);
552
- if (batch.type === "resync") return batch.complete === false && typeof batch.reason === "string";
553
- return batch.type === "changes" && Array.isArray(batch.changes) && batch.changes.every(value => {
554
- if (!value || typeof value !== "object") return false;
555
- const change = value as Record<string, unknown>;
556
- return ["created", "modified", "deleted"].includes(String(change.kind)) && typeof change.path === "string" && !!change.path
557
- && (change.file === undefined || (file(change.file) && (change.file as { path: string }).path === change.path))
558
- && (change.previous === undefined || (file(change.previous) && (change.previous as { path: string }).path === change.path));
559
- });
560
- }
561
-
562
553
  function createBoardClient(
563
554
  base: string,
564
555
  headers: (extra?: Record<string, string>) => Record<string, string>,
@@ -698,37 +689,28 @@ function createBoardClient(
698
689
  const minting = new Map<string, Promise<string | null>>();
699
690
  let linksUnsupported = false;
700
691
  const LINK_REFRESH_MS = 5 * 60_000;
692
+ const uploads = new Map<string, UploadDocument>();
693
+ const fileSync = createFileSyncTransport(doFetch, (jobId, operation) => `${base}${boardPath(jobId, `file-sync/${operation}`)}`, headers);
701
694
  return {
702
695
  directLinks,
703
- info: (jobId) => request<BoardInfo>(boardPath(jobId, "board")),
704
- watchFiles(jobId, handlers) {
705
- let active = true;
706
- let stop: () => void = () => {};
707
- const close = (error: CenterError | null) => {
708
- if (!active) return;
709
- active = false; stop(); handlers.onClose(error);
710
- };
711
- const fetchStream: typeof fetch = async (input, init) => {
712
- const response = await doFetch(input, init);
713
- if (response.ok && !/^text\/event-stream(?:\s*;|$)/i.test(response.headers.get("content-type") ?? "")) {
714
- await response.body?.cancel().catch(() => undefined);
715
- throw new CenterError("文件事件接口未返回事件流,请检查服务代理配置", "INVALID_RESPONSE", 502);
716
- }
717
- return response;
718
- };
719
- stop = openSse(fetchStream, `${base}${boardPath(jobId, "files/events")}`, headers(), (event, data) => {
720
- if (!active) return;
721
- if (event === "files" || event === "message") {
722
- if (!isWorkspaceFileBatch(data)) { close(new CenterError("文件事件数据不完整,正在重新同步", "INVALID_RESPONSE", 502)); return; }
723
- handlers.onEvent(data);
724
- } else if (event === "end") {
725
- const end = data && typeof data === "object" ? data as Record<string, unknown> : {};
726
- close(new CenterError(typeof end.detail === "string" ? end.detail : "文件监听连接中断,正在重连", typeof end.reason === "string" ? end.reason : "NETWORK", typeof end.status === "number" ? end.status : 0));
727
- }
728
- }, close);
729
- return () => { active = false; stop(); };
696
+ uploadFile(jobId, file, options) {
697
+ let upload = uploads.get(jobId);
698
+ if (!upload) {
699
+ const { job, task } = boardTarget(jobId);
700
+ upload = createDocumentUploader(doFetch, `${base}/api/jobs/${job}${task ? `/tasks/${task}` : ""}/file-sync`, headers);
701
+ uploads.set(jobId, upload);
702
+ }
703
+ return upload(file, options);
730
704
  },
731
- stat: (jobId, path = ".") => request<DirListing | FileInfo>(boardPath(jobId, "files", `path=${encodeURIComponent(path)}`)),
705
+ async uploadDirectory(jobId, signal) {
706
+ const capabilities = await documentRequest<{ uploadsPath?: string }>(doFetch, `${base}${boardPath(jobId, "file-sync/capabilities")}`, { signal }, headers);
707
+ return typeof capabilities.uploadsPath === "string" ? capabilities.uploadsPath : null;
708
+ },
709
+ fileSync,
710
+ listFiles: (jobId, dir, options = {}) => documentRequest<DirListing>(doFetch, `${base}${boardPath(jobId, "file-sync/list", new URLSearchParams({ dir: dir || ".", limit: String(options.limit ?? 200), ...(options.cursor ? { cursor: options.cursor } : {}) }).toString())}`, { signal: options.signal }, headers),
711
+ info: (jobId) => request<BoardInfo>(boardPath(jobId, "board")),
712
+ pollFiles: (jobId, scope, signal) => documentRequest(doFetch, `${base}${boardPath(jobId, "file-sync/changes")}`, { method: "POST", signal, headers: { "Content-Type": "application/json" }, body: JSON.stringify(scope) }, headers),
713
+ stat: (jobId, path = ".") => documentRequest<DirListing | FileInfo>(doFetch, `${base}${boardPath(jobId, "file-sync/stat", `path=${encodeURIComponent(path)}`)}`, {}, headers),
732
714
  fileUrl: (jobId, path, opts = {}) => boardFileUrl(base, jobId, path, opts.download),
733
715
  async fileLinkBase(jobId) {
734
716
  const { job, task } = boardTarget(jobId);
@@ -761,6 +743,11 @@ function createBoardClient(
761
743
  return { bytes, start, size };
762
744
  },
763
745
  async readBuffer(jobId, path, opts = {}) {
746
+ if (isTaskUploadPath(path)) {
747
+ const file = await syncFile(fileSync, jobId, path, { signal: opts.signal, decode: false,
748
+ onProgress: progress => opts.onBytes?.(progress.loadedBytes) });
749
+ return file.bytes.buffer as ArrayBuffer;
750
+ }
764
751
  const { body } = await fileFetch(jobId, path, {}, opts.signal);
765
752
  return (await body({ onBytes: opts.onBytes })).buffer;
766
753
  },
@@ -0,0 +1,196 @@
1
+ import { createFileWorkQueue } from "../board/fileSyncQueue";
2
+ /** Original attachments. The message contains an authorized reference only. */
3
+ export const DOCUMENT_MAX_BYTES = 100 * 1024 * 1024;
4
+ export const UPLOAD_OTHER_MAX_BYTES = 10 * 1024 * 1024;
5
+ export const DOCUMENT_CHUNK_BYTES = 1024 * 1024;
6
+ export const isOriginalDocument = (name: string) => /\.(html?|md|markdown)$/i.test(name);
7
+ export const isTaskUploadPath = (path: string) => /\/(?:uploads|attachments)\/[^/]+\/[^/]+$/.test(path.replace(/\\/g, "/"));
8
+ const UPLOAD_TYPES: Record<string, string> = {
9
+ html: "text/html", htm: "text/html", md: "text/markdown", markdown: "text/markdown",
10
+ txt: "text/plain", log: "text/plain", yaml: "text/yaml", yml: "text/yaml", csv: "text/csv", tsv: "text/tab-separated-values", json: "application/json",
11
+ js: "text/plain", jsx: "text/plain", mjs: "text/plain", cjs: "text/plain", ts: "text/plain", tsx: "text/plain", mts: "text/plain", cts: "text/plain", py: "text/plain", sh: "text/plain", css: "text/plain", mjsx: "text/plain", cjsx: "text/plain", mtsx: "text/plain", ctsx: "text/plain",
12
+ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp",
13
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
14
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", xlsm: "application/vnd.ms-excel.sheet.macroEnabled.12",
15
+ };
16
+ export const FILE_UPLOAD_ACCEPT = Object.keys(UPLOAD_TYPES).map(extension => `.${extension}`).join(",");
17
+ export const uploadFileType = (name: string) => {
18
+ const dot = name.lastIndexOf(".");
19
+ const extension = dot > 0 ? name.slice(dot + 1).toLowerCase() : undefined;
20
+ return extension && Object.hasOwn(UPLOAD_TYPES, extension) ? UPLOAD_TYPES[extension] : undefined;
21
+ };
22
+ export type DocumentReceipt = { uploadId: string; draftId: string; name: string; size: number; sha256: string; path?: string; url?: string };
23
+ export type UploadPhase = "queued" | "checking" | "uploading" | "verifying";
24
+ export type DocumentUploadOptions = { draftId: string; signal?: AbortSignal; onBytes?: (bytes: number) => void; onPhase?: (phase: UploadPhase) => void };
25
+ export type UploadDocument = (file: File, options: DocumentUploadOptions) => Promise<DocumentReceipt>;
26
+ const aborted = () => new DOMException("上传已取消", "AbortError");
27
+ function pause(ms: number, signal?: AbortSignal) {
28
+ return new Promise<void>((resolve, reject) => {
29
+ if (signal?.aborted) return reject(aborted());
30
+ const stop = () => { clearTimeout(timer); reject(aborted()); };
31
+ const timer = setTimeout(() => { signal?.removeEventListener("abort", stop); resolve(); }, ms);
32
+ signal?.addEventListener("abort", stop, { once: true });
33
+ });
34
+ }
35
+
36
+ /** Blob cloning is cheap; hashing a 100 MiB file does not copy it through the UI thread. */
37
+ export async function hashDocument(file: Blob, signal?: AbortSignal): Promise<string> {
38
+ if (signal?.aborted) throw aborted();
39
+ if (typeof Worker !== "undefined" && typeof URL.createObjectURL === "function") {
40
+ const source = `onmessage=async e=>{try{const bytes=await e.data.arrayBuffer();const hash=await crypto.subtle.digest('SHA-256',bytes);postMessage({hash:Array.from(new Uint8Array(hash),b=>b.toString(16).padStart(2,'0')).join('')});}catch(e){postMessage({error:String(e)})}}`;
41
+ const url = URL.createObjectURL(new Blob([source], { type: "text/javascript" }));
42
+ let worker: Worker | undefined;
43
+ try {
44
+ worker = new Worker(url);
45
+ return await new Promise<string>((resolve, reject) => {
46
+ const cleanup = () => { clearTimeout(timer); signal?.removeEventListener("abort", stop); worker!.onmessage = null; worker!.onerror = null; };
47
+ const stop = () => { cleanup(); reject(aborted()); };
48
+ const timer = setTimeout(() => { cleanup(); reject(Object.assign(new Error("文件校验超时,请重试"), { status: 408, code: "hash_timeout" })); }, 120_000);
49
+ signal?.addEventListener("abort", stop, { once: true });
50
+ worker!.onmessage = e => { cleanup(); e.data?.hash ? resolve(e.data.hash) : reject(new Error(e.data?.error || "文件校验失败,请重试")); };
51
+ worker!.onerror = () => { cleanup(); reject(new Error("文件校验失败,请重试")); };
52
+ if (signal?.aborted) { stop(); return; }
53
+ try { worker!.postMessage(file); } catch (error) { cleanup(); reject(error); }
54
+ });
55
+ } finally { worker?.terminate(); URL.revokeObjectURL(url); }
56
+ }
57
+ const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
58
+ if (signal?.aborted) throw aborted();
59
+ return Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, "0")).join("");
60
+ }
61
+
62
+ /** All calls are bounded short requests, including body reads. */
63
+ export async function documentRequest<T>(fetcher: typeof fetch, url: string, init: RequestInit = {}, headers: () => Record<string, string> = () => ({})): Promise<T> {
64
+ const ctl = new AbortController();
65
+ const signal = init.signal;
66
+ const cancel = () => ctl.abort();
67
+ if (signal?.aborted) throw aborted();
68
+ signal?.addEventListener("abort", cancel, { once: true });
69
+ const timer = setTimeout(cancel, 30_000);
70
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
71
+ let rejectAbort: (() => void) | undefined;
72
+ try {
73
+ const cancelled = new Promise<never>((_resolve, reject) => { rejectAbort = () => reject(aborted()); ctl.signal.addEventListener("abort", rejectAbort, { once: true }); });
74
+ return await Promise.race([cancelled, (async () => {
75
+ const response = await fetcher(url, { ...init, cache: "no-store", signal: ctl.signal, headers: { ...headers(), ...init.headers } });
76
+ if (ctl.signal.aborted) { void response.body?.cancel().catch(() => undefined); throw aborted(); }
77
+ reader = response.body?.getReader();
78
+ let raw = "", bytes = 0;
79
+ if (reader) {
80
+ const decoder = new TextDecoder();
81
+ for (;;) {
82
+ const part = await reader.read(); if (part.done) break;
83
+ bytes += part.value.byteLength;
84
+ if (bytes > 2 * 1024 * 1024) throw Object.assign(new Error("文件响应超过协议上限"), { status: 502, code: "response_too_large" });
85
+ raw += decoder.decode(part.value, { stream: true });
86
+ }
87
+ raw += decoder.decode();
88
+ } else raw = await response.text();
89
+ let value: Record<string, unknown>;
90
+ try { value = raw ? JSON.parse(raw) : {}; } catch { throw Object.assign(new Error("文件接口返回的数据无效"), { status: 502, code: "invalid_response" }); }
91
+ const retryHeader = response.headers.get("retry-after");
92
+ const retryHint = retryHeader ? (/^\d+(\.\d+)?$/.test(retryHeader) ? Number(retryHeader) * 1000 : Date.parse(retryHeader) - Date.now()) : undefined;
93
+ const retryAfterMs = retryHint !== undefined && Number.isFinite(retryHint) ? Math.max(0, retryHint) : undefined;
94
+ if (response.status === 202) throw Object.assign(new Error("文件正在校验"), { status: 202, retryAfterMs: retryAfterMs ?? 1000 });
95
+ if (!response.ok) {
96
+ const missing = value.error === "file_not_found" ? "文件不存在(可能已被删除或改名)" : "服务端尚未支持原件上传或增量文件同步,请升级节点和中枢";
97
+ const common: Record<string, string> = { file_sync_busy: "文件服务繁忙,请稍后重试", too_many_snapshot_builds: "文件服务繁忙,请稍后重试", too_many_uploads: "上传任务较多,请稍后重试", file_sync_queue_timeout: "文件处理排队超时,请稍后重试", file_sync_timeout: "文件服务响应超时,请重试", snapshot_cache_full: "文件缓存空间不足,请稍后重试", upload_storage_full: "文件上传空间不足,请稍后重试" };
98
+ const error = new Error(common[String(value.error)] ?? String(value.detail || (response.status === 404 ? missing : value.error) || `文件请求失败(HTTP ${response.status})`));
99
+ Object.assign(error, { status: response.status, code: value.error, ...(retryAfterMs !== undefined ? { retryAfterMs } : {}) });
100
+ throw error;
101
+ }
102
+ return value as T;
103
+ })()]);
104
+ } catch (error) {
105
+ if (signal?.aborted) throw aborted();
106
+ if (ctl.signal.aborted) throw Object.assign(new Error("文件请求超时,请重试"), { status: 0, code: "timeout" });
107
+ throw error;
108
+ } finally { clearTimeout(timer); signal?.removeEventListener("abort", cancel); if (rejectAbort) ctl.signal.removeEventListener("abort", rejectAbort); ctl.abort(); void reader?.cancel().catch(() => undefined); }
109
+ }
110
+
111
+ const uploadQueues = new WeakMap<typeof fetch, ReturnType<typeof createFileWorkQueue>>();
112
+ async function retryUploadRequest<T>(work: () => Promise<T>, signal: AbortSignal | undefined, busyOnly = false, deadline = Date.now() + 180_000): Promise<T> {
113
+ for (let attempt = 0; ; attempt++) {
114
+ if (signal?.aborted) throw aborted();
115
+ try { return await work(); } catch (error) {
116
+ const failure = error as { status?: number; code?: string; name?: string; retryAfterMs?: number };
117
+ const status = failure.status ?? 0;
118
+ const retryable = busyOnly ? status === 429 : !status || [408, 429, 500, 502, 503, 504].includes(status);
119
+ if (signal?.aborted || failure.name === "AbortError" || !retryable || attempt >= 3 || ["invalid_response", "response_too_large", "integrity", "client_queue_full"].includes(failure.code ?? "")) throw error;
120
+ const base = 500 * 2 ** attempt, hint = Number(failure.retryAfterMs ?? 0);
121
+ const delay = Math.max(base, Number.isFinite(hint) ? hint : 0) + Math.floor(Math.random() * base / 4);
122
+ if (delay > deadline - Date.now()) throw error;
123
+ await pause(delay, signal);
124
+ }
125
+ }
126
+ }
127
+ export function createDocumentUploader(fetcher: typeof fetch, base: string, headers: () => Record<string, string> = () => ({})): UploadDocument {
128
+ // Share hashing/upload slots across tasks using this client, with cancellable bounded waiters.
129
+ let queue = uploadQueues.get(fetcher);
130
+ if (!queue) { queue = createFileWorkQueue(1, 128); uploadQueues.set(fetcher, queue); }
131
+ return (file, options) => {
132
+ options.onPhase?.("queued");
133
+ const upload = async (): Promise<DocumentReceipt> => {
134
+ const { signal, onBytes, onPhase, draftId } = options;
135
+ if (signal?.aborted) throw aborted();
136
+ const type = uploadFileType(file.name);
137
+ if (!type) throw new Error("暂不支持此文件类型,请选择文档、图片或文本文件");
138
+ const maxBytes = isOriginalDocument(file.name) ? DOCUMENT_MAX_BYTES : UPLOAD_OTHER_MAX_BYTES;
139
+ if (file.size > maxBytes) throw new Error(`“${file.name}”超过 ${isOriginalDocument(file.name) ? "100" : "10"} MiB,请缩小后重试。`);
140
+ if (new TextEncoder().encode(file.name).length > 240) throw new Error("文件名超过 240 个 UTF-8 字节,请缩短文件名后重试。");
141
+ onPhase?.("checking");
142
+ const sha256 = await hashDocument(file, signal);
143
+ const request = <T>(path: string, init: RequestInit) => documentRequest<T>(fetcher, `${base}${path}`, { ...init, signal }, headers);
144
+ const session = await retryUploadRequest(() => request<{ uploadId: string; chunkBytes: number }>("/uploads", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ draftId, name: file.name, size: file.size, type, sha256 }) }), signal, true);
145
+ if (!session.uploadId || session.chunkBytes !== DOCUMENT_CHUNK_BYTES) throw new Error("上传协议不匹配,请升级服务端");
146
+ const root = `/uploads/${encodeURIComponent(session.uploadId)}`;
147
+ try {
148
+ onPhase?.("uploading");
149
+ onBytes?.(0);
150
+ for (let start = 0, index = 0; start < file.size; start += session.chunkBytes, index++) {
151
+ const body = file.slice(start, Math.min(file.size, start + session.chunkBytes));
152
+ // Only these numbered chunks are safely idempotent after ambiguous network failures.
153
+ await retryUploadRequest(() => request(`${root}/chunks/${index}`, { method: "PUT", headers: { "Content-Type": "application/octet-stream" }, body }), signal);
154
+ onBytes?.(Math.min(file.size, start + session.chunkBytes));
155
+ }
156
+ onPhase?.("verifying");
157
+ const deadline = Date.now() + 180_000;
158
+ for (;;) {
159
+ try {
160
+ const result = await retryUploadRequest(() => request<DocumentReceipt>(`${root}/complete`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ draftId }) }), signal, false, deadline);
161
+ if (result.sha256 !== sha256) throw new Error("上传文件校验不一致,请重试");
162
+ return { ...result, draftId, name: file.name, size: file.size };
163
+ } catch (error) {
164
+ const pending = error as { status?: number; retryAfterMs?: number };
165
+ if (pending.status !== 202 || Date.now() >= deadline) throw error;
166
+ const delay = Math.max(100, pending.retryAfterMs ?? 1000);
167
+ if (delay > deadline - Date.now()) throw error;
168
+ await pause(delay, signal);
169
+ }
170
+ }
171
+ } catch (error) {
172
+ void documentRequest(fetcher, `${base}${root}`, { method: "DELETE" }, headers).catch(() => undefined);
173
+ throw error;
174
+ }
175
+ };
176
+ return queue.run(upload, options.signal);
177
+ };
178
+ }
179
+
180
+ export function documentMarker(receipt: DocumentReceipt) {
181
+ if (receipt.path) {
182
+ if (/[\r\n\]]/.test(receipt.path)) throw new Error("上传文件路径无效,请重试");
183
+ return `[Attached ${uploadFileType(receipt.name)?.startsWith("image/") ? "image" : "file"}: ${receipt.path}]`;
184
+ }
185
+ const name = receipt.name.replace(/[\r\n\[\]]/g, " ");
186
+ return `[文件原件:${name}](cli-upload://${encodeURIComponent(receipt.draftId)}/${encodeURIComponent(receipt.uploadId)})`;
187
+ }
188
+
189
+ export function extractDocumentReferences(text: string) {
190
+ const attachments: Array<{ uploadId: string; draftId: string }> = [];
191
+ const clean = text.replace(/\[文件原件:([^\]\r\n]*)\]\(cli-upload:\/\/([^/\s)]+)\/([^\s)]+)\)/g, (_match, name, draft, upload) => {
192
+ try { attachments.push({ draftId: decodeURIComponent(draft), uploadId: decodeURIComponent(upload) }); } catch { throw new Error("文件引用无效,请移除后重新上传"); }
193
+ return `[文件原件:${name}]`;
194
+ });
195
+ return { text: clean, ...(attachments.length ? { attachments } : {}) };
196
+ }
package/src/core/store.ts CHANGED
@@ -19,6 +19,7 @@ import { formatAnswersText } from "../ui/format";
19
19
  import { backgroundBlock, charCount, MAX_MESSAGE_CHARS, serializeBlock } from "./blocks";
20
20
  import { cloneBoardView, reportBoardOwner, activateBoardOwner, selectedBoardOwner, mergeBoardHistory, rememberBoardFile } from "../board/context";
21
21
  import { cloneWorkspaceChange, subscribeWorkspaceFiles, retryWorkspaceFiles, type WorkspaceWatchOptions } from "../board/fileWatch";
22
+ import { retainFileScope } from "../board/fileScope";
22
23
  import type { WorkspaceChangeEvent, WorkspaceWatchStatus } from "./types";
23
24
  import { createWorkspace, type BoardView, type CliWorkspace } from "../board/workspace";
24
25
  import type { AskAnswers, AskQuestion, CenterJob, CenterTurn, ConversationClosedCallback, ConversationTurnCallback, NewConversationOptions } from "./types";
@@ -52,6 +53,12 @@ export interface CliState {
52
53
  viewing: Record<string, { ref: BoardRef; off: boolean }>;
53
54
  /** 看板现在的样子(按对话键):正在看的文件、开着的文件页签、当前页签。没有看板时没有。 */
54
55
  boards: Record<string, BoardView>;
56
+ /** 当前对话是否有展开且可见的外置看板;Chat 内嵌看板不计入。 */
57
+ externalBoards: Record<string, boolean>;
58
+ /** 同一工作区中是否有展开且可见的对话列表。 */
59
+ listVisible: boolean;
60
+ /** 外置看板的模块和只读限制,内嵌视图沿用同一约束。 */
61
+ externalBoardOptions: Record<string, { availableTabs: ("files" | "changes" | "browser")[]; readOnly: boolean }>;
55
62
  /** 最近看板背景,收起/卸载后保留,供下一条消息被动参考。 */
56
63
  boardContext: Record<string, BoardView>;
57
64
  /** 业务方补充的项目背景(按对话键;"" = 还没建的新对话),随下一条消息发出(见 appendContext)。 */
@@ -222,7 +229,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
222
229
  // 列表只放宿主给的 jobIds 和本页里新建 / 接续出来的对话:不从 center 发现,也不从本地存储恢复——
223
230
  // 不传就不加载,免得把这个产品线令牌下别的业务、别的用户的对话列出来。
224
231
 
225
- let state: CliState = { selected: null, jobIds: [], jobs: {}, convs: {}, listLoading: false, listError: null, refs: {}, viewing: {}, boards: {}, boardContext: {}, context: {} };
232
+ let state: CliState = { selected: null, jobIds: [], jobs: {}, convs: {}, listLoading: false, listError: null, refs: {}, viewing: {}, boards: {}, externalBoards: {}, listVisible: false, externalBoardOptions: {}, boardContext: {}, context: {} };
226
233
  const listeners = new Set<() => void>();
227
234
  const eventListeners = new Set<(event: CliEvent) => void>();
228
235
  const settledTurns = new Set<string>();
@@ -392,10 +399,11 @@ export function createCliStore(options: CliStoreOptions = {}) {
392
399
  previous?.();
393
400
  if (streams.get(key) !== entry || entry.refs <= 0) return;
394
401
  const { jobId, taskId } = parseConvKey(key);
395
- let ended = false, retired = false;
402
+ let ended = false, retired = false, snapshotReceived = false;
396
403
  let cleanup: (() => void) | null = null;
397
404
  const owns = () => streams.get(key) === entry && entry.refs > 0 && entry.stop === retire;
398
405
  const active = () => !retired && owns();
406
+ const refreshFiles = () => { if (active() && client.board) retryWorkspaceFiles(client.board, key); };
399
407
  const retire = () => {
400
408
  if (retired) return;
401
409
  retired = true;
@@ -435,13 +443,37 @@ export function createCliStore(options: CliStoreOptions = {}) {
435
443
  try {
436
444
  cleanup = client.stream(jobId, {
437
445
  onOpen: () => { if (!active()) return; entry.attempt = 0; patchConv(key, (conv) => ({ ...conv, stream: "live", streamError: null })); },
438
- onSnapshot: (snapshot) => { if (active()) patchConv(key, (conv) => applySnapshot(conv, snapshot)); },
439
- onChunk: (items) => { if (active()) patchConv(key, (conv) => applyItems(conv, items)); },
440
- onEnd: (end) => {
446
+ onSnapshot: (snapshot) => {
447
+ if (!active()) return;
448
+ patchConv(key, (conv) => applySnapshot(conv, snapshot));
449
+ // Each connection catches up once, including rounds completed while disconnected.
450
+ if (!snapshotReceived) { snapshotReceived = true; refreshFiles(); }
451
+ },
452
+ onChunk: (items) => {
441
453
  if (!active()) return;
454
+ let refresh = false;
455
+ patchConv(key, (conv) => {
456
+ let next = conv;
457
+ for (const item of items) {
458
+ const previous = next;
459
+ next = applyItems(next, [item]);
460
+ // applyItems also rejects events from another batch task's node session.
461
+ if (next === previous) continue;
462
+ // Observe every event: end + next user in one chunk can leave running=true.
463
+ if (item.type === "node" && item.event.type === "turn_end" || item.type === "transcript") refresh = true;
464
+ if (item.type === "turn" && item.turn.status !== "merged" && TERMINAL_TURN.has(item.turn.status)
465
+ && !TERMINAL_TURN.has(previous.turns.find((turn) => turn.turn_id === item.turn.turn_id)?.status ?? "")) refresh = true;
466
+ }
467
+ return next;
468
+ });
469
+ if (refresh) refreshFiles();
470
+ },
471
+ onEnd: (end) => {
472
+ if (!active() || ended) return;
442
473
  ended = true;
443
474
  const continuedBy = taskId ? (end.continued_by_job_id && end.continued_by_task_id ? convKey(end.continued_by_job_id, end.continued_by_task_id) : null) : end.continued_by_job_id ?? null;
444
475
  patchConv(key, (conv) => ({ ...conv, stream: "ended", endReason: end.reason, continuedBy, nodeRunning: false }));
476
+ refreshFiles();
445
477
  void refreshJob(jobId, true);
446
478
  },
447
479
  onClose: close,
@@ -727,7 +759,28 @@ export function createCliStore(options: CliStoreOptions = {}) {
727
759
  }
728
760
 
729
761
  // 同一个仓库里登记的看板(cli-board)。后登记的优先(例如弹出的看板盖在常驻的上面)。
730
- const surfaces: BoardSurface[] = [];
762
+ const surfaces: { surface: BoardSurface; owner?: string; inline?: boolean; priority?: () => number }[] = [];
763
+ const visibleLists = new Set<string>();
764
+ function reportListPresence(owner: string, visible: boolean) {
765
+ if (visible) visibleLists.add(owner); else visibleLists.delete(owner);
766
+ const listVisible = visibleLists.size > 0;
767
+ if (state.listVisible !== listVisible) set({ listVisible });
768
+ }
769
+ const externalBoards = new Map<string, Map<string, { visible: boolean; availableTabs?: readonly ("files" | "changes" | "browser")[]; readOnly?: boolean }>>();
770
+ function reportBoardPresence(key: string, owner: string, visible: boolean | null, options: { availableTabs?: readonly ("files" | "changes" | "browser")[]; readOnly?: boolean } = {}) {
771
+ const owners = externalBoards.get(key) ?? new Map<string, { visible: boolean; availableTabs?: readonly ("files" | "changes" | "browser")[]; readOnly?: boolean }>();
772
+ if (visible === null) owners.delete(owner); else owners.set(owner, { visible, ...options });
773
+ if (owners.size) externalBoards.set(key, owners); else externalBoards.delete(key);
774
+ const values = [...owners.values()];
775
+ const shown = values.some(value => value.visible);
776
+ const limits = owners.size ? { availableTabs: (["files", "changes", "browser"] as const).filter(tab => values.every(value => !value.availableTabs || value.availableTabs.includes(tab))), readOnly: values.some(value => value.readOnly) } : undefined;
777
+ if (!!state.externalBoards[key] === shown && JSON.stringify(state.externalBoardOptions[key]) === JSON.stringify(limits)) return;
778
+ const next = { ...state.externalBoards };
779
+ if (shown) next[key] = true; else delete next[key];
780
+ const configurations = { ...state.externalBoardOptions };
781
+ if (limits) configurations[key] = limits; else delete configurations[key];
782
+ set({ externalBoards: next, externalBoardOptions: configurations });
783
+ }
731
784
  // ── 看板 → 对话的引用 ─────────────────────────────
732
785
 
733
786
  function setRefs(key: string, refs: BoardRef[]) {
@@ -821,12 +874,13 @@ export function createCliStore(options: CliStoreOptions = {}) {
821
874
  const fileSubscriptions = new Map<string,{listeners:Set<{change?:(event:WorkspaceChangeEvent)=>void;status?:WorkspaceWatchOptions['onStatus']}>;stop:()=>void;status?:WorkspaceWatchStatus}>();
822
875
  function watchWorkspace(key:string,listener?:(event:WorkspaceChangeEvent)=>void,options:WorkspaceWatchOptions={}) {
823
876
  if(!client.board) throw new Error("当前后端不支持工作区文件订阅");
877
+ const releaseScope=options.paths?.length||options.dirs?.length?retainFileScope(client.board,key,{paths:[...options.paths??[]],dirs:[...options.dirs??[]]}):()=>{};
824
878
  const item={change:listener,status:options.onStatus};let subscription=fileSubscriptions.get(key);
825
879
  if(!subscription){
826
880
  subscription={listeners:new Set([item]),stop:()=>{}};fileSubscriptions.set(key,subscription);const owned=subscription;
827
- owned.stop=subscribeWorkspaceFiles(client.board,key,event=>{emit(event);for(const next of [...owned.listeners])notifyCallback(next.change,cloneWorkspaceChange(event));},{...options,onStatus:status=>{owned.status=status;emit(status);for(const next of [...owned.listeners])notifyCallback(next.status,{...status});}});
881
+ owned.stop=subscribeWorkspaceFiles(client.board,key,event=>{emit(event);for(const next of [...owned.listeners])notifyCallback(next.change,cloneWorkspaceChange(event));},{intervalMs:options.intervalMs,onStatus:status=>{owned.status=status;emit(status);for(const next of [...owned.listeners])notifyCallback(next.status,{...status});}});
828
882
  }else{subscription.listeners.add(item);if(subscription.status)notifyCallback(item.status,{...subscription.status});retryWorkspaceFiles(client.board,key);}
829
- const owned=subscription;let active=true;return()=>{if(!active)return;active=false;owned.listeners.delete(item);if(!owned.listeners.size){if(fileSubscriptions.get(key)===owned)fileSubscriptions.delete(key);owned.stop();}};
883
+ const owned=subscription;let active=true;return()=>{if(!active)return;active=false;owned.listeners.delete(item);if(!owned.listeners.size){if(fileSubscriptions.get(key)===owned)fileSubscriptions.delete(key);owned.stop();}releaseScope();};
830
884
  }
831
885
 
832
886
  // ── 补充背景:业务方随时追加,随下一条消息发给智能体 ────────
@@ -890,9 +944,11 @@ export function createCliStore(options: CliStoreOptions = {}) {
890
944
  if (ref && cur && cur.ref.id === ref.id && cur.off) setViewingState(key, { ...cur, off: false });
891
945
  }
892
946
 
893
- function openInBoard(jobId: string, target: OpenTarget): boolean {
947
+ function openInBoard(jobId: string, target: OpenTarget, preferredInlineOwner?: string): boolean {
894
948
  let handled = false;
895
- for (const surface of [...surfaces].reverse()) {
949
+ const available = [...surfaces].reverse().filter(item => !preferredInlineOwner || !item.inline || item.owner === preferredInlineOwner)
950
+ .sort((a, b) => (b.priority?.() ?? 2) - (a.priority?.() ?? 2));
951
+ for (const { surface } of available) {
896
952
  try { handled = surface(jobId, { ...target }); }
897
953
  catch (error) { console.error("[@spzxg/cli-ui] board surface callback failed", error); }
898
954
  if (handled) break;
@@ -908,9 +964,10 @@ export function createCliStore(options: CliStoreOptions = {}) {
908
964
  /** 在看板里打开文件 / 浏览器 / 改动。返回有没有看板接住。 */
909
965
  openInBoard,
910
966
  /** 看板登记自己;返回注销函数。 */
911
- registerBoard(surface: BoardSurface) {
912
- surfaces.push(surface);
913
- return () => { const i = surfaces.indexOf(surface); if (i >= 0) surfaces.splice(i, 1); };
967
+ registerBoard(surface: BoardSurface, options: { owner?: string; inline?: boolean; priority?: () => number } = {}) {
968
+ const entry = { surface, ...options };
969
+ surfaces.push(entry);
970
+ return () => { const i = surfaces.indexOf(entry); if (i >= 0) surfaces.splice(i, 1); };
914
971
  },
915
972
  subscribe(fn: () => void) { listeners.add(fn); return () => { listeners.delete(fn); }; },
916
973
  on(fn: (event: CliEvent) => void) { eventListeners.add(fn); return () => { eventListeners.delete(fn); }; },
@@ -923,7 +980,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
923
980
  addRef, removeRef, takeRefs, restoreRefs,
924
981
  setViewing, dismissViewing, takeViewing, restoreViewing,
925
982
  /** 看板报告自己的样子(看板组件用)。 */
926
- reportBoard, activateBoard,
983
+ reportBoard, activateBoard, reportBoardPresence, reportListPresence,
927
984
  /** 业务方追加项目背景:随下一条消息发给智能体。 */
928
985
  appendContext, removeContext, pendingContext,
929
986
  /** 工作区文件接口(不显示看板也能用):列文件、读文件、下载、打包。不传 = 当前选中的对话。 */
package/src/core/types.ts CHANGED
@@ -400,7 +400,7 @@ export interface FileEntry {
400
400
  changeToken?: string;
401
401
  }
402
402
 
403
- export interface DirListing { type: "dir"; path: string; entries: FileEntry[]; truncated: boolean; /** 目录里的条目总数(新版节点给)。 */ total?: number }
403
+ export interface DirListing { type: "dir"; path: string; entries: FileEntry[]; truncated: boolean; /** 目录里的条目总数(新版节点给)。 */ total?: number; nextCursor?: string; revision?: string }
404
404
  export interface FileInfo { type: "file"; path: string; name: string; size: number; mtime: number; contentType: string; inline: boolean }
405
405
 
406
406
  export interface DiffFile {
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { CliMsgList, CliChat, CliBoard, type CliMsgListProps, type CliChatProps, type CliBoardProps } from "./components";
2
+ export type { GroupChatAdapter, GroupChatMessage, GroupChatReference } from "./chat/GroupChat";
2
3
  /**
3
4
  * @spzxg/cli-ui —— 任务中心(cli-task-center)对话任务的前端积木,数据全部来自 center 服务。
4
5
  *
@@ -50,3 +51,5 @@ export { formatBoardContext, appendBoardContext, BOARD_HISTORY_MAX } from "./boa
50
51
  export { subscribeWorkspaceFiles, type WorkspaceWatchOptions } from "./board/fileWatch";
51
52
  export type { WorkspaceHandlers } from "./core/provider";
52
53
  export type { BoardFile } from "./board/workspace";
54
+
55
+ export { syncFile, clearFileSyncNamespace, FILE_SYNC_MAX, type FileSyncTransport, type FileSyncManifest, type FileSyncProgress } from "./board/fileSync";
@@ -1,7 +1,7 @@
1
1
  // cli-msglist:消息对话列表。界面取自 Operator 的任务栏(TasksColumn 的列表视图,不做看板),
2
2
  // 一张卡片是 center 里的一个对话任务(mode=conversation)。对话任务里开了几个对话时,卡片下面按对话展开,
3
3
  // 点哪个对话选哪个(选中值是对话键 "job#task");卡片上的「+」在这个对话任务里新开一个对话(同一个工作区)。
4
- import { useEffect, useMemo, useState, type CSSProperties } from "react";
4
+ import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
5
5
  import { CliErrorBoundary } from "../ui/ErrorBoundary";
6
6
  import type { CliEvent, JobEntry } from "../core/store";
7
7
  import type { CliStoreOptions, CliStore } from "../core/store";
@@ -12,6 +12,7 @@ import { Icon } from "../ui/icons";
12
12
  import { agentLabel, relTime, toMs } from "../ui/format";
13
13
  import { AgentBadge, ErrNote, Skel, StatusDot, type DotCls } from "../ui/shared";
14
14
  import { resolveMsgListConfig, type CliMsgListConfig, type ResolvedMsgListConfig } from "../core/config";
15
+ import { useBoardVisibility } from "../board/useBoardVisibility";
15
16
 
16
17
  /** onTurnEnd / onConversationEnd:浏览器里从实时流算出的结果通知,不依赖 center 的 callback.url。 */
17
18
  export interface CliMsgListProps extends CliStoreOptions, ResultHandlers {
@@ -284,6 +285,12 @@ function MsgListInner({ jobIds, selectedId, onSelect, onNew, title = "对话", c
284
285
  const cfg = useMemo(() => resolveMsgListConfig(config), [configKey]); // eslint-disable-line react-hooks/exhaustive-deps
285
286
  const h = cfg.header;
286
287
  const store = useCliStore();
288
+ const rootRef = useRef<HTMLDivElement>(null);
289
+ const owner = useId();
290
+ const visible = useBoardVisibility(rootRef, true);
291
+ const expanded = visible && !className?.split(/\s+/).includes("collapsed");
292
+ useLayoutEffect(() => { store.reportListPresence(owner, expanded); }, [store, owner, expanded]);
293
+ useLayoutEffect(() => () => { store.reportListPresence(owner, false); }, [store, owner]);
287
294
  const ids = useCliState((s) => s.jobIds);
288
295
  const jobs = useCliState((s) => s.jobs);
289
296
  const shared = useCliState((s) => s.selected);
@@ -343,7 +350,7 @@ function MsgListInner({ jobIds, selectedId, onSelect, onNew, title = "对话", c
343
350
  const showBanner = showRow || showContext;
344
351
 
345
352
  return (
346
- <div className={`cli-ui cli-msglist ${className ?? ""}`} data-theme={theme} style={{ ...style, ...(width !== undefined ? { width } : null) }}>
353
+ <div ref={rootRef} className={`cli-ui cli-msglist ${className ?? ""}`} data-theme={theme} style={{ ...style, ...(width !== undefined ? { width } : null) }}>
347
354
  <div className="col col-tasks">
348
355
  {showBanner && <div className="proj-banner">
349
356
  {showRow && <div className="pb-row">