@xgspz/cli-ui 0.6.15 → 0.6.16

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 (68) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +21 -1
  3. package/dist/{cli-ui-embed-vIXVNs6k.js → cli-ui-embed-D9xgur2y.js} +18299 -18587
  4. package/dist/{cli-ui-index-R24x28OX.js → cli-ui-index-WBtxmw23.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-DUghzByz.js → cli-ui-jszip.min-CU3ZzoXb.js} +1 -1
  6. package/dist/{cli-ui-jszip.min-DfH6rdQD.js → cli-ui-jszip.min-DdU0X3VJ.js} +2 -2
  7. package/dist/{cli-ui-xlsx-CP0lJhff.js → cli-ui-xlsx-Dx6T7qhC.js} +1 -1
  8. package/dist/cli-ui.css +1 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +1 -1
  11. package/src/board/ChangesPane.tsx +10 -4
  12. package/src/board/CliBoard.tsx +48 -16
  13. package/src/board/FileViewer.tsx +34 -1
  14. package/src/board/diffRefresh.ts +63 -0
  15. package/src/board/linkage.ts +68 -0
  16. package/src/board/pathResolve.ts +108 -0
  17. package/src/chat/CliChat.tsx +76 -181
  18. package/src/chat/Composer.tsx +4 -3
  19. package/src/core/conversation.ts +9 -1
  20. package/src/core/store.ts +112 -38
  21. package/src/msglist/CliMsgList.tsx +3 -2
  22. package/src/operator/node/AgentConnect.tsx +19 -15
  23. package/src/operator/node/AppearancePanel.tsx +1 -1
  24. package/src/operator/node/BranchNavigator.tsx +1 -1
  25. package/src/operator/node/BrowserPane.tsx +1 -1
  26. package/src/operator/node/Chat.tsx +16 -261
  27. package/src/operator/node/CommandPalette.tsx +1 -1
  28. package/src/operator/node/Composer.tsx +1 -1
  29. package/src/operator/node/Layout.tsx +1 -1
  30. package/src/operator/node/Markdown.tsx +6 -36
  31. package/src/operator/node/Modal.tsx +1 -1
  32. package/src/operator/node/OnboardingWizard.tsx +9 -5
  33. package/src/operator/node/Orchestrator.tsx +1 -1
  34. package/src/operator/node/ProjectLanding.tsx +1 -1
  35. package/src/operator/node/ProjectsColumn.tsx +1 -1
  36. package/src/operator/node/ProviderAuth.tsx +12 -10
  37. package/src/operator/node/RunSwitch.tsx +1 -1
  38. package/src/operator/node/Services.tsx +1 -1
  39. package/src/operator/node/SessionRail.tsx +27 -8
  40. package/src/operator/node/SessionView.tsx +1 -1
  41. package/src/operator/node/SettingsView.tsx +1 -1
  42. package/src/operator/node/TaskBoard.tsx +1 -1
  43. package/src/operator/node/TaskChanges.tsx +34 -5
  44. package/src/operator/node/TaskHero.tsx +1 -1
  45. package/src/operator/node/TasksColumn.tsx +1 -1
  46. package/src/operator/node/Transcript.tsx +10 -237
  47. package/src/operator/node/Welcome.tsx +1 -1
  48. package/src/operator/node/WorktreePrune.tsx +1 -1
  49. package/src/operator/node/boardInteraction.ts +6 -3
  50. package/src/operator/node/boardState.ts +12 -13
  51. package/src/operator/node/fileLinks.ts +4 -38
  52. package/src/operator/node/format.ts +12 -104
  53. package/src/operator/node/github.tsx +10 -8
  54. package/src/operator/node/modals.tsx +15 -13
  55. package/src/operator/node/recheckOnReturn.ts +28 -0
  56. package/src/operator/node/shared.tsx +10 -50
  57. package/src/operator/node/useTaskStream.ts +2 -2
  58. package/src/styles.css +5 -0
  59. package/src/ui/Markdown.tsx +36 -21
  60. package/src/ui/Transcript.tsx +8 -5
  61. package/src/ui/fileLinks.ts +6 -3
  62. package/src/ui/format.ts +11 -5
  63. package/src/ui/shared.tsx +11 -7
  64. package/src/ui/transcriptModel.ts +40 -9
  65. package/src/ui/useTranscriptWindow.ts +253 -0
  66. package/src/operator/node/boundedPoll.ts +0 -47
  67. package/src/operator/node/icons.tsx +0 -88
  68. package/src/operator/node/transcriptModel.ts +0 -129
package/src/core/store.ts CHANGED
@@ -16,8 +16,10 @@ import { notifyCallback } from "./callbacks";
16
16
  import { CenterError, createCenterClient, firstLine, type CenterClient, type CenterClientOptions } from "./client";
17
17
  import { addTurnsIfAbsent, applyItems, applyOlderPage, applySnapshot, closedCallback, convEnded, convKey, emptyConv, isChatTask, NEW_TASK, parseConvKey, TERMINAL_JOB, TERMINAL_TURN, turnCallback, upsertTurns, type ConvState } from "./conversation";
18
18
  import { formatAnswersText } from "../ui/format";
19
+ import { KEEP_TRANSCRIPTS, trimToTail } from "../ui/transcriptModel";
19
20
  import { backgroundBlock, charCount, MAX_MESSAGE_CHARS, serializeBlock } from "./blocks";
20
- import { cloneBoardView, reportBoardOwner, activateBoardOwner, selectedBoardOwner, mergeBoardHistory, rememberBoardFile } from "../board/context";
21
+ import { cloneBoardView, mergeBoardHistory, rememberBoardFile } from "../board/context";
22
+ import { createBoardOwners, createBoardSurfaces } from "../board/linkage";
21
23
  import { cloneWorkspaceChange, subscribeWorkspaceFiles, retryWorkspaceFiles, type WorkspaceWatchOptions } from "../board/fileWatch";
22
24
  import { retainFileScope } from "../board/fileScope";
23
25
  import type { WorkspaceChangeEvent, WorkspaceWatchStatus } from "./types";
@@ -193,6 +195,12 @@ export const jitter = (ms: number) => Math.round(ms * (0.7 + Math.random() * 0.6
193
195
  * 很多人开着页面不看时,中枢和节点不用一直为他们推数据。
194
196
  */
195
197
  export const PARK_HIDDEN_MS = 5 * 60_000;
198
+ /**
199
+ * 没人看的对话(对话、看板都不看了)最多留这么多个的记录在内存里,每个离开时截到最新 SNAPSHOT_TAIL 条(往上翻过的更早消息丢掉,
200
+ * 再往上翻时重新取):来回切换最近看过的仍然立刻显示,更早的整个释放,再打开时从实时流重新读。
201
+ * 与节点适配(useTaskStream)同一条规则、同一组常量(ui/transcriptModel 的 KEEP_TRANSCRIPTS / trimToTail)。
202
+ */
203
+ export const IDLE_CONV_MAX = KEEP_TRANSCRIPTS;
196
204
  /**
197
205
  * 401 / 403:多半是宿主的登录会话过期了。隔 30 秒再试,最多 AUTH_RETRIES 次;之后停下等事件
198
206
  * (宿主续上会话后切回页面、获得焦点或调用 store.reconnect() 即恢复),不无限重试。
@@ -359,11 +367,14 @@ export function createCliStore(options: CliStoreOptions = {}) {
359
367
  }
360
368
 
361
369
  const refreshAgain = new Set<string>();
370
+ /** 每个对话任务拉过几次详情(成功或失败都算):列表排队期间别处拉过了就不再重复。 */
371
+ const fetches = new Map<string, number>();
362
372
  async function refreshJob(jobId: string, force = false) {
363
373
  const entry = state.jobs[jobId];
364
374
  // 正在拉:拉完再补一次,免得错过这期间的变化(例如轮次刚从排队变成执行中)。
365
375
  if (entry?.loading) { refreshAgain.add(jobId); return; }
366
376
  if (!force && entry && Date.now() - entry.fetchedAt < JOB_REFRESH_GAP_MS) return;
377
+ fetches.set(jobId, (fetches.get(jobId) ?? 0) + 1);
367
378
  patchJob(jobId, { loading: true });
368
379
  try {
369
380
  const job = await client.getJob(jobId);
@@ -374,8 +385,11 @@ export function createCliStore(options: CliStoreOptions = {}) {
374
385
  firstInput = first?.turns[0]?.input ?? "";
375
386
  }
376
387
  patchJob(jobId, { job, firstInput, loading: false, error: null, fetchedAt: Date.now() });
377
- // 新数据里已包含实时用量,清零累计(这个对话任务里的每个对话)。
378
- for (const conv of Object.values(state.convs)) if (conv.jobId === jobId) patchConv(conv.key, (c) => ({ ...c, liveUsage: { fresh: 0, cacheRead: 0 } }));
388
+ // 新数据里已包含实时用量,清零累计(这个对话任务里的每个对话)。已经是 0 的不动:不然每次刷新都换一个对话对象,
389
+ // 看着它的对话组件会把整份消息重算、重画一遍。
390
+ for (const conv of Object.values(state.convs)) {
391
+ if (conv.jobId === jobId && (conv.liveUsage.fresh || conv.liveUsage.cacheRead)) patchConv(conv.key, (c) => ({ ...c, liveUsage: { fresh: 0, cacheRead: 0 } }));
392
+ }
379
393
  } catch (error) {
380
394
  patchJob(jobId, { loading: false, error: error instanceof Error ? error.message : String(error), fetchedAt: Date.now() });
381
395
  // 本地记住的对话在 center 已经不存在(被删、换了环境):从列表里拿掉。宿主给的 jobIds 不动,由宿主决定。
@@ -388,6 +402,38 @@ export function createCliStore(options: CliStoreOptions = {}) {
388
402
  if (refreshAgain.delete(jobId)) void refreshJob(jobId, true);
389
403
  }
390
404
 
405
+ /** 只要拿到详情就行(打开对话、列表补拉):已经在拉的不再排一次「拉完再补」。 */
406
+ function loadJob(jobId: string, force = false) {
407
+ if (state.jobs[jobId]?.loading) return Promise.resolve();
408
+ return refreshJob(jobId, force);
409
+ }
410
+
411
+ // 列表的后台拉取(首次加载、刷新、补拉新加进来的)共用一个并发上限 LIST_CONCURRENCY;
412
+ // 同一个任务排着或正在拉时不再排第二次。打开对话、发消息这类用户在等的不走这里。
413
+ const listQueue = new Map<string, Promise<void>>();
414
+ let listActive = 0;
415
+ const listWaiting: Array<() => void> = [];
416
+ function queueListJob(jobId: string, force: boolean): Promise<void> {
417
+ const queued = listQueue.get(jobId);
418
+ if (queued) return queued;
419
+ const seen = fetches.get(jobId) ?? 0;
420
+ const run = (async () => {
421
+ if (listActive >= LIST_CONCURRENCY) await new Promise<void>((resolve) => listWaiting.push(resolve));
422
+ listActive += 1;
423
+ try {
424
+ // 排队期间别处已经开始拉了(打开了这个对话、轮次变化):不再重复。
425
+ if ((fetches.get(jobId) ?? 0) !== seen) return;
426
+ await loadJob(jobId, force);
427
+ } finally {
428
+ listActive -= 1;
429
+ listWaiting.shift()?.();
430
+ }
431
+ })();
432
+ listQueue.set(jobId, run);
433
+ void run.finally(() => { if (listQueue.get(jobId) === run) listQueue.delete(jobId); });
434
+ return run;
435
+ }
436
+
391
437
  /**
392
438
  * 刷新列表。缺省只刷还没结束的对话(结束了的不会再变,轮询不必每次都拉);all = true 全部重拉(首次加载、手动刷新)。
393
439
  */
@@ -398,13 +444,18 @@ export function createCliStore(options: CliStoreOptions = {}) {
398
444
  const status = state.jobs[id]?.job?.status;
399
445
  return !status || !TERMINAL_JOB.has(status);
400
446
  });
401
- await runLimited(ids, LIST_CONCURRENCY, (id) => refreshJob(id, true));
447
+ await Promise.all(ids.map((id) => queueListJob(id, true)));
402
448
  set({ listLoading: false });
403
449
  } catch (error) {
404
450
  set({ listLoading: false, listError: error instanceof Error ? error.message : String(error) });
405
451
  }
406
452
  }
407
453
 
454
+ /** 列表里还没有数据的对话任务(宿主新给的、别的组件新建的):排进列表的后台拉取(列表组件用)。 */
455
+ function loadMissingJobs() {
456
+ for (const id of state.jobIds) if (!state.jobs[id]) void queueListJob(id, false);
457
+ }
458
+
408
459
  // ── 实时流(引用计数 + 断线重连) ─────────────────────
409
460
 
410
461
  // synced:这个流已经收到过快照;之后的快照是断线重连后的补齐。
@@ -430,6 +481,8 @@ export function createCliStore(options: CliStoreOptions = {}) {
430
481
  if (streams.get(key) !== entry || entry.refs <= 0) return;
431
482
  const { jobId, taskId } = parseConvKey(key);
432
483
  let ended = false, retired = false, snapshotReceived = false;
484
+ // 这条连接上节点回合结束时查过文件、当时的工具执行次数(ConvState.edits);中枢随后把这一轮标成结束时据此去重。
485
+ let roundEndEdits: number | null = null;
433
486
  let endReason: string | null = null;
434
487
  let cleanup: (() => void) | null = null;
435
488
  const owns = () => streams.get(key) === entry && entry.refs > 0 && entry.stop === retire;
@@ -526,11 +579,18 @@ export function createCliStore(options: CliStoreOptions = {}) {
526
579
  // applyItems also rejects events from another batch task's node session.
527
580
  if (next === previous) continue;
528
581
  // Observe every event: end + next user in one chunk can leave running=true.
529
- if (item.type === "node" && item.event.type === "turn_end" || item.type === "transcript") refresh = true;
582
+ if (item.type === "transcript") { refresh = true; roundEndEdits = null; }
583
+ // 节点上一个回合结束就查文件(中枢的这一轮可能还没结束:节点接着跑下一回合)。
584
+ if (item.type === "node" && item.event.type === "turn_end") { refresh = true; roundEndEdits = next.edits; }
530
585
  // 有轮次的对话以中枢把这一轮标成结束为准(节点已停、轮末保存已做);没有轮次的看节点 turn_end。
531
586
  if (item.type === "node" && item.event.type === "turn_end" && !next.turns.length) settled = true;
532
587
  if (item.type === "turn" && item.turn.status !== "merged" && TERMINAL_TURN.has(item.turn.status)
533
- && !TERMINAL_TURN.has(previous.turns.find((turn) => turn.turn_id === item.turn.turn_id)?.status ?? "")) { refresh = true; settled = true; }
588
+ && !TERMINAL_TURN.has(previous.turns.find((turn) => turn.turn_id === item.turn.turn_id)?.status ?? "")) {
589
+ settled = true;
590
+ // 节点回合结束时已经查过、之后再没有工具执行完:文件不会变,不再查第二次。
591
+ if (roundEndEdits !== next.edits) refresh = true;
592
+ roundEndEdits = null;
593
+ }
534
594
  }
535
595
  return settled ? { ...next, settled: next.settled + 1 } : next;
536
596
  });
@@ -608,7 +668,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
608
668
  function open(key: string): () => void {
609
669
  const { jobId, taskId } = parseConvKey(key);
610
670
  if (taskId === NEW_TASK) {
611
- void refreshJob(jobId);
671
+ void loadJob(jobId);
612
672
  return () => undefined;
613
673
  }
614
674
  let entry = streams.get(key);
@@ -619,7 +679,9 @@ export function createCliStore(options: CliStoreOptions = {}) {
619
679
  }
620
680
  entry.refs += 1;
621
681
  if (entry.refs === 1) {
622
- void refreshJob(jobId);
682
+ markActive(key);
683
+ // 列表正在拉这个任务(页面刚打开时常见):用它的结果,不再排一次「拉完再补」。
684
+ void loadJob(jobId);
623
685
  connect(key);
624
686
  }
625
687
  const owned = entry;
@@ -638,10 +700,35 @@ export function createCliStore(options: CliStoreOptions = {}) {
638
700
  settleDrop(current);
639
701
  syncLive();
640
702
  current.stop?.();
641
- if (!streams.has(key)) patchConv(key, (conv) => ({ ...conv, stream: conv.stream === "ended" ? "ended" : "idle" }));
703
+ if (!streams.has(key)) {
704
+ patchConv(key, (conv) => ({ ...conv, stream: conv.stream === "ended" ? "ended" : "idle" }));
705
+ markIdle(key);
706
+ }
642
707
  };
643
708
  }
644
709
 
710
+ // 没人看的对话按最近不看的先后排着(最早不看的在前),超过 IDLE_CONV_MAX 个就释放最早的那些的记录。
711
+ const idleConvs: string[] = [];
712
+ function markIdle(key: string) {
713
+ markActive(key);
714
+ idleConvs.push(key);
715
+ // 离开的对话截回快照大小:往上翻过很远的对话在后台不留几千条消息。
716
+ patchConv(key, (conv) => {
717
+ const trimmed = trimToTail(conv.msgs, conv.older);
718
+ return trimmed.msgs === conv.msgs ? conv : { ...conv, msgs: trimmed.msgs, older: trimmed.older };
719
+ });
720
+ if (idleConvs.length <= IDLE_CONV_MAX) return;
721
+ const drop = idleConvs.splice(0, idleConvs.length - IDLE_CONV_MAX).filter((k) => !streams.has(k) && state.convs[k]);
722
+ if (!drop.length) return;
723
+ const convs = { ...state.convs };
724
+ for (const k of drop) delete convs[k];
725
+ set({ convs });
726
+ }
727
+ function markActive(key: string) {
728
+ const at = idleConvs.indexOf(key);
729
+ if (at >= 0) idleConvs.splice(at, 1);
730
+ }
731
+
645
732
  // ── 发消息 / 新建 / 撤回 / 结束 ───────────────────────
646
733
 
647
734
  /**
@@ -830,7 +917,8 @@ export function createCliStore(options: CliStoreOptions = {}) {
830
917
  * 同一个对话同一时刻只取一页。返回取到没有。
831
918
  */
832
919
  const loadingOlder = new Set<string>();
833
- async function loadOlder(key: string): Promise<boolean> {
920
+ /** beforeApply:这一页接上去之前调用(界面趁这时量下第一行的位置,接上后视图不跳)。 */
921
+ async function loadOlder(key: string, opts: { beforeApply?: () => void } = {}): Promise<boolean> {
834
922
  const conv = state.convs[key];
835
923
  // 节点会话:一次性任务是正在看的那个对话,对话任务是流里这个对话的远端任务。
836
924
  const remote = conv ? conv.remoteTaskId ?? conv.task?.remote_task_id ?? null : null;
@@ -840,6 +928,9 @@ export function createCliStore(options: CliStoreOptions = {}) {
840
928
  try {
841
929
  const page = await client.history(key, remote, first.id, 200);
842
930
  // 取的过程中换了节点会话(重建、续派切过去):这一页作废。
931
+ const current = state.convs[key];
932
+ if (!current || (current.remoteTaskId ?? current.task?.remote_task_id ?? null) !== remote || current.msgs[0]?.id !== first.id) return false;
933
+ notifyCallback(opts.beforeApply, undefined);
843
934
  patchConv(key, (c) => ((c.remoteTaskId ?? c.task?.remote_task_id ?? null) === remote && c.msgs[0]?.id === first.id ? applyOlderPage(c, page.messages ?? [], page.older) : c));
844
935
  return true;
845
936
  } catch (error) {
@@ -851,7 +942,9 @@ export function createCliStore(options: CliStoreOptions = {}) {
851
942
  }
852
943
 
853
944
  // 同一个仓库里登记的看板(cli-board)。后登记的优先(例如弹出的看板盖在常驻的上面)。
854
- const surfaces: { surface: BoardSurface; owner?: string; inline?: boolean; priority?: () => number }[] = [];
945
+ // 「在看板里打开」的接收方和各看板报告的样子:规则与节点适配共用(board/linkage)。
946
+ const surfaces = createBoardSurfaces<{ jobId: string; target: OpenTarget }>();
947
+ const boardOwners = createBoardOwners();
855
948
  const visibleLists = new Set<string>();
856
949
  function reportListPresence(owner: string, visible: boolean) {
857
950
  if (visible) visibleLists.add(owner); else visibleLists.delete(owner);
@@ -916,7 +1009,6 @@ export function createCliStore(options: CliStoreOptions = {}) {
916
1009
  */
917
1010
  // 每个看板各自报告(按 owner 记),生效的是最近报告、还开着的那个;一个看板撤掉只撤它自己的。
918
1011
  const viewSources = new Map<string, Map<string, BoardRef>>();
919
- const boardSources = new Map<string, Map<string, BoardView>>();
920
1012
  /** 记下 owner 的报告,返回现在生效的那个(最近报告的)。 */
921
1013
  function latestReport<T>(sources: Map<string, Map<string, T>>, key: string, owner: string, value: T | null): T | null {
922
1014
  const m = sources.get(key) ?? new Map<string, T>();
@@ -949,16 +1041,12 @@ export function createCliStore(options: CliStoreOptions = {}) {
949
1041
  if(contextChanged&&state.boardContext[key]===retained)emit({type:"board_context",jobId:key,view:cloneBoardView(retained!)});
950
1042
  }
951
1043
  function reportBoard(key:string,view:BoardView|null,owner="default") {
952
- const sources=boardSources.get(key)??new Map<string,BoardView>();
953
1044
  let history=state.boardContext[key]?.history??[];
954
- if(view){history=mergeBoardHistory(history,sources.get(owner),view);reportBoardOwner(sources,owner,cloneBoardView(view));}
955
- else sources.delete(owner);
956
- if(sources.size)boardSources.set(key,sources);else boardSources.delete(key);
957
- const top=selectedBoardOwner(sources)?.[1]??null;applyBoardSelection(key,top,history);
1045
+ if(view)history=mergeBoardHistory(history,boardOwners.previous(key,owner),view);
1046
+ const top=boardOwners.report(key,owner,view?cloneBoardView(view):null);applyBoardSelection(key,top,history);
958
1047
  }
959
1048
  function activateBoard(key:string,owner:string) {
960
- const sources=boardSources.get(key);if(!sources)return;
961
- const top=activateBoardOwner(sources,owner)?.[1];if(!top)return;
1049
+ const top=boardOwners.activate(key,owner);if(!top)return;
962
1050
  const file=top.current??(top.file?{path:top.file,sourceId:key}:null);
963
1051
  const history=file?rememberBoardFile(state.boardContext[key]?.history??[],file):state.boardContext[key]?.history??[];
964
1052
  applyBoardSelection(key,top,history);
@@ -1037,14 +1125,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
1037
1125
  }
1038
1126
 
1039
1127
  function openInBoard(jobId: string, target: OpenTarget, preferredInlineOwner?: string): boolean {
1040
- let handled = false;
1041
- const available = [...surfaces].reverse().filter(item => !preferredInlineOwner || !item.inline || item.owner === preferredInlineOwner)
1042
- .sort((a, b) => (b.priority?.() ?? 2) - (a.priority?.() ?? 2));
1043
- for (const { surface } of available) {
1044
- try { handled = surface(jobId, { ...target }); }
1045
- catch (error) { console.error("[@xgspz/cli-ui] board surface callback failed", error); }
1046
- if (handled) break;
1047
- }
1128
+ const handled = surfaces.open({ jobId, target }, (item) => !preferredInlineOwner || !item.inline || item.owner === preferredInlineOwner);
1048
1129
  emit({ type: "open", jobId, target, handled });
1049
1130
  return handled;
1050
1131
  }
@@ -1058,9 +1139,8 @@ export function createCliStore(options: CliStoreOptions = {}) {
1058
1139
  openInBoard,
1059
1140
  /** 看板登记自己;返回注销函数。 */
1060
1141
  registerBoard(surface: BoardSurface, options: { owner?: string; inline?: boolean; priority?: () => number } = {}) {
1061
- const entry = { surface, ...options };
1062
- surfaces.push(entry);
1063
- return () => { const i = surfaces.indexOf(entry); if (i >= 0) surfaces.splice(i, 1); };
1142
+ // 每个接收方拿到自己的一份 target,改了也不影响下一个。
1143
+ return surfaces.register(({ jobId, target }) => surface(jobId, { ...target }), options);
1064
1144
  },
1065
1145
  subscribe(fn: () => void) { listeners.add(fn); return () => { listeners.delete(fn); }; },
1066
1146
  on(fn: (event: CliEvent) => void) { eventListeners.add(fn); return () => { eventListeners.delete(fn); }; },
@@ -1070,6 +1150,8 @@ export function createCliStore(options: CliStoreOptions = {}) {
1070
1150
  emit({ type: "select", jobId });
1071
1151
  },
1072
1152
  setJobIds, addJob, removeJob, replaceJob, refreshJob, refreshAll,
1153
+ /** 列表组件用:补拉列表里还没有数据的对话任务。 */
1154
+ loadMissingJobs,
1073
1155
  addRef, removeRef, takeRefs, restoreRefs,
1074
1156
  setViewing, dismissViewing, takeViewing, restoreViewing,
1075
1157
  /** 看板报告自己的样子(看板组件用)。 */
@@ -1085,14 +1167,6 @@ export function createCliStore(options: CliStoreOptions = {}) {
1085
1167
  export type CliStore = ReturnType<typeof createCliStore>;
1086
1168
 
1087
1169
 
1088
- async function runLimited<T>(items: T[], limit: number, fn: (item: T) => Promise<unknown>) {
1089
- let index = 0;
1090
- const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
1091
- while (index < items.length) await fn(items[index++]);
1092
- });
1093
- await Promise.all(workers);
1094
- }
1095
-
1096
1170
  const REF_MAX = 20;
1097
1171
  const REF_TEXT_MAX = 4_000;
1098
1172
 
@@ -343,8 +343,9 @@ function MsgListInner({ jobIds, selectedId, onSelect, onNew, title = "对话", c
343
343
  };
344
344
  }, [store]);
345
345
 
346
- // 新加进来的对话(别的组件新建的 / 接续出来的)还没数据:补拉一次。
347
- useEffect(() => { for (const id of ids) if (!jobs[id]) void store.refreshJob(id); }, [ids, jobs, store]);
346
+ // 新加进来的对话(宿主后给的、别的组件新建的 / 接续出来的)还没数据:补拉一次。
347
+ // 和首次加载、刷新共用列表的并发上限;已经在排、在拉的不重复。
348
+ useEffect(() => { store.loadMissingJobs(); }, [ids, store]);
348
349
 
349
350
  const cards = useMemo(() => ids.map((id) => cardView(id, jobs[id])), [ids, jobs]);
350
351
  const q = query.trim().toLowerCase();
@@ -1,14 +1,14 @@
1
1
  "use client";
2
2
 
3
3
  import { useCallback, useEffect, useRef, useState } from "react";
4
- import { Icon } from "./icons";
4
+ import { Icon } from "../../ui/icons";
5
5
  import { jget, jsend } from "./api";
6
6
  import { Modal } from "./Modal";
7
7
  import { AUTH_BANNER_HINT } from "./protocol/authFailure";
8
8
  import { isOptInAgent } from "./protocol/agents/optIn";
9
9
  import { ProviderAuthPanel } from "./ProviderAuth";
10
10
  import type { AgentInfo, AgentInfoT, AgentsResponseT, AgentLoginT, ClaudeVerifyT } from "./types";
11
- import { useBoundedPoll } from "./boundedPoll";
11
+ import { useRecheckOnReturn } from "./recheckOnReturn";
12
12
 
13
13
  const NUDGE_DISMISSED = "orch_agent_nudge_dismissed";
14
14
 
@@ -247,7 +247,8 @@ function BedrockConnect({ agent, onConnected, compatible = false }: { agent: Age
247
247
  // setting, or a device-code `aws sso login` for the SSO profile) and surfaces
248
248
  // the device URL + one-time code here. Clicking through lands on the IdP
249
249
  // (silent if that session is still live) then the AWS approval page; nothing is
250
- // pasted back — the command exits on approval and the poll flips to success.
250
+ // pasted back — the command exits on approval; coming back to this tab (or 检查状态)
251
+ // re-reads the login and flips it to success.
251
252
  function BedrockRefresh({ agent, onConnected }: { agent: AgentInfoT; onConnected?: () => void }) {
252
253
  const [login, setLogin] = useState<AgentLoginT | null>(null);
253
254
  const [busy, setBusy] = useState(false);
@@ -283,14 +284,15 @@ function BedrockRefresh({ agent, onConnected }: { agent: AgentInfoT; onConnected
283
284
  }
284
285
  };
285
286
 
286
- // Re-check while the CLI waits for the browser-side approval — for a bounded
287
- // window, and again whenever the user comes back to this tab (see useBoundedPoll).
288
- useBoundedPoll(!!login && (login.status === "starting" || login.status === "awaiting"), () => {
287
+ // Re-check while the CLI waits for the browser-side approval — whenever the user
288
+ // comes back to this tab, or presses 检查状态 (useRecheckOnReturn; no polling).
289
+ const checkLogin = () => {
289
290
  jget<AgentLoginT>(base).then((l) => {
290
291
  setLogin(l);
291
292
  if (l.status === "success") succeed();
292
293
  }).catch(() => {});
293
- }, 1800, 10 * 60_000);
294
+ };
295
+ useRecheckOnReturn(!!login && (login.status === "starting" || login.status === "awaiting"), checkLogin);
294
296
 
295
297
  if (login?.status === "success") {
296
298
  return (
@@ -323,14 +325,14 @@ function BedrockRefresh({ agent, onConnected }: { agent: AgentInfoT; onConnected
323
325
  2. 如果页面要求输入验证码,请输入{login.code ? ":" : "页面上显示的验证码。"}
324
326
  </div>
325
327
  {login.code && <div className="ctx-mono" style={{ fontSize: 20, letterSpacing: 2, margin: "8px 0 2px", fontWeight: 600 }}>{login.code}</div>}
326
- <div className="wiz-verify" style={{ marginTop: 10 }}><span className="wiz-spin" /> <span>等待你在浏览器中确认…</span></div>
328
+ <div className="wiz-verify" style={{ marginTop: 10 }}><span className="wiz-spin" /> <span>等待你在浏览器中确认…回到这个页面时会自动检查。</span> <button className="linkbtn" onClick={checkLogin}>检查状态</button></div>
327
329
  <LogToggle log={login.log} show={showLog} setShow={setShowLog} />
328
330
  </div>
329
331
  );
330
332
  }
331
333
 
332
334
  if (login?.status === "starting") {
333
- return <div className="wiz-verify" style={{ marginTop: 12 }}><span className="wiz-spin" /> <span>正在启动 AWS 刷新…获取登录链接。</span></div>;
335
+ return <div className="wiz-verify" style={{ marginTop: 12 }}><span className="wiz-spin" /> <span>正在启动 AWS 刷新…获取登录链接。</span> <button className="linkbtn" onClick={checkLogin}>检查状态</button></div>;
334
336
  }
335
337
 
336
338
  // idle — the refresh CTA under the verify button
@@ -426,14 +428,15 @@ function SubscriptionConnect({ agent, onConnected, compact }: { agent: AgentInfo
426
428
  };
427
429
 
428
430
  // Re-check while the CLI waits for the user to authorize / exchange the code —
429
- // for a bounded window, and again whenever the user comes back (useBoundedPoll).
430
- useBoundedPoll(!checkingAuth && !(existingAuth?.authenticated && !chooseLogin) && !!login
431
- && (login.status === "starting" || login.status === "awaiting" || login.status === "submitting"), () => {
431
+ // whenever the user comes back to this tab, or presses 检查状态 (no polling).
432
+ const checkLogin = () => {
432
433
  jget<AgentLoginT>(base).then((l) => {
433
434
  setLogin(l);
434
435
  if (l.status === "success") succeed();
435
436
  }).catch(() => {});
436
- }, 1800, 10 * 60_000);
437
+ };
438
+ useRecheckOnReturn(!checkingAuth && !(existingAuth?.authenticated && !chooseLogin) && !!login
439
+ && (login.status === "starting" || login.status === "awaiting" || login.status === "submitting"), checkLogin);
437
440
 
438
441
  const submitCode = async () => {
439
442
  if (!code.trim()) return;
@@ -511,6 +514,7 @@ function SubscriptionConnect({ agent, onConnected, compact }: { agent: AgentInfo
511
514
  {login.status === "submitting" || busy ? "验证中…" : "提交"}
512
515
  </button>
513
516
  </div>
517
+ {login.status === "submitting" && <div className="hlp" style={{ marginTop: 6 }}>正在验证授权码… <button className="linkbtn" onClick={checkLogin}>检查状态</button></div>}
514
518
  </>
515
519
  ) : (
516
520
  <>
@@ -518,7 +522,7 @@ function SubscriptionConnect({ agent, onConnected, compact }: { agent: AgentInfo
518
522
  2. 出现提示时,输入这个一次性验证码{login.code ? "" : "(显示在登录页上)"}:
519
523
  </div>
520
524
  {login.code && <div className="ctx-mono" style={{ fontSize: 20, letterSpacing: 2, margin: "8px 0 2px", fontWeight: 600 }}>{login.code}</div>}
521
- <div className="wiz-verify" style={{ marginTop: 10 }}><span className="wiz-spin" /> <span>等待你在浏览器中完成授权…</span></div>
525
+ <div className="wiz-verify" style={{ marginTop: 10 }}><span className="wiz-spin" /> <span>等待你在浏览器中完成授权…回到这个页面时会自动检查。</span> <button className="linkbtn" onClick={checkLogin}>检查状态</button></div>
522
526
  </>
523
527
  )}
524
528
  <LogToggle log={login.log} show={showLog} setShow={setShowLog} />
@@ -527,7 +531,7 @@ function SubscriptionConnect({ agent, onConnected, compact }: { agent: AgentInfo
527
531
  }
528
532
 
529
533
  if (login?.status === "starting") {
530
- return <div className="wiz-verify"><span className="wiz-spin" /> <span>正在启动登录…准备授权链接。</span></div>;
534
+ return <div className="wiz-verify"><span className="wiz-spin" /> <span>正在启动登录…准备授权链接。</span> <button className="linkbtn" onClick={checkLogin}>检查状态</button></div>;
531
535
  }
532
536
 
533
537
  // idle — initial CTA
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { Icon } from "./icons";
3
+ import { Icon } from "../../ui/icons";
4
4
  import type { Appearance } from "./types";
5
5
 
6
6
  export function AppearancePanel({ appearance, setAppearance, onClose }: { appearance: Appearance; setAppearance: (k: keyof Appearance, v: string) => void; onClose: () => void }) {
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { useEffect, useRef } from "react";
3
- import { Icon } from "./icons";
3
+ import { Icon } from "../../ui/icons";
4
4
  import { SLABEL, type TaskRow } from "./types";
5
5
  import { branchOptions } from "./branchFlow";
6
6
 
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { useMemo, useState } from "react";
3
- import { Icon } from "./icons";
3
+ import { Icon } from "../../ui/icons";
4
4
  import { BrowserPane as BrowserSurface } from "../../board/BrowserPane";
5
5
  import { createOperatorBoardBackend } from "./boardBackend";
6
6
  import type { BrowserStatus } from "../../core/types";