@spzxg/cli-ui 0.6.6 → 0.6.8

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 (79) hide show
  1. package/CHANGELOG.md +40 -1
  2. package/README.md +90 -21
  3. package/dist/cli-ui-embed-Be9_d9jB.js +49691 -0
  4. package/dist/{cli-ui-index-b8wF7wrN.js → cli-ui-index-CzNfiynu.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-B0uLrCHg.js → cli-ui-jszip.min-BL0mSekN.js} +2 -2
  6. package/dist/{cli-ui-jszip.min-ggP8OokP.js → cli-ui-jszip.min-DjCIAfse.js} +1 -1
  7. package/dist/{cli-ui-xlsx-DEDdHrJJ.js → cli-ui-xlsx-CscI4xk0.js} +1 -1
  8. package/dist/cli-ui.css +1 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +3 -1
  11. package/src/board/CliBoard.tsx +128 -100
  12. package/src/board/FileViewer.tsx +259 -132
  13. package/src/board/FilesPane.tsx +129 -40
  14. package/src/board/HtmlReadingPreview.tsx +52 -0
  15. package/src/board/MarkdownPreview.tsx +134 -0
  16. package/src/board/PanelControls.tsx +19 -7
  17. package/src/board/fileKind.ts +138 -0
  18. package/src/board/fileOps.ts +9 -2
  19. package/src/board/fileScope.ts +29 -0
  20. package/src/board/fileSync.ts +343 -0
  21. package/src/board/fileSyncCache.ts +250 -0
  22. package/src/board/fileSyncProcessor.ts +71 -0
  23. package/src/board/fileSyncQueue.ts +56 -0
  24. package/src/board/fileSyncTransport.ts +85 -0
  25. package/src/board/fileWatch.ts +177 -76
  26. package/src/board/folderDownload.ts +39 -5
  27. package/src/board/frameQuote.ts +10 -8
  28. package/src/board/htmlReading.ts +148 -0
  29. package/src/board/textLoad.ts +14 -39
  30. package/src/board/useBoardUpload.tsx +51 -0
  31. package/src/board/useBoardVisibility.ts +35 -0
  32. package/src/board/workspace.ts +38 -6
  33. package/src/chat/CliChat.tsx +121 -51
  34. package/src/chat/Composer.tsx +54 -22
  35. package/src/chat/GroupChat.tsx +113 -0
  36. package/src/chat/RunControls.tsx +143 -46
  37. package/src/chat/draft.ts +12 -4
  38. package/src/chat/useExecutionConfig.ts +93 -0
  39. package/src/components.tsx +1 -1
  40. package/src/core/blocks.ts +2 -1
  41. package/src/core/client.ts +65 -61
  42. package/src/core/config.ts +6 -2
  43. package/src/core/documentUpload.ts +196 -0
  44. package/src/core/execution.ts +24 -0
  45. package/src/core/store.ts +81 -20
  46. package/src/core/types.ts +3 -1
  47. package/src/index.ts +4 -0
  48. package/src/msglist/CliMsgList.tsx +9 -2
  49. package/src/operator/node/Chat.tsx +11 -9
  50. package/src/operator/node/Composer.tsx +103 -75
  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 +31 -28
  54. package/src/operator/node/Transcript.tsx +21 -9
  55. package/src/operator/node/boardBackend.ts +0 -19
  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/useTaskStream.ts +3 -1
  63. package/src/operator.base.css +150 -43
  64. package/src/operator.css +150 -43
  65. package/src/styles.css +253 -39
  66. package/src/ui/Markdown.tsx +2 -2
  67. package/src/ui/Popover.tsx +25 -12
  68. package/src/ui/QueuedMessages.tsx +69 -0
  69. package/src/ui/ReferenceCard.tsx +28 -0
  70. package/src/ui/TextBlock.tsx +6 -4
  71. package/src/ui/Transcript.tsx +20 -10
  72. package/src/ui/UploadProgress.tsx +46 -0
  73. package/src/ui/WorkspaceDrawer.tsx +64 -0
  74. package/src/ui/WorkspaceTabs.tsx +60 -0
  75. package/src/ui/format.ts +3 -1
  76. package/src/ui/reference.ts +40 -0
  77. package/src/ui/useChatScrollbar.ts +29 -0
  78. package/src/ui/useComposerAutosize.ts +54 -0
  79. package/dist/cli-ui-embed-BAqonuoD.js +0 -45952
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)。 */
@@ -146,7 +153,7 @@ export interface CliStoreOptions extends CenterClientOptions {
146
153
  */
147
154
  newConversation?: NewConversationOptions;
148
155
  /** 自己建对话(例如经业务后端建,以便把回调密钥留在服务端):返回 center 的 job_id。 */
149
- createConversation?: (firstMessage: string) => Promise<string>;
156
+ createConversation?: (firstMessage: string, options?: NewConversationOptions) => Promise<string>;
150
157
  /**
151
158
  * 每条消息的 business_id(center 按它去重,回调里原样带回)。缺省 `cli-<uuid>`。
152
159
  * kind = "create" 是第一条(新建对话),"message" 是之后的每条。
@@ -156,10 +163,12 @@ export interface CliStoreOptions extends CenterClientOptions {
156
163
 
157
164
  /** 发一条消息时可以单独指定的新建参数(组件自己的 props),优先于仓库的。 */
158
165
  export interface SendOptions {
166
+ expectedExecutionRevision?: number;
167
+ executionConfig?: import("./execution").ExecutionOptions;
159
168
  /** 新建对话后是否改仓库的选中(缺省改)。 */
160
169
  select?: boolean;
161
170
  newConversation?: NewConversationOptions;
162
- createConversation?: (firstMessage: string) => Promise<string>;
171
+ createConversation?: (firstMessage: string, options?: NewConversationOptions) => Promise<string>;
163
172
  businessIdFor?: (text: string, kind: "create" | "message") => string;
164
173
  }
165
174
 
@@ -222,7 +231,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
222
231
  // 列表只放宿主给的 jobIds 和本页里新建 / 接续出来的对话:不从 center 发现,也不从本地存储恢复——
223
232
  // 不传就不加载,免得把这个产品线令牌下别的业务、别的用户的对话列出来。
224
233
 
225
- let state: CliState = { selected: null, jobIds: [], jobs: {}, convs: {}, listLoading: false, listError: null, refs: {}, viewing: {}, boards: {}, boardContext: {}, context: {} };
234
+ let state: CliState = { selected: null, jobIds: [], jobs: {}, convs: {}, listLoading: false, listError: null, refs: {}, viewing: {}, boards: {}, externalBoards: {}, listVisible: false, externalBoardOptions: {}, boardContext: {}, context: {} };
226
235
  const listeners = new Set<() => void>();
227
236
  const eventListeners = new Set<(event: CliEvent) => void>();
228
237
  const settledTurns = new Set<string>();
@@ -392,10 +401,11 @@ export function createCliStore(options: CliStoreOptions = {}) {
392
401
  previous?.();
393
402
  if (streams.get(key) !== entry || entry.refs <= 0) return;
394
403
  const { jobId, taskId } = parseConvKey(key);
395
- let ended = false, retired = false;
404
+ let ended = false, retired = false, snapshotReceived = false;
396
405
  let cleanup: (() => void) | null = null;
397
406
  const owns = () => streams.get(key) === entry && entry.refs > 0 && entry.stop === retire;
398
407
  const active = () => !retired && owns();
408
+ const refreshFiles = () => { if (active() && client.board) retryWorkspaceFiles(client.board, key); };
399
409
  const retire = () => {
400
410
  if (retired) return;
401
411
  retired = true;
@@ -435,13 +445,37 @@ export function createCliStore(options: CliStoreOptions = {}) {
435
445
  try {
436
446
  cleanup = client.stream(jobId, {
437
447
  onOpen: () => { if (!active()) return; entry.attempt = 0; patchConv(key, (conv) => ({ ...conv, stream: "live", streamError: null })); },
438
- onSnapshot: (snapshot) => { if (active()) patchConv(key, (conv) => applySnapshot(conv, snapshot)); },
439
- onChunk: (items) => { if (active()) patchConv(key, (conv) => applyItems(conv, items)); },
440
- onEnd: (end) => {
448
+ onSnapshot: (snapshot) => {
449
+ if (!active()) return;
450
+ patchConv(key, (conv) => applySnapshot(conv, snapshot));
451
+ // Each connection catches up once, including rounds completed while disconnected.
452
+ if (!snapshotReceived) { snapshotReceived = true; refreshFiles(); }
453
+ },
454
+ onChunk: (items) => {
441
455
  if (!active()) return;
456
+ let refresh = false;
457
+ patchConv(key, (conv) => {
458
+ let next = conv;
459
+ for (const item of items) {
460
+ const previous = next;
461
+ next = applyItems(next, [item]);
462
+ // applyItems also rejects events from another batch task's node session.
463
+ if (next === previous) continue;
464
+ // Observe every event: end + next user in one chunk can leave running=true.
465
+ if (item.type === "node" && item.event.type === "turn_end" || item.type === "transcript") refresh = true;
466
+ if (item.type === "turn" && item.turn.status !== "merged" && TERMINAL_TURN.has(item.turn.status)
467
+ && !TERMINAL_TURN.has(previous.turns.find((turn) => turn.turn_id === item.turn.turn_id)?.status ?? "")) refresh = true;
468
+ }
469
+ return next;
470
+ });
471
+ if (refresh) refreshFiles();
472
+ },
473
+ onEnd: (end) => {
474
+ if (!active() || ended) return;
442
475
  ended = true;
443
476
  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;
444
477
  patchConv(key, (conv) => ({ ...conv, stream: "ended", endReason: end.reason, continuedBy, nodeRunning: false }));
478
+ refreshFiles();
445
479
  void refreshJob(jobId, true);
446
480
  },
447
481
  onClose: close,
@@ -569,7 +603,8 @@ export function createCliStore(options: CliStoreOptions = {}) {
569
603
 
570
604
  async function sendWithText(key: string | null, text: string, overrides: SendOptions): Promise<string> {
571
605
  // 发消息的组件自己的参数优先(同一个仓库里可以有几个对话组件,各带各的新建参数),没有再用仓库的。
572
- const newConversation = overrides.newConversation ?? options.newConversation;
606
+ const originalOptions = overrides.newConversation ?? options.newConversation;
607
+ const newConversation = overrides.executionConfig ? { ...originalOptions, agent: overrides.executionConfig.agent ?? originalOptions?.agent, executionConfig: overrides.executionConfig } : originalOptions;
573
608
  const createConversation = overrides.createConversation ?? options.createConversation;
574
609
  const businessIdFor = overrides.businessIdFor ?? options.businessIdFor;
575
610
  const ref = key ? parseConvKey(key) : null;
@@ -578,7 +613,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
578
613
  if (ref?.taskId === NEW_TASK) {
579
614
  // 在对话任务里新开一个对话:标题取第一行,智能体 / 浏览器模式沿用对话任务的第一个对话(center 决定)。
580
615
  if (!client.openConversation) throw new CenterError("当前后端不支持在对话任务里新开对话", "UNSUPPORTED", 0);
581
- const result = await retryOnce(() => client.openConversation!(ref.jobId, text, { title: firstLine(text, 60), businessId }));
616
+ const result = await retryOnce(() => client.openConversation!(ref.jobId, text, { title: firstLine(text, 60), businessId, agent: newConversation?.agent, executionConfig: newConversation?.executionConfig, browser: newConversation?.browser, timeoutSeconds: newConversation?.timeoutSeconds }));
582
617
  const target = convKey(result.job_id, result.task_id ?? result.turn.task_id);
583
618
  patchConv(target, (conv) => ({ ...conv, turns: addTurnsIfAbsent(conv.turns, [{ ...result.turn, queue_position: result.queue_position }]) }));
584
619
  addJobIfMissing(result.job_id);
@@ -591,7 +626,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
591
626
  const selectedBeforeCreate = state.selected;
592
627
  let newId: string;
593
628
  if (createConversation) {
594
- newId = await createConversation(text);
629
+ newId = newConversation?.executionConfig ? await createConversation(text, newConversation) : await createConversation(text);
595
630
  } else {
596
631
  // 新建只发一次:center 的 POST /api/jobs 没有幂等,建好了却没收到响应时重发会建出第二个对话。
597
632
  const created = await client.createConversation(text, { ...newConversation, title: newConversation?.title || firstLine(text), businessId: newConversation?.businessId || businessId });
@@ -621,7 +656,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
621
656
  void refreshJob(ref.jobId, true);
622
657
  return key;
623
658
  }
624
- const result = await retryOnce(() => client.sendMessage(ref.jobId, text, { businessId, ...(ref.taskId ? { taskId: ref.taskId } : {}) }));
659
+ const result = await retryOnce(() => client.sendMessage(ref.jobId, text, { businessId, ...(ref.taskId ? { taskId: ref.taskId } : {}), ...(overrides.expectedExecutionRevision !== undefined ? { expectedExecutionRevision: overrides.expectedExecutionRevision } : {}) }));
625
660
  const jobTarget = result.job_id || ref.jobId;
626
661
  // 指定了对话的:接续后跟到新对话任务里对应的那个对话;只有 job_id 的照旧只换 job。
627
662
  const target = ref.taskId ? convKey(jobTarget, result.task_id ?? result.turn.task_id ?? ref.taskId) : jobTarget;
@@ -727,7 +762,28 @@ export function createCliStore(options: CliStoreOptions = {}) {
727
762
  }
728
763
 
729
764
  // 同一个仓库里登记的看板(cli-board)。后登记的优先(例如弹出的看板盖在常驻的上面)。
730
- const surfaces: BoardSurface[] = [];
765
+ const surfaces: { surface: BoardSurface; owner?: string; inline?: boolean; priority?: () => number }[] = [];
766
+ const visibleLists = new Set<string>();
767
+ function reportListPresence(owner: string, visible: boolean) {
768
+ if (visible) visibleLists.add(owner); else visibleLists.delete(owner);
769
+ const listVisible = visibleLists.size > 0;
770
+ if (state.listVisible !== listVisible) set({ listVisible });
771
+ }
772
+ const externalBoards = new Map<string, Map<string, { visible: boolean; availableTabs?: readonly ("files" | "changes" | "browser")[]; readOnly?: boolean }>>();
773
+ function reportBoardPresence(key: string, owner: string, visible: boolean | null, options: { availableTabs?: readonly ("files" | "changes" | "browser")[]; readOnly?: boolean } = {}) {
774
+ const owners = externalBoards.get(key) ?? new Map<string, { visible: boolean; availableTabs?: readonly ("files" | "changes" | "browser")[]; readOnly?: boolean }>();
775
+ if (visible === null) owners.delete(owner); else owners.set(owner, { visible, ...options });
776
+ if (owners.size) externalBoards.set(key, owners); else externalBoards.delete(key);
777
+ const values = [...owners.values()];
778
+ const shown = values.some(value => value.visible);
779
+ 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;
780
+ if (!!state.externalBoards[key] === shown && JSON.stringify(state.externalBoardOptions[key]) === JSON.stringify(limits)) return;
781
+ const next = { ...state.externalBoards };
782
+ if (shown) next[key] = true; else delete next[key];
783
+ const configurations = { ...state.externalBoardOptions };
784
+ if (limits) configurations[key] = limits; else delete configurations[key];
785
+ set({ externalBoards: next, externalBoardOptions: configurations });
786
+ }
731
787
  // ── 看板 → 对话的引用 ─────────────────────────────
732
788
 
733
789
  function setRefs(key: string, refs: BoardRef[]) {
@@ -821,12 +877,13 @@ export function createCliStore(options: CliStoreOptions = {}) {
821
877
  const fileSubscriptions = new Map<string,{listeners:Set<{change?:(event:WorkspaceChangeEvent)=>void;status?:WorkspaceWatchOptions['onStatus']}>;stop:()=>void;status?:WorkspaceWatchStatus}>();
822
878
  function watchWorkspace(key:string,listener?:(event:WorkspaceChangeEvent)=>void,options:WorkspaceWatchOptions={}) {
823
879
  if(!client.board) throw new Error("当前后端不支持工作区文件订阅");
880
+ const releaseScope=options.paths?.length||options.dirs?.length?retainFileScope(client.board,key,{paths:[...options.paths??[]],dirs:[...options.dirs??[]]}):()=>{};
824
881
  const item={change:listener,status:options.onStatus};let subscription=fileSubscriptions.get(key);
825
882
  if(!subscription){
826
883
  subscription={listeners:new Set([item]),stop:()=>{}};fileSubscriptions.set(key,subscription);const owned=subscription;
827
- 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});}});
884
+ 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});}});
828
885
  }else{subscription.listeners.add(item);if(subscription.status)notifyCallback(item.status,{...subscription.status});retryWorkspaceFiles(client.board,key);}
829
- 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();}};
886
+ 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();};
830
887
  }
831
888
 
832
889
  // ── 补充背景:业务方随时追加,随下一条消息发给智能体 ────────
@@ -890,9 +947,11 @@ export function createCliStore(options: CliStoreOptions = {}) {
890
947
  if (ref && cur && cur.ref.id === ref.id && cur.off) setViewingState(key, { ...cur, off: false });
891
948
  }
892
949
 
893
- function openInBoard(jobId: string, target: OpenTarget): boolean {
950
+ function openInBoard(jobId: string, target: OpenTarget, preferredInlineOwner?: string): boolean {
894
951
  let handled = false;
895
- for (const surface of [...surfaces].reverse()) {
952
+ const available = [...surfaces].reverse().filter(item => !preferredInlineOwner || !item.inline || item.owner === preferredInlineOwner)
953
+ .sort((a, b) => (b.priority?.() ?? 2) - (a.priority?.() ?? 2));
954
+ for (const { surface } of available) {
896
955
  try { handled = surface(jobId, { ...target }); }
897
956
  catch (error) { console.error("[@spzxg/cli-ui] board surface callback failed", error); }
898
957
  if (handled) break;
@@ -903,14 +962,16 @@ export function createCliStore(options: CliStoreOptions = {}) {
903
962
 
904
963
  return {
905
964
  client,
965
+ getNewConversationOptions: () => options.newConversation,
906
966
  draftScope: connectionDraftScope(options),
907
967
  getState: () => state,
908
968
  /** 在看板里打开文件 / 浏览器 / 改动。返回有没有看板接住。 */
909
969
  openInBoard,
910
970
  /** 看板登记自己;返回注销函数。 */
911
- registerBoard(surface: BoardSurface) {
912
- surfaces.push(surface);
913
- return () => { const i = surfaces.indexOf(surface); if (i >= 0) surfaces.splice(i, 1); };
971
+ registerBoard(surface: BoardSurface, options: { owner?: string; inline?: boolean; priority?: () => number } = {}) {
972
+ const entry = { surface, ...options };
973
+ surfaces.push(entry);
974
+ return () => { const i = surfaces.indexOf(entry); if (i >= 0) surfaces.splice(i, 1); };
914
975
  },
915
976
  subscribe(fn: () => void) { listeners.add(fn); return () => { listeners.delete(fn); }; },
916
977
  on(fn: (event: CliEvent) => void) { eventListeners.add(fn); return () => { eventListeners.delete(fn); }; },
@@ -923,7 +984,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
923
984
  addRef, removeRef, takeRefs, restoreRefs,
924
985
  setViewing, dismissViewing, takeViewing, restoreViewing,
925
986
  /** 看板报告自己的样子(看板组件用)。 */
926
- reportBoard, activateBoard,
987
+ reportBoard, activateBoard, reportBoardPresence, reportListPresence,
927
988
  /** 业务方追加项目背景:随下一条消息发给智能体。 */
928
989
  appendContext, removeContext, pendingContext,
929
990
  /** 工作区文件接口(不显示看板也能用):列文件、读文件、下载、打包。不传 = 当前选中的对话。 */
package/src/core/types.ts CHANGED
@@ -131,6 +131,8 @@ export interface BrowserPacing {
131
131
 
132
132
  /** 新建对话时可带的参数(POST /api/jobs,mode=conversation)。 */
133
133
  export interface NewConversationOptions {
134
+ /** 首轮执行配置。由服务端校验、持久化,并用于后续派发和重试。 */
135
+ executionConfig?: import("./execution").ExecutionOptions;
134
136
  title?: string;
135
137
  /** 业务自己的 ID(tasks[0].business_id):第一轮的回调里原样带回,用来和业务数据对应。 */
136
138
  businessId?: string;
@@ -400,7 +402,7 @@ export interface FileEntry {
400
402
  changeToken?: string;
401
403
  }
402
404
 
403
- export interface DirListing { type: "dir"; path: string; entries: FileEntry[]; truncated: boolean; /** 目录里的条目总数(新版节点给)。 */ total?: number }
405
+ export interface DirListing { type: "dir"; path: string; entries: FileEntry[]; truncated: boolean; /** 目录里的条目总数(新版节点给)。 */ total?: number; nextCursor?: string; revision?: string }
404
406
  export interface FileInfo { type: "file"; path: string; name: string; size: number; mtime: number; contentType: string; inline: boolean }
405
407
 
406
408
  export interface DiffFile {
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
  *
@@ -42,6 +43,7 @@ export {
42
43
  type TextBlock, type MessagePart,
43
44
  } from "./core/blocks";
44
45
  export type * from "./core/types";
46
+ export type { ExecutionOptions, ExecutionConfiguration } from "./core/execution";
45
47
 
46
48
 
47
49
  export { RunControls, type RunControlProps, type RunCapabilities, type RunTask } from "./chat/RunControls";
@@ -50,3 +52,5 @@ export { formatBoardContext, appendBoardContext, BOARD_HISTORY_MAX } from "./boa
50
52
  export { subscribeWorkspaceFiles, type WorkspaceWatchOptions } from "./board/fileWatch";
51
53
  export type { WorkspaceHandlers } from "./core/provider";
52
54
  export type { BoardFile } from "./board/workspace";
55
+
56
+ export { syncFile, clearFileSyncNamespace, FILE_SYNC_MAX, type FileSyncTransport, type FileSyncManifest, type FileSyncProgress } from "./board/fileSync";
@@ -1,7 +1,7 @@
1
1
  // cli-msglist:消息对话列表。界面取自 Operator 的任务栏(TasksColumn 的列表视图,不做看板),
2
2
  // 一张卡片是 center 里的一个对话任务(mode=conversation)。对话任务里开了几个对话时,卡片下面按对话展开,
3
3
  // 点哪个对话选哪个(选中值是对话键 "job#task");卡片上的「+」在这个对话任务里新开一个对话(同一个工作区)。
4
- import { useEffect, useMemo, useState, type CSSProperties } from "react";
4
+ import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
5
5
  import { CliErrorBoundary } from "../ui/ErrorBoundary";
6
6
  import type { CliEvent, JobEntry } from "../core/store";
7
7
  import type { CliStoreOptions, CliStore } from "../core/store";
@@ -12,6 +12,7 @@ import { Icon } from "../ui/icons";
12
12
  import { agentLabel, relTime, toMs } from "../ui/format";
13
13
  import { AgentBadge, ErrNote, Skel, StatusDot, type DotCls } from "../ui/shared";
14
14
  import { resolveMsgListConfig, type CliMsgListConfig, type ResolvedMsgListConfig } from "../core/config";
15
+ import { useBoardVisibility } from "../board/useBoardVisibility";
15
16
 
16
17
  /** onTurnEnd / onConversationEnd:浏览器里从实时流算出的结果通知,不依赖 center 的 callback.url。 */
17
18
  export interface CliMsgListProps extends CliStoreOptions, ResultHandlers {
@@ -284,6 +285,12 @@ function MsgListInner({ jobIds, selectedId, onSelect, onNew, title = "对话", c
284
285
  const cfg = useMemo(() => resolveMsgListConfig(config), [configKey]); // eslint-disable-line react-hooks/exhaustive-deps
285
286
  const h = cfg.header;
286
287
  const store = useCliStore();
288
+ const rootRef = useRef<HTMLDivElement>(null);
289
+ const owner = useId();
290
+ const visible = useBoardVisibility(rootRef, true);
291
+ const expanded = visible && !className?.split(/\s+/).includes("collapsed");
292
+ useLayoutEffect(() => { store.reportListPresence(owner, expanded); }, [store, owner, expanded]);
293
+ useLayoutEffect(() => () => { store.reportListPresence(owner, false); }, [store, owner]);
287
294
  const ids = useCliState((s) => s.jobIds);
288
295
  const jobs = useCliState((s) => s.jobs);
289
296
  const shared = useCliState((s) => s.selected);
@@ -343,7 +350,7 @@ function MsgListInner({ jobIds, selectedId, onSelect, onNew, title = "对话", c
343
350
  const showBanner = showRow || showContext;
344
351
 
345
352
  return (
346
- <div className={`cli-ui cli-msglist ${className ?? ""}`} data-theme={theme} style={{ ...style, ...(width !== undefined ? { width } : null) }}>
353
+ <div ref={rootRef} className={`cli-ui cli-msglist ${className ?? ""}`} data-theme={theme} style={{ ...style, ...(width !== undefined ? { width } : null) }}>
347
354
  <div className="col col-tasks">
348
355
  {showBanner && <div className="proj-banner">
349
356
  {showRow && <div className="pb-row">
@@ -12,10 +12,13 @@ import { OperatorBoardContext } from "./boardInteraction";
12
12
  import { toolDataOf } from "./transcriptModel";
13
13
  import { FileLinkContext } from "./fileLinks";
14
14
  import { Composer } from "./Composer";
15
+ import { QueuedMessages } from "../../ui/QueuedMessages";
16
+ import { useChatScrollbar } from "../../ui/useChatScrollbar";
15
17
  import { BranchHistory, useBranchHistory } from "./BranchHistory";
16
18
  import { BranchNavigator } from "./BranchNavigator";
17
19
  import type { Quote } from "./composerDraft";
18
20
  import type { RunControlProps } from "./RunControls";
21
+ import type { ResolvedChatConfig } from "../../core/config";
19
22
  const QUIET_AFTER_MS = 180_000;
20
23
  function RunningIndicator({ agent, startedAt, lastActivityRef }: { agent: string | null; startedAt: number | null; lastActivityRef: React.MutableRefObject<number> }) {
21
24
  const [now, setNow] = useState(() => Date.now());
@@ -66,6 +69,7 @@ export type ChatProps = {
66
69
  running: boolean; blockedBy?: string[]; transcriptLoading?: boolean;
67
70
  older?: number; onLoadOlder?: (beforeApply?: () => void) => Promise<boolean>;
68
71
  controls: RunControlProps; configBusy: boolean; active?: boolean;
72
+ composer?: Pick<ResolvedChatConfig["composer"], "show" | "fileUpload">;
69
73
  onSelectTask: (id: string) => void; onFork: (text: string, sourceIds: string[], requestId: string) => Promise<boolean>;
70
74
  onSend: (text: string) => Promise<boolean>; onStop: () => Promise<boolean>;
71
75
  onStart: () => void; onEdit: () => void; onClear: () => void; onSetSendContext: (v: boolean) => void;
@@ -74,8 +78,9 @@ export type ChatProps = {
74
78
  };
75
79
  export function Chat(props: ChatProps) { return <ChatSession key={props.task.id} {...props} />; }
76
80
  function ChatSession({ project, task, tasks, agents, messages, running, transcriptLoading, older=0, onLoadOlder,
77
- controls, configBusy, active=true, onSelectTask, onFork, onSend, onStop, onClear, onReconnect, onAnswer, onCancelQueued }: ChatProps) {
81
+ controls, configBusy, composer, active=true, onSelectTask, onFork, onSend, onStop, onClear, onReconnect, onAnswer, onCancelQueued }: ChatProps) {
78
82
  const scrollRef = useRef<HTMLDivElement>(null);
83
+ useChatScrollbar(scrollRef, task.id);
79
84
  const [quoteRequest, setQuoteRequest] = useState<Quote|null>(null);
80
85
  const flow=useBranchHistory(task,tasks);
81
86
  const fileScope=useMemo(()=>({taskId:task.id}),[task.id]);
@@ -83,7 +88,7 @@ function ChatSession({ project, task, tasks, agents, messages, running, transcri
83
88
  const view=active?'chat':'changes';
84
89
  const quote=(m: Msg, source: TaskRow=task, selected?:string)=>{
85
90
  const text=selected||splitAttachments(m.content).text;
86
- if(text.trim()) setQuoteRequest({id:m.id, author:`${m.role==='user'?'你':agentLabel(agents,source.agent)}${source.id===task.id?'':` · ${source.title}`}`,text});
91
+ if(text.trim()) setQuoteRequest({id:m.id, author:`${m.role==='user'?'你':agentLabel(agents,source.agent)}${source.id===task.id?'':` · ${source.title}`}`,text,sourceTaskId:source.id});
87
92
  };
88
93
  const openBoard=useContext(OperatorBoardContext);
89
94
  const handoffs=useRef<Set<string>|null>(null);
@@ -414,7 +419,7 @@ function ChatSession({ project, task, tasks, agents, messages, running, transcri
414
419
  // window sheds rows above. Layout effect, so it lands before paint (no jump).
415
420
  useLayoutEffect(() => {
416
421
  if (pinned.current && scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
417
- }, [messages, running, startIdx, endIdx]);
422
+ }, [messages, running, startIdx, endIdx, win]);
418
423
  // …and when the pane itself resizes (the composer growing, a banner
419
424
  // appearing). Content resizes are left alone, so expanding a tool card at
420
425
  // the bottom doesn't yank its top out of view.
@@ -436,6 +441,7 @@ function ChatSession({ project, task, tasks, agents, messages, running, transcri
436
441
  pinned.current=false; setAtBottom(false); flowPositioned.current=true;
437
442
  },[active,flow.ready,task.parent_task_id]);
438
443
  useEffect(()=>{if(!task.parent_task_id && active) scrollToBottom("auto");},[active,task.id]);
444
+ const queued = <QueuedMessages scopeKey={task.id} messages={messages.filter(message => message.role === "queued")} onCancel={message => stableCancelQueued(message.id)} attachmentUrl={attachment => `/api/tasks/${encodeURIComponent(attachment.taskId)}/uploads/${encodeURIComponent(attachment.name)}`} renderMessage={message => <MessageView m={message} initial={false} hideWho readOnly />} />;
439
445
  return (<FileLinkContext.Provider value={fileScope}>
440
446
  <>
441
447
  <div className="transcript-wrap">
@@ -488,11 +494,6 @@ function ChatSession({ project, task, tasks, agents, messages, running, transcri
488
494
  {running && !awaitingAnswer && !hasBelow && (
489
495
  <RunningIndicator agent={task.agent} startedAt={turnStartedAt} lastActivityRef={lastActivityRef} />
490
496
  )}
491
- {/* Follow-ups queued mid-turn, pinned below the live turn — they
492
- send in order once it ends. */}
493
- {!hasBelow && messages.filter((m) => m.role === "queued").map((m) => (
494
- <MessageView key={m.id} m={m} initial={false} hideWho={false} onAnswer={stableAnswer} onCancelQueued={stableCancelQueued} />
495
- ))}
496
497
  {!hasBelow && <BranchNavigator sourceId={task.id} projectId={project.id} tasks={tasks} completed={task.status==='done'} onSelect={onSelectTask} />}
497
498
  </div>
498
499
  </div>
@@ -510,7 +511,8 @@ function ChatSession({ project, task, tasks, agents, messages, running, transcri
510
511
  )}
511
512
  </div>
512
513
  </div>
513
- <Composer tasks={tasks} onFork={onFork} task={task} agentLabel={agentLabel(agents, task.agent)} disabled={task.started!==1} configBusy={configBusy} running={running} onSend={onSend} onStop={onStop} onClear={onClear} quoteRequest={quoteRequest} onQuoteHandled={()=>setQuoteRequest(null)} controls={controls} />
514
+ {composer?.show !== false ? <Composer tasks={tasks} onFork={onFork} task={task} agentLabel={agentLabel(agents, task.agent)} disabled={task.started!==1} configBusy={configBusy} running={running} onSend={async text => { const sent = await onSend(text); if (sent && !running) scrollToBottom("auto"); return sent; }} onStop={onStop} onClear={onClear} quoteRequest={quoteRequest} onQuoteHandled={()=>setQuoteRequest(null)} controls={controls}
515
+ fileUpload={composer?.fileUpload} queued={queued} /> : queued}
514
516
  </></FileLinkContext.Provider>
515
517
  );
516
518