@spzxg/cli-ui 0.6.2 → 0.6.6

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 +49 -0
  2. package/README.md +165 -19
  3. package/dist/cli-ui-embed-BAqonuoD.js +45952 -0
  4. package/dist/{cli-ui-index-BY5i3wId.js → cli-ui-index-b8wF7wrN.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-m01fexHW.js → cli-ui-jszip.min-B0uLrCHg.js} +2 -2
  6. package/dist/{cli-ui-jszip.min-BZsUwI-s.js → cli-ui-jszip.min-ggP8OokP.js} +1 -1
  7. package/dist/{cli-ui-xlsx-CpWicJLT.js → cli-ui-xlsx-DEDdHrJJ.js} +1 -1
  8. package/dist/cli-ui.css +32 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +17 -10
  11. package/src/board/BrowserPane.tsx +346 -115
  12. package/src/board/ChangesPane.tsx +2 -2
  13. package/src/board/CliBoard.tsx +79 -50
  14. package/src/board/FileViewer.tsx +54 -31
  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 +94 -0
  21. package/src/board/folderDownload.ts +30 -14
  22. package/src/board/frameQuote.ts +2 -2
  23. package/src/board/panels.ts +33 -0
  24. package/src/board/textLoad.ts +9 -7
  25. package/src/board/workspace.ts +53 -14
  26. package/src/chat/CliChat.tsx +93 -40
  27. package/src/chat/Composer.tsx +44 -42
  28. package/src/chat/RunControls.tsx +99 -0
  29. package/src/chat/configuration.ts +15 -0
  30. package/src/chat/draft.ts +41 -0
  31. package/src/chat/export.ts +2 -2
  32. package/src/components.tsx +13 -0
  33. package/src/core/blocks.ts +17 -6
  34. package/src/core/callbacks.ts +8 -0
  35. package/src/core/client.ts +103 -19
  36. package/src/core/conversation.ts +4 -3
  37. package/src/core/id.ts +15 -0
  38. package/src/core/identity.ts +33 -0
  39. package/src/core/provider.tsx +31 -14
  40. package/src/core/store.ts +151 -53
  41. package/src/core/types.ts +64 -9
  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/layout/split.ts +67 -30
  47. package/src/msglist/CliMsgList.tsx +19 -13
  48. package/src/operator/branchFlow.ts +35 -0
  49. package/src/operator/index.ts +9 -0
  50. package/src/operator/liveStream.ts +1 -0
  51. package/src/operator/node/AgentConnect.tsx +624 -0
  52. package/src/operator/node/AppearancePanel.tsx +35 -0
  53. package/src/operator/node/BranchHistory.tsx +67 -0
  54. package/src/operator/node/BranchNavigator.tsx +36 -0
  55. package/src/operator/node/BrowserPane.tsx +128 -0
  56. package/src/operator/node/BrowserPopup.tsx +22 -0
  57. package/src/operator/node/Chat.tsx +517 -0
  58. package/src/operator/node/CommandPalette.tsx +213 -0
  59. package/src/operator/node/Composer.tsx +287 -0
  60. package/src/operator/node/InsightsView.tsx +831 -0
  61. package/src/operator/node/Layout.tsx +190 -0
  62. package/src/operator/node/Markdown.tsx +61 -0
  63. package/src/operator/node/Modal.tsx +179 -0
  64. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  65. package/src/operator/node/OnboardingWizard.tsx +250 -0
  66. package/src/operator/node/Orchestrator.tsx +634 -0
  67. package/src/operator/node/ProjectLanding.tsx +86 -0
  68. package/src/operator/node/ProjectsColumn.tsx +137 -0
  69. package/src/operator/node/ProviderAuth.tsx +561 -0
  70. package/src/operator/node/RunControls.tsx +2 -0
  71. package/src/operator/node/RunSwitch.tsx +113 -0
  72. package/src/operator/node/Services.tsx +245 -0
  73. package/src/operator/node/SessionRail.tsx +260 -0
  74. package/src/operator/node/SessionView.tsx +288 -0
  75. package/src/operator/node/SettingsView.tsx +690 -0
  76. package/src/operator/node/TaskBoard.tsx +352 -0
  77. package/src/operator/node/TaskChanges.tsx +530 -0
  78. package/src/operator/node/TaskHero.tsx +50 -0
  79. package/src/operator/node/TasksColumn.tsx +200 -0
  80. package/src/operator/node/Terminal.tsx +143 -0
  81. package/src/operator/node/Transcript.tsx +468 -0
  82. package/src/operator/node/ViewFile.tsx +79 -0
  83. package/src/operator/node/Welcome.tsx +101 -0
  84. package/src/operator/node/WorktreePrune.tsx +333 -0
  85. package/src/operator/node/agents.ts +58 -0
  86. package/src/operator/node/api.ts +40 -0
  87. package/src/operator/node/boardBackend.ts +82 -0
  88. package/src/operator/node/boardEvents.ts +22 -0
  89. package/src/operator/node/boardInteraction.ts +15 -0
  90. package/src/operator/node/boardState.ts +57 -0
  91. package/src/operator/node/boardWorkspace.ts +12 -0
  92. package/src/operator/node/branchFlow.ts +1 -0
  93. package/src/operator/node/composerDraft.ts +52 -0
  94. package/src/operator/node/fileLinks.ts +81 -0
  95. package/src/operator/node/format.ts +290 -0
  96. package/src/operator/node/github.tsx +234 -0
  97. package/src/operator/node/icons.tsx +88 -0
  98. package/src/operator/node/liveStream.ts +270 -0
  99. package/src/operator/node/modals.tsx +720 -0
  100. package/src/operator/node/navHistory.ts +70 -0
  101. package/src/operator/node/persist.ts +26 -0
  102. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  103. package/src/operator/node/protocol/agents/types.ts +394 -0
  104. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  105. package/src/operator/node/protocol/authFailure.ts +66 -0
  106. package/src/operator/node/protocol/customModels.ts +23 -0
  107. package/src/operator/node/protocol/events.ts +13 -0
  108. package/src/operator/node/protocol/features.ts +70 -0
  109. package/src/operator/node/protocol/internalUsage.ts +1 -0
  110. package/src/operator/node/protocol/promptLimits.ts +52 -0
  111. package/src/operator/node/protocol/storageRetention.ts +13 -0
  112. package/src/operator/node/protocol/types.ts +332 -0
  113. package/src/operator/node/protocol/usageLimit.ts +72 -0
  114. package/src/operator/node/shared.tsx +89 -0
  115. package/src/operator/node/transcriptModel.ts +129 -0
  116. package/src/operator/node/types.ts +296 -0
  117. package/src/operator/node/useGlobalEvents.ts +97 -0
  118. package/src/operator/node/useOrchestrator.ts +696 -0
  119. package/src/operator/node/usePrefs.ts +114 -0
  120. package/src/operator/node/useRecaps.ts +91 -0
  121. package/src/operator/node/useTaskStream.ts +280 -0
  122. package/src/operator.base.css +1806 -0
  123. package/src/operator.css +194 -16
  124. package/src/styles.css +49 -1
  125. package/src/ui/Markdown.tsx +7 -2
  126. package/src/ui/Popover.tsx +82 -0
  127. package/src/ui/TextBlock.tsx +3 -3
  128. package/src/ui/Transcript.tsx +17 -10
  129. package/src/ui/fileLinks.ts +6 -0
  130. package/src/ui/format.ts +1 -1
  131. package/src/ui/shared.tsx +1 -1
  132. package/src/ui/transcriptModel.ts +2 -2
  133. package/dist/cli-ui-embed-6IJ_jRVh.js +0 -41223
@@ -9,14 +9,21 @@
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" }>;
20
+ type Tabs = Extract<BrowserMessage, { type: "tabs" }>;
21
+ type Target = { targetId: string; targetEpoch: number };
22
+ function messageTarget(message: BrowserMessage): Target | null {
23
+ return typeof message.targetId === "string" && message.targetId.length > 0 && Number.isSafeInteger(message.targetEpoch) && message.targetEpoch! >= 0
24
+ ? { targetId: message.targetId, targetEpoch: message.targetEpoch! } : null;
25
+ }
26
+ const sameTarget = (a: Target | null, b: Target | null) => !!a && !!b && a.targetId === b.targetId && a.targetEpoch === b.targetEpoch;
20
27
 
21
28
  const CAPABILITY_LABEL: Record<string, string> = { config: "任务配置", manual: "手动打开过", ai: "AI 自行启用" };
22
29
  const SPECIAL_KEYS: Record<string, number> = {
@@ -90,6 +97,7 @@ interface BrowserFeed {
90
97
  /** 发输入用的 viewer id;ready = 输入能送到节点。 */
91
98
  viewer: string | null;
92
99
  ready: boolean;
100
+ generation: number;
93
101
  frames: BrowserFramesState;
94
102
  paused: boolean;
95
103
  reconnect: () => void;
@@ -97,7 +105,7 @@ interface BrowserFeed {
97
105
  * 最近的页面、弹窗、画面:中枢在连上时就推过来,那时画面区(要等状态是「已打开」才挂上)可能还没挂好,
98
106
  * 挂上时从这里补上——否则地址栏一直是空的,要等下一次换页。
99
107
  */
100
- latest: MutableRefObject<{ page?: BrowserMessage; dialog?: BrowserMessage | null; frame?: BrowserMessage }>;
108
+ latest: MutableRefObject<{ generation: number; protocol: boolean; tabs?: Tabs; page?: BrowserMessage; dialog?: BrowserMessage | null; frame?: BrowserMessage }>;
101
109
  }
102
110
 
103
111
  /**
@@ -112,15 +120,44 @@ function useBrowserFeed(backend: BoardBackend, jobId: string, sink: MutableRefOb
112
120
  const [ended, setEnded] = useState<string | null>(null);
113
121
  const [viewer, setViewer] = useState<string | null>(null);
114
122
  const [ready, setReady] = useState(false);
123
+ const [generation, setGeneration] = useState(0);
115
124
  const [frames, setFrames] = useState<BrowserFramesState>({ state: "idle" });
116
125
  const [retry, setRetry] = useState(0);
117
126
  const reconnect = useCallback(() => { setEnded(null); setRetry((n) => n + 1); }, []);
118
- const latest = useRef<BrowserFeed["latest"]["current"]>({});
127
+ const latest = useRef<BrowserFeed["latest"]["current"]>({ generation: 0, protocol: false });
128
+ const beginViewer = useCallback(() => {
129
+ const previous = latest.current;
130
+ // Epochs belong to one stream. A recovered viewer may start at epoch 1 again.
131
+ // Legacy servers retain their dialog while reconnecting, as before.
132
+ const next = previous.generation + 1;
133
+ latest.current = previous.protocol ? { generation: next, protocol: true } : { ...previous, generation: next };
134
+ setGeneration(next);
135
+ }, []);
119
136
  const deliver = useCallback((m: BrowserMessage) => {
120
- if (m.type === "page") latest.current.page = m;
121
- else if (m.type === "dialog") latest.current.dialog = m;
122
- else if (m.type === "dialog_closed") latest.current.dialog = null;
123
- else if (m.type === "frame") latest.current.frame = m;
137
+ const cache = latest.current;
138
+ const target = messageTarget(m);
139
+ const current = cache.page ? messageTarget(cache.page) : null;
140
+ if (target) cache.protocol = true;
141
+ if (m.type === "tabs") {
142
+ cache.protocol = true; cache.tabs = m;
143
+ if (m.activeId === null) { delete cache.page; delete cache.frame; cache.dialog = null; }
144
+ }
145
+ if (m.type === "page") {
146
+ if (cache.protocol && !target) return;
147
+ if (target) {
148
+ cache.protocol = true;
149
+ if (current && (target.targetEpoch < current.targetEpoch || (target.targetEpoch === current.targetEpoch && target.targetId !== current.targetId))) return;
150
+ if (!sameTarget(current, target)) { delete cache.frame; cache.dialog = null; }
151
+ }
152
+ cache.page = m;
153
+ }
154
+ // A frame can arrive late or precede its page announcement. Neither is safe
155
+ // to display or use for coordinate input in the current page.
156
+ else if (cache.protocol && m.type !== "tabs" && m.type !== "error" && (!target || !sameTarget(current, target))) return;
157
+ else if (target && current && !sameTarget(current, target)) return;
158
+ if (m.type === "dialog") cache.dialog = m;
159
+ else if (m.type === "dialog_closed") cache.dialog = null;
160
+ else if (m.type === "frame") cache.frame = m;
124
161
  sink.current?.(m);
125
162
  }, [sink]);
126
163
 
@@ -133,32 +170,45 @@ function useBrowserFeed(backend: BoardBackend, jobId: string, sink: MutableRefOb
133
170
  let timer: ReturnType<typeof setTimeout> | undefined;
134
171
  let attempt = 0;
135
172
  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,
173
+ if (!alive) return;
174
+ let finished = false;
175
+ let seenViewer = false;
176
+ let stopThis: (() => void) | null = null;
177
+ const retire = () => {
178
+ if (!alive || finished) return false;
179
+ finished = true;
180
+ stopThis?.();
181
+ setViewer(null); setReady(false);
182
+ return true;
183
+ };
184
+ stopThis = backend.browserLive!(jobId, {
185
+ onViewer: (id, isReady) => { if (!alive || finished) return; if (!seenViewer) { seenViewer = true; beginViewer(); } attempt = 0; setViewer(id); setReady(isReady); },
186
+ onStatus: (s) => { if (!alive || finished) return; attempt = 0; setStreamError(null); setStatus(s); },
187
+ onMessage: (message) => { if (alive && !finished) deliver(message); },
188
+ onFrames: (state) => { if (alive && !finished) setFrames(state); },
142
189
  onEnd: (end) => {
143
- if (end.reason === "refused") { final = true; setEnded(end.detail || "执行节点拒绝了浏览器连接"); }
190
+ if (end.reason !== "refused" && end.reason !== "client_closed") return;
191
+ if (!retire()) return;
192
+ setFrames({ state: "idle" });
193
+ if (end.reason === "refused") setEnded(end.detail || "执行节点拒绝了浏览器连接");
144
194
  },
145
195
  onClose: (err) => {
146
- setViewer(null);
147
- setReady(false);
148
- if (!alive || final) return;
196
+ if (!retire()) return;
149
197
  if (isMissingRoute(err)) { setMode("legacy"); return; }
150
198
  // 对话不存在:重连也没用。
151
- if (err && err.status === 404) { setEnded(err.message); return; }
199
+ if (err && [400,404,405,410,501].includes(err.status)) { setEnded(err.message); return; }
152
200
  if (err) setStreamError(err.message);
153
201
  // 409 还没派发:隔几秒再问;401 / 403 多半是宿主会话过期,30 秒后再试(续上会话就自己恢复);
154
202
  // 其余(断线、中枢重启、人太多)按退避重连。
155
203
  timer = setTimeout(connect, nextTry(err, attempt++));
156
204
  },
157
205
  });
206
+ stop = stopThis;
207
+ if (finished) stopThis();
158
208
  };
159
209
  connect();
160
210
  return () => { alive = false; stop?.(); if (timer) clearTimeout(timer); };
161
- }, [backend, jobId, mode, visible, retry, deliver]);
211
+ }, [backend, jobId, mode, visible, retry, deliver, beginViewer]);
162
212
 
163
213
  // ── 旧版:状态一条(页面在后台时也断开,切回来再连:人多时少占中枢和节点的连接) ──
164
214
  useEffect(() => {
@@ -168,15 +218,23 @@ function useBrowserFeed(backend: BoardBackend, jobId: string, sink: MutableRefOb
168
218
  let timer: ReturnType<typeof setTimeout> | undefined;
169
219
  let attempt = 0;
170
220
  const connect = () => {
171
- stop = backend.browserEvents(jobId, {
172
- onStatus: (s) => { attempt = 0; setStreamError(null); setStatus(s); },
221
+ if (!alive) return;
222
+ let finished = false;
223
+ let stopThis: (() => void) | null = null;
224
+ stopThis = backend.browserEvents(jobId, {
225
+ onStatus: (s) => { if (!alive || finished) return; attempt = 0; setStreamError(null); setStatus(s); },
226
+ onError: (error) => { if (alive && !finished) setStreamError(error.message); },
173
227
  onClose: (err) => {
174
- if (!alive) return;
175
- if (err && err.status === 404) { setEnded(err.message); return; }
228
+ if (!alive || finished) return;
229
+ finished = true;
230
+ stopThis?.();
231
+ if (err && [400,404,405,410,501].includes(err.status)) { setEnded(err.message); return; }
176
232
  if (err) setStreamError(err.message);
177
233
  timer = setTimeout(connect, nextTry(err, attempt++));
178
234
  },
179
235
  });
236
+ stop = stopThis;
237
+ if (finished) stopThis();
180
238
  };
181
239
  connect();
182
240
  return () => { alive = false; stop?.(); if (timer) clearTimeout(timer); };
@@ -190,34 +248,51 @@ function useBrowserFeed(backend: BoardBackend, jobId: string, sink: MutableRefOb
190
248
  let stop: (() => void) | null = null;
191
249
  let timer: ReturnType<typeof setTimeout> | undefined;
192
250
  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
251
  const connect = () => {
252
+ if (!alive) return;
253
+ let finished = false;
254
+ let seenViewer = false;
255
+ let stopThis: (() => void) | null = null;
256
+ const again = (detail?: string) => {
257
+ // A stream can emit an end event and then close; retire it only once.
258
+ if (!alive || finished) return;
259
+ finished = true;
260
+ stopThis?.();
261
+ setViewer(null);
262
+ setReady(false);
263
+ setFrames({ state: "retrying", ...(detail ? { detail } : {}) });
264
+ timer = setTimeout(connect, retryDelay(attempt++));
265
+ };
201
266
  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); },
267
+ stopThis = backend.browserFrames(jobId, {
268
+ onViewer: (id) => { if (!alive || finished) return; if (!seenViewer) { seenViewer = true; beginViewer(); } attempt = 0; setViewer(id); setReady(true); setFrames({ state: "live" }); },
269
+ onMessage: (message) => { if (alive && !finished) deliver(message); },
270
+ onEnd: (end) => {
271
+ if (end.reason !== "client_closed") { again(end.reason === "refused" ? end.detail : undefined); return; }
272
+ if (!alive || finished) return;
273
+ finished = true;
274
+ stopThis?.();
275
+ setViewer(null); setReady(false); setFrames({ state: "idle" });
276
+ },
277
+ // Clean EOF also invalidates the old viewer and needs a reconnect.
278
+ onClose: (err) => again(err?.message),
207
279
  });
280
+ stop = stopThis;
281
+ // Custom transports may close synchronously before returning cleanup.
282
+ if (finished) stopThis();
208
283
  };
209
284
  connect();
210
285
  return () => { alive = false; stop?.(); if (timer) clearTimeout(timer); };
211
- }, [backend, jobId, legacyRunning, retry, deliver]); // eslint-disable-line react-hooks/exhaustive-deps
286
+ }, [backend, jobId, legacyRunning, retry, deliver, beginViewer]); // eslint-disable-line react-hooks/exhaustive-deps
212
287
 
213
288
  // 浏览器关了:上一个页面的地址、弹窗、画面都作废,重新打开时不先闪一下旧画面。
214
289
  const running = status?.state === "running";
215
- useEffect(() => { if (!running) latest.current = {}; }, [running]);
290
+ useEffect(() => { if (status && !running) latest.current = { generation: latest.current.generation, protocol: false }; }, [running, status]);
216
291
 
217
- return { status, setStatus, streamError, ended, viewer, ready, frames, paused: !visible, reconnect, latest };
292
+ return { status, setStatus, streamError, ended, viewer, ready, generation, frames, paused: !visible, reconnect, latest };
218
293
  }
219
294
 
220
- interface ScreenHandle { draw(base64: string, meta: FrameMeta | null): void }
295
+ interface ScreenHandle { draw(base64: string, meta: FrameMeta | null): void; clear(): void }
221
296
 
222
297
  /** base64 → 字节。 */
223
298
  function decodeBase64(data: string): Uint8Array {
@@ -234,27 +309,38 @@ function decodeBase64(data: string): Uint8Array {
234
309
  * · 输入用指针事件:鼠标照原样;触屏点一下 = 点击,拖动 = 滚动;接管时滚轮 / 触摸只作用于画面,不滚动宿主页面。
235
310
  * · 画面按比例缩放,适应容器宽高(窄的侧栏、弹窗、整页都可以)。
236
311
  */
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) {
312
+ const Screen = forwardRef<ScreenHandle, { human: boolean; inputEpoch: number; hidden: boolean; label: string; send: (msg: Record<string, unknown>) => void; onFocusKeys: () => void }>(
313
+ function Screen({ human, inputEpoch, hidden, label, send, onFocusKeys }, ref) {
239
314
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
240
315
  const imgRef = useRef<HTMLImageElement | null>(null);
241
316
  const meta = useRef<FrameMeta | null>(null);
242
317
  const seq = useRef({ next: 0, drawn: 0 });
318
+ const imageGeneration = useRef(0);
243
319
  const [fallback] = useState(() => typeof createImageBitmap !== "function");
244
- const [fallbackSrc, setFallbackSrc] = useState<string | null>(null);
320
+ const [fallbackFrame, setFallbackFrame] = useState<{src:string;meta:FrameMeta|null} | null>(null);
245
321
 
246
322
  useImperativeHandle(ref, () => ({
323
+ clear() {
324
+ imageGeneration.current++;
325
+ seq.current = { next: 0, drawn: 0 };
326
+ meta.current = null;
327
+ setFallbackFrame(null);
328
+ const canvas = canvasRef.current;
329
+ // Resetting dimensions also clears its pixels without requiring a 2d context.
330
+ if (canvas) canvas.width = canvas.width;
331
+ },
247
332
  draw(data, frameMeta) {
248
- meta.current = frameMeta;
249
333
  const type = data.startsWith("iVBOR") ? "image/png" : "image/jpeg";
250
- if (fallback) { setFallbackSrc(`data:${type};base64,${data}`); return; }
334
+ if (fallback) { setFallbackFrame({src:`data:${type};base64,${data}`,meta:frameMeta}); return; }
251
335
  const n = ++seq.current.next;
336
+ const generation = imageGeneration.current;
252
337
  let bytes: Uint8Array;
253
338
  try { bytes = decodeBase64(data); } catch { return; }
254
339
  createImageBitmap(new Blob([bytes as BlobPart], { type })).then((bitmap) => {
255
340
  const canvas = canvasRef.current;
256
- if (!canvas || n < seq.current.drawn) { bitmap.close(); return; }
341
+ if (!canvas || generation !== imageGeneration.current || n < seq.current.drawn) { bitmap.close(); return; }
257
342
  seq.current.drawn = n;
343
+ meta.current = frameMeta;
258
344
  if (canvas.width !== bitmap.width || canvas.height !== bitmap.height) { canvas.width = bitmap.width; canvas.height = bitmap.height; }
259
345
  canvas.getContext("2d")?.drawImage(bitmap, 0, 0);
260
346
  bitmap.close();
@@ -262,11 +348,18 @@ const Screen = forwardRef<ScreenHandle, { human: boolean; hidden: boolean; label
262
348
  },
263
349
  }), [fallback]);
264
350
 
351
+ // Identical image bytes may accompany updated CSS viewport metadata and
352
+ // need not trigger another load event in the fallback image element.
353
+ useEffect(()=>{const img=imgRef.current;if(fallbackFrame&&img?.complete&&img.naturalWidth>0)meta.current=fallbackFrame.meta;},[fallbackFrame]);
354
+
265
355
  const el = () => (fallback ? imgRef.current : canvasRef.current);
266
356
  /** 屏幕坐标 → 页面 CSS 像素(按帧自带的页面尺寸换算)。 */
267
357
  const point = useCallback((clientX: number, clientY: number) => {
268
358
  const node = el();
269
359
  if (!node) return null;
360
+ // Network arrival is not an interactive image: never map a click onto
361
+ // the blank initial canvas or an image that has not decoded yet.
362
+ if (fallback ? !(node instanceof HTMLImageElement && node.complete && node.naturalWidth > 0) : seq.current.drawn === 0) return null;
270
363
  const r = node.getBoundingClientRect();
271
364
  if (!r.width || !r.height) return null;
272
365
  const natural = node instanceof HTMLCanvasElement ? { w: node.width, h: node.height } : { w: node.naturalWidth, h: node.naturalHeight };
@@ -290,13 +383,13 @@ const Screen = forwardRef<ScreenHandle, { human: boolean; hidden: boolean; label
290
383
  };
291
384
 
292
385
  const onPointerDown = (e: React.PointerEvent) => {
293
- if (!human) return;
386
+ if (!human || !point(e.clientX,e.clientY)) return;
294
387
  e.preventDefault();
295
- onFocusKeys();
296
388
  if (e.pointerType === "touch") {
297
389
  touch.current = { id: e.pointerId, x: e.clientX, y: e.clientY, lastX: e.clientX, lastY: e.clientY, moved: false };
298
390
  return;
299
391
  }
392
+ onFocusKeys();
300
393
  (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
301
394
  pressed.current = MOUSE_BUTTONS[e.button] ?? "left";
302
395
  mouse("mousePressed", e, { button: pressed.current, clickCount: e.detail || 1 });
@@ -326,29 +419,35 @@ const Screen = forwardRef<ScreenHandle, { human: boolean; hidden: boolean; label
326
419
  const t = touch.current;
327
420
  if (e.pointerType === "touch" && t && t.id === e.pointerId) {
328
421
  touch.current = null;
329
- if (!t.moved) {
422
+ if (!t.moved && point(e.clientX, e.clientY)) {
423
+ // Send before focusing: the mobile keyboard can resize/reposition the frame.
330
424
  // 触屏点一下 = 鼠标点击
331
425
  mouse("mousePressed", e, { button: "left", clickCount: 1 });
332
426
  mouse("mouseReleased", e, { button: "left", clickCount: 1 });
427
+ onFocusKeys();
333
428
  }
334
429
  return;
335
430
  }
431
+ if (e.pointerType === "touch" || !pressed.current) return;
336
432
  pressed.current = null;
337
433
  mouse("mouseReleased", e, { button: MOUSE_BUTTONS[e.button] ?? "left", clickCount: e.detail || 1 });
338
434
  };
339
435
 
340
- // 按住鼠标时切走窗口 / 标签页:松手事件收不到,远端会一直「按着」(一直在拖、选中)。补发一次松开。
341
- useEffect(() => {
342
- const release = () => {
436
+ // A new control/viewer epoch cannot inherit a drag from the previous one.
437
+ useEffect(()=>{touch.current=null;pressed.current=null;lastPoint.current=null;},[human,inputEpoch]);
438
+ const release = useCallback(() => {
439
+ touch.current=null;
343
440
  if (!pressed.current || !lastPoint.current) return;
344
441
  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 });
442
+ if (p && humanRef.current) send({ type: "mouse", event: "mouseReleased", ...p, button: pressed.current, clickCount: 1, modifiers: 0 });
346
443
  pressed.current = null;
347
- };
444
+ },[point,send]);
445
+ // Cancellation/window blur must release the remote pointer as well.
446
+ useEffect(() => {
348
447
  window.addEventListener("blur", release);
349
448
  document.addEventListener("visibilitychange", release);
350
449
  return () => { window.removeEventListener("blur", release); document.removeEventListener("visibilitychange", release); };
351
- }, [point, send]);
450
+ }, [release]);
352
451
 
353
452
  // 滚轮:React 的 onWheel 是被动监听,拦不住宿主页面滚动,所以用原生监听(passive: false)。
354
453
  useEffect(() => {
@@ -369,13 +468,13 @@ const Screen = forwardRef<ScreenHandle, { human: boolean; hidden: boolean; label
369
468
  className: `bp-frame ${human ? "live" : ""}`,
370
469
  style: { display: hidden ? "none" : undefined, touchAction: human ? "none" : "auto" } as React.CSSProperties,
371
470
  onPointerDown, onPointerMove, onPointerUp,
372
- onPointerCancel: () => { touch.current = null; pressed.current = null; },
471
+ onPointerCancel: release,
373
472
  onContextMenu: (e: React.MouseEvent) => { if (human) e.preventDefault(); },
374
473
  role: "img" as const,
375
474
  "aria-label": label,
376
475
  };
377
476
  return fallback
378
- ? (fallbackSrc ? <img ref={imgRef} src={fallbackSrc} alt={label} draggable={false} {...common} /> : null)
477
+ ? (fallbackFrame ? <img ref={imgRef} src={fallbackFrame.src} onLoad={()=>{meta.current=fallbackFrame.meta;}} alt={label} draggable={false} {...common} /> : null)
379
478
  : <canvas ref={canvasRef} {...common} />;
380
479
  },
381
480
  );
@@ -388,14 +487,23 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
388
487
  const screenRef = useRef<ScreenHandle | null>(null);
389
488
  const keysRef = useRef<HTMLTextAreaElement | null>(null);
390
489
  const [hasFrame, setHasFrame] = useState(false);
490
+ const [tabs, setTabs] = useState<Tabs | null>(null);
491
+ const [switching, setSwitching] = useState<string | null>(null);
492
+ const targetRef = useRef<Target | null>(null);
493
+ const frameReady = useRef(false);
494
+ const pendingTarget = useRef<string | null>(null);
495
+ const pageGeneration = useRef(feed.latest.current.generation);
391
496
  const [page, setPage] = useState<{ url: string; title: string }>({ url: "", title: "" });
392
497
  const [addr, setAddr] = useState("");
393
498
  const [editing, setEditing] = useState(false);
394
499
  const [notice, setNotice] = useState<string | null>(null);
395
500
  const [dialog, setDialog] = useState<Dialog | null>(null);
396
501
  const [promptText, setPromptText] = useState("");
502
+ const [dialogSending, setDialogSending] = useState(false);
503
+ const pendingDialog = useRef<Record<string, unknown> | null>(null);
397
504
  // 在网页里复制出来的文字:除了写进剪贴板,还可以「引用到对话」。
398
- const [copied, setCopied] = useState<string | null>(null);
505
+ const [copied, setCopied] = useState<{text:string;page:{url:string;title:string}} | null>(null);
506
+ const clipboardSequence=useRef(0);
399
507
  // 画面没有响应:接管时发了操作,之后 UNRESPONSIVE_MS 一帧都没来。
400
508
  const [stuck, setStuck] = useState(false);
401
509
  const lastInputAt = useRef(0);
@@ -408,32 +516,6 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
408
516
  }, []);
409
517
  useEffect(() => () => { if (noticeTimer.current) clearTimeout(noticeTimer.current); }, []);
410
518
 
411
- // 连接上来的消息:画面直接画,其余更新界面。挂上时先补上挂上之前到的页面 / 弹窗 / 最后一帧。
412
- const latest = feed.latest;
413
- useEffect(() => {
414
- const handle = (m: BrowserMessage) => {
415
- if (m.type === "frame" && m.data) {
416
- screenRef.current?.draw(m.data, m.metadata ?? null);
417
- lastFrameAt.current = Date.now();
418
- setStuck(false);
419
- setHasFrame(true);
420
- } 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);
424
- else if (m.type === "clipboard") {
425
- setCopied(m.text);
426
- void writeClipboard(m.text).then((ok) => flash(ok ? "已复制到你的剪贴板" : "没能写入剪贴板(浏览器不允许),可以在网页里选中后再试", 6000));
427
- }
428
- };
429
- const { page: lastPage, dialog: lastDialog, frame: lastFrame } = latest.current;
430
- if (lastPage) handle(lastPage);
431
- if (lastDialog) handle(lastDialog);
432
- if (lastFrame) handle(lastFrame);
433
- sink.current = handle;
434
- return () => { sink.current = null; };
435
- }, [sink, flash, latest]);
436
-
437
519
  useEffect(() => { if (!editing) setAddr(page.url); }, [page.url, editing]);
438
520
  const onPageRef = useRef(onPage);
439
521
  onPageRef.current = onPage;
@@ -451,16 +533,35 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
451
533
 
452
534
  // 输入按顺序发:一个请求在路上时攒着,回来后一次发完(鼠标移动只留最后一个,连续的滚轮合并)。
453
535
  const viewerRef = useRef<string | null>(null);
454
- viewerRef.current = feed.ready ? feed.viewer : null;
455
536
  const queue = useRef<Array<Record<string, unknown>>>([]);
456
- const inflight = useRef(false);
537
+ const controlRef = useRef(human); controlRef.current=human;
538
+ const mountedRef = useRef(true);
539
+ useEffect(()=>{mountedRef.current=true;return()=>{mountedRef.current=false;queue.current=[];};},[]);
540
+ useEffect(() => { if (!feed.ready || !human) { pendingDialog.current = null; setDialogSending(false); } }, [feed.ready, feed.viewer, human]);
541
+ const inflight = useRef<{ viewer:string; epoch:number } | null>(null);
542
+ const inputEpoch = useRef(0);
543
+ const previousControl = useRef(human);
544
+ const nextViewer=feed.ready?feed.viewer:null;
545
+ if(viewerRef.current!==nextViewer||previousControl.current!==human){
546
+ inputEpoch.current++;queue.current=[];inflight.current=null;
547
+ }
548
+ previousControl.current=human;
549
+ viewerRef.current=nextViewer;
457
550
  const reconnect = feed.reconnect;
458
551
  const flush = useCallback(() => {
459
552
  const viewer = viewerRef.current;
460
- if (inflight.current || !viewer || !queue.current.length) return;
553
+ if (!mountedRef.current || !controlRef.current || inflight.current || !viewer || !queue.current.length) return;
461
554
  const batch = queue.current.splice(0, 50);
462
- inflight.current = true;
555
+ const token={viewer,epoch:inputEpoch.current};
556
+ inflight.current = token;
463
557
  backend.browserInput(jobId, viewer, batch).catch((error: unknown) => {
558
+ if(!mountedRef.current||inflight.current!==token||viewerRef.current!==viewer||!controlRef.current)return;
559
+ if (pendingDialog.current && batch.includes(pendingDialog.current)) { pendingDialog.current = null; setDialogSending(false); }
560
+ if (batch.some((message) => message.type === "select_tab")) {
561
+ flash("页面切换未确认,正在重新连接…", 5000);
562
+ reconnect();
563
+ return;
564
+ }
464
565
  // 画面连接已失效(中枢重启、连接断开):丢掉这批输入并重连,避免用户以为点了没反应。
465
566
  if (error instanceof CenterError && (error.status === 404 || error.status === 409)) {
466
567
  queue.current = [];
@@ -474,13 +575,19 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
474
575
  const why = error instanceof CenterError && error.status === 429 ? "操作太快,任务中心暂时没接收" : "网络不稳";
475
576
  flash(`${why}:刚才的操作可能没有送达,请看画面确认后再操作`, 5000);
476
577
  }
477
- }).finally(() => { inflight.current = false; flush(); });
578
+ }).finally(() => { if(inflight.current!==token)return;inflight.current = null; flush(); });
478
579
  }, [backend, jobId, reconnect, flash]);
479
580
  const send = useCallback((msg: Record<string, unknown>) => {
581
+ if(!mountedRef.current||!controlRef.current)return false;
480
582
  // 画面连接还没就绪(刚接管、正在重连):不攒着等连上再补发——过几秒才落下的点击比没点更糟。
481
583
  if (!viewerRef.current) {
482
584
  if (msg.type !== "mouse" || msg.event === "mousePressed") flash("画面连接还没就绪,刚才的操作没有送达,请稍后再操作");
483
- return;
585
+ return false;
586
+ }
587
+ if (feed.latest.current.protocol) {
588
+ if (!targetRef.current || (!frameReady.current && msg.type !== "dialog") || pendingTarget.current || pageGeneration.current !== feed.latest.current.generation) return false;
589
+ // Preserve command identity (dialog acknowledgement tracks the queued object).
590
+ Object.assign(msg, targetRef.current);
484
591
  }
485
592
  lastInputAt.current = Date.now();
486
593
  const q = queue.current;
@@ -496,7 +603,102 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
496
603
  : msg;
497
604
  } else q.push(msg);
498
605
  flush();
499
- }, [flush, flash]);
606
+ return true;
607
+ }, [flush, flash, feed.latest]);
608
+
609
+ const clearPage = useCallback(() => {
610
+ inputEpoch.current++;
611
+ queue.current = [];
612
+ inflight.current = null;
613
+ frameReady.current = false;
614
+ screenRef.current?.clear();
615
+ setHasFrame(false);
616
+ setPage({ url: "", title: "" });
617
+ setAddr(""); setEditing(false);
618
+ if (keysRef.current) keysRef.current.value = "";
619
+ pendingDialog.current = null; setDialogSending(false); setDialog(null); setPromptText("");
620
+ clipboardSequence.current++; setCopied(null); setNotice(null);
621
+ lastInputAt.current = 0; lastFrameAt.current = 0; setStuck(false);
622
+ }, []);
623
+ const selectTab = (id: string) => {
624
+ const current = targetRef.current;
625
+ if (!human || !feed.ready || !viewerRef.current || tabs?.canSelect !== true || !current || pendingTarget.current || id === current.targetId || pageGeneration.current !== feed.latest.current.generation) return;
626
+ if (!tabs.tabs.some((tab) => tab.id === id)) return;
627
+ clearPage();
628
+ pendingTarget.current = id; setSwitching(id);
629
+ queue.current.push({ type: "select_tab", targetId: id, expectedTargetId: current.targetId, expectedTargetEpoch: current.targetEpoch });
630
+ lastInputAt.current = Date.now();
631
+ flush();
632
+ };
633
+
634
+ useEffect(() => {
635
+ if (pageGeneration.current === feed.generation || !feed.latest.current.protocol) return;
636
+ pageGeneration.current = feed.generation;
637
+ targetRef.current = null; pendingTarget.current = null; setSwitching(null); setTabs(null);
638
+ clearPage();
639
+ }, [feed.generation, feed.latest, clearPage]);
640
+ useEffect(() => {
641
+ if (!switching) return;
642
+ const timer = setTimeout(() => {
643
+ if (!pendingTarget.current) return;
644
+ flash("切换页面尚未确认,正在重新连接…", 5000);
645
+ reconnect();
646
+ }, UNRESPONSIVE_MS);
647
+ return () => clearTimeout(timer);
648
+ }, [switching, flash, reconnect]);
649
+
650
+ // 连接上来的消息:画面直接画,其余更新界面。挂上时先补上挂上之前到的页面 / 弹窗 / 最后一帧。
651
+ const latest = feed.latest;
652
+ useEffect(() => {
653
+ const handle = (m: BrowserMessage) => {
654
+ if (pageGeneration.current !== latest.current.generation) {
655
+ pageGeneration.current = latest.current.generation;
656
+ if (latest.current.protocol) {
657
+ targetRef.current = null; pendingTarget.current = null; setSwitching(null); setTabs(null);
658
+ clearPage();
659
+ }
660
+ }
661
+ const target = messageTarget(m);
662
+ // Until the page announcement confirms a switch, events from the previous
663
+ // page may still be in transit (notably clipboard results and JS dialogs).
664
+ if (pendingTarget.current && m.type !== "page" && m.type !== "tabs" && m.type !== "error") return;
665
+ if (m.type === "tabs") {
666
+ setTabs(m);
667
+ if (m.activeId === null) { clearPage(); targetRef.current = null; pendingTarget.current = null; setSwitching(null); }
668
+ } else if (m.type === "page") {
669
+ // The old page may still announce its title while a switch is in flight.
670
+ if (pendingTarget.current && sameTarget(targetRef.current, target)) return;
671
+ if (target && !sameTarget(targetRef.current, target)) {
672
+ clearPage(); targetRef.current = target;
673
+ pendingTarget.current = null; setSwitching(null);
674
+ }
675
+ setPage({ url: m.url ?? "", title: m.title ?? "" });
676
+ }
677
+ if (m.type === "frame" && m.data) {
678
+ if (latest.current.protocol && (pendingTarget.current || !sameTarget(targetRef.current, target))) return;
679
+ screenRef.current?.draw(m.data, m.metadata ?? null);
680
+ frameReady.current = true;
681
+ lastFrameAt.current = Date.now();
682
+ setStuck(false);
683
+ setHasFrame(true);
684
+ } else if (m.type === "error" && m.message) { if(pendingDialog.current){pendingDialog.current=null;setDialogSending(false);} if (pendingTarget.current) reconnect(); flash(m.message); }
685
+ else if (m.type === "dialog") { pendingDialog.current = null; setDialogSending(false); setDialog(m); setPromptText(m.defaultPrompt ?? ""); }
686
+ else if (m.type === "dialog_closed") { pendingDialog.current = null; setDialogSending(false); setDialog(null); }
687
+ else if (m.type === "clipboard") {
688
+ const source=latest.current.page;
689
+ const sequence=++clipboardSequence.current;
690
+ setCopied({text:m.text,page:{url:source?.type==='page'?source.url??'':'',title:source?.type==='page'?source.title??'':''}});
691
+ void writeClipboard(m.text).then((ok) => {if(sequence===clipboardSequence.current)flash(ok ? "已复制到你的剪贴板" : "没能写入剪贴板(浏览器不允许),可以在网页里选中后再试", 6000);});
692
+ }
693
+ };
694
+ const { tabs: lastTabs, page: lastPage, dialog: lastDialog, frame: lastFrame } = latest.current;
695
+ if (lastTabs) handle(lastTabs);
696
+ if (lastPage) handle(lastPage);
697
+ if (lastDialog) handle(lastDialog);
698
+ if (lastFrame) handle(lastFrame);
699
+ sink.current = handle;
700
+ return () => { sink.current = null; clipboardSequence.current++; };
701
+ }, [sink, flash, latest, clearPage, reconnect]);
500
702
 
501
703
  // 键盘:
502
704
  // · 普通字符(不带 Ctrl / ⌘)作为真正的按键发出(带 text)——网页的 keydown、快捷键、输入即搜索都能收到;
@@ -525,30 +727,43 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
525
727
  };
526
728
  const nav = (msg: Record<string, unknown>) => { if (human) send(msg); };
527
729
  const answer = (accept: boolean) => {
528
- if (!dialog) return;
529
- send({ type: "dialog", accept, ...(dialog.dialogType === "prompt" && accept ? { promptText } : {}) });
530
- setDialog(null);
730
+ if (!dialog || !human || !feed.ready || pendingDialog.current) return;
731
+ const command = { type: "dialog", accept, ...(dialog.dialogType === "prompt" && accept ? { promptText } : {}) };
732
+ if (send(command)) { pendingDialog.current = command; setDialogSending(true); }
733
+ // Keep the dialog until the remote browser confirms dialog_closed. Failed
734
+ // or disconnected input must remain visible and available to retry.
531
735
  };
532
736
 
533
737
  const { frames, paused } = feed;
534
738
  const retrying = frames.state === "retrying";
739
+ const interactive = human && (!latest.current.protocol || (feed.ready && !!targetRef.current && hasFrame && !switching && pageGeneration.current === latest.current.generation));
740
+ const activeTab = switching ?? targetRef.current?.targetId ?? tabs?.activeId;
741
+ const listedTabs = tabs?.tabs.filter((tab, index, all) => typeof tab.id === "string" && !!tab.id && all.findIndex((candidate) => candidate.id === tab.id) === index) ?? [];
535
742
  return (
536
743
  <div className="bp-viewer">
744
+ {listedTabs.length > 0 && <nav className="bp-tabs" aria-label="任务浏览器页面">
745
+ {listedTabs.map((tab) => <button key={tab.id} className={`bp-tab${activeTab === tab.id ? " active" : ""}`} aria-pressed={activeTab === tab.id}
746
+ title={`${tab.title || "未命名页面"}${tab.url ? `\n${tab.url}` : ""}${!human ? "\n接管浏览器后可切换" : ""}`}
747
+ disabled={!human || !feed.ready || tabs?.canSelect !== true || !!switching || !targetRef.current}
748
+ onClick={() => selectTab(tab.id)}>
749
+ <span>{tab.title || tab.url || "未命名页面"}</span>{switching === tab.id && <span className="bp-tab-wait" aria-label="正在切换">…</span>}
750
+ </button>)}
751
+ </nav>}
537
752
  <div className="prev-chrome bp-chrome">
538
- <button className="bp-nav" disabled={!human} onClick={() => nav({ type: "back" })} title="后退">‹</button>
539
- <button className="bp-nav" disabled={!human} onClick={() => nav({ type: "forward" })} title="前进">›</button>
540
- <button className="bp-nav" disabled={!human} onClick={() => nav({ type: "reload" })} title="刷新">↻</button>
753
+ <button className="bp-nav" disabled={!interactive} onClick={() => nav({ type: "back" })} title="后退">‹</button>
754
+ <button className="bp-nav" disabled={!interactive} onClick={() => nav({ type: "forward" })} title="前进">›</button>
755
+ <button className="bp-nav" disabled={!interactive} onClick={() => nav({ type: "reload" })} title="刷新">↻</button>
541
756
  <form className="bp-addr-form" onSubmit={(e) => { e.preventDefault(); const url = normalizeUrl(addr); if (url) nav({ type: "navigate", url }); setEditing(false); (e.currentTarget.querySelector("input") as HTMLInputElement | null)?.blur(); }}>
542
- <input className="prev-addr bp-addr" value={addr} readOnly={!human} title={page.title || page.url}
757
+ <input className="prev-addr bp-addr" value={addr} readOnly={!interactive} title={page.title || page.url}
543
758
  onFocus={() => setEditing(true)} onBlur={() => setEditing(false)} onChange={(e) => setAddr(e.target.value)} />
544
759
  </form>
545
760
  </div>
546
- <div className={`bp-screen ${human ? "live" : ""}`}>
761
+ <div className={`bp-screen ${interactive ? "live" : ""}`}>
547
762
  {/* 画面一直挂着(收到第一帧前隐藏),解码在内存里做,不受宿主页面 CSP 的 img-src 限制。 */}
548
- <Screen ref={screenRef} human={human} hidden={!hasFrame} label={page.title || "浏览器画面"} send={send} onFocusKeys={() => keysRef.current?.focus({ preventScroll: true })} />
763
+ <Screen ref={screenRef} human={interactive} inputEpoch={inputEpoch.current} hidden={!hasFrame} label={page.title || "浏览器画面"} send={send} onFocusKeys={() => keysRef.current?.focus({ preventScroll: true })} />
549
764
  {!hasFrame && (
550
765
  <div className="bp-empty">
551
- {paused ? "页面不可见,已暂停画面" : retrying ? `画面连接中断,正在重连…${frames.detail ? `(${frames.detail})` : ""}` : "正在连接画面…"}
766
+ {switching ? "正在切换页面…" : paused ? "页面不可见,已暂停画面" : retrying ? `画面连接中断,正在重连…${frames.detail ? `(${frames.detail})` : ""}` : "正在连接画面…"}
552
767
  {retrying && <><br /><button className="btn btn-line btn-sm" style={{ marginTop: 8 }} onClick={reconnect}>立即重试</button></>}
553
768
  </div>
554
769
  )}
@@ -557,19 +772,21 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
557
772
  <div className="bp-dialog-box">
558
773
  <div className="bp-dialog-t">网页{dialog.dialogType === "confirm" ? "请求确认" : dialog.dialogType === "prompt" ? "请求输入" : dialog.dialogType === "beforeunload" ? "询问是否离开" : "提示"}</div>
559
774
  <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); }} />}
775
+ {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
776
  {!human && <div className="bp-dialog-hint">接管浏览器后才能处理这个弹窗。</div>}
777
+ {human && !feed.ready && <div className="bp-dialog-hint">画面连接恢复后可继续处理这个弹窗。</div>}
778
+ {dialogSending && <div className="bp-dialog-hint" role="status">正在等待网页确认…</div>}
562
779
  <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>
780
+ {dialog.dialogType !== "alert" && <button className="btn btn-line btn-sm" disabled={!human || !feed.ready || dialogSending} onClick={() => answer(false)}>取消</button>}
781
+ <button className="btn btn-accent btn-sm" disabled={!human || !feed.ready || dialogSending} onClick={() => answer(true)}>确定</button>
565
782
  </div>
566
783
  </div>
567
784
  </div>
568
785
  )}
569
786
  {notice && (
570
787
  <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>
788
+ {copied?.text && onQuoteText && /剪贴板/.test(notice) && (
789
+ <button className="bp-toast-btn" onClick={() => { onQuoteText(copied.text, copied.page); setCopied(null); setNotice(null); }}>引用到对话</button>
573
790
  )}
574
791
  </div>
575
792
  )}
@@ -584,7 +801,7 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
584
801
  ref={keysRef}
585
802
  className="bp-keys"
586
803
  aria-label="向浏览器输入"
587
- disabled={!human}
804
+ disabled={!interactive}
588
805
  onKeyDown={onKeyDown}
589
806
  onInput={(e) => { if (!(e.nativeEvent as InputEvent).isComposing) flushText(e.currentTarget); }}
590
807
  onCompositionEnd={(e) => flushText(e.currentTarget)}
@@ -593,15 +810,27 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
593
810
  );
594
811
  }
595
812
 
596
- export function BrowserPane({ backend, jobId, readOnly, onQuote, onQuoteText, onPage }: {
813
+ type BrowserPaneProps = {
597
814
  backend: BoardBackend; jobId: string; readOnly?: boolean;
815
+ extension?: (status: BrowserStatus, update: (status:BrowserStatus)=>void) => ReactNode;
816
+ footer?: ReactNode;
817
+ className?: string;
598
818
  /** 给了就显示「引用到对话」:把浏览器当前页面(标题 + 地址)带进下一条消息。 */
599
819
  onQuote?: (page: { url: string; title: string }) => void;
600
820
  /** 给了就能把在网页里复制(⌘C / Ctrl+C)出来的文字引用到对话。 */
601
821
  onQuoteText?: (text: string, page: { url: string; title: string }) => void;
602
822
  /** 当前页面变了(null = 浏览器没开 / 不在看)。 */
603
823
  onPage?: (page: { url: string; title: string } | null) => void;
604
- }) {
824
+ };
825
+ const browserBackendIds = new WeakMap<BoardBackend,number>();
826
+ let nextBrowserBackendId=1;
827
+ /** The low-level public component is safe to reuse across task/backend identity changes too. */
828
+ export function BrowserPane(props:BrowserPaneProps) {
829
+ let identity=browserBackendIds.get(props.backend);
830
+ if(identity===undefined){identity=nextBrowserBackendId++;browserBackendIds.set(props.backend,identity);}
831
+ return <BrowserPaneInner key={`${identity}:${props.jobId}`} {...props}/>;
832
+ }
833
+ function BrowserPaneInner({ backend, jobId, readOnly, onQuote, onQuoteText, onPage, extension, footer, className }:BrowserPaneProps) {
605
834
  const sink = useRef<((m: BrowserMessage) => void) | null>(null);
606
835
  const feed = useBrowserFeed(backend, jobId, sink);
607
836
  const { status, setStatus, streamError, ended } = feed;
@@ -649,7 +878,7 @@ export function BrowserPane({ backend, jobId, readOnly, onQuote, onQuoteText, on
649
878
  return (
650
879
  <>
651
880
  {big && zoomable && <div className="cli-ui bp-backdrop" onClick={() => setBig(false)} />}
652
- <div className={`rail-pad bp ${big && zoomable ? "bp-big" : ""}`}>
881
+ <div className={`rail-pad bp ${big && zoomable ? "bp-big" : ""} ${className??""}`}>
653
882
  <div className="bp-head">
654
883
  <span className="rail-eyebrow">浏览器 · ego lite</span>
655
884
  <span className={`bp-badge ${running ? (human ? "human" : "ai") : ""}`}>
@@ -674,7 +903,7 @@ export function BrowserPane({ backend, jobId, readOnly, onQuote, onQuoteText, on
674
903
 
675
904
  {running ? (
676
905
  <>
677
- <Viewer backend={backend} jobId={jobId} human={human && !readOnly} feed={feed} sink={sink} onPage={onPage} onQuoteText={onQuoteText} />
906
+ <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
907
  {!readOnly && (
679
908
  <div className="prev-actions bp-actions">
680
909
  {human
@@ -684,7 +913,7 @@ export function BrowserPane({ backend, jobId, readOnly, onQuote, onQuoteText, on
684
913
  {onQuote && (
685
914
  <button className="btn btn-ghost btn-sm" title="把当前页面的标题和地址带进下一条消息(在网页里复制文字后,也可以把复制的文字引用过去)"
686
915
  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 ?? "" }); }}>
916
+ onClick={() => { const p = feed.latest.current.page; if (p?.type === "page" && p.url) { setBig(false); onQuote({ url: p.url, title: p.title ?? "" }); } }}>
688
917
  {Icon.board()} 引用到对话
689
918
  </button>
690
919
  )}
@@ -710,6 +939,8 @@ export function BrowserPane({ backend, jobId, readOnly, onQuote, onQuoteText, on
710
939
  </div>
711
940
  </div>
712
941
  )}
942
+ {extension?.(status,setStatus)}
943
+ {footer}
713
944
  {shownError && <div className="bp-error">{shownError}</div>}
714
945
  {shownError && ended && <button className="btn btn-line btn-sm" onClick={feed.reconnect}>重试</button>}
715
946
  </div>