@spzxg/cli-ui 0.6.6 → 0.6.8

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 (79) hide show
  1. package/CHANGELOG.md +40 -1
  2. package/README.md +90 -21
  3. package/dist/cli-ui-embed-Be9_d9jB.js +49691 -0
  4. package/dist/{cli-ui-index-b8wF7wrN.js → cli-ui-index-CzNfiynu.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-B0uLrCHg.js → cli-ui-jszip.min-BL0mSekN.js} +2 -2
  6. package/dist/{cli-ui-jszip.min-ggP8OokP.js → cli-ui-jszip.min-DjCIAfse.js} +1 -1
  7. package/dist/{cli-ui-xlsx-DEDdHrJJ.js → cli-ui-xlsx-CscI4xk0.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 +121 -51
  34. package/src/chat/Composer.tsx +54 -22
  35. package/src/chat/GroupChat.tsx +113 -0
  36. package/src/chat/RunControls.tsx +143 -46
  37. package/src/chat/draft.ts +12 -4
  38. package/src/chat/useExecutionConfig.ts +93 -0
  39. package/src/components.tsx +1 -1
  40. package/src/core/blocks.ts +2 -1
  41. package/src/core/client.ts +65 -61
  42. package/src/core/config.ts +6 -2
  43. package/src/core/documentUpload.ts +196 -0
  44. package/src/core/execution.ts +24 -0
  45. package/src/core/store.ts +81 -20
  46. package/src/core/types.ts +3 -1
  47. package/src/index.ts +4 -0
  48. package/src/msglist/CliMsgList.tsx +9 -2
  49. package/src/operator/node/Chat.tsx +11 -9
  50. package/src/operator/node/Composer.tsx +103 -75
  51. package/src/operator/node/Orchestrator.tsx +1 -1
  52. package/src/operator/node/SessionRail.tsx +71 -32
  53. package/src/operator/node/SessionView.tsx +31 -28
  54. package/src/operator/node/Transcript.tsx +21 -9
  55. package/src/operator/node/boardBackend.ts +0 -19
  56. package/src/operator/node/boardEvents.ts +21 -3
  57. package/src/operator/node/boardState.ts +18 -1
  58. package/src/operator/node/boardWorkspace.ts +4 -1
  59. package/src/operator/node/composerDraft.ts +6 -2
  60. package/src/operator/node/format.ts +4 -2
  61. package/src/operator/node/liveStream.ts +14 -2
  62. package/src/operator/node/useTaskStream.ts +3 -1
  63. package/src/operator.base.css +150 -43
  64. package/src/operator.css +150 -43
  65. package/src/styles.css +253 -39
  66. package/src/ui/Markdown.tsx +2 -2
  67. package/src/ui/Popover.tsx +25 -12
  68. package/src/ui/QueuedMessages.tsx +69 -0
  69. package/src/ui/ReferenceCard.tsx +28 -0
  70. package/src/ui/TextBlock.tsx +6 -4
  71. package/src/ui/Transcript.tsx +20 -10
  72. package/src/ui/UploadProgress.tsx +46 -0
  73. package/src/ui/WorkspaceDrawer.tsx +64 -0
  74. package/src/ui/WorkspaceTabs.tsx +60 -0
  75. package/src/ui/format.ts +3 -1
  76. package/src/ui/reference.ts +40 -0
  77. package/src/ui/useChatScrollbar.ts +29 -0
  78. package/src/ui/useComposerAutosize.ts +54 -0
  79. package/dist/cli-ui-embed-BAqonuoD.js +0 -45952
@@ -1,17 +1,16 @@
1
1
  // 预览里文本文件(代码、日志、Markdown、CSV)的读取。
2
2
  //
3
3
  // 能读多大:一次最多 max 字节(预览里是 32 MB),更大的只看开头或末尾。
4
- // 文件在变(任务在写日志、在改报告)时不整个重读:后端有 readBytes 时,从上次读到的地方往前多取一小段(OVERLAP),
5
- // 这一小段和手里的末尾一模一样就说明文件只是往后追加了,只取新增的部分接上;对不上(文件被改写、变短了)才整个重读。
6
- // 只对 UTF-8 文件这样做(GBK 的字节边界不好对齐,整个重读)。
4
+ // 新协议以不可变修订和内容块进行同步;旧后端不能通过尾部重叠推断整份文件未变化。
7
5
 
8
6
  import type { BoardBackend } from "../core/client";
9
7
  import { decodeText } from "../core/textDecode";
8
+ import { syncFile } from "./fileSync";
10
9
 
11
10
  /** 往前多取这么多字节,用来确认文件只是追加。 */
12
11
  export const OVERLAP = 256;
13
12
  /** 追加的段落多了合成一段(每段是 <pre> 里的一个文字节点)。 */
14
- const MAX_PARTS = 200;
13
+
15
14
 
16
15
  export interface LoadedText {
17
16
  text: string;
@@ -24,6 +23,7 @@ export interface LoadedText {
24
23
  /** 读的是末尾。 */
25
24
  tail: boolean;
26
25
  encoding?: string;
26
+ revision?: string;
27
27
  /** 读到的内容在文件里的字节范围 [start, end)。 */
28
28
  start: number;
29
29
  end: number;
@@ -44,11 +44,16 @@ export function incompleteUtf8Tail(bytes: Uint8Array) {
44
44
  return 0;
45
45
  }
46
46
 
47
- const sameBytes = (a: Uint8Array, b: Uint8Array) => a.length === b.length && a.every((x, i) => x === b[i]);
47
+
48
48
 
49
49
  /** 第一次读:开头(或末尾)最多 max 字节。 */
50
- export async function loadText(backend: BoardBackend, jobId: string, path: string, opts: { max: number; tail?: boolean; onBytes?: (bytes: number) => void; signal?: AbortSignal }): Promise<LoadedText> {
50
+ export async function loadText(backend: BoardBackend, jobId: string, path: string, opts: { max: number; tail?: boolean; onBytes?: (bytes: number) => void; signal?: AbortSignal; preview?: boolean; fresh?: boolean }): Promise<LoadedText> {
51
51
  if (opts.signal?.aborted) throw new DOMException("文件读取已取消", "AbortError");
52
+ if (backend.fileSync) {
53
+ const result = await syncFile(backend.fileSync, jobId, path, { signal: opts.signal, preview: opts.preview, fresh: opts.fresh, range: { maxBytes: opts.max, ...(opts.tail ? { tail: true } : {}) }, onProgress: progress => opts.onBytes?.(progress.loadedBytes) });
54
+ const text = result.text ?? decodeText(result.bytes, { fromMiddle: result.start > 0 }).text;
55
+ return { text, parts: [text], truncated: result.truncated, size: result.size, tail: result.start > 0, encoding: result.encoding, revision: result.revision, start: result.start, end: result.start + result.bytes.length, tailBytes: null };
56
+ }
52
57
  if (!backend.readBytes) {
53
58
  const r = await backend.readText(jobId, path, opts.max, { tail: opts.tail, onBytes: opts.onBytes, signal: opts.signal });
54
59
  return { text: r.text, parts: [r.text], truncated: r.truncated, size: r.size, tail: !!r.tail, encoding: r.encoding, start: 0, end: 0, tailBytes: null };
@@ -64,38 +69,8 @@ export async function loadText(backend: BoardBackend, jobId: string, path: strin
64
69
  };
65
70
  }
66
71
 
67
- /**
68
- * 文件变了:能接上就只取新增的部分,返回新的内容;返回 null = 接不上(被改写 / 变短 / 不是 UTF-8 / 后端不支持),调用方整个重读。
69
- * 只看开头且文件比 max 大时,开头没变就不用重读(追加的在后面,本来就不显示)。
70
- */
71
- export async function refreshText(backend: BoardBackend, jobId: string, path: string, prev: LoadedText, max: number, options: { signal?: AbortSignal } = {}): Promise<LoadedText | null> {
72
+ /** Every refresh verifies a revision. A matching tail cannot prove the rest stayed unchanged. */
73
+ export async function refreshText(backend: BoardBackend, jobId: string, path: string, prev: LoadedText, max: number, options: { signal?: AbortSignal; preview?: boolean; fresh?: boolean } = {}): Promise<LoadedText | null> {
72
74
  if (options.signal?.aborted) throw new DOMException("文件读取已取消", "AbortError");
73
- if (!backend.readBytes || !prev.tailBytes) return null;
74
- const overlap = prev.tailBytes.length;
75
- const from = prev.end - overlap;
76
- const headOnly = !prev.tail && prev.truncated;
77
- // 末尾模式攒多了(超过 1.5 倍):整个重读末尾,免得越攒越大。
78
- if (prev.tail && prev.end - prev.start > max * 1.5) return null;
79
- const room = headOnly ? 0 : prev.tail ? max : Math.max(0, max - (prev.end - prev.start));
80
- const chunk = await backend.readBytes(jobId, path, { start: from, maxBytes: overlap + room, signal: options.signal });
81
- if (chunk.size < prev.end || chunk.start !== from || chunk.bytes.length < overlap) return null;
82
- if (!sameBytes(chunk.bytes.subarray(0, overlap), prev.tailBytes)) return null;
83
- const readEnd = from + chunk.bytes.length;
84
- if (headOnly) return { ...prev, size: chunk.size };
85
- const added = chunk.bytes.subarray(overlap);
86
- const k = incompleteUtf8Tail(added);
87
- const delta = added.subarray(0, added.length - k);
88
- let addedText: string;
89
- try { addedText = new TextDecoder("utf-8", { fatal: true }).decode(delta); } catch { return null; }
90
- const end = prev.end + delta.length;
91
- const tailBytes = new Uint8Array(Math.min(OVERLAP, overlap + delta.length));
92
- const joined = new Uint8Array(overlap + delta.length);
93
- joined.set(prev.tailBytes, 0);
94
- joined.set(delta, overlap);
95
- tailBytes.set(joined.subarray(joined.length - tailBytes.length));
96
- const parts = addedText ? (prev.parts.length >= MAX_PARTS ? [prev.text, addedText] : [...prev.parts, addedText]) : prev.parts;
97
- return {
98
- ...prev, text: addedText ? prev.text + addedText : prev.text, parts, size: chunk.size, end, tailBytes,
99
- truncated: prev.tail || chunk.size > readEnd,
100
- };
75
+ return backend.fileSync ? loadText(backend, jobId, path, { max, tail: prev.tail, signal: options.signal, preview: options.preview, fresh: options.fresh }) : null;
101
76
  }
@@ -0,0 +1,51 @@
1
+ import { useLayoutEffect, useRef, useState } from "react";
2
+ import { clientId } from "../core/id";
3
+ import type { DocumentReceipt, UploadDocument, UploadPhase } from "../core/documentUpload";
4
+ import { FILE_UPLOAD_ACCEPT } from "../core/documentUpload";
5
+ import { UploadProgress } from "../ui/UploadProgress";
6
+
7
+ type PendingUpload = { file: File; phase: UploadPhase | "ready" | "error"; bytes: number; error?: string };
8
+
9
+ /** Uploading a file only changes the current workspace. It never submits a message. */
10
+ export function useBoardUpload(scope: string | null, upload: UploadDocument | undefined, onUploaded: (receipt: DocumentReceipt) => void) {
11
+ const inputRef = useRef<HTMLInputElement>(null);
12
+ const active = useRef<AbortController | null>(null);
13
+ const committedScope = useRef(scope);
14
+ const [pending, setPending] = useState<PendingUpload | null>(null);
15
+ useLayoutEffect(() => {
16
+ committedScope.current = scope;
17
+ setPending(null);
18
+ return () => { active.current?.abort(); active.current = null; };
19
+ }, [scope, upload]);
20
+
21
+ const start = (file: File) => {
22
+ if (!scope || !upload || active.current) return;
23
+ const controller = new AbortController();
24
+ active.current = controller;
25
+ const current = () => active.current === controller && committedScope.current === scope && !controller.signal.aborted;
26
+ setPending({ file, phase: "queued", bytes: 0 });
27
+ void Promise.resolve().then(() => upload(file, {
28
+ draftId: clientId(), signal: controller.signal,
29
+ onPhase: phase => { if (current()) setPending(previous => previous ? { ...previous, phase } : previous); },
30
+ onBytes: bytes => { if (current()) setPending(previous => previous ? { ...previous, bytes } : previous); },
31
+ })).then(receipt => {
32
+ if (!current()) return;
33
+ if (!receipt.path) throw new Error("文件尚未进入当前工作区,请稍后重试");
34
+ setPending({ file, phase: "ready", bytes: file.size });
35
+ onUploaded(receipt);
36
+ }).catch(error => {
37
+ if (current()) setPending(previous => previous ? { ...previous, phase: "error", error: error instanceof Error ? error.message : "上传失败,请重试" } : previous);
38
+ }).finally(() => { if (active.current === controller) active.current = null; });
39
+ };
40
+ const dismiss = () => { active.current?.abort(); active.current = null; setPending(null); };
41
+ const busy = !!pending && pending.phase !== "ready" && pending.phase !== "error";
42
+ return {
43
+ busy,
44
+ choose: () => { if (!busy) inputRef.current?.click(); },
45
+ input: <input ref={inputRef} type="file" hidden accept={FILE_UPLOAD_ACCEPT} aria-label="上传文件到工作区"
46
+ onChange={event => { const file = event.target.files?.[0]; event.target.value = ""; if (file) start(file); }} />,
47
+ progress: pending && <div className="cli-board-upload"><UploadProgress name={pending.file.name} status={pending.phase}
48
+ bytes={pending.bytes} total={pending.file.size} error={pending.error} onCancel={dismiss} onRemove={dismiss}
49
+ onRetry={() => start(pending.file)} /></div>,
50
+ };
51
+ }
@@ -0,0 +1,35 @@
1
+ import { useEffect, useLayoutEffect, useState, type RefObject } from "react";
2
+
3
+ const useLayout = typeof window === "undefined" ? useEffect : useLayoutEffect;
4
+
5
+ /** Observe this board's ancestor chain, including host CSS that hides a pane. */
6
+ export function useBoardVisibility(ref: RefObject<HTMLElement | null>, enabled: boolean) {
7
+ const [visible, setVisible] = useState(true);
8
+ useLayout(() => {
9
+ if (!enabled || !ref.current) return;
10
+ const ancestors: HTMLElement[] = [];
11
+ for (let node: HTMLElement | null = ref.current; node; node = node.parentElement) ancestors.push(node);
12
+ let pending = false, alive = true;
13
+ const read = () => {
14
+ pending = false;
15
+ if (!alive) return;
16
+ setVisible(ancestors.every(node => {
17
+ const style = getComputedStyle(node);
18
+ return !node.hidden && style.display !== "none" && style.visibility !== "hidden" && style.visibility !== "collapse" && style.contentVisibility !== "hidden";
19
+ }));
20
+ };
21
+ const schedule = () => {
22
+ if (pending) return;
23
+ pending = true;
24
+ queueMicrotask(read);
25
+ };
26
+ const mutations = new MutationObserver(schedule);
27
+ for (const node of ancestors) mutations.observe(node, { attributes: true, attributeFilter: ["class", "style", "hidden"] });
28
+ const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(schedule);
29
+ resize?.observe(ref.current);
30
+ window.addEventListener("resize", schedule);
31
+ read();
32
+ return () => { alive = false; mutations.disconnect(); resize?.disconnect(); window.removeEventListener("resize", schedule); };
33
+ }, [ref, enabled]);
34
+ return visible;
35
+ }
@@ -11,11 +11,14 @@
11
11
  *
12
12
  * 数据都经中枢读执行节点(和看板一样),弱网时按网络错误(CenterError,code = "NETWORK")失败,可以重试。
13
13
  */
14
- import { subscribeWorkspaceFiles, type WorkspaceWatchOptions } from "./fileWatch";
14
+ import { retryWorkspaceFiles, subscribeWorkspaceFiles, type WorkspaceWatchOptions, type WorkspaceRefreshOptions } from "./fileWatch";
15
15
  import type { BoardBackend } from "../core/client";
16
- import type { BoardInfo, FileEntry, WorkspaceChangeEvent } from "../core/types";
16
+ import type { BoardInfo, FileEntry, DirListing, WorkspaceChangeEvent } from "../core/types";
17
17
  import { directFileUrl, downloadFile, mimeOf } from "./fileOps";
18
18
  import { fmtSize } from "./fileKind";
19
+ import { syncFile, FILE_SYNC_MAX } from "./fileSync";
20
+ import { isOriginalDocument } from "../core/documentUpload";
21
+ import { loadText } from "./textLoad";
19
22
  import { downloadFolder, listFolder, zipFolder, type FolderOptions, type FolderResult, type FolderZip } from "./folderDownload";
20
23
 
21
24
  /** readText 最多读这么大(更大的用 readFile)。 */
@@ -55,9 +58,13 @@ export interface CliWorkspace {
55
58
  stat(path: string): ReturnType<BoardBackend["stat"]>;
56
59
  /** 订阅文件变更。初始快照只建立基线;返回取消函数。 */
57
60
  watch(listener: (event: WorkspaceChangeEvent) => void, options?: WorkspaceWatchOptions): () => void;
61
+ /** 请求一次增量检查。轮末回调用无参;用户明确重试可传 manual:true(仍遵守服务端冷却)。不创建连接或周期轮询。 */
62
+ refresh(options?: WorkspaceRefreshOptions): void;
58
63
  /** 包括当前文件、打开页签、最近浏览和显示状态的背景快照。 */
59
64
  context(): BoardView | null;
60
- /** 列一个目录(不递归)。 */
65
+ /** 按需读取单层目录的一页;nextCursor 非空时还有后续页。 */
66
+ listPage(dir?: string, opts?: { cursor?: string; limit?: number; signal?: AbortSignal }): Promise<DirListing>;
67
+ /** 显式列出一个目录(不递归,最多10000项);界面首屏推荐 listPage。 */
61
68
  list(dir?: string): Promise<FileEntry[]>;
62
69
  /** 列 dir 下所有文件(递归,只含文件)。 */
63
70
  listAll(dir?: string, opts?: { signal?: AbortSignal }): Promise<FileEntry[]>;
@@ -100,11 +107,27 @@ export function createWorkspace(deps: {
100
107
  info: () => backend().info(key),
101
108
  stat: path => backend().stat(key, clean(path) || "."),
102
109
  watch: (listener, options) => deps.watch ? deps.watch(listener,options) : subscribeWorkspaceFiles(backend(), key, listener, options),
110
+ refresh: options => options ? retryWorkspaceFiles(backend(), key, options) : retryWorkspaceFiles(backend(), key),
103
111
  context: () => deps.context?.() ?? deps.view() ?? null,
112
+ async listPage(dir = "", opts = {}) {
113
+ const api = backend();
114
+ const result = api.listFiles ? await api.listFiles(key, clean(dir) || ".", opts) : await api.stat(key, clean(dir) || ".");
115
+ if (result.type !== "dir") throw new Error(`「${dir}」不是目录`);
116
+ return result;
117
+ },
104
118
  async list(dir = "") {
105
- const res = await backend().stat(key, clean(dir) || ".");
106
- if (res.type !== "dir") throw new Error(`「${dir}」不是目录`);
107
- return res.entries;
119
+ const api = backend(), entries: FileEntry[] = [], seen = new Set<string>();
120
+ let cursor: string | undefined;
121
+ do {
122
+ const result = api.listFiles ? await api.listFiles(key, clean(dir) || ".", { cursor, limit: 200 }) : await api.stat(key, clean(dir) || ".");
123
+ if (result.type !== "dir") throw new Error(`「${dir}」不是目录`);
124
+ entries.push(...result.entries);
125
+ if (entries.length > WORKSPACE_LIST_MAX) throw new Error("目录超过10000项,请用 listPage 按需读取");
126
+ cursor = result.nextCursor;
127
+ if ((result.truncated && (!cursor || !api.listFiles)) || (cursor && seen.has(cursor))) throw new Error("目录列表不完整,请使用支持分页的服务端");
128
+ if (cursor) seen.add(cursor);
129
+ } while (cursor);
130
+ return entries;
108
131
  },
109
132
  async listAll(dir = "", opts = {}) {
110
133
  const files = await listFolder(backend(), key, clean(dir), { signal: opts.signal, maxFiles: WORKSPACE_LIST_MAX, maxBytes: Infinity });
@@ -115,6 +138,10 @@ export function createWorkspace(deps: {
115
138
  if (signal?.aborted) throw new DOMException("已取消", "AbortError");
116
139
  let rejectAbort: (() => void) | undefined;
117
140
  try {
141
+ if (backend().fileSync && isOriginalDocument(path)) {
142
+ const result = await loadText(backend(), key, clean(path), { max: FILE_SYNC_MAX, signal });
143
+ return result.text;
144
+ }
118
145
  const reading = signal ? backend().readText(key, clean(path), WORKSPACE_TEXT_MAX, { signal }) : backend().readText(key, clean(path), WORKSPACE_TEXT_MAX);
119
146
  const r = signal ? await Promise.race([reading, new Promise<never>((_resolve, reject) => {
120
147
  rejectAbort = () => reject(new DOMException("已取消", "AbortError"));
@@ -129,6 +156,11 @@ export function createWorkspace(deps: {
129
156
  const p = clean(path);
130
157
  const max = opts.maxBytes ?? WORKSPACE_FILE_MAX;
131
158
  if(!Number.isFinite(max)||max<0)throw new RangeError("maxBytes 必须是非负有限字节数");
159
+ if (backend().fileSync && isOriginalDocument(p)) {
160
+ const result = await syncFile(backend().fileSync!, key, p, { signal: opts.signal, decode: false, range: { maxBytes: Math.min(max, FILE_SYNC_MAX) }, onProgress: progress => opts.onProgress?.(progress.loadedBytes) });
161
+ if (result.truncated) throw new Error(`文件超过 ${fmtSize(max)},请用 fileUrl / download`);
162
+ return new Blob([result.bytes], { type: mimeOf(p) });
163
+ }
132
164
  // 太大的读到一半就停:整个读进内存会把页面撑爆。
133
165
  const ctl = new AbortController();
134
166
  const stop = () => ctl.abort();
@@ -20,9 +20,22 @@ import { exportMarkdown } from "./export";
20
20
  import { FileLinkContext, FileOpenContext } from "../ui/fileLinks";
21
21
  import { FileViewer } from "../board/FileViewer";
22
22
  import { BrowserPane } from "../board/BrowserPane";
23
+ import { CliBoard } from "../board/CliBoard";
24
+ import { WorkspaceTabs } from "../ui/WorkspaceTabs";
25
+ import { WorkspaceDrawer, useWorkspaceDrawer } from "../ui/WorkspaceDrawer";
26
+ import { useChatScrollbar } from "../ui/useChatScrollbar";
27
+ import { QueuedMessages } from "../ui/QueuedMessages";
28
+ import { ReferenceCard } from "../ui/ReferenceCard";
29
+ import type { UploadDocument } from "../core/documentUpload";
30
+ import { GroupChat, type GroupChatAdapter } from "./GroupChat";
31
+ import { RunControls } from "./RunControls";
32
+ import { useExecutionConfig } from "./useExecutionConfig";
33
+ import { CenterError } from "../core/client";
23
34
 
24
35
  /** onTurnEnd / onConversationEnd:浏览器里从实时流算出的结果通知,不依赖 center 的 callback.url。 */
25
36
  export interface CliChatProps extends CliStoreOptions, ResultHandlers {
37
+ /** 宿主提供的多发言人群聊;发送统一交给 group.onSend,不连接普通单任务转录。 */
38
+ group?: GroupChatAdapter;
26
39
  /** Integration slots share this transcript/composer; they do not create another chat implementation. */
27
40
  slots?: { beforeMessages?: ReactNode; afterMessages?: ReactNode; composerToolbar?: ReactNode; beforeComposer?: ReactNode };
28
41
  /** Saving execution settings blocks send until persistence completes. */
@@ -77,6 +90,7 @@ export interface CliChatProps extends CliStoreOptions, ResultHandlers {
77
90
 
78
91
  export function CliChat(props: CliChatProps) {
79
92
  const ctx = useCliStoreContext();
93
+ if (props.group) return <CliErrorBoundary component="cli-chat" theme={props.theme}>{withOwnProvider(props, <GroupChat key={props.group.scopeKey} group={props.group} theme={props.theme} className={props.className} style={props.style} readOnly={props.readOnly} />, !!ctx)}</CliErrorBoundary>;
80
94
  return <CliErrorBoundary component="cli-chat" theme={props.theme}>{withOwnProvider(props, <ChatInner {...props} inProvider={!!ctx} />, !!ctx)}</CliErrorBoundary>;
81
95
  }
82
96
 
@@ -143,6 +157,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
143
157
  // 内部用对话键(key):只有 job_id = 对话任务的第一个对话;"job#task" = 里面指定的那个对话(见 core/conversation.ts)。
144
158
  // 新对话的草稿按组件实例存(对话的草稿按对话键存,看同一个对话的组件共用一份是对的)。
145
159
  const instanceId = useId();
160
+ const inlineOwner = `chat-board-${instanceId}`;
146
161
  const controlled = jobIdProp !== undefined;
147
162
  // jobId 也可以直接给对话键(例如 cli-msglist 的 onSelect 给的 "job#task"),taskId 再给就以 taskId 为准。
148
163
  const keyProp = jobIdProp === undefined ? undefined : jobIdProp ? convKey(parseConvKey(jobIdProp).jobId, taskIdProp ?? parseConvKey(jobIdProp).taskId) : null;
@@ -164,6 +179,13 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
164
179
  }, [controlled, store]);
165
180
  const conv = useCliState((s) => (key && !isNew ? s.convs[key] : undefined)) ?? (key && !isNew ? emptyConv(key) : null);
166
181
  const entry = useCliState((s) => (jobId ? s.jobs[jobId] : undefined));
182
+ const externalBoardVisible = useCliState(s => !!(key && s.externalBoards[key]));
183
+ const listVisible = useCliState(s => s.listVisible);
184
+ const externalBoardOptions = useCliState(s => key ? s.externalBoardOptions[key] : undefined);
185
+ const [tabsHost, setTabsHost] = useState<HTMLDivElement | null>(null);
186
+ const [workspace, setWorkspace] = useState<{ key: string | null; active: boolean }>({ key: null, active: false });
187
+ const showChat = useCallback(() => setWorkspace({ key, active: false }), [key]);
188
+ const showWorkspace = useCallback(() => setWorkspace({ key, active: true }), [key]);
167
189
 
168
190
  // 看这个对话:开实时流(同一对话多处看只连一次)。
169
191
  useEffect(() => (key ? store.open(key) : undefined), [store, key]);
@@ -210,7 +232,17 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
210
232
  const task = (shownTaskId ? job?.tasks?.find((t) => t.id === shownTaskId) : undefined) ?? (shownTaskId || batch ? undefined : job?.tasks?.[0]);
211
233
  const info = taskId ? job?.conversation?.conversations?.find((c) => c.task_id === taskId) : undefined;
212
234
  const agent = task?.agent ?? conv?.turns[conv.turns.length - 1]?.agent ?? null;
213
- const agentText = labelOf(agent);
235
+ const draftScope = store.draftScope ? `${store.draftScope}:${key ?? `new:${instanceId}`}` : key ?? `new:${instanceId}`;
236
+ const initialOptions = newConversation ?? store.getNewConversationOptions();
237
+ const execution = useExecutionConfig({ client: store.client, scope: draftScope,
238
+ target: key && !isNew ? key : null, running: running || batchPending,
239
+ enabled: showComposer && (cfg.composer.agentSelector || cfg.composer.executionMode),
240
+ initial: { ...(initialOptions?.agent ? { agent: initialOptions.agent } : {}), ...initialOptions?.executionConfig },
241
+ });
242
+ const agentText = labelOf(execution.config?.agent ?? agent);
243
+ const executionAgent = execution.config?.agents.find(item => item.id === execution.config?.agent);
244
+ const executionReadOnly = !!key && !isNew && !store.client.setExecutionConfig;
245
+ const settingsBusy = !!configurationBusy || execution.blocked;
214
246
  const jobStatus = conv?.job?.status ?? job?.status ?? null;
215
247
  // 对话式子任务没有「结束」:大任务结束了,再发一条会重新打开它。
216
248
  // 支持续派的普通子任务同理:大任务结束不挡输入框。
@@ -287,11 +319,11 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
287
319
  // 本组件的新建参数随每次发送带上:放进共享 Provider 时也用自己的,不依赖仓库是谁建的。
288
320
  const sendOpts = useRef({ newConversation, createConversation, businessIdFor, select: !controlled });
289
321
  useLayoutEffect(() => { sendOpts.current = { newConversation, createConversation, businessIdFor, select: !controlled }; });
290
- const [skipPassive,setSkipPassive]=useState(false);
291
- const skipPassiveRef=useRef(false);
292
- useLayoutEffect(()=>{skipPassiveRef.current=skipPassive;},[skipPassive]);
293
- useEffect(()=>setSkipPassive(false),[key]);
322
+ const prepareExecution = useRef(execution.prepareSend);
323
+ const reloadExecution = useRef(execution.reload);
324
+ useLayoutEffect(() => { prepareExecution.current = execution.prepareSend; reloadExecution.current = execution.reload; });
294
325
  const send = useCallback(async (text: string) => {
326
+ const executionOptions = prepareExecution.current();
295
327
  // 一次性任务:发给正在看的那个子任务(接着它聊)。
296
328
  const id = latest.current.conv?.batch ? latest.current.runKey : latest.current.key;
297
329
  // 看板里引用到这个对话的东西:合成一个「看板上下文」块跟着这条消息发出去;发失败就放回去。
@@ -299,7 +331,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
299
331
  const refKey = latest.current.key;
300
332
  const refs = refKey ? store.takeRefs(refKey) : [];
301
333
  const passiveView=refKey?store.getState().boardContext[refKey]:null;
302
- const passive=passiveContext&&!skipPassiveRef.current&&passiveView?formatBoardContext(passiveView,refKey??undefined):'';
334
+ const passive=passiveContext&&passiveView?formatBoardContext(passiveView,refKey??undefined):'';
303
335
  const viewing = !passive && refKey ? store.takeViewing(refKey) : null;
304
336
  const blocks = [
305
337
  ...(passive ? [{name:"看板正在看",kind:"viewing" as const,text:passive}] : []),
@@ -309,13 +341,13 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
309
341
  const full = blocks.length ? [text, ...blocks.map(serializeBlock)].join("\n\n") : text;
310
342
  let target: string;
311
343
  try {
312
- if(charCount(full)>MAX_MESSAGE_CHARS)throw new Error("加上看板背景后消息过长,请精简内容或取消本次自动附带。");
313
- target = await store.send(id ?? null, full, sendOpts.current);
344
+ if(charCount(full)>MAX_MESSAGE_CHARS)throw new Error("加上看板背景后消息过长,请精简消息内容后重试。");
345
+ target = await store.send(id ?? null, full, { ...sendOpts.current, ...executionOptions });
314
346
  } catch (error) {
315
347
  if (refKey) { store.restoreRefs(refKey, refs); store.restoreViewing(refKey, viewing); }
348
+ if (error instanceof CenterError && error.code === "execution_config_conflict" && latest.current.key === refKey) await reloadExecution.current();
316
349
  throw error;
317
350
  }
318
- if(latest.current.key===refKey)setSkipPassive(false);
319
351
  if (target !== id && latest.current.key === refKey) go(target);
320
352
  }, [store, go, passiveContext]);
321
353
  const passiveView=useCliState(state=>key?state.boardContext[key]:undefined);
@@ -326,6 +358,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
326
358
  // 输入框上方显示哪些(config.refs):不显示的照样随消息带上。正在看的缺省不显示。
327
359
  const viewing = !passiveText && viewingRef && refShown(cfg, "viewing") ? viewingRef : null;
328
360
  const shownRefs = refs?.filter((r) => refShown(cfg, r.kind));
361
+ const chatRef = useRef<HTMLDivElement>(null);
329
362
  // 业务方补充的背景(store.appendContext):随下一条消息带上,输入框上方显示出来,让用户知道会带上什么。
330
363
  const contextItems = useCliState((s) => s.context[key ?? ""]);
331
364
  // AI 把浏览器交给用户(实时新来的「🖐 …」行):同一 Provider 里有看板在看这个对话,就把看板切到浏览器(收起的会请宿主展开)。
@@ -340,8 +373,8 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
340
373
  if (!seen.ids) { seen.ids = new Set(handoffs.map((m) => m.id)); return; }
341
374
  const fresh = handoffs.filter((m) => !seen.ids!.has(m.id));
342
375
  for (const m of fresh) seen.ids.add(m.id);
343
- if (fresh.length) store.openInBoard(key, { kind: "browser" });
344
- }, [key, conv, fileOpen, store]);
376
+ if (fresh.length) store.openInBoard(key, { kind: "browser" }, inlineOwner);
377
+ }, [key, conv, fileOpen, store, inlineOwner]);
345
378
 
346
379
  // 对话里弹窗打开的文件 / 浏览器(没有看板接的时候)。换对话就关掉。
347
380
  const [popup, setPopup] = useState<{ kind: "file"; path: string } | { kind: "browser" } | null>(null);
@@ -353,7 +386,12 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
353
386
  return () => window.removeEventListener("keydown", onKey);
354
387
  }, [popup]);
355
388
  const handlers = useMemo<MessageHandlers>(() => ({
356
- 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)});},
389
+ onQuote: readOnly ? undefined : (m,selected) => {
390
+ const id = latest.current.key;
391
+ if (!id) return;
392
+ store.addRef(id,{id:`message:${m.id}`,kind:"message",label:m.role==='user'?'引用你的消息':'引用智能体消息',text:selected||(m.role==='user'?withoutBoardContext(m.content):m.content)});
393
+ chatRef.current?.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus({ preventScroll: true });
394
+ },
357
395
  // 与 Operator 的 answerQuestion 一致:先把卡片标成已答,再把回答发出去(center 已结束提问那一轮,回答就是下一条消息)。
358
396
  onAnswer: readOnlyProp || (readOnly && !(batch && store.client.answerAsk)) ? undefined : async (askId: string, questions: AskQuestion[], answers: AskAnswers) => {
359
397
  const id = latest.current.key;
@@ -385,7 +423,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
385
423
  onShowBrowser: !store.client.board || fileOpen === "link" ? undefined : () => {
386
424
  const id = latest.current.key;
387
425
  if (!id) return;
388
- if (fileOpen !== "modal" && store.openInBoard(id, { kind: "browser" })) return;
426
+ if (fileOpen !== "modal" && store.openInBoard(id, { kind: "browser" }, inlineOwner)) return;
389
427
  if (fileOpen !== "board") setPopup({ kind: "browser" });
390
428
  },
391
429
  onRetry: readOnly ? undefined : (msgId: string) => {
@@ -393,7 +431,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
393
431
  const at = list.findIndex((m) => m.id === msgId);
394
432
  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; }
395
433
  },
396
- }), [readOnly, readOnlyProp, batch, chat, send, store, go, report, fileOpen]);
434
+ }), [readOnly, readOnlyProp, batch, chat, send, store, go, report, fileOpen, inlineOwner]);
397
435
  const stop = useCallback(async () => {
398
436
  const { key: id, conv: c } = latest.current;
399
437
  const turn = c ? runningTurn(c) : null;
@@ -428,6 +466,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
428
466
 
429
467
  // ── 分窗渲染(移植自 Operator 的 SessionView):DOM 里最多 RENDER_MAX 行,几千条消息也照样流畅 ──
430
468
  const scrollRef = useRef<HTMLDivElement>(null);
469
+ useChatScrollbar(scrollRef, key);
431
470
  const pinned = useRef(true);
432
471
  const [atBottom, setAtBottom] = useState(true);
433
472
  const committed = useMemo(() => messages.filter((m) => m.role !== "queued"), [messages]);
@@ -569,7 +608,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
569
608
  if (el && node) { const delta = node.getBoundingClientRect().top - a.top; if (Math.abs(delta) > 1) el.scrollTop += delta; }
570
609
  }
571
610
  }, [win]);
572
- useLayoutEffect(() => { if (pinned.current && scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; }, [messages, running, startIdx, endIdx]);
611
+ useLayoutEffect(() => { if (pinned.current && scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; }, [messages, running, startIdx, endIdx, win]);
573
612
  useEffect(() => {
574
613
  const el = scrollRef.current;
575
614
  if (!el || typeof ResizeObserver === "undefined") return;
@@ -601,6 +640,18 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
601
640
  const fileUrlRef = useRef(fileUrl);
602
641
  fileUrlRef.current = fileUrl;
603
642
  const board = store.client.board;
643
+ const mobileWorkspace = useWorkspaceDrawer(chatRef);
644
+ const workspaceAvailable = h.show && !!key && !isNew && !!board && !externalBoardVisible;
645
+ const workspaceTabs = workspaceAvailable && !mobileWorkspace;
646
+ const workspaceActive = workspaceAvailable && workspace.key === key && workspace.active;
647
+ const showCurrentTitle = h.title || externalBoardVisible || (mobileWorkspace && workspaceAvailable);
648
+ const quoteToChat = useCallback(() => {
649
+ const sourceKey = key;
650
+ showChat();
651
+ requestAnimationFrame(() => {
652
+ if (keyRef.current === sourceKey) chatRef.current?.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus({ preventScroll: true });
653
+ });
654
+ }, [key, showChat]);
604
655
  // 文件地址:宿主给的 fileUrl 优先;没给且后端有看板接口、地址能直接打开时,用 center 的文件接口。
605
656
  const resolveFile = useMemo(() => {
606
657
  if (fileUrl) return (path: string, kind: "link" | "image") => (jobId ? fileUrlRef.current?.(path, jobId, kind, taskId) ?? null : null);
@@ -611,27 +662,44 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
611
662
  const openFile = useMemo(() => {
612
663
  if (!key || fileOpen === "link") return null;
613
664
  return (path: string) => {
614
- if (fileOpen !== "modal" && store.openInBoard(key, { kind: "file", path })) return true;
665
+ if (fileOpen !== "modal" && store.openInBoard(key, { kind: "file", path }, inlineOwner)) return true;
615
666
  if (fileOpen === "board" || !board) return false;
616
667
  setPopup({ kind: "file", path });
617
668
  return true;
618
669
  };
619
- }, [key, fileOpen, store, board]);
670
+ }, [key, fileOpen, store, board, inlineOwner]);
671
+ const uploadDocument = useMemo<UploadDocument | undefined>(() => {
672
+ const staging = store.client.uploadDocument;
673
+ if (!key || isNew || !board?.uploadFile) return staging;
674
+ return async (file, options) => {
675
+ const target = await board.info(key);
676
+ if (options.signal?.aborted) throw new DOMException("上传已取消", "AbortError");
677
+ if (!target.dispatched) {
678
+ if (staging) return staging(file, options);
679
+ throw new Error("当前会话工作区尚未就绪");
680
+ }
681
+ const receipt = await board.uploadFile!(key, file, options);
682
+ if (!receipt.path) throw new Error("文件尚未进入当前工作区,请重试");
683
+ if (!options.signal?.aborted && latest.current.key === key) openFile?.(receipt.path);
684
+ return receipt;
685
+ };
686
+ }, [store, key, isNew, board, openFile]);
620
687
 
621
688
  return (
622
689
  <FileLinkContext.Provider value={resolveFile}>
623
690
  <FileOpenContext.Provider value={openFile}>
624
- <div className={`cli-ui cli-chat ${className ?? ""}`} data-theme={theme} style={style}>
691
+ <div ref={chatRef} className={`cli-ui cli-chat ${className ?? ""}`} data-theme={theme} style={style}>
625
692
  <div className="session">
626
- {h.show && (h.crumb || h.export || h.title || h.agent || h.usage || h.model || h.status || h.close) && <div className="sess-head">
627
- {(h.crumb || h.title) && <div className="sh-main">
693
+ {h.show && (workspaceAvailable || externalBoardVisible || h.crumb || h.export || h.title || h.agent || h.usage || h.model || h.status || h.close) && <div className="sess-head">
694
+ {workspaceTabs ? <WorkspaceTabs title={convTitle} subtitle={h.crumb ? title : undefined} chatActive={!workspaceActive} onChat={showChat} onTabsHost={setTabsHost} /> : (h.crumb || showCurrentTitle) && <div className="sh-main">
628
695
  {h.crumb && <div className="crumb">
629
696
  <span className="pic" style={{ width: 16, height: 16, borderRadius: 5, background: color, display: "grid", placeItems: "center", color: "#fff", fontSize: 9, fontWeight: 700 }}>{title[0]}</span>
630
697
  {title} <span className="sep">/</span> 对话
631
698
  </div>}
632
- {h.title && <div className="sh-title"><span className="sht-text" title={convTitle}>{convTitle}</span></div>}
699
+ {showCurrentTitle && <div className="sh-title"><span className="sht-text" title={convTitle}>{convTitle}</span></div>}
633
700
  </div>}
634
701
  <div className="sh-tools">
702
+ {mobileWorkspace && workspaceAvailable && <button type="button" className="btn btn-ghost btn-sm workspace-open" aria-label="打开工作区" aria-haspopup="dialog" aria-expanded={workspaceActive} title="打开工作区" onClick={showWorkspace}>{Icon.board()}</button>}
635
703
  {h.agent && agent && <AgentBadge label={agentText} />}
636
704
  {h.usage && (fresh > 0 || cacheRead > 0) && (
637
705
  <span className="usage-chip" title={`新增 ${fresh.toLocaleString()} tokens${cacheRead ? `\n缓存读取 ${cacheRead.toLocaleString()}(每轮重复读取的上下文)` : ""}`}>
@@ -661,6 +729,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
661
729
  </div>
662
730
  </div>}
663
731
 
732
+ <div className="workspace-chat-content" data-chat-layout={listVisible || externalBoardVisible ? "wide" : "centered"} hidden={workspaceActive && !mobileWorkspace} style={workspaceActive && !mobileWorkspace ? { display: "none" } : undefined}>
664
733
  {closing !== "idle" && key && (
665
734
  <div className="clear-confirm">
666
735
  <span>结束这个对话?结束后不能再发消息{running ? ",正在执行的一轮会被中止" : ""}{taskId && manyConvs ? ";同一任务里的其他对话不受影响" : ""}。</span>
@@ -737,7 +806,6 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
737
806
  </div>
738
807
  )}
739
808
  {cfg.transcript.runningIndicator && running && !awaiting && !awaitingAnswer && !hasBelow && <RunningIndicator agent={agent} agentName={cfg.labels.agent} startedAt={turnStartedAt} lastActivityRef={lastActivityRef} />}
740
- {!hasBelow && queued.map((m) => <MessageView key={m.id} m={m} initial={false} hideWho={false} handlers={handlers} view={view} />)}
741
809
  {ended && cfg.transcript.status && (
742
810
  <div className="session-label"><span className="ln" />{batch ? statusText : <>{REASON_TEXT[closedReason ?? ""] ?? "对话已结束"}{!finalEnd ? " · 再发消息会开一个新对话接着聊" : ""}</>}<span className="ln" /></div>
743
811
  )}
@@ -763,42 +831,36 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
763
831
  ))}
764
832
  </div>
765
833
  )}
766
- {showComposer && (!!shownRefs?.length || !!viewing) && (
767
- <div className="cli-refs" aria-label="引用自看板,下一条消息会带上">
768
- <span className="cli-refs-t">{Icon.board()} 引用自看板</span>
769
- {viewing && (
770
- <span className="cli-ref auto" title={`${viewing.text}\n(看板上正在看,下一条消息缺省带上;点 × 这次不带)`}>
771
- <span className="cli-ref-k">正在看</span>
772
- <span className="cli-ref-l">{viewing.label}</span>
773
- <button className="cli-ref-x" onClick={() => key && store.dismissViewing(key)} title="这次不带上" aria-label={`不带上 ${viewing.label}`}>{Icon.x()}</button>
774
- </span>
775
- )}
776
- {(shownRefs ?? []).map((r) => (
777
- <span key={r.id} className={`cli-ref ${r.kind === "selection" ? "sel" : ""}`} title={r.text}>
778
- <span className="cli-ref-l">{r.label}</span>
779
- <button className="cli-ref-x" onClick={() => key && store.removeRef(key, r.id)} title="不带上" aria-label={`不带上 ${r.label}`}>{Icon.x()}</button>
780
- </span>
781
- ))}
782
- </div>
783
- )}
784
- {showComposer && passiveText && passiveView && <div className="cli-board-background" role="note" aria-label="看板浏览背景">
785
- <details><summary>看板背景 · {(passiveView.current?.path??passiveView.file)?.split('/').pop()??'未选中文件'} · 打开 {(passiveView.opened??passiveView.files).length} · 最近浏览 {passiveView.history?.length??0}</summary>
786
- <div>{passiveView.visible===false?'看板已收起,将附带上次选中的文件及浏览记录。':'将附带当前文件、打开页签和最近浏览记录,仅发送路径等信息。'}</div>
787
- <ul>{(passiveView.history??[]).map((file,index)=><li key={`${file.sourceId}:${file.path}:${index}`}>{file.path}{file.sourceId&&file.sourceId!==key?'(来源任务)':''}</li>)}</ul>
788
- </details>
789
- <label><input type="checkbox" checked={!skipPassive} onChange={event=>setSkipPassive(!event.target.checked)} />下条消息带入</label>
790
- </div>}
791
834
  {slots?.beforeComposer}
835
+ {showComposer && execution.error && <div className="control-error" role="status">{execution.error} {store.client.getExecutionConfig && <button type="button" className="btn btn-ghost btn-sm" disabled={execution.loading || execution.busy} onClick={() => void execution.reload()}>重新读取</button>}</div>}
836
+ {!showComposer && <QueuedMessages scopeKey={key ?? "new"} messages={queued} onCancel={handlers.onCancelQueued} attachmentUrl={handlers.attachmentUrl} />}
792
837
  {showComposer && (
793
838
  <Composer
839
+ uploadDocument={uploadDocument}
794
840
  key={key ?? "new"}
795
- draft={store.draftScope ? `${store.draftScope}:${key ?? `new:${instanceId}`}` : key ?? `new:${instanceId}`}
841
+ draft={draftScope}
842
+ context={(!!shownRefs?.length || !!viewing) && <div className="cli-refs composer-references" aria-label="下一条消息的引用">
843
+ {viewing && <ReferenceCard key={`${key}:viewing:${viewing.id}`} className="reference-viewing" title={`正在看${viewing.label}`} text={viewing.text} disabled={composerDisabled || !!configurationBusy} removeLabel={`不带上 ${viewing.label}`} onRemove={() => { if (key) store.dismissViewing(key); chatRef.current?.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus({ preventScroll: true }); }} />}
844
+ {(shownRefs ?? []).map(ref => <ReferenceCard key={`${key}:${ref.id}`} title={ref.kind === "message" && ref.label === "引用你的消息" ? "你" : ref.kind === "message" && ref.label === "引用智能体消息" ? "智能体" : ref.label} text={ref.text} disabled={composerDisabled || !!configurationBusy} removeLabel={`不带上 ${ref.label}`} onRemove={() => { if (key) store.removeRef(key, ref.id); chatRef.current?.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus({ preventScroll: true }); }} />)}
845
+ </div>}
796
846
  placeholder={fillTemplate(key && !isNew ? cfg.composer.placeholder : cfg.composer.placeholderNew, { title: convTitle, agent: agentText })}
797
- disabled={composerDisabled || !!configurationBusy}
847
+ disabled={composerDisabled || settingsBusy}
798
848
  disabledHint={cfg.composer.placeholderDisabled ? fillTemplate(cfg.composer.placeholderDisabled, { title: convTitle, agent: agentText }) : disabledHint}
799
849
  running={running || batchPending}
800
- toolbar={slots?.composerToolbar}
801
- onSend={async text => { if (text.trim() === "/clear" && onClear) { onClear(); return; } await send(text); }}
850
+ toolbar={<>
851
+ <RunControls task={{ id: draftScope, agent: execution.config?.agent ?? agent ?? initialOptions?.agent ?? undefined,
852
+ model: execution.config?.model, reasoning: execution.config?.reasoning, permission_mode: execution.config?.permission_mode }}
853
+ agents={execution.config?.agents} caps={executionAgent?.capabilities} defaults={executionAgent?.defaults}
854
+ showAgent={cfg.composer.agentSelector} showPermission={cfg.composer.executionMode}
855
+ unavailableReason={executionReadOnly ? "当前接入仅支持读取执行设置。" : execution.error ?? (execution.loading ? "正在读取执行设置…" : undefined)}
856
+ disabled={composerDisabled || running || batchPending || !!configurationBusy || execution.loading || executionReadOnly}
857
+ onSetAgent={agent => execution.save({ agent, model: null, reasoning: null, permission_mode: null })}
858
+ onSetModel={model => execution.save({ model })} onSetReasoning={reasoning => execution.save({ reasoning })}
859
+ onSetPermission={permission_mode => execution.save({ permission_mode })} />
860
+ {slots?.composerToolbar}
861
+ </>}
862
+ queued={<QueuedMessages scopeKey={key ?? "new"} messages={queued} onCancel={handlers.onCancelQueued} attachmentUrl={handlers.attachmentUrl} />}
863
+ onSend={async text => { if (text.trim() === "/clear" && onClear) { onClear(); return; } const sentFrom = key; await send(text); if (!running && !batchPending && latest.current.key === sentFrom) scrollToBottom("auto"); }}
802
864
  onStop={cfg.composer.stop && key && conv && runningTurn(conv) ? stop : undefined}
803
865
  taskControl={cfg.composer.stop && !readOnlyProp && controlTask && jobId && store.client.pauseTask && store.client.resumeTask ? {
804
866
  paused: controlTask.status === "paused",
@@ -810,6 +872,14 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
810
872
  options={composerOptions}
811
873
  />
812
874
  )}
875
+ </div>
876
+ <WorkspaceDrawer enabled={mobileWorkspace} open={workspaceActive} onClose={showChat}>
877
+ {workspaceAvailable && <CliBoard jobId={key} inlineOwner={inlineOwner} quoteToChat={!readOnly} autoQuoteOpenFile={passiveContext}
878
+ readOnly={readOnly || externalBoardOptions?.readOnly} availableTabs={externalBoardOptions?.availableTabs}
879
+ acceptOpen={fileOpen !== "modal" && fileOpen !== "link"} theme={theme}
880
+ visible={workspaceActive} onExpand={showWorkspace} onQuoteToChat={quoteToChat}
881
+ inlineLayout={mobileWorkspace ? undefined : { tabsHost, active: workspaceActive, onActivate: showWorkspace, onChat: showChat }} />}
882
+ </WorkspaceDrawer>
813
883
  </div>
814
884
  {popup && key && board && (
815
885
  <div className="scrim" onClick={(e) => { if (e.target === e.currentTarget) setPopup(null); }}>