@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
package/dist/cli-ui.js CHANGED
@@ -1,4 +1,4 @@
1
- import { C as a, b as i, d as r, e as t, f as o, h as C, i as l, j as n, k as m, m as d, l as u, n as w, o as B, p as f } from "./cli-ui-embed-BAqonuoD.js";
1
+ import { C as a, b as i, d as r, e as t, f as o, h as C, i as l, j as n, k as m, m as d, l as u, n as w, o as B, p as f } from "./cli-ui-embed-CB7ibAtF.js";
2
2
  export {
3
3
  a as CliBoard,
4
4
  i as CliBrowserView,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spzxg/cli-ui",
3
- "version": "0.6.6",
3
+ "version": "0.6.7",
4
4
  "type": "module",
5
5
  "description": "相互联动的 CliMsgList、CliChat、CliBoard,支持任务中枢和完整 Operator 工作区。",
6
6
  "main": "src/index.ts",
@@ -41,6 +41,7 @@
41
41
  "@types/react": "19.2.17",
42
42
  "@types/react-dom": "19.2.3",
43
43
  "@vitejs/plugin-react": "4.7.0",
44
+ "fake-indexeddb": "^6.2.7",
44
45
  "jsdom": "^25.0.1",
45
46
  "postcss": "^8.5.32",
46
47
  "typescript": "^5.7.3",
@@ -51,6 +52,7 @@
51
52
  "@xterm/addon-fit": "^0.11.0",
52
53
  "@xterm/addon-web-links": "^0.12.0",
53
54
  "@xterm/xterm": "^6.0.0",
55
+ "htmlparser2": "10.0.0",
54
56
  "jszip": "^3.10.2",
55
57
  "mammoth": "^1.13.0",
56
58
  "react-markdown": "^10.1.0",
@@ -1,3 +1,5 @@
1
+ import { retryWorkspaceFiles } from "./fileWatch";
2
+ import { retainFileScope } from "./fileScope";
1
3
  // cli-board:右侧看板。界面取自 Operator 的会话右栏(SessionRail):文件 / 改动 / 浏览器三个页签。
2
4
  // 数据经 center 的 /api/jobs/:id/board、files、diff、browser 转发自执行节点(浏览器不直连节点)。
3
5
  //
@@ -5,17 +7,16 @@
5
7
  // · 改动:分支上的 diff(只读);
6
8
  // · 浏览器:任务浏览器的实时画面、接管 / 交还、打开 / 关闭。
7
9
  //
8
- // 同步:任务每有进展(实时流里的轮次 / 输出变化)就刷新,运行中再定时刷新;打开着的文件改了会自动重读。
10
+ // 同步:每轮实时输出结束、恢复页面或手动刷新时检查打开的文件,只读取缺失变化块;没有周期轮询。
9
11
  // 和 cli-chat 放在同一个 Provider 里时,对话里点本地文件 / 「查看浏览器」会在这里打开(store.openInBoard)。
10
12
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
13
+ import { createPortal } from "react-dom";
11
14
  import { CliErrorBoundary } from "../ui/ErrorBoundary";
12
15
  import type { BoardRef, CliEvent, CliStore, CliStoreOptions, OpenTarget } from "../core/store";
13
16
  import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider } from "../core/provider";
14
17
  import { convKey, parseConvKey, isRunning } from "../core/conversation";
15
- import { toolDataOf } from "../ui/transcriptModel";
16
18
  import type { BoardInfo, FileEntry } from "../core/types";
17
19
  import { Icon } from "../ui/icons";
18
- import { CenterError } from "../core/client";
19
20
  import { FileViewer, type FileAction } from "./FileViewer";
20
21
  import { FilesPane } from "./FilesPane";
21
22
  import { ChangesPane } from "./ChangesPane";
@@ -24,7 +25,9 @@ import { rememberBoardFile } from "./context";
24
25
  import type { BoardFile } from "./workspace";
25
26
  import type { WorkspaceHandlers } from "../core/provider";
26
27
  import { useBoardPanels } from "./panels";
27
- import { PanelControls, PanelTab } from "./PanelControls";
28
+ import { PanelControls, PanelTab, type InlineBoardLayout } from "./PanelControls";
29
+ import { useBoardVisibility } from "./useBoardVisibility";
30
+ import { useBoardUpload } from "./useBoardUpload";
28
31
 
29
32
  export type BoardTab = "files" | "changes" | "browser";
30
33
 
@@ -34,7 +37,7 @@ export interface BoardExtraTab {
34
37
  label: string;
35
38
  render: (ctx: { jobId: string; store: CliStore; quote: (ref: Omit<BoardRef, "id"> & { id?: string }) => void }) => ReactNode;
36
39
  }
37
- const TAB_LABEL: Record<BoardTab, string> = { files: "文件", changes: "改动", browser: "浏览器" };
40
+ const TAB_LABEL: Record<BoardTab, string> = { files: "工作区", changes: "改动", browser: "浏览器" };
38
41
  /** 同时开着的文件页签最多几个:再开新的,关掉最早打开、当前没在看的那个。 */
39
42
  export const MAX_FILE_TABS = 8;
40
43
  const FILE_TAB = "file:";
@@ -53,12 +56,19 @@ const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayout
53
56
  function saveTabs(jobId: string, files: string[], tab: string, history: BoardFile[]) {
54
57
  try { sessionStorage.setItem(tabsKey(jobId), JSON.stringify({ files, tab, history })); } catch { /* 存不下就不记 */ }
55
58
  }
56
- /** 有输出就刷新文件,但两次之间至少隔这么久(智能体连写几十个文件时不至于每个都打一次中枢)。 */
57
- const ACTIVITY_GAP_MS = 700;
58
59
 
59
60
  export interface CliBoardProps extends CliStoreOptions, WorkspaceHandlers {
60
- /** 默认监听整个工作区,收起后继续。旧中枢降级状态可由 onFilesWatch 观察。 */
61
+ /** @internal 同一个看板在 Chat 标题区放置页签。 */
62
+ inlineLayout?: InlineBoardLayout;
63
+ /** @internal 将当前 Chat 的打开请求交给自己的内嵌看板。 */
64
+ inlineOwner?: string;
65
+ /** @internal 手机抽屉沿用看板内容,独立控制它的可见状态。 */
66
+ visible?: boolean;
67
+ /** @internal 引用后返回当前 Chat。 */
68
+ onQuoteToChat?: () => void;
69
+ /** 默认在轮次结束、恢复页面和手动刷新时检查已打开文件与可见目录。收起后暂停文件读取。 */
61
70
  watchFiles?: boolean;
71
+ /** @deprecated 保留兼容;文件同步由事件触发,不再使用定时间隔。 */
62
72
  fileWatchIntervalMs?: number;
63
73
  /** 看哪个对话(center 的 job_id)。不给就跟共享仓库的选中走(和 cli-msglist / cli-chat 联动)。 */
64
74
  jobId?: string | null;
@@ -73,7 +83,7 @@ export interface CliBoardProps extends CliStoreOptions, WorkspaceHandlers {
73
83
  readOnly?: boolean;
74
84
  /** 文件上的自定义操作(预览头部的按钮)。 */
75
85
  fileActions?: FileAction[];
76
- /** 运行中兜底刷新文件列表的间隔,缺省 15000ms(文件变化主要靠实时流里的工具结果触发,这里只兜底;人多时每人每 4 秒去节点列一次目录太重);0 = 只在实时流有变化时刷新。改动(diff)不轮询,只在有进展时刷新。 */
86
+ /** @deprecated 保留兼容;文件同步由事件触发,不再进行定时轮询。 */
77
87
  pollMs?: number;
78
88
  /** 接不接对话里的「打开文件 / 查看浏览器」,缺省接。 */
79
89
  acceptOpen?: boolean;
@@ -113,15 +123,23 @@ export function CliBoard(props: CliBoardProps) {
113
123
  return <CliErrorBoundary component="cli-board" theme={props.theme}>{withOwnProvider(props, <BoardInner {...props} inProvider={!!ctx} />, !!ctx)}</CliErrorBoundary>;
114
124
  }
115
125
 
116
- interface OpenFile { reloadKey?:number; path: string; size?: number; mtime?: number; version: number }
126
+ interface OpenFile { name?: string; reloadKey?:number; path: string; size?: number; mtime?: number; version: number }
117
127
 
118
- function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs, defaultTab, readOnly, fileActions, pollMs = 15_000, acceptOpen = true, quoteToChat, autoQuoteOpenFile, extraTabs, onCollapse, collapsed, onExpand, onNavigate, onEvent, onFilesChange, onFilesWatch, onBoardContextChange, watchFiles = true, fileWatchIntervalMs, theme = "light", className, style, inProvider }: CliBoardProps & { inProvider: boolean }) {
128
+ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs, defaultTab, readOnly, fileActions, acceptOpen = true, quoteToChat, autoQuoteOpenFile, extraTabs, onCollapse, collapsed, onExpand, onNavigate, onEvent, onFilesChange, onFilesWatch, onBoardContextChange, watchFiles = true, fileWatchIntervalMs, theme = "light", className, style, inProvider, inlineLayout, inlineOwner, visible: visibilityOverride, onQuoteToChat }: CliBoardProps & { inProvider: boolean }) {
119
129
  useComponentEvents(onEvent, {onFilesChange,onFilesWatch,onBoardContextChange}, inProvider);
120
130
  const store = useCliStore();
121
131
  const backend = store.client.board;
122
132
  const selected = useCliState((s) => s.selected);
123
133
  // 内部用对话键(见 core/conversation.ts):看板接口按它找对话任务里的那个对话。
124
134
  const jobId = jobIdProp !== undefined ? (jobIdProp ? convKey(parseConvKey(jobIdProp).jobId, taskIdProp ?? parseConvKey(jobIdProp).taskId) : null) : selected;
135
+ const rootRef = useRef<HTMLElement | null>(null);
136
+ const isInline = !!inlineOwner || !!inlineLayout;
137
+ const cssVisible = useBoardVisibility(rootRef, !isInline);
138
+ const visible = !collapsed && (isInline ? visibilityOverride ?? inlineLayout?.active ?? true : cssVisible);
139
+ const layoutRef = useRef(inlineLayout);
140
+ layoutRef.current = inlineLayout;
141
+ const quoteCallbackRef = useRef(onQuoteToChat);
142
+ quoteCallbackRef.current = onQuoteToChat;
125
143
 
126
144
  const storageId = jobId && store.draftScope ? `${store.draftScope}:${jobId}` : jobId;
127
145
  const [info, setInfo] = useState<BoardInfo | null>(null);
@@ -131,12 +149,8 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs,
131
149
  const [dir, setDir] = useState("");
132
150
  const [files, setFiles] = useState<OpenFile[]>([]);
133
151
  const [history,setHistory] = useState<BoardFile[]>([]);
134
- // 刷新信号,按代价分开:
135
- // tick 进展(实时流有新输出 / 轮次变化,节流)、切回页面、手动刷新 —— 文件、改动、打开的文件都跟着刷新;
136
- // pollTick 运行中的定时刷新 —— 只刷文件列表和打开的文件(改动要在节点上跑 git diff,不轮询);
137
- // statusKey 轮次 / 对话状态变化 —— 才重新查看板概况(它会探测节点的浏览器接口,不随输出刷新)。
152
+ // 文件变化、恢复页面与手动刷新更新视图;流式轮末检查由 store 直接触发。
138
153
  const [tick, setTick] = useState(0);
139
- const [pollTick, setPollTick] = useState(0);
140
154
 
141
155
  // 换了对话:回到初始状态。
142
156
  const lastJob = useRef(jobId);
@@ -147,14 +161,8 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs,
147
161
  }
148
162
  // 恢复上次开着的文件页签(存在 sessionStorage):挂上之后、绘制之前做——服务端渲染和首次渲染一致,不会对不上。
149
163
  const restored = useRef<string | null>(null);
150
- useIsoLayoutEffect(() => {
151
- if (!storageId || restored.current === storageId) return;
152
- restored.current = storageId;
153
- const saved = loadTabs(storageId);
154
- setHistory(saved.history.map(file=>({...file,sourceId:file.sourceId??jobId??undefined})));
155
- if (saved.files.length) setFiles((cur) => (cur.length ? cur : saved.files.map((path) => ({ path, version: 0 }))));
156
- if (saved.tab) setTab((cur) => (cur === (defaultTab ?? tabs?.[0] ?? "files") ? saved.tab! : cur));
157
- }, [storageId]);
164
+ const [hydrated, setHydrated] = useState<{ id: string | null; visible: boolean }>({ id: null, visible: false });
165
+ const restoreNeeded = hydrated.id !== storageId || (!isInline && visible && !hydrated.visible);
158
166
 
159
167
  // 实时流:看板单独用时也要知道任务在不在跑(同一对话只连一次)。
160
168
  useEffect(() => (jobId ? store.open(jobId) : undefined), [store, jobId]);
@@ -164,67 +172,58 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs,
164
172
  const statusKey = conv
165
173
  ? `${conv.turns.map((t) => `${t.turn_id}:${t.status}`).join(",")}|${conv.job?.status ?? ""}|${conv.task?.id ?? ""}:${conv.task?.status ?? ""}:${conv.remoteTaskId ?? conv.task?.remote_task_id ?? ""}`
166
174
  : "";
167
- // 能改动文件的只有工具调用:签名 = 轮次状态 + 消息条数 + 最近一个工具拿没拿到结果(拿到结果就是文件刚写完)。
168
- // 智能体逐字输出回复时条数和工具都不变,不触发刷新——几十个人同时看也不会每秒去节点上列一遍目录。
169
- const lastTool = useMemo(() => {
170
- const msgs = conv?.msgs ?? [];
171
- for (let i = msgs.length - 1, n = 0; i >= 0 && n < 50; i--, n++) if (msgs[i].role === "tool") return `${msgs[i].id}:${toolDataOf(msgs[i]).result !== undefined}`;
172
- return "";
173
- }, [conv?.msgs]);
174
- const activity = conv ? `${statusKey}|${conv.msgs.length}|${lastTool}` : "";
175
-
176
175
  const bump = useCallback(() => setTick((n) => n + 1), []);
177
- // 手动刷新(页签栏的 ↻):连看板概况一起重查。
178
176
  const [manual, setManual] = useState(0);
179
- const refreshAll = useCallback(() => { setManual((n) => n + 1); bump(); }, [bump]);
180
- const lastBump = useRef(0);
181
- const pending = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
182
- useEffect(() => {
183
- if (!activity) return;
184
- const wait = Math.max(0, ACTIVITY_GAP_MS - (Date.now() - lastBump.current));
185
- if (pending.current) clearTimeout(pending.current);
186
- pending.current = setTimeout(() => { lastBump.current = Date.now(); bump(); }, wait);
187
- return () => { if (pending.current) clearTimeout(pending.current); };
188
- }, [activity, bump]);
189
- useEffect(() => {
190
- if (!running || !pollMs) return;
191
- const t = setInterval(() => { if (document.visibilityState === "visible") setPollTick((n) => n + 1); }, pollMs);
192
- return () => clearInterval(t);
193
- }, [running, pollMs, bump]);
177
+ const refreshAll = useCallback(() => {
178
+ setManual((n) => n + 1); bump();
179
+ if (backend && jobId) retryWorkspaceFiles(backend, jobId, { manual: true });
180
+ }, [bump, backend, jobId]);
194
181
  useEffect(() => {
195
182
  const on = () => { if (document.visibilityState === "visible") bump(); };
196
183
  document.addEventListener("visibilitychange", on);
197
184
  return () => document.removeEventListener("visibilitychange", on);
198
185
  }, [bump]);
199
186
 
200
- // 看板信息:还没派发到节点时每 3 秒再问一次。
187
+ // 派发和节点变化由已有聊天流驱动,不为文件看板再开启轮询。
201
188
  useEffect(() => {
202
189
  if (!backend || !jobId) return;
203
190
  let alive = true;
204
- let timer: ReturnType<typeof setTimeout> | undefined;
205
- const load = () => backend.info(jobId).then((i) => {
191
+ void backend.info(jobId).then((i) => {
206
192
  if (!alive) return;
207
193
  setInfo((old) => (old && old.dispatched === i.dispatched && old.remote_task_id === i.remote_task_id && old.files === i.files && old.browser === i.browser && old.node?.health === i.node?.health ? old : i));
208
194
  setInfoError(null);
209
- if (!i.dispatched) timer = setTimeout(load, 3000);
210
- }, (e: unknown) => {
211
- if (!alive) return;
212
- setInfoError(e instanceof Error ? e.message : String(e));
213
- timer = setTimeout(load, 5000);
214
- });
215
- void load();
216
- return () => { alive = false; if (timer) clearTimeout(timer); };
217
- }, [backend, jobId, statusKey, manual]); // eslint-disable-line react-hooks/exhaustive-deps
195
+ }, (e: unknown) => { if (alive) setInfoError(e instanceof Error ? e.message : String(e)); });
196
+ return () => { alive = false; };
197
+ }, [backend, jobId, statusKey, manual]);
218
198
 
219
199
  const builtinTabs = useMemo<string[]>(() => (availableTabs ?? ["files", "changes", "browser"]).filter(t => ["files","changes","browser"].includes(t)).filter(t => t !== "browser" || !info || info.browser !== false).filter(t => t !== "files" || !info || info.files !== false), [availableTabs, info?.browser, info?.files]);
220
200
  const allowedPanels = [...builtinTabs, ...(extraTabs ?? []).map(t => t.id)];
221
201
  const panels = useBoardPanels(jobId ? `center:${storageId}` : null, allowedPanels, [...(tabs ?? builtinTabs.filter(tab=>tab!=="changes")), ...(extraTabs ?? []).map(t => t.id)]);
202
+ useIsoLayoutEffect(() => {
203
+ if (!restoreNeeded) {
204
+ if (!visible && hydrated.visible) setHydrated({ id: storageId, visible: false });
205
+ return;
206
+ }
207
+ if (storageId) {
208
+ const current = jobId ? store.getState().boardContext[jobId] : undefined;
209
+ const saved = loadTabs(storageId);
210
+ const paths = current?.files ?? saved.files;
211
+ restored.current = storageId;
212
+ setFiles(previous => paths.map(path => previous.find(file => file.path === path) ?? { path, version: 0 }));
213
+ setHistory((current?.history ?? saved.history).map(file => ({ ...file, sourceId: file.sourceId ?? jobId ?? undefined })));
214
+ setDir(current?.dir ?? "");
215
+ const selectedTab = current?.tab ?? saved.tab;
216
+ if (selectedTab) { setTab(selectedTab); if (allowedPanels.includes(selectedTab)) panels.show(selectedTab); }
217
+ }
218
+ setHydrated({ id: storageId, visible });
219
+ }, [store, storageId, jobId, visible, restoreNeeded]); // eslint-disable-line react-hooks/exhaustive-deps
222
220
  const shownTabs = [...panels.open, ...(builtinTabs.includes("files") ? files.map(f => fileTabId(f.path)) : [])];
223
221
  const activeTab = shownTabs.includes(tab) ? tab : shownTabs[0] ?? "";
224
222
  const file = activeTab.startsWith(FILE_TAB) ? files.find((f) => fileTabId(f.path) === activeTab) ?? null : null;
225
223
  useEffect(()=>{if(file&&jobId)setHistory(previous=>{const next=rememberBoardFile(previous,{path:file.path,sourceId:jobId});return JSON.stringify(next)===JSON.stringify(previous)?previous:next;});},[file?.path,jobId]);
226
- const tabLabel = (t: string) => TAB_LABEL[t as BoardTab] ?? (t.startsWith(FILE_TAB) ? t.slice(FILE_TAB.length).split("/").pop() || t : undefined) ?? extraTabs?.find((x) => x.id === t)?.label ?? t;
224
+ const tabLabel = (t: string) => TAB_LABEL[t as BoardTab] ?? (t.startsWith(FILE_TAB) ? files.find(file => file.path === t.slice(FILE_TAB.length))?.name || t.slice(FILE_TAB.length).split("/").pop() || t : undefined) ?? extraTabs?.find((x) => x.id === t)?.label ?? t;
227
225
  // 页签多了横向滚动:当前页签总是滚到看得见的地方(新开的文件页签在最后,窄看板里容易被挤出去)。
226
+ const [toolbarHost, setToolbarHost] = useState<HTMLSpanElement | null>(null);
228
227
  const tabsRef = useRef<HTMLDivElement | null>(null);
229
228
  const revealTab = useCallback(() => {
230
229
  // 直接算滚动位置,不用 scrollIntoView:它会连带滚动宿主页面。
@@ -242,7 +241,7 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs,
242
241
  let alive = true;
243
242
  void document.fonts?.ready.then(() => { if (alive) revealTab(); });
244
243
  return () => { alive = false; timers.forEach(clearTimeout); };
245
- }, [activeTab, shownTabs.length, collapsed, revealTab]);
244
+ }, [activeTab, shownTabs.length, collapsed, visible, inlineLayout?.active, inlineLayout?.tabsHost, revealTab]);
246
245
  // 看板宽度或页签宽度变了(分栏拖动、刚挂上时分栏才给出宽度、字体加载完页签变宽):再对一次。
247
246
  useEffect(() => {
248
247
  const box = tabsRef.current;
@@ -251,7 +250,7 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs,
251
250
  ro.observe(box);
252
251
  for (const child of box.children) ro.observe(child);
253
252
  return () => ro.disconnect();
254
- }, [collapsed, revealTab, shownTabs.length]);
253
+ }, [collapsed, visible, inlineLayout?.tabsHost, revealTab, shownTabs.length]);
255
254
  // 记下开着的文件页签和当前页签(刷新页面后恢复)。恢复之前不写,免得用空的盖掉上次的。
256
255
  useEffect(() => { if (storageId && restored.current === storageId) saveTabs(storageId, files.map((f) => f.path), activeTab, history); }, [storageId, files, activeTab, history]);
257
256
  // 引用到对话:和对话在同一个 Provider 里时缺省开。
@@ -265,21 +264,25 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs,
265
264
  },[store,backend,jobId,watchFiles,fileWatchIntervalMs,info?.dispatched,info?.files]);
266
265
  const canQuote = (quoteToChat ?? inProvider) && !!jobId;
267
266
  const quote = useCallback((ref: Omit<BoardRef, "id"> & { id?: string }) => {
268
- if (jobId) store.addRef(jobId, { ...ref, id: ref.id ?? `${ref.kind}:${ref.label}` });
267
+ if (jobId) { store.addRef(jobId, { ...ref, id: ref.id ?? `${ref.kind}:${ref.label}` }); if (quoteCallbackRef.current) quoteCallbackRef.current(); else layoutRef.current?.onChat(); }
269
268
  }, [store, jobId]);
270
- const quoteText = useCallback((path: string, text: string) => {
269
+ const quoteText = useCallback((path: string, text: string, context?: { revision: string }) => {
271
270
  const name = path.split("/").pop() || path;
272
271
  const short = text.replace(/\s+/g, " ").slice(0, 24);
273
272
  // 同一个文件里选不同的段落各算一条(按内容区分),同一段重复点只留一条。
274
- quote({ id: `sel:${path}:${text.length}:${short}`, kind: "selection", label: `${name}:${short}${text.length > 24 ? "…" : ""}`, text: `工作区文件 ${path} 里选中的这段:\n${text.split("\n").map((l) => ` > ${l}`).join("\n")}` });
273
+ quote({ id: `sel:${path}:${text.length}:${short}`, kind: "selection", label: `${name}:${short}${text.length > 24 ? "…" : ""}`, text: `${context ? `文件修订:${context.revision}\n` : ""}工作区文件 ${path} 里选中的这段:\n${text.split("\n").map((l) => ` > ${l}`).join("\n")}` });
275
274
  }, [quote]);
276
275
  // 正在看的东西(打开的文件、浏览器当前页面):告诉对话(缺省带进下一条消息一次)。收起、切走、关掉、离开时撤掉。
277
276
  const autoView = (autoQuoteOpenFile ?? canQuote) && !!jobId;
278
277
  const [browserPage, setBrowserPage] = useState<{ url: string; title: string } | null>(null);
279
- const viewingPath = autoView && !collapsed && file ? file.path : null;
280
- const viewingPage = autoView && !collapsed && activeTab === "browser" && browserPage ? browserPage : null;
278
+ const viewingPath = autoView && visible && file ? file.path : null;
279
+ const viewingPage = autoView && visible && activeTab === "browser" && browserPage ? browserPage : null;
281
280
  // 这个看板自己的标识:同一个对话开着两个看板时,各撤各的「正在看」和看板状态。
282
281
  const owner = useMemo(() => `board-${Math.random().toString(36).slice(2, 10)}`, []);
282
+ useIsoLayoutEffect(() => {
283
+ if (jobId && !isInline) store.reportBoardPresence(jobId, owner, visible, { availableTabs, readOnly });
284
+ }, [store, jobId, owner, visible, isInline, availableTabs?.join(","), readOnly]); // eslint-disable-line react-hooks/exhaustive-deps
285
+ useIsoLayoutEffect(() => () => { if (jobId && !isInline) store.reportBoardPresence(jobId, owner, null); }, [store, jobId, owner, isInline]);
283
286
  useEffect(() => {
284
287
  if (!jobId || !autoView) return;
285
288
  store.setViewing(jobId, viewingPath
@@ -303,14 +306,14 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs,
303
306
  backend.stat(jobId, path).then((s) => {
304
307
  if (!alive || s.type !== "file") return;
305
308
  // 第一次拿到修改时间(从对话里打开的、刷新后恢复的)只记下来;之后变了才重读。
306
- update((f) => (f.mtime === s.mtime ? f : { ...f, size: s.size, mtime: s.mtime, version: f.mtime === undefined ? f.version : f.version + 1 }));
309
+ update((f) => (f.mtime === s.mtime && f.name === s.name ? f : { ...f, name: s.name, size: s.size, mtime: s.mtime, version: f.mtime === undefined ? f.version : f.version + 1 }));
307
310
  }, (e: unknown) => {
308
311
  // 打开着的文件被删了:重读一次,预览里显示「文件不存在」,不继续显示旧内容。
309
- if (alive && e instanceof CenterError && e.status === 404) update((f) => (f.mtime !== -1 ? { ...f, mtime: -1, version: f.version + 1 } : f));
312
+ if (alive && (e as { status?: number }).status === 404) update((f) => (f.mtime !== -1 ? { ...f, mtime: -1, version: f.version + 1 } : f));
310
313
  });
311
314
  return () => { alive = false; };
312
315
  // 只看正在看的那个文件:切到别的文件页签时查一次它(后台的页签不轮询)。
313
- }, [tick, pollTick, file?.path, info?.dispatched]); // eslint-disable-line react-hooks/exhaustive-deps
316
+ }, [tick, file?.path, info?.dispatched]); // eslint-disable-line react-hooks/exhaustive-deps
314
317
 
315
318
  const activeRef = useRef(activeTab);
316
319
  activeRef.current = activeTab;
@@ -320,8 +323,8 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs,
320
323
  if (!clean) return;
321
324
  setFiles((list) => {
322
325
  const have = list.find((f) => f.path === clean);
323
- if (have) return list.map((f) => (f.path === clean ? { ...f, size: entry?.size ?? f.size, mtime: entry?.mtime ?? f.mtime, version: f.version + 1 } : f));
324
- const next = [...list, { path: clean, size: entry?.size, mtime: entry?.mtime, version: 0 }];
326
+ if (have) return list.map((f) => (f.path === clean ? { ...f, name: entry?.name ?? f.name, size: entry?.size ?? f.size, mtime: entry?.mtime ?? f.mtime, version: f.version + 1 } : f));
327
+ const next = [...list, { path: clean, name: entry?.name, size: entry?.size, mtime: entry?.mtime, version: 0 }];
325
328
  while (next.length > MAX_FILE_TABS) {
326
329
  const i = next.findIndex((f) => fileTabId(f.path) !== activeRef.current && f.path !== clean);
327
330
  next.splice(i >= 0 ? i : 0, 1);
@@ -332,6 +335,13 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs,
332
335
  const parent = clean.includes("/") && !clean.startsWith("/") ? clean.split("/").slice(0, -1).join("/") : "";
333
336
  setDir(parent);
334
337
  }, []);
338
+ const uploadToWorkspace = useMemo(() => !readOnly && jobId && backend?.uploadFile
339
+ ? (file: File, options: import("../core/documentUpload").DocumentUploadOptions) => backend.uploadFile!(jobId, file, options)
340
+ : undefined, [backend, jobId, readOnly]);
341
+ const upload = useBoardUpload(jobId, uploadToWorkspace, receipt => {
342
+ openPath(receipt.path!, { name: receipt.name, size: receipt.size });
343
+ setTick(value => value + 1);
344
+ });
335
345
  /** 关文件页签:关的是正在看的,就切到它旁边的文件页签,没有了回文件列表。 */
336
346
  const closeFile = useCallback((path: string) => {
337
347
  setFiles((list) => {
@@ -348,6 +358,8 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs,
348
358
  jobRef.current = jobId;
349
359
  const expandRef = useRef(onExpand);
350
360
  expandRef.current = onExpand;
361
+ const visibleRef = useRef(visible);
362
+ visibleRef.current = visible;
351
363
  useEffect(() => {
352
364
  if (!acceptOpen || !backend) return;
353
365
  return store.registerBoard((target: string, what: OpenTarget) => {
@@ -359,30 +371,38 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs,
359
371
  setTab(targetPanel);
360
372
  if (what.kind === "files") setDir(what.path ?? "");
361
373
  }
362
- expandRef.current?.();
374
+ if (layoutRef.current) layoutRef.current.onActivate(); else expandRef.current?.();
363
375
  return true;
364
- });
365
- }, [store, backend, acceptOpen, openPath, builtinTabs.join(","), allowedPanels.join(","), panels.show]);
376
+ }, { owner: inlineOwner ?? owner, inline: isInline, priority: () => isInline ? 1 : visibleRef.current ? 2 : 0 });
377
+ }, [store, backend, acceptOpen, openPath, builtinTabs.join(","), allowedPanels.join(","), panels.show, inlineOwner, owner, isInline]); // eslint-disable-line react-hooks/exhaustive-deps
366
378
 
367
379
  const navRef = useRef(onNavigate);
368
380
  navRef.current = onNavigate;
369
381
  useEffect(() => { navRef.current?.({ tab: activeTab, dir, file: file?.path ?? null }); }, [activeTab, dir, file?.path]);
370
382
  // 看板现在的样子报给仓库:业务方用 store.workspace().openFile() / openFiles() 拿到当前打开的文件。
371
- const openPaths = files.map((f) => f.path).join("\n");
383
+ const openPaths = JSON.stringify(files.map((f) => f.path));
384
+ const filesEnabled = builtinTabs.includes("files");
385
+ useEffect(() => {
386
+ if (!backend || !jobId || !watchFiles || !visible || !filesEnabled) return;
387
+ return retainFileScope(backend, jobId, { paths: JSON.parse(openPaths) as string[], dirs: [] });
388
+ }, [backend, jobId, watchFiles, visible, openPaths, filesEnabled]);
372
389
  useEffect(() => {
373
- if (!jobId) return;
374
- store.reportBoard(jobId, { tab: activeTab, dir, file: file?.path ?? null, files: files.map(file=>file.path), current:file?{path:file.path,sourceId:jobId}:null,opened:files.map(file=>({path:file.path,sourceId:jobId})),history,browser:activeTab==="browser"?browserPage:null,visible:!collapsed,autoContext:autoQuoteOpenFile??canQuote }, owner);
375
- }, [store, jobId, activeTab, dir, file?.path, openPaths, history, browserPage, collapsed, autoQuoteOpenFile, canQuote, owner]);
390
+ if (!jobId || restoreNeeded) return;
391
+ const currentFile = isInline && !visible ? null : file;
392
+ store.reportBoard(jobId, { tab: activeTab, dir, file: currentFile?.path ?? null, files: files.map(file=>file.path), current:file?{path:file.path,sourceId:jobId}:null,opened:files.map(file=>({path:file.path,sourceId:jobId})),history,browser:activeTab==="browser"?browserPage:null,visible,autoContext:autoQuoteOpenFile??canQuote }, owner);
393
+ }, [store, jobId, activeTab, dir, file?.path, openPaths, history, browserPage, visible, autoQuoteOpenFile, canQuote, owner, restoreNeeded]);
376
394
  useEffect(() => () => { if (jobId) store.reportBoard(jobId, null, owner); }, [store, jobId, owner]);
377
395
 
378
396
  const body = () => {
379
397
  if (!activeTab) return <div className="cli-board-empty">看板模块已全部关闭。通过右上角“管理看板模块”重新打开。</div>;
380
398
  if (!backend) return <div className="tc-note">当前后端不支持看板(需要任务中心提供 /api/jobs/:id/board)。</div>;
381
399
  if (!jobId) return <div className="tc-note">选择或开始一个对话后,这里显示它的文件、改动和浏览器。</div>;
382
- if (!info) return <div className="tc-note">{infoError ? `读取看板失败:${infoError}(稍后自动重试)` : "读取中…"}</div>;
400
+ if (!info) return <div className="tc-note">{infoError ? <>读取看板失败:{infoError} <button className="cli-fv-btn" onClick={refreshAll}>重试</button></> : "读取中…"}</div>;
383
401
  if (!info.dispatched) return <div className="tc-note">任务还没开始执行({info.detail ?? "等待分配执行节点"})。开始后这里会显示工作区文件。</div>;
402
+ if (!visible) return null;
384
403
  if (activeTab === "changes") return <ChangesPane key={jobId} backend={backend} jobId={jobId} refreshKey={tick} onOpen={(p) => openPath(p)} />;
385
404
  if (activeTab === "browser") {
405
+ if (!visible) return null;
386
406
  return <BrowserPane key={`${jobId}:${info.remote_task_id}`} backend={backend} jobId={jobId} readOnly={readOnly}
387
407
  onPage={setBrowserPage}
388
408
  onQuote={canQuote ? (p) => quote({ id: "browser:page", kind: "browser", label: p.title || p.url, text: `任务浏览器当前打开的页面:${p.title ? `${p.title} — ` : ""}${p.url}` }) : undefined}
@@ -396,27 +416,28 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs,
396
416
  if (extra) return extra.render({ jobId, store, quote });
397
417
  if (activeTab.startsWith(FILE_TAB) && file) {
398
418
  return (
399
- <FileViewer
419
+ <FileViewer toolbarHost={toolbarHost}
400
420
  key={file.path}
401
421
  backend={backend}
402
422
  jobId={jobId}
403
423
  path={file.path}
424
+ displayName={file.name}
404
425
  size={file.size}
405
426
  mtime={file.mtime}
406
- version={file.version} reloadKey={file.reloadKey}
427
+ version={file.version + manual} reloadKey={file.reloadKey}
407
428
  actions={allFileActions}
408
429
  onOpenPath={(p) => openPath(p)}
409
- onQuoteText={canQuote ? (text) => quoteText(file.path, text) : undefined}
410
- lead={<button className="cli-fv-back" onClick={() => { panels.show("files"); setTab("files"); }} title="返回文件列表">‹</button>}
430
+ onQuoteText={canQuote ? (text, context) => quoteText(file.path, text, context) : undefined}
431
+ onBack={() => { setDir(file.path.includes("/") ? file.path.slice(0, file.path.lastIndexOf("/")) : ""); panels.show("files"); setTab("files"); }}
411
432
  />
412
433
  );
413
434
  }
414
- return <FilesPane key={jobId} backend={backend} jobId={jobId} dir={dir} onDir={setDir} onOpen={(e) => openPath(e.path, e)} refreshKey={tick + pollTick} />;
435
+ return <FilesPane toolbarHost={toolbarHost} key={jobId} backend={backend} jobId={jobId} dir={dir} onDir={setDir} onOpen={(e) => openPath(e.path, e)} refreshKey={tick} />;
415
436
  };
416
437
 
417
438
  if (collapsed) {
418
439
  return (
419
- <aside className={`cli-ui cli-board collapsed ${className ?? ""}`} data-theme={theme} style={style}>
440
+ <aside ref={rootRef} className={`cli-ui cli-board collapsed ${className ?? ""}`} data-theme={theme} style={style}>
420
441
  <button className="cli-board-strip" onClick={onExpand} title="显示看板">
421
442
  {Icon.chevRight()}
422
443
  {shownTabs.map((t) => <span key={t} className="cli-board-strip-t">{tabLabel(t)}</span>)}
@@ -426,32 +447,39 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs,
426
447
  );
427
448
  }
428
449
 
429
- return (
430
- <aside className={`cli-ui cli-board sess-rail ${className ?? ""}`} data-theme={theme} style={style} onPointerDown={()=>jobId&&store.activateBoard(jobId,owner)} onClickCapture={()=>jobId&&store.activateBoard(jobId,owner)} onFocusCapture={()=>jobId&&store.activateBoard(jobId,owner)}>
431
- <div className="rail-tabs">
432
- <div className="rail-tabs-scroll" role="tablist" ref={tabsRef}>
450
+ const selectTab = (id: string) => { setTab(id); layoutRef.current?.onActivate(); };
451
+ const tabBar = <div className="rail-tabs">
452
+ <div className="rail-tabs-scroll" role={inlineLayout ? undefined : "tablist"} ref={tabsRef}>
433
453
  {shownTabs.map((t) => t.startsWith(FILE_TAB) ? (
434
- <span key={t} className={`rail-tab rail-ftab ${activeTab === t ? "on" : ""}`} role="tab" aria-selected={activeTab === t} title={t.slice(FILE_TAB.length)}
454
+ <span key={t} className={`rail-tab rail-ftab ${activeTab === t && (!inlineLayout || inlineLayout.active) ? "on" : ""}`} title={t.slice(FILE_TAB.length)}
435
455
  onMouseDown={(e) => { if (e.button === 1) { e.preventDefault(); closeFile(t.slice(FILE_TAB.length)); } }}>
436
- <button className="rail-ftab-l" onClick={() => setTab(t)}>{Icon.doc()}{tabLabel(t)}</button>
456
+ <button className="rail-ftab-l" role="tab" aria-selected={activeTab === t && (!inlineLayout || inlineLayout.active)} aria-label={tabLabel(t)} onClick={() => selectTab(t)}>{Icon.doc()}<span className="rail-ftab-text">{tabLabel(t)}</span></button>
437
457
  <button className="rail-ftab-x" onClick={() => closeFile(t.slice(FILE_TAB.length))} title="关闭(鼠标中键也可以)" aria-label={`关闭 ${tabLabel(t)}`}>{Icon.x()}</button>
438
458
  </span>
439
459
  ) : (
440
- <PanelTab key={t} id={t} label={tabLabel(t)} active={activeTab === t} onSelect={() => setTab(t)} onClose={() => {
460
+ <PanelTab key={t} id={t} label={tabLabel(t)} active={activeTab === t && (!inlineLayout || inlineLayout.active)} onSelect={() => selectTab(t)} onClose={() => {
441
461
  panels.close(t);
442
462
  if (activeTab === t) setTab(shownTabs.find(id => id !== t) ?? "");
463
+ if (inlineLayout && shownTabs.length === 1) inlineLayout.onChat();
443
464
  }} />
444
465
  ))}
445
466
  </div>
446
- <PanelControls available={allowedPanels} open={panels.open} label={tabLabel}
447
- onShow={id => { panels.show(id); setTab(id); }}
467
+ <PanelControls toolbarRef={setToolbarHost} available={allowedPanels} open={panels.open} label={tabLabel}
468
+ upload={!readOnly && builtinTabs.includes("files") && uploadToWorkspace ? { onChoose: upload.choose, disabled: upload.busy || !info?.dispatched, hint: !info?.dispatched ? "工作区尚未就绪" : "保存到当前会话,不发送消息" } : undefined}
469
+ onShow={id => { panels.show(id); selectTab(id); }}
448
470
  onClose={id => { panels.close(id); if (activeTab === id) setTab(shownTabs.find(t => t !== id) ?? ""); }} />
449
- {info?.dispatched && <button className="rail-collapse" onClick={refreshAll} title={running ? "运行中 · 自动同步" : "刷新"}>
471
+ {info?.dispatched && !file && <button className="rail-collapse" onClick={refreshAll} title={running ? "运行中 · 本轮结束后同步,点击立即检查" : "刷新"}>
450
472
  <span className={`cli-sync-dot ${running ? "on" : ""}`} />↻
451
473
  </button>}
452
474
  {onCollapse && <button className="rail-collapse" onClick={onCollapse} title="隐藏面板">{Icon.chevRight()}</button>}
453
- </div>
475
+ </div>;
476
+ return <>
477
+ {inlineLayout?.tabsHost && createPortal(tabBar, inlineLayout.tabsHost)}
478
+ <aside ref={rootRef} className={`cli-ui cli-board sess-rail${isInline ? " cli-inline-board" : ""} ${className ?? ""}`} data-theme={theme} hidden={isInline && !visible} style={{ ...style, ...(isInline && !visible ? { display: "none" } : {}) }} onPointerDown={()=>jobId&&store.activateBoard(jobId,owner)} onClickCapture={()=>jobId&&store.activateBoard(jobId,owner)} onFocusCapture={()=>jobId&&store.activateBoard(jobId,owner)}>
479
+ {!inlineLayout && tabBar}
480
+ {upload.input}
481
+ {upload.progress}
454
482
  <div className="rail-scroll">{body()}</div>
455
483
  </aside>
456
- );
484
+ </>;
457
485
  }