@spzxg/cli-ui 0.6.2 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +137 -11
- package/dist/cli-ui-embed-BmLN2Jjg.js +43290 -0
- package/dist/{cli-ui-index-BY5i3wId.js → cli-ui-index-D4qfPHMd.js} +2 -2
- package/dist/{cli-ui-jszip.min-BZsUwI-s.js → cli-ui-jszip.min-DC9898pC.js} +1 -1
- package/dist/{cli-ui-jszip.min-m01fexHW.js → cli-ui-jszip.min-DpEl0G1z.js} +2 -2
- package/dist/{cli-ui-xlsx-CpWicJLT.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 +73 -30
- package/src/chat/Composer.tsx +41 -41
- 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 +8 -6
- package/src/core/conversation.ts +3 -3
- 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 +20 -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/shared.tsx +1 -1
- package/src/ui/transcriptModel.ts +2 -2
- package/dist/cli-ui-embed-6IJ_jRVh.js +0 -41223
package/dist/cli-ui.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as a, b as i, d as r, e as t, f as o, h as C, i as l, j as n, k as m, m as d, l as u, n as w, o as B, p as f } from "./cli-ui-embed-
|
|
1
|
+
import { C as a, b as i, d as r, e as t, f as o, h as C, i as l, j as n, k as m, m as d, l as u, n as w, o as B, p as f } from "./cli-ui-embed-BmLN2Jjg.js";
|
|
2
2
|
export {
|
|
3
3
|
a as CliBoard,
|
|
4
4
|
i as CliBrowserView,
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spzxg/cli-ui",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "相互联动的 CliMsgList、CliChat、CliBoard,支持任务中枢和完整 Operator 工作区。",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"types": "src/index.ts",
|
|
8
8
|
"exports": {
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
"./elements": "./src/elements.tsx",
|
|
11
11
|
"./styles.css": "./src/styles.css",
|
|
12
12
|
"./embed": "./src/embed.ts",
|
|
13
|
-
"./dist/*": "./dist/*"
|
|
13
|
+
"./dist/*": "./dist/*",
|
|
14
|
+
"./operator": "./src/operator/index.ts",
|
|
15
|
+
"./operator.css": "./src/operator.base.css"
|
|
14
16
|
},
|
|
15
17
|
"files": [
|
|
16
18
|
"dist",
|
|
@@ -18,7 +20,8 @@
|
|
|
18
20
|
"!src/**/*.test.ts",
|
|
19
21
|
"!src/**/*.test.tsx",
|
|
20
22
|
"!src/test-setup.ts",
|
|
21
|
-
"README.md"
|
|
23
|
+
"README.md",
|
|
24
|
+
"CHANGELOG.md"
|
|
22
25
|
],
|
|
23
26
|
"scripts": {
|
|
24
27
|
"build": "tsc -p tsconfig.json --noEmit",
|
|
@@ -27,15 +30,15 @@
|
|
|
27
30
|
"demo": "node demo/mock-center.mjs & vite --config demo/vite.config.ts",
|
|
28
31
|
"css:sync": "node scripts/scope-css.mjs",
|
|
29
32
|
"build:embed": "vite build --config vite.embed.config.ts",
|
|
30
|
-
"prepublishOnly": "npm run lint && npm test && npm run build:embed"
|
|
33
|
+
"prepublishOnly": "npm run css:sync && npm run lint && npm test && npm run build:embed"
|
|
31
34
|
},
|
|
32
35
|
"peerDependencies": {
|
|
33
36
|
"react": "^19.0.0",
|
|
34
37
|
"react-dom": "^19.0.0"
|
|
35
38
|
},
|
|
36
39
|
"devDependencies": {
|
|
37
|
-
"@types/react": "
|
|
38
|
-
"@types/react-dom": "
|
|
40
|
+
"@types/react": "19.2.17",
|
|
41
|
+
"@types/react-dom": "19.2.3",
|
|
39
42
|
"@vitejs/plugin-react": "^4.3.4",
|
|
40
43
|
"jsdom": "^25.0.1",
|
|
41
44
|
"postcss": "^8.5.32",
|
|
@@ -48,6 +51,9 @@
|
|
|
48
51
|
"mammoth": "^1.13.0",
|
|
49
52
|
"react-markdown": "^10.1.0",
|
|
50
53
|
"rehype-highlight": "^7.0.2",
|
|
51
|
-
"remark-gfm": "^4.0.1"
|
|
54
|
+
"remark-gfm": "^4.0.1",
|
|
55
|
+
"@xterm/xterm": "^6.0.0",
|
|
56
|
+
"@xterm/addon-fit": "^0.11.0",
|
|
57
|
+
"@xterm/addon-web-links": "^0.12.0"
|
|
52
58
|
}
|
|
53
59
|
}
|
|
@@ -9,11 +9,11 @@
|
|
|
9
9
|
// · 按住拖动带着按键(选中文字、拖滑块);滚轮合并发送;
|
|
10
10
|
// · 网页的 alert / confirm / prompt 画面里看不到,这里单独弹出来处理。
|
|
11
11
|
// Operator 的「在节点屏幕上显示」「弹出窗口」「登录状态」是节点本机的功能,这里不提供。
|
|
12
|
-
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, type MutableRefObject } from "react";
|
|
13
|
-
import { CenterError, isMissingRoute, type BoardBackend } from "../core/client
|
|
14
|
-
import { jitter } from "../core/store
|
|
15
|
-
import type { BrowserFramesState, BrowserMessage, BrowserStatus } from "../core/types
|
|
16
|
-
import { Icon } from "../ui/icons
|
|
12
|
+
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, type MutableRefObject, type ReactNode } from "react";
|
|
13
|
+
import { CenterError, isMissingRoute, type BoardBackend } from "../core/client";
|
|
14
|
+
import { jitter } from "../core/store";
|
|
15
|
+
import type { BrowserFramesState, BrowserMessage, BrowserStatus } from "../core/types";
|
|
16
|
+
import { Icon } from "../ui/icons";
|
|
17
17
|
|
|
18
18
|
interface FrameMeta { deviceWidth: number; deviceHeight: number }
|
|
19
19
|
type Dialog = Extract<BrowserMessage, { type: "dialog" }>;
|
|
@@ -133,28 +133,40 @@ function useBrowserFeed(backend: BoardBackend, jobId: string, sink: MutableRefOb
|
|
|
133
133
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
134
134
|
let attempt = 0;
|
|
135
135
|
const connect = () => {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
136
|
+
if (!alive) return;
|
|
137
|
+
let finished = false;
|
|
138
|
+
let stopThis: (() => void) | null = null;
|
|
139
|
+
const retire = () => {
|
|
140
|
+
if (!alive || finished) return false;
|
|
141
|
+
finished = true;
|
|
142
|
+
stopThis?.();
|
|
143
|
+
setViewer(null); setReady(false);
|
|
144
|
+
return true;
|
|
145
|
+
};
|
|
146
|
+
stopThis = backend.browserLive!(jobId, {
|
|
147
|
+
onViewer: (id, isReady) => { if (!alive || finished) return; attempt = 0; setViewer(id); setReady(isReady); },
|
|
148
|
+
onStatus: (s) => { if (!alive || finished) return; attempt = 0; setStreamError(null); setStatus(s); },
|
|
149
|
+
onMessage: (message) => { if (alive && !finished) deliver(message); },
|
|
150
|
+
onFrames: (state) => { if (alive && !finished) setFrames(state); },
|
|
142
151
|
onEnd: (end) => {
|
|
143
|
-
if (end.reason
|
|
152
|
+
if (end.reason !== "refused" && end.reason !== "client_closed") return;
|
|
153
|
+
if (!retire()) return;
|
|
154
|
+
setFrames({ state: "idle" });
|
|
155
|
+
if (end.reason === "refused") setEnded(end.detail || "执行节点拒绝了浏览器连接");
|
|
144
156
|
},
|
|
145
157
|
onClose: (err) => {
|
|
146
|
-
|
|
147
|
-
setReady(false);
|
|
148
|
-
if (!alive || final) return;
|
|
158
|
+
if (!retire()) return;
|
|
149
159
|
if (isMissingRoute(err)) { setMode("legacy"); return; }
|
|
150
160
|
// 对话不存在:重连也没用。
|
|
151
|
-
if (err && err.status
|
|
161
|
+
if (err && [400,404,405,410,501].includes(err.status)) { setEnded(err.message); return; }
|
|
152
162
|
if (err) setStreamError(err.message);
|
|
153
163
|
// 409 还没派发:隔几秒再问;401 / 403 多半是宿主会话过期,30 秒后再试(续上会话就自己恢复);
|
|
154
164
|
// 其余(断线、中枢重启、人太多)按退避重连。
|
|
155
165
|
timer = setTimeout(connect, nextTry(err, attempt++));
|
|
156
166
|
},
|
|
157
167
|
});
|
|
168
|
+
stop = stopThis;
|
|
169
|
+
if (finished) stopThis();
|
|
158
170
|
};
|
|
159
171
|
connect();
|
|
160
172
|
return () => { alive = false; stop?.(); if (timer) clearTimeout(timer); };
|
|
@@ -168,15 +180,23 @@ function useBrowserFeed(backend: BoardBackend, jobId: string, sink: MutableRefOb
|
|
|
168
180
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
169
181
|
let attempt = 0;
|
|
170
182
|
const connect = () => {
|
|
171
|
-
|
|
172
|
-
|
|
183
|
+
if (!alive) return;
|
|
184
|
+
let finished = false;
|
|
185
|
+
let stopThis: (() => void) | null = null;
|
|
186
|
+
stopThis = backend.browserEvents(jobId, {
|
|
187
|
+
onStatus: (s) => { if (!alive || finished) return; attempt = 0; setStreamError(null); setStatus(s); },
|
|
188
|
+
onError: (error) => { if (alive && !finished) setStreamError(error.message); },
|
|
173
189
|
onClose: (err) => {
|
|
174
|
-
if (!alive) return;
|
|
175
|
-
|
|
190
|
+
if (!alive || finished) return;
|
|
191
|
+
finished = true;
|
|
192
|
+
stopThis?.();
|
|
193
|
+
if (err && [400,404,405,410,501].includes(err.status)) { setEnded(err.message); return; }
|
|
176
194
|
if (err) setStreamError(err.message);
|
|
177
195
|
timer = setTimeout(connect, nextTry(err, attempt++));
|
|
178
196
|
},
|
|
179
197
|
});
|
|
198
|
+
stop = stopThis;
|
|
199
|
+
if (finished) stopThis();
|
|
180
200
|
};
|
|
181
201
|
connect();
|
|
182
202
|
return () => { alive = false; stop?.(); if (timer) clearTimeout(timer); };
|
|
@@ -190,21 +210,37 @@ function useBrowserFeed(backend: BoardBackend, jobId: string, sink: MutableRefOb
|
|
|
190
210
|
let stop: (() => void) | null = null;
|
|
191
211
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
192
212
|
let attempt = 0;
|
|
193
|
-
const again = (detail?: string) => {
|
|
194
|
-
setViewer(null);
|
|
195
|
-
setReady(false);
|
|
196
|
-
if (!alive) return;
|
|
197
|
-
setFrames({ state: "retrying", ...(detail ? { detail } : {}) });
|
|
198
|
-
timer = setTimeout(connect, retryDelay(attempt++));
|
|
199
|
-
};
|
|
200
213
|
const connect = () => {
|
|
214
|
+
if (!alive) return;
|
|
215
|
+
let finished = false;
|
|
216
|
+
let stopThis: (() => void) | null = null;
|
|
217
|
+
const again = (detail?: string) => {
|
|
218
|
+
// A stream can emit an end event and then close; retire it only once.
|
|
219
|
+
if (!alive || finished) return;
|
|
220
|
+
finished = true;
|
|
221
|
+
stopThis?.();
|
|
222
|
+
setViewer(null);
|
|
223
|
+
setReady(false);
|
|
224
|
+
setFrames({ state: "retrying", ...(detail ? { detail } : {}) });
|
|
225
|
+
timer = setTimeout(connect, retryDelay(attempt++));
|
|
226
|
+
};
|
|
201
227
|
setFrames({ state: attempt ? "retrying" : "connecting" });
|
|
202
|
-
|
|
203
|
-
onViewer: (id) => { attempt = 0; setViewer(id); setReady(true); setFrames({ state: "live" }); },
|
|
204
|
-
onMessage: deliver,
|
|
205
|
-
onEnd: (end) => {
|
|
206
|
-
|
|
228
|
+
stopThis = backend.browserFrames(jobId, {
|
|
229
|
+
onViewer: (id) => { if (!alive || finished) return; attempt = 0; setViewer(id); setReady(true); setFrames({ state: "live" }); },
|
|
230
|
+
onMessage: (message) => { if (alive && !finished) deliver(message); },
|
|
231
|
+
onEnd: (end) => {
|
|
232
|
+
if (end.reason !== "client_closed") { again(end.reason === "refused" ? end.detail : undefined); return; }
|
|
233
|
+
if (!alive || finished) return;
|
|
234
|
+
finished = true;
|
|
235
|
+
stopThis?.();
|
|
236
|
+
setViewer(null); setReady(false); setFrames({ state: "idle" });
|
|
237
|
+
},
|
|
238
|
+
// Clean EOF also invalidates the old viewer and needs a reconnect.
|
|
239
|
+
onClose: (err) => again(err?.message),
|
|
207
240
|
});
|
|
241
|
+
stop = stopThis;
|
|
242
|
+
// Custom transports may close synchronously before returning cleanup.
|
|
243
|
+
if (finished) stopThis();
|
|
208
244
|
};
|
|
209
245
|
connect();
|
|
210
246
|
return () => { alive = false; stop?.(); if (timer) clearTimeout(timer); };
|
|
@@ -212,7 +248,7 @@ function useBrowserFeed(backend: BoardBackend, jobId: string, sink: MutableRefOb
|
|
|
212
248
|
|
|
213
249
|
// 浏览器关了:上一个页面的地址、弹窗、画面都作废,重新打开时不先闪一下旧画面。
|
|
214
250
|
const running = status?.state === "running";
|
|
215
|
-
useEffect(() => { if (!running) latest.current = {}; }, [running]);
|
|
251
|
+
useEffect(() => { if (status && !running) latest.current = {}; }, [running, status]);
|
|
216
252
|
|
|
217
253
|
return { status, setStatus, streamError, ended, viewer, ready, frames, paused: !visible, reconnect, latest };
|
|
218
254
|
}
|
|
@@ -234,20 +270,19 @@ function decodeBase64(data: string): Uint8Array {
|
|
|
234
270
|
* · 输入用指针事件:鼠标照原样;触屏点一下 = 点击,拖动 = 滚动;接管时滚轮 / 触摸只作用于画面,不滚动宿主页面。
|
|
235
271
|
* · 画面按比例缩放,适应容器宽高(窄的侧栏、弹窗、整页都可以)。
|
|
236
272
|
*/
|
|
237
|
-
const Screen = forwardRef<ScreenHandle, { human: boolean; hidden: boolean; label: string; send: (msg: Record<string, unknown>) => void; onFocusKeys: () => void }>(
|
|
238
|
-
function Screen({ human, hidden, label, send, onFocusKeys }, ref) {
|
|
273
|
+
const Screen = forwardRef<ScreenHandle, { human: boolean; inputEpoch: number; hidden: boolean; label: string; send: (msg: Record<string, unknown>) => void; onFocusKeys: () => void }>(
|
|
274
|
+
function Screen({ human, inputEpoch, hidden, label, send, onFocusKeys }, ref) {
|
|
239
275
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
240
276
|
const imgRef = useRef<HTMLImageElement | null>(null);
|
|
241
277
|
const meta = useRef<FrameMeta | null>(null);
|
|
242
278
|
const seq = useRef({ next: 0, drawn: 0 });
|
|
243
279
|
const [fallback] = useState(() => typeof createImageBitmap !== "function");
|
|
244
|
-
const [
|
|
280
|
+
const [fallbackFrame, setFallbackFrame] = useState<{src:string;meta:FrameMeta|null} | null>(null);
|
|
245
281
|
|
|
246
282
|
useImperativeHandle(ref, () => ({
|
|
247
283
|
draw(data, frameMeta) {
|
|
248
|
-
meta.current = frameMeta;
|
|
249
284
|
const type = data.startsWith("iVBOR") ? "image/png" : "image/jpeg";
|
|
250
|
-
if (fallback) {
|
|
285
|
+
if (fallback) { setFallbackFrame({src:`data:${type};base64,${data}`,meta:frameMeta}); return; }
|
|
251
286
|
const n = ++seq.current.next;
|
|
252
287
|
let bytes: Uint8Array;
|
|
253
288
|
try { bytes = decodeBase64(data); } catch { return; }
|
|
@@ -255,6 +290,7 @@ const Screen = forwardRef<ScreenHandle, { human: boolean; hidden: boolean; label
|
|
|
255
290
|
const canvas = canvasRef.current;
|
|
256
291
|
if (!canvas || n < seq.current.drawn) { bitmap.close(); return; }
|
|
257
292
|
seq.current.drawn = n;
|
|
293
|
+
meta.current = frameMeta;
|
|
258
294
|
if (canvas.width !== bitmap.width || canvas.height !== bitmap.height) { canvas.width = bitmap.width; canvas.height = bitmap.height; }
|
|
259
295
|
canvas.getContext("2d")?.drawImage(bitmap, 0, 0);
|
|
260
296
|
bitmap.close();
|
|
@@ -262,11 +298,18 @@ const Screen = forwardRef<ScreenHandle, { human: boolean; hidden: boolean; label
|
|
|
262
298
|
},
|
|
263
299
|
}), [fallback]);
|
|
264
300
|
|
|
301
|
+
// Identical image bytes may accompany updated CSS viewport metadata and
|
|
302
|
+
// need not trigger another load event in the fallback image element.
|
|
303
|
+
useEffect(()=>{const img=imgRef.current;if(fallbackFrame&&img?.complete&&img.naturalWidth>0)meta.current=fallbackFrame.meta;},[fallbackFrame]);
|
|
304
|
+
|
|
265
305
|
const el = () => (fallback ? imgRef.current : canvasRef.current);
|
|
266
306
|
/** 屏幕坐标 → 页面 CSS 像素(按帧自带的页面尺寸换算)。 */
|
|
267
307
|
const point = useCallback((clientX: number, clientY: number) => {
|
|
268
308
|
const node = el();
|
|
269
309
|
if (!node) return null;
|
|
310
|
+
// Network arrival is not an interactive image: never map a click onto
|
|
311
|
+
// the blank initial canvas or an image that has not decoded yet.
|
|
312
|
+
if (fallback ? !(node instanceof HTMLImageElement && node.complete && node.naturalWidth > 0) : seq.current.drawn === 0) return null;
|
|
270
313
|
const r = node.getBoundingClientRect();
|
|
271
314
|
if (!r.width || !r.height) return null;
|
|
272
315
|
const natural = node instanceof HTMLCanvasElement ? { w: node.width, h: node.height } : { w: node.naturalWidth, h: node.naturalHeight };
|
|
@@ -290,7 +333,7 @@ const Screen = forwardRef<ScreenHandle, { human: boolean; hidden: boolean; label
|
|
|
290
333
|
};
|
|
291
334
|
|
|
292
335
|
const onPointerDown = (e: React.PointerEvent) => {
|
|
293
|
-
if (!human) return;
|
|
336
|
+
if (!human || !point(e.clientX,e.clientY)) return;
|
|
294
337
|
e.preventDefault();
|
|
295
338
|
onFocusKeys();
|
|
296
339
|
if (e.pointerType === "touch") {
|
|
@@ -333,22 +376,26 @@ const Screen = forwardRef<ScreenHandle, { human: boolean; hidden: boolean; label
|
|
|
333
376
|
}
|
|
334
377
|
return;
|
|
335
378
|
}
|
|
379
|
+
if (e.pointerType === "touch" || !pressed.current) return;
|
|
336
380
|
pressed.current = null;
|
|
337
381
|
mouse("mouseReleased", e, { button: MOUSE_BUTTONS[e.button] ?? "left", clickCount: e.detail || 1 });
|
|
338
382
|
};
|
|
339
383
|
|
|
340
|
-
//
|
|
341
|
-
useEffect(()
|
|
342
|
-
|
|
384
|
+
// A new control/viewer epoch cannot inherit a drag from the previous one.
|
|
385
|
+
useEffect(()=>{touch.current=null;pressed.current=null;lastPoint.current=null;},[human,inputEpoch]);
|
|
386
|
+
const release = useCallback(() => {
|
|
387
|
+
touch.current=null;
|
|
343
388
|
if (!pressed.current || !lastPoint.current) return;
|
|
344
389
|
const p = point(lastPoint.current.clientX, lastPoint.current.clientY);
|
|
345
|
-
if (p) send({ type: "mouse", event: "mouseReleased", ...p, button: pressed.current, clickCount: 1, modifiers: 0 });
|
|
390
|
+
if (p && humanRef.current) send({ type: "mouse", event: "mouseReleased", ...p, button: pressed.current, clickCount: 1, modifiers: 0 });
|
|
346
391
|
pressed.current = null;
|
|
347
|
-
|
|
392
|
+
},[point,send]);
|
|
393
|
+
// Cancellation/window blur must release the remote pointer as well.
|
|
394
|
+
useEffect(() => {
|
|
348
395
|
window.addEventListener("blur", release);
|
|
349
396
|
document.addEventListener("visibilitychange", release);
|
|
350
397
|
return () => { window.removeEventListener("blur", release); document.removeEventListener("visibilitychange", release); };
|
|
351
|
-
}, [
|
|
398
|
+
}, [release]);
|
|
352
399
|
|
|
353
400
|
// 滚轮:React 的 onWheel 是被动监听,拦不住宿主页面滚动,所以用原生监听(passive: false)。
|
|
354
401
|
useEffect(() => {
|
|
@@ -369,13 +416,13 @@ const Screen = forwardRef<ScreenHandle, { human: boolean; hidden: boolean; label
|
|
|
369
416
|
className: `bp-frame ${human ? "live" : ""}`,
|
|
370
417
|
style: { display: hidden ? "none" : undefined, touchAction: human ? "none" : "auto" } as React.CSSProperties,
|
|
371
418
|
onPointerDown, onPointerMove, onPointerUp,
|
|
372
|
-
onPointerCancel:
|
|
419
|
+
onPointerCancel: release,
|
|
373
420
|
onContextMenu: (e: React.MouseEvent) => { if (human) e.preventDefault(); },
|
|
374
421
|
role: "img" as const,
|
|
375
422
|
"aria-label": label,
|
|
376
423
|
};
|
|
377
424
|
return fallback
|
|
378
|
-
? (
|
|
425
|
+
? (fallbackFrame ? <img ref={imgRef} src={fallbackFrame.src} onLoad={()=>{meta.current=fallbackFrame.meta;}} alt={label} draggable={false} {...common} /> : null)
|
|
379
426
|
: <canvas ref={canvasRef} {...common} />;
|
|
380
427
|
},
|
|
381
428
|
);
|
|
@@ -394,8 +441,11 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
|
|
|
394
441
|
const [notice, setNotice] = useState<string | null>(null);
|
|
395
442
|
const [dialog, setDialog] = useState<Dialog | null>(null);
|
|
396
443
|
const [promptText, setPromptText] = useState("");
|
|
444
|
+
const [dialogSending, setDialogSending] = useState(false);
|
|
445
|
+
const pendingDialog = useRef<Record<string, unknown> | null>(null);
|
|
397
446
|
// 在网页里复制出来的文字:除了写进剪贴板,还可以「引用到对话」。
|
|
398
|
-
const [copied, setCopied] = useState<string | null>(null);
|
|
447
|
+
const [copied, setCopied] = useState<{text:string;page:{url:string;title:string}} | null>(null);
|
|
448
|
+
const clipboardSequence=useRef(0);
|
|
399
449
|
// 画面没有响应:接管时发了操作,之后 UNRESPONSIVE_MS 一帧都没来。
|
|
400
450
|
const [stuck, setStuck] = useState(false);
|
|
401
451
|
const lastInputAt = useRef(0);
|
|
@@ -418,12 +468,14 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
|
|
|
418
468
|
setStuck(false);
|
|
419
469
|
setHasFrame(true);
|
|
420
470
|
} else if (m.type === "page") setPage({ url: m.url ?? "", title: m.title ?? "" });
|
|
421
|
-
else if (m.type === "error" && m.message) flash(m.message);
|
|
422
|
-
else if (m.type === "dialog") { setDialog(m); setPromptText(m.defaultPrompt ?? ""); }
|
|
423
|
-
else if (m.type === "dialog_closed") setDialog(null);
|
|
471
|
+
else if (m.type === "error" && m.message) { if(pendingDialog.current){pendingDialog.current=null;setDialogSending(false);} flash(m.message); }
|
|
472
|
+
else if (m.type === "dialog") { pendingDialog.current = null; setDialogSending(false); setDialog(m); setPromptText(m.defaultPrompt ?? ""); }
|
|
473
|
+
else if (m.type === "dialog_closed") { pendingDialog.current = null; setDialogSending(false); setDialog(null); }
|
|
424
474
|
else if (m.type === "clipboard") {
|
|
425
|
-
|
|
426
|
-
|
|
475
|
+
const source=latest.current.page;
|
|
476
|
+
const sequence=++clipboardSequence.current;
|
|
477
|
+
setCopied({text:m.text,page:{url:source?.type==='page'?source.url??'':'',title:source?.type==='page'?source.title??'':''}});
|
|
478
|
+
void writeClipboard(m.text).then((ok) => {if(sequence===clipboardSequence.current)flash(ok ? "已复制到你的剪贴板" : "没能写入剪贴板(浏览器不允许),可以在网页里选中后再试", 6000);});
|
|
427
479
|
}
|
|
428
480
|
};
|
|
429
481
|
const { page: lastPage, dialog: lastDialog, frame: lastFrame } = latest.current;
|
|
@@ -431,7 +483,7 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
|
|
|
431
483
|
if (lastDialog) handle(lastDialog);
|
|
432
484
|
if (lastFrame) handle(lastFrame);
|
|
433
485
|
sink.current = handle;
|
|
434
|
-
return () => { sink.current = null; };
|
|
486
|
+
return () => { sink.current = null; clipboardSequence.current++; };
|
|
435
487
|
}, [sink, flash, latest]);
|
|
436
488
|
|
|
437
489
|
useEffect(() => { if (!editing) setAddr(page.url); }, [page.url, editing]);
|
|
@@ -451,16 +503,30 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
|
|
|
451
503
|
|
|
452
504
|
// 输入按顺序发:一个请求在路上时攒着,回来后一次发完(鼠标移动只留最后一个,连续的滚轮合并)。
|
|
453
505
|
const viewerRef = useRef<string | null>(null);
|
|
454
|
-
viewerRef.current = feed.ready ? feed.viewer : null;
|
|
455
506
|
const queue = useRef<Array<Record<string, unknown>>>([]);
|
|
456
|
-
const
|
|
507
|
+
const controlRef = useRef(human); controlRef.current=human;
|
|
508
|
+
const mountedRef = useRef(true);
|
|
509
|
+
useEffect(()=>{mountedRef.current=true;return()=>{mountedRef.current=false;queue.current=[];};},[]);
|
|
510
|
+
useEffect(() => { if (!feed.ready || !human) { pendingDialog.current = null; setDialogSending(false); } }, [feed.ready, feed.viewer, human]);
|
|
511
|
+
const inflight = useRef<{ viewer:string; epoch:number } | null>(null);
|
|
512
|
+
const inputEpoch = useRef(0);
|
|
513
|
+
const previousControl = useRef(human);
|
|
514
|
+
const nextViewer=feed.ready?feed.viewer:null;
|
|
515
|
+
if(viewerRef.current!==nextViewer||previousControl.current!==human){
|
|
516
|
+
inputEpoch.current++;queue.current=[];inflight.current=null;
|
|
517
|
+
}
|
|
518
|
+
previousControl.current=human;
|
|
519
|
+
viewerRef.current=nextViewer;
|
|
457
520
|
const reconnect = feed.reconnect;
|
|
458
521
|
const flush = useCallback(() => {
|
|
459
522
|
const viewer = viewerRef.current;
|
|
460
|
-
if (inflight.current || !viewer || !queue.current.length) return;
|
|
523
|
+
if (!mountedRef.current || !controlRef.current || inflight.current || !viewer || !queue.current.length) return;
|
|
461
524
|
const batch = queue.current.splice(0, 50);
|
|
462
|
-
|
|
525
|
+
const token={viewer,epoch:inputEpoch.current};
|
|
526
|
+
inflight.current = token;
|
|
463
527
|
backend.browserInput(jobId, viewer, batch).catch((error: unknown) => {
|
|
528
|
+
if(!mountedRef.current||inflight.current!==token||viewerRef.current!==viewer||!controlRef.current)return;
|
|
529
|
+
if (pendingDialog.current && batch.includes(pendingDialog.current)) { pendingDialog.current = null; setDialogSending(false); }
|
|
464
530
|
// 画面连接已失效(中枢重启、连接断开):丢掉这批输入并重连,避免用户以为点了没反应。
|
|
465
531
|
if (error instanceof CenterError && (error.status === 404 || error.status === 409)) {
|
|
466
532
|
queue.current = [];
|
|
@@ -474,13 +540,14 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
|
|
|
474
540
|
const why = error instanceof CenterError && error.status === 429 ? "操作太快,任务中心暂时没接收" : "网络不稳";
|
|
475
541
|
flash(`${why}:刚才的操作可能没有送达,请看画面确认后再操作`, 5000);
|
|
476
542
|
}
|
|
477
|
-
}).finally(() => { inflight.current =
|
|
543
|
+
}).finally(() => { if(inflight.current!==token)return;inflight.current = null; flush(); });
|
|
478
544
|
}, [backend, jobId, reconnect, flash]);
|
|
479
545
|
const send = useCallback((msg: Record<string, unknown>) => {
|
|
546
|
+
if(!mountedRef.current||!controlRef.current)return false;
|
|
480
547
|
// 画面连接还没就绪(刚接管、正在重连):不攒着等连上再补发——过几秒才落下的点击比没点更糟。
|
|
481
548
|
if (!viewerRef.current) {
|
|
482
549
|
if (msg.type !== "mouse" || msg.event === "mousePressed") flash("画面连接还没就绪,刚才的操作没有送达,请稍后再操作");
|
|
483
|
-
return;
|
|
550
|
+
return false;
|
|
484
551
|
}
|
|
485
552
|
lastInputAt.current = Date.now();
|
|
486
553
|
const q = queue.current;
|
|
@@ -496,6 +563,7 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
|
|
|
496
563
|
: msg;
|
|
497
564
|
} else q.push(msg);
|
|
498
565
|
flush();
|
|
566
|
+
return true;
|
|
499
567
|
}, [flush, flash]);
|
|
500
568
|
|
|
501
569
|
// 键盘:
|
|
@@ -525,9 +593,11 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
|
|
|
525
593
|
};
|
|
526
594
|
const nav = (msg: Record<string, unknown>) => { if (human) send(msg); };
|
|
527
595
|
const answer = (accept: boolean) => {
|
|
528
|
-
if (!dialog) return;
|
|
529
|
-
|
|
530
|
-
|
|
596
|
+
if (!dialog || !human || !feed.ready || pendingDialog.current) return;
|
|
597
|
+
const command = { type: "dialog", accept, ...(dialog.dialogType === "prompt" && accept ? { promptText } : {}) };
|
|
598
|
+
if (send(command)) { pendingDialog.current = command; setDialogSending(true); }
|
|
599
|
+
// Keep the dialog until the remote browser confirms dialog_closed. Failed
|
|
600
|
+
// or disconnected input must remain visible and available to retry.
|
|
531
601
|
};
|
|
532
602
|
|
|
533
603
|
const { frames, paused } = feed;
|
|
@@ -545,7 +615,7 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
|
|
|
545
615
|
</div>
|
|
546
616
|
<div className={`bp-screen ${human ? "live" : ""}`}>
|
|
547
617
|
{/* 画面一直挂着(收到第一帧前隐藏),解码在内存里做,不受宿主页面 CSP 的 img-src 限制。 */}
|
|
548
|
-
<Screen ref={screenRef} human={human} hidden={!hasFrame} label={page.title || "浏览器画面"} send={send} onFocusKeys={() => keysRef.current?.focus({ preventScroll: true })} />
|
|
618
|
+
<Screen ref={screenRef} human={human} inputEpoch={inputEpoch.current} hidden={!hasFrame} label={page.title || "浏览器画面"} send={send} onFocusKeys={() => keysRef.current?.focus({ preventScroll: true })} />
|
|
549
619
|
{!hasFrame && (
|
|
550
620
|
<div className="bp-empty">
|
|
551
621
|
{paused ? "页面不可见,已暂停画面" : retrying ? `画面连接中断,正在重连…${frames.detail ? `(${frames.detail})` : ""}` : "正在连接画面…"}
|
|
@@ -557,19 +627,21 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
|
|
|
557
627
|
<div className="bp-dialog-box">
|
|
558
628
|
<div className="bp-dialog-t">网页{dialog.dialogType === "confirm" ? "请求确认" : dialog.dialogType === "prompt" ? "请求输入" : dialog.dialogType === "beforeunload" ? "询问是否离开" : "提示"}</div>
|
|
559
629
|
<div className="bp-dialog-m">{dialog.message || "(没有内容)"}</div>
|
|
560
|
-
{dialog.dialogType === "prompt" && <input className="bp-dialog-in" value={promptText} disabled={!human} onChange={(e) => setPromptText(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") answer(true); }} />}
|
|
630
|
+
{dialog.dialogType === "prompt" && <input className="bp-dialog-in" value={promptText} disabled={!human || !feed.ready || dialogSending} onChange={(e) => setPromptText(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") answer(true); }} />}
|
|
561
631
|
{!human && <div className="bp-dialog-hint">接管浏览器后才能处理这个弹窗。</div>}
|
|
632
|
+
{human && !feed.ready && <div className="bp-dialog-hint">画面连接恢复后可继续处理这个弹窗。</div>}
|
|
633
|
+
{dialogSending && <div className="bp-dialog-hint" role="status">正在等待网页确认…</div>}
|
|
562
634
|
<div className="bp-dialog-a">
|
|
563
|
-
{dialog.dialogType !== "alert" && <button className="btn btn-line btn-sm" disabled={!human} onClick={() => answer(false)}>取消</button>}
|
|
564
|
-
<button className="btn btn-accent btn-sm" disabled={!human} onClick={() => answer(true)}>确定</button>
|
|
635
|
+
{dialog.dialogType !== "alert" && <button className="btn btn-line btn-sm" disabled={!human || !feed.ready || dialogSending} onClick={() => answer(false)}>取消</button>}
|
|
636
|
+
<button className="btn btn-accent btn-sm" disabled={!human || !feed.ready || dialogSending} onClick={() => answer(true)}>确定</button>
|
|
565
637
|
</div>
|
|
566
638
|
</div>
|
|
567
639
|
</div>
|
|
568
640
|
)}
|
|
569
641
|
{notice && (
|
|
570
642
|
<div className="bp-toast">{notice}
|
|
571
|
-
{copied && onQuoteText && /剪贴板/.test(notice) && (
|
|
572
|
-
<button className="bp-toast-btn" onClick={() => { onQuoteText(copied, page); setCopied(null); setNotice(null); }}>引用到对话</button>
|
|
643
|
+
{copied?.text && onQuoteText && /剪贴板/.test(notice) && (
|
|
644
|
+
<button className="bp-toast-btn" onClick={() => { onQuoteText(copied.text, copied.page); setCopied(null); setNotice(null); }}>引用到对话</button>
|
|
573
645
|
)}
|
|
574
646
|
</div>
|
|
575
647
|
)}
|
|
@@ -593,15 +665,27 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
|
|
|
593
665
|
);
|
|
594
666
|
}
|
|
595
667
|
|
|
596
|
-
|
|
668
|
+
type BrowserPaneProps = {
|
|
597
669
|
backend: BoardBackend; jobId: string; readOnly?: boolean;
|
|
670
|
+
extension?: (status: BrowserStatus, update: (status:BrowserStatus)=>void) => ReactNode;
|
|
671
|
+
footer?: ReactNode;
|
|
672
|
+
className?: string;
|
|
598
673
|
/** 给了就显示「引用到对话」:把浏览器当前页面(标题 + 地址)带进下一条消息。 */
|
|
599
674
|
onQuote?: (page: { url: string; title: string }) => void;
|
|
600
675
|
/** 给了就能把在网页里复制(⌘C / Ctrl+C)出来的文字引用到对话。 */
|
|
601
676
|
onQuoteText?: (text: string, page: { url: string; title: string }) => void;
|
|
602
677
|
/** 当前页面变了(null = 浏览器没开 / 不在看)。 */
|
|
603
678
|
onPage?: (page: { url: string; title: string } | null) => void;
|
|
604
|
-
}
|
|
679
|
+
};
|
|
680
|
+
const browserBackendIds = new WeakMap<BoardBackend,number>();
|
|
681
|
+
let nextBrowserBackendId=1;
|
|
682
|
+
/** The low-level public component is safe to reuse across task/backend identity changes too. */
|
|
683
|
+
export function BrowserPane(props:BrowserPaneProps) {
|
|
684
|
+
let identity=browserBackendIds.get(props.backend);
|
|
685
|
+
if(identity===undefined){identity=nextBrowserBackendId++;browserBackendIds.set(props.backend,identity);}
|
|
686
|
+
return <BrowserPaneInner key={`${identity}:${props.jobId}`} {...props}/>;
|
|
687
|
+
}
|
|
688
|
+
function BrowserPaneInner({ backend, jobId, readOnly, onQuote, onQuoteText, onPage, extension, footer, className }:BrowserPaneProps) {
|
|
605
689
|
const sink = useRef<((m: BrowserMessage) => void) | null>(null);
|
|
606
690
|
const feed = useBrowserFeed(backend, jobId, sink);
|
|
607
691
|
const { status, setStatus, streamError, ended } = feed;
|
|
@@ -649,7 +733,7 @@ export function BrowserPane({ backend, jobId, readOnly, onQuote, onQuoteText, on
|
|
|
649
733
|
return (
|
|
650
734
|
<>
|
|
651
735
|
{big && zoomable && <div className="cli-ui bp-backdrop" onClick={() => setBig(false)} />}
|
|
652
|
-
<div className={`rail-pad bp ${big && zoomable ? "bp-big" : ""}`}>
|
|
736
|
+
<div className={`rail-pad bp ${big && zoomable ? "bp-big" : ""} ${className??""}`}>
|
|
653
737
|
<div className="bp-head">
|
|
654
738
|
<span className="rail-eyebrow">浏览器 · ego lite</span>
|
|
655
739
|
<span className={`bp-badge ${running ? (human ? "human" : "ai") : ""}`}>
|
|
@@ -674,7 +758,7 @@ export function BrowserPane({ backend, jobId, readOnly, onQuote, onQuoteText, on
|
|
|
674
758
|
|
|
675
759
|
{running ? (
|
|
676
760
|
<>
|
|
677
|
-
<Viewer backend={backend} jobId={jobId} human={human && !readOnly} feed={feed} sink={sink} onPage={onPage} onQuoteText={onQuoteText} />
|
|
761
|
+
<Viewer backend={backend} jobId={jobId} human={human && !readOnly} feed={feed} sink={sink} onPage={onPage} onQuoteText={onQuoteText?(text,page)=>{setBig(false);onQuoteText(text,page);}:undefined} />
|
|
678
762
|
{!readOnly && (
|
|
679
763
|
<div className="prev-actions bp-actions">
|
|
680
764
|
{human
|
|
@@ -684,7 +768,7 @@ export function BrowserPane({ backend, jobId, readOnly, onQuote, onQuoteText, on
|
|
|
684
768
|
{onQuote && (
|
|
685
769
|
<button className="btn btn-ghost btn-sm" title="把当前页面的标题和地址带进下一条消息(在网页里复制文字后,也可以把复制的文字引用过去)"
|
|
686
770
|
disabled={!feed.latest.current.page}
|
|
687
|
-
onClick={() => { const p = feed.latest.current.page; if (p?.type === "page" && p.url) onQuote({ url: p.url, title: p.title ?? "" }); }}>
|
|
771
|
+
onClick={() => { const p = feed.latest.current.page; if (p?.type === "page" && p.url) { setBig(false); onQuote({ url: p.url, title: p.title ?? "" }); } }}>
|
|
688
772
|
{Icon.board()} 引用到对话
|
|
689
773
|
</button>
|
|
690
774
|
)}
|
|
@@ -710,6 +794,8 @@ export function BrowserPane({ backend, jobId, readOnly, onQuote, onQuoteText, on
|
|
|
710
794
|
</div>
|
|
711
795
|
</div>
|
|
712
796
|
)}
|
|
797
|
+
{extension?.(status,setStatus)}
|
|
798
|
+
{footer}
|
|
713
799
|
{shownError && <div className="bp-error">{shownError}</div>}
|
|
714
800
|
{shownError && ended && <button className="btn btn-line btn-sm" onClick={feed.reconnect}>重试</button>}
|
|
715
801
|
</div>
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// 改动:移植自 Operator 的 TaskChanges(只读部分:分支、统计、文件列表、逐文件 diff)。
|
|
2
2
|
// 合并 / 解决冲突 / 建 PR 是 Operator 里的操作,经中枢的对话任务不提供。
|
|
3
3
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
-
import type { BoardBackend } from "../core/client
|
|
5
|
-
import type { DiffFile, DiffResult } from "../core/types
|
|
4
|
+
import type { BoardBackend } from "../core/client";
|
|
5
|
+
import type { DiffFile, DiffResult } from "../core/types";
|
|
6
6
|
|
|
7
7
|
const STATUS_LABEL: Record<string, string> = { A: "新增", M: "修改", D: "删除", R: "重命名", "?": "未跟踪" };
|
|
8
8
|
const COLLAPSE_LINES = 400;
|