@xgspz/cli-ui 0.0.0-stage → 0.6.14
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 +156 -0
- package/README.md +512 -2
- package/dist/cli-ui-embed-D-3DvyTN.js +50279 -0
- package/dist/cli-ui-index-C34qFb4u.js +11209 -0
- package/dist/cli-ui-jszip.min-BObcE2nn.js +26 -0
- package/dist/cli-ui-jszip.min-DojbZPiE.js +2324 -0
- package/dist/cli-ui-xlsx-JVLtx0qR.js +93 -0
- package/dist/cli-ui.css +32 -0
- package/dist/cli-ui.js +17 -0
- package/package.json +60 -4
- package/src/board/BrowserPane.tsx +969 -0
- package/src/board/ChangesPane.tsx +185 -0
- package/src/board/CliBoard.tsx +484 -0
- package/src/board/FileViewer.tsx +721 -0
- package/src/board/FilesPane.tsx +262 -0
- package/src/board/HtmlReadingPreview.tsx +52 -0
- package/src/board/MarkdownPreview.tsx +134 -0
- package/src/board/PanelControls.tsx +40 -0
- package/src/board/blocks.tsx +97 -0
- package/src/board/context.ts +65 -0
- package/src/board/diffLines.ts +46 -0
- package/src/board/fileKind.ts +250 -0
- package/src/board/fileOps.ts +85 -0
- package/src/board/fileScope.ts +32 -0
- package/src/board/fileSync.ts +351 -0
- package/src/board/fileSyncCache.ts +250 -0
- package/src/board/fileSyncProcessor.ts +72 -0
- package/src/board/fileSyncQueue.ts +56 -0
- package/src/board/fileSyncTransport.ts +85 -0
- package/src/board/fileWatch.ts +211 -0
- package/src/board/folderDownload.ts +211 -0
- package/src/board/frameQuote.ts +52 -0
- package/src/board/htmlReading.ts +148 -0
- package/src/board/panels.ts +33 -0
- package/src/board/textLoad.ts +76 -0
- package/src/board/useBoardUpload.tsx +51 -0
- package/src/board/useBoardVisibility.ts +35 -0
- package/src/board/workspace.ts +200 -0
- package/src/board/zip.ts +85 -0
- package/src/chat/CliChat.tsx +932 -0
- package/src/chat/Composer.tsx +239 -0
- package/src/chat/GroupChat.tsx +113 -0
- package/src/chat/RunControls.tsx +196 -0
- package/src/chat/configuration.ts +15 -0
- package/src/chat/draft.ts +49 -0
- package/src/chat/export.ts +34 -0
- package/src/chat/queueEdit.ts +27 -0
- package/src/chat/useExecutionConfig.ts +93 -0
- package/src/components.tsx +13 -0
- package/src/core/blocks.ts +205 -0
- package/src/core/callbacks.ts +8 -0
- package/src/core/client.ts +822 -0
- package/src/core/config.ts +252 -0
- package/src/core/conversation.ts +577 -0
- package/src/core/documentUpload.ts +196 -0
- package/src/core/execution.ts +24 -0
- package/src/core/id.ts +15 -0
- package/src/core/identity.ts +21 -0
- package/src/core/provider.tsx +141 -0
- package/src/core/sha256.ts +21 -0
- package/src/core/sse.ts +51 -0
- package/src/core/store.ts +1103 -0
- package/src/core/subagents.ts +78 -0
- package/src/core/textDecode.ts +24 -0
- package/src/core/types.ts +505 -0
- package/src/core/xlsx.ts +142 -0
- package/src/elements.tsx +376 -0
- package/src/embed.ts +56 -0
- package/src/index.ts +56 -0
- package/src/layout/CliSplit.tsx +53 -0
- package/src/layout/split.ts +251 -0
- package/src/msglist/CliMsgList.tsx +422 -0
- 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 +619 -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 +538 -0
- package/src/operator/node/CommandPalette.tsx +213 -0
- package/src/operator/node/Composer.tsx +316 -0
- package/src/operator/node/InsightsView.tsx +831 -0
- package/src/operator/node/Layout.tsx +192 -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 +248 -0
- package/src/operator/node/Orchestrator.tsx +644 -0
- package/src/operator/node/ProjectLanding.tsx +86 -0
- package/src/operator/node/ProjectsColumn.tsx +137 -0
- package/src/operator/node/ProviderAuth.tsx +559 -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 +299 -0
- package/src/operator/node/SessionView.tsx +292 -0
- package/src/operator/node/SettingsView.tsx +690 -0
- package/src/operator/node/TaskBoard.tsx +352 -0
- package/src/operator/node/TaskChanges.tsx +526 -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 +485 -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 +57 -0
- package/src/operator/node/boardBackend.ts +63 -0
- package/src/operator/node/boardEvents.ts +40 -0
- package/src/operator/node/boardInteraction.ts +15 -0
- package/src/operator/node/boardState.ts +74 -0
- package/src/operator/node/boardWorkspace.ts +15 -0
- package/src/operator/node/boundedPoll.ts +47 -0
- package/src/operator/node/branchFlow.ts +1 -0
- package/src/operator/node/composerDraft.ts +78 -0
- package/src/operator/node/fileLinks.ts +81 -0
- package/src/operator/node/format.ts +292 -0
- package/src/operator/node/github.tsx +232 -0
- package/src/operator/node/icons.tsx +88 -0
- package/src/operator/node/liveStream.ts +323 -0
- package/src/operator/node/modals.tsx +766 -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 +335 -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 +305 -0
- package/src/operator/node/useGlobalEvents.ts +97 -0
- package/src/operator/node/useOrchestrator.ts +697 -0
- package/src/operator/node/usePrefs.ts +114 -0
- package/src/operator/node/useRecaps.ts +102 -0
- package/src/operator/node/useTaskStream.ts +286 -0
- package/src/operator.base.css +1949 -0
- package/src/operator.css +1949 -0
- package/src/styles.css +579 -0
- package/src/ui/ErrorBoundary.tsx +38 -0
- package/src/ui/Markdown.tsx +60 -0
- package/src/ui/Popover.tsx +95 -0
- package/src/ui/QueuedMessages.tsx +110 -0
- package/src/ui/ReferenceCard.tsx +28 -0
- package/src/ui/SubagentChat.tsx +233 -0
- package/src/ui/TextBlock.tsx +51 -0
- package/src/ui/Transcript.tsx +435 -0
- package/src/ui/UploadProgress.tsx +46 -0
- package/src/ui/WorkspaceDrawer.tsx +64 -0
- package/src/ui/WorkspaceTabs.tsx +60 -0
- package/src/ui/fileLinks.ts +52 -0
- package/src/ui/format.ts +103 -0
- package/src/ui/icons.tsx +92 -0
- package/src/ui/reference.ts +40 -0
- package/src/ui/shared.tsx +38 -0
- package/src/ui/transcriptModel.ts +151 -0
- package/src/ui/useChatScrollbar.ts +29 -0
- package/src/ui/useComposerAutosize.ts +54 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import { retryWorkspaceFiles } from "./fileWatch";
|
|
2
|
+
import { retainFileScope } from "./fileScope";
|
|
3
|
+
// cli-board:右侧看板。界面取自 Operator 的会话右栏(SessionRail):文件 / 改动 / 浏览器三个页签。
|
|
4
|
+
// 数据经 center 的 /api/jobs/:id/board、files、diff、browser 转发自执行节点(浏览器不直连节点)。
|
|
5
|
+
//
|
|
6
|
+
// · 文件:工作区目录浏览、预览(Markdown / 代码 / CSV / 图片 / PDF / HTML / Word)、下载;
|
|
7
|
+
// · 改动:分支上的 diff(只读);
|
|
8
|
+
// · 浏览器:任务浏览器的实时画面、接管 / 交还、打开 / 关闭。
|
|
9
|
+
//
|
|
10
|
+
// 同步:每轮实时输出结束、恢复页面或手动刷新时检查打开的文件,只读取缺失变化块;没有周期轮询。
|
|
11
|
+
// 和 cli-chat 放在同一个 Provider 里时,对话里点本地文件 / 「查看浏览器」会在这里打开(store.openInBoard)。
|
|
12
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
13
|
+
import { createPortal } from "react-dom";
|
|
14
|
+
import { CliErrorBoundary } from "../ui/ErrorBoundary";
|
|
15
|
+
import type { BoardRef, CliEvent, CliStore, CliStoreOptions, OpenTarget } from "../core/store";
|
|
16
|
+
import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider } from "../core/provider";
|
|
17
|
+
import { convKey, parseConvKey, isRunning } from "../core/conversation";
|
|
18
|
+
import type { BoardInfo, FileEntry } from "../core/types";
|
|
19
|
+
import { Icon } from "../ui/icons";
|
|
20
|
+
import { FileViewer, type FileAction } from "./FileViewer";
|
|
21
|
+
import { FilesPane } from "./FilesPane";
|
|
22
|
+
import { ChangesPane } from "./ChangesPane";
|
|
23
|
+
import { BrowserPane } from "./BrowserPane";
|
|
24
|
+
import { rememberBoardFile } from "./context";
|
|
25
|
+
import type { BoardFile } from "./workspace";
|
|
26
|
+
import type { WorkspaceHandlers } from "../core/provider";
|
|
27
|
+
import { useBoardPanels } from "./panels";
|
|
28
|
+
import { PanelControls, PanelTab, type InlineBoardLayout } from "./PanelControls";
|
|
29
|
+
import { useBoardVisibility } from "./useBoardVisibility";
|
|
30
|
+
import { useBoardUpload } from "./useBoardUpload";
|
|
31
|
+
|
|
32
|
+
export type BoardTab = "files" | "changes" | "browser";
|
|
33
|
+
|
|
34
|
+
/** 宿主加的看板页签。 */
|
|
35
|
+
export interface BoardExtraTab {
|
|
36
|
+
id: string;
|
|
37
|
+
label: string;
|
|
38
|
+
render: (ctx: { jobId: string; store: CliStore; quote: (ref: Omit<BoardRef, "id"> & { id?: string }) => void }) => ReactNode;
|
|
39
|
+
}
|
|
40
|
+
const TAB_LABEL: Record<BoardTab, string> = { files: "工作区", changes: "改动", browser: "浏览器" };
|
|
41
|
+
/** 同时开着的文件页签最多几个:再开新的,关掉最早打开、当前没在看的那个。 */
|
|
42
|
+
export const MAX_FILE_TABS = 8;
|
|
43
|
+
const FILE_TAB = "file:";
|
|
44
|
+
const fileTabId = (path: string) => `${FILE_TAB}${path}`;
|
|
45
|
+
/** 开着的文件页签记在会话存储里(按对话):刷新页面后还在。 */
|
|
46
|
+
const tabsKey = (jobId: string) => `cli-ui:board-tabs:${jobId}`;
|
|
47
|
+
function loadTabs(jobId: string | null): { files: string[]; tab: string | null; history: BoardFile[] } {
|
|
48
|
+
if (!jobId) return { files: [], tab: null, history: [] };
|
|
49
|
+
try {
|
|
50
|
+
const v = JSON.parse(sessionStorage.getItem(tabsKey(jobId)) ?? "null") as { files?: unknown; tab?: unknown; history?: BoardFile[] } | null;
|
|
51
|
+
const files = Array.isArray(v?.files) ? v.files.filter((f): f is string => typeof f === "string").slice(-MAX_FILE_TABS) : [];
|
|
52
|
+
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})) };
|
|
53
|
+
} catch { return { files: [], tab: null, history: [] }; }
|
|
54
|
+
}
|
|
55
|
+
const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
56
|
+
function saveTabs(jobId: string, files: string[], tab: string, history: BoardFile[]) {
|
|
57
|
+
try { sessionStorage.setItem(tabsKey(jobId), JSON.stringify({ files, tab, history })); } catch { /* 存不下就不记 */ }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface CliBoardProps extends CliStoreOptions, WorkspaceHandlers {
|
|
61
|
+
/** @internal 同一个看板在 Chat 标题区放置页签。 */
|
|
62
|
+
inlineLayout?: InlineBoardLayout;
|
|
63
|
+
/** @internal 将当前 Chat 的打开请求交给自己的内嵌看板。 */
|
|
64
|
+
inlineOwner?: string;
|
|
65
|
+
/** @internal 手机抽屉沿用看板内容,独立控制它的可见状态。 */
|
|
66
|
+
visible?: boolean;
|
|
67
|
+
/** @internal 引用后返回当前 Chat。 */
|
|
68
|
+
onQuoteToChat?: () => void;
|
|
69
|
+
/** 默认在轮次结束、恢复页面和手动刷新时检查已打开文件与可见目录。收起后暂停文件读取。 */
|
|
70
|
+
watchFiles?: boolean;
|
|
71
|
+
/** @deprecated 保留兼容;文件同步由事件触发,不再使用定时间隔。 */
|
|
72
|
+
fileWatchIntervalMs?: number;
|
|
73
|
+
/** 看哪个对话(center 的 job_id)。不给就跟共享仓库的选中走(和 cli-msglist / cli-chat 联动)。 */
|
|
74
|
+
jobId?: string | null;
|
|
75
|
+
/** 对话任务里的哪个对话(和 jobId 一起用,同 cli-chat 的 taskId)。不给 = 缺省那个(最近派发过的)。 */
|
|
76
|
+
taskId?: string | null;
|
|
77
|
+
/** 默认打开的模块及顺序;用户仍可手动打开其他可用模块。[] 表示初始全部关闭。 */
|
|
78
|
+
tabs?: BoardTab[];
|
|
79
|
+
/** 允许使用的模块;与 tabs 的默认可见状态分开。缺省允许全部受后端支持的模块。 */
|
|
80
|
+
availableTabs?: BoardTab[];
|
|
81
|
+
defaultTab?: BoardTab;
|
|
82
|
+
/** 只看:不能接管 / 打开 / 关闭浏览器。 */
|
|
83
|
+
readOnly?: boolean;
|
|
84
|
+
/** 文件上的自定义操作(预览头部的按钮)。 */
|
|
85
|
+
fileActions?: FileAction[];
|
|
86
|
+
/** @deprecated 保留兼容;文件同步由事件触发,不再进行定时轮询。 */
|
|
87
|
+
pollMs?: number;
|
|
88
|
+
/** 接不接对话里的「打开文件 / 查看浏览器」,缺省接。 */
|
|
89
|
+
acceptOpen?: boolean;
|
|
90
|
+
/**
|
|
91
|
+
* 显示「引用到对话」(文件预览、浏览器):把看板里的东西带进这个对话的下一条消息(store.addRef)。
|
|
92
|
+
* 缺省:和对话放在同一个 Provider 里时显示。
|
|
93
|
+
*/
|
|
94
|
+
quoteToChat?: boolean;
|
|
95
|
+
/**
|
|
96
|
+
* 宿主自己加的页签(例如终端、另一个浏览器),排在内置页签后面。ctx.quote 可以把页签里的东西引用到对话。
|
|
97
|
+
*/
|
|
98
|
+
extraTabs?: BoardExtraTab[];
|
|
99
|
+
/**
|
|
100
|
+
* 看板上打开着的文件缺省带进对话的下一条消息一次(对话输入框上方显示「正在看」,可以点掉)。
|
|
101
|
+
* 缺省和 quoteToChat 一样。
|
|
102
|
+
*/
|
|
103
|
+
autoQuoteOpenFile?: boolean;
|
|
104
|
+
/** 保留旧宿主兼容;看板不再显示内置收起按钮。 */
|
|
105
|
+
onCollapse?: () => void;
|
|
106
|
+
/**
|
|
107
|
+
* 收起状态:只显示一条窄栏(点一下展开)。收起时仍然接对话里的打开请求——接到就记下要打开的东西并调 onExpand,
|
|
108
|
+
* 宿主把 collapsed 改回 false 后直接显示。
|
|
109
|
+
*/
|
|
110
|
+
collapsed?: boolean;
|
|
111
|
+
onExpand?: () => void;
|
|
112
|
+
/** 选中 / 打开文件 / 切页签时通知宿主(例如记到地址栏)。 */
|
|
113
|
+
onNavigate?: (state: { tab: BoardTab | string; dir: string; file: string | null }) => void;
|
|
114
|
+
onEvent?: (event: CliEvent) => void;
|
|
115
|
+
theme?: "light" | "dark";
|
|
116
|
+
className?: string;
|
|
117
|
+
style?: CSSProperties;
|
|
118
|
+
store?: CliStore;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function CliBoard(props: CliBoardProps) {
|
|
122
|
+
const ctx = useCliStoreContext();
|
|
123
|
+
return <CliErrorBoundary component="cli-board" theme={props.theme}>{withOwnProvider(props, <BoardInner {...props} inProvider={!!ctx} />, !!ctx)}</CliErrorBoundary>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface OpenFile { name?: string; reloadKey?:number; path: string; size?: number; mtime?: number; version: number }
|
|
127
|
+
|
|
128
|
+
function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs, defaultTab, readOnly, fileActions, acceptOpen = true, quoteToChat, autoQuoteOpenFile, extraTabs, collapsed, onExpand, onNavigate, onEvent, onFilesChange, onFilesWatch, onBoardContextChange, watchFiles = true, fileWatchIntervalMs, theme = "light", className, style, inProvider, inlineLayout, inlineOwner, visible: visibilityOverride, onQuoteToChat }: CliBoardProps & { inProvider: boolean }) {
|
|
129
|
+
useComponentEvents(onEvent, {onFilesChange,onFilesWatch,onBoardContextChange}, inProvider);
|
|
130
|
+
const store = useCliStore();
|
|
131
|
+
const backend = store.client.board;
|
|
132
|
+
const selected = useCliState((s) => s.selected);
|
|
133
|
+
// 内部用对话键(见 core/conversation.ts):看板接口按它找对话任务里的那个对话。
|
|
134
|
+
const jobId = jobIdProp !== undefined ? (jobIdProp ? convKey(parseConvKey(jobIdProp).jobId, taskIdProp ?? parseConvKey(jobIdProp).taskId) : null) : selected;
|
|
135
|
+
const rootRef = useRef<HTMLElement | null>(null);
|
|
136
|
+
const isInline = !!inlineOwner || !!inlineLayout;
|
|
137
|
+
const cssVisible = useBoardVisibility(rootRef, !isInline);
|
|
138
|
+
const visible = !collapsed && (isInline ? visibilityOverride ?? inlineLayout?.active ?? true : cssVisible);
|
|
139
|
+
const layoutRef = useRef(inlineLayout);
|
|
140
|
+
layoutRef.current = inlineLayout;
|
|
141
|
+
const quoteCallbackRef = useRef(onQuoteToChat);
|
|
142
|
+
quoteCallbackRef.current = onQuoteToChat;
|
|
143
|
+
|
|
144
|
+
const storageId = jobId && store.draftScope ? `${store.draftScope}:${jobId}` : jobId;
|
|
145
|
+
const [info, setInfo] = useState<BoardInfo | null>(null);
|
|
146
|
+
const [infoError, setInfoError] = useState<string | null>(null);
|
|
147
|
+
// 页签:内置的(文件列表 / 改动 / 浏览器)、打开的文件(file:<路径>,可以开多个、可以关)、宿主加的。
|
|
148
|
+
const [tab, setTab] = useState<string>(defaultTab ?? tabs?.[0] ?? "files");
|
|
149
|
+
const [dir, setDir] = useState("");
|
|
150
|
+
const [files, setFiles] = useState<OpenFile[]>([]);
|
|
151
|
+
const [history,setHistory] = useState<BoardFile[]>([]);
|
|
152
|
+
// 文件变化、恢复页面与手动刷新更新视图;流式轮末检查由 store 直接触发。
|
|
153
|
+
const [tick, setTick] = useState(0);
|
|
154
|
+
|
|
155
|
+
// 换了对话:回到初始状态。
|
|
156
|
+
const lastJob = useRef(jobId);
|
|
157
|
+
if (lastJob.current !== jobId) {
|
|
158
|
+
lastJob.current = jobId;
|
|
159
|
+
setInfo(null); setInfoError(null); setDir(""); setFiles([]); setHistory([]);
|
|
160
|
+
setTab(defaultTab ?? tabs?.[0] ?? "files");
|
|
161
|
+
}
|
|
162
|
+
// 恢复上次开着的文件页签(存在 sessionStorage):挂上之后、绘制之前做——服务端渲染和首次渲染一致,不会对不上。
|
|
163
|
+
const restored = useRef<string | null>(null);
|
|
164
|
+
const [hydrated, setHydrated] = useState<{ id: string | null; visible: boolean }>({ id: null, visible: false });
|
|
165
|
+
const restoreNeeded = hydrated.id !== storageId || (!isInline && visible && !hydrated.visible);
|
|
166
|
+
|
|
167
|
+
// 实时流:看板单独用时也要知道任务在不在跑(同一对话只连一次)。
|
|
168
|
+
useEffect(() => (jobId ? store.open(jobId) : undefined), [store, jobId]);
|
|
169
|
+
const conv = useCliState((s) => (jobId ? s.convs[jobId] : undefined));
|
|
170
|
+
const running = conv ? isRunning(conv) : false;
|
|
171
|
+
// 进展签名:轮次状态 + 看的子任务(一次性任务没有轮次:续派开出新子任务、派发到节点、换了节点对话都要重查看板概况)+ 转录条数。变了就刷新(节流)。
|
|
172
|
+
const statusKey = conv
|
|
173
|
+
? `${conv.turns.map((t) => `${t.turn_id}:${t.status}`).join(",")}|${conv.job?.status ?? ""}|${conv.task?.id ?? ""}:${conv.task?.status ?? ""}:${conv.remoteTaskId ?? conv.task?.remote_task_id ?? ""}`
|
|
174
|
+
: "";
|
|
175
|
+
const bump = useCallback(() => setTick((n) => n + 1), []);
|
|
176
|
+
const [manual, setManual] = useState(0);
|
|
177
|
+
const refreshAll = useCallback(() => {
|
|
178
|
+
setManual((n) => n + 1); bump();
|
|
179
|
+
if (backend && jobId) retryWorkspaceFiles(backend, jobId, { manual: true });
|
|
180
|
+
}, [bump, backend, jobId]);
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
const on = () => { if (document.visibilityState === "visible") bump(); };
|
|
183
|
+
document.addEventListener("visibilitychange", on);
|
|
184
|
+
return () => document.removeEventListener("visibilitychange", on);
|
|
185
|
+
}, [bump]);
|
|
186
|
+
|
|
187
|
+
// 派发和节点变化由已有聊天流驱动,不为文件看板再开启轮询。
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
if (!backend || !jobId) return;
|
|
190
|
+
let alive = true;
|
|
191
|
+
void backend.info(jobId).then((i) => {
|
|
192
|
+
if (!alive) return;
|
|
193
|
+
setInfo((old) => (old && old.dispatched === i.dispatched && old.remote_task_id === i.remote_task_id && old.files === i.files && old.browser === i.browser && old.node?.health === i.node?.health ? old : i));
|
|
194
|
+
setInfoError(null);
|
|
195
|
+
}, (e: unknown) => { if (alive) setInfoError(e instanceof Error ? e.message : String(e)); });
|
|
196
|
+
return () => { alive = false; };
|
|
197
|
+
}, [backend, jobId, statusKey, manual]);
|
|
198
|
+
|
|
199
|
+
const builtinTabs = useMemo<string[]>(() => (availableTabs ?? ["files", "changes", "browser"]).filter(t => ["files","changes","browser"].includes(t)).filter(t => t !== "browser" || !info || info.browser !== false).filter(t => t !== "files" || !info || info.files !== false), [availableTabs, info?.browser, info?.files]);
|
|
200
|
+
const allowedPanels = [...builtinTabs, ...(extraTabs ?? []).map(t => t.id)];
|
|
201
|
+
const panels = useBoardPanels(jobId ? `center:${storageId}` : null, allowedPanels, [...(tabs ?? builtinTabs.filter(tab=>tab!=="changes")), ...(extraTabs ?? []).map(t => t.id)]);
|
|
202
|
+
useIsoLayoutEffect(() => {
|
|
203
|
+
if (!restoreNeeded) {
|
|
204
|
+
if (!visible && hydrated.visible) setHydrated({ id: storageId, visible: false });
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (storageId) {
|
|
208
|
+
const current = jobId ? store.getState().boardContext[jobId] : undefined;
|
|
209
|
+
const saved = loadTabs(storageId);
|
|
210
|
+
const paths = current?.files ?? saved.files;
|
|
211
|
+
restored.current = storageId;
|
|
212
|
+
setFiles(previous => paths.map(path => previous.find(file => file.path === path) ?? { path, version: 0 }));
|
|
213
|
+
setHistory((current?.history ?? saved.history).map(file => ({ ...file, sourceId: file.sourceId ?? jobId ?? undefined })));
|
|
214
|
+
setDir(current?.dir ?? "");
|
|
215
|
+
const selectedTab = current?.tab ?? saved.tab;
|
|
216
|
+
if (selectedTab) { setTab(selectedTab); if (allowedPanels.includes(selectedTab)) panels.show(selectedTab); }
|
|
217
|
+
}
|
|
218
|
+
setHydrated({ id: storageId, visible });
|
|
219
|
+
}, [store, storageId, jobId, visible, restoreNeeded]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
220
|
+
const shownTabs = [...panels.open, ...(builtinTabs.includes("files") ? files.map(f => fileTabId(f.path)) : [])];
|
|
221
|
+
const activeTab = shownTabs.includes(tab) ? tab : shownTabs[0] ?? "";
|
|
222
|
+
const file = activeTab.startsWith(FILE_TAB) ? files.find((f) => fileTabId(f.path) === activeTab) ?? null : null;
|
|
223
|
+
useEffect(()=>{if(file&&jobId)setHistory(previous=>{const next=rememberBoardFile(previous,{path:file.path,sourceId:jobId});return JSON.stringify(next)===JSON.stringify(previous)?previous:next;});},[file?.path,jobId]);
|
|
224
|
+
const tabLabel = (t: string) => TAB_LABEL[t as BoardTab] ?? (t.startsWith(FILE_TAB) ? files.find(file => file.path === t.slice(FILE_TAB.length))?.name || t.slice(FILE_TAB.length).split("/").pop() || t : undefined) ?? extraTabs?.find((x) => x.id === t)?.label ?? t;
|
|
225
|
+
// 页签多了横向滚动:当前页签总是滚到看得见的地方(新开的文件页签在最后,窄看板里容易被挤出去)。
|
|
226
|
+
const [toolbarHost, setToolbarHost] = useState<HTMLSpanElement | null>(null);
|
|
227
|
+
const tabsRef = useRef<HTMLDivElement | null>(null);
|
|
228
|
+
const revealTab = useCallback(() => {
|
|
229
|
+
// 直接算滚动位置,不用 scrollIntoView:它会连带滚动宿主页面。
|
|
230
|
+
const box = tabsRef.current;
|
|
231
|
+
const on = box?.querySelector<HTMLElement>(".rail-tab.on");
|
|
232
|
+
if (!box || !on) return;
|
|
233
|
+
const b = box.getBoundingClientRect();
|
|
234
|
+
const r = on.getBoundingClientRect();
|
|
235
|
+
if (r.left < b.left) box.scrollLeft -= b.left - r.left + 8;
|
|
236
|
+
else if (r.right > b.right) box.scrollLeft += r.right - b.right + 8;
|
|
237
|
+
}, []);
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
// 用定时器而不是 requestAnimationFrame:后台 / 被遮住的页面里 rAF 不跑。字体加载完页签会变宽,再对一次。
|
|
240
|
+
const timers = [setTimeout(revealTab, 0), setTimeout(revealTab, 400)];
|
|
241
|
+
let alive = true;
|
|
242
|
+
void document.fonts?.ready.then(() => { if (alive) revealTab(); });
|
|
243
|
+
return () => { alive = false; timers.forEach(clearTimeout); };
|
|
244
|
+
}, [activeTab, shownTabs.length, collapsed, visible, inlineLayout?.active, inlineLayout?.tabsHost, revealTab]);
|
|
245
|
+
// 看板宽度或页签宽度变了(分栏拖动、刚挂上时分栏才给出宽度、字体加载完页签变宽):再对一次。
|
|
246
|
+
useEffect(() => {
|
|
247
|
+
const box = tabsRef.current;
|
|
248
|
+
if (!box || typeof ResizeObserver === "undefined") return;
|
|
249
|
+
const ro = new ResizeObserver(() => revealTab());
|
|
250
|
+
ro.observe(box);
|
|
251
|
+
for (const child of box.children) ro.observe(child);
|
|
252
|
+
return () => ro.disconnect();
|
|
253
|
+
}, [collapsed, visible, inlineLayout?.tabsHost, revealTab, shownTabs.length]);
|
|
254
|
+
// 记下开着的文件页签和当前页签(刷新页面后恢复)。恢复之前不写,免得用空的盖掉上次的。
|
|
255
|
+
useEffect(() => { if (storageId && restored.current === storageId) saveTabs(storageId, files.map((f) => f.path), activeTab, history); }, [storageId, files, activeTab, history]);
|
|
256
|
+
// 引用到对话:和对话在同一个 Provider 里时缺省开。
|
|
257
|
+
useEffect(()=>{
|
|
258
|
+
if(!watchFiles||!jobId||!backend||!info?.dispatched||info.files===false)return;
|
|
259
|
+
return store.watchWorkspace(jobId,event=>{
|
|
260
|
+
setTick(value=>value+1);
|
|
261
|
+
const changed=new Set(event.changes.map(change=>change.path));
|
|
262
|
+
setFiles(previous=>previous.map(file=>changed.has(file.path)?{...file,version:file.version+1,reloadKey:(file.reloadKey??0)+1}:file));
|
|
263
|
+
},{intervalMs:fileWatchIntervalMs});
|
|
264
|
+
},[store,backend,jobId,watchFiles,fileWatchIntervalMs,info?.dispatched,info?.files]);
|
|
265
|
+
const canQuote = (quoteToChat ?? inProvider) && !!jobId;
|
|
266
|
+
const quote = useCallback((ref: Omit<BoardRef, "id"> & { id?: string }) => {
|
|
267
|
+
if (jobId) { store.addRef(jobId, { ...ref, id: ref.id ?? `${ref.kind}:${ref.label}` }); if (quoteCallbackRef.current) quoteCallbackRef.current(); else layoutRef.current?.onChat(); }
|
|
268
|
+
}, [store, jobId]);
|
|
269
|
+
const quoteText = useCallback((path: string, text: string, context?: { revision: string }) => {
|
|
270
|
+
const name = path.split("/").pop() || path;
|
|
271
|
+
const short = text.replace(/\s+/g, " ").slice(0, 24);
|
|
272
|
+
// 同一个文件里选不同的段落各算一条(按内容区分),同一段重复点只留一条。
|
|
273
|
+
quote({ id: `sel:${path}:${text.length}:${short}`, kind: "selection", label: `${name}:${short}${text.length > 24 ? "…" : ""}`, text: `${context ? `文件修订:${context.revision}\n` : ""}工作区文件 ${path} 里选中的这段:\n${text.split("\n").map((l) => ` > ${l}`).join("\n")}` });
|
|
274
|
+
}, [quote]);
|
|
275
|
+
// 正在看的东西(打开的文件、浏览器当前页面):告诉对话(缺省带进下一条消息一次)。收起、切走、关掉、离开时撤掉。
|
|
276
|
+
const autoView = (autoQuoteOpenFile ?? canQuote) && !!jobId;
|
|
277
|
+
const [browserPage, setBrowserPage] = useState<{ url: string; title: string } | null>(null);
|
|
278
|
+
const viewingPath = autoView && visible && file ? file.path : null;
|
|
279
|
+
const viewingPage = autoView && visible && activeTab === "browser" && browserPage ? browserPage : null;
|
|
280
|
+
// 这个看板自己的标识:同一个对话开着两个看板时,各撤各的「正在看」和看板状态。
|
|
281
|
+
const owner = useMemo(() => `board-${Math.random().toString(36).slice(2, 10)}`, []);
|
|
282
|
+
useIsoLayoutEffect(() => {
|
|
283
|
+
if (jobId && !isInline) store.reportBoardPresence(jobId, owner, visible, { availableTabs, readOnly });
|
|
284
|
+
}, [store, jobId, owner, visible, isInline, availableTabs?.join(","), readOnly]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
285
|
+
useIsoLayoutEffect(() => () => { if (jobId && !isInline) store.reportBoardPresence(jobId, owner, null); }, [store, jobId, owner, isInline]);
|
|
286
|
+
useEffect(() => {
|
|
287
|
+
if (!jobId || !autoView) return;
|
|
288
|
+
store.setViewing(jobId, viewingPath
|
|
289
|
+
? { id: `file:${viewingPath}`, kind: "file", label: viewingPath.split("/").pop() || viewingPath, text: `用户当前在看板里打开着工作区文件 ${viewingPath}` }
|
|
290
|
+
: viewingPage
|
|
291
|
+
? { id: "browser:page", kind: "browser", label: viewingPage.title || viewingPage.url, text: `用户当前在看板里看着任务浏览器页面:${viewingPage.title ? `${viewingPage.title} — ` : ""}${viewingPage.url}` }
|
|
292
|
+
: null, owner);
|
|
293
|
+
}, [store, jobId, autoView, viewingPath, viewingPage?.url, viewingPage?.title, owner]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
294
|
+
useEffect(() => () => { if (jobId) store.setViewing(jobId, null, owner); }, [store, jobId, owner]);
|
|
295
|
+
const allFileActions = useMemo<FileAction[]>(() => [
|
|
296
|
+
...(canQuote ? [{ label: "引用到对话", title: "把这个文件带进下一条消息(智能体会按路径去读)", onClick: (f: { path: string; name: string }) => quote({ id: `file:${f.path}`, kind: "file", label: f.name, text: `工作区文件 ${f.path}` }) }] : []),
|
|
297
|
+
...(fileActions ?? []),
|
|
298
|
+
], [canQuote, quote, fileActions]);
|
|
299
|
+
|
|
300
|
+
// 打开着的文件:每次刷新查一下它的修改时间,变了就重读。
|
|
301
|
+
useEffect(() => {
|
|
302
|
+
if (!backend || !jobId || !file || !info?.dispatched || collapsed) return;
|
|
303
|
+
let alive = true;
|
|
304
|
+
const path = file.path;
|
|
305
|
+
const update = (fn: (f: OpenFile) => OpenFile) => setFiles((list) => list.map((f) => (f.path === path ? fn(f) : f)));
|
|
306
|
+
backend.stat(jobId, path).then((s) => {
|
|
307
|
+
if (!alive || s.type !== "file") return;
|
|
308
|
+
// 第一次拿到修改时间(从对话里打开的、刷新后恢复的)只记下来;之后变了才重读。
|
|
309
|
+
update((f) => (f.mtime === s.mtime && f.name === s.name ? f : { ...f, name: s.name, size: s.size, mtime: s.mtime, version: f.mtime === undefined ? f.version : f.version + 1 }));
|
|
310
|
+
}, (e: unknown) => {
|
|
311
|
+
// 打开着的文件被删了:重读一次,预览里显示「文件不存在」,不继续显示旧内容。
|
|
312
|
+
if (alive && (e as { status?: number }).status === 404) update((f) => (f.mtime !== -1 ? { ...f, mtime: -1, version: f.version + 1 } : f));
|
|
313
|
+
});
|
|
314
|
+
return () => { alive = false; };
|
|
315
|
+
// 只看正在看的那个文件:切到别的文件页签时查一次它(后台的页签不轮询)。
|
|
316
|
+
}, [tick, file?.path, info?.dispatched]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
317
|
+
|
|
318
|
+
const activeRef = useRef(activeTab);
|
|
319
|
+
activeRef.current = activeTab;
|
|
320
|
+
/** 打开文件:已经开着就切过去(并重读),否则新开一个页签;超过 MAX_FILE_TABS 关掉最早的(不关正在看的)。 */
|
|
321
|
+
const openPath = useCallback((path: string, entry?: Partial<FileEntry>) => {
|
|
322
|
+
const clean = path.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
323
|
+
if (!clean) return;
|
|
324
|
+
setFiles((list) => {
|
|
325
|
+
const have = list.find((f) => f.path === clean);
|
|
326
|
+
if (have) return list.map((f) => (f.path === clean ? { ...f, name: entry?.name ?? f.name, size: entry?.size ?? f.size, mtime: entry?.mtime ?? f.mtime, version: f.version + 1 } : f));
|
|
327
|
+
const next = [...list, { path: clean, name: entry?.name, size: entry?.size, mtime: entry?.mtime, version: 0 }];
|
|
328
|
+
while (next.length > MAX_FILE_TABS) {
|
|
329
|
+
const i = next.findIndex((f) => fileTabId(f.path) !== activeRef.current && f.path !== clean);
|
|
330
|
+
next.splice(i >= 0 ? i : 0, 1);
|
|
331
|
+
}
|
|
332
|
+
return next;
|
|
333
|
+
});
|
|
334
|
+
setTab(fileTabId(clean));
|
|
335
|
+
const parent = clean.includes("/") && !clean.startsWith("/") ? clean.split("/").slice(0, -1).join("/") : "";
|
|
336
|
+
setDir(parent);
|
|
337
|
+
}, []);
|
|
338
|
+
const uploadToWorkspace = useMemo(() => !readOnly && jobId && backend?.uploadFile
|
|
339
|
+
? (file: File, options: import("../core/documentUpload").DocumentUploadOptions) => backend.uploadFile!(jobId, file, options)
|
|
340
|
+
: undefined, [backend, jobId, readOnly]);
|
|
341
|
+
const upload = useBoardUpload(jobId, uploadToWorkspace, receipt => {
|
|
342
|
+
openPath(receipt.path!, { name: receipt.name, size: receipt.size });
|
|
343
|
+
setTick(value => value + 1);
|
|
344
|
+
});
|
|
345
|
+
/** 关文件页签:关的是正在看的,就切到它旁边的文件页签,没有了回文件列表。 */
|
|
346
|
+
const closeFile = useCallback((path: string) => {
|
|
347
|
+
setFiles((list) => {
|
|
348
|
+
const i = list.findIndex((f) => f.path === path);
|
|
349
|
+
if (i < 0) return list;
|
|
350
|
+
const next = list.filter((f) => f.path !== path);
|
|
351
|
+
if (activeRef.current === fileTabId(path)) setTab(next.length ? fileTabId(next[Math.min(i, next.length - 1)].path) : "files");
|
|
352
|
+
return next;
|
|
353
|
+
});
|
|
354
|
+
}, []);
|
|
355
|
+
|
|
356
|
+
// 接对话里的打开请求(只接自己正在看的对话)。
|
|
357
|
+
const jobRef = useRef(jobId);
|
|
358
|
+
jobRef.current = jobId;
|
|
359
|
+
const expandRef = useRef(onExpand);
|
|
360
|
+
expandRef.current = onExpand;
|
|
361
|
+
const visibleRef = useRef(visible);
|
|
362
|
+
visibleRef.current = visible;
|
|
363
|
+
useEffect(() => {
|
|
364
|
+
if (!acceptOpen || !backend) return;
|
|
365
|
+
return store.registerBoard((target: string, what: OpenTarget) => {
|
|
366
|
+
if (!jobRef.current || target !== jobRef.current) return false;
|
|
367
|
+
if (what.kind === "file") { if (!builtinTabs.includes("files")) return false; openPath(what.path); }
|
|
368
|
+
else {
|
|
369
|
+
const targetPanel = what.kind === "tab" ? what.id : what.kind;
|
|
370
|
+
if (!panels.show(targetPanel)) return false;
|
|
371
|
+
setTab(targetPanel);
|
|
372
|
+
if (what.kind === "files") setDir(what.path ?? "");
|
|
373
|
+
}
|
|
374
|
+
if (layoutRef.current) layoutRef.current.onActivate(); else expandRef.current?.();
|
|
375
|
+
return true;
|
|
376
|
+
}, { owner: inlineOwner ?? owner, inline: isInline, priority: () => isInline ? 1 : visibleRef.current ? 2 : 0 });
|
|
377
|
+
}, [store, backend, acceptOpen, openPath, builtinTabs.join(","), allowedPanels.join(","), panels.show, inlineOwner, owner, isInline]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
378
|
+
|
|
379
|
+
const navRef = useRef(onNavigate);
|
|
380
|
+
navRef.current = onNavigate;
|
|
381
|
+
useEffect(() => { navRef.current?.({ tab: activeTab, dir, file: file?.path ?? null }); }, [activeTab, dir, file?.path]);
|
|
382
|
+
// 看板现在的样子报给仓库:业务方用 store.workspace().openFile() / openFiles() 拿到当前打开的文件。
|
|
383
|
+
const openPaths = JSON.stringify(files.map((f) => f.path));
|
|
384
|
+
const filesEnabled = builtinTabs.includes("files");
|
|
385
|
+
useEffect(() => {
|
|
386
|
+
if (!backend || !jobId || !watchFiles || !visible || !filesEnabled) return;
|
|
387
|
+
return retainFileScope(backend, jobId, { paths: JSON.parse(openPaths) as string[], dirs: [] });
|
|
388
|
+
}, [backend, jobId, watchFiles, visible, openPaths, filesEnabled]);
|
|
389
|
+
useEffect(() => {
|
|
390
|
+
if (!jobId || restoreNeeded) return;
|
|
391
|
+
const currentFile = isInline && !visible ? null : file;
|
|
392
|
+
store.reportBoard(jobId, { tab: activeTab, dir, file: currentFile?.path ?? null, files: files.map(file=>file.path), current:file?{path:file.path,sourceId:jobId}:null,opened:files.map(file=>({path:file.path,sourceId:jobId})),history,browser:activeTab==="browser"?browserPage:null,visible,autoContext:autoQuoteOpenFile??canQuote }, owner);
|
|
393
|
+
}, [store, jobId, activeTab, dir, file?.path, openPaths, history, browserPage, visible, autoQuoteOpenFile, canQuote, owner, restoreNeeded]);
|
|
394
|
+
useEffect(() => () => { if (jobId) store.reportBoard(jobId, null, owner); }, [store, jobId, owner]);
|
|
395
|
+
|
|
396
|
+
const body = () => {
|
|
397
|
+
if (!activeTab) return <div className="cli-board-empty">看板模块已全部关闭。通过右上角“管理看板模块”重新打开。</div>;
|
|
398
|
+
if (!backend) return <div className="tc-note">当前后端不支持看板(需要任务中心提供 /api/jobs/:id/board)。</div>;
|
|
399
|
+
if (!jobId) return <div className="tc-note">选择或开始一个对话后,这里显示它的文件、改动和浏览器。</div>;
|
|
400
|
+
if (!info) return <div className="tc-note">{infoError ? <>读取看板失败:{infoError} <button className="cli-fv-btn" onClick={refreshAll}>重试</button></> : "读取中…"}</div>;
|
|
401
|
+
if (!info.dispatched) return <div className="tc-note">任务还没开始执行({info.detail ?? "等待分配执行节点"})。开始后这里会显示工作区文件。</div>;
|
|
402
|
+
if (!visible) return null;
|
|
403
|
+
if (activeTab === "changes") return <ChangesPane key={jobId} backend={backend} jobId={jobId} refreshKey={tick} settled={conv?.settled ?? 0} onOpen={(p) => openPath(p)} />;
|
|
404
|
+
if (activeTab === "browser") {
|
|
405
|
+
if (!visible) return null;
|
|
406
|
+
return <BrowserPane key={`${jobId}:${info.remote_task_id}`} backend={backend} jobId={jobId} readOnly={readOnly}
|
|
407
|
+
onPage={setBrowserPage}
|
|
408
|
+
onQuote={canQuote ? (p) => quote({ id: "browser:page", kind: "browser", label: p.title || p.url, text: `任务浏览器当前打开的页面:${p.title ? `${p.title} — ` : ""}${p.url}` }) : undefined}
|
|
409
|
+
onQuoteText={canQuote ? (text, p) => {
|
|
410
|
+
const clipped = text.length > 3_000 ? `${text.slice(0, 3_000)}…` : text;
|
|
411
|
+
const short = clipped.replace(/\s+/g, " ").slice(0, 24);
|
|
412
|
+
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")}` });
|
|
413
|
+
} : undefined} />;
|
|
414
|
+
}
|
|
415
|
+
const extra = extraTabs?.find((t) => t.id === activeTab);
|
|
416
|
+
if (extra) return extra.render({ jobId, store, quote });
|
|
417
|
+
if (activeTab.startsWith(FILE_TAB) && file) {
|
|
418
|
+
return (
|
|
419
|
+
<FileViewer toolbarHost={toolbarHost}
|
|
420
|
+
key={file.path}
|
|
421
|
+
backend={backend}
|
|
422
|
+
jobId={jobId}
|
|
423
|
+
path={file.path}
|
|
424
|
+
displayName={file.name}
|
|
425
|
+
size={file.size}
|
|
426
|
+
mtime={file.mtime}
|
|
427
|
+
version={file.version + manual} reloadKey={file.reloadKey}
|
|
428
|
+
actions={allFileActions}
|
|
429
|
+
onOpenPath={(p) => openPath(p)}
|
|
430
|
+
onQuoteText={canQuote ? (text, context) => quoteText(file.path, text, context) : undefined}
|
|
431
|
+
onBack={() => { setDir(file.path.includes("/") ? file.path.slice(0, file.path.lastIndexOf("/")) : ""); panels.show("files"); setTab("files"); }}
|
|
432
|
+
/>
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
return <FilesPane toolbarHost={toolbarHost} key={jobId} backend={backend} jobId={jobId} dir={dir} onDir={setDir} onOpen={(e) => openPath(e.path, e)} refreshKey={tick} />;
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
if (collapsed) {
|
|
439
|
+
return (
|
|
440
|
+
<aside ref={rootRef} className={`cli-ui cli-board collapsed ${className ?? ""}`} data-theme={theme} style={style}>
|
|
441
|
+
<button className="cli-board-strip" onClick={onExpand} title="显示看板">
|
|
442
|
+
{Icon.chevRight()}
|
|
443
|
+
{shownTabs.map((t) => <span key={t} className="cli-board-strip-t">{tabLabel(t)}</span>)}
|
|
444
|
+
{running && <span className="cli-sync-dot on" />}
|
|
445
|
+
</button>
|
|
446
|
+
</aside>
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const selectTab = (id: string) => { setTab(id); layoutRef.current?.onActivate(); };
|
|
451
|
+
const tabBar = <div className="rail-tabs">
|
|
452
|
+
<div className="rail-tabs-scroll" role={inlineLayout ? undefined : "tablist"} ref={tabsRef}>
|
|
453
|
+
{shownTabs.map((t) => t.startsWith(FILE_TAB) ? (
|
|
454
|
+
<span key={t} className={`rail-tab rail-ftab ${activeTab === t && (!inlineLayout || inlineLayout.active) ? "on" : ""}`} title={t.slice(FILE_TAB.length)}
|
|
455
|
+
onMouseDown={(e) => { if (e.button === 1) { e.preventDefault(); closeFile(t.slice(FILE_TAB.length)); } }}>
|
|
456
|
+
<button className="rail-ftab-l" role="tab" aria-selected={activeTab === t && (!inlineLayout || inlineLayout.active)} aria-label={tabLabel(t)} onClick={() => selectTab(t)}>{Icon.doc()}<span className="rail-ftab-text">{tabLabel(t)}</span></button>
|
|
457
|
+
<button className="rail-ftab-x" onClick={() => closeFile(t.slice(FILE_TAB.length))} title="关闭(鼠标中键也可以)" aria-label={`关闭 ${tabLabel(t)}`}>{Icon.x()}</button>
|
|
458
|
+
</span>
|
|
459
|
+
) : (
|
|
460
|
+
<PanelTab key={t} id={t} label={tabLabel(t)} active={activeTab === t && (!inlineLayout || inlineLayout.active)} onSelect={() => selectTab(t)} onClose={() => {
|
|
461
|
+
panels.close(t);
|
|
462
|
+
if (activeTab === t) setTab(shownTabs.find(id => id !== t) ?? "");
|
|
463
|
+
if (inlineLayout && shownTabs.length === 1) inlineLayout.onChat();
|
|
464
|
+
}} />
|
|
465
|
+
))}
|
|
466
|
+
</div>
|
|
467
|
+
<PanelControls toolbarRef={setToolbarHost} available={allowedPanels} open={panels.open} label={tabLabel}
|
|
468
|
+
upload={!readOnly && builtinTabs.includes("files") && uploadToWorkspace ? { onChoose: upload.choose, disabled: upload.busy || !info?.dispatched, hint: !info?.dispatched ? "工作区尚未就绪" : "保存到当前会话,不发送消息" } : undefined}
|
|
469
|
+
onShow={id => { panels.show(id); selectTab(id); }}
|
|
470
|
+
onClose={id => { panels.close(id); if (activeTab === id) setTab(shownTabs.find(t => t !== id) ?? ""); }} />
|
|
471
|
+
{info?.dispatched && !file && <button className="rail-collapse" onClick={refreshAll} title={running ? "运行中 · 本轮结束后同步,点击立即检查" : "刷新"}>
|
|
472
|
+
<span className={`cli-sync-dot ${running ? "on" : ""}`} />↻
|
|
473
|
+
</button>}
|
|
474
|
+
</div>;
|
|
475
|
+
return <>
|
|
476
|
+
{inlineLayout?.tabsHost && createPortal(tabBar, inlineLayout.tabsHost)}
|
|
477
|
+
<aside ref={rootRef} className={`cli-ui cli-board sess-rail${isInline ? " cli-inline-board" : ""} ${className ?? ""}`} data-theme={theme} hidden={isInline && !visible} style={{ ...style, ...(isInline && !visible ? { display: "none" } : {}) }} onPointerDown={()=>jobId&&store.activateBoard(jobId,owner)} onClickCapture={()=>jobId&&store.activateBoard(jobId,owner)} onFocusCapture={()=>jobId&&store.activateBoard(jobId,owner)}>
|
|
478
|
+
{!inlineLayout && tabBar}
|
|
479
|
+
{upload.input}
|
|
480
|
+
{upload.progress}
|
|
481
|
+
<div className="rail-scroll">{body()}</div>
|
|
482
|
+
</aside>
|
|
483
|
+
</>;
|
|
484
|
+
}
|