@xgspz/cli-ui 0.6.14 → 0.6.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +37 -4
  2. package/README.md +29 -4
  3. package/dist/{cli-ui-embed-D-3DvyTN.js → cli-ui-embed-D9xgur2y.js} +18376 -18648
  4. package/dist/{cli-ui-index-C34qFb4u.js → cli-ui-index-WBtxmw23.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-DojbZPiE.js → cli-ui-jszip.min-CU3ZzoXb.js} +1 -1
  6. package/dist/{cli-ui-jszip.min-BObcE2nn.js → cli-ui-jszip.min-DdU0X3VJ.js} +2 -2
  7. package/dist/{cli-ui-xlsx-JVLtx0qR.js → cli-ui-xlsx-Dx6T7qhC.js} +1 -1
  8. package/dist/cli-ui.css +1 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +1 -1
  11. package/src/board/ChangesPane.tsx +10 -4
  12. package/src/board/CliBoard.tsx +48 -16
  13. package/src/board/FileViewer.tsx +34 -1
  14. package/src/board/diffRefresh.ts +63 -0
  15. package/src/board/linkage.ts +68 -0
  16. package/src/board/pathResolve.ts +108 -0
  17. package/src/chat/CliChat.tsx +105 -182
  18. package/src/chat/Composer.tsx +4 -3
  19. package/src/core/conversation.ts +16 -4
  20. package/src/core/store.ts +120 -40
  21. package/src/core/types.ts +2 -0
  22. package/src/msglist/CliMsgList.tsx +3 -2
  23. package/src/operator/node/AgentConnect.tsx +19 -15
  24. package/src/operator/node/AppearancePanel.tsx +1 -1
  25. package/src/operator/node/BranchNavigator.tsx +1 -1
  26. package/src/operator/node/BrowserPane.tsx +1 -1
  27. package/src/operator/node/Chat.tsx +16 -261
  28. package/src/operator/node/CommandPalette.tsx +1 -1
  29. package/src/operator/node/Composer.tsx +1 -1
  30. package/src/operator/node/Layout.tsx +1 -1
  31. package/src/operator/node/Markdown.tsx +6 -36
  32. package/src/operator/node/Modal.tsx +1 -1
  33. package/src/operator/node/OnboardingWizard.tsx +9 -5
  34. package/src/operator/node/Orchestrator.tsx +1 -1
  35. package/src/operator/node/ProjectLanding.tsx +1 -1
  36. package/src/operator/node/ProjectsColumn.tsx +1 -1
  37. package/src/operator/node/ProviderAuth.tsx +12 -10
  38. package/src/operator/node/RunSwitch.tsx +1 -1
  39. package/src/operator/node/Services.tsx +1 -1
  40. package/src/operator/node/SessionRail.tsx +27 -8
  41. package/src/operator/node/SessionView.tsx +1 -1
  42. package/src/operator/node/SettingsView.tsx +1 -1
  43. package/src/operator/node/TaskBoard.tsx +1 -1
  44. package/src/operator/node/TaskChanges.tsx +34 -5
  45. package/src/operator/node/TaskHero.tsx +1 -1
  46. package/src/operator/node/TasksColumn.tsx +1 -1
  47. package/src/operator/node/Transcript.tsx +10 -237
  48. package/src/operator/node/Welcome.tsx +1 -1
  49. package/src/operator/node/WorktreePrune.tsx +1 -1
  50. package/src/operator/node/boardInteraction.ts +6 -3
  51. package/src/operator/node/boardState.ts +12 -13
  52. package/src/operator/node/fileLinks.ts +4 -38
  53. package/src/operator/node/format.ts +12 -104
  54. package/src/operator/node/github.tsx +10 -8
  55. package/src/operator/node/modals.tsx +15 -13
  56. package/src/operator/node/recheckOnReturn.ts +28 -0
  57. package/src/operator/node/shared.tsx +10 -50
  58. package/src/operator/node/useTaskStream.ts +2 -2
  59. package/src/operator.base.css +3 -0
  60. package/src/operator.css +3 -0
  61. package/src/styles.css +5 -0
  62. package/src/ui/Markdown.tsx +36 -21
  63. package/src/ui/Transcript.tsx +8 -5
  64. package/src/ui/fileLinks.ts +6 -3
  65. package/src/ui/format.ts +11 -5
  66. package/src/ui/shared.tsx +11 -7
  67. package/src/ui/transcriptModel.ts +40 -9
  68. package/src/ui/useTranscriptWindow.ts +253 -0
  69. package/src/operator/node/boundedPoll.ts +0 -47
  70. package/src/operator/node/icons.tsx +0 -88
  71. package/src/operator/node/transcriptModel.ts +0 -129
@@ -15,12 +15,14 @@ import { Icon } from "../ui/icons";
15
15
  import { agentLabel as labelOf, fmtTokens, formatAnswersText, modelLabel } from "../ui/format";
16
16
  import { AgentBadge, Avatar, Skel, StatusDot, type DotCls } from "../ui/shared";
17
17
  import { HANDOFF_NOTICE_PREFIX, MessageView, SessionBreak, type MessageHandlers } from "../ui/Transcript";
18
- import { RENDER_MAX, RENDER_STEP, RENDER_WINDOW, buildTranscriptItems, hasOpenAsk, toolDataOf } from "../ui/transcriptModel";
18
+ import { buildTranscriptItems, hasOpenAsk, toolDataOf } from "../ui/transcriptModel";
19
+ import { useTranscriptWindow } from "../ui/useTranscriptWindow";
19
20
  import { Composer, type ComposerOptions } from "./Composer";
20
21
  import { fillTemplate, refShown, resolveChatConfig, type CliChatConfig } from "../core/config";
21
22
  import { exportMarkdown } from "./export";
22
23
  import { FileLinkContext, FileOpenContext } from "../ui/fileLinks";
23
24
  import { FileViewer } from "../board/FileViewer";
25
+ import { pathHints, resolveMissingPath } from "../board/pathResolve";
24
26
  import { BrowserPane } from "../board/BrowserPane";
25
27
  import { CliBoard } from "../board/CliBoard";
26
28
  import { WorkspaceTabs } from "../ui/WorkspaceTabs";
@@ -122,6 +124,11 @@ function batchStatusView(taskStatus: string | null, jobStatus: string | null): {
122
124
 
123
125
  /** 「智能体在工作」那一行:跑了多久,很久没输出时提示可能卡住。与 Operator 的 RunningIndicator 一致。 */
124
126
  const QUIET_AFTER_MS = 180_000;
127
+ /**
128
+ * 「正在加载会话」最多等这么久:会话记录还没到(执行节点暂时读不到、网关缓冲了实时数据、旧版中枢补发失败后
129
+ * 不再重试)就改成说明 +「重新读取」,不一直转圈。中枢报了 transcript_unavailable 时立刻说明。
130
+ */
131
+ export const TRANSCRIPT_WAIT_MS = 20_000;
125
132
  function RunningIndicator({ agent, agentName, startedAt, lastActivityRef }: { agent: string | null; agentName: string; startedAt: number | null; lastActivityRef: React.MutableRefObject<number> }) {
126
133
  const [now, setNow] = useState(() => Date.now());
127
134
  useEffect(() => { const t = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(t); }, []);
@@ -288,6 +295,24 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
288
295
  && (remoteAwaiting === true || (!running && !!lastTurn?.asked && lastTurn.status === "succeeded"));
289
296
  // 一次性任务的子任务还没派发(没有节点对话)、或者流已经结束还没有转录:不再转圈,转录里有说明。
290
297
  const loading = !!key && !isNew && !!conv && !conv.hasTranscript && !conv.turns.length && conv.stream !== "error" && conv.stream !== "ended" && !(conv.batch && !conv.remoteTaskId);
298
+ // 记录迟迟不来:到时改成说明(见 TRANSCRIPT_WAIT_MS);「重新读取」后重新计时。
299
+ const [loadStalled, setLoadStalled] = useState(false);
300
+ const [loadAttempt, setLoadAttempt] = useState(0);
301
+ useEffect(() => {
302
+ setLoadStalled(false);
303
+ if (!loading) return;
304
+ const timer = setTimeout(() => setLoadStalled(true), TRANSCRIPT_WAIT_MS);
305
+ return () => clearTimeout(timer);
306
+ }, [loading, key, loadAttempt]);
307
+ // 已结束的一次性任务:中枢补不到记录时关流前会先报 transcript_unavailable。
308
+ const transcriptProblem = !!key && !isNew && !!conv && !conv.hasTranscript && !conv.turns.length
309
+ ? conv.transcriptError ?? (loading && loadStalled
310
+ ? conv.stream === "connecting" || (conv.stream === "live" && !conv.job)
311
+ ? "实时连接还没有收到数据:网络或网关可能缓冲了实时推送。"
312
+ : "会话记录还没读到:执行节点暂时没有响应。"
313
+ : null)
314
+ : null;
315
+ const rereadTranscript = () => { if (!key) return; setLoadAttempt((n) => n + 1); store.reconnect(key, { force: true }); };
291
316
  const batchStatus = batch ? conv?.batchTask?.status ?? task?.status ?? null : null;
292
317
 
293
318
  // 用量:任务详情里的累计 + 实时流里上次刷新后的增量(「新开」的对话还没有用量,不借第一个对话的)。
@@ -363,13 +388,13 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
363
388
  if (target !== id && latest.current.key === refKey) go(target);
364
389
  }, [store, go, passiveContext]);
365
390
  const passiveView=useCliState(state=>key?state.boardContext[key]:undefined);
366
- const passiveText=passiveContext&&passiveView?formatBoardContext(passiveView,key??undefined):'';
391
+ const passiveText=useMemo(()=>passiveContext&&passiveView?formatBoardContext(passiveView,key??undefined):'',[passiveContext,passiveView,key]);
367
392
  const refs = useCliState((s) => (key ? s.refs[key] : undefined));
368
393
  const viewingState = useCliState((s) => (key ? s.viewing[key] : undefined));
369
394
  const viewingRef = viewingState && !viewingState.off && !refs?.some((r) => r.id === viewingState.ref.id) ? viewingState.ref : null;
370
395
  // 输入框上方显示哪些(config.refs):不显示的照样随消息带上。正在看的缺省不显示。
371
396
  const viewing = !passiveText && viewingRef && refShown(cfg, "viewing") ? viewingRef : null;
372
- const shownRefs = refs?.filter((r) => refShown(cfg, r.kind));
397
+ const shownRefs = useMemo(() => refs?.filter((r) => refShown(cfg, r.kind)), [refs, cfg]);
373
398
  const chatRef = useRef<HTMLDivElement>(null);
374
399
  // 业务方补充的背景(store.appendContext):随下一条消息带上,输入框上方显示出来,让用户知道会带上什么。
375
400
  const contextItems = useCliState((s) => s.context[key ?? ""]);
@@ -389,7 +414,14 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
389
414
  }, [key, conv, fileOpen, store, inlineOwner]);
390
415
 
391
416
  // 对话里弹窗打开的文件 / 浏览器(没有看板接的时候)。换对话就关掉。
392
- const [popup, setPopup] = useState<{ kind: "file"; path: string } | { kind: "browser" } | null>(null);
417
+ const [popup, setPopup] = useState<{ kind: "file"; path: string; resolvedFrom?: string } | { kind: "browser" } | null>(null);
418
+ // 弹窗里打不开的相对路径:和看板一样按这个对话里的线索补全(board/pathResolve.ts)。
419
+ const resolvePopupPath = useCallback((path: string, signal: AbortSignal) => {
420
+ const board = store.client.board, id = latest.current.key;
421
+ if (!board || !id) return Promise.resolve([]);
422
+ const seen = (store.getState().boardContext[id]?.history ?? []).map((f) => f.path);
423
+ return resolveMissingPath(board, id, path, pathHints(store.getState().convs[id]?.msgs ?? [], seen), signal);
424
+ }, [store]);
393
425
  useEffect(() => setPopup(null), [key]);
394
426
  useEffect(() => {
395
427
  if (!popup) return;
@@ -488,167 +520,27 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
488
520
  useEffect(() => { lastActivityRef.current = Date.now(); }, [messages]);
489
521
  const turnStartedAt = runSeenAt ?? lastUserTs;
490
522
 
491
- // ── 分窗渲染(移植自 Operator 的 SessionView):DOM 里最多 RENDER_MAX 行,几千条消息也照样流畅 ──
523
+ // ── 分窗渲染(与节点 Chat 共用 useTranscriptWindow):DOM 里最多 RENDER_MAX 行,几千条消息也照样流畅 ──
492
524
  const scrollRef = useRef<HTMLDivElement>(null);
493
525
  useChatScrollbar(scrollRef, key);
494
- const pinned = useRef(true);
495
- const [atBottom, setAtBottom] = useState(true);
496
526
  const committed = useMemo(() => messages.filter((m) => m.role !== "queued"), [messages]);
497
- const queued = useMemo(() => messages.filter((m) => m.role === "queued"), [messages]);
527
+ // 排队消息每次都是重新生成的对象:内容没变就沿用上一份,输入框上方的排队区不随流式输出重画。
528
+ const queuedNow = messages.filter((m) => m.role === "queued");
529
+ const queuedSig = queuedNow.map((m) => [m.id, m.content, m.note ?? "", m.turnId ?? "", m.ts ?? ""].join("\u0001")).join("\u0002");
530
+ const queued = useMemo(() => queuedNow, [queuedSig]); // eslint-disable-line react-hooks/exhaustive-deps
498
531
  // 「立即发送」只在有一轮正在执行、且接入开放了停止时可用。
499
532
  const queueSendNow = !readOnly && !batch && cfg.composer.stop && key && conv && runningTurn(conv) ? sendQueuedNow : undefined;
500
533
  const queueSendNowHint = !cfg.composer.stop && conv && runningTurn(conv) ? "当前接入没有开放停止,排队消息会在这一轮结束后发送" : undefined;
501
534
  // 节点转录比手里更早、还没取的条数(实时流只给最新一段,往上翻时分页取,和 Operator 一样)。
502
535
  const older = conv?.older ?? 0;
503
- const [loadingOlder, setLoadingOlder] = useState(false);
504
- const olderRef = useRef(older); olderRef.current = older;
505
- const keyRef2 = useRef(key); keyRef2.current = key;
506
536
  const items = useMemo(() => buildTranscriptItems(committed, older > 0)
507
537
  .filter((it) => cfg.transcript.initialPrompt || it.kind !== "msg" || !it.initial), [committed, older, cfg.transcript.initialPrompt]);
508
538
  // 展开了的「续等」折叠行(按折叠行的 key)。
509
539
  const [openFolds, setOpenFolds] = useState<Set<string>>(() => new Set());
510
- const [win, setWin] = useState<{ start: string | null; end: string | null }>({ start: null, end: null });
511
- const { startIdx, endIdx } = useMemo(() => {
512
- const find = (key: string | null) => (key ? items.findIndex((it) => it.key === key) : -1);
513
- const e = find(win.end);
514
- const last = e >= 0 ? e : items.length - 1;
515
- let s = find(win.start);
516
- if (s >= 0) while (s > 0 && items[s - 1].kind !== "msg") s--;
517
- return { startIdx: s >= 0 && s <= last ? s : Math.max(0, last + 1 - RENDER_WINDOW), endIdx: last };
518
- }, [items, win]);
519
- const shown = useMemo(() => items.slice(startIdx, endIdx + 1), [items, startIdx, endIdx]);
520
- const hiddenAbove = useMemo(() => { let n = 0; for (let i = 0; i < startIdx; i++) if (items[i].kind === "msg") n++; return n; }, [items, startIdx]);
521
- const hiddenBelow = useMemo(() => { let n = 0; for (let i = endIdx + 1; i < items.length; i++) if (items[i].kind === "msg") n++; return n; }, [items, endIdx]);
522
- const itemsRef = useRef(items); itemsRef.current = items;
523
- const rangeRef = useRef({ startIdx, endIdx }); rangeRef.current = { startIdx, endIdx };
524
- const winRef = useRef(win); winRef.current = win;
525
- type Anchor = { key: string; top: number };
526
- const anchorRef = useRef<(Anchor & { win: typeof win }) | null>(null);
527
-
528
- const measure = useCallback((key?: string): Anchor | null => {
529
- const el = scrollRef.current;
530
- const node = key ? el?.querySelector<HTMLElement>(`[data-tkey="${CSS.escape(key)}"]`) : el?.querySelector<HTMLElement>("[data-tkey][data-msg]");
531
- return node?.dataset.tkey ? { key: node.dataset.tkey, top: node.getBoundingClientRect().top } : null;
532
- }, []);
533
- const setRange = useCallback((s: number, e: number, anchor?: Anchor | null) => {
534
- const its = itemsRef.current;
535
- let a = s; while (a < its.length && its[a].kind !== "msg") a++;
536
- let b = e; while (b > 0 && its[b].kind !== "msg") b--;
537
- const next = { start: its[a]?.key ?? null, end: e >= its.length - 1 ? null : its[b]?.key ?? null };
538
- if (winRef.current.start === next.start && winRef.current.end === next.end) return;
539
- if (anchor) anchorRef.current = { ...anchor, win: next };
540
- setWin(next);
541
- }, []);
542
- const revealEarlier = useCallback(() => {
543
- const { startIdx: s, endIdx: e } = rangeRef.current;
544
- if (s <= 0) {
545
- // 手里的都显示了,节点上还有更早的:取一页接在前面,再往上展开(窗口按原来的那一行定位,不跳)。
546
- const k = keyRef2.current;
547
- if (!k || olderRef.current <= 0) return;
548
- setLoadingOlder(true);
549
- void store.loadOlder(k).then((ok) => { if (ok) requestAnimationFrame(() => revealRef.current.earlier()); }).finally(() => setLoadingOlder(false));
550
- return;
551
- }
552
- const ns = Math.max(0, s - RENDER_STEP);
553
- setRange(ns, Math.min(e, ns + RENDER_MAX - 1), measure());
554
- }, [measure, setRange, store]);
555
- const revealLater = useCallback(() => {
556
- const its = itemsRef.current;
557
- const { startIdx: s, endIdx: e } = rangeRef.current;
558
- if (e >= its.length - 1) return;
559
- const ne = Math.min(its.length - 1, e + RENDER_STEP);
560
- const ns = Math.max(s, ne - RENDER_MAX + 1);
561
- setRange(ns, ne, ns > s ? measure(its[ns].key) : null);
562
- }, [measure, setRange]);
563
- const revealRef = useRef({ earlier: revealEarlier, later: revealLater });
564
- revealRef.current = { earlier: revealEarlier, later: revealLater };
565
-
566
- const onScroll = useCallback(() => {
567
- const el = scrollRef.current;
568
- if (!el) return;
569
- const bottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60 && winRef.current.end === null;
570
- pinned.current = bottom;
571
- setAtBottom(bottom);
572
- if (!bottom && winRef.current.start === null) {
573
- const first = itemsRef.current.slice(rangeRef.current.startIdx).find((it) => it.kind === "msg");
574
- if (first) setWin((w) => ({ ...w, start: first.key }));
575
- } else if (bottom && winRef.current.start !== null) {
576
- setWin({ start: null, end: null });
577
- }
578
- }, []);
579
-
580
- const topSentinelRef = useRef<HTMLDivElement>(null);
581
- const bottomSentinelRef = useRef<HTMLDivElement>(null);
582
- const hasAbove = hiddenAbove > 0 || older > 0;
540
+ const loadOlderPage = useCallback((beforeApply: () => void) => (key ? store.loadOlder(key, { beforeApply }) : Promise.resolve(false)), [store, key]);
541
+ const { shown, hiddenAbove, hiddenBelow, loadingOlder, atBottom, onScroll, revealEarlier, revealLater, scrollToBottom, scrollToUserMsg, topSentinelRef, bottomSentinelRef } =
542
+ useTranscriptWindow({ scrollRef, items, older, loadOlder: loadOlderPage, messages, running, resetKey: key });
583
543
  const hasBelow = hiddenBelow > 0;
584
- useEffect(() => {
585
- const root = scrollRef.current;
586
- if (!root || typeof IntersectionObserver === "undefined") return;
587
- const watch = (target: HTMLElement | null, margin: string, go: () => void) => {
588
- if (!target) return null;
589
- const io = new IntersectionObserver((entries) => { if (!pinned.current && entries.some((e) => e.isIntersecting)) go(); }, { root, rootMargin: margin });
590
- io.observe(target);
591
- return io;
592
- };
593
- const up = hasAbove ? watch(topSentinelRef.current, "1800px 0px 0px 0px", () => revealRef.current.earlier()) : null;
594
- const down = hasBelow ? watch(bottomSentinelRef.current, "0px 0px 1800px 0px", () => revealRef.current.later()) : null;
595
- return () => { up?.disconnect(); down?.disconnect(); };
596
- }, [hasAbove, hasBelow]);
597
-
598
- useLayoutEffect(() => {
599
- if (win.start === null || win.end !== null || endIdx - startIdx + 1 <= RENDER_MAX + 2) return;
600
- const el = scrollRef.current;
601
- if (!el) return;
602
- if (el.scrollTop < (el.scrollHeight - el.clientHeight) / 2) setRange(startIdx, startIdx + RENDER_MAX - 1);
603
- else { const ns = endIdx - RENDER_MAX + 1; setRange(ns, endIdx, measure(itemsRef.current[ns].key)); }
604
- }, [win, startIdx, endIdx, setRange, measure]);
605
-
606
- const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
607
- const el = scrollRef.current;
608
- if (!el) return;
609
- pinned.current = true;
610
- setAtBottom(true);
611
- if (winRef.current.start !== null || winRef.current.end !== null) { setWin({ start: null, end: null }); return; }
612
- el.scrollTo({ top: el.scrollHeight, behavior });
613
- }, []);
614
-
615
- // 在「你」的消息之间跳。
616
- const scrollToUserMsg = useCallback((dir: -1 | 1) => {
617
- const el = scrollRef.current;
618
- if (!el) return;
619
- const base = el.getBoundingClientRect().top;
620
- const tops = Array.from(el.querySelectorAll<HTMLElement>(".msg.user:not(.queued)")).map((n) => n.getBoundingClientRect().top - base + el.scrollTop);
621
- const cur = el.scrollTop;
622
- const candidates = dir < 0 ? tops.filter((t) => t < cur - 8) : tops.filter((t) => t > cur + 8);
623
- if (!candidates.length) { if (dir < 0) revealRef.current.earlier(); else revealRef.current.later(); }
624
- if (!tops.length) return;
625
- const target = candidates.length ? (dir < 0 ? candidates[candidates.length - 1] : candidates[0]) : dir < 0 ? tops[0] : tops[tops.length - 1];
626
- el.scrollTo({ top: Math.max(0, target - 12), behavior: "smooth" });
627
- }, []);
628
-
629
- useLayoutEffect(() => {
630
- const a = anchorRef.current;
631
- const el = scrollRef.current;
632
- if (a && a.win === win) {
633
- anchorRef.current = null;
634
- const node = el?.querySelector<HTMLElement>(`[data-tkey="${CSS.escape(a.key)}"]`);
635
- if (el && node) { const delta = node.getBoundingClientRect().top - a.top; if (Math.abs(delta) > 1) el.scrollTop += delta; }
636
- }
637
- }, [win]);
638
- useLayoutEffect(() => { if (pinned.current && scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; }, [messages, running, startIdx, endIdx, win]);
639
- useEffect(() => {
640
- const el = scrollRef.current;
641
- if (!el || typeof ResizeObserver === "undefined") return;
642
- const ro = new ResizeObserver(() => { if (pinned.current) el.scrollTop = el.scrollHeight; });
643
- ro.observe(el);
644
- return () => ro.disconnect();
645
- }, []);
646
- useEffect(() => {
647
- pinned.current = true;
648
- setAtBottom(true);
649
- setWin({ start: null, end: null });
650
- if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
651
- }, [key]);
652
544
 
653
545
  const contextReset = !!lastTurn?.context_reset && conv?.turns.some((turn) => turn.context_reset);
654
546
  const h = cfg.header;
@@ -712,6 +604,51 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
712
604
  };
713
605
  }, [store, key, isNew, board, openFile]);
714
606
 
607
+ // ── 输入框和内嵌看板的参数:保持引用稳定(输入框、看板都是 memo),智能体流式输出时不跟着每一块重画 ──
608
+ const saveExecution = useRef(execution.save);
609
+ saveExecution.current = execution.save;
610
+ const runControls = useMemo(() => <>
611
+ <RunControls task={{ id: draftScope, agent: execution.config?.agent ?? agent ?? initialOptions?.agent ?? undefined,
612
+ model: execution.config?.model, reasoning: execution.config?.reasoning, permission_mode: execution.config?.permission_mode }}
613
+ agents={execution.config?.agents} caps={executionAgent?.capabilities} defaults={executionAgent?.defaults}
614
+ showAgent={cfg.composer.agentSelector} showPermission={cfg.composer.executionMode}
615
+ unavailableReason={batch ? "一次性任务沿用任务执行设置,不支持在对话中修改。" : executionReadOnly ? "当前接入仅支持读取执行设置。" : execution.error ?? (!executionEnabled || execution.loading ? "正在读取执行设置…" : undefined)}
616
+ disabled={!executionEnabled || composerDisabled || running || batchPending || !!configurationBusy || execution.loading || executionReadOnly}
617
+ onSetAgent={agent => saveExecution.current({ agent, model: null, reasoning: null, permission_mode: null })}
618
+ onSetModel={model => saveExecution.current({ model })} onSetReasoning={reasoning => saveExecution.current({ reasoning })}
619
+ onSetPermission={permission_mode => saveExecution.current({ permission_mode })} />
620
+ {slots?.composerToolbar}
621
+ </>, [draftScope, execution.config, agent, initialOptions?.agent, executionAgent, cfg, batch, executionReadOnly, execution.error, executionEnabled, execution.loading,
622
+ composerDisabled, running, batchPending, configurationBusy, slots?.composerToolbar]);
623
+ const focusComposer = useCallback(() => chatRef.current?.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus({ preventScroll: true }), []);
624
+ const composerReferences = useMemo(() => (!!shownRefs?.length || !!viewing) && <div className="cli-refs composer-references" aria-label="下一条消息的引用">
625
+ {viewing && <ReferenceCard key={`${key}:viewing:${viewing.id}`} className="reference-viewing" title={`正在看${viewing.label}`} text={viewing.text} disabled={composerDisabled || !!configurationBusy} removeLabel={`不带上 ${viewing.label}`} onRemove={() => { if (key) store.dismissViewing(key); focusComposer(); }} />}
626
+ {(shownRefs ?? []).map(ref => <ReferenceCard key={`${key}:${ref.id}`} title={ref.kind === "message" && ref.label === "引用你的消息" ? "你" : ref.kind === "message" && ref.label === "引用智能体消息" ? "智能体" : ref.label} text={ref.text} disabled={composerDisabled || !!configurationBusy} removeLabel={`不带上 ${ref.label}`} onRemove={() => { if (key) store.removeRef(key, ref.id); focusComposer(); }} />)}
627
+ </div>, [shownRefs, viewing, key, composerDisabled, configurationBusy, store, focusComposer]);
628
+ const queuedMessages = useMemo(() => <QueuedMessages scopeKey={key ?? "new"} messages={queued} onCancel={handlers.onCancelQueued} onSendNow={queueSendNow} sendNowUnavailable={queueSendNowHint} onEdit={handlers.onCancelQueued ? editQueued : undefined} attachmentUrl={handlers.attachmentUrl} />,
629
+ [key, queued, handlers, queueSendNow, queueSendNowHint, editQueued]);
630
+ const onClearRef = useRef(onClear);
631
+ onClearRef.current = onClear;
632
+ // 发出前记下当时是否在执行:在执行时发的消息会排队,不滚到底。
633
+ const busyRef = useRef(false);
634
+ busyRef.current = running || batchPending;
635
+ const sendFromComposer = useCallback(async (text: string) => {
636
+ if (text.trim() === "/clear" && onClearRef.current) { onClearRef.current(); return; }
637
+ const sentFrom = key;
638
+ const wasBusy = busyRef.current;
639
+ await send(text);
640
+ if (!wasBusy && latest.current.key === sentFrom) scrollToBottom("auto");
641
+ }, [key, send, scrollToBottom]);
642
+ const pauseResume = useMemo(() => cfg.composer.stop && !readOnlyProp && controlTask && jobId && store.client.pauseTask && store.client.resumeTask ? {
643
+ paused: controlTask.status === "paused",
644
+ onToggle: async () => {
645
+ const action = controlTask.status === "paused" ? store.client.resumeTask! : store.client.pauseTask!;
646
+ await report(action(controlTask.id).then(() => store.refreshJob(jobId, true)), controlTask.status === "paused" ? "继续任务失败" : "暂停任务失败");
647
+ },
648
+ } : undefined, [cfg, readOnlyProp, controlTask, jobId, store, report]);
649
+ const inlineLayout = useMemo(() => (mobileWorkspace ? undefined : { tabsHost, active: workspaceActive, onActivate: showWorkspace, onChat: showChat }),
650
+ [mobileWorkspace, tabsHost, workspaceActive, showWorkspace, showChat]);
651
+
715
652
  return (
716
653
  <FileLinkContext.Provider value={resolveFile}>
717
654
  <FileOpenContext.Provider value={openFile}>
@@ -790,7 +727,12 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
790
727
  </div></div>
791
728
  )}
792
729
  {contextReset && <div className="msg system info"><div className="msg-body">ℹ 执行节点上的会话重建过:从那一轮起是新的 Agent 会话,之前的对话上下文没有带过去(工作区文件还在)。</div></div>}
793
- {loading && (
730
+ {transcriptProblem && (
731
+ <div className="msg system info cli-transcript-problem" role="status"><div className="msg-body">⚠ {transcriptProblem}
732
+ <button className="btn btn-line btn-sm" style={{ marginLeft: 8 }} onClick={rereadTranscript}>重新读取</button>
733
+ </div></div>
734
+ )}
735
+ {loading && !transcriptProblem && (
794
736
  <div aria-hidden>
795
737
  <div className="session-label"><span className="ln" />正在加载会话<span className="ln" /></div>
796
738
  <div className="msg user" style={{ opacity: .7 }}>
@@ -803,9 +745,9 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
803
745
  </div>
804
746
  </div>
805
747
  )}
806
- {hasAbove && (
748
+ {hiddenAbove > 0 && (
807
749
  <div ref={topSentinelRef} className="load-older">
808
- <button className="btn btn-ghost btn-sm" onClick={revealEarlier} disabled={loadingOlder}>{loadingOlder ? "正在加载更早的消息…" : `加载更早的消息(还有 ${hiddenAbove + older} 条)`}</button>
750
+ <button className="btn btn-ghost btn-sm" onClick={() => void revealEarlier()} disabled={loadingOlder}>{loadingOlder ? "正在加载更早的消息…" : `加载更早的消息(还有 ${hiddenAbove} 条)`}</button>
809
751
  </div>
810
752
  )}
811
753
  {shown.map((it) =>
@@ -868,36 +810,16 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
868
810
  uploadDocument={uploadDocument}
869
811
  key={key ?? "new"}
870
812
  draft={draftScope}
871
- context={(!!shownRefs?.length || !!viewing) && <div className="cli-refs composer-references" aria-label="下一条消息的引用">
872
- {viewing && <ReferenceCard key={`${key}:viewing:${viewing.id}`} className="reference-viewing" title={`正在看${viewing.label}`} text={viewing.text} disabled={composerDisabled || !!configurationBusy} removeLabel={`不带上 ${viewing.label}`} onRemove={() => { if (key) store.dismissViewing(key); chatRef.current?.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus({ preventScroll: true }); }} />}
873
- {(shownRefs ?? []).map(ref => <ReferenceCard key={`${key}:${ref.id}`} title={ref.kind === "message" && ref.label === "引用你的消息" ? "你" : ref.kind === "message" && ref.label === "引用智能体消息" ? "智能体" : ref.label} text={ref.text} disabled={composerDisabled || !!configurationBusy} removeLabel={`不带上 ${ref.label}`} onRemove={() => { if (key) store.removeRef(key, ref.id); chatRef.current?.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus({ preventScroll: true }); }} />)}
874
- </div>}
813
+ context={composerReferences}
875
814
  placeholder={fillTemplate(key && !isNew ? cfg.composer.placeholder : cfg.composer.placeholderNew, { title: convTitle, agent: agentText })}
876
815
  disabled={composerDisabled || settingsBusy}
877
816
  disabledHint={cfg.composer.placeholderDisabled ? fillTemplate(cfg.composer.placeholderDisabled, { title: convTitle, agent: agentText }) : disabledHint}
878
817
  running={running || batchPending}
879
- toolbar={<>
880
- <RunControls task={{ id: draftScope, agent: execution.config?.agent ?? agent ?? initialOptions?.agent ?? undefined,
881
- model: execution.config?.model, reasoning: execution.config?.reasoning, permission_mode: execution.config?.permission_mode }}
882
- agents={execution.config?.agents} caps={executionAgent?.capabilities} defaults={executionAgent?.defaults}
883
- showAgent={cfg.composer.agentSelector} showPermission={cfg.composer.executionMode}
884
- unavailableReason={batch ? "一次性任务沿用任务执行设置,不支持在对话中修改。" : executionReadOnly ? "当前接入仅支持读取执行设置。" : execution.error ?? (!executionEnabled || execution.loading ? "正在读取执行设置…" : undefined)}
885
- disabled={!executionEnabled || composerDisabled || running || batchPending || !!configurationBusy || execution.loading || executionReadOnly}
886
- onSetAgent={agent => execution.save({ agent, model: null, reasoning: null, permission_mode: null })}
887
- onSetModel={model => execution.save({ model })} onSetReasoning={reasoning => execution.save({ reasoning })}
888
- onSetPermission={permission_mode => execution.save({ permission_mode })} />
889
- {slots?.composerToolbar}
890
- </>}
891
- queued={<QueuedMessages scopeKey={key ?? "new"} messages={queued} onCancel={handlers.onCancelQueued} onSendNow={queueSendNow} sendNowUnavailable={queueSendNowHint} onEdit={handlers.onCancelQueued ? editQueued : undefined} attachmentUrl={handlers.attachmentUrl} />}
892
- onSend={async text => { if (text.trim() === "/clear" && onClear) { onClear(); return; } const sentFrom = key; await send(text); if (!running && !batchPending && latest.current.key === sentFrom) scrollToBottom("auto"); }}
818
+ toolbar={runControls}
819
+ queued={queuedMessages}
820
+ onSend={sendFromComposer}
893
821
  onStop={cfg.composer.stop && key && conv && runningTurn(conv) ? stop : undefined}
894
- taskControl={cfg.composer.stop && !readOnlyProp && controlTask && jobId && store.client.pauseTask && store.client.resumeTask ? {
895
- paused: controlTask.status === "paused",
896
- onToggle: async () => {
897
- const action = controlTask.status === "paused" ? store.client.resumeTask! : store.client.pauseTask!;
898
- await report(action(controlTask.id).then(() => store.refreshJob(jobId, true)), controlTask.status === "paused" ? "继续任务失败" : "暂停任务失败");
899
- },
900
- } : undefined}
822
+ taskControl={pauseResume}
901
823
  options={composerOptions}
902
824
  />
903
825
  )}
@@ -907,7 +829,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
907
829
  readOnly={readOnly || externalBoardOptions?.readOnly} availableTabs={externalBoardOptions?.availableTabs}
908
830
  acceptOpen={fileOpen !== "modal" && fileOpen !== "link"} theme={theme}
909
831
  visible={workspaceActive} onExpand={showWorkspace} onQuoteToChat={quoteToChat}
910
- inlineLayout={mobileWorkspace ? undefined : { tabsHost, active: workspaceActive, onActivate: showWorkspace, onChat: showChat }} />}
832
+ inlineLayout={inlineLayout} />}
911
833
  </WorkspaceDrawer>
912
834
  </div>
913
835
  </SubagentChatScope>
@@ -915,7 +837,8 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
915
837
  <div className="scrim" onClick={(e) => { if (e.target === e.currentTarget) setPopup(null); }}>
916
838
  <div className={`modal cli-popup ${popup.kind}`} role="dialog" aria-modal="true" aria-label={popup.kind === "file" ? popup.path : "任务浏览器"}>
917
839
  {popup.kind === "file" ? (
918
- <FileViewer backend={board} jobId={key} path={popup.path} onClose={() => setPopup(null)} onOpenPath={(p) => setPopup({ kind: "file", path: p })} />
840
+ <FileViewer backend={board} jobId={key} path={popup.path} onClose={() => setPopup(null)} onOpenPath={(p) => setPopup({ kind: "file", path: p })}
841
+ resolveMissing={resolvePopupPath} onResolved={(to) => setPopup({ kind: "file", path: to, resolvedFrom: popup.resolvedFrom ?? popup.path })} resolvedFrom={popup.resolvedFrom} />
919
842
  ) : (
920
843
  <>
921
844
  <div className="cli-fv-h"><span className="cli-fv-name">任务浏览器</span><span className="cli-fv-sp" /><button className="cli-fv-icon" onClick={() => setPopup(null)} title="关闭">{Icon.x()}</button></div>
@@ -1,6 +1,6 @@
1
1
  // 输入框与 Operator 共用原件上传。上传完成只保留附件引用,用户发送时才交给 Agent。
2
2
  // 没有原件上传适配器的宿主继续使用原有文档文本解析。
3
- import { useEffect, useRef, useState, type ReactNode } from "react";
3
+ import { memo, useEffect, useRef, useState, type ReactNode } from "react";
4
4
  import {
5
5
  ACCEPT_ATTR, ACCEPTED_LABEL, MAX_MESSAGE_CHARS, PASTE_NAME, charCount, composeMessage, isLongText, readFileText, type TextBlock,
6
6
  } from "../core/blocks";
@@ -24,7 +24,8 @@ export interface ComposerOptions {
24
24
  queue: boolean;
25
25
  }
26
26
 
27
- export function Composer({ draft, placeholder, disabled, disabledHint, running, onSend, onStop, taskControl, options, toolbar, queued, context, uploadDocument }: {
27
+ // memo:对话每次重画(流式输出时每来一块一次)不带着输入框重画;参数由 CliChat 保持引用稳定。
28
+ export const Composer = memo(function Composer({ draft, placeholder, disabled, disabledHint, running, onSend, onStop, taskControl, options, toolbar, queued, context, uploadDocument }: {
28
29
  /** 草稿按这个键存在 localStorage(一般是对话 id)。 */
29
30
  draft: string;
30
31
  placeholder: string;
@@ -236,4 +237,4 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
236
237
  </div>
237
238
  </div>
238
239
  );
239
- }
240
+ });
@@ -68,6 +68,8 @@ export interface ConvState {
68
68
  /** 节点转录(Operator 消息)。 */
69
69
  msgs: Msg[];
70
70
  hasTranscript: boolean;
71
+ /** 中枢说这段对话记录暂时读不到(transcript_unavailable);读到转录后清掉。 */
72
+ transcriptError: string | null;
71
73
  /** 节点转录里比 msgs 更早、还没取的条数(实时流只给最新一段;往上翻时 store.loadOlder 分页取)。 */
72
74
  older: number;
73
75
  nodeRunning: boolean;
@@ -76,6 +78,8 @@ export interface ConvState {
76
78
  * 断线重连后补齐时 +1。首次连上不算。看板的「改动」据此重读 diff。
77
79
  */
78
80
  settled: number;
81
+ /** 运行中工具执行完的次数(节点 tool_result 事件):工具可能改了文件,看板的「改动」据此在一轮当中节流重读。 */
82
+ edits: number;
79
83
  resolvedModel: string | null;
80
84
  /** 上次刷新任务详情之后,实时流里累计的用量(usage 事件)。 */
81
85
  liveUsage: { fresh: number; cacheRead: number };
@@ -97,7 +101,7 @@ export interface ConvState {
97
101
  }
98
102
 
99
103
  export const emptyConv = (key: string): ConvState => ({
100
- key, ...parseConvKey(key), job: null, task: null, turns: [], msgs: [], hasTranscript: false, older: 0, nodeRunning: false, settled: 0, resolvedModel: null,
104
+ key, ...parseConvKey(key), job: null, task: null, turns: [], msgs: [], hasTranscript: false, transcriptError: null, older: 0, nodeRunning: false, settled: 0, edits: 0, resolvedModel: null,
101
105
  liveUsage: { fresh: 0, cacheRead: 0 }, stream: "idle", streamError: null, endReason: null, continuedBy: null, localAnswers: {},
102
106
  batch: false, batchTask: null, remoteTaskId: null,
103
107
  });
@@ -161,7 +165,7 @@ function focusBatchTask(conv: ConvState, task: BatchStreamTask | null): ConvStat
161
165
  const batchTask = task ? { ...kept, ...task } : null;
162
166
  return {
163
167
  ...conv, batch: true, batchTask, task: batchTask ? batchStreamTask(batchTask) : null, remoteTaskId: remote,
164
- ...(sameRemote ? {} : { msgs: [], hasTranscript: false, older: 0, nodeRunning: false }),
168
+ ...(sameRemote ? {} : { msgs: [], hasTranscript: false, transcriptError: null, older: 0, nodeRunning: false }),
165
169
  };
166
170
  }
167
171
 
@@ -190,12 +194,14 @@ export function applyItems(conv: ConvState, items: StreamItem[]): ConvState {
190
194
  let next = conv;
191
195
  for (const item of items) {
192
196
  // 一次性任务:只认看的这个子任务所在的节点对话。
193
- if (next.batch && (item.type === "transcript" || item.type === "node") && item.remote_task_id && item.remote_task_id !== next.remoteTaskId) continue;
197
+ if (next.batch && (item.type === "transcript" || item.type === "node" || item.type === "transcript_unavailable") && item.remote_task_id && item.remote_task_id !== next.remoteTaskId) continue;
194
198
  if (item.type === "transcript") {
195
199
  // 节点转录全量(连上 / 重连节点时各一次):整体替换,没变的消息保留对象引用。
196
200
  const messages = Array.isArray(item.messages) ? item.messages.map(toMsg) : [];
197
201
  const merged = mergeSnapshot(next.msgs, messages, typeof item.older === "number" ? item.older : 0);
198
- next = { ...next, msgs: merged.msgs, older: merged.older, hasTranscript: true, nodeRunning: item.running === true };
202
+ next = { ...next, msgs: merged.msgs, older: merged.older, hasTranscript: true, transcriptError: null, nodeRunning: item.running === true };
203
+ } else if (item.type === "transcript_unavailable") {
204
+ if (!next.hasTranscript) next = { ...next, transcriptError: typeof item.error === "string" && item.error ? item.error : "暂时读不到这段对话记录" };
199
205
  } else if (item.type === "node") {
200
206
  next = applyNodeEvent(next, item.event);
201
207
  } else if (item.type === "turn") {
@@ -226,6 +232,12 @@ const localId = (prefix: string) => `${prefix}-${Date.now()}-${++synthetic}`;
226
232
 
227
233
  /** 节点的一个回合事件。与 Operator 的 handleStreamEvent 同一套规则。 */
228
234
  export function applyNodeEvent(conv: ConvState, ev: NodeEvent): ConvState {
235
+ const next = applyTranscriptEvent(conv, ev);
236
+ // 工具执行完(不管对不对得上卡片)都算一次:看板的「改动」据此重读。
237
+ return ev.type === "tool_result" ? { ...next, edits: (next.edits ?? 0) + 1 } : next;
238
+ }
239
+
240
+ function applyTranscriptEvent(conv: ConvState, ev: NodeEvent): ConvState {
229
241
  const e = ev as Record<string, any>;
230
242
  const gen = typeof e.generation === "number" ? e.generation : 1;
231
243
  const set = (msgs: Msg[]) => ({ ...conv, msgs });