@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.
- package/CHANGELOG.md +55 -0
- package/README.md +109 -30
- package/dist/cli-ui-embed-CB7ibAtF.js +49558 -0
- package/dist/{cli-ui-index-D4qfPHMd.js → cli-ui-index-BmwOLWFI.js} +2 -2
- package/dist/{cli-ui-jszip.min-DC9898pC.js → cli-ui-jszip.min-BKNOJnhm.js} +1 -1
- package/dist/{cli-ui-jszip.min-DpEl0G1z.js → cli-ui-jszip.min-Cq0WjNYb.js} +2 -2
- package/dist/{cli-ui-xlsx-zs7oEtPj.js → cli-ui-xlsx-Sl7ypvSe.js} +1 -1
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +11 -8
- package/src/board/BrowserPane.tsx +198 -53
- package/src/board/CliBoard.tsx +128 -100
- package/src/board/FileViewer.tsx +276 -136
- package/src/board/FilesPane.tsx +129 -40
- package/src/board/HtmlReadingPreview.tsx +52 -0
- package/src/board/MarkdownPreview.tsx +134 -0
- package/src/board/PanelControls.tsx +19 -7
- package/src/board/fileKind.ts +138 -0
- package/src/board/fileOps.ts +9 -2
- package/src/board/fileScope.ts +29 -0
- package/src/board/fileSync.ts +343 -0
- package/src/board/fileSyncCache.ts +250 -0
- package/src/board/fileSyncProcessor.ts +71 -0
- package/src/board/fileSyncQueue.ts +56 -0
- package/src/board/fileSyncTransport.ts +85 -0
- package/src/board/fileWatch.ts +177 -70
- package/src/board/folderDownload.ts +39 -5
- package/src/board/frameQuote.ts +12 -10
- package/src/board/htmlReading.ts +148 -0
- package/src/board/textLoad.ts +18 -41
- package/src/board/useBoardUpload.tsx +51 -0
- package/src/board/useBoardVisibility.ts +35 -0
- package/src/board/workspace.ts +53 -11
- package/src/chat/CliChat.tsx +105 -55
- package/src/chat/Composer.tsx +55 -21
- package/src/chat/GroupChat.tsx +113 -0
- package/src/chat/RunControls.tsx +132 -42
- package/src/chat/draft.ts +5 -2
- package/src/core/blocks.ts +17 -5
- package/src/core/client.ts +88 -19
- package/src/core/conversation.ts +1 -0
- package/src/core/documentUpload.ts +196 -0
- package/src/core/provider.tsx +7 -0
- package/src/core/store.ts +134 -36
- package/src/core/types.ts +31 -10
- package/src/index.ts +3 -0
- package/src/layout/split.ts +67 -30
- package/src/msglist/CliMsgList.tsx +18 -5
- package/src/operator/node/Chat.tsx +7 -8
- package/src/operator/node/Composer.tsx +99 -72
- package/src/operator/node/Orchestrator.tsx +1 -1
- package/src/operator/node/SessionRail.tsx +71 -32
- package/src/operator/node/SessionView.tsx +25 -25
- package/src/operator/node/Transcript.tsx +28 -11
- package/src/operator/node/boardBackend.ts +6 -23
- package/src/operator/node/boardEvents.ts +21 -3
- package/src/operator/node/boardState.ts +18 -1
- package/src/operator/node/boardWorkspace.ts +4 -1
- package/src/operator/node/composerDraft.ts +6 -2
- package/src/operator/node/format.ts +4 -2
- package/src/operator/node/liveStream.ts +14 -2
- package/src/operator/node/protocol/types.ts +2 -2
- package/src/operator/node/useTaskStream.ts +8 -5
- package/src/operator.base.css +148 -37
- package/src/operator.css +148 -37
- package/src/styles.css +274 -32
- package/src/ui/Markdown.tsx +2 -2
- package/src/ui/Popover.tsx +32 -14
- package/src/ui/QueuedMessages.tsx +69 -0
- package/src/ui/ReferenceCard.tsx +28 -0
- package/src/ui/TextBlock.tsx +6 -4
- package/src/ui/Transcript.tsx +27 -12
- package/src/ui/UploadProgress.tsx +46 -0
- package/src/ui/WorkspaceDrawer.tsx +64 -0
- package/src/ui/WorkspaceTabs.tsx +60 -0
- package/src/ui/format.ts +3 -1
- package/src/ui/reference.ts +40 -0
- package/src/ui/useChatScrollbar.ts +29 -0
- package/src/ui/useComposerAutosize.ts +54 -0
- 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
|
-
|
|
389
|
-
let
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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 (
|
|
543
|
+
if (current !== owned) return;
|
|
478
544
|
current.refs -= 1;
|
|
479
545
|
if (current.refs > 0) return;
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
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));},{
|
|
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[] =>
|
|
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
|
-
|
|
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
|
-
|
|
872
|
-
|
|
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
|
-
|
|
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
|
|
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";
|
package/src/layout/split.ts
CHANGED
|
@@ -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
|
|
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
|
-
* ·
|
|
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
|
|
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 ===
|
|
61
|
+
if (i === preferredFlexIndex()) return null;
|
|
55
62
|
const s = spec(i).size;
|
|
56
|
-
if (typeof s === "string") return
|
|
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
|
|
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
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
82
|
-
if (
|
|
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?.(
|
|
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] =
|
|
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 =
|
|
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) :
|
|
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((
|
|
239
|
+
sizes = panes.map(() => null);
|
|
203
240
|
apply();
|
|
204
241
|
finish();
|
|
205
242
|
},
|