@spzxg/cli-ui 0.6.2 → 0.6.6

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 (133) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +165 -19
  3. package/dist/cli-ui-embed-BAqonuoD.js +45952 -0
  4. package/dist/{cli-ui-index-BY5i3wId.js → cli-ui-index-b8wF7wrN.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-m01fexHW.js → cli-ui-jszip.min-B0uLrCHg.js} +2 -2
  6. package/dist/{cli-ui-jszip.min-BZsUwI-s.js → cli-ui-jszip.min-ggP8OokP.js} +1 -1
  7. package/dist/{cli-ui-xlsx-CpWicJLT.js → cli-ui-xlsx-DEDdHrJJ.js} +1 -1
  8. package/dist/cli-ui.css +32 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +17 -10
  11. package/src/board/BrowserPane.tsx +346 -115
  12. package/src/board/ChangesPane.tsx +2 -2
  13. package/src/board/CliBoard.tsx +79 -50
  14. package/src/board/FileViewer.tsx +54 -31
  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 +94 -0
  21. package/src/board/folderDownload.ts +30 -14
  22. package/src/board/frameQuote.ts +2 -2
  23. package/src/board/panels.ts +33 -0
  24. package/src/board/textLoad.ts +9 -7
  25. package/src/board/workspace.ts +53 -14
  26. package/src/chat/CliChat.tsx +93 -40
  27. package/src/chat/Composer.tsx +44 -42
  28. package/src/chat/RunControls.tsx +99 -0
  29. package/src/chat/configuration.ts +15 -0
  30. package/src/chat/draft.ts +41 -0
  31. package/src/chat/export.ts +2 -2
  32. package/src/components.tsx +13 -0
  33. package/src/core/blocks.ts +17 -6
  34. package/src/core/callbacks.ts +8 -0
  35. package/src/core/client.ts +103 -19
  36. package/src/core/conversation.ts +4 -3
  37. package/src/core/id.ts +15 -0
  38. package/src/core/identity.ts +33 -0
  39. package/src/core/provider.tsx +31 -14
  40. package/src/core/store.ts +151 -53
  41. package/src/core/types.ts +64 -9
  42. package/src/elements.tsx +135 -29
  43. package/src/embed.ts +6 -8
  44. package/src/index.ts +24 -17
  45. package/src/layout/CliSplit.tsx +1 -1
  46. package/src/layout/split.ts +67 -30
  47. package/src/msglist/CliMsgList.tsx +19 -13
  48. package/src/operator/branchFlow.ts +35 -0
  49. package/src/operator/index.ts +9 -0
  50. package/src/operator/liveStream.ts +1 -0
  51. package/src/operator/node/AgentConnect.tsx +624 -0
  52. package/src/operator/node/AppearancePanel.tsx +35 -0
  53. package/src/operator/node/BranchHistory.tsx +67 -0
  54. package/src/operator/node/BranchNavigator.tsx +36 -0
  55. package/src/operator/node/BrowserPane.tsx +128 -0
  56. package/src/operator/node/BrowserPopup.tsx +22 -0
  57. package/src/operator/node/Chat.tsx +517 -0
  58. package/src/operator/node/CommandPalette.tsx +213 -0
  59. package/src/operator/node/Composer.tsx +287 -0
  60. package/src/operator/node/InsightsView.tsx +831 -0
  61. package/src/operator/node/Layout.tsx +190 -0
  62. package/src/operator/node/Markdown.tsx +61 -0
  63. package/src/operator/node/Modal.tsx +179 -0
  64. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  65. package/src/operator/node/OnboardingWizard.tsx +250 -0
  66. package/src/operator/node/Orchestrator.tsx +634 -0
  67. package/src/operator/node/ProjectLanding.tsx +86 -0
  68. package/src/operator/node/ProjectsColumn.tsx +137 -0
  69. package/src/operator/node/ProviderAuth.tsx +561 -0
  70. package/src/operator/node/RunControls.tsx +2 -0
  71. package/src/operator/node/RunSwitch.tsx +113 -0
  72. package/src/operator/node/Services.tsx +245 -0
  73. package/src/operator/node/SessionRail.tsx +260 -0
  74. package/src/operator/node/SessionView.tsx +288 -0
  75. package/src/operator/node/SettingsView.tsx +690 -0
  76. package/src/operator/node/TaskBoard.tsx +352 -0
  77. package/src/operator/node/TaskChanges.tsx +530 -0
  78. package/src/operator/node/TaskHero.tsx +50 -0
  79. package/src/operator/node/TasksColumn.tsx +200 -0
  80. package/src/operator/node/Terminal.tsx +143 -0
  81. package/src/operator/node/Transcript.tsx +468 -0
  82. package/src/operator/node/ViewFile.tsx +79 -0
  83. package/src/operator/node/Welcome.tsx +101 -0
  84. package/src/operator/node/WorktreePrune.tsx +333 -0
  85. package/src/operator/node/agents.ts +58 -0
  86. package/src/operator/node/api.ts +40 -0
  87. package/src/operator/node/boardBackend.ts +82 -0
  88. package/src/operator/node/boardEvents.ts +22 -0
  89. package/src/operator/node/boardInteraction.ts +15 -0
  90. package/src/operator/node/boardState.ts +57 -0
  91. package/src/operator/node/boardWorkspace.ts +12 -0
  92. package/src/operator/node/branchFlow.ts +1 -0
  93. package/src/operator/node/composerDraft.ts +52 -0
  94. package/src/operator/node/fileLinks.ts +81 -0
  95. package/src/operator/node/format.ts +290 -0
  96. package/src/operator/node/github.tsx +234 -0
  97. package/src/operator/node/icons.tsx +88 -0
  98. package/src/operator/node/liveStream.ts +270 -0
  99. package/src/operator/node/modals.tsx +720 -0
  100. package/src/operator/node/navHistory.ts +70 -0
  101. package/src/operator/node/persist.ts +26 -0
  102. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  103. package/src/operator/node/protocol/agents/types.ts +394 -0
  104. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  105. package/src/operator/node/protocol/authFailure.ts +66 -0
  106. package/src/operator/node/protocol/customModels.ts +23 -0
  107. package/src/operator/node/protocol/events.ts +13 -0
  108. package/src/operator/node/protocol/features.ts +70 -0
  109. package/src/operator/node/protocol/internalUsage.ts +1 -0
  110. package/src/operator/node/protocol/promptLimits.ts +52 -0
  111. package/src/operator/node/protocol/storageRetention.ts +13 -0
  112. package/src/operator/node/protocol/types.ts +332 -0
  113. package/src/operator/node/protocol/usageLimit.ts +72 -0
  114. package/src/operator/node/shared.tsx +89 -0
  115. package/src/operator/node/transcriptModel.ts +129 -0
  116. package/src/operator/node/types.ts +296 -0
  117. package/src/operator/node/useGlobalEvents.ts +97 -0
  118. package/src/operator/node/useOrchestrator.ts +696 -0
  119. package/src/operator/node/usePrefs.ts +114 -0
  120. package/src/operator/node/useRecaps.ts +91 -0
  121. package/src/operator/node/useTaskStream.ts +280 -0
  122. package/src/operator.base.css +1806 -0
  123. package/src/operator.css +194 -16
  124. package/src/styles.css +49 -1
  125. package/src/ui/Markdown.tsx +7 -2
  126. package/src/ui/Popover.tsx +82 -0
  127. package/src/ui/TextBlock.tsx +3 -3
  128. package/src/ui/Transcript.tsx +17 -10
  129. package/src/ui/fileLinks.ts +6 -0
  130. package/src/ui/format.ts +1 -1
  131. package/src/ui/shared.tsx +1 -1
  132. package/src/ui/transcriptModel.ts +2 -2
  133. package/dist/cli-ui-embed-6IJ_jRVh.js +0 -41223
@@ -1,8 +1,8 @@
1
1
  // 改动:移植自 Operator 的 TaskChanges(只读部分:分支、统计、文件列表、逐文件 diff)。
2
2
  // 合并 / 解决冲突 / 建 PR 是 Operator 里的操作,经中枢的对话任务不提供。
3
3
  import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
4
- import type { BoardBackend } from "../core/client.js";
5
- import type { DiffFile, DiffResult } from "../core/types.js";
4
+ import type { BoardBackend } from "../core/client";
5
+ import type { DiffFile, DiffResult } from "../core/types";
6
6
 
7
7
  const STATUS_LABEL: Record<string, string> = { A: "新增", M: "修改", D: "删除", R: "重命名", "?": "未跟踪" };
8
8
  const COLLAPSE_LINES = 400;
@@ -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,11 +136,17 @@ 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);
140
148
  const [error, setError] = useState<string | null>(null);
149
+ const [downloadError, setDownloadError] = useState<string | null>(null);
141
150
  const [copied, setCopied] = useState(false);
142
151
  const [busy, setBusy] = useState(false);
143
152
  const [mode, setMode] = useState<"preview" | "source">("preview");
@@ -177,20 +186,19 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
177
186
  useEffect(() => {
178
187
  if (!htmlBridge) return;
179
188
  const ctl = new AbortController();
180
- let over = false;
181
189
  setHtmlDoc(null);
182
190
  setProgress(0);
183
- backend.readBuffer(jobId, path, {
191
+ readBoundedBuffer(backend, jobId, path, HTML_QUOTE_MAX, {
184
192
  signal: ctl.signal,
185
193
  // 大小事先不知道(从对话里打开的):读着读着超过上限就停下,改成直接打开。
186
- onBytes: (n) => { setProgress(n); if (n > HTML_QUOTE_MAX && !over) { over = true; ctl.abort(); } },
194
+ onBytes: (n) => { setProgress(n); },
187
195
  }).then((buffer) => {
188
196
  if (ctl.signal.aborted) return;
189
197
  const url = linksRef.current.url?.(path);
190
198
  const base = url ? withVersion(new URL(url, window.location.href).href, version) : undefined;
191
199
  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(); };
200
+ }, (error:unknown) => { if (ctl.signal.aborted)return; if(error instanceof FileReadLimitError)setHtmlDoc("toobig");else setHtmlDoc("fallback"); });
201
+ return () => ctl.abort();
194
202
  }, [htmlBridge, backend, jobId, path, version, attempt, token]);
195
203
 
196
204
  // 重读时保留滚动位置;原本贴着底(或在看末尾)的继续贴着底——任务在往文件里写时像 tail -f。
@@ -238,8 +246,13 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
238
246
  const r = typeof range.getBoundingClientRect === "function" ? range.getBoundingClientRect() : { left: box.left + box.width / 2, width: 0, top: box.top + 48 };
239
247
  setSel({ text, x: Math.min(Math.max(8, r.left - box.left + r.width / 2), box.width - 8), y: Math.max(40, r.top - box.top) });
240
248
  };
241
- const onUp = () => setTimeout(read, 0);
242
- const onChange = () => { const s = window.getSelection(); if (!s || s.isCollapsed) setSel((cur) => (cur?.frame ? cur : null)); };
249
+ let selectionTimer: ReturnType<typeof setTimeout> | undefined;
250
+ const onUp = () => {
251
+ if (selectionTimer !== undefined) clearTimeout(selectionTimer);
252
+ selectionTimer = setTimeout(() => { selectionTimer = undefined; read(); }, 0);
253
+ };
254
+ // Native touch selection/handle changes need not generate mouseup or keyup.
255
+ const onChange = onUp;
243
256
  const body = bodyRef.current;
244
257
  document.addEventListener("mouseup", onUp);
245
258
  document.addEventListener("keyup", onUp);
@@ -250,6 +263,7 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
250
263
  body?.addEventListener("scroll", onScroll, { passive: true });
251
264
  document.addEventListener("mousedown", onDown);
252
265
  return () => {
266
+ if (selectionTimer !== undefined) clearTimeout(selectionTimer);
253
267
  document.removeEventListener("mousedown", onDown);
254
268
  document.removeEventListener("mouseup", onUp);
255
269
  document.removeEventListener("keyup", onUp);
@@ -267,15 +281,18 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
267
281
  };
268
282
  useEffect(() => {
269
283
  let alive = true;
284
+ const ctl=new AbortController();
270
285
  setError(null);
271
286
  if (needsSrc || kind === "binary") { setLoaded({ kind: "none" }); return; }
272
287
  if (kind === "docx") {
273
288
  if (size !== undefined && size > DOCX_PREVIEW_MAX) { setError(`文件较大(${fmtSize(size)}),请下载后查看`); return; }
274
289
  setProgress(0);
275
- backend.readBuffer(jobId, path, { onBytes: (n) => { if (alive) setProgress(n); } }).then(async (buffer) => {
290
+ readBoundedBuffer(backend, jobId, path, DOCX_PREVIEW_MAX, { signal:ctl.signal,onBytes: (n) => { if (alive) setProgress(n); } }).then(async (buffer) => {
291
+ if(!alive)return;
276
292
  const mammoth = await import("mammoth");
277
293
  const convert = (mammoth as unknown as { convertToHtml: typeof import("mammoth").convertToHtml }).convertToHtml
278
294
  ?? (mammoth as unknown as { default: typeof import("mammoth") }).default.convertToHtml;
295
+ if(!alive)return;
279
296
  const input: Record<string, unknown> = { arrayBuffer: buffer };
280
297
  const B = (globalThis as { Buffer?: { from(b: ArrayBuffer): unknown } }).Buffer;
281
298
  if (B) input.buffer = B.from(buffer);
@@ -284,12 +301,12 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
284
301
  }).catch((e: unknown) => { if (alive) setError(e instanceof Error ? e.message : String(e)); });
285
302
  } else {
286
303
  // 只是文件变了(同一个文件、同样看开头 / 末尾):能接上就只取新增的部分(见 textLoad.ts),接不上再整个重读。
287
- const key = `${path}|${tail}|${attempt}`;
304
+ const key = `${path}|${tail}|${attempt}|${reloadKey??""}`;
288
305
  const prev = loadedRef.current?.kind === "text" && textKey.current === key ? loadedRef.current : null;
289
306
  textKey.current = key;
290
307
  if (!prev) setProgress(0);
291
- const full = () => loadText(backend, jobId, path, { max: TEXT_MAX, tail, onBytes: (n) => { if (alive && !prev) setProgress(n); } });
292
- (prev ? refreshText(backend, jobId, path, prev, TEXT_MAX).then((r) => r ?? full(), full) : full()).then(
308
+ const full = () => loadText(backend, jobId, path, { max: TEXT_MAX, tail, signal: ctl.signal, onBytes: (n) => { if (alive && !prev) setProgress(n); } });
309
+ (prev ? refreshText(backend, jobId, path, prev, TEXT_MAX, { signal: ctl.signal }).then((r) => r ?? full(), full) : full()).then(
293
310
  (r) => {
294
311
  if (!alive) return;
295
312
  const box = bodyRef.current;
@@ -299,8 +316,8 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
299
316
  (e: unknown) => { if (alive) setError(e instanceof Error ? e.message : String(e)); },
300
317
  );
301
318
  }
302
- return () => { alive = false; };
303
- }, [backend, jobId, path, kind, needsSrc, version, tail, attempt]); // eslint-disable-line react-hooks/exhaustive-deps
319
+ return () => { alive = false; ctl.abort(); };
320
+ }, [backend, jobId, path, kind, needsSrc, version, reloadKey, tail, attempt]); // eslint-disable-line react-hooks/exhaustive-deps
304
321
  useLayoutEffect(() => {
305
322
  const box = bodyRef.current;
306
323
  if (box && stick.current && loaded?.kind === "text") box.scrollTop = box.scrollHeight;
@@ -311,8 +328,9 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
311
328
  }), [backend, jobId, path, name, links]);
312
329
 
313
330
  const download = async () => {
314
- setBusy(true);
315
- try { await downloadFile(backend, jobId, path, knownSize); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); }
331
+ if (busy) return;
332
+ setBusy(true); setDownloadError(null);
333
+ try { await downloadFile(backend, jobId, path, knownSize); } catch (e) { setDownloadError(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); }
316
334
  };
317
335
  const openUrl = links.url ? links.url(path) : src.url;
318
336
  const openNew = () => { if (openUrl) window.open(openUrl, "_blank", "noopener"); };
@@ -370,11 +388,15 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
370
388
  {onClose && <button className="cli-fv-icon" onClick={onClose} title="关闭">{Icon.x()}</button>}
371
389
  </div>
372
390
  )}
391
+ {downloadError && <div className="cli-fv-trunc" role="alert">下载失败:{downloadError}
392
+ <button className="cli-fv-btn" disabled={busy} onClick={() => void download()}>重试下载</button>
393
+ <button className="cli-fv-btn" onClick={() => setDownloadError(null)} aria-label="关闭下载错误">关闭</button>
394
+ </div>}
373
395
  <div className={`cli-fv-b k-${kind}`} ref={bodyRef}>
374
396
  {error || src.error ? (
375
397
  <div className="cli-fv-note err">读取失败:{error ?? src.error}
376
398
  <button className="btn btn-line btn-sm" style={{ marginLeft: 8 }} onClick={retry}>重试</button>
377
- <button className="btn btn-line btn-sm" style={{ marginLeft: 8 }} onClick={() => void download()}>{Icon.download()} 下载</button>
399
+ <button className="btn btn-line btn-sm" style={{ marginLeft: 8 }} onClick={() => void download()} disabled={busy}>{Icon.download()} 下载</button>
378
400
  </div>
379
401
  ) : kind === "binary" ? (
380
402
  <div className="cli-fv-note">这种文件不能在这里预览。
@@ -488,8 +510,9 @@ function SourceView({ backend, jobId, path, version }: { backend: BoardBackend;
488
510
  const [state, setState] = useState<{ text: string; size?: number; truncated?: boolean } | null>(null);
489
511
  useEffect(() => {
490
512
  let alive = true;
491
- backend.readText(jobId, path, TEXT_MAX).then((r) => { if (alive) setState({ text: r.text, size: r.size, truncated: r.truncated }); }, (e: unknown) => { if (alive) setState({ text: `读取失败:${e instanceof Error ? e.message : String(e)}` }); });
492
- return () => { alive = false; };
513
+ const ctl = new AbortController();
514
+ backend.readText(jobId, path, TEXT_MAX, { signal: ctl.signal }).then((r) => { if (alive) setState({ text: r.text, size: r.size, truncated: r.truncated }); }, (e: unknown) => { if (alive) setState({ text: `读取失败:${e instanceof Error ? e.message : String(e)}` }); });
515
+ return () => { alive = false; ctl.abort(); };
493
516
  }, [backend, jobId, path, version]);
494
517
  if (!state) return <div className="cli-fv-note">加载中…</div>;
495
518
  return (
@@ -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 的选中走。 */