@spzxg/cli-ui 0.6.2 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +137 -11
  3. package/dist/cli-ui-embed-BmLN2Jjg.js +43290 -0
  4. package/dist/{cli-ui-index-BY5i3wId.js → cli-ui-index-D4qfPHMd.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-BZsUwI-s.js → cli-ui-jszip.min-DC9898pC.js} +1 -1
  6. package/dist/{cli-ui-jszip.min-m01fexHW.js → cli-ui-jszip.min-DpEl0G1z.js} +2 -2
  7. package/dist/{cli-ui-xlsx-CpWicJLT.js → cli-ui-xlsx-zs7oEtPj.js} +1 -1
  8. package/dist/cli-ui.css +32 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +14 -8
  11. package/src/board/BrowserPane.tsx +160 -74
  12. package/src/board/ChangesPane.tsx +2 -2
  13. package/src/board/CliBoard.tsx +79 -50
  14. package/src/board/FileViewer.tsx +32 -22
  15. package/src/board/FilesPane.tsx +32 -15
  16. package/src/board/PanelControls.tsx +28 -0
  17. package/src/board/blocks.tsx +5 -5
  18. package/src/board/context.ts +65 -0
  19. package/src/board/fileOps.ts +26 -3
  20. package/src/board/fileWatch.ts +88 -0
  21. package/src/board/folderDownload.ts +30 -14
  22. package/src/board/panels.ts +33 -0
  23. package/src/board/textLoad.ts +2 -2
  24. package/src/board/workspace.ts +38 -9
  25. package/src/chat/CliChat.tsx +73 -30
  26. package/src/chat/Composer.tsx +41 -41
  27. package/src/chat/RunControls.tsx +99 -0
  28. package/src/chat/configuration.ts +15 -0
  29. package/src/chat/draft.ts +41 -0
  30. package/src/chat/export.ts +2 -2
  31. package/src/components.tsx +13 -0
  32. package/src/core/blocks.ts +2 -2
  33. package/src/core/callbacks.ts +8 -0
  34. package/src/core/client.ts +8 -6
  35. package/src/core/conversation.ts +3 -3
  36. package/src/core/id.ts +15 -0
  37. package/src/core/identity.ts +33 -0
  38. package/src/core/provider.tsx +24 -14
  39. package/src/core/store.ts +83 -26
  40. package/src/core/types.ts +34 -0
  41. package/src/elements.tsx +135 -29
  42. package/src/embed.ts +6 -8
  43. package/src/index.ts +24 -17
  44. package/src/layout/CliSplit.tsx +1 -1
  45. package/src/msglist/CliMsgList.tsx +10 -10
  46. package/src/operator/branchFlow.ts +35 -0
  47. package/src/operator/index.ts +9 -0
  48. package/src/operator/liveStream.ts +1 -0
  49. package/src/operator/node/AgentConnect.tsx +624 -0
  50. package/src/operator/node/AppearancePanel.tsx +35 -0
  51. package/src/operator/node/BranchHistory.tsx +67 -0
  52. package/src/operator/node/BranchNavigator.tsx +36 -0
  53. package/src/operator/node/BrowserPane.tsx +128 -0
  54. package/src/operator/node/BrowserPopup.tsx +22 -0
  55. package/src/operator/node/Chat.tsx +517 -0
  56. package/src/operator/node/CommandPalette.tsx +213 -0
  57. package/src/operator/node/Composer.tsx +287 -0
  58. package/src/operator/node/InsightsView.tsx +831 -0
  59. package/src/operator/node/Layout.tsx +190 -0
  60. package/src/operator/node/Markdown.tsx +61 -0
  61. package/src/operator/node/Modal.tsx +179 -0
  62. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  63. package/src/operator/node/OnboardingWizard.tsx +250 -0
  64. package/src/operator/node/Orchestrator.tsx +634 -0
  65. package/src/operator/node/ProjectLanding.tsx +86 -0
  66. package/src/operator/node/ProjectsColumn.tsx +137 -0
  67. package/src/operator/node/ProviderAuth.tsx +561 -0
  68. package/src/operator/node/RunControls.tsx +2 -0
  69. package/src/operator/node/RunSwitch.tsx +113 -0
  70. package/src/operator/node/Services.tsx +245 -0
  71. package/src/operator/node/SessionRail.tsx +260 -0
  72. package/src/operator/node/SessionView.tsx +288 -0
  73. package/src/operator/node/SettingsView.tsx +690 -0
  74. package/src/operator/node/TaskBoard.tsx +352 -0
  75. package/src/operator/node/TaskChanges.tsx +530 -0
  76. package/src/operator/node/TaskHero.tsx +50 -0
  77. package/src/operator/node/TasksColumn.tsx +200 -0
  78. package/src/operator/node/Terminal.tsx +143 -0
  79. package/src/operator/node/Transcript.tsx +463 -0
  80. package/src/operator/node/ViewFile.tsx +79 -0
  81. package/src/operator/node/Welcome.tsx +101 -0
  82. package/src/operator/node/WorktreePrune.tsx +333 -0
  83. package/src/operator/node/agents.ts +58 -0
  84. package/src/operator/node/api.ts +40 -0
  85. package/src/operator/node/boardBackend.ts +80 -0
  86. package/src/operator/node/boardEvents.ts +22 -0
  87. package/src/operator/node/boardInteraction.ts +15 -0
  88. package/src/operator/node/boardState.ts +57 -0
  89. package/src/operator/node/boardWorkspace.ts +12 -0
  90. package/src/operator/node/branchFlow.ts +1 -0
  91. package/src/operator/node/composerDraft.ts +52 -0
  92. package/src/operator/node/fileLinks.ts +81 -0
  93. package/src/operator/node/format.ts +290 -0
  94. package/src/operator/node/github.tsx +234 -0
  95. package/src/operator/node/icons.tsx +88 -0
  96. package/src/operator/node/liveStream.ts +270 -0
  97. package/src/operator/node/modals.tsx +720 -0
  98. package/src/operator/node/navHistory.ts +70 -0
  99. package/src/operator/node/persist.ts +26 -0
  100. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  101. package/src/operator/node/protocol/agents/types.ts +394 -0
  102. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  103. package/src/operator/node/protocol/authFailure.ts +66 -0
  104. package/src/operator/node/protocol/customModels.ts +23 -0
  105. package/src/operator/node/protocol/events.ts +13 -0
  106. package/src/operator/node/protocol/features.ts +70 -0
  107. package/src/operator/node/protocol/internalUsage.ts +1 -0
  108. package/src/operator/node/protocol/promptLimits.ts +52 -0
  109. package/src/operator/node/protocol/storageRetention.ts +13 -0
  110. package/src/operator/node/protocol/types.ts +332 -0
  111. package/src/operator/node/protocol/usageLimit.ts +72 -0
  112. package/src/operator/node/shared.tsx +89 -0
  113. package/src/operator/node/transcriptModel.ts +129 -0
  114. package/src/operator/node/types.ts +296 -0
  115. package/src/operator/node/useGlobalEvents.ts +97 -0
  116. package/src/operator/node/useOrchestrator.ts +696 -0
  117. package/src/operator/node/usePrefs.ts +114 -0
  118. package/src/operator/node/useRecaps.ts +91 -0
  119. package/src/operator/node/useTaskStream.ts +279 -0
  120. package/src/operator.base.css +1798 -0
  121. package/src/operator.css +186 -16
  122. package/src/styles.css +20 -0
  123. package/src/ui/Markdown.tsx +7 -2
  124. package/src/ui/Popover.tsx +77 -0
  125. package/src/ui/TextBlock.tsx +3 -3
  126. package/src/ui/Transcript.tsx +10 -8
  127. package/src/ui/fileLinks.ts +6 -0
  128. package/src/ui/format.ts +1 -1
  129. package/src/ui/shared.tsx +1 -1
  130. package/src/ui/transcriptModel.ts +2 -2
  131. package/dist/cli-ui-embed-6IJ_jRVh.js +0 -41223
@@ -8,18 +8,23 @@
8
8
  // 同步:任务每有进展(实时流里的轮次 / 输出变化)就刷新,运行中再定时刷新;打开着的文件改了会自动重读。
9
9
  // 和 cli-chat 放在同一个 Provider 里时,对话里点本地文件 / 「查看浏览器」会在这里打开(store.openInBoard)。
10
10
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
11
- import { CliErrorBoundary } from "../ui/ErrorBoundary.js";
12
- import type { BoardRef, CliEvent, CliStore, CliStoreOptions, OpenTarget } from "../core/store.js";
13
- import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider } from "../core/provider.js";
14
- import { convKey, parseConvKey, isRunning } from "../core/conversation.js";
15
- import { toolDataOf } from "../ui/transcriptModel.js";
16
- import type { BoardInfo, FileEntry } from "../core/types.js";
17
- import { Icon } from "../ui/icons.js";
18
- import { CenterError } from "../core/client.js";
19
- import { FileViewer, type FileAction } from "./FileViewer.js";
20
- import { FilesPane } from "./FilesPane.js";
21
- import { ChangesPane } from "./ChangesPane.js";
22
- import { BrowserPane } from "./BrowserPane.js";
11
+ import { CliErrorBoundary } from "../ui/ErrorBoundary";
12
+ import type { BoardRef, CliEvent, CliStore, CliStoreOptions, OpenTarget } from "../core/store";
13
+ import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider } from "../core/provider";
14
+ import { convKey, parseConvKey, isRunning } from "../core/conversation";
15
+ import { toolDataOf } from "../ui/transcriptModel";
16
+ import type { BoardInfo, FileEntry } from "../core/types";
17
+ import { Icon } from "../ui/icons";
18
+ import { CenterError } from "../core/client";
19
+ import { FileViewer, type FileAction } from "./FileViewer";
20
+ import { FilesPane } from "./FilesPane";
21
+ import { ChangesPane } from "./ChangesPane";
22
+ import { BrowserPane } from "./BrowserPane";
23
+ import { rememberBoardFile } from "./context";
24
+ import type { BoardFile } from "./workspace";
25
+ import type { WorkspaceHandlers } from "../core/provider";
26
+ import { useBoardPanels } from "./panels";
27
+ import { PanelControls, PanelTab } from "./PanelControls";
23
28
 
24
29
  export type BoardTab = "files" | "changes" | "browser";
25
30
 
@@ -36,28 +41,33 @@ const FILE_TAB = "file:";
36
41
  const fileTabId = (path: string) => `${FILE_TAB}${path}`;
37
42
  /** 开着的文件页签记在会话存储里(按对话):刷新页面后还在。 */
38
43
  const tabsKey = (jobId: string) => `cli-ui:board-tabs:${jobId}`;
39
- function loadTabs(jobId: string | null): { files: string[]; tab: string | null } {
40
- if (!jobId) return { files: [], tab: null };
44
+ function loadTabs(jobId: string | null): { files: string[]; tab: string | null; history: BoardFile[] } {
45
+ if (!jobId) return { files: [], tab: null, history: [] };
41
46
  try {
42
- const v = JSON.parse(sessionStorage.getItem(tabsKey(jobId)) ?? "null") as { files?: unknown; tab?: unknown } | null;
47
+ const v = JSON.parse(sessionStorage.getItem(tabsKey(jobId)) ?? "null") as { files?: unknown; tab?: unknown; history?: BoardFile[] } | null;
43
48
  const files = Array.isArray(v?.files) ? v.files.filter((f): f is string => typeof f === "string").slice(-MAX_FILE_TABS) : [];
44
- return { files, tab: typeof v?.tab === "string" ? v.tab : null };
45
- } catch { return { files: [], tab: null }; }
49
+ return { files, tab: typeof v?.tab === "string" ? v.tab : null, history: Array.isArray(v?.history) ? v.history.filter(f=>typeof f?.path==="string").slice(-40) : files.map(path=>({path})) };
50
+ } catch { return { files: [], tab: null, history: [] }; }
46
51
  }
47
52
  const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
48
- function saveTabs(jobId: string, files: string[], tab: string) {
49
- try { sessionStorage.setItem(tabsKey(jobId), JSON.stringify({ files, tab })); } catch { /* 存不下就不记 */ }
53
+ function saveTabs(jobId: string, files: string[], tab: string, history: BoardFile[]) {
54
+ try { sessionStorage.setItem(tabsKey(jobId), JSON.stringify({ files, tab, history })); } catch { /* 存不下就不记 */ }
50
55
  }
51
56
  /** 有输出就刷新文件,但两次之间至少隔这么久(智能体连写几十个文件时不至于每个都打一次中枢)。 */
52
57
  const ACTIVITY_GAP_MS = 700;
53
58
 
54
- export interface CliBoardProps extends CliStoreOptions {
59
+ export interface CliBoardProps extends CliStoreOptions, WorkspaceHandlers {
60
+ /** 默认监听整个工作区,收起后继续。旧中枢降级状态可由 onFilesWatch 观察。 */
61
+ watchFiles?: boolean;
62
+ fileWatchIntervalMs?: number;
55
63
  /** 看哪个对话(center 的 job_id)。不给就跟共享仓库的选中走(和 cli-msglist / cli-chat 联动)。 */
56
64
  jobId?: string | null;
57
65
  /** 对话任务里的哪个对话(和 jobId 一起用,同 cli-chat 的 taskId)。不给 = 缺省那个(最近派发过的)。 */
58
66
  taskId?: string | null;
59
- /** 显示哪些页签、什么顺序。缺省三个都有(节点没开浏览器功能时不显示「浏览器」)。 */
67
+ /** 默认打开的模块及顺序;用户仍可手动打开其他可用模块。[] 表示初始全部关闭。 */
60
68
  tabs?: BoardTab[];
69
+ /** 允许使用的模块;与 tabs 的默认可见状态分开。缺省允许全部受后端支持的模块。 */
70
+ availableTabs?: BoardTab[];
61
71
  defaultTab?: BoardTab;
62
72
  /** 只看:不能接管 / 打开 / 关闭浏览器。 */
63
73
  readOnly?: boolean;
@@ -103,22 +113,24 @@ export function CliBoard(props: CliBoardProps) {
103
113
  return <CliErrorBoundary component="cli-board" theme={props.theme}>{withOwnProvider(props, <BoardInner {...props} inProvider={!!ctx} />, !!ctx)}</CliErrorBoundary>;
104
114
  }
105
115
 
106
- interface OpenFile { path: string; size?: number; mtime?: number; version: number }
116
+ interface OpenFile { reloadKey?:number; path: string; size?: number; mtime?: number; version: number }
107
117
 
108
- function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, defaultTab, readOnly, fileActions, pollMs = 15_000, acceptOpen = true, quoteToChat, autoQuoteOpenFile, extraTabs, onCollapse, collapsed, onExpand, onNavigate, onEvent, theme = "light", className, style, inProvider }: CliBoardProps & { inProvider: boolean }) {
109
- useComponentEvents(onEvent, {}, inProvider);
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 }) {
119
+ useComponentEvents(onEvent, {onFilesChange,onFilesWatch,onBoardContextChange}, inProvider);
110
120
  const store = useCliStore();
111
121
  const backend = store.client.board;
112
122
  const selected = useCliState((s) => s.selected);
113
123
  // 内部用对话键(见 core/conversation.ts):看板接口按它找对话任务里的那个对话。
114
124
  const jobId = jobIdProp !== undefined ? (jobIdProp ? convKey(parseConvKey(jobIdProp).jobId, taskIdProp ?? parseConvKey(jobIdProp).taskId) : null) : selected;
115
125
 
126
+ const storageId = jobId && store.draftScope ? `${store.draftScope}:${jobId}` : jobId;
116
127
  const [info, setInfo] = useState<BoardInfo | null>(null);
117
128
  const [infoError, setInfoError] = useState<string | null>(null);
118
129
  // 页签:内置的(文件列表 / 改动 / 浏览器)、打开的文件(file:<路径>,可以开多个、可以关)、宿主加的。
119
130
  const [tab, setTab] = useState<string>(defaultTab ?? tabs?.[0] ?? "files");
120
131
  const [dir, setDir] = useState("");
121
132
  const [files, setFiles] = useState<OpenFile[]>([]);
133
+ const [history,setHistory] = useState<BoardFile[]>([]);
122
134
  // 刷新信号,按代价分开:
123
135
  // tick 进展(实时流有新输出 / 轮次变化,节流)、切回页面、手动刷新 —— 文件、改动、打开的文件都跟着刷新;
124
136
  // pollTick 运行中的定时刷新 —— 只刷文件列表和打开的文件(改动要在节点上跑 git diff,不轮询);
@@ -130,18 +142,19 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, defaultTab, re
130
142
  const lastJob = useRef(jobId);
131
143
  if (lastJob.current !== jobId) {
132
144
  lastJob.current = jobId;
133
- setInfo(null); setInfoError(null); setDir(""); setFiles([]);
145
+ setInfo(null); setInfoError(null); setDir(""); setFiles([]); setHistory([]);
134
146
  setTab(defaultTab ?? tabs?.[0] ?? "files");
135
147
  }
136
148
  // 恢复上次开着的文件页签(存在 sessionStorage):挂上之后、绘制之前做——服务端渲染和首次渲染一致,不会对不上。
137
149
  const restored = useRef<string | null>(null);
138
150
  useIsoLayoutEffect(() => {
139
- if (!jobId || restored.current === jobId) return;
140
- restored.current = jobId;
141
- const saved = loadTabs(jobId);
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})));
142
155
  if (saved.files.length) setFiles((cur) => (cur.length ? cur : saved.files.map((path) => ({ path, version: 0 }))));
143
156
  if (saved.tab) setTab((cur) => (cur === (defaultTab ?? tabs?.[0] ?? "files") ? saved.tab! : cur));
144
- }, [jobId]);
157
+ }, [storageId]);
145
158
 
146
159
  // 实时流:看板单独用时也要知道任务在不在跑(同一对话只连一次)。
147
160
  useEffect(() => (jobId ? store.open(jobId) : undefined), [store, jobId]);
@@ -191,7 +204,7 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, defaultTab, re
191
204
  let timer: ReturnType<typeof setTimeout> | undefined;
192
205
  const load = () => backend.info(jobId).then((i) => {
193
206
  if (!alive) return;
194
- setInfo((old) => (old && old.dispatched === i.dispatched && old.remote_task_id === i.remote_task_id && old.browser === i.browser && old.node?.health === i.node?.health ? old : i));
207
+ 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));
195
208
  setInfoError(null);
196
209
  if (!i.dispatched) timer = setTimeout(load, 3000);
197
210
  }, (e: unknown) => {
@@ -203,15 +216,13 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, defaultTab, re
203
216
  return () => { alive = false; if (timer) clearTimeout(timer); };
204
217
  }, [backend, jobId, statusKey, manual]); // eslint-disable-line react-hooks/exhaustive-deps
205
218
 
206
- const builtinTabs = useMemo<string[]>(() => (tabs ?? ["files", "changes", "browser"]).filter((t) => t !== "browser" || !info || info.browser !== false), [tabs, info]);
207
- const fileTabsOn = builtinTabs.includes("files");
208
- const shownTabs = useMemo<string[]>(() => [
209
- ...builtinTabs,
210
- ...(fileTabsOn ? files.map((f) => fileTabId(f.path)) : []),
211
- ...(extraTabs ?? []).map((t) => t.id),
212
- ], [builtinTabs, fileTabsOn, files, extraTabs]);
213
- const activeTab = shownTabs.includes(tab) ? tab : shownTabs[0] ?? "files";
219
+ 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
+ const allowedPanels = [...builtinTabs, ...(extraTabs ?? []).map(t => t.id)];
221
+ const panels = useBoardPanels(jobId ? `center:${storageId}` : null, allowedPanels, [...(tabs ?? builtinTabs.filter(tab=>tab!=="changes")), ...(extraTabs ?? []).map(t => t.id)]);
222
+ const shownTabs = [...panels.open, ...(builtinTabs.includes("files") ? files.map(f => fileTabId(f.path)) : [])];
223
+ const activeTab = shownTabs.includes(tab) ? tab : shownTabs[0] ?? "";
214
224
  const file = activeTab.startsWith(FILE_TAB) ? files.find((f) => fileTabId(f.path) === activeTab) ?? null : null;
225
+ 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]);
215
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;
216
227
  // 页签多了横向滚动:当前页签总是滚到看得见的地方(新开的文件页签在最后,窄看板里容易被挤出去)。
217
228
  const tabsRef = useRef<HTMLDivElement | null>(null);
@@ -242,8 +253,16 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, defaultTab, re
242
253
  return () => ro.disconnect();
243
254
  }, [collapsed, revealTab, shownTabs.length]);
244
255
  // 记下开着的文件页签和当前页签(刷新页面后恢复)。恢复之前不写,免得用空的盖掉上次的。
245
- useEffect(() => { if (jobId && restored.current === jobId) saveTabs(jobId, files.map((f) => f.path), activeTab); }, [jobId, files, activeTab]);
256
+ useEffect(() => { if (storageId && restored.current === storageId) saveTabs(storageId, files.map((f) => f.path), activeTab, history); }, [storageId, files, activeTab, history]);
246
257
  // 引用到对话:和对话在同一个 Provider 里时缺省开。
258
+ useEffect(()=>{
259
+ if(!watchFiles||!jobId||!backend||!info?.dispatched||info.files===false)return;
260
+ return store.watchWorkspace(jobId,event=>{
261
+ setTick(value=>value+1);
262
+ const changed=new Set(event.changes.map(change=>change.path));
263
+ setFiles(previous=>previous.map(file=>changed.has(file.path)?{...file,version:file.version+1,reloadKey:(file.reloadKey??0)+1}:file));
264
+ },{intervalMs:fileWatchIntervalMs});
265
+ },[store,backend,jobId,watchFiles,fileWatchIntervalMs,info?.dispatched,info?.files]);
247
266
  const canQuote = (quoteToChat ?? inProvider) && !!jobId;
248
267
  const quote = useCallback((ref: Omit<BoardRef, "id"> & { id?: string }) => {
249
268
  if (jobId) store.addRef(jobId, { ...ref, id: ref.id ?? `${ref.kind}:${ref.label}` });
@@ -333,14 +352,17 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, defaultTab, re
333
352
  if (!acceptOpen || !backend) return;
334
353
  return store.registerBoard((target: string, what: OpenTarget) => {
335
354
  if (!jobRef.current || target !== jobRef.current) return false;
336
- if (what.kind === "file") openPath(what.path);
337
- else if (what.kind === "files") { setTab("files"); setDir(what.path ?? ""); }
338
- else if (what.kind === "tab") setTab(what.id);
339
- else setTab(what.kind);
355
+ if (what.kind === "file") { if (!builtinTabs.includes("files")) return false; openPath(what.path); }
356
+ else {
357
+ const targetPanel = what.kind === "tab" ? what.id : what.kind;
358
+ if (!panels.show(targetPanel)) return false;
359
+ setTab(targetPanel);
360
+ if (what.kind === "files") setDir(what.path ?? "");
361
+ }
340
362
  expandRef.current?.();
341
363
  return true;
342
364
  });
343
- }, [store, backend, acceptOpen, openPath]);
365
+ }, [store, backend, acceptOpen, openPath, builtinTabs.join(","), allowedPanels.join(","), panels.show]);
344
366
 
345
367
  const navRef = useRef(onNavigate);
346
368
  navRef.current = onNavigate;
@@ -349,11 +371,12 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, defaultTab, re
349
371
  const openPaths = files.map((f) => f.path).join("\n");
350
372
  useEffect(() => {
351
373
  if (!jobId) return;
352
- store.reportBoard(jobId, { tab: activeTab, dir, file: file?.path ?? null, files: openPaths ? openPaths.split("\n") : [] }, owner);
353
- }, [store, jobId, activeTab, dir, file?.path, openPaths, owner]);
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]);
354
376
  useEffect(() => () => { if (jobId) store.reportBoard(jobId, null, owner); }, [store, jobId, owner]);
355
377
 
356
378
  const body = () => {
379
+ if (!activeTab) return <div className="cli-board-empty">看板模块已全部关闭。通过右上角“管理看板模块”重新打开。</div>;
357
380
  if (!backend) return <div className="tc-note">当前后端不支持看板(需要任务中心提供 /api/jobs/:id/board)。</div>;
358
381
  if (!jobId) return <div className="tc-note">选择或开始一个对话后,这里显示它的文件、改动和浏览器。</div>;
359
382
  if (!info) return <div className="tc-note">{infoError ? `读取看板失败:${infoError}(稍后自动重试)` : "读取中…"}</div>;
@@ -380,11 +403,11 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, defaultTab, re
380
403
  path={file.path}
381
404
  size={file.size}
382
405
  mtime={file.mtime}
383
- version={file.version}
406
+ version={file.version} reloadKey={file.reloadKey}
384
407
  actions={allFileActions}
385
408
  onOpenPath={(p) => openPath(p)}
386
409
  onQuoteText={canQuote ? (text) => quoteText(file.path, text) : undefined}
387
- lead={<button className="cli-fv-back" onClick={() => setTab("files")} title="返回文件列表">‹</button>}
410
+ lead={<button className="cli-fv-back" onClick={() => { panels.show("files"); setTab("files"); }} title="返回文件列表">‹</button>}
388
411
  />
389
412
  );
390
413
  }
@@ -404,7 +427,7 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, defaultTab, re
404
427
  }
405
428
 
406
429
  return (
407
- <aside className={`cli-ui cli-board sess-rail ${className ?? ""}`} data-theme={theme} style={style}>
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)}>
408
431
  <div className="rail-tabs">
409
432
  <div className="rail-tabs-scroll" role="tablist" ref={tabsRef}>
410
433
  {shownTabs.map((t) => t.startsWith(FILE_TAB) ? (
@@ -414,9 +437,15 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, defaultTab, re
414
437
  <button className="rail-ftab-x" onClick={() => closeFile(t.slice(FILE_TAB.length))} title="关闭(鼠标中键也可以)" aria-label={`关闭 ${tabLabel(t)}`}>{Icon.x()}</button>
415
438
  </span>
416
439
  ) : (
417
- <button key={t} role="tab" aria-selected={activeTab === t} className={`rail-tab ${activeTab === t ? "on" : ""}`} onClick={() => setTab(t)}>{tabLabel(t)}</button>
440
+ <PanelTab key={t} id={t} label={tabLabel(t)} active={activeTab === t} onSelect={() => setTab(t)} onClose={() => {
441
+ panels.close(t);
442
+ if (activeTab === t) setTab(shownTabs.find(id => id !== t) ?? "");
443
+ }} />
418
444
  ))}
419
445
  </div>
446
+ <PanelControls available={allowedPanels} open={panels.open} label={tabLabel}
447
+ onShow={id => { panels.show(id); setTab(id); }}
448
+ onClose={id => { panels.close(id); if (activeTab === id) setTab(shownTabs.find(t => t !== id) ?? ""); }} />
420
449
  {info?.dispatched && <button className="rail-collapse" onClick={refreshAll} title={running ? "运行中 · 自动同步" : "刷新"}>
421
450
  <span className={`cli-sync-dot ${running ? "on" : ""}`} />↻
422
451
  </button>}
@@ -7,16 +7,16 @@
7
7
  // · 文本只读开头 512 KB(Range),超过的可以切到「看末尾」(日志);GBK 导出的文件按 GB18030 解。
8
8
  // · 文件在变(任务还在写):看板递增 version 时重读;读的时候保留滚动位置,原本在最底下的继续贴着底(像 tail -f)。
9
9
  import { Fragment, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
10
- import { fileUrlFromBase, type BoardBackend } from "../core/client.js";
11
- import { Icon } from "../ui/icons.js";
12
- import { Markdown } from "../ui/Markdown.js";
13
- import { FileLinkContext, FileOpenContext } from "../ui/fileLinks.js";
14
- import { codeLang, fmtSize, parseCsv, previewKind, resolveRelative, splitMarkdown, type PreviewKind } from "./fileKind.js";
15
- import { loadText, refreshText, type LoadedText } from "./textLoad.js";
16
- import { decodeText } from "../core/textDecode.js";
17
- import { relTime } from "../ui/format.js";
18
- import { downloadFile, mimeOf } from "./fileOps.js";
19
- import { bridgeDoc, frameToken, type FrameSelMessage } from "./frameQuote.js";
10
+ import { fileUrlFromBase, type BoardBackend } from "../core/client";
11
+ import { Icon } from "../ui/icons";
12
+ import { Markdown } from "../ui/Markdown";
13
+ import { FileLinkContext, FileOpenContext } from "../ui/fileLinks";
14
+ import { codeLang, fmtSize, parseCsv, previewKind, resolveRelative, splitMarkdown, type PreviewKind } from "./fileKind";
15
+ import { loadText, refreshText, type LoadedText } from "./textLoad";
16
+ import { decodeText } from "../core/textDecode";
17
+ import { relTime } from "../ui/format";
18
+ import { downloadFile, mimeOf, readBoundedBuffer, FileReadLimitError } from "./fileOps";
19
+ import { bridgeDoc, frameToken, type FrameSelMessage } from "./frameQuote";
20
20
 
21
21
  export { downloadFile };
22
22
 
@@ -93,13 +93,14 @@ function useFileSrc(backend: BoardBackend, jobId: string, path: string, enabled:
93
93
  setState({ url: null, error: null });
94
94
  if (size !== undefined && size > BLOB_PREVIEW_MAX) { setState({ url: null, error: `文件较大(${fmtSize(size)}),请下载后查看` }); return; }
95
95
  let alive = true;
96
+ const ctl = new AbortController();
96
97
  let objectUrl: string | null = null;
97
- backend.readBuffer(jobId, path).then((buffer) => {
98
+ readBoundedBuffer(backend, jobId, path, BLOB_PREVIEW_MAX, {signal:ctl.signal}).then((buffer) => {
98
99
  if (!alive) return;
99
100
  objectUrl = URL.createObjectURL(new Blob([buffer], { type: mimeOf(path) }));
100
101
  setState({ url: objectUrl, error: null });
101
102
  }, (error: unknown) => { if (alive) setState({ url: null, error: error instanceof Error ? error.message : String(error) }); });
102
- return () => { alive = false; if (objectUrl) URL.revokeObjectURL(objectUrl); };
103
+ return () => { alive = false; ctl.abort(); if (objectUrl) URL.revokeObjectURL(objectUrl); };
103
104
  }, [backend, jobId, path, enabled, version, links.blob, size, attempt]);
104
105
  return direct ? { url: direct, error: null } : state;
105
106
  }
@@ -118,6 +119,8 @@ export interface FileViewerProps {
118
119
  mtime?: number;
119
120
  /** 变了就重新读(看板在文件更新时递增)。 */
120
121
  version?: number;
122
+ /** 明确的文件变更事件使内容重新读取,不能只按末尾重叠判断为追加。 */
123
+ reloadKey?: number | string;
121
124
  /** 预览里点了别的本地文件(Markdown 里的链接)。 */
122
125
  onOpenPath?: (path: string) => void;
123
126
  onClose?: () => void;
@@ -133,7 +136,12 @@ export interface FileViewerProps {
133
136
  /** 选中的文字超过这么多只带开头(引用是给智能体指位置的,不是搬运全文)。 */
134
137
  const QUOTE_SELECTION_MAX = 3_000;
135
138
 
136
- export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onOpenPath, onClose, lead, actions = [], bare, onQuoteText }: FileViewerProps) {
139
+ const viewerBackendIds=new WeakMap<BoardBackend,number>();let nextViewerBackendId=1;
140
+ export function FileViewer(props:FileViewerProps) {
141
+ let id=viewerBackendIds.get(props.backend);if(id===undefined){id=nextViewerBackendId++;viewerBackendIds.set(props.backend,id);}
142
+ return <FileViewerInner key={JSON.stringify([id,props.jobId,props.path])} {...props}/>;
143
+ }
144
+ function FileViewerInner({ backend, jobId, path, size, mtime, version = 0, reloadKey, onOpenPath, onClose, lead, actions = [], bare, onQuoteText }: FileViewerProps) {
137
145
  const name = path.split("/").pop() || path;
138
146
  const kind: PreviewKind = previewKind(path);
139
147
  const [loaded, setLoaded] = useState<Loaded | null>(null);
@@ -177,20 +185,19 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
177
185
  useEffect(() => {
178
186
  if (!htmlBridge) return;
179
187
  const ctl = new AbortController();
180
- let over = false;
181
188
  setHtmlDoc(null);
182
189
  setProgress(0);
183
- backend.readBuffer(jobId, path, {
190
+ readBoundedBuffer(backend, jobId, path, HTML_QUOTE_MAX, {
184
191
  signal: ctl.signal,
185
192
  // 大小事先不知道(从对话里打开的):读着读着超过上限就停下,改成直接打开。
186
- onBytes: (n) => { setProgress(n); if (n > HTML_QUOTE_MAX && !over) { over = true; ctl.abort(); } },
193
+ onBytes: (n) => { setProgress(n); },
187
194
  }).then((buffer) => {
188
195
  if (ctl.signal.aborted) return;
189
196
  const url = linksRef.current.url?.(path);
190
197
  const base = url ? withVersion(new URL(url, window.location.href).href, version) : undefined;
191
198
  setHtmlDoc({ srcDoc: bridgeDoc(decodeText(new Uint8Array(buffer)).text, { token, base }) });
192
- }, () => { if (over) setHtmlDoc("toobig"); else if (!ctl.signal.aborted) setHtmlDoc("fallback"); });
193
- return () => { if (!over) ctl.abort(); };
199
+ }, (error:unknown) => { if (ctl.signal.aborted)return; if(error instanceof FileReadLimitError)setHtmlDoc("toobig");else setHtmlDoc("fallback"); });
200
+ return () => ctl.abort();
194
201
  }, [htmlBridge, backend, jobId, path, version, attempt, token]);
195
202
 
196
203
  // 重读时保留滚动位置;原本贴着底(或在看末尾)的继续贴着底——任务在往文件里写时像 tail -f。
@@ -267,15 +274,18 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
267
274
  };
268
275
  useEffect(() => {
269
276
  let alive = true;
277
+ const ctl=new AbortController();
270
278
  setError(null);
271
279
  if (needsSrc || kind === "binary") { setLoaded({ kind: "none" }); return; }
272
280
  if (kind === "docx") {
273
281
  if (size !== undefined && size > DOCX_PREVIEW_MAX) { setError(`文件较大(${fmtSize(size)}),请下载后查看`); return; }
274
282
  setProgress(0);
275
- backend.readBuffer(jobId, path, { onBytes: (n) => { if (alive) setProgress(n); } }).then(async (buffer) => {
283
+ readBoundedBuffer(backend, jobId, path, DOCX_PREVIEW_MAX, { signal:ctl.signal,onBytes: (n) => { if (alive) setProgress(n); } }).then(async (buffer) => {
284
+ if(!alive)return;
276
285
  const mammoth = await import("mammoth");
277
286
  const convert = (mammoth as unknown as { convertToHtml: typeof import("mammoth").convertToHtml }).convertToHtml
278
287
  ?? (mammoth as unknown as { default: typeof import("mammoth") }).default.convertToHtml;
288
+ if(!alive)return;
279
289
  const input: Record<string, unknown> = { arrayBuffer: buffer };
280
290
  const B = (globalThis as { Buffer?: { from(b: ArrayBuffer): unknown } }).Buffer;
281
291
  if (B) input.buffer = B.from(buffer);
@@ -284,7 +294,7 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
284
294
  }).catch((e: unknown) => { if (alive) setError(e instanceof Error ? e.message : String(e)); });
285
295
  } else {
286
296
  // 只是文件变了(同一个文件、同样看开头 / 末尾):能接上就只取新增的部分(见 textLoad.ts),接不上再整个重读。
287
- const key = `${path}|${tail}|${attempt}`;
297
+ const key = `${path}|${tail}|${attempt}|${reloadKey??""}`;
288
298
  const prev = loadedRef.current?.kind === "text" && textKey.current === key ? loadedRef.current : null;
289
299
  textKey.current = key;
290
300
  if (!prev) setProgress(0);
@@ -299,8 +309,8 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
299
309
  (e: unknown) => { if (alive) setError(e instanceof Error ? e.message : String(e)); },
300
310
  );
301
311
  }
302
- return () => { alive = false; };
303
- }, [backend, jobId, path, kind, needsSrc, version, tail, attempt]); // eslint-disable-line react-hooks/exhaustive-deps
312
+ return () => { alive = false; ctl.abort(); };
313
+ }, [backend, jobId, path, kind, needsSrc, version, reloadKey, tail, attempt]); // eslint-disable-line react-hooks/exhaustive-deps
304
314
  useLayoutEffect(() => {
305
315
  const box = bodyRef.current;
306
316
  if (box && stick.current && loaded?.kind === "text") box.scrollTop = box.scrollHeight;
@@ -1,13 +1,13 @@
1
1
  // 工作区文件:面包屑 + 列表。目录点进去,文件点开预览;行尾可直接下载。
2
2
  // 列表随任务进展自动刷新(CliBoard 给 refreshKey),这次刷新里新出现 / 改过的文件标出来。
3
3
  import { useEffect, useRef, useState } from "react";
4
- import { CenterError, type BoardBackend } from "../core/client.js";
5
- import type { FileEntry } from "../core/types.js";
6
- import { Icon } from "../ui/icons.js";
7
- import { relTime } from "../ui/format.js";
8
- import { downloadFile } from "./fileOps.js";
9
- import { fmtSize } from "./fileKind.js";
10
- import { downloadFolder, FAILED_LIST_NAME, type FolderProgress } from "./folderDownload.js";
4
+ import { CenterError, type BoardBackend } from "../core/client";
5
+ import type { FileEntry } from "../core/types";
6
+ import { Icon } from "../ui/icons";
7
+ import { relTime } from "../ui/format";
8
+ import { downloadFile } from "./fileOps";
9
+ import { fmtSize } from "./fileKind";
10
+ import { downloadFolder, FAILED_LIST_NAME, type FolderProgress } from "./folderDownload";
11
11
 
12
12
  export interface FilesPaneProps {
13
13
  backend: BoardBackend;
@@ -37,23 +37,40 @@ export function FilesPane({ backend, jobId, dir, onDir, onOpen, refreshKey, onLi
37
37
  const [zip, setZip] = useState<{ dir: string; progress: FolderProgress; abort: AbortController } | null>(null);
38
38
  // 下载的结果 / 失败(和「同步失败」分开:下载出错不代表列表过期)。
39
39
  const [notice, setNotice] = useState<{ text: string; err: boolean } | null>(null);
40
+ const zipAbort = useRef<AbortController | null>(null);
41
+ const downloadScope = useRef<object | null>({});
40
42
  const zipDir = (target: string) => {
41
- if (zip) return;
43
+ if (zipAbort.current) return;
42
44
  const abort = new AbortController();
45
+ zipAbort.current = abort;
46
+ const current = () => zipAbort.current === abort && !abort.signal.aborted;
43
47
  setNotice(null);
44
48
  setZip({ dir: target, progress: { phase: "listing", files: 0, total: 0, bytes: 0 }, abort });
45
- downloadFolder(backend, jobId, target, { signal: abort.signal, onProgress: (progress) => setZip((z) => (z && z.abort === abort ? { ...z, progress } : z)) })
46
- .then(({ files, failed }) => {
47
- if (failed.length) setNotice({ err: true, text: `已打包 ${files} 个文件;${failed.length} 个没读到(${failed[0].reason}${failed.length > 1 ? " 等" : ""}),清单在压缩包里的 ${FAILED_LIST_NAME}` });
49
+ downloadFolder(backend, jobId, target, { signal: abort.signal, onProgress: (progress) => { if (current()) setZip((z) => (z && z.abort === abort ? { ...z, progress } : z)); } })
50
+ .then(({ files, failed, failedListName }) => {
51
+ if (current() && failed.length) setNotice({ err: true, text: `已打包 ${files} 个文件;${failed.length} 个没读到(${failed[0].reason}${failed.length > 1 ? " 等" : ""}),清单在压缩包里的 ${failedListName??FAILED_LIST_NAME}` });
48
52
  })
49
- .catch((err: unknown) => { if (!(err instanceof DOMException && err.name === "AbortError")) setNotice({ err: true, text: `打包下载失败:${err instanceof Error ? err.message : String(err)}` }); })
50
- .finally(() => setZip((z) => (z && z.abort === abort ? null : z)));
53
+ .catch((err: unknown) => { if (current() && !(err instanceof DOMException && err.name === "AbortError")) setNotice({ err: true, text: `打包下载失败:${err instanceof Error ? err.message : String(err)}` }); })
54
+ .finally(() => { if (zipAbort.current === abort) { zipAbort.current = null; setZip(null); } });
51
55
  };
52
56
  const downloadOne = (path: string, size: number) => {
57
+ const scope = downloadScope.current;
53
58
  setNotice(null);
54
- void downloadFile(backend, jobId, path, size).catch((err: unknown) => setNotice({ err: true, text: `下载失败:${err instanceof Error ? err.message : String(err)}` }));
59
+ void downloadFile(backend, jobId, path, size).catch((err: unknown) => { if (scope === downloadScope.current) setNotice({ err: true, text: `下载失败:${err instanceof Error ? err.message : String(err)}` }); });
55
60
  };
56
- useEffect(() => () => zip?.abort.abort(), [jobId]); // eslint-disable-line react-hooks/exhaustive-deps
61
+ useEffect(() => {
62
+ downloadScope.current = {};
63
+ setZip(null);
64
+ setNotice(null);
65
+ setEntries(null);
66
+ seen.current = { dir, mtimes: null };
67
+ setFresh(new Set());
68
+ return () => {
69
+ downloadScope.current = null;
70
+ zipAbort.current?.abort();
71
+ zipAbort.current = null;
72
+ };
73
+ }, [jobId, backend]); // eslint-disable-line react-hooks/exhaustive-deps
57
74
 
58
75
  useEffect(() => {
59
76
  let alive = true;
@@ -0,0 +1,28 @@
1
+ import { useState } from 'react';
2
+ import { Icon } from '../ui/icons';
3
+ import { Popover } from '../ui/Popover';
4
+ export function PanelTab({ id, label, active, onSelect, onClose }: {
5
+ id: string; label: string; active: boolean; onSelect: () => void; onClose: () => void;
6
+ }) {
7
+ return <span className={`rail-tab rail-ftab ${active ? 'on' : ''}`} onClick={onSelect}>
8
+ <button className="rail-ftab-l" role="tab" aria-selected={active} aria-label={label} data-panel-id={id} onClick={event => { event.stopPropagation(); onSelect(); }}>{label}</button>
9
+ <button className="rail-ftab-x" aria-label={id.startsWith("file:") ? `关闭 ${label}` : `关闭${label}面板`} title={id === 'browser' ? '隐藏浏览器面板,浏览器任务继续运行' : `关闭${label}面板,可从添加模块重新打开`}
10
+ onClick={event => { event.stopPropagation(); onClose(); }}>{Icon.x()}</button>
11
+ </span>;
12
+ }
13
+ export function PanelControls({ available, open, label, onShow, onClose }: {
14
+ available: readonly string[]; open: readonly string[]; label: (id: string) => string;
15
+ onShow: (id: string) => void; onClose: (id: string) => void;
16
+ }) {
17
+ const [expanded, setExpanded] = useState(false);
18
+ return <div className="cli-panel-controls">
19
+ <button className="rail-collapse" aria-label="管理看板模块" title="添加或关闭看板模块" aria-expanded={expanded} onClick={() => setExpanded(value => !value)}>{Icon.plus()}</button>
20
+ {expanded && <Popover onClose={() => setExpanded(false)}>
21
+ <div className="pop-sec">看板模块 · 可独立打开或关闭</div>
22
+ {available.map(id => <button key={id} className="pop-item" aria-label={`${open.includes(id) ? '关闭' : '打开'}${label(id)}面板`} aria-pressed={open.includes(id)}
23
+ onClick={() => { if (open.includes(id)) onClose(id); else onShow(id); setExpanded(false); }}>
24
+ <span>{label(id)}</span>{open.includes(id) ? Icon.check() : Icon.plus()}
25
+ </button>)}
26
+ </Popover>}
27
+ </div>;
28
+ }
@@ -7,11 +7,11 @@
7
7
  // (baseUrl / token / line / headers)建一个。不给 jobId 时跟 Provider 的选中走。
8
8
  // 它们只依赖中枢的看板接口(/api/jobs/:id/files、/browser…),不依赖 cli-chat / cli-board。
9
9
  import { useState, type CSSProperties } from "react";
10
- import { CliErrorBoundary } from "../ui/ErrorBoundary.js";
11
- import type { CliEvent, CliStore, CliStoreOptions } from "../core/store.js";
12
- import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider } from "../core/provider.js";
13
- import { FileViewer, type FileAction } from "./FileViewer.js";
14
- import { BrowserPane } from "./BrowserPane.js";
10
+ import { CliErrorBoundary } from "../ui/ErrorBoundary";
11
+ import type { CliEvent, CliStore, CliStoreOptions } from "../core/store";
12
+ import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider } from "../core/provider";
13
+ import { FileViewer, type FileAction } from "./FileViewer";
14
+ import { BrowserPane } from "./BrowserPane";
15
15
 
16
16
  interface BlockProps extends CliStoreOptions {
17
17
  /** 哪个对话(center 的 job_id)。不给就跟 Provider 的选中走。 */
@@ -0,0 +1,65 @@
1
+ import type { BoardFile, BoardView } from './workspace';
2
+ import { serializeBlock, splitMessage, VIEWING_NAME } from '../core/blocks';
3
+ export const BOARD_HISTORY_MAX = 40;
4
+ const identity=(file:BoardFile)=>JSON.stringify([file.sourceId??'',file.path]);
5
+ export function rememberBoardFile(history:readonly BoardFile[],file:BoardFile):BoardFile[]{
6
+ return [...history.filter(item=>identity(item)!==identity(file)),{...file}].slice(-BOARD_HISTORY_MAX);
7
+ }
8
+ export function cloneBoardView(view:BoardView):BoardView {
9
+ return {...view,files:[...view.files],current:view.current?{...view.current}:view.current,opened:view.opened?.map(file=>({...file})),history:view.history?.map(file=>({...file})),browser:view.browser?{title:view.browser.title,url:browserUrl(view.browser.url)}:view.browser};
10
+ }
11
+ function browserUrl(raw:string):string {
12
+ try{const url=new URL(raw);return url.protocol==='http:'||url.protocol==='https:'?`${url.origin}${url.pathname}`:url.protocol==='about:'?`${url.protocol}${url.pathname}`:url.protocol;}catch{return '';}
13
+ }
14
+ function describe(file:BoardFile,currentSourceId?:string){
15
+ return file.sourceId&&file.sourceId!==currentSourceId?{path:file.path,sourceTask:file.sourceId,note:'来源任务的工作区,非当前执行分支'}:{path:file.path};
16
+ }
17
+ /** Metadata only; do not silently read/upload file bodies, URLs with auth tokens or arbitrary DOM. */
18
+ export function formatBoardContext(view:BoardView,currentSourceId?:string):string {
19
+ if(view.autoContext===false)return '';
20
+ const current=view.current===undefined?(view.file?{path:view.file}:null):view.current;
21
+ const opened=view.opened??view.files.map(path=>({path}));
22
+ const history=view.history??[];
23
+ if(!current&&!opened.length&&!history.length&&!view.browser)return '';
24
+ const data={panelVisible:view.visible!==false,activeTab:view.tab,currentFile:current?describe(current,currentSourceId):null,
25
+ openFiles:opened.slice(-8).map(file=>describe(file,currentSourceId)),recentlyVisited:history.slice(-BOARD_HISTORY_MAX).map(file=>describe(file,currentSourceId)),
26
+ ...(view.browser?{browser:{url:browserUrl(view.browser.url),title:view.browser.title}}:{})};
27
+ const text=JSON.stringify(data,null,2);
28
+ // Bound context without truncating a path into a different, plausible filename.
29
+ if(text.length>16000){data.recentlyVisited=[];const compact=JSON.stringify(data,null,2);if(compact.length>16000)return '看板浏览背景较大,本次未自动附带;请用户明确引用需要的文件。';return `用户工作区浏览背景(仅元信息;较早浏览记录已省略):\n${compact}`;}
30
+ return `用户工作区浏览背景(自动附带,仅元信息;最近访问不代表仍在查看;panelVisible=false 时 currentFile 是收起前选中的文件):\n${text}`;
31
+ }
32
+ export function appendBoardContext(text:string,view:BoardView|null,currentSourceId?:string):string {
33
+ const context=view?formatBoardContext(view,currentSourceId):'';
34
+ return context?[text,serializeBlock({name:VIEWING_NAME,kind:'viewing',text:context})].filter(Boolean).join('\n\n'):text;
35
+ }
36
+
37
+ export function withoutBoardContext(text:string):string {
38
+ return splitMessage(text).filter(part=>part.kind!=='block'||part.block.kind!=='viewing').map(part=>part.kind==='text'?part.text:serializeBlock(part.block)).join('\n\n');
39
+ }
40
+
41
+ /** Local selection changes may select an owner; shared history and remote page
42
+ * metadata updates must not steal the user's active board. */
43
+ const localBoardView=(view:BoardView)=>JSON.stringify({tab:view.tab,dir:view.dir,file:view.file,files:view.files,current:view.current,opened:view.opened,visible:view.visible!==false});
44
+ export function selectedBoardOwner(sources:Map<string,BoardView>):[string,BoardView]|undefined {
45
+ const entries=[...sources.entries()];return entries.reverse().find(([,view])=>view.visible!==false)??entries[0];
46
+ }
47
+ export function reportBoardOwner(sources:Map<string,BoardView>,owner:string,view:BoardView):[string,BoardView]|undefined {
48
+ const previous=sources.get(owner),active=selectedBoardOwner(sources);
49
+ if(previous){if(localBoardView(previous)!==localBoardView(view)){sources.delete(owner);sources.set(owner,view);}else sources.set(owner,view);}
50
+ else if(active&&active[1].visible!==false){const existing=[...sources];sources.clear();sources.set(owner,view);for(const [id,value]of existing)sources.set(id,value);}
51
+ else sources.set(owner,view);
52
+ return selectedBoardOwner(sources);
53
+ }
54
+ export function activateBoardOwner(sources:Map<string,BoardView>,owner:string):[string,BoardView]|undefined {
55
+ const view=sources.get(owner);if(view){sources.delete(owner);sources.set(owner,view);}return selectedBoardOwner(sources);
56
+ }
57
+ export function mergeBoardHistory(existing:readonly BoardFile[],previous:BoardView|undefined,next:BoardView):BoardFile[] {
58
+ const old=previous?.history??[],incoming=next.history??[];let history=[...existing];
59
+ const known=new Set(old.map(identity));
60
+ // Newly mounted boards may restore old history; only adopt previously unknown
61
+ // records so a late restore cannot reorder the live workspace's recent visits.
62
+ const additions=incoming.filter(file=>!known.has(identity(file))&&(!previous?!history.some(item=>identity(item)===identity(file)):true));
63
+ if(previous&&incoming.length&&identity(incoming[incoming.length-1])!==identity(old[old.length-1]??{path:''}))additions.push(incoming[incoming.length-1]);
64
+ for(const file of additions)history=rememberBoardFile(history,file);return history;
65
+ }