@spzxg/cli-ui 0.6.5 → 0.6.7

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 (80) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +109 -30
  3. package/dist/cli-ui-embed-CB7ibAtF.js +49558 -0
  4. package/dist/{cli-ui-index-D4qfPHMd.js → cli-ui-index-BmwOLWFI.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-DC9898pC.js → cli-ui-jszip.min-BKNOJnhm.js} +1 -1
  6. package/dist/{cli-ui-jszip.min-DpEl0G1z.js → cli-ui-jszip.min-Cq0WjNYb.js} +2 -2
  7. package/dist/{cli-ui-xlsx-zs7oEtPj.js → cli-ui-xlsx-Sl7ypvSe.js} +1 -1
  8. package/dist/cli-ui.css +1 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +11 -8
  11. package/src/board/BrowserPane.tsx +198 -53
  12. package/src/board/CliBoard.tsx +128 -100
  13. package/src/board/FileViewer.tsx +276 -136
  14. package/src/board/FilesPane.tsx +129 -40
  15. package/src/board/HtmlReadingPreview.tsx +52 -0
  16. package/src/board/MarkdownPreview.tsx +134 -0
  17. package/src/board/PanelControls.tsx +19 -7
  18. package/src/board/fileKind.ts +138 -0
  19. package/src/board/fileOps.ts +9 -2
  20. package/src/board/fileScope.ts +29 -0
  21. package/src/board/fileSync.ts +343 -0
  22. package/src/board/fileSyncCache.ts +250 -0
  23. package/src/board/fileSyncProcessor.ts +71 -0
  24. package/src/board/fileSyncQueue.ts +56 -0
  25. package/src/board/fileSyncTransport.ts +85 -0
  26. package/src/board/fileWatch.ts +177 -70
  27. package/src/board/folderDownload.ts +39 -5
  28. package/src/board/frameQuote.ts +12 -10
  29. package/src/board/htmlReading.ts +148 -0
  30. package/src/board/textLoad.ts +18 -41
  31. package/src/board/useBoardUpload.tsx +51 -0
  32. package/src/board/useBoardVisibility.ts +35 -0
  33. package/src/board/workspace.ts +53 -11
  34. package/src/chat/CliChat.tsx +105 -55
  35. package/src/chat/Composer.tsx +55 -21
  36. package/src/chat/GroupChat.tsx +113 -0
  37. package/src/chat/RunControls.tsx +132 -42
  38. package/src/chat/draft.ts +5 -2
  39. package/src/core/blocks.ts +17 -5
  40. package/src/core/client.ts +88 -19
  41. package/src/core/conversation.ts +1 -0
  42. package/src/core/documentUpload.ts +196 -0
  43. package/src/core/provider.tsx +7 -0
  44. package/src/core/store.ts +134 -36
  45. package/src/core/types.ts +31 -10
  46. package/src/index.ts +3 -0
  47. package/src/layout/split.ts +67 -30
  48. package/src/msglist/CliMsgList.tsx +18 -5
  49. package/src/operator/node/Chat.tsx +7 -8
  50. package/src/operator/node/Composer.tsx +99 -72
  51. package/src/operator/node/Orchestrator.tsx +1 -1
  52. package/src/operator/node/SessionRail.tsx +71 -32
  53. package/src/operator/node/SessionView.tsx +25 -25
  54. package/src/operator/node/Transcript.tsx +28 -11
  55. package/src/operator/node/boardBackend.ts +6 -23
  56. package/src/operator/node/boardEvents.ts +21 -3
  57. package/src/operator/node/boardState.ts +18 -1
  58. package/src/operator/node/boardWorkspace.ts +4 -1
  59. package/src/operator/node/composerDraft.ts +6 -2
  60. package/src/operator/node/format.ts +4 -2
  61. package/src/operator/node/liveStream.ts +14 -2
  62. package/src/operator/node/protocol/types.ts +2 -2
  63. package/src/operator/node/useTaskStream.ts +8 -5
  64. package/src/operator.base.css +148 -37
  65. package/src/operator.css +148 -37
  66. package/src/styles.css +274 -32
  67. package/src/ui/Markdown.tsx +2 -2
  68. package/src/ui/Popover.tsx +32 -14
  69. package/src/ui/QueuedMessages.tsx +69 -0
  70. package/src/ui/ReferenceCard.tsx +28 -0
  71. package/src/ui/TextBlock.tsx +6 -4
  72. package/src/ui/Transcript.tsx +27 -12
  73. package/src/ui/UploadProgress.tsx +46 -0
  74. package/src/ui/WorkspaceDrawer.tsx +64 -0
  75. package/src/ui/WorkspaceTabs.tsx +60 -0
  76. package/src/ui/format.ts +3 -1
  77. package/src/ui/reference.ts +40 -0
  78. package/src/ui/useChatScrollbar.ts +29 -0
  79. package/src/ui/useComposerAutosize.ts +54 -0
  80. package/dist/cli-ui-embed-BmLN2Jjg.js +0 -43290
package/src/core/store.ts CHANGED
@@ -19,6 +19,7 @@ import { formatAnswersText } from "../ui/format";
19
19
  import { backgroundBlock, charCount, MAX_MESSAGE_CHARS, serializeBlock } from "./blocks";
20
20
  import { cloneBoardView, reportBoardOwner, activateBoardOwner, selectedBoardOwner, mergeBoardHistory, rememberBoardFile } from "../board/context";
21
21
  import { cloneWorkspaceChange, subscribeWorkspaceFiles, retryWorkspaceFiles, type WorkspaceWatchOptions } from "../board/fileWatch";
22
+ import { retainFileScope } from "../board/fileScope";
22
23
  import type { WorkspaceChangeEvent, WorkspaceWatchStatus } from "./types";
23
24
  import { createWorkspace, type BoardView, type CliWorkspace } from "../board/workspace";
24
25
  import type { AskAnswers, AskQuestion, CenterJob, CenterTurn, ConversationClosedCallback, ConversationTurnCallback, NewConversationOptions } from "./types";
@@ -52,6 +53,12 @@ export interface CliState {
52
53
  viewing: Record<string, { ref: BoardRef; off: boolean }>;
53
54
  /** 看板现在的样子(按对话键):正在看的文件、开着的文件页签、当前页签。没有看板时没有。 */
54
55
  boards: Record<string, BoardView>;
56
+ /** 当前对话是否有展开且可见的外置看板;Chat 内嵌看板不计入。 */
57
+ externalBoards: Record<string, boolean>;
58
+ /** 同一工作区中是否有展开且可见的对话列表。 */
59
+ listVisible: boolean;
60
+ /** 外置看板的模块和只读限制,内嵌视图沿用同一约束。 */
61
+ externalBoardOptions: Record<string, { availableTabs: ("files" | "changes" | "browser")[]; readOnly: boolean }>;
55
62
  /** 最近看板背景,收起/卸载后保留,供下一条消息被动参考。 */
56
63
  boardContext: Record<string, BoardView>;
57
64
  /** 业务方补充的项目背景(按对话键;"" = 还没建的新对话),随下一条消息发出(见 appendContext)。 */
@@ -121,6 +128,9 @@ export type CliEvent =
121
128
  export function cloneWorkspaceEvent(event:CliEvent):CliEvent {
122
129
  if(event.type==='files_changed')return cloneWorkspaceChange(event);
123
130
  if(event.type==='files_watch')return {...event};
131
+ if(event.type==='board_ref')return {...event,refs:event.refs.map(ref=>({...ref}))};
132
+ if(event.type==='context')return {...event,pending:event.pending.map(item=>({...item})),...(event.sent?{sent:event.sent.map(item=>({...item}))}:{})};
133
+ if(event.type==='open')return {...event,target:{...event.target}};
124
134
  if(event.type==='board_context')return {...event,view:cloneBoardView(event.view)};
125
135
  if(event.type==='board_change')return {...event,view:event.view?cloneBoardView(event.view):null};
126
136
  return event;
@@ -219,7 +229,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
219
229
  // 列表只放宿主给的 jobIds 和本页里新建 / 接续出来的对话:不从 center 发现,也不从本地存储恢复——
220
230
  // 不传就不加载,免得把这个产品线令牌下别的业务、别的用户的对话列出来。
221
231
 
222
- let state: CliState = { selected: null, jobIds: [], jobs: {}, convs: {}, listLoading: false, listError: null, refs: {}, viewing: {}, boards: {}, boardContext: {}, context: {} };
232
+ let state: CliState = { selected: null, jobIds: [], jobs: {}, convs: {}, listLoading: false, listError: null, refs: {}, viewing: {}, boards: {}, externalBoards: {}, listVisible: false, externalBoardOptions: {}, boardContext: {}, context: {} };
223
233
  const listeners = new Set<() => void>();
224
234
  const eventListeners = new Set<(event: CliEvent) => void>();
225
235
  const settledTurns = new Set<string>();
@@ -384,24 +394,28 @@ export function createCliStore(options: CliStoreOptions = {}) {
384
394
  function connect(key: string) {
385
395
  const entry = streams.get(key);
386
396
  if (!entry || entry.refs <= 0) return;
397
+ const previous = entry.stop;
398
+ entry.stop = null;
399
+ previous?.();
400
+ if (streams.get(key) !== entry || entry.refs <= 0) return;
387
401
  const { jobId, taskId } = parseConvKey(key);
388
- patchConv(key, (conv) => ({ ...conv, stream: entry.attempt ? "reconnecting" : "connecting", streamError: null }));
389
- let ended = false;
390
- entry.stop = client.stream(jobId, {
391
- onOpen: () => { entry.attempt = 0; patchConv(key, (conv) => ({ ...conv, stream: "live", streamError: null })); },
392
- onSnapshot: (snapshot) => patchConv(key, (conv) => applySnapshot(conv, snapshot)),
393
- onChunk: (items) => patchConv(key, (conv) => applyItems(conv, items)),
394
- onEnd: (end) => {
395
- ended = true;
396
- // 接续的新对话:指定了对话的跟到接续它的那个对话(还没有就不跟,再发消息时 center 会开出来);只有 job_id 的照旧跟新 job。
397
- const continuedBy = taskId ? (end.continued_by_job_id && end.continued_by_task_id ? convKey(end.continued_by_job_id, end.continued_by_task_id) : null) : end.continued_by_job_id ?? null;
398
- patchConv(key, (conv) => ({ ...conv, stream: "ended", endReason: end.reason, continuedBy, nodeRunning: false }));
399
- void refreshJob(jobId, true);
400
- },
401
- onClose: (error) => {
402
- entry.stop = null;
403
- const current = streams.get(key);
404
- if (!current || current.refs <= 0) return;
402
+ let ended = false, retired = false, snapshotReceived = false;
403
+ let cleanup: (() => void) | null = null;
404
+ const owns = () => streams.get(key) === entry && entry.refs > 0 && entry.stop === retire;
405
+ const active = () => !retired && owns();
406
+ const refreshFiles = () => { if (active() && client.board) retryWorkspaceFiles(client.board, key); };
407
+ const retire = () => {
408
+ if (retired) return;
409
+ retired = true;
410
+ if (entry.stop === retire) entry.stop = null;
411
+ const dispose = cleanup; cleanup = null;
412
+ notifyCallback(dispose ?? undefined, undefined);
413
+ };
414
+ const close = (error: CenterError | null) => {
415
+ if (!active()) return;
416
+ retire();
417
+ const current = streams.get(key);
418
+ if (current !== entry || current.refs <= 0 || current.stop) return;
405
419
  // 对话结束(end)不再连;中枢停机(server_shutdown)稍后重连。
406
420
  if (ended && state.convs[key]?.endReason !== "server_shutdown") return;
407
421
  // 404:对话不存在,重连也没用。
@@ -422,8 +436,56 @@ export function createCliStore(options: CliStoreOptions = {}) {
422
436
  current.attempt += 1;
423
437
  patchConv(key, (conv) => ({ ...conv, stream: "reconnecting", streamError: error?.message ?? null }));
424
438
  current.timer = setTimeout(() => { current.timer = null; connect(key); }, jitter(delay));
425
- },
426
- }, undefined, taskId);
439
+ };
440
+ entry.stop = retire;
441
+ patchConv(key, (conv) => ({ ...conv, stream: entry.attempt ? "reconnecting" : "connecting", streamError: null }));
442
+ if (!active()) return;
443
+ try {
444
+ cleanup = client.stream(jobId, {
445
+ onOpen: () => { if (!active()) return; entry.attempt = 0; patchConv(key, (conv) => ({ ...conv, stream: "live", streamError: null })); },
446
+ onSnapshot: (snapshot) => {
447
+ if (!active()) return;
448
+ patchConv(key, (conv) => applySnapshot(conv, snapshot));
449
+ // Each connection catches up once, including rounds completed while disconnected.
450
+ if (!snapshotReceived) { snapshotReceived = true; refreshFiles(); }
451
+ },
452
+ onChunk: (items) => {
453
+ if (!active()) return;
454
+ let refresh = false;
455
+ patchConv(key, (conv) => {
456
+ let next = conv;
457
+ for (const item of items) {
458
+ const previous = next;
459
+ next = applyItems(next, [item]);
460
+ // applyItems also rejects events from another batch task's node session.
461
+ if (next === previous) continue;
462
+ // Observe every event: end + next user in one chunk can leave running=true.
463
+ if (item.type === "node" && item.event.type === "turn_end" || item.type === "transcript") refresh = true;
464
+ if (item.type === "turn" && item.turn.status !== "merged" && TERMINAL_TURN.has(item.turn.status)
465
+ && !TERMINAL_TURN.has(previous.turns.find((turn) => turn.turn_id === item.turn.turn_id)?.status ?? "")) refresh = true;
466
+ }
467
+ return next;
468
+ });
469
+ if (refresh) refreshFiles();
470
+ },
471
+ onEnd: (end) => {
472
+ if (!active() || ended) return;
473
+ ended = true;
474
+ const continuedBy = taskId ? (end.continued_by_job_id && end.continued_by_task_id ? convKey(end.continued_by_job_id, end.continued_by_task_id) : null) : end.continued_by_job_id ?? null;
475
+ patchConv(key, (conv) => ({ ...conv, stream: "ended", endReason: end.reason, continuedBy, nodeRunning: false }));
476
+ refreshFiles();
477
+ void refreshJob(jobId, true);
478
+ },
479
+ onClose: close,
480
+ }, undefined, taskId);
481
+ // Some custom transports close synchronously before returning cleanup.
482
+ if (retired || !owns()) {
483
+ const dispose = cleanup; cleanup = null;
484
+ notifyCallback(dispose ?? undefined, undefined);
485
+ }
486
+ } catch (error) {
487
+ close(error instanceof CenterError ? error : new CenterError(String(error), "NETWORK", 0));
488
+ }
427
489
  }
428
490
 
429
491
  // 网络恢复 / 标签页切回来时,正在退避等待的连接立刻重连,不用等完退避时间。
@@ -472,16 +534,22 @@ export function createCliStore(options: CliStoreOptions = {}) {
472
534
  void refreshJob(jobId);
473
535
  connect(key);
474
536
  }
537
+ const owned = entry;
538
+ let active = true;
475
539
  return () => {
540
+ if (!active) return;
541
+ active = false;
476
542
  const current = streams.get(key);
477
- if (!current) return;
543
+ if (current !== owned) return;
478
544
  current.refs -= 1;
479
545
  if (current.refs > 0) return;
480
- current.stop?.();
481
- if (current.timer) clearTimeout(current.timer);
546
+ // Retire the registry before calling host cleanup: it may synchronously
547
+ // acquire a new lease for the same task.
482
548
  streams.delete(key);
549
+ if (current.timer) { clearTimeout(current.timer); current.timer = null; }
483
550
  syncLive();
484
- patchConv(key, (conv) => ({ ...conv, stream: conv.stream === "ended" ? "ended" : "idle" }));
551
+ current.stop?.();
552
+ if (!streams.has(key)) patchConv(key, (conv) => ({ ...conv, stream: conv.stream === "ended" ? "ended" : "idle" }));
485
553
  };
486
554
  }
487
555
 
@@ -552,6 +620,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
552
620
  return target;
553
621
  }
554
622
  if (!key || !ref) {
623
+ const selectedBeforeCreate = state.selected;
555
624
  let newId: string;
556
625
  if (createConversation) {
557
626
  newId = await createConversation(text);
@@ -563,7 +632,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
563
632
  }
564
633
  addJob(newId);
565
634
  // 共享选中只由「跟着选中走」的组件改;传了 jobId、自己接管的对话组件新建对话不去动别人的选中。
566
- if (overrides.select !== false) set({ selected: newId });
635
+ if (overrides.select !== false && state.selected === selectedBeforeCreate) set({ selected: newId });
567
636
  emit({ type: "job_change", jobId: newId, previousJobId: null, taskId: null, key: newId, previousKey: null });
568
637
  void refreshJob(newId, true);
569
638
  return newId;
@@ -690,12 +759,33 @@ export function createCliStore(options: CliStoreOptions = {}) {
690
759
  }
691
760
 
692
761
  // 同一个仓库里登记的看板(cli-board)。后登记的优先(例如弹出的看板盖在常驻的上面)。
693
- const surfaces: BoardSurface[] = [];
762
+ const surfaces: { surface: BoardSurface; owner?: string; inline?: boolean; priority?: () => number }[] = [];
763
+ const visibleLists = new Set<string>();
764
+ function reportListPresence(owner: string, visible: boolean) {
765
+ if (visible) visibleLists.add(owner); else visibleLists.delete(owner);
766
+ const listVisible = visibleLists.size > 0;
767
+ if (state.listVisible !== listVisible) set({ listVisible });
768
+ }
769
+ const externalBoards = new Map<string, Map<string, { visible: boolean; availableTabs?: readonly ("files" | "changes" | "browser")[]; readOnly?: boolean }>>();
770
+ function reportBoardPresence(key: string, owner: string, visible: boolean | null, options: { availableTabs?: readonly ("files" | "changes" | "browser")[]; readOnly?: boolean } = {}) {
771
+ const owners = externalBoards.get(key) ?? new Map<string, { visible: boolean; availableTabs?: readonly ("files" | "changes" | "browser")[]; readOnly?: boolean }>();
772
+ if (visible === null) owners.delete(owner); else owners.set(owner, { visible, ...options });
773
+ if (owners.size) externalBoards.set(key, owners); else externalBoards.delete(key);
774
+ const values = [...owners.values()];
775
+ const shown = values.some(value => value.visible);
776
+ const limits = owners.size ? { availableTabs: (["files", "changes", "browser"] as const).filter(tab => values.every(value => !value.availableTabs || value.availableTabs.includes(tab))), readOnly: values.some(value => value.readOnly) } : undefined;
777
+ if (!!state.externalBoards[key] === shown && JSON.stringify(state.externalBoardOptions[key]) === JSON.stringify(limits)) return;
778
+ const next = { ...state.externalBoards };
779
+ if (shown) next[key] = true; else delete next[key];
780
+ const configurations = { ...state.externalBoardOptions };
781
+ if (limits) configurations[key] = limits; else delete configurations[key];
782
+ set({ externalBoards: next, externalBoardOptions: configurations });
783
+ }
694
784
  // ── 看板 → 对话的引用 ─────────────────────────────
695
785
 
696
786
  function setRefs(key: string, refs: BoardRef[]) {
697
787
  const next = { ...state.refs };
698
- if (refs.length) next[key] = refs; else delete next[key];
788
+ if (refs.length) next[key] = refs.map(ref => ({ ...ref })); else delete next[key];
699
789
  set({ refs: next });
700
790
  emit({ type: "board_ref", key, refs });
701
791
  }
@@ -784,12 +874,13 @@ export function createCliStore(options: CliStoreOptions = {}) {
784
874
  const fileSubscriptions = new Map<string,{listeners:Set<{change?:(event:WorkspaceChangeEvent)=>void;status?:WorkspaceWatchOptions['onStatus']}>;stop:()=>void;status?:WorkspaceWatchStatus}>();
785
875
  function watchWorkspace(key:string,listener?:(event:WorkspaceChangeEvent)=>void,options:WorkspaceWatchOptions={}) {
786
876
  if(!client.board) throw new Error("当前后端不支持工作区文件订阅");
877
+ const releaseScope=options.paths?.length||options.dirs?.length?retainFileScope(client.board,key,{paths:[...options.paths??[]],dirs:[...options.dirs??[]]}):()=>{};
787
878
  const item={change:listener,status:options.onStatus};let subscription=fileSubscriptions.get(key);
788
879
  if(!subscription){
789
880
  subscription={listeners:new Set([item]),stop:()=>{}};fileSubscriptions.set(key,subscription);const owned=subscription;
790
- owned.stop=subscribeWorkspaceFiles(client.board,key,event=>{emit(event);for(const next of [...owned.listeners])notifyCallback(next.change,cloneWorkspaceChange(event));},{...options,onStatus:status=>{owned.status=status;emit(status);for(const next of [...owned.listeners])notifyCallback(next.status,{...status});}});
881
+ owned.stop=subscribeWorkspaceFiles(client.board,key,event=>{emit(event);for(const next of [...owned.listeners])notifyCallback(next.change,cloneWorkspaceChange(event));},{intervalMs:options.intervalMs,onStatus:status=>{owned.status=status;emit(status);for(const next of [...owned.listeners])notifyCallback(next.status,{...status});}});
791
882
  }else{subscription.listeners.add(item);if(subscription.status)notifyCallback(item.status,{...subscription.status});retryWorkspaceFiles(client.board,key);}
792
- const owned=subscription;let active=true;return()=>{if(!active)return;active=false;owned.listeners.delete(item);if(!owned.listeners.size){if(fileSubscriptions.get(key)===owned)fileSubscriptions.delete(key);owned.stop();}};
883
+ const owned=subscription;let active=true;return()=>{if(!active)return;active=false;owned.listeners.delete(item);if(!owned.listeners.size){if(fileSubscriptions.get(key)===owned)fileSubscriptions.delete(key);owned.stop();}releaseScope();};
793
884
  }
794
885
 
795
886
  // ── 补充背景:业务方随时追加,随下一条消息发给智能体 ────────
@@ -822,7 +913,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
822
913
  if (next.length !== list.length) setContext(k, next);
823
914
  }
824
915
  /** 还没发出的补充背景。 */
825
- const pendingContext = (key: string | null): ContextItem[] => [...(state.context[ctxKey(key)] ?? [])];
916
+ const pendingContext = (key: string | null): ContextItem[] => (state.context[ctxKey(key)] ?? []).map(item => ({ ...item }));
826
917
 
827
918
  function workspace(key?: string | null): CliWorkspace {
828
919
  const k = key ?? state.selected;
@@ -853,9 +944,15 @@ export function createCliStore(options: CliStoreOptions = {}) {
853
944
  if (ref && cur && cur.ref.id === ref.id && cur.off) setViewingState(key, { ...cur, off: false });
854
945
  }
855
946
 
856
- function openInBoard(jobId: string, target: OpenTarget): boolean {
947
+ function openInBoard(jobId: string, target: OpenTarget, preferredInlineOwner?: string): boolean {
857
948
  let handled = false;
858
- for (let i = surfaces.length - 1; i >= 0 && !handled; i--) handled = surfaces[i](jobId, target);
949
+ const available = [...surfaces].reverse().filter(item => !preferredInlineOwner || !item.inline || item.owner === preferredInlineOwner)
950
+ .sort((a, b) => (b.priority?.() ?? 2) - (a.priority?.() ?? 2));
951
+ for (const { surface } of available) {
952
+ try { handled = surface(jobId, { ...target }); }
953
+ catch (error) { console.error("[@spzxg/cli-ui] board surface callback failed", error); }
954
+ if (handled) break;
955
+ }
859
956
  emit({ type: "open", jobId, target, handled });
860
957
  return handled;
861
958
  }
@@ -867,9 +964,10 @@ export function createCliStore(options: CliStoreOptions = {}) {
867
964
  /** 在看板里打开文件 / 浏览器 / 改动。返回有没有看板接住。 */
868
965
  openInBoard,
869
966
  /** 看板登记自己;返回注销函数。 */
870
- registerBoard(surface: BoardSurface) {
871
- surfaces.push(surface);
872
- return () => { const i = surfaces.indexOf(surface); if (i >= 0) surfaces.splice(i, 1); };
967
+ registerBoard(surface: BoardSurface, options: { owner?: string; inline?: boolean; priority?: () => number } = {}) {
968
+ const entry = { surface, ...options };
969
+ surfaces.push(entry);
970
+ return () => { const i = surfaces.indexOf(entry); if (i >= 0) surfaces.splice(i, 1); };
873
971
  },
874
972
  subscribe(fn: () => void) { listeners.add(fn); return () => { listeners.delete(fn); }; },
875
973
  on(fn: (event: CliEvent) => void) { eventListeners.add(fn); return () => { eventListeners.delete(fn); }; },
@@ -882,7 +980,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
882
980
  addRef, removeRef, takeRefs, restoreRefs,
883
981
  setViewing, dismissViewing, takeViewing, restoreViewing,
884
982
  /** 看板报告自己的样子(看板组件用)。 */
885
- reportBoard, activateBoard,
983
+ reportBoard, activateBoard, reportBoardPresence, reportListPresence,
886
984
  /** 业务方追加项目背景:随下一条消息发给智能体。 */
887
985
  appendContext, removeContext, pendingContext,
888
986
  /** 工作区文件接口(不显示看板也能用):列文件、读文件、下载、打包。不传 = 当前选中的对话。 */
package/src/core/types.ts CHANGED
@@ -43,7 +43,13 @@ export interface CenterTurn {
43
43
  queue_position?: number | null;
44
44
  }
45
45
 
46
- export interface CenterTask {
46
+ /** Node-observed human wait state. Missing/null means unavailable, not confirmed idle. */
47
+ export interface AwaitingInputState {
48
+ awaiting_input?: boolean | null;
49
+ awaiting_input_observed_at?: string | null;
50
+ }
51
+
52
+ export interface CenterTask extends AwaitingInputState {
47
53
  id: string;
48
54
  job_id: string;
49
55
  seq?: number;
@@ -63,7 +69,7 @@ export interface CenterTask {
63
69
  }
64
70
 
65
71
  /** 对话任务里的一个对话(一个子任务、一个 Agent 会话)。conversation.conversations 的一项。 */
66
- export interface ConversationInfo {
72
+ export interface ConversationInfo extends AwaitingInputState {
67
73
  task_id: string;
68
74
  seq: number;
69
75
  title: string;
@@ -79,7 +85,7 @@ export interface ConversationInfo {
79
85
  created_at: string | null;
80
86
  }
81
87
 
82
- export interface ConversationSummary {
88
+ export interface ConversationSummary extends AwaitingInputState {
83
89
  /** 里面的每个对话(按开的先后)。旧版 center 没有这个字段:当作只有一个对话。 */
84
90
  conversations?: ConversationInfo[];
85
91
  turns: number;
@@ -117,6 +123,12 @@ export interface SendResult {
117
123
  turn: CenterTurn;
118
124
  }
119
125
 
126
+ /** 每次浏览器操作前的随机等待范围;合法范围由任务中心与节点共同校验。 */
127
+ export interface BrowserPacing {
128
+ min_seconds: number;
129
+ max_seconds: number;
130
+ }
131
+
120
132
  /** 新建对话时可带的参数(POST /api/jobs,mode=conversation)。 */
121
133
  export interface NewConversationOptions {
122
134
  title?: string;
@@ -127,6 +139,10 @@ export interface NewConversationOptions {
127
139
  agent?: string;
128
140
  timeoutSeconds?: number;
129
141
  browser?: boolean;
142
+ /** Ego 操作手册的 YAML 正文(不是文件名或 URL);必须显式 browser:true。 */
143
+ browserPlaybook?: string;
144
+ /** 转发至节点执行器和浏览器脚本;必须显式 browser:true。 */
145
+ browserPacing?: BrowserPacing;
130
146
  }
131
147
 
132
148
  // ── Operator:节点转录 ─────────────────────────────────────────────
@@ -213,8 +229,8 @@ export type NodeEvent =
213
229
  | { type: "tool"; id: string; title: string; detail: string; peek?: ToolPeek; diff?: DiffLine[]; msgId?: string; generation?: number }
214
230
  | { type: "tool_result"; id: string; content: string; isError: boolean; peek?: ToolPeek; msgId?: string }
215
231
  | { type: "ask"; id: string; questions: AskQuestion[]; msgId?: string; generation?: number }
216
- | { type: "ask_answered"; id: string; answers: AskAnswers; msgId?: string }
217
- | { type: "input"; input: InputRequest; msgId?: string; generation?: number }
232
+ | { type: "ask_answered"; id: string; answers: AskAnswers; msgId?: string; awaiting_input?: 0 | 1 }
233
+ | { type: "input"; input: InputRequest; msgId?: string; generation?: number; awaiting_input?: 0 | 1 }
218
234
  | { type: "usage"; usage: TurnUsage }
219
235
  | { type: "notice"; content: string; msgId?: string; generation?: number }
220
236
  | { type: "error"; content: string; msgId?: string; generation?: number }
@@ -233,7 +249,7 @@ export type StreamItem =
233
249
  | { type: "task"; task: StreamTask | BatchStreamTask };
234
250
 
235
251
  /** 实时流看的那个对话(GET /api/jobs/:id/stream?task=)。 */
236
- export interface StreamTask { id: string; seq: number; title: string; status: string; closed: boolean; remote_task_id: string | null; continue_from_task_id: string | null }
252
+ export interface StreamTask extends AwaitingInputState { id: string; seq: number; title: string; status: string; closed: boolean; remote_task_id: string | null; continue_from_task_id: string | null }
237
253
 
238
254
  export interface StreamSnapshot {
239
255
  job: { id: string; status: JobStatus; closed_reason: string | null; node_id: string | null; mode: string; title: string; conversation: ConversationSummary };
@@ -246,7 +262,7 @@ export interface StreamSnapshot {
246
262
  * 一次性任务(含续派)的子任务,实时流里的样子。cli-chat 只读查看这类任务时用。
247
263
  * prompt / opener_prompt:指定了 ?task= 时看的那个子任务才有(opener_prompt = 开出它所在节点对话的那个子任务的原文)。
248
264
  */
249
- export interface BatchStreamTask {
265
+ export interface BatchStreamTask extends AwaitingInputState {
250
266
  id: string;
251
267
  seq: number;
252
268
  task_key: string | null;
@@ -384,7 +400,7 @@ export interface FileEntry {
384
400
  changeToken?: string;
385
401
  }
386
402
 
387
- export interface DirListing { type: "dir"; path: string; entries: FileEntry[]; truncated: boolean; /** 目录里的条目总数(新版节点给)。 */ total?: number }
403
+ export interface DirListing { type: "dir"; path: string; entries: FileEntry[]; truncated: boolean; /** 目录里的条目总数(新版节点给)。 */ total?: number; nextCursor?: string; revision?: string }
388
404
  export interface FileInfo { type: "file"; path: string; name: string; size: number; mtime: number; contentType: string; inline: boolean }
389
405
 
390
406
  export interface DiffFile {
@@ -426,15 +442,20 @@ export interface BrowserStatus {
426
442
  }
427
443
 
428
444
  /** 浏览器画面流里的一条消息(节点原样转发)。 */
429
- export type BrowserMessage =
445
+ export interface BrowserTab { id: string; title: string; url: string }
446
+ /** 新节点用页面身份隔离输入;旧节点可以省略这两个字段。 */
447
+ export interface BrowserTarget { targetId?: string; targetEpoch?: number }
448
+ export type BrowserMessage = BrowserTarget & (
430
449
  | { type: "frame"; data: string; metadata?: { deviceWidth: number; deviceHeight: number } }
431
450
  | { type: "page"; url?: string; title?: string }
451
+ /** 只包含当前任务内的网页;只有明确声明 canSelect 才显示可切换标签。 */
452
+ | { type: "tabs"; tabs: BrowserTab[]; activeId: string | null; canSelect?: boolean }
432
453
  | { type: "error"; message?: string }
433
454
  /** 按了复制 / 剪切:选中的文字(只发给按键的人),写进本机剪贴板。 */
434
455
  | { type: "clipboard"; text: string }
435
456
  /** 网页弹出了 alert / confirm / prompt(画面里看不到,单独显示)。 */
436
457
  | { type: "dialog"; dialogType: "alert" | "confirm" | "prompt" | "beforeunload"; message: string; defaultPrompt?: string }
437
- | { type: "dialog_closed" };
458
+ | { type: "dialog_closed" });
438
459
 
439
460
  /** 浏览器实时连接(GET /browser/live)的画面连接状况。 */
440
461
  export type BrowserFramesState = { state: "idle" | "connecting" | "live" | "retrying"; detail?: string };
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { CliMsgList, CliChat, CliBoard, type CliMsgListProps, type CliChatProps, type CliBoardProps } from "./components";
2
+ export type { GroupChatAdapter, GroupChatMessage, GroupChatReference } from "./chat/GroupChat";
2
3
  /**
3
4
  * @spzxg/cli-ui —— 任务中心(cli-task-center)对话任务的前端积木,数据全部来自 center 服务。
4
5
  *
@@ -50,3 +51,5 @@ export { formatBoardContext, appendBoardContext, BOARD_HISTORY_MAX } from "./boa
50
51
  export { subscribeWorkspaceFiles, type WorkspaceWatchOptions } from "./board/fileWatch";
51
52
  export type { WorkspaceHandlers } from "./core/provider";
52
53
  export type { BoardFile } from "./board/workspace";
54
+
55
+ export { syncFile, clearFileSyncNamespace, FILE_SYNC_MAX, type FileSyncTransport, type FileSyncManifest, type FileSyncProgress } from "./board/fileSync";
@@ -5,12 +5,12 @@
5
5
  * attachSplit(container, { panes: [{ size: 300, min: 220, max: 480 }, { min: 360 }, { size: "42%", min: 320 }], storageKey: "strategy" })
6
6
  *
7
7
  * 规则:
8
- * · 写了 size 的栏是定宽(px 或百分比,百分比按容器宽度换算成 px);没写 size 的栏占剩下的宽度(只能有一个,没有就是最后一栏)。
8
+ * · 写了 size 的栏是定宽(px 或百分比,未手动调整的百分比随容器宽度变化);没写 size 的栏占剩下的宽度(只能有一个,没有就是最后一栏)。
9
9
  * · 拖动条改它旁边定宽的那一栏;任何一栏都不会小于自己的 min,占剩余宽度的那栏也一样。
10
10
  * · 双击拖动条:那一栏回到初始宽度。键盘:拖动条能聚焦,←/→ 每次 16px(按住 Shift 64px)。
11
11
  * · 子元素根节点带 collapsed 类(cli-board 收起时):那一栏变成 40px 窄条(--cli-split-collapsed),旁边的拖动条隐藏;展开后回到原来的宽度。
12
12
  * · 写了 storageKey:拖好的宽度记在 localStorage(cli-ui:split:<storageKey>),下次打开还是这个宽度。
13
- * · 窗口变窄时定宽栏按比例让出宽度,保证占剩余宽度的那栏不小于它的 min。
13
+ * · 窗口变窄时定宽栏按超过 min 的空间比例让出宽度;各栏 min 之和能放下时,保证占剩余宽度的那栏不小于它的 min。
14
14
  */
15
15
  export type SplitPane = { size?: number | `${number}%`; min?: number; max?: number };
16
16
  export type SplitOptions = {
@@ -26,6 +26,8 @@ const STEP = 16;
26
26
 
27
27
  export function attachSplit(container: HTMLElement, initial: SplitOptions = {}): SplitHandle {
28
28
  let options = initial;
29
+ // Only user overrides are pixels. null keeps the configured responsive default.
30
+ // Old persisted numeric arrays remain valid user preferences.
29
31
  let sizes: (number | null)[] = [];
30
32
  let gutters: HTMLElement[] = [];
31
33
  let panes: HTMLElement[] = [];
@@ -45,48 +47,79 @@ export function attachSplit(container: HTMLElement, initial: SplitOptions = {}):
45
47
  };
46
48
 
47
49
  const spec = (i: number): SplitPane => options.panes?.[i] ?? {};
48
- const flexIndex = () => {
50
+ const preferredFlexIndex = () => {
49
51
  const i = panes.findIndex((_, n) => spec(n).size === undefined);
50
52
  return i >= 0 ? i : panes.length - 1;
51
53
  };
52
- const minOf = (i: number) => spec(i).min ?? DEFAULT_MIN;
54
+ const minOf = (i: number) => Math.max(0, spec(i).min ?? DEFAULT_MIN);
55
+ const clamp = (i: number, w: number) => Math.round(Math.min(Math.max(minOf(i), spec(i).max ?? Infinity), Math.max(minOf(i), w)));
56
+ const contentWidth = () => {
57
+ const style = getComputedStyle(container);
58
+ return Math.max(0, container.clientWidth - (parseFloat(style.paddingLeft) || 0) - (parseFloat(style.paddingRight) || 0));
59
+ };
53
60
  const initialSize = (i: number): number | null => {
54
- if (i === flexIndex()) return null;
61
+ if (i === preferredFlexIndex()) return null;
55
62
  const s = spec(i).size;
56
- if (typeof s === "string") return Math.round((parseFloat(s) / 100) * (container.clientWidth || 1200));
57
- return s ?? 320;
63
+ if (typeof s === "string") return clamp(i, (parseFloat(s) / 100) * (contentWidth() || 1200));
64
+ return clamp(i, s ?? 320);
58
65
  };
59
66
  const isCollapsed = (pane: HTMLElement) => !!pane.firstElementChild?.classList.contains("collapsed");
60
- const clamp = (i: number, w: number) => Math.round(Math.min(spec(i).max ?? Infinity, Math.max(minOf(i), w)));
67
+ const isVisible = (pane: HTMLElement) => !pane.hidden && getComputedStyle(pane).display !== "none";
68
+ const flexIndex = () => {
69
+ const preferred = preferredFlexIndex();
70
+ if (panes[preferred] && isVisible(panes[preferred]) && !isCollapsed(panes[preferred])) return preferred;
71
+ // A host may hide whole panes on mobile. A remaining visible pane fills the
72
+ // space without turning its default width into a permanent pixel override.
73
+ for (let i = panes.length - 1; i >= 0; i--) if (isVisible(panes[i]) && !isCollapsed(panes[i])) return i;
74
+ return -1;
75
+ };
76
+ const desiredSizes = () => panes.map((_, i) => i === preferredFlexIndex() ? null : clamp(i, sizes[i] ?? initialSize(i)!));
61
77
 
62
78
  /** 定宽栏放进容器:总宽超了就按比例收,给占剩余宽度的那栏留出它的 min。 */
63
- function fitted(): (number | null)[] {
64
- const total = container.clientWidth;
65
- const flex = flexIndex();
66
- const fixed = sizes.map((s, i) => (i === flex || isCollapsed(panes[i]) ? 0 : s ?? 0));
67
- const collapsedW = panes.reduce((sum, p, i) => sum + (i !== flex && isCollapsed(p) ? p.getBoundingClientRect().width : 0), 0);
68
- const room = total - minOf(flex) - collapsedW;
69
- const sum = fixed.reduce((a, b) => a + b, 0);
70
- if (!total || sum <= room) return sizes;
71
- const ratio = Math.max(0, room) / sum;
72
- return sizes.map((s, i) => (i === flex || s === null ? null : Math.max(minOf(i), Math.floor(s * ratio))));
79
+ function fitted(flex: number): (number | null)[] {
80
+ const shown = desiredSizes();
81
+ const fixed = panes.flatMap((p, i) => i !== flex && isVisible(p) && !isCollapsed(p) ? [i] : []);
82
+ const collapsedW = panes.reduce((sum, p) => sum + (isVisible(p) && isCollapsed(p) ? p.getBoundingClientRect().width : 0), 0);
83
+ const gutterW = gutters.reduce((sum, g) => {
84
+ if (g.hidden) return sum;
85
+ const style = getComputedStyle(g);
86
+ const width = g.getBoundingClientRect().width || parseFloat(style.flexBasis) || 0;
87
+ return sum + Math.max(0, width + (parseFloat(style.marginLeft) || 0) + (parseFloat(style.marginRight) || 0));
88
+ }, 0);
89
+ const room = contentWidth() - (flex < 0 ? 0 : minOf(flex)) - collapsedW - gutterW;
90
+ const sum = fixed.reduce((total, i) => total + shown[i]!, 0);
91
+ if (!container.clientWidth || sum <= room) return shown;
92
+ const minimum = fixed.reduce((total, i) => total + minOf(i), 0);
93
+ // Scaling whole widths can hit one pane's minimum and still overflow the
94
+ // flexible pane. Allocate only the space above each minimum instead.
95
+ const ratio = sum > minimum ? Math.max(0, room - minimum) / (sum - minimum) : 0;
96
+ for (const i of fixed) shown[i] = minOf(i) + Math.floor((shown[i]! - minOf(i)) * ratio);
97
+ return shown;
73
98
  }
74
99
 
75
100
  function apply() {
76
101
  const flex = flexIndex();
77
- const shown = fitted();
102
+ // Set narrow strips before measuring: their previous expanded width must
103
+ // not be subtracted from the available space on the first collapse frame.
104
+ for (const pane of panes) {
105
+ if (!isCollapsed(pane)) continue;
106
+ pane.style.flex = "0 0 var(--cli-split-collapsed, 40px)";
107
+ pane.style.width = "";
108
+ pane.style.minWidth = "";
109
+ }
110
+ gutters.forEach((g, i) => { g.hidden = !isVisible(panes[i]) || !isVisible(panes[i + 1]) || isCollapsed(panes[i]) || isCollapsed(panes[i + 1]); });
111
+ const shown = fitted(flex);
78
112
  panes.forEach((pane, i) => {
79
113
  pane.classList.add("cli-split-pane");
114
+ if (isCollapsed(pane)) return;
80
115
  if (i === flex) { pane.style.flex = "1 1 0"; pane.style.width = ""; pane.style.minWidth = `${minOf(i)}px`; return; }
81
- // 收起的栏(cli-board 的窄条宽度是 100%,没有自身宽度):固定窄条宽,宿主可用 --cli-split-collapsed 改。
82
- if (isCollapsed(pane)) { pane.style.flex = "0 0 var(--cli-split-collapsed, 40px)"; pane.style.width = ""; pane.style.minWidth = ""; return; }
116
+ // Hidden preferred flex panes keep their flex role for when they return.
117
+ if (shown[i] === null) { pane.style.flex = "1 1 0"; pane.style.width = ""; pane.style.minWidth = `${minOf(i)}px`; return; }
83
118
  pane.style.flex = `0 0 ${shown[i]}px`;
84
119
  pane.style.width = `${shown[i]}px`;
85
120
  pane.style.minWidth = "0";
86
121
  });
87
122
  gutters.forEach((g, i) => {
88
- const hidden = isCollapsed(panes[i]) || isCollapsed(panes[i + 1]);
89
- g.hidden = hidden;
90
123
  g.setAttribute("aria-valuenow", String(Math.round(panes[sideOf(i)]?.getBoundingClientRect().width ?? 0)));
91
124
  });
92
125
  }
@@ -95,18 +128,20 @@ export function attachSplit(container: HTMLElement, initial: SplitOptions = {}):
95
128
  const sideOf = (gutter: number) => (gutter === flexIndex() ? gutter + 1 : gutter);
96
129
 
97
130
  function resize(gutter: number, dx: number) {
131
+ if (gutters[gutter]?.hidden) return;
98
132
  const target = sideOf(gutter);
99
133
  const sign = target === gutter ? 1 : -1;
100
134
  const flex = flexIndex();
135
+ if (flex < 0) return;
101
136
  const current = panes[target].getBoundingClientRect().width;
102
137
  // 占剩余宽度的那栏不能被挤到 min 以下。
103
138
  const flexW = panes[flex].getBoundingClientRect().width;
104
- const grow = Math.min(sign * dx, flexW - minOf(flex));
139
+ const grow = Math.min(sign * dx, Math.max(0, flexW - minOf(flex)));
105
140
  sizes[target] = clamp(target, current + grow);
106
141
  apply();
107
142
  }
108
143
 
109
- function finish() { save(); options.onResize?.([...sizes]); }
144
+ function finish() { save(); options.onResize?.(desiredSizes()); }
110
145
 
111
146
  function makeGutter(index: number) {
112
147
  const g = document.createElement("div");
@@ -138,7 +173,7 @@ export function attachSplit(container: HTMLElement, initial: SplitOptions = {}):
138
173
  });
139
174
  g.addEventListener("dblclick", () => {
140
175
  const target = sideOf(index);
141
- sizes[target] = initialSize(target);
176
+ sizes[target] = null;
142
177
  apply();
143
178
  finish();
144
179
  });
@@ -153,20 +188,22 @@ export function attachSplit(container: HTMLElement, initial: SplitOptions = {}):
153
188
 
154
189
  /** 子元素变了(宿主增删栏):重新找栏、重新插拖动条。 */
155
190
  function rebuild() {
191
+ for (const p of panes) ro?.unobserve?.(p);
156
192
  for (const g of gutters) g.remove();
157
193
  panes = [...container.children].filter((c): c is HTMLElement => c instanceof HTMLElement && !c.classList.contains("cli-split-gutter"));
158
194
  const saved = load();
159
- const flex = flexIndex();
195
+ const flex = preferredFlexIndex();
160
196
  sizes = panes.map((_, i) => {
161
197
  if (i === flex) return null;
162
198
  const keep = sizes[i];
163
- if (typeof keep === "number") return keep;
199
+ if (typeof keep === "number" && Number.isFinite(keep)) return clamp(i, keep);
164
200
  const s = saved && saved.length === panes.length ? saved[i] : null;
165
- return typeof s === "number" ? clamp(i, s) : initialSize(i);
201
+ return typeof s === "number" && Number.isFinite(s) ? clamp(i, s) : null;
166
202
  });
167
203
  gutters = panes.slice(0, -1).map((_, i) => makeGutter(i));
168
204
  gutters.forEach((g, i) => panes[i].after(g));
169
205
  apply();
206
+ for (const p of panes) ro?.observe(p);
170
207
  }
171
208
 
172
209
  // 只看栏的增删、栏的根节点换了、根节点的 class 变了(收起 / 展开);栏里面的内容(流式输出)不看,不然每个字都要重排。
@@ -199,7 +236,7 @@ export function attachSplit(container: HTMLElement, initial: SplitOptions = {}):
199
236
  observer.disconnect(); rebuild(); observe();
200
237
  },
201
238
  reset() {
202
- sizes = panes.map((_, i) => initialSize(i));
239
+ sizes = panes.map(() => null);
203
240
  apply();
204
241
  finish();
205
242
  },