@spzxg/cli-ui 0.6.1 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +145 -13
  3. package/dist/cli-ui-embed-BmLN2Jjg.js +43290 -0
  4. package/dist/{cli-ui-index-BFpRZXn4.js → cli-ui-index-D4qfPHMd.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-B9tqMA-e.js → cli-ui-jszip.min-DC9898pC.js} +1 -1
  6. package/dist/{cli-ui-jszip.min-DIavJzUG.js → cli-ui-jszip.min-DpEl0G1z.js} +2 -2
  7. package/dist/{cli-ui-xlsx-BKHhqcoa.js → cli-ui-xlsx-zs7oEtPj.js} +1 -1
  8. package/dist/cli-ui.css +32 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +14 -8
  11. package/src/board/BrowserPane.tsx +160 -74
  12. package/src/board/ChangesPane.tsx +2 -2
  13. package/src/board/CliBoard.tsx +79 -50
  14. package/src/board/FileViewer.tsx +32 -22
  15. package/src/board/FilesPane.tsx +32 -15
  16. package/src/board/PanelControls.tsx +28 -0
  17. package/src/board/blocks.tsx +5 -5
  18. package/src/board/context.ts +65 -0
  19. package/src/board/fileOps.ts +26 -3
  20. package/src/board/fileWatch.ts +88 -0
  21. package/src/board/folderDownload.ts +30 -14
  22. package/src/board/panels.ts +33 -0
  23. package/src/board/textLoad.ts +2 -2
  24. package/src/board/workspace.ts +38 -9
  25. package/src/chat/CliChat.tsx +113 -53
  26. package/src/chat/Composer.tsx +68 -57
  27. package/src/chat/RunControls.tsx +99 -0
  28. package/src/chat/configuration.ts +15 -0
  29. package/src/chat/draft.ts +41 -0
  30. package/src/chat/export.ts +2 -2
  31. package/src/components.tsx +13 -0
  32. package/src/core/blocks.ts +2 -2
  33. package/src/core/callbacks.ts +8 -0
  34. package/src/core/client.ts +16 -6
  35. package/src/core/config.ts +11 -15
  36. package/src/core/conversation.ts +4 -4
  37. package/src/core/id.ts +15 -0
  38. package/src/core/identity.ts +33 -0
  39. package/src/core/provider.tsx +24 -14
  40. package/src/core/store.ts +83 -26
  41. package/src/core/types.ts +34 -0
  42. package/src/elements.tsx +135 -29
  43. package/src/embed.ts +6 -8
  44. package/src/index.ts +24 -17
  45. package/src/layout/CliSplit.tsx +1 -1
  46. package/src/msglist/CliMsgList.tsx +10 -10
  47. package/src/operator/branchFlow.ts +35 -0
  48. package/src/operator/index.ts +9 -0
  49. package/src/operator/liveStream.ts +1 -0
  50. package/src/operator/node/AgentConnect.tsx +624 -0
  51. package/src/operator/node/AppearancePanel.tsx +35 -0
  52. package/src/operator/node/BranchHistory.tsx +67 -0
  53. package/src/operator/node/BranchNavigator.tsx +36 -0
  54. package/src/operator/node/BrowserPane.tsx +128 -0
  55. package/src/operator/node/BrowserPopup.tsx +22 -0
  56. package/src/operator/node/Chat.tsx +517 -0
  57. package/src/operator/node/CommandPalette.tsx +213 -0
  58. package/src/operator/node/Composer.tsx +287 -0
  59. package/src/operator/node/InsightsView.tsx +831 -0
  60. package/src/operator/node/Layout.tsx +190 -0
  61. package/src/operator/node/Markdown.tsx +61 -0
  62. package/src/operator/node/Modal.tsx +179 -0
  63. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  64. package/src/operator/node/OnboardingWizard.tsx +250 -0
  65. package/src/operator/node/Orchestrator.tsx +634 -0
  66. package/src/operator/node/ProjectLanding.tsx +86 -0
  67. package/src/operator/node/ProjectsColumn.tsx +137 -0
  68. package/src/operator/node/ProviderAuth.tsx +561 -0
  69. package/src/operator/node/RunControls.tsx +2 -0
  70. package/src/operator/node/RunSwitch.tsx +113 -0
  71. package/src/operator/node/Services.tsx +245 -0
  72. package/src/operator/node/SessionRail.tsx +260 -0
  73. package/src/operator/node/SessionView.tsx +288 -0
  74. package/src/operator/node/SettingsView.tsx +690 -0
  75. package/src/operator/node/TaskBoard.tsx +352 -0
  76. package/src/operator/node/TaskChanges.tsx +530 -0
  77. package/src/operator/node/TaskHero.tsx +50 -0
  78. package/src/operator/node/TasksColumn.tsx +200 -0
  79. package/src/operator/node/Terminal.tsx +143 -0
  80. package/src/operator/node/Transcript.tsx +463 -0
  81. package/src/operator/node/ViewFile.tsx +79 -0
  82. package/src/operator/node/Welcome.tsx +101 -0
  83. package/src/operator/node/WorktreePrune.tsx +333 -0
  84. package/src/operator/node/agents.ts +58 -0
  85. package/src/operator/node/api.ts +40 -0
  86. package/src/operator/node/boardBackend.ts +80 -0
  87. package/src/operator/node/boardEvents.ts +22 -0
  88. package/src/operator/node/boardInteraction.ts +15 -0
  89. package/src/operator/node/boardState.ts +57 -0
  90. package/src/operator/node/boardWorkspace.ts +12 -0
  91. package/src/operator/node/branchFlow.ts +1 -0
  92. package/src/operator/node/composerDraft.ts +52 -0
  93. package/src/operator/node/fileLinks.ts +81 -0
  94. package/src/operator/node/format.ts +290 -0
  95. package/src/operator/node/github.tsx +234 -0
  96. package/src/operator/node/icons.tsx +88 -0
  97. package/src/operator/node/liveStream.ts +270 -0
  98. package/src/operator/node/modals.tsx +720 -0
  99. package/src/operator/node/navHistory.ts +70 -0
  100. package/src/operator/node/persist.ts +26 -0
  101. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  102. package/src/operator/node/protocol/agents/types.ts +394 -0
  103. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  104. package/src/operator/node/protocol/authFailure.ts +66 -0
  105. package/src/operator/node/protocol/customModels.ts +23 -0
  106. package/src/operator/node/protocol/events.ts +13 -0
  107. package/src/operator/node/protocol/features.ts +70 -0
  108. package/src/operator/node/protocol/internalUsage.ts +1 -0
  109. package/src/operator/node/protocol/promptLimits.ts +52 -0
  110. package/src/operator/node/protocol/storageRetention.ts +13 -0
  111. package/src/operator/node/protocol/types.ts +332 -0
  112. package/src/operator/node/protocol/usageLimit.ts +72 -0
  113. package/src/operator/node/shared.tsx +89 -0
  114. package/src/operator/node/transcriptModel.ts +129 -0
  115. package/src/operator/node/types.ts +296 -0
  116. package/src/operator/node/useGlobalEvents.ts +97 -0
  117. package/src/operator/node/useOrchestrator.ts +696 -0
  118. package/src/operator/node/usePrefs.ts +114 -0
  119. package/src/operator/node/useRecaps.ts +91 -0
  120. package/src/operator/node/useTaskStream.ts +279 -0
  121. package/src/operator.base.css +1798 -0
  122. package/src/operator.css +186 -16
  123. package/src/styles.css +51 -0
  124. package/src/ui/Markdown.tsx +7 -2
  125. package/src/ui/Popover.tsx +77 -0
  126. package/src/ui/TextBlock.tsx +3 -3
  127. package/src/ui/Transcript.tsx +10 -8
  128. package/src/ui/fileLinks.ts +6 -0
  129. package/src/ui/format.ts +1 -1
  130. package/src/ui/icons.tsx +1 -0
  131. package/src/ui/shared.tsx +1 -1
  132. package/src/ui/transcriptModel.ts +2 -2
  133. package/dist/cli-ui-embed-Dk2QGHxn.js +0 -41146
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-Dk2QGHxn.js";
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.1",
3
+ "version": "0.6.5",
4
4
  "type": "module",
5
- "description": "任务中心(cli-task-center)对话任务的前端积木:cli-msglist 消息对话列表、cli-chat 对话,数据全部来自 center 服务。",
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": "^19.0.2",
38
- "@types/react-dom": "^19.0.2",
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.js";
14
- import { jitter } from "../core/store.js";
15
- import type { BrowserFramesState, BrowserMessage, BrowserStatus } from "../core/types.js";
16
- import { Icon } from "../ui/icons.js";
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
- let final = false;
137
- stop = backend.browserLive!(jobId, {
138
- onViewer: (id, isReady) => { attempt = 0; setViewer(id); setReady(isReady); },
139
- onStatus: (s) => { attempt = 0; setStreamError(null); setStatus(s); },
140
- onMessage: deliver,
141
- onFrames: setFrames,
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 === "refused") { final = true; setEnded(end.detail || "执行节点拒绝了浏览器连接"); }
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
- setViewer(null);
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 === 404) { setEnded(err.message); return; }
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
- stop = backend.browserEvents(jobId, {
172
- onStatus: (s) => { attempt = 0; setStreamError(null); setStatus(s); },
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
- if (err && err.status === 404) { setEnded(err.message); return; }
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
- stop = backend.browserFrames(jobId, {
203
- onViewer: (id) => { attempt = 0; setViewer(id); setReady(true); setFrames({ state: "live" }); },
204
- onMessage: deliver,
205
- onEnd: (end) => { if (end.reason !== "client_closed") again(end.reason === "refused" ? end.detail : undefined); },
206
- onClose: (err) => { if (err) again(err.message); },
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 [fallbackSrc, setFallbackSrc] = useState<string | null>(null);
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) { setFallbackSrc(`data:${type};base64,${data}`); return; }
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
- const release = () => {
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
- }, [point, send]);
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: () => { touch.current = null; pressed.current = null; },
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
- ? (fallbackSrc ? <img ref={imgRef} src={fallbackSrc} alt={label} draggable={false} {...common} /> : null)
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
- setCopied(m.text);
426
- void writeClipboard(m.text).then((ok) => flash(ok ? "已复制到你的剪贴板" : "没能写入剪贴板(浏览器不允许),可以在网页里选中后再试", 6000));
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 inflight = useRef(false);
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
- inflight.current = true;
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 = false; flush(); });
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
- send({ type: "dialog", accept, ...(dialog.dialogType === "prompt" && accept ? { promptText } : {}) });
530
- setDialog(null);
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
- export function BrowserPane({ backend, jobId, readOnly, onQuote, onQuoteText, onPage }: {
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.js";
5
- import type { DiffFile, DiffResult } from "../core/types.js";
4
+ import type { BoardBackend } from "../core/client";
5
+ import type { DiffFile, DiffResult } from "../core/types";
6
6
 
7
7
  const STATUS_LABEL: Record<string, string> = { A: "新增", M: "修改", D: "删除", R: "重命名", "?": "未跟踪" };
8
8
  const COLLAPSE_LINES = 400;