@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
@@ -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,19 @@ 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";
23
31
 
24
32
  /** onTurnEnd / onConversationEnd:浏览器里从实时流算出的结果通知,不依赖 center 的 callback.url。 */
25
33
  export interface CliChatProps extends CliStoreOptions, ResultHandlers {
34
+ /** 宿主提供的多发言人群聊;发送统一交给 group.onSend,不连接普通单任务转录。 */
35
+ group?: GroupChatAdapter;
26
36
  /** Integration slots share this transcript/composer; they do not create another chat implementation. */
27
37
  slots?: { beforeMessages?: ReactNode; afterMessages?: ReactNode; composerToolbar?: ReactNode; beforeComposer?: ReactNode };
28
38
  /** Saving execution settings blocks send until persistence completes. */
@@ -77,6 +87,7 @@ export interface CliChatProps extends CliStoreOptions, ResultHandlers {
77
87
 
78
88
  export function CliChat(props: CliChatProps) {
79
89
  const ctx = useCliStoreContext();
90
+ 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
91
  return <CliErrorBoundary component="cli-chat" theme={props.theme}>{withOwnProvider(props, <ChatInner {...props} inProvider={!!ctx} />, !!ctx)}</CliErrorBoundary>;
81
92
  }
82
93
 
@@ -143,6 +154,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
143
154
  // 内部用对话键(key):只有 job_id = 对话任务的第一个对话;"job#task" = 里面指定的那个对话(见 core/conversation.ts)。
144
155
  // 新对话的草稿按组件实例存(对话的草稿按对话键存,看同一个对话的组件共用一份是对的)。
145
156
  const instanceId = useId();
157
+ const inlineOwner = `chat-board-${instanceId}`;
146
158
  const controlled = jobIdProp !== undefined;
147
159
  // jobId 也可以直接给对话键(例如 cli-msglist 的 onSelect 给的 "job#task"),taskId 再给就以 taskId 为准。
148
160
  const keyProp = jobIdProp === undefined ? undefined : jobIdProp ? convKey(parseConvKey(jobIdProp).jobId, taskIdProp ?? parseConvKey(jobIdProp).taskId) : null;
@@ -164,6 +176,13 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
164
176
  }, [controlled, store]);
165
177
  const conv = useCliState((s) => (key && !isNew ? s.convs[key] : undefined)) ?? (key && !isNew ? emptyConv(key) : null);
166
178
  const entry = useCliState((s) => (jobId ? s.jobs[jobId] : undefined));
179
+ const externalBoardVisible = useCliState(s => !!(key && s.externalBoards[key]));
180
+ const listVisible = useCliState(s => s.listVisible);
181
+ const externalBoardOptions = useCliState(s => key ? s.externalBoardOptions[key] : undefined);
182
+ const [tabsHost, setTabsHost] = useState<HTMLDivElement | null>(null);
183
+ const [workspace, setWorkspace] = useState<{ key: string | null; active: boolean }>({ key: null, active: false });
184
+ const showChat = useCallback(() => setWorkspace({ key, active: false }), [key]);
185
+ const showWorkspace = useCallback(() => setWorkspace({ key, active: true }), [key]);
167
186
 
168
187
  // 看这个对话:开实时流(同一对话多处看只连一次)。
169
188
  useEffect(() => (key ? store.open(key) : undefined), [store, key]);
@@ -287,10 +306,6 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
287
306
  // 本组件的新建参数随每次发送带上:放进共享 Provider 时也用自己的,不依赖仓库是谁建的。
288
307
  const sendOpts = useRef({ newConversation, createConversation, businessIdFor, select: !controlled });
289
308
  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]);
294
309
  const send = useCallback(async (text: string) => {
295
310
  // 一次性任务:发给正在看的那个子任务(接着它聊)。
296
311
  const id = latest.current.conv?.batch ? latest.current.runKey : latest.current.key;
@@ -299,7 +314,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
299
314
  const refKey = latest.current.key;
300
315
  const refs = refKey ? store.takeRefs(refKey) : [];
301
316
  const passiveView=refKey?store.getState().boardContext[refKey]:null;
302
- const passive=passiveContext&&!skipPassiveRef.current&&passiveView?formatBoardContext(passiveView,refKey??undefined):'';
317
+ const passive=passiveContext&&passiveView?formatBoardContext(passiveView,refKey??undefined):'';
303
318
  const viewing = !passive && refKey ? store.takeViewing(refKey) : null;
304
319
  const blocks = [
305
320
  ...(passive ? [{name:"看板正在看",kind:"viewing" as const,text:passive}] : []),
@@ -309,13 +324,12 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
309
324
  const full = blocks.length ? [text, ...blocks.map(serializeBlock)].join("\n\n") : text;
310
325
  let target: string;
311
326
  try {
312
- if(charCount(full)>MAX_MESSAGE_CHARS)throw new Error("加上看板背景后消息过长,请精简内容或取消本次自动附带。");
327
+ if(charCount(full)>MAX_MESSAGE_CHARS)throw new Error("加上看板背景后消息过长,请精简消息内容后重试。");
313
328
  target = await store.send(id ?? null, full, sendOpts.current);
314
329
  } catch (error) {
315
330
  if (refKey) { store.restoreRefs(refKey, refs); store.restoreViewing(refKey, viewing); }
316
331
  throw error;
317
332
  }
318
- if(latest.current.key===refKey)setSkipPassive(false);
319
333
  if (target !== id && latest.current.key === refKey) go(target);
320
334
  }, [store, go, passiveContext]);
321
335
  const passiveView=useCliState(state=>key?state.boardContext[key]:undefined);
@@ -326,6 +340,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
326
340
  // 输入框上方显示哪些(config.refs):不显示的照样随消息带上。正在看的缺省不显示。
327
341
  const viewing = !passiveText && viewingRef && refShown(cfg, "viewing") ? viewingRef : null;
328
342
  const shownRefs = refs?.filter((r) => refShown(cfg, r.kind));
343
+ const chatRef = useRef<HTMLDivElement>(null);
329
344
  // 业务方补充的背景(store.appendContext):随下一条消息带上,输入框上方显示出来,让用户知道会带上什么。
330
345
  const contextItems = useCliState((s) => s.context[key ?? ""]);
331
346
  // AI 把浏览器交给用户(实时新来的「🖐 …」行):同一 Provider 里有看板在看这个对话,就把看板切到浏览器(收起的会请宿主展开)。
@@ -340,8 +355,8 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
340
355
  if (!seen.ids) { seen.ids = new Set(handoffs.map((m) => m.id)); return; }
341
356
  const fresh = handoffs.filter((m) => !seen.ids!.has(m.id));
342
357
  for (const m of fresh) seen.ids.add(m.id);
343
- if (fresh.length) store.openInBoard(key, { kind: "browser" });
344
- }, [key, conv, fileOpen, store]);
358
+ if (fresh.length) store.openInBoard(key, { kind: "browser" }, inlineOwner);
359
+ }, [key, conv, fileOpen, store, inlineOwner]);
345
360
 
346
361
  // 对话里弹窗打开的文件 / 浏览器(没有看板接的时候)。换对话就关掉。
347
362
  const [popup, setPopup] = useState<{ kind: "file"; path: string } | { kind: "browser" } | null>(null);
@@ -353,7 +368,12 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
353
368
  return () => window.removeEventListener("keydown", onKey);
354
369
  }, [popup]);
355
370
  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)});},
371
+ onQuote: readOnly ? undefined : (m,selected) => {
372
+ const id = latest.current.key;
373
+ if (!id) return;
374
+ store.addRef(id,{id:`message:${m.id}`,kind:"message",label:m.role==='user'?'引用你的消息':'引用智能体消息',text:selected||(m.role==='user'?withoutBoardContext(m.content):m.content)});
375
+ chatRef.current?.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus({ preventScroll: true });
376
+ },
357
377
  // 与 Operator 的 answerQuestion 一致:先把卡片标成已答,再把回答发出去(center 已结束提问那一轮,回答就是下一条消息)。
358
378
  onAnswer: readOnlyProp || (readOnly && !(batch && store.client.answerAsk)) ? undefined : async (askId: string, questions: AskQuestion[], answers: AskAnswers) => {
359
379
  const id = latest.current.key;
@@ -385,7 +405,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
385
405
  onShowBrowser: !store.client.board || fileOpen === "link" ? undefined : () => {
386
406
  const id = latest.current.key;
387
407
  if (!id) return;
388
- if (fileOpen !== "modal" && store.openInBoard(id, { kind: "browser" })) return;
408
+ if (fileOpen !== "modal" && store.openInBoard(id, { kind: "browser" }, inlineOwner)) return;
389
409
  if (fileOpen !== "board") setPopup({ kind: "browser" });
390
410
  },
391
411
  onRetry: readOnly ? undefined : (msgId: string) => {
@@ -393,7 +413,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
393
413
  const at = list.findIndex((m) => m.id === msgId);
394
414
  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
415
  },
396
- }), [readOnly, readOnlyProp, batch, chat, send, store, go, report, fileOpen]);
416
+ }), [readOnly, readOnlyProp, batch, chat, send, store, go, report, fileOpen, inlineOwner]);
397
417
  const stop = useCallback(async () => {
398
418
  const { key: id, conv: c } = latest.current;
399
419
  const turn = c ? runningTurn(c) : null;
@@ -428,6 +448,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
428
448
 
429
449
  // ── 分窗渲染(移植自 Operator 的 SessionView):DOM 里最多 RENDER_MAX 行,几千条消息也照样流畅 ──
430
450
  const scrollRef = useRef<HTMLDivElement>(null);
451
+ useChatScrollbar(scrollRef, key);
431
452
  const pinned = useRef(true);
432
453
  const [atBottom, setAtBottom] = useState(true);
433
454
  const committed = useMemo(() => messages.filter((m) => m.role !== "queued"), [messages]);
@@ -569,7 +590,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
569
590
  if (el && node) { const delta = node.getBoundingClientRect().top - a.top; if (Math.abs(delta) > 1) el.scrollTop += delta; }
570
591
  }
571
592
  }, [win]);
572
- useLayoutEffect(() => { if (pinned.current && scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; }, [messages, running, startIdx, endIdx]);
593
+ useLayoutEffect(() => { if (pinned.current && scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; }, [messages, running, startIdx, endIdx, win]);
573
594
  useEffect(() => {
574
595
  const el = scrollRef.current;
575
596
  if (!el || typeof ResizeObserver === "undefined") return;
@@ -601,6 +622,18 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
601
622
  const fileUrlRef = useRef(fileUrl);
602
623
  fileUrlRef.current = fileUrl;
603
624
  const board = store.client.board;
625
+ const mobileWorkspace = useWorkspaceDrawer(chatRef);
626
+ const workspaceAvailable = h.show && !!key && !isNew && !!board && !externalBoardVisible;
627
+ const workspaceTabs = workspaceAvailable && !mobileWorkspace;
628
+ const workspaceActive = workspaceAvailable && workspace.key === key && workspace.active;
629
+ const showCurrentTitle = h.title || externalBoardVisible || (mobileWorkspace && workspaceAvailable);
630
+ const quoteToChat = useCallback(() => {
631
+ const sourceKey = key;
632
+ showChat();
633
+ requestAnimationFrame(() => {
634
+ if (keyRef.current === sourceKey) chatRef.current?.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus({ preventScroll: true });
635
+ });
636
+ }, [key, showChat]);
604
637
  // 文件地址:宿主给的 fileUrl 优先;没给且后端有看板接口、地址能直接打开时,用 center 的文件接口。
605
638
  const resolveFile = useMemo(() => {
606
639
  if (fileUrl) return (path: string, kind: "link" | "image") => (jobId ? fileUrlRef.current?.(path, jobId, kind, taskId) ?? null : null);
@@ -611,27 +644,44 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
611
644
  const openFile = useMemo(() => {
612
645
  if (!key || fileOpen === "link") return null;
613
646
  return (path: string) => {
614
- if (fileOpen !== "modal" && store.openInBoard(key, { kind: "file", path })) return true;
647
+ if (fileOpen !== "modal" && store.openInBoard(key, { kind: "file", path }, inlineOwner)) return true;
615
648
  if (fileOpen === "board" || !board) return false;
616
649
  setPopup({ kind: "file", path });
617
650
  return true;
618
651
  };
619
- }, [key, fileOpen, store, board]);
652
+ }, [key, fileOpen, store, board, inlineOwner]);
653
+ const uploadDocument = useMemo<UploadDocument | undefined>(() => {
654
+ const staging = store.client.uploadDocument;
655
+ if (!key || isNew || !board?.uploadFile) return staging;
656
+ return async (file, options) => {
657
+ const target = await board.info(key);
658
+ if (options.signal?.aborted) throw new DOMException("上传已取消", "AbortError");
659
+ if (!target.dispatched) {
660
+ if (staging) return staging(file, options);
661
+ throw new Error("当前会话工作区尚未就绪");
662
+ }
663
+ const receipt = await board.uploadFile!(key, file, options);
664
+ if (!receipt.path) throw new Error("文件尚未进入当前工作区,请重试");
665
+ if (!options.signal?.aborted && latest.current.key === key) openFile?.(receipt.path);
666
+ return receipt;
667
+ };
668
+ }, [store, key, isNew, board, openFile]);
620
669
 
621
670
  return (
622
671
  <FileLinkContext.Provider value={resolveFile}>
623
672
  <FileOpenContext.Provider value={openFile}>
624
- <div className={`cli-ui cli-chat ${className ?? ""}`} data-theme={theme} style={style}>
673
+ <div ref={chatRef} className={`cli-ui cli-chat ${className ?? ""}`} data-theme={theme} style={style}>
625
674
  <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">
675
+ {h.show && (workspaceAvailable || externalBoardVisible || h.crumb || h.export || h.title || h.agent || h.usage || h.model || h.status || h.close) && <div className="sess-head">
676
+ {workspaceTabs ? <WorkspaceTabs title={convTitle} subtitle={h.crumb ? title : undefined} chatActive={!workspaceActive} onChat={showChat} onTabsHost={setTabsHost} /> : (h.crumb || showCurrentTitle) && <div className="sh-main">
628
677
  {h.crumb && <div className="crumb">
629
678
  <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
679
  {title} <span className="sep">/</span> 对话
631
680
  </div>}
632
- {h.title && <div className="sh-title"><span className="sht-text" title={convTitle}>{convTitle}</span></div>}
681
+ {showCurrentTitle && <div className="sh-title"><span className="sht-text" title={convTitle}>{convTitle}</span></div>}
633
682
  </div>}
634
683
  <div className="sh-tools">
684
+ {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
685
  {h.agent && agent && <AgentBadge label={agentText} />}
636
686
  {h.usage && (fresh > 0 || cacheRead > 0) && (
637
687
  <span className="usage-chip" title={`新增 ${fresh.toLocaleString()} tokens${cacheRead ? `\n缓存读取 ${cacheRead.toLocaleString()}(每轮重复读取的上下文)` : ""}`}>
@@ -661,6 +711,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
661
711
  </div>
662
712
  </div>}
663
713
 
714
+ <div className="workspace-chat-content" data-chat-layout={listVisible || externalBoardVisible ? "wide" : "centered"} hidden={workspaceActive && !mobileWorkspace} style={workspaceActive && !mobileWorkspace ? { display: "none" } : undefined}>
664
715
  {closing !== "idle" && key && (
665
716
  <div className="clear-confirm">
666
717
  <span>结束这个对话?结束后不能再发消息{running ? ",正在执行的一轮会被中止" : ""}{taskId && manyConvs ? ";同一任务里的其他对话不受影响" : ""}。</span>
@@ -737,7 +788,6 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
737
788
  </div>
738
789
  )}
739
790
  {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
791
  {ended && cfg.transcript.status && (
742
792
  <div className="session-label"><span className="ln" />{batch ? statusText : <>{REASON_TEXT[closedReason ?? ""] ?? "对话已结束"}{!finalEnd ? " · 再发消息会开一个新对话接着聊" : ""}</>}<span className="ln" /></div>
743
793
  )}
@@ -763,42 +813,24 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
763
813
  ))}
764
814
  </div>
765
815
  )}
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
816
  {slots?.beforeComposer}
817
+ {!showComposer && <QueuedMessages scopeKey={key ?? "new"} messages={queued} onCancel={handlers.onCancelQueued} attachmentUrl={handlers.attachmentUrl} />}
792
818
  {showComposer && (
793
819
  <Composer
820
+ uploadDocument={uploadDocument}
794
821
  key={key ?? "new"}
795
822
  draft={store.draftScope ? `${store.draftScope}:${key ?? `new:${instanceId}`}` : key ?? `new:${instanceId}`}
823
+ context={(!!shownRefs?.length || !!viewing) && <div className="cli-refs composer-references" aria-label="下一条消息的引用">
824
+ {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 }); }} />}
825
+ {(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 }); }} />)}
826
+ </div>}
796
827
  placeholder={fillTemplate(key && !isNew ? cfg.composer.placeholder : cfg.composer.placeholderNew, { title: convTitle, agent: agentText })}
797
828
  disabled={composerDisabled || !!configurationBusy}
798
829
  disabledHint={cfg.composer.placeholderDisabled ? fillTemplate(cfg.composer.placeholderDisabled, { title: convTitle, agent: agentText }) : disabledHint}
799
830
  running={running || batchPending}
800
831
  toolbar={slots?.composerToolbar}
801
- onSend={async text => { if (text.trim() === "/clear" && onClear) { onClear(); return; } await send(text); }}
832
+ queued={<QueuedMessages scopeKey={key ?? "new"} messages={queued} onCancel={handlers.onCancelQueued} attachmentUrl={handlers.attachmentUrl} />}
833
+ 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
834
  onStop={cfg.composer.stop && key && conv && runningTurn(conv) ? stop : undefined}
803
835
  taskControl={cfg.composer.stop && !readOnlyProp && controlTask && jobId && store.client.pauseTask && store.client.resumeTask ? {
804
836
  paused: controlTask.status === "paused",
@@ -810,6 +842,14 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
810
842
  options={composerOptions}
811
843
  />
812
844
  )}
845
+ </div>
846
+ <WorkspaceDrawer enabled={mobileWorkspace} open={workspaceActive} onClose={showChat}>
847
+ {workspaceAvailable && <CliBoard jobId={key} inlineOwner={inlineOwner} quoteToChat={!readOnly} autoQuoteOpenFile={passiveContext}
848
+ readOnly={readOnly || externalBoardOptions?.readOnly} availableTabs={externalBoardOptions?.availableTabs}
849
+ acceptOpen={fileOpen !== "modal" && fileOpen !== "link"} theme={theme}
850
+ visible={workspaceActive} onExpand={showWorkspace} onQuoteToChat={quoteToChat}
851
+ inlineLayout={mobileWorkspace ? undefined : { tabsHost, active: workspaceActive, onActivate: showWorkspace, onChat: showChat }} />}
852
+ </WorkspaceDrawer>
813
853
  </div>
814
854
  {popup && key && board && (
815
855
  <div className="scrim" onClick={(e) => { if (e.target === e.currentTarget) setPopup(null); }}>