@spzxg/cli-ui 0.6.5 → 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 (36) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +33 -13
  3. package/dist/cli-ui-embed-BAqonuoD.js +45952 -0
  4. package/dist/{cli-ui-index-D4qfPHMd.js → cli-ui-index-b8wF7wrN.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-DpEl0G1z.js → cli-ui-jszip.min-B0uLrCHg.js} +2 -2
  6. package/dist/{cli-ui-jszip.min-DC9898pC.js → cli-ui-jszip.min-ggP8OokP.js} +1 -1
  7. package/dist/{cli-ui-xlsx-zs7oEtPj.js → cli-ui-xlsx-DEDdHrJJ.js} +1 -1
  8. package/dist/cli-ui.css +1 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +9 -8
  11. package/src/board/BrowserPane.tsx +198 -53
  12. package/src/board/FileViewer.tsx +22 -9
  13. package/src/board/fileWatch.ts +9 -3
  14. package/src/board/frameQuote.ts +2 -2
  15. package/src/board/textLoad.ts +7 -5
  16. package/src/board/workspace.ts +15 -5
  17. package/src/chat/CliChat.tsx +21 -11
  18. package/src/chat/Composer.tsx +4 -2
  19. package/src/core/blocks.ts +15 -4
  20. package/src/core/client.ts +96 -14
  21. package/src/core/conversation.ts +1 -0
  22. package/src/core/provider.tsx +7 -0
  23. package/src/core/store.ts +68 -27
  24. package/src/core/types.ts +30 -9
  25. package/src/layout/split.ts +67 -30
  26. package/src/msglist/CliMsgList.tsx +9 -3
  27. package/src/operator/node/Transcript.tsx +7 -2
  28. package/src/operator/node/boardBackend.ts +6 -4
  29. package/src/operator/node/protocol/types.ts +2 -2
  30. package/src/operator/node/useTaskStream.ts +5 -4
  31. package/src/operator.base.css +8 -0
  32. package/src/operator.css +8 -0
  33. package/src/styles.css +29 -1
  34. package/src/ui/Popover.tsx +10 -5
  35. package/src/ui/Transcript.tsx +7 -2
  36. package/dist/cli-ui-embed-BmLN2Jjg.js +0 -43290
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-BmLN2Jjg.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-BAqonuoD.js";
2
2
  export {
3
3
  a as CliBoard,
4
4
  i as CliBrowserView,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spzxg/cli-ui",
3
- "version": "0.6.5",
3
+ "version": "0.6.6",
4
4
  "type": "module",
5
5
  "description": "相互联动的 CliMsgList、CliChat、CliBoard,支持任务中枢和完整 Operator 工作区。",
6
6
  "main": "src/index.ts",
@@ -37,23 +37,24 @@
37
37
  "react-dom": "^19.0.0"
38
38
  },
39
39
  "devDependencies": {
40
+ "@types/node": "^22.20.5",
40
41
  "@types/react": "19.2.17",
41
42
  "@types/react-dom": "19.2.3",
42
- "@vitejs/plugin-react": "^4.3.4",
43
+ "@vitejs/plugin-react": "4.7.0",
43
44
  "jsdom": "^25.0.1",
44
45
  "postcss": "^8.5.32",
45
46
  "typescript": "^5.7.3",
46
- "vite": "^6.0.7",
47
- "vitest": "^2.1.8"
47
+ "vite": "6.4.5",
48
+ "vitest": "4.1.12"
48
49
  },
49
50
  "dependencies": {
51
+ "@xterm/addon-fit": "^0.11.0",
52
+ "@xterm/addon-web-links": "^0.12.0",
53
+ "@xterm/xterm": "^6.0.0",
50
54
  "jszip": "^3.10.2",
51
55
  "mammoth": "^1.13.0",
52
56
  "react-markdown": "^10.1.0",
53
57
  "rehype-highlight": "^7.0.2",
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"
58
+ "remark-gfm": "^4.0.1"
58
59
  }
59
60
  }
@@ -17,6 +17,13 @@ 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
 
@@ -135,6 +172,7 @@ function useBrowserFeed(backend: BoardBackend, jobId: string, sink: MutableRefOb
135
172
  const connect = () => {
136
173
  if (!alive) return;
137
174
  let finished = false;
175
+ let seenViewer = false;
138
176
  let stopThis: (() => void) | null = null;
139
177
  const retire = () => {
140
178
  if (!alive || finished) return false;
@@ -144,7 +182,7 @@ function useBrowserFeed(backend: BoardBackend, jobId: string, sink: MutableRefOb
144
182
  return true;
145
183
  };
146
184
  stopThis = backend.browserLive!(jobId, {
147
- onViewer: (id, isReady) => { if (!alive || finished) return; attempt = 0; setViewer(id); setReady(isReady); },
185
+ onViewer: (id, isReady) => { if (!alive || finished) return; if (!seenViewer) { seenViewer = true; beginViewer(); } attempt = 0; setViewer(id); setReady(isReady); },
148
186
  onStatus: (s) => { if (!alive || finished) return; attempt = 0; setStreamError(null); setStatus(s); },
149
187
  onMessage: (message) => { if (alive && !finished) deliver(message); },
150
188
  onFrames: (state) => { if (alive && !finished) setFrames(state); },
@@ -170,7 +208,7 @@ function useBrowserFeed(backend: BoardBackend, jobId: string, sink: MutableRefOb
170
208
  };
171
209
  connect();
172
210
  return () => { alive = false; stop?.(); if (timer) clearTimeout(timer); };
173
- }, [backend, jobId, mode, visible, retry, deliver]);
211
+ }, [backend, jobId, mode, visible, retry, deliver, beginViewer]);
174
212
 
175
213
  // ── 旧版:状态一条(页面在后台时也断开,切回来再连:人多时少占中枢和节点的连接) ──
176
214
  useEffect(() => {
@@ -213,6 +251,7 @@ function useBrowserFeed(backend: BoardBackend, jobId: string, sink: MutableRefOb
213
251
  const connect = () => {
214
252
  if (!alive) return;
215
253
  let finished = false;
254
+ let seenViewer = false;
216
255
  let stopThis: (() => void) | null = null;
217
256
  const again = (detail?: string) => {
218
257
  // A stream can emit an end event and then close; retire it only once.
@@ -226,7 +265,7 @@ function useBrowserFeed(backend: BoardBackend, jobId: string, sink: MutableRefOb
226
265
  };
227
266
  setFrames({ state: attempt ? "retrying" : "connecting" });
228
267
  stopThis = backend.browserFrames(jobId, {
229
- onViewer: (id) => { if (!alive || finished) return; attempt = 0; setViewer(id); setReady(true); setFrames({ state: "live" }); },
268
+ onViewer: (id) => { if (!alive || finished) return; if (!seenViewer) { seenViewer = true; beginViewer(); } attempt = 0; setViewer(id); setReady(true); setFrames({ state: "live" }); },
230
269
  onMessage: (message) => { if (alive && !finished) deliver(message); },
231
270
  onEnd: (end) => {
232
271
  if (end.reason !== "client_closed") { again(end.reason === "refused" ? end.detail : undefined); return; }
@@ -244,16 +283,16 @@ function useBrowserFeed(backend: BoardBackend, jobId: string, sink: MutableRefOb
244
283
  };
245
284
  connect();
246
285
  return () => { alive = false; stop?.(); if (timer) clearTimeout(timer); };
247
- }, [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
248
287
 
249
288
  // 浏览器关了:上一个页面的地址、弹窗、画面都作废,重新打开时不先闪一下旧画面。
250
289
  const running = status?.state === "running";
251
- useEffect(() => { if (status && !running) latest.current = {}; }, [running, status]);
290
+ useEffect(() => { if (status && !running) latest.current = { generation: latest.current.generation, protocol: false }; }, [running, status]);
252
291
 
253
- 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 };
254
293
  }
255
294
 
256
- interface ScreenHandle { draw(base64: string, meta: FrameMeta | null): void }
295
+ interface ScreenHandle { draw(base64: string, meta: FrameMeta | null): void; clear(): void }
257
296
 
258
297
  /** base64 → 字节。 */
259
298
  function decodeBase64(data: string): Uint8Array {
@@ -276,19 +315,30 @@ const Screen = forwardRef<ScreenHandle, { human: boolean; inputEpoch: number; hi
276
315
  const imgRef = useRef<HTMLImageElement | null>(null);
277
316
  const meta = useRef<FrameMeta | null>(null);
278
317
  const seq = useRef({ next: 0, drawn: 0 });
318
+ const imageGeneration = useRef(0);
279
319
  const [fallback] = useState(() => typeof createImageBitmap !== "function");
280
320
  const [fallbackFrame, setFallbackFrame] = useState<{src:string;meta:FrameMeta|null} | null>(null);
281
321
 
282
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
+ },
283
332
  draw(data, frameMeta) {
284
333
  const type = data.startsWith("iVBOR") ? "image/png" : "image/jpeg";
285
334
  if (fallback) { setFallbackFrame({src:`data:${type};base64,${data}`,meta:frameMeta}); return; }
286
335
  const n = ++seq.current.next;
336
+ const generation = imageGeneration.current;
287
337
  let bytes: Uint8Array;
288
338
  try { bytes = decodeBase64(data); } catch { return; }
289
339
  createImageBitmap(new Blob([bytes as BlobPart], { type })).then((bitmap) => {
290
340
  const canvas = canvasRef.current;
291
- if (!canvas || n < seq.current.drawn) { bitmap.close(); return; }
341
+ if (!canvas || generation !== imageGeneration.current || n < seq.current.drawn) { bitmap.close(); return; }
292
342
  seq.current.drawn = n;
293
343
  meta.current = frameMeta;
294
344
  if (canvas.width !== bitmap.width || canvas.height !== bitmap.height) { canvas.width = bitmap.width; canvas.height = bitmap.height; }
@@ -335,11 +385,11 @@ const Screen = forwardRef<ScreenHandle, { human: boolean; inputEpoch: number; hi
335
385
  const onPointerDown = (e: React.PointerEvent) => {
336
386
  if (!human || !point(e.clientX,e.clientY)) return;
337
387
  e.preventDefault();
338
- onFocusKeys();
339
388
  if (e.pointerType === "touch") {
340
389
  touch.current = { id: e.pointerId, x: e.clientX, y: e.clientY, lastX: e.clientX, lastY: e.clientY, moved: false };
341
390
  return;
342
391
  }
392
+ onFocusKeys();
343
393
  (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
344
394
  pressed.current = MOUSE_BUTTONS[e.button] ?? "left";
345
395
  mouse("mousePressed", e, { button: pressed.current, clickCount: e.detail || 1 });
@@ -369,10 +419,12 @@ const Screen = forwardRef<ScreenHandle, { human: boolean; inputEpoch: number; hi
369
419
  const t = touch.current;
370
420
  if (e.pointerType === "touch" && t && t.id === e.pointerId) {
371
421
  touch.current = null;
372
- if (!t.moved) {
422
+ if (!t.moved && point(e.clientX, e.clientY)) {
423
+ // Send before focusing: the mobile keyboard can resize/reposition the frame.
373
424
  // 触屏点一下 = 鼠标点击
374
425
  mouse("mousePressed", e, { button: "left", clickCount: 1 });
375
426
  mouse("mouseReleased", e, { button: "left", clickCount: 1 });
427
+ onFocusKeys();
376
428
  }
377
429
  return;
378
430
  }
@@ -435,6 +487,12 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
435
487
  const screenRef = useRef<ScreenHandle | null>(null);
436
488
  const keysRef = useRef<HTMLTextAreaElement | null>(null);
437
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);
438
496
  const [page, setPage] = useState<{ url: string; title: string }>({ url: "", title: "" });
439
497
  const [addr, setAddr] = useState("");
440
498
  const [editing, setEditing] = useState(false);
@@ -458,34 +516,6 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
458
516
  }, []);
459
517
  useEffect(() => () => { if (noticeTimer.current) clearTimeout(noticeTimer.current); }, []);
460
518
 
461
- // 连接上来的消息:画面直接画,其余更新界面。挂上时先补上挂上之前到的页面 / 弹窗 / 最后一帧。
462
- const latest = feed.latest;
463
- useEffect(() => {
464
- const handle = (m: BrowserMessage) => {
465
- if (m.type === "frame" && m.data) {
466
- screenRef.current?.draw(m.data, m.metadata ?? null);
467
- lastFrameAt.current = Date.now();
468
- setStuck(false);
469
- setHasFrame(true);
470
- } else if (m.type === "page") setPage({ url: m.url ?? "", title: m.title ?? "" });
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); }
474
- else if (m.type === "clipboard") {
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);});
479
- }
480
- };
481
- const { page: lastPage, dialog: lastDialog, frame: lastFrame } = latest.current;
482
- if (lastPage) handle(lastPage);
483
- if (lastDialog) handle(lastDialog);
484
- if (lastFrame) handle(lastFrame);
485
- sink.current = handle;
486
- return () => { sink.current = null; clipboardSequence.current++; };
487
- }, [sink, flash, latest]);
488
-
489
519
  useEffect(() => { if (!editing) setAddr(page.url); }, [page.url, editing]);
490
520
  const onPageRef = useRef(onPage);
491
521
  onPageRef.current = onPage;
@@ -527,6 +557,11 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
527
557
  backend.browserInput(jobId, viewer, batch).catch((error: unknown) => {
528
558
  if(!mountedRef.current||inflight.current!==token||viewerRef.current!==viewer||!controlRef.current)return;
529
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
+ }
530
565
  // 画面连接已失效(中枢重启、连接断开):丢掉这批输入并重连,避免用户以为点了没反应。
531
566
  if (error instanceof CenterError && (error.status === 404 || error.status === 409)) {
532
567
  queue.current = [];
@@ -549,6 +584,11 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
549
584
  if (msg.type !== "mouse" || msg.event === "mousePressed") flash("画面连接还没就绪,刚才的操作没有送达,请稍后再操作");
550
585
  return false;
551
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);
591
+ }
552
592
  lastInputAt.current = Date.now();
553
593
  const q = queue.current;
554
594
  // 请求卡住时别无限攒:超了先丢鼠标移动,还超就丢最早的。
@@ -564,7 +604,101 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
564
604
  } else q.push(msg);
565
605
  flush();
566
606
  return true;
567
- }, [flush, flash]);
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]);
568
702
 
569
703
  // 键盘:
570
704
  // · 普通字符(不带 Ctrl / ⌘)作为真正的按键发出(带 text)——网页的 keydown、快捷键、输入即搜索都能收到;
@@ -602,23 +736,34 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
602
736
 
603
737
  const { frames, paused } = feed;
604
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) ?? [];
605
742
  return (
606
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>}
607
752
  <div className="prev-chrome bp-chrome">
608
- <button className="bp-nav" disabled={!human} onClick={() => nav({ type: "back" })} title="后退">‹</button>
609
- <button className="bp-nav" disabled={!human} onClick={() => nav({ type: "forward" })} title="前进">›</button>
610
- <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>
611
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(); }}>
612
- <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}
613
758
  onFocus={() => setEditing(true)} onBlur={() => setEditing(false)} onChange={(e) => setAddr(e.target.value)} />
614
759
  </form>
615
760
  </div>
616
- <div className={`bp-screen ${human ? "live" : ""}`}>
761
+ <div className={`bp-screen ${interactive ? "live" : ""}`}>
617
762
  {/* 画面一直挂着(收到第一帧前隐藏),解码在内存里做,不受宿主页面 CSP 的 img-src 限制。 */}
618
- <Screen ref={screenRef} human={human} inputEpoch={inputEpoch.current} 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 })} />
619
764
  {!hasFrame && (
620
765
  <div className="bp-empty">
621
- {paused ? "页面不可见,已暂停画面" : retrying ? `画面连接中断,正在重连…${frames.detail ? `(${frames.detail})` : ""}` : "正在连接画面…"}
766
+ {switching ? "正在切换页面…" : paused ? "页面不可见,已暂停画面" : retrying ? `画面连接中断,正在重连…${frames.detail ? `(${frames.detail})` : ""}` : "正在连接画面…"}
622
767
  {retrying && <><br /><button className="btn btn-line btn-sm" style={{ marginTop: 8 }} onClick={reconnect}>立即重试</button></>}
623
768
  </div>
624
769
  )}
@@ -656,7 +801,7 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
656
801
  ref={keysRef}
657
802
  className="bp-keys"
658
803
  aria-label="向浏览器输入"
659
- disabled={!human}
804
+ disabled={!interactive}
660
805
  onKeyDown={onKeyDown}
661
806
  onInput={(e) => { if (!(e.nativeEvent as InputEvent).isComposing) flushText(e.currentTarget); }}
662
807
  onCompositionEnd={(e) => flushText(e.currentTarget)}
@@ -146,6 +146,7 @@ function FileViewerInner({ backend, jobId, path, size, mtime, version = 0, reloa
146
146
  const kind: PreviewKind = previewKind(path);
147
147
  const [loaded, setLoaded] = useState<Loaded | null>(null);
148
148
  const [error, setError] = useState<string | null>(null);
149
+ const [downloadError, setDownloadError] = useState<string | null>(null);
149
150
  const [copied, setCopied] = useState(false);
150
151
  const [busy, setBusy] = useState(false);
151
152
  const [mode, setMode] = useState<"preview" | "source">("preview");
@@ -245,8 +246,13 @@ function FileViewerInner({ backend, jobId, path, size, mtime, version = 0, reloa
245
246
  const r = typeof range.getBoundingClientRect === "function" ? range.getBoundingClientRect() : { left: box.left + box.width / 2, width: 0, top: box.top + 48 };
246
247
  setSel({ text, x: Math.min(Math.max(8, r.left - box.left + r.width / 2), box.width - 8), y: Math.max(40, r.top - box.top) });
247
248
  };
248
- const onUp = () => setTimeout(read, 0);
249
- const onChange = () => { const s = window.getSelection(); if (!s || s.isCollapsed) setSel((cur) => (cur?.frame ? cur : null)); };
249
+ let selectionTimer: ReturnType<typeof setTimeout> | undefined;
250
+ const onUp = () => {
251
+ if (selectionTimer !== undefined) clearTimeout(selectionTimer);
252
+ selectionTimer = setTimeout(() => { selectionTimer = undefined; read(); }, 0);
253
+ };
254
+ // Native touch selection/handle changes need not generate mouseup or keyup.
255
+ const onChange = onUp;
250
256
  const body = bodyRef.current;
251
257
  document.addEventListener("mouseup", onUp);
252
258
  document.addEventListener("keyup", onUp);
@@ -257,6 +263,7 @@ function FileViewerInner({ backend, jobId, path, size, mtime, version = 0, reloa
257
263
  body?.addEventListener("scroll", onScroll, { passive: true });
258
264
  document.addEventListener("mousedown", onDown);
259
265
  return () => {
266
+ if (selectionTimer !== undefined) clearTimeout(selectionTimer);
260
267
  document.removeEventListener("mousedown", onDown);
261
268
  document.removeEventListener("mouseup", onUp);
262
269
  document.removeEventListener("keyup", onUp);
@@ -298,8 +305,8 @@ function FileViewerInner({ backend, jobId, path, size, mtime, version = 0, reloa
298
305
  const prev = loadedRef.current?.kind === "text" && textKey.current === key ? loadedRef.current : null;
299
306
  textKey.current = key;
300
307
  if (!prev) setProgress(0);
301
- const full = () => loadText(backend, jobId, path, { max: TEXT_MAX, tail, onBytes: (n) => { if (alive && !prev) setProgress(n); } });
302
- (prev ? refreshText(backend, jobId, path, prev, TEXT_MAX).then((r) => r ?? full(), full) : full()).then(
308
+ const full = () => loadText(backend, jobId, path, { max: TEXT_MAX, tail, signal: ctl.signal, onBytes: (n) => { if (alive && !prev) setProgress(n); } });
309
+ (prev ? refreshText(backend, jobId, path, prev, TEXT_MAX, { signal: ctl.signal }).then((r) => r ?? full(), full) : full()).then(
303
310
  (r) => {
304
311
  if (!alive) return;
305
312
  const box = bodyRef.current;
@@ -321,8 +328,9 @@ function FileViewerInner({ backend, jobId, path, size, mtime, version = 0, reloa
321
328
  }), [backend, jobId, path, name, links]);
322
329
 
323
330
  const download = async () => {
324
- setBusy(true);
325
- try { await downloadFile(backend, jobId, path, knownSize); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); }
331
+ if (busy) return;
332
+ setBusy(true); setDownloadError(null);
333
+ try { await downloadFile(backend, jobId, path, knownSize); } catch (e) { setDownloadError(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); }
326
334
  };
327
335
  const openUrl = links.url ? links.url(path) : src.url;
328
336
  const openNew = () => { if (openUrl) window.open(openUrl, "_blank", "noopener"); };
@@ -380,11 +388,15 @@ function FileViewerInner({ backend, jobId, path, size, mtime, version = 0, reloa
380
388
  {onClose && <button className="cli-fv-icon" onClick={onClose} title="关闭">{Icon.x()}</button>}
381
389
  </div>
382
390
  )}
391
+ {downloadError && <div className="cli-fv-trunc" role="alert">下载失败:{downloadError}
392
+ <button className="cli-fv-btn" disabled={busy} onClick={() => void download()}>重试下载</button>
393
+ <button className="cli-fv-btn" onClick={() => setDownloadError(null)} aria-label="关闭下载错误">关闭</button>
394
+ </div>}
383
395
  <div className={`cli-fv-b k-${kind}`} ref={bodyRef}>
384
396
  {error || src.error ? (
385
397
  <div className="cli-fv-note err">读取失败:{error ?? src.error}
386
398
  <button className="btn btn-line btn-sm" style={{ marginLeft: 8 }} onClick={retry}>重试</button>
387
- <button className="btn btn-line btn-sm" style={{ marginLeft: 8 }} onClick={() => void download()}>{Icon.download()} 下载</button>
399
+ <button className="btn btn-line btn-sm" style={{ marginLeft: 8 }} onClick={() => void download()} disabled={busy}>{Icon.download()} 下载</button>
388
400
  </div>
389
401
  ) : kind === "binary" ? (
390
402
  <div className="cli-fv-note">这种文件不能在这里预览。
@@ -498,8 +510,9 @@ function SourceView({ backend, jobId, path, version }: { backend: BoardBackend;
498
510
  const [state, setState] = useState<{ text: string; size?: number; truncated?: boolean } | null>(null);
499
511
  useEffect(() => {
500
512
  let alive = true;
501
- backend.readText(jobId, path, TEXT_MAX).then((r) => { if (alive) setState({ text: r.text, size: r.size, truncated: r.truncated }); }, (e: unknown) => { if (alive) setState({ text: `读取失败:${e instanceof Error ? e.message : String(e)}` }); });
502
- return () => { alive = false; };
513
+ const ctl = new AbortController();
514
+ backend.readText(jobId, path, TEXT_MAX, { signal: ctl.signal }).then((r) => { if (alive) setState({ text: r.text, size: r.size, truncated: r.truncated }); }, (e: unknown) => { if (alive) setState({ text: `读取失败:${e instanceof Error ? e.message : String(e)}` }); });
515
+ return () => { alive = false; ctl.abort(); };
503
516
  }, [backend, jobId, path, version]);
504
517
  if (!state) return <div className="cli-fv-note">加载中…</div>;
505
518
  return (
@@ -1,4 +1,4 @@
1
- import { CenterError, type BoardBackend } from '../core/client';
1
+ import { CenterError, isMissingRoute, type BoardBackend } from '../core/client';
2
2
  import { notifyCallback } from '../core/callbacks';
3
3
  import type { FileEntry, WorkspaceChangeEvent, WorkspaceFileBatch, WorkspaceFileChange, WorkspaceWatchStatus } from '../core/types';
4
4
  import { listFolder } from './folderDownload';
@@ -15,7 +15,7 @@ export function subscribeWorkspaceFiles(backend:BoardBackend,jobId:string,listen
15
15
  let entries=registries.get(backend);if(!entries){entries=new Map();registries.set(backend,entries);}
16
16
  let entry=entries.get(jobId);const subscription={change:listener,status:options.onStatus};
17
17
  if(!entry){
18
- const mode=backend.watchFiles?'events':'poll',coverage=mode==='events'?'workspace':'listed-files';
18
+ let mode:WorkspaceWatchStatus['mode']=backend.watchFiles?'events':'poll',coverage:WorkspaceWatchStatus['coverage']=mode==='events'?'workspace':'listed-files';
19
19
  entry={listeners:new Set(),stop:()=>{},status:{type:'files_watch',jobId,state:'connecting',mode,coverage,complete:false}};entries.set(jobId,entry);
20
20
  const owned=entry;let alive=true,baseline:Map<string,FileEntry>|null=null,limited=false,blocked=false,stopTransport:(()=>void)|null=null;
21
21
  let timer:ReturnType<typeof setTimeout>|undefined,controller:AbortController|null=null,attempt=0,generation=0;
@@ -67,7 +67,13 @@ export function subscribeWorkspaceFiles(backend:BoardBackend,jobId:string,listen
67
67
  const retire=()=>{finished=true;if(stopTransport===retire)stopTransport=null;const dispose=cleanup;cleanup=null;notifyCallback(dispose??undefined,undefined);};
68
68
  const close=(error:unknown)=>{
69
69
  if(!alive||finished||token!==generation)return;retire();if(!alive||token!==generation)return;const code=error instanceof CenterError?error.status:0;
70
- blocked=[400,404,405,410,501].includes(code);const slow=[401,403,429].includes(code);
70
+ if(error instanceof CenterError&&(isMissingRoute(error)||(code===501&&error.code==='file_watch_unsupported'))){
71
+ // A directory listing has narrower coverage than native events. Establish
72
+ // a fresh baseline rather than reporting inaccessible paths as deleted.
73
+ mode='poll';coverage='listed-files';baseline=null;limited=false;attempt=0;blocked=false;
74
+ status('connecting',false,error.message);if(alive&&token===generation)void scan();return;
75
+ }
76
+ blocked=[400,404,405,410,413,501].includes(code);const slow=[401,403,429].includes(code);
71
77
  status(blocked||slow?'error':'reconnecting',false,error instanceof Error?error.message:undefined);
72
78
  if(!alive||token!==generation||blocked)return;
73
79
  const delay=slow?30000:Math.min(30000,1000*2**Math.min(attempt++,5));
@@ -18,9 +18,9 @@ function bridgeScript(token: string) {
18
18
  return `(function(){var t=${t};function post(m){m.cliUiSel=t;try{parent.postMessage(m,"*")}catch(e){}}
19
19
  function send(){var s=getSelection(),text=s&&!s.isCollapsed?String(s).trim():"",rect=null;
20
20
  if(text&&s.rangeCount){var b=s.getRangeAt(0).getBoundingClientRect();rect={left:b.left,top:b.top,width:b.width}}post({text:text,rect:rect})}
21
- function later(){setTimeout(send,0)}
21
+ var timer;function later(){clearTimeout(timer);timer=setTimeout(send,0)}
22
22
  document.addEventListener("mouseup",later);document.addEventListener("keyup",later);
23
- document.addEventListener("selectionchange",function(){var s=getSelection();if(!s||s.isCollapsed)post({text:""})});
23
+ document.addEventListener("selectionchange",later);
24
24
  addEventListener("scroll",function(){post({scroll:true})},true);
25
25
  addEventListener("message",function(e){if(e.source===parent&&e.data&&e.data.cliUiClear===t){var s=getSelection();s&&s.removeAllRanges()}});
26
26
  document.addEventListener("click",function(e){var a=e.target&&e.target.closest&&e.target.closest('a[href^="#"]');if(!a)return;e.preventDefault();
@@ -47,12 +47,13 @@ export function incompleteUtf8Tail(bytes: Uint8Array) {
47
47
  const sameBytes = (a: Uint8Array, b: Uint8Array) => a.length === b.length && a.every((x, i) => x === b[i]);
48
48
 
49
49
  /** 第一次读:开头(或末尾)最多 max 字节。 */
50
- export async function loadText(backend: BoardBackend, jobId: string, path: string, opts: { max: number; tail?: boolean; onBytes?: (bytes: number) => void }): Promise<LoadedText> {
50
+ export async function loadText(backend: BoardBackend, jobId: string, path: string, opts: { max: number; tail?: boolean; onBytes?: (bytes: number) => void; signal?: AbortSignal }): Promise<LoadedText> {
51
+ if (opts.signal?.aborted) throw new DOMException("文件读取已取消", "AbortError");
51
52
  if (!backend.readBytes) {
52
- const r = await backend.readText(jobId, path, opts.max, { tail: opts.tail, onBytes: opts.onBytes });
53
+ const r = await backend.readText(jobId, path, opts.max, { tail: opts.tail, onBytes: opts.onBytes, signal: opts.signal });
53
54
  return { text: r.text, parts: [r.text], truncated: r.truncated, size: r.size, tail: !!r.tail, encoding: r.encoding, start: 0, end: 0, tailBytes: null };
54
55
  }
55
- const chunk = await backend.readBytes(jobId, path, { maxBytes: opts.max, tail: opts.tail, onBytes: opts.onBytes });
56
+ const chunk = await backend.readBytes(jobId, path, { maxBytes: opts.max, tail: opts.tail, onBytes: opts.onBytes, signal: opts.signal });
56
57
  const { text, encoding } = decodeText(chunk.bytes, { fromMiddle: chunk.start > 0 });
57
58
  const k = encoding === "utf-8" ? incompleteUtf8Tail(chunk.bytes) : 0;
58
59
  const end = chunk.start + chunk.bytes.length - k;
@@ -67,7 +68,8 @@ export async function loadText(backend: BoardBackend, jobId: string, path: strin
67
68
  * 文件变了:能接上就只取新增的部分,返回新的内容;返回 null = 接不上(被改写 / 变短 / 不是 UTF-8 / 后端不支持),调用方整个重读。
68
69
  * 只看开头且文件比 max 大时,开头没变就不用重读(追加的在后面,本来就不显示)。
69
70
  */
70
- export async function refreshText(backend: BoardBackend, jobId: string, path: string, prev: LoadedText, max: number): Promise<LoadedText | null> {
71
+ export async function refreshText(backend: BoardBackend, jobId: string, path: string, prev: LoadedText, max: number, options: { signal?: AbortSignal } = {}): Promise<LoadedText | null> {
72
+ if (options.signal?.aborted) throw new DOMException("文件读取已取消", "AbortError");
71
73
  if (!backend.readBytes || !prev.tailBytes) return null;
72
74
  const overlap = prev.tailBytes.length;
73
75
  const from = prev.end - overlap;
@@ -75,7 +77,7 @@ export async function refreshText(backend: BoardBackend, jobId: string, path: st
75
77
  // 末尾模式攒多了(超过 1.5 倍):整个重读末尾,免得越攒越大。
76
78
  if (prev.tail && prev.end - prev.start > max * 1.5) return null;
77
79
  const room = headOnly ? 0 : prev.tail ? max : Math.max(0, max - (prev.end - prev.start));
78
- const chunk = await backend.readBytes(jobId, path, { start: from, maxBytes: overlap + room });
80
+ const chunk = await backend.readBytes(jobId, path, { start: from, maxBytes: overlap + room, signal: options.signal });
79
81
  if (chunk.size < prev.end || chunk.start !== from || chunk.bytes.length < overlap) return null;
80
82
  if (!sameBytes(chunk.bytes.subarray(0, overlap), prev.tailBytes)) return null;
81
83
  const readEnd = from + chunk.bytes.length;