@spzxg/cli-ui 0.4.0 → 0.6.0
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/README.md +78 -10
- package/dist/cli-ui-embed-CXdmrwo1.js +40896 -0
- package/dist/cli-ui-index-BZppwn-v.js +13528 -0
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +2 -1
- package/src/board/BrowserPane.tsx +396 -96
- package/src/board/ChangesPane.tsx +13 -4
- package/src/board/CliBoard.tsx +219 -37
- package/src/board/FileViewer.tsx +244 -63
- package/src/board/FilesPane.tsx +51 -4
- package/src/board/blocks.tsx +3 -2
- package/src/board/fileOps.ts +55 -0
- package/src/board/folderDownload.ts +161 -0
- package/src/board/workspace.ts +129 -0
- package/src/board/zip.ts +85 -0
- package/src/chat/CliChat.tsx +114 -21
- package/src/core/blocks.ts +21 -2
- package/src/core/client.ts +258 -41
- package/src/core/conversation.ts +17 -7
- package/src/core/store.ts +340 -71
- package/src/core/textDecode.ts +24 -0
- package/src/core/types.ts +21 -6
- package/src/elements.tsx +106 -11
- package/src/index.ts +7 -2
- package/src/layout/CliSplit.tsx +53 -0
- package/src/layout/split.ts +214 -0
- package/src/msglist/CliMsgList.tsx +50 -22
- package/src/styles.css +101 -0
- package/src/ui/ErrorBoundary.tsx +38 -0
- package/src/ui/TextBlock.tsx +1 -1
- package/src/ui/transcriptModel.ts +66 -9
- package/dist/cli-ui-embed-D7xtcvGG.js +0 -37967
- package/dist/cli-ui-index-O0krpZBg.js +0 -13516
|
@@ -6,6 +6,8 @@ import type { DiffFile, DiffResult } from "../core/types.js";
|
|
|
6
6
|
|
|
7
7
|
const STATUS_LABEL: Record<string, string> = { A: "新增", M: "修改", D: "删除", R: "重命名", "?": "未跟踪" };
|
|
8
8
|
const COLLAPSE_LINES = 400;
|
|
9
|
+
/** 一次画多少个文件的 diff:改了几千个文件时不一次塞进页面,往下点「再显示」。 */
|
|
10
|
+
const RENDER_BATCH = 150;
|
|
9
11
|
|
|
10
12
|
// 去掉 diff --git / index / --- / +++ 文件头,只留 hunk 行(与 Operator 一致)。
|
|
11
13
|
function hunkLines(patch: string): string[] {
|
|
@@ -43,7 +45,7 @@ const FileDiff = memo(function FileDiff({ file: f, userToggled, onToggle, refs,
|
|
|
43
45
|
file: DiffFile; userToggled: boolean; onToggle: (path: string) => void;
|
|
44
46
|
refs: { current: Record<string, HTMLDivElement | null> }; onOpen?: (path: string) => void;
|
|
45
47
|
}) {
|
|
46
|
-
const lines = useMemo(() => (f.binary ? [] : hunkLines(f.patch)), [f]);
|
|
48
|
+
const lines = useMemo(() => (f.binary || f.omitted ? [] : hunkLines(f.patch)), [f]);
|
|
47
49
|
const big = lines.length > COLLAPSE_LINES;
|
|
48
50
|
const isCollapsed = userToggled ? !big : big;
|
|
49
51
|
const est = Math.round(34 + (isCollapsed ? 38 : Math.max(1, lines.length) * 18.6));
|
|
@@ -62,7 +64,8 @@ const FileDiff = memo(function FileDiff({ file: f, userToggled, onToggle, refs,
|
|
|
62
64
|
big && <button className="tc-bigdiff" onClick={() => onToggle(f.path)}>大型 diff({lines.length.toLocaleString()} 行)—— 点击展开</button>
|
|
63
65
|
) : (
|
|
64
66
|
<div className="tc-hunks">
|
|
65
|
-
{f.
|
|
67
|
+
{f.omitted ? <div className="tc-empty">改动太多,这个文件的 diff 没有带上{onOpen && f.status !== "D" ? ",可以点「打开」看文件" : ""}。</div>
|
|
68
|
+
: f.binary ? <div className="tc-empty">二进制文件 —— 不予显示</div>
|
|
66
69
|
: lines.length === 0 ? <div className="tc-empty">没有文本改动(仅权限或重命名)。</div>
|
|
67
70
|
: lines.map((ln, i) => <div key={i} className={`dl ${lineClass(ln)}`}>{ln || " "}</div>)}
|
|
68
71
|
{f.truncated && <div className="tc-empty">… 文件 diff 已截断</div>}
|
|
@@ -79,6 +82,7 @@ export function ChangesPane({ backend, jobId, refreshKey, onOpen }: { backend: B
|
|
|
79
82
|
const [manual, setManual] = useState(0);
|
|
80
83
|
const [toggled, setToggled] = useState<Set<string>>(new Set());
|
|
81
84
|
const [active, setActive] = useState<string | null>(null);
|
|
85
|
+
const [shown, setShown] = useState(RENDER_BATCH);
|
|
82
86
|
const secRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
|
83
87
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
84
88
|
|
|
@@ -93,6 +97,9 @@ export function ChangesPane({ backend, jobId, refreshKey, onOpen }: { backend: B
|
|
|
93
97
|
const toggle = useCallback((path: string) => setToggled((s) => { const n = new Set(s); if (n.has(path)) n.delete(path); else n.add(path); return n; }), []);
|
|
94
98
|
const jump = (path: string) => {
|
|
95
99
|
setActive(path);
|
|
100
|
+
// 跳到还没画出来的文件:先把它画出来。
|
|
101
|
+
const at = files.findIndex((f) => f.path === path);
|
|
102
|
+
if (at >= shown) setShown(at + RENDER_BATCH);
|
|
96
103
|
const el = secRefs.current[path];
|
|
97
104
|
const box = scrollRef.current;
|
|
98
105
|
if (el && box) box.scrollTo({ top: el.offsetTop - box.offsetTop, behavior: "smooth" });
|
|
@@ -130,11 +137,13 @@ export function ChangesPane({ backend, jobId, refreshKey, onOpen }: { backend: B
|
|
|
130
137
|
<button key={f.path} className={`tc-frow ${active === f.path ? "on" : ""}`} onClick={() => jump(f.path)} title={f.path}>
|
|
131
138
|
<span className={`tc-st s-${f.status === "?" ? "new" : f.status}`} title={STATUS_LABEL[f.status] || f.status}>{f.status}</span>
|
|
132
139
|
<span className="tc-fpath">{f.path}</span>
|
|
133
|
-
{f.binary ? <span className="tc-bin">bin</span> : <span className="tc-cnt"><b className="add">+{f.additions}</b> <b className="del">−{f.deletions}</b></span>}
|
|
140
|
+
{f.binary ? <span className="tc-bin">bin</span> : f.omitted ? <span className="tc-bin">略</span> : <span className="tc-cnt"><b className="add">+{f.additions}</b> <b className="del">−{f.deletions}</b></span>}
|
|
134
141
|
</button>
|
|
135
142
|
))}
|
|
136
143
|
</div>
|
|
137
|
-
{
|
|
144
|
+
{(data.patchesOmitted ?? 0) > 0 && <div className="tc-empty">改动太多:{data.patchesOmitted} 个文件的 diff 没有带上(只列出文件),可以打开文件查看。</div>}
|
|
145
|
+
{files.slice(0, shown).map((f) => <FileDiff key={f.path} file={f} userToggled={toggled.has(f.path)} onToggle={toggle} refs={secRefs} onOpen={onOpen} />)}
|
|
146
|
+
{files.length > shown && <button className="tc-bigdiff" onClick={() => setShown((n) => n + RENDER_BATCH)}>还有 {(files.length - shown).toLocaleString()} 个文件 —— 再显示 {Math.min(RENDER_BATCH, files.length - shown)} 个</button>}
|
|
138
147
|
</div>
|
|
139
148
|
)}
|
|
140
149
|
</div>
|
package/src/board/CliBoard.tsx
CHANGED
|
@@ -7,10 +7,12 @@
|
|
|
7
7
|
//
|
|
8
8
|
// 同步:任务每有进展(实时流里的轮次 / 输出变化)就刷新,运行中再定时刷新;打开着的文件改了会自动重读。
|
|
9
9
|
// 和 cli-chat 放在同一个 Provider 里时,对话里点本地文件 / 「查看浏览器」会在这里打开(store.openInBoard)。
|
|
10
|
-
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
|
11
|
-
import
|
|
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";
|
|
12
13
|
import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider } from "../core/provider.js";
|
|
13
14
|
import { convKey, parseConvKey, isRunning } from "../core/conversation.js";
|
|
15
|
+
import { toolDataOf } from "../ui/transcriptModel.js";
|
|
14
16
|
import type { BoardInfo, FileEntry } from "../core/types.js";
|
|
15
17
|
import { Icon } from "../ui/icons.js";
|
|
16
18
|
import { CenterError } from "../core/client.js";
|
|
@@ -20,7 +22,34 @@ import { ChangesPane } from "./ChangesPane.js";
|
|
|
20
22
|
import { BrowserPane } from "./BrowserPane.js";
|
|
21
23
|
|
|
22
24
|
export type BoardTab = "files" | "changes" | "browser";
|
|
25
|
+
|
|
26
|
+
/** 宿主加的看板页签。 */
|
|
27
|
+
export interface BoardExtraTab {
|
|
28
|
+
id: string;
|
|
29
|
+
label: string;
|
|
30
|
+
render: (ctx: { jobId: string; store: CliStore; quote: (ref: Omit<BoardRef, "id"> & { id?: string }) => void }) => ReactNode;
|
|
31
|
+
}
|
|
23
32
|
const TAB_LABEL: Record<BoardTab, string> = { files: "文件", changes: "改动", browser: "浏览器" };
|
|
33
|
+
/** 同时开着的文件页签最多几个:再开新的,关掉最早打开、当前没在看的那个。 */
|
|
34
|
+
export const MAX_FILE_TABS = 8;
|
|
35
|
+
const FILE_TAB = "file:";
|
|
36
|
+
const fileTabId = (path: string) => `${FILE_TAB}${path}`;
|
|
37
|
+
/** 开着的文件页签记在会话存储里(按对话):刷新页面后还在。 */
|
|
38
|
+
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 };
|
|
41
|
+
try {
|
|
42
|
+
const v = JSON.parse(sessionStorage.getItem(tabsKey(jobId)) ?? "null") as { files?: unknown; tab?: unknown } | null;
|
|
43
|
+
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 }; }
|
|
46
|
+
}
|
|
47
|
+
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 { /* 存不下就不记 */ }
|
|
50
|
+
}
|
|
51
|
+
/** 有输出就刷新文件,但两次之间至少隔这么久(智能体连写几十个文件时不至于每个都打一次中枢)。 */
|
|
52
|
+
const ACTIVITY_GAP_MS = 700;
|
|
24
53
|
|
|
25
54
|
export interface CliBoardProps extends CliStoreOptions {
|
|
26
55
|
/** 看哪个对话(center 的 job_id)。不给就跟共享仓库的选中走(和 cli-msglist / cli-chat 联动)。 */
|
|
@@ -34,10 +63,24 @@ export interface CliBoardProps extends CliStoreOptions {
|
|
|
34
63
|
readOnly?: boolean;
|
|
35
64
|
/** 文件上的自定义操作(预览头部的按钮)。 */
|
|
36
65
|
fileActions?: FileAction[];
|
|
37
|
-
/**
|
|
66
|
+
/** 运行中兜底刷新文件列表的间隔,缺省 15000ms(文件变化主要靠实时流里的工具结果触发,这里只兜底;人多时每人每 4 秒去节点列一次目录太重);0 = 只在实时流有变化时刷新。改动(diff)不轮询,只在有进展时刷新。 */
|
|
38
67
|
pollMs?: number;
|
|
39
68
|
/** 接不接对话里的「打开文件 / 查看浏览器」,缺省接。 */
|
|
40
69
|
acceptOpen?: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* 显示「引用到对话」(文件预览、浏览器):把看板里的东西带进这个对话的下一条消息(store.addRef)。
|
|
72
|
+
* 缺省:和对话放在同一个 Provider 里时显示。
|
|
73
|
+
*/
|
|
74
|
+
quoteToChat?: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* 宿主自己加的页签(例如终端、另一个浏览器),排在内置页签后面。ctx.quote 可以把页签里的东西引用到对话。
|
|
77
|
+
*/
|
|
78
|
+
extraTabs?: BoardExtraTab[];
|
|
79
|
+
/**
|
|
80
|
+
* 看板上打开着的文件缺省带进对话的下一条消息一次(对话输入框上方显示「正在看」,可以点掉)。
|
|
81
|
+
* 缺省和 quoteToChat 一样。
|
|
82
|
+
*/
|
|
83
|
+
autoQuoteOpenFile?: boolean;
|
|
41
84
|
/** 给了就在页签栏右侧显示「隐藏面板」按钮。 */
|
|
42
85
|
onCollapse?: () => void;
|
|
43
86
|
/**
|
|
@@ -47,7 +90,7 @@ export interface CliBoardProps extends CliStoreOptions {
|
|
|
47
90
|
collapsed?: boolean;
|
|
48
91
|
onExpand?: () => void;
|
|
49
92
|
/** 选中 / 打开文件 / 切页签时通知宿主(例如记到地址栏)。 */
|
|
50
|
-
onNavigate?: (state: { tab: BoardTab; dir: string; file: string | null }) => void;
|
|
93
|
+
onNavigate?: (state: { tab: BoardTab | string; dir: string; file: string | null }) => void;
|
|
51
94
|
onEvent?: (event: CliEvent) => void;
|
|
52
95
|
theme?: "light" | "dark";
|
|
53
96
|
className?: string;
|
|
@@ -57,12 +100,12 @@ export interface CliBoardProps extends CliStoreOptions {
|
|
|
57
100
|
|
|
58
101
|
export function CliBoard(props: CliBoardProps) {
|
|
59
102
|
const ctx = useCliStoreContext();
|
|
60
|
-
return withOwnProvider(props, <BoardInner {...props} inProvider={!!ctx} />, !!ctx)
|
|
103
|
+
return <CliErrorBoundary component="cli-board" theme={props.theme}>{withOwnProvider(props, <BoardInner {...props} inProvider={!!ctx} />, !!ctx)}</CliErrorBoundary>;
|
|
61
104
|
}
|
|
62
105
|
|
|
63
106
|
interface OpenFile { path: string; size?: number; mtime?: number; version: number }
|
|
64
107
|
|
|
65
|
-
function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, defaultTab, readOnly, fileActions, pollMs =
|
|
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 }) {
|
|
66
109
|
useComponentEvents(onEvent, {}, inProvider);
|
|
67
110
|
const store = useCliStore();
|
|
68
111
|
const backend = store.client.board;
|
|
@@ -72,9 +115,10 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, defaultTab, re
|
|
|
72
115
|
|
|
73
116
|
const [info, setInfo] = useState<BoardInfo | null>(null);
|
|
74
117
|
const [infoError, setInfoError] = useState<string | null>(null);
|
|
75
|
-
|
|
118
|
+
// 页签:内置的(文件列表 / 改动 / 浏览器)、打开的文件(file:<路径>,可以开多个、可以关)、宿主加的。
|
|
119
|
+
const [tab, setTab] = useState<string>(defaultTab ?? tabs?.[0] ?? "files");
|
|
76
120
|
const [dir, setDir] = useState("");
|
|
77
|
-
const [
|
|
121
|
+
const [files, setFiles] = useState<OpenFile[]>([]);
|
|
78
122
|
// 刷新信号,按代价分开:
|
|
79
123
|
// tick 进展(实时流有新输出 / 轮次变化,节流)、切回页面、手动刷新 —— 文件、改动、打开的文件都跟着刷新;
|
|
80
124
|
// pollTick 运行中的定时刷新 —— 只刷文件列表和打开的文件(改动要在节点上跑 git diff,不轮询);
|
|
@@ -86,16 +130,35 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, defaultTab, re
|
|
|
86
130
|
const lastJob = useRef(jobId);
|
|
87
131
|
if (lastJob.current !== jobId) {
|
|
88
132
|
lastJob.current = jobId;
|
|
89
|
-
setInfo(null); setInfoError(null); setDir("");
|
|
133
|
+
setInfo(null); setInfoError(null); setDir(""); setFiles([]);
|
|
134
|
+
setTab(defaultTab ?? tabs?.[0] ?? "files");
|
|
90
135
|
}
|
|
136
|
+
// 恢复上次开着的文件页签(存在 sessionStorage):挂上之后、绘制之前做——服务端渲染和首次渲染一致,不会对不上。
|
|
137
|
+
const restored = useRef<string | null>(null);
|
|
138
|
+
useIsoLayoutEffect(() => {
|
|
139
|
+
if (!jobId || restored.current === jobId) return;
|
|
140
|
+
restored.current = jobId;
|
|
141
|
+
const saved = loadTabs(jobId);
|
|
142
|
+
if (saved.files.length) setFiles((cur) => (cur.length ? cur : saved.files.map((path) => ({ path, version: 0 }))));
|
|
143
|
+
if (saved.tab) setTab((cur) => (cur === (defaultTab ?? tabs?.[0] ?? "files") ? saved.tab! : cur));
|
|
144
|
+
}, [jobId]);
|
|
91
145
|
|
|
92
146
|
// 实时流:看板单独用时也要知道任务在不在跑(同一对话只连一次)。
|
|
93
147
|
useEffect(() => (jobId ? store.open(jobId) : undefined), [store, jobId]);
|
|
94
148
|
const conv = useCliState((s) => (jobId ? s.convs[jobId] : undefined));
|
|
95
149
|
const running = conv ? isRunning(conv) : false;
|
|
96
|
-
// 进展签名:轮次状态 + 转录条数。变了就刷新(节流)。
|
|
97
|
-
const statusKey = conv
|
|
98
|
-
|
|
150
|
+
// 进展签名:轮次状态 + 看的子任务(一次性任务没有轮次:续派开出新子任务、派发到节点、换了节点对话都要重查看板概况)+ 转录条数。变了就刷新(节流)。
|
|
151
|
+
const statusKey = conv
|
|
152
|
+
? `${conv.turns.map((t) => `${t.turn_id}:${t.status}`).join(",")}|${conv.job?.status ?? ""}|${conv.task?.id ?? ""}:${conv.task?.status ?? ""}:${conv.remoteTaskId ?? conv.task?.remote_task_id ?? ""}`
|
|
153
|
+
: "";
|
|
154
|
+
// 能改动文件的只有工具调用:签名 = 轮次状态 + 消息条数 + 最近一个工具拿没拿到结果(拿到结果就是文件刚写完)。
|
|
155
|
+
// 智能体逐字输出回复时条数和工具都不变,不触发刷新——几十个人同时看也不会每秒去节点上列一遍目录。
|
|
156
|
+
const lastTool = useMemo(() => {
|
|
157
|
+
const msgs = conv?.msgs ?? [];
|
|
158
|
+
for (let i = msgs.length - 1, n = 0; i >= 0 && n < 50; i--, n++) if (msgs[i].role === "tool") return `${msgs[i].id}:${toolDataOf(msgs[i]).result !== undefined}`;
|
|
159
|
+
return "";
|
|
160
|
+
}, [conv?.msgs]);
|
|
161
|
+
const activity = conv ? `${statusKey}|${conv.msgs.length}|${lastTool}` : "";
|
|
99
162
|
|
|
100
163
|
const bump = useCallback(() => setTick((n) => n + 1), []);
|
|
101
164
|
// 手动刷新(页签栏的 ↻):连看板概况一起重查。
|
|
@@ -105,7 +168,7 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, defaultTab, re
|
|
|
105
168
|
const pending = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
106
169
|
useEffect(() => {
|
|
107
170
|
if (!activity) return;
|
|
108
|
-
const wait = Math.max(0,
|
|
171
|
+
const wait = Math.max(0, ACTIVITY_GAP_MS - (Date.now() - lastBump.current));
|
|
109
172
|
if (pending.current) clearTimeout(pending.current);
|
|
110
173
|
pending.current = setTimeout(() => { lastBump.current = Date.now(); bump(); }, wait);
|
|
111
174
|
return () => { if (pending.current) clearTimeout(pending.current); };
|
|
@@ -140,34 +203,126 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, defaultTab, re
|
|
|
140
203
|
return () => { alive = false; if (timer) clearTimeout(timer); };
|
|
141
204
|
}, [backend, jobId, statusKey, manual]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
142
205
|
|
|
143
|
-
const
|
|
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]);
|
|
144
213
|
const activeTab = shownTabs.includes(tab) ? tab : shownTabs[0] ?? "files";
|
|
214
|
+
const file = activeTab.startsWith(FILE_TAB) ? files.find((f) => fileTabId(f.path) === activeTab) ?? null : null;
|
|
215
|
+
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
|
+
// 页签多了横向滚动:当前页签总是滚到看得见的地方(新开的文件页签在最后,窄看板里容易被挤出去)。
|
|
217
|
+
const tabsRef = useRef<HTMLDivElement | null>(null);
|
|
218
|
+
const revealTab = useCallback(() => {
|
|
219
|
+
// 直接算滚动位置,不用 scrollIntoView:它会连带滚动宿主页面。
|
|
220
|
+
const box = tabsRef.current;
|
|
221
|
+
const on = box?.querySelector<HTMLElement>(".rail-tab.on");
|
|
222
|
+
if (!box || !on) return;
|
|
223
|
+
const b = box.getBoundingClientRect();
|
|
224
|
+
const r = on.getBoundingClientRect();
|
|
225
|
+
if (r.left < b.left) box.scrollLeft -= b.left - r.left + 8;
|
|
226
|
+
else if (r.right > b.right) box.scrollLeft += r.right - b.right + 8;
|
|
227
|
+
}, []);
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
// 用定时器而不是 requestAnimationFrame:后台 / 被遮住的页面里 rAF 不跑。字体加载完页签会变宽,再对一次。
|
|
230
|
+
const timers = [setTimeout(revealTab, 0), setTimeout(revealTab, 400)];
|
|
231
|
+
let alive = true;
|
|
232
|
+
void document.fonts?.ready.then(() => { if (alive) revealTab(); });
|
|
233
|
+
return () => { alive = false; timers.forEach(clearTimeout); };
|
|
234
|
+
}, [activeTab, shownTabs.length, collapsed, revealTab]);
|
|
235
|
+
// 看板宽度或页签宽度变了(分栏拖动、刚挂上时分栏才给出宽度、字体加载完页签变宽):再对一次。
|
|
236
|
+
useEffect(() => {
|
|
237
|
+
const box = tabsRef.current;
|
|
238
|
+
if (!box || typeof ResizeObserver === "undefined") return;
|
|
239
|
+
const ro = new ResizeObserver(() => revealTab());
|
|
240
|
+
ro.observe(box);
|
|
241
|
+
for (const child of box.children) ro.observe(child);
|
|
242
|
+
return () => ro.disconnect();
|
|
243
|
+
}, [collapsed, revealTab, shownTabs.length]);
|
|
244
|
+
// 记下开着的文件页签和当前页签(刷新页面后恢复)。恢复之前不写,免得用空的盖掉上次的。
|
|
245
|
+
useEffect(() => { if (jobId && restored.current === jobId) saveTabs(jobId, files.map((f) => f.path), activeTab); }, [jobId, files, activeTab]);
|
|
246
|
+
// 引用到对话:和对话在同一个 Provider 里时缺省开。
|
|
247
|
+
const canQuote = (quoteToChat ?? inProvider) && !!jobId;
|
|
248
|
+
const quote = useCallback((ref: Omit<BoardRef, "id"> & { id?: string }) => {
|
|
249
|
+
if (jobId) store.addRef(jobId, { ...ref, id: ref.id ?? `${ref.kind}:${ref.label}` });
|
|
250
|
+
}, [store, jobId]);
|
|
251
|
+
const quoteText = useCallback((path: string, text: string) => {
|
|
252
|
+
const name = path.split("/").pop() || path;
|
|
253
|
+
const short = text.replace(/\s+/g, " ").slice(0, 24);
|
|
254
|
+
// 同一个文件里选不同的段落各算一条(按内容区分),同一段重复点只留一条。
|
|
255
|
+
quote({ id: `sel:${path}:${text.length}:${short}`, kind: "selection", label: `${name}:${short}${text.length > 24 ? "…" : ""}`, text: `工作区文件 ${path} 里选中的这段:\n${text.split("\n").map((l) => ` > ${l}`).join("\n")}` });
|
|
256
|
+
}, [quote]);
|
|
257
|
+
// 正在看的东西(打开的文件、浏览器当前页面):告诉对话(缺省带进下一条消息一次)。收起、切走、关掉、离开时撤掉。
|
|
258
|
+
const autoView = (autoQuoteOpenFile ?? canQuote) && !!jobId;
|
|
259
|
+
const [browserPage, setBrowserPage] = useState<{ url: string; title: string } | null>(null);
|
|
260
|
+
const viewingPath = autoView && !collapsed && file ? file.path : null;
|
|
261
|
+
const viewingPage = autoView && !collapsed && activeTab === "browser" && browserPage ? browserPage : null;
|
|
262
|
+
// 这个看板自己的标识:同一个对话开着两个看板时,各撤各的「正在看」和看板状态。
|
|
263
|
+
const owner = useMemo(() => `board-${Math.random().toString(36).slice(2, 10)}`, []);
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
if (!jobId || !autoView) return;
|
|
266
|
+
store.setViewing(jobId, viewingPath
|
|
267
|
+
? { id: `file:${viewingPath}`, kind: "file", label: viewingPath.split("/").pop() || viewingPath, text: `当前在看板里打开的文件 ${viewingPath}` }
|
|
268
|
+
: viewingPage
|
|
269
|
+
? { id: "browser:page", kind: "browser", label: viewingPage.title || viewingPage.url, text: `当前在看板里看的任务浏览器页面:${viewingPage.title ? `${viewingPage.title} — ` : ""}${viewingPage.url}` }
|
|
270
|
+
: null, owner);
|
|
271
|
+
}, [store, jobId, autoView, viewingPath, viewingPage?.url, viewingPage?.title, owner]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
272
|
+
useEffect(() => () => { if (jobId) store.setViewing(jobId, null, owner); }, [store, jobId, owner]);
|
|
273
|
+
const allFileActions = useMemo<FileAction[]>(() => [
|
|
274
|
+
...(canQuote ? [{ label: "引用到对话", title: "把这个文件带进下一条消息(智能体会按路径去读)", onClick: (f: { path: string; name: string }) => quote({ id: `file:${f.path}`, kind: "file", label: f.name, text: `工作区文件 ${f.path}` }) }] : []),
|
|
275
|
+
...(fileActions ?? []),
|
|
276
|
+
], [canQuote, quote, fileActions]);
|
|
145
277
|
|
|
146
278
|
// 打开着的文件:每次刷新查一下它的修改时间,变了就重读。
|
|
147
279
|
useEffect(() => {
|
|
148
|
-
if (!backend || !jobId || !file || !
|
|
280
|
+
if (!backend || !jobId || !file || !info?.dispatched || collapsed) return;
|
|
149
281
|
let alive = true;
|
|
150
|
-
|
|
282
|
+
const path = file.path;
|
|
283
|
+
const update = (fn: (f: OpenFile) => OpenFile) => setFiles((list) => list.map((f) => (f.path === path ? fn(f) : f)));
|
|
284
|
+
backend.stat(jobId, path).then((s) => {
|
|
151
285
|
if (!alive || s.type !== "file") return;
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
// 第一次拿到修改时间(从对话里打开的)只记下来;之后变了才重读。
|
|
155
|
-
return { ...f, size: s.size, mtime: s.mtime, version: f.mtime === undefined ? f.version : f.version + 1 };
|
|
156
|
-
});
|
|
286
|
+
// 第一次拿到修改时间(从对话里打开的、刷新后恢复的)只记下来;之后变了才重读。
|
|
287
|
+
update((f) => (f.mtime === s.mtime ? f : { ...f, size: s.size, mtime: s.mtime, version: f.mtime === undefined ? f.version : f.version + 1 }));
|
|
157
288
|
}, (e: unknown) => {
|
|
158
|
-
//
|
|
159
|
-
if (alive && e instanceof CenterError && e.status === 404)
|
|
289
|
+
// 打开着的文件被删了:重读一次,预览里显示「文件不存在」,不继续显示旧内容。
|
|
290
|
+
if (alive && e instanceof CenterError && e.status === 404) update((f) => (f.mtime !== -1 ? { ...f, mtime: -1, version: f.version + 1 } : f));
|
|
160
291
|
});
|
|
161
292
|
return () => { alive = false; };
|
|
162
|
-
|
|
293
|
+
// 只看正在看的那个文件:切到别的文件页签时查一次它(后台的页签不轮询)。
|
|
294
|
+
}, [tick, pollTick, file?.path, info?.dispatched]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
163
295
|
|
|
296
|
+
const activeRef = useRef(activeTab);
|
|
297
|
+
activeRef.current = activeTab;
|
|
298
|
+
/** 打开文件:已经开着就切过去(并重读),否则新开一个页签;超过 MAX_FILE_TABS 关掉最早的(不关正在看的)。 */
|
|
164
299
|
const openPath = useCallback((path: string, entry?: Partial<FileEntry>) => {
|
|
165
|
-
const clean = path.replace(/^\.\//, "");
|
|
166
|
-
|
|
167
|
-
|
|
300
|
+
const clean = path.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
301
|
+
if (!clean) return;
|
|
302
|
+
setFiles((list) => {
|
|
303
|
+
const have = list.find((f) => f.path === clean);
|
|
304
|
+
if (have) return list.map((f) => (f.path === clean ? { ...f, size: entry?.size ?? f.size, mtime: entry?.mtime ?? f.mtime, version: f.version + 1 } : f));
|
|
305
|
+
const next = [...list, { path: clean, size: entry?.size, mtime: entry?.mtime, version: 0 }];
|
|
306
|
+
while (next.length > MAX_FILE_TABS) {
|
|
307
|
+
const i = next.findIndex((f) => fileTabId(f.path) !== activeRef.current && f.path !== clean);
|
|
308
|
+
next.splice(i >= 0 ? i : 0, 1);
|
|
309
|
+
}
|
|
310
|
+
return next;
|
|
311
|
+
});
|
|
312
|
+
setTab(fileTabId(clean));
|
|
168
313
|
const parent = clean.includes("/") && !clean.startsWith("/") ? clean.split("/").slice(0, -1).join("/") : "";
|
|
169
314
|
setDir(parent);
|
|
170
315
|
}, []);
|
|
316
|
+
/** 关文件页签:关的是正在看的,就切到它旁边的文件页签,没有了回文件列表。 */
|
|
317
|
+
const closeFile = useCallback((path: string) => {
|
|
318
|
+
setFiles((list) => {
|
|
319
|
+
const i = list.findIndex((f) => f.path === path);
|
|
320
|
+
if (i < 0) return list;
|
|
321
|
+
const next = list.filter((f) => f.path !== path);
|
|
322
|
+
if (activeRef.current === fileTabId(path)) setTab(next.length ? fileTabId(next[Math.min(i, next.length - 1)].path) : "files");
|
|
323
|
+
return next;
|
|
324
|
+
});
|
|
325
|
+
}, []);
|
|
171
326
|
|
|
172
327
|
// 接对话里的打开请求(只接自己正在看的对话)。
|
|
173
328
|
const jobRef = useRef(jobId);
|
|
@@ -179,7 +334,8 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, defaultTab, re
|
|
|
179
334
|
return store.registerBoard((target: string, what: OpenTarget) => {
|
|
180
335
|
if (!jobRef.current || target !== jobRef.current) return false;
|
|
181
336
|
if (what.kind === "file") openPath(what.path);
|
|
182
|
-
else if (what.kind === "files") { setTab("files");
|
|
337
|
+
else if (what.kind === "files") { setTab("files"); setDir(what.path ?? ""); }
|
|
338
|
+
else if (what.kind === "tab") setTab(what.id);
|
|
183
339
|
else setTab(what.kind);
|
|
184
340
|
expandRef.current?.();
|
|
185
341
|
return true;
|
|
@@ -189,6 +345,13 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, defaultTab, re
|
|
|
189
345
|
const navRef = useRef(onNavigate);
|
|
190
346
|
navRef.current = onNavigate;
|
|
191
347
|
useEffect(() => { navRef.current?.({ tab: activeTab, dir, file: file?.path ?? null }); }, [activeTab, dir, file?.path]);
|
|
348
|
+
// 看板现在的样子报给仓库:业务方用 store.workspace().openFile() / openFiles() 拿到当前打开的文件。
|
|
349
|
+
const openPaths = files.map((f) => f.path).join("\n");
|
|
350
|
+
useEffect(() => {
|
|
351
|
+
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]);
|
|
354
|
+
useEffect(() => () => { if (jobId) store.reportBoard(jobId, null, owner); }, [store, jobId, owner]);
|
|
192
355
|
|
|
193
356
|
const body = () => {
|
|
194
357
|
if (!backend) return <div className="tc-note">当前后端不支持看板(需要任务中心提供 /api/jobs/:id/board)。</div>;
|
|
@@ -196,8 +359,19 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, defaultTab, re
|
|
|
196
359
|
if (!info) return <div className="tc-note">{infoError ? `读取看板失败:${infoError}(稍后自动重试)` : "读取中…"}</div>;
|
|
197
360
|
if (!info.dispatched) return <div className="tc-note">任务还没开始执行({info.detail ?? "等待分配执行节点"})。开始后这里会显示工作区文件。</div>;
|
|
198
361
|
if (activeTab === "changes") return <ChangesPane key={jobId} backend={backend} jobId={jobId} refreshKey={tick} onOpen={(p) => openPath(p)} />;
|
|
199
|
-
if (activeTab === "browser")
|
|
200
|
-
|
|
362
|
+
if (activeTab === "browser") {
|
|
363
|
+
return <BrowserPane key={`${jobId}:${info.remote_task_id}`} backend={backend} jobId={jobId} readOnly={readOnly}
|
|
364
|
+
onPage={setBrowserPage}
|
|
365
|
+
onQuote={canQuote ? (p) => quote({ id: "browser:page", kind: "browser", label: p.title || p.url, text: `任务浏览器当前打开的页面:${p.title ? `${p.title} — ` : ""}${p.url}` }) : undefined}
|
|
366
|
+
onQuoteText={canQuote ? (text, p) => {
|
|
367
|
+
const clipped = text.length > 3_000 ? `${text.slice(0, 3_000)}…` : text;
|
|
368
|
+
const short = clipped.replace(/\s+/g, " ").slice(0, 24);
|
|
369
|
+
quote({ id: `bsel:${p.url}:${text.length}:${short}`, kind: "selection", label: `网页:${short}${clipped.length > 24 ? "…" : ""}`, text: `任务浏览器页面 ${p.title ? `「${p.title}」` : ""}${p.url} 里复制的这段:\n${clipped.split("\n").map((l) => ` > ${l}`).join("\n")}` });
|
|
370
|
+
} : undefined} />;
|
|
371
|
+
}
|
|
372
|
+
const extra = extraTabs?.find((t) => t.id === activeTab);
|
|
373
|
+
if (extra) return extra.render({ jobId, store, quote });
|
|
374
|
+
if (activeTab.startsWith(FILE_TAB) && file) {
|
|
201
375
|
return (
|
|
202
376
|
<FileViewer
|
|
203
377
|
key={file.path}
|
|
@@ -207,9 +381,10 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, defaultTab, re
|
|
|
207
381
|
size={file.size}
|
|
208
382
|
mtime={file.mtime}
|
|
209
383
|
version={file.version}
|
|
210
|
-
actions={
|
|
384
|
+
actions={allFileActions}
|
|
211
385
|
onOpenPath={(p) => openPath(p)}
|
|
212
|
-
|
|
386
|
+
onQuoteText={canQuote ? (text) => quoteText(file.path, text) : undefined}
|
|
387
|
+
lead={<button className="cli-fv-back" onClick={() => setTab("files")} title="返回文件列表">‹</button>}
|
|
213
388
|
/>
|
|
214
389
|
);
|
|
215
390
|
}
|
|
@@ -221,7 +396,7 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, defaultTab, re
|
|
|
221
396
|
<aside className={`cli-ui cli-board collapsed ${className ?? ""}`} data-theme={theme} style={style}>
|
|
222
397
|
<button className="cli-board-strip" onClick={onExpand} title="显示看板">
|
|
223
398
|
{Icon.chevRight()}
|
|
224
|
-
{shownTabs.map((t) => <span key={t} className="cli-board-strip-t">{
|
|
399
|
+
{shownTabs.map((t) => <span key={t} className="cli-board-strip-t">{tabLabel(t)}</span>)}
|
|
225
400
|
{running && <span className="cli-sync-dot on" />}
|
|
226
401
|
</button>
|
|
227
402
|
</aside>
|
|
@@ -231,10 +406,17 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, defaultTab, re
|
|
|
231
406
|
return (
|
|
232
407
|
<aside className={`cli-ui cli-board sess-rail ${className ?? ""}`} data-theme={theme} style={style}>
|
|
233
408
|
<div className="rail-tabs">
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
409
|
+
<div className="rail-tabs-scroll" role="tablist" ref={tabsRef}>
|
|
410
|
+
{shownTabs.map((t) => t.startsWith(FILE_TAB) ? (
|
|
411
|
+
<span key={t} className={`rail-tab rail-ftab ${activeTab === t ? "on" : ""}`} role="tab" aria-selected={activeTab === t} title={t.slice(FILE_TAB.length)}
|
|
412
|
+
onMouseDown={(e) => { if (e.button === 1) { e.preventDefault(); closeFile(t.slice(FILE_TAB.length)); } }}>
|
|
413
|
+
<button className="rail-ftab-l" onClick={() => setTab(t)}>{Icon.doc()}{tabLabel(t)}</button>
|
|
414
|
+
<button className="rail-ftab-x" onClick={() => closeFile(t.slice(FILE_TAB.length))} title="关闭(鼠标中键也可以)" aria-label={`关闭 ${tabLabel(t)}`}>{Icon.x()}</button>
|
|
415
|
+
</span>
|
|
416
|
+
) : (
|
|
417
|
+
<button key={t} role="tab" aria-selected={activeTab === t} className={`rail-tab ${activeTab === t ? "on" : ""}`} onClick={() => setTab(t)}>{tabLabel(t)}</button>
|
|
418
|
+
))}
|
|
419
|
+
</div>
|
|
238
420
|
{info?.dispatched && <button className="rail-collapse" onClick={refreshAll} title={running ? "运行中 · 自动同步" : "刷新"}>
|
|
239
421
|
<span className={`cli-sync-dot ${running ? "on" : ""}`} />↻
|
|
240
422
|
</button>}
|