@spzxg/cli-ui 0.6.1 → 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.
- package/CHANGELOG.md +26 -0
- package/README.md +145 -13
- package/dist/cli-ui-embed-BmLN2Jjg.js +43290 -0
- package/dist/{cli-ui-index-BFpRZXn4.js → cli-ui-index-D4qfPHMd.js} +2 -2
- package/dist/{cli-ui-jszip.min-B9tqMA-e.js → cli-ui-jszip.min-DC9898pC.js} +1 -1
- package/dist/{cli-ui-jszip.min-DIavJzUG.js → cli-ui-jszip.min-DpEl0G1z.js} +2 -2
- package/dist/{cli-ui-xlsx-BKHhqcoa.js → cli-ui-xlsx-zs7oEtPj.js} +1 -1
- package/dist/cli-ui.css +32 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +14 -8
- package/src/board/BrowserPane.tsx +160 -74
- package/src/board/ChangesPane.tsx +2 -2
- package/src/board/CliBoard.tsx +79 -50
- package/src/board/FileViewer.tsx +32 -22
- package/src/board/FilesPane.tsx +32 -15
- package/src/board/PanelControls.tsx +28 -0
- package/src/board/blocks.tsx +5 -5
- package/src/board/context.ts +65 -0
- package/src/board/fileOps.ts +26 -3
- package/src/board/fileWatch.ts +88 -0
- package/src/board/folderDownload.ts +30 -14
- package/src/board/panels.ts +33 -0
- package/src/board/textLoad.ts +2 -2
- package/src/board/workspace.ts +38 -9
- package/src/chat/CliChat.tsx +113 -53
- package/src/chat/Composer.tsx +68 -57
- package/src/chat/RunControls.tsx +99 -0
- package/src/chat/configuration.ts +15 -0
- package/src/chat/draft.ts +41 -0
- package/src/chat/export.ts +2 -2
- package/src/components.tsx +13 -0
- package/src/core/blocks.ts +2 -2
- package/src/core/callbacks.ts +8 -0
- package/src/core/client.ts +16 -6
- package/src/core/config.ts +11 -15
- package/src/core/conversation.ts +4 -4
- package/src/core/id.ts +15 -0
- package/src/core/identity.ts +33 -0
- package/src/core/provider.tsx +24 -14
- package/src/core/store.ts +83 -26
- package/src/core/types.ts +34 -0
- package/src/elements.tsx +135 -29
- package/src/embed.ts +6 -8
- package/src/index.ts +24 -17
- package/src/layout/CliSplit.tsx +1 -1
- package/src/msglist/CliMsgList.tsx +10 -10
- package/src/operator/branchFlow.ts +35 -0
- package/src/operator/index.ts +9 -0
- package/src/operator/liveStream.ts +1 -0
- package/src/operator/node/AgentConnect.tsx +624 -0
- package/src/operator/node/AppearancePanel.tsx +35 -0
- package/src/operator/node/BranchHistory.tsx +67 -0
- package/src/operator/node/BranchNavigator.tsx +36 -0
- package/src/operator/node/BrowserPane.tsx +128 -0
- package/src/operator/node/BrowserPopup.tsx +22 -0
- package/src/operator/node/Chat.tsx +517 -0
- package/src/operator/node/CommandPalette.tsx +213 -0
- package/src/operator/node/Composer.tsx +287 -0
- package/src/operator/node/InsightsView.tsx +831 -0
- package/src/operator/node/Layout.tsx +190 -0
- package/src/operator/node/Markdown.tsx +61 -0
- package/src/operator/node/Modal.tsx +179 -0
- package/src/operator/node/NeedsYouMenu.tsx +54 -0
- package/src/operator/node/OnboardingWizard.tsx +250 -0
- package/src/operator/node/Orchestrator.tsx +634 -0
- package/src/operator/node/ProjectLanding.tsx +86 -0
- package/src/operator/node/ProjectsColumn.tsx +137 -0
- package/src/operator/node/ProviderAuth.tsx +561 -0
- package/src/operator/node/RunControls.tsx +2 -0
- package/src/operator/node/RunSwitch.tsx +113 -0
- package/src/operator/node/Services.tsx +245 -0
- package/src/operator/node/SessionRail.tsx +260 -0
- package/src/operator/node/SessionView.tsx +288 -0
- package/src/operator/node/SettingsView.tsx +690 -0
- package/src/operator/node/TaskBoard.tsx +352 -0
- package/src/operator/node/TaskChanges.tsx +530 -0
- package/src/operator/node/TaskHero.tsx +50 -0
- package/src/operator/node/TasksColumn.tsx +200 -0
- package/src/operator/node/Terminal.tsx +143 -0
- package/src/operator/node/Transcript.tsx +463 -0
- package/src/operator/node/ViewFile.tsx +79 -0
- package/src/operator/node/Welcome.tsx +101 -0
- package/src/operator/node/WorktreePrune.tsx +333 -0
- package/src/operator/node/agents.ts +58 -0
- package/src/operator/node/api.ts +40 -0
- package/src/operator/node/boardBackend.ts +80 -0
- package/src/operator/node/boardEvents.ts +22 -0
- package/src/operator/node/boardInteraction.ts +15 -0
- package/src/operator/node/boardState.ts +57 -0
- package/src/operator/node/boardWorkspace.ts +12 -0
- package/src/operator/node/branchFlow.ts +1 -0
- package/src/operator/node/composerDraft.ts +52 -0
- package/src/operator/node/fileLinks.ts +81 -0
- package/src/operator/node/format.ts +290 -0
- package/src/operator/node/github.tsx +234 -0
- package/src/operator/node/icons.tsx +88 -0
- package/src/operator/node/liveStream.ts +270 -0
- package/src/operator/node/modals.tsx +720 -0
- package/src/operator/node/navHistory.ts +70 -0
- package/src/operator/node/persist.ts +26 -0
- package/src/operator/node/protocol/agents/optIn.ts +9 -0
- package/src/operator/node/protocol/agents/types.ts +394 -0
- package/src/operator/node/protocol/approvalFailure.ts +77 -0
- package/src/operator/node/protocol/authFailure.ts +66 -0
- package/src/operator/node/protocol/customModels.ts +23 -0
- package/src/operator/node/protocol/events.ts +13 -0
- package/src/operator/node/protocol/features.ts +70 -0
- package/src/operator/node/protocol/internalUsage.ts +1 -0
- package/src/operator/node/protocol/promptLimits.ts +52 -0
- package/src/operator/node/protocol/storageRetention.ts +13 -0
- package/src/operator/node/protocol/types.ts +332 -0
- package/src/operator/node/protocol/usageLimit.ts +72 -0
- package/src/operator/node/shared.tsx +89 -0
- package/src/operator/node/transcriptModel.ts +129 -0
- package/src/operator/node/types.ts +296 -0
- package/src/operator/node/useGlobalEvents.ts +97 -0
- package/src/operator/node/useOrchestrator.ts +696 -0
- package/src/operator/node/usePrefs.ts +114 -0
- package/src/operator/node/useRecaps.ts +91 -0
- package/src/operator/node/useTaskStream.ts +279 -0
- package/src/operator.base.css +1798 -0
- package/src/operator.css +186 -16
- package/src/styles.css +51 -0
- package/src/ui/Markdown.tsx +7 -2
- package/src/ui/Popover.tsx +77 -0
- package/src/ui/TextBlock.tsx +3 -3
- package/src/ui/Transcript.tsx +10 -8
- package/src/ui/fileLinks.ts +6 -0
- package/src/ui/format.ts +1 -1
- package/src/ui/icons.tsx +1 -0
- package/src/ui/shared.tsx +1 -1
- package/src/ui/transcriptModel.ts +2 -2
- package/dist/cli-ui-embed-Dk2QGHxn.js +0 -41146
package/src/board/CliBoard.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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 (!
|
|
140
|
-
restored.current =
|
|
141
|
-
const saved = loadTabs(
|
|
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
|
-
}, [
|
|
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[]>(() => (
|
|
207
|
-
const
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
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 (
|
|
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
|
|
338
|
-
|
|
339
|
-
|
|
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:
|
|
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
|
-
<
|
|
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>}
|
package/src/board/FileViewer.tsx
CHANGED
|
@@ -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
|
|
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 } from "./fileOps
|
|
19
|
-
import { bridgeDoc, frameToken, type FrameSelMessage } from "./frameQuote
|
|
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
|
|
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
|
-
|
|
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
|
|
190
|
+
readBoundedBuffer(backend, jobId, path, HTML_QUOTE_MAX, {
|
|
184
191
|
signal: ctl.signal,
|
|
185
192
|
// 大小事先不知道(从对话里打开的):读着读着超过上限就停下,改成直接打开。
|
|
186
|
-
onBytes: (n) => { setProgress(n);
|
|
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 (
|
|
193
|
-
return () =>
|
|
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
|
|
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;
|
package/src/board/FilesPane.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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 (
|
|
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(() =>
|
|
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(() =>
|
|
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
|
+
}
|
package/src/board/blocks.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|