@xgspz/cli-ui 0.6.15 → 0.6.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +21 -1
  3. package/dist/{cli-ui-embed-vIXVNs6k.js → cli-ui-embed-D9xgur2y.js} +18299 -18587
  4. package/dist/{cli-ui-index-R24x28OX.js → cli-ui-index-WBtxmw23.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-DUghzByz.js → cli-ui-jszip.min-CU3ZzoXb.js} +1 -1
  6. package/dist/{cli-ui-jszip.min-DfH6rdQD.js → cli-ui-jszip.min-DdU0X3VJ.js} +2 -2
  7. package/dist/{cli-ui-xlsx-CP0lJhff.js → cli-ui-xlsx-Dx6T7qhC.js} +1 -1
  8. package/dist/cli-ui.css +1 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +1 -1
  11. package/src/board/ChangesPane.tsx +10 -4
  12. package/src/board/CliBoard.tsx +48 -16
  13. package/src/board/FileViewer.tsx +34 -1
  14. package/src/board/diffRefresh.ts +63 -0
  15. package/src/board/linkage.ts +68 -0
  16. package/src/board/pathResolve.ts +108 -0
  17. package/src/chat/CliChat.tsx +76 -181
  18. package/src/chat/Composer.tsx +4 -3
  19. package/src/core/conversation.ts +9 -1
  20. package/src/core/store.ts +112 -38
  21. package/src/msglist/CliMsgList.tsx +3 -2
  22. package/src/operator/node/AgentConnect.tsx +19 -15
  23. package/src/operator/node/AppearancePanel.tsx +1 -1
  24. package/src/operator/node/BranchNavigator.tsx +1 -1
  25. package/src/operator/node/BrowserPane.tsx +1 -1
  26. package/src/operator/node/Chat.tsx +16 -261
  27. package/src/operator/node/CommandPalette.tsx +1 -1
  28. package/src/operator/node/Composer.tsx +1 -1
  29. package/src/operator/node/Layout.tsx +1 -1
  30. package/src/operator/node/Markdown.tsx +6 -36
  31. package/src/operator/node/Modal.tsx +1 -1
  32. package/src/operator/node/OnboardingWizard.tsx +9 -5
  33. package/src/operator/node/Orchestrator.tsx +1 -1
  34. package/src/operator/node/ProjectLanding.tsx +1 -1
  35. package/src/operator/node/ProjectsColumn.tsx +1 -1
  36. package/src/operator/node/ProviderAuth.tsx +12 -10
  37. package/src/operator/node/RunSwitch.tsx +1 -1
  38. package/src/operator/node/Services.tsx +1 -1
  39. package/src/operator/node/SessionRail.tsx +27 -8
  40. package/src/operator/node/SessionView.tsx +1 -1
  41. package/src/operator/node/SettingsView.tsx +1 -1
  42. package/src/operator/node/TaskBoard.tsx +1 -1
  43. package/src/operator/node/TaskChanges.tsx +34 -5
  44. package/src/operator/node/TaskHero.tsx +1 -1
  45. package/src/operator/node/TasksColumn.tsx +1 -1
  46. package/src/operator/node/Transcript.tsx +10 -237
  47. package/src/operator/node/Welcome.tsx +1 -1
  48. package/src/operator/node/WorktreePrune.tsx +1 -1
  49. package/src/operator/node/boardInteraction.ts +6 -3
  50. package/src/operator/node/boardState.ts +12 -13
  51. package/src/operator/node/fileLinks.ts +4 -38
  52. package/src/operator/node/format.ts +12 -104
  53. package/src/operator/node/github.tsx +10 -8
  54. package/src/operator/node/modals.tsx +15 -13
  55. package/src/operator/node/recheckOnReturn.ts +28 -0
  56. package/src/operator/node/shared.tsx +10 -50
  57. package/src/operator/node/useTaskStream.ts +2 -2
  58. package/src/styles.css +5 -0
  59. package/src/ui/Markdown.tsx +36 -21
  60. package/src/ui/Transcript.tsx +8 -5
  61. package/src/ui/fileLinks.ts +6 -3
  62. package/src/ui/format.ts +11 -5
  63. package/src/ui/shared.tsx +11 -7
  64. package/src/ui/transcriptModel.ts +40 -9
  65. package/src/ui/useTranscriptWindow.ts +253 -0
  66. package/src/operator/node/boundedPoll.ts +0 -47
  67. package/src/operator/node/icons.tsx +0 -88
  68. package/src/operator/node/transcriptModel.ts +0 -129
@@ -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";
@@ -386,13 +388,13 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
386
388
  if (target !== id && latest.current.key === refKey) go(target);
387
389
  }, [store, go, passiveContext]);
388
390
  const passiveView=useCliState(state=>key?state.boardContext[key]:undefined);
389
- const passiveText=passiveContext&&passiveView?formatBoardContext(passiveView,key??undefined):'';
391
+ const passiveText=useMemo(()=>passiveContext&&passiveView?formatBoardContext(passiveView,key??undefined):'',[passiveContext,passiveView,key]);
390
392
  const refs = useCliState((s) => (key ? s.refs[key] : undefined));
391
393
  const viewingState = useCliState((s) => (key ? s.viewing[key] : undefined));
392
394
  const viewingRef = viewingState && !viewingState.off && !refs?.some((r) => r.id === viewingState.ref.id) ? viewingState.ref : null;
393
395
  // 输入框上方显示哪些(config.refs):不显示的照样随消息带上。正在看的缺省不显示。
394
396
  const viewing = !passiveText && viewingRef && refShown(cfg, "viewing") ? viewingRef : null;
395
- const shownRefs = refs?.filter((r) => refShown(cfg, r.kind));
397
+ const shownRefs = useMemo(() => refs?.filter((r) => refShown(cfg, r.kind)), [refs, cfg]);
396
398
  const chatRef = useRef<HTMLDivElement>(null);
397
399
  // 业务方补充的背景(store.appendContext):随下一条消息带上,输入框上方显示出来,让用户知道会带上什么。
398
400
  const contextItems = useCliState((s) => s.context[key ?? ""]);
@@ -412,7 +414,14 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
412
414
  }, [key, conv, fileOpen, store, inlineOwner]);
413
415
 
414
416
  // 对话里弹窗打开的文件 / 浏览器(没有看板接的时候)。换对话就关掉。
415
- 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]);
416
425
  useEffect(() => setPopup(null), [key]);
417
426
  useEffect(() => {
418
427
  if (!popup) return;
@@ -511,167 +520,27 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
511
520
  useEffect(() => { lastActivityRef.current = Date.now(); }, [messages]);
512
521
  const turnStartedAt = runSeenAt ?? lastUserTs;
513
522
 
514
- // ── 分窗渲染(移植自 Operator 的 SessionView):DOM 里最多 RENDER_MAX 行,几千条消息也照样流畅 ──
523
+ // ── 分窗渲染(与节点 Chat 共用 useTranscriptWindow):DOM 里最多 RENDER_MAX 行,几千条消息也照样流畅 ──
515
524
  const scrollRef = useRef<HTMLDivElement>(null);
516
525
  useChatScrollbar(scrollRef, key);
517
- const pinned = useRef(true);
518
- const [atBottom, setAtBottom] = useState(true);
519
526
  const committed = useMemo(() => messages.filter((m) => m.role !== "queued"), [messages]);
520
- 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
521
531
  // 「立即发送」只在有一轮正在执行、且接入开放了停止时可用。
522
532
  const queueSendNow = !readOnly && !batch && cfg.composer.stop && key && conv && runningTurn(conv) ? sendQueuedNow : undefined;
523
533
  const queueSendNowHint = !cfg.composer.stop && conv && runningTurn(conv) ? "当前接入没有开放停止,排队消息会在这一轮结束后发送" : undefined;
524
534
  // 节点转录比手里更早、还没取的条数(实时流只给最新一段,往上翻时分页取,和 Operator 一样)。
525
535
  const older = conv?.older ?? 0;
526
- const [loadingOlder, setLoadingOlder] = useState(false);
527
- const olderRef = useRef(older); olderRef.current = older;
528
- const keyRef2 = useRef(key); keyRef2.current = key;
529
536
  const items = useMemo(() => buildTranscriptItems(committed, older > 0)
530
537
  .filter((it) => cfg.transcript.initialPrompt || it.kind !== "msg" || !it.initial), [committed, older, cfg.transcript.initialPrompt]);
531
538
  // 展开了的「续等」折叠行(按折叠行的 key)。
532
539
  const [openFolds, setOpenFolds] = useState<Set<string>>(() => new Set());
533
- const [win, setWin] = useState<{ start: string | null; end: string | null }>({ start: null, end: null });
534
- const { startIdx, endIdx } = useMemo(() => {
535
- const find = (key: string | null) => (key ? items.findIndex((it) => it.key === key) : -1);
536
- const e = find(win.end);
537
- const last = e >= 0 ? e : items.length - 1;
538
- let s = find(win.start);
539
- if (s >= 0) while (s > 0 && items[s - 1].kind !== "msg") s--;
540
- return { startIdx: s >= 0 && s <= last ? s : Math.max(0, last + 1 - RENDER_WINDOW), endIdx: last };
541
- }, [items, win]);
542
- const shown = useMemo(() => items.slice(startIdx, endIdx + 1), [items, startIdx, endIdx]);
543
- const hiddenAbove = useMemo(() => { let n = 0; for (let i = 0; i < startIdx; i++) if (items[i].kind === "msg") n++; return n; }, [items, startIdx]);
544
- 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]);
545
- const itemsRef = useRef(items); itemsRef.current = items;
546
- const rangeRef = useRef({ startIdx, endIdx }); rangeRef.current = { startIdx, endIdx };
547
- const winRef = useRef(win); winRef.current = win;
548
- type Anchor = { key: string; top: number };
549
- const anchorRef = useRef<(Anchor & { win: typeof win }) | null>(null);
550
-
551
- const measure = useCallback((key?: string): Anchor | null => {
552
- const el = scrollRef.current;
553
- const node = key ? el?.querySelector<HTMLElement>(`[data-tkey="${CSS.escape(key)}"]`) : el?.querySelector<HTMLElement>("[data-tkey][data-msg]");
554
- return node?.dataset.tkey ? { key: node.dataset.tkey, top: node.getBoundingClientRect().top } : null;
555
- }, []);
556
- const setRange = useCallback((s: number, e: number, anchor?: Anchor | null) => {
557
- const its = itemsRef.current;
558
- let a = s; while (a < its.length && its[a].kind !== "msg") a++;
559
- let b = e; while (b > 0 && its[b].kind !== "msg") b--;
560
- const next = { start: its[a]?.key ?? null, end: e >= its.length - 1 ? null : its[b]?.key ?? null };
561
- if (winRef.current.start === next.start && winRef.current.end === next.end) return;
562
- if (anchor) anchorRef.current = { ...anchor, win: next };
563
- setWin(next);
564
- }, []);
565
- const revealEarlier = useCallback(() => {
566
- const { startIdx: s, endIdx: e } = rangeRef.current;
567
- if (s <= 0) {
568
- // 手里的都显示了,节点上还有更早的:取一页接在前面,再往上展开(窗口按原来的那一行定位,不跳)。
569
- const k = keyRef2.current;
570
- if (!k || olderRef.current <= 0) return;
571
- setLoadingOlder(true);
572
- void store.loadOlder(k).then((ok) => { if (ok) requestAnimationFrame(() => revealRef.current.earlier()); }).finally(() => setLoadingOlder(false));
573
- return;
574
- }
575
- const ns = Math.max(0, s - RENDER_STEP);
576
- setRange(ns, Math.min(e, ns + RENDER_MAX - 1), measure());
577
- }, [measure, setRange, store]);
578
- const revealLater = useCallback(() => {
579
- const its = itemsRef.current;
580
- const { startIdx: s, endIdx: e } = rangeRef.current;
581
- if (e >= its.length - 1) return;
582
- const ne = Math.min(its.length - 1, e + RENDER_STEP);
583
- const ns = Math.max(s, ne - RENDER_MAX + 1);
584
- setRange(ns, ne, ns > s ? measure(its[ns].key) : null);
585
- }, [measure, setRange]);
586
- const revealRef = useRef({ earlier: revealEarlier, later: revealLater });
587
- revealRef.current = { earlier: revealEarlier, later: revealLater };
588
-
589
- const onScroll = useCallback(() => {
590
- const el = scrollRef.current;
591
- if (!el) return;
592
- const bottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60 && winRef.current.end === null;
593
- pinned.current = bottom;
594
- setAtBottom(bottom);
595
- if (!bottom && winRef.current.start === null) {
596
- const first = itemsRef.current.slice(rangeRef.current.startIdx).find((it) => it.kind === "msg");
597
- if (first) setWin((w) => ({ ...w, start: first.key }));
598
- } else if (bottom && winRef.current.start !== null) {
599
- setWin({ start: null, end: null });
600
- }
601
- }, []);
602
-
603
- const topSentinelRef = useRef<HTMLDivElement>(null);
604
- const bottomSentinelRef = useRef<HTMLDivElement>(null);
605
- 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 });
606
543
  const hasBelow = hiddenBelow > 0;
607
- useEffect(() => {
608
- const root = scrollRef.current;
609
- if (!root || typeof IntersectionObserver === "undefined") return;
610
- const watch = (target: HTMLElement | null, margin: string, go: () => void) => {
611
- if (!target) return null;
612
- const io = new IntersectionObserver((entries) => { if (!pinned.current && entries.some((e) => e.isIntersecting)) go(); }, { root, rootMargin: margin });
613
- io.observe(target);
614
- return io;
615
- };
616
- const up = hasAbove ? watch(topSentinelRef.current, "1800px 0px 0px 0px", () => revealRef.current.earlier()) : null;
617
- const down = hasBelow ? watch(bottomSentinelRef.current, "0px 0px 1800px 0px", () => revealRef.current.later()) : null;
618
- return () => { up?.disconnect(); down?.disconnect(); };
619
- }, [hasAbove, hasBelow]);
620
-
621
- useLayoutEffect(() => {
622
- if (win.start === null || win.end !== null || endIdx - startIdx + 1 <= RENDER_MAX + 2) return;
623
- const el = scrollRef.current;
624
- if (!el) return;
625
- if (el.scrollTop < (el.scrollHeight - el.clientHeight) / 2) setRange(startIdx, startIdx + RENDER_MAX - 1);
626
- else { const ns = endIdx - RENDER_MAX + 1; setRange(ns, endIdx, measure(itemsRef.current[ns].key)); }
627
- }, [win, startIdx, endIdx, setRange, measure]);
628
-
629
- const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
630
- const el = scrollRef.current;
631
- if (!el) return;
632
- pinned.current = true;
633
- setAtBottom(true);
634
- if (winRef.current.start !== null || winRef.current.end !== null) { setWin({ start: null, end: null }); return; }
635
- el.scrollTo({ top: el.scrollHeight, behavior });
636
- }, []);
637
-
638
- // 在「你」的消息之间跳。
639
- const scrollToUserMsg = useCallback((dir: -1 | 1) => {
640
- const el = scrollRef.current;
641
- if (!el) return;
642
- const base = el.getBoundingClientRect().top;
643
- const tops = Array.from(el.querySelectorAll<HTMLElement>(".msg.user:not(.queued)")).map((n) => n.getBoundingClientRect().top - base + el.scrollTop);
644
- const cur = el.scrollTop;
645
- const candidates = dir < 0 ? tops.filter((t) => t < cur - 8) : tops.filter((t) => t > cur + 8);
646
- if (!candidates.length) { if (dir < 0) revealRef.current.earlier(); else revealRef.current.later(); }
647
- if (!tops.length) return;
648
- const target = candidates.length ? (dir < 0 ? candidates[candidates.length - 1] : candidates[0]) : dir < 0 ? tops[0] : tops[tops.length - 1];
649
- el.scrollTo({ top: Math.max(0, target - 12), behavior: "smooth" });
650
- }, []);
651
-
652
- useLayoutEffect(() => {
653
- const a = anchorRef.current;
654
- const el = scrollRef.current;
655
- if (a && a.win === win) {
656
- anchorRef.current = null;
657
- const node = el?.querySelector<HTMLElement>(`[data-tkey="${CSS.escape(a.key)}"]`);
658
- if (el && node) { const delta = node.getBoundingClientRect().top - a.top; if (Math.abs(delta) > 1) el.scrollTop += delta; }
659
- }
660
- }, [win]);
661
- useLayoutEffect(() => { if (pinned.current && scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; }, [messages, running, startIdx, endIdx, win]);
662
- useEffect(() => {
663
- const el = scrollRef.current;
664
- if (!el || typeof ResizeObserver === "undefined") return;
665
- const ro = new ResizeObserver(() => { if (pinned.current) el.scrollTop = el.scrollHeight; });
666
- ro.observe(el);
667
- return () => ro.disconnect();
668
- }, []);
669
- useEffect(() => {
670
- pinned.current = true;
671
- setAtBottom(true);
672
- setWin({ start: null, end: null });
673
- if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
674
- }, [key]);
675
544
 
676
545
  const contextReset = !!lastTurn?.context_reset && conv?.turns.some((turn) => turn.context_reset);
677
546
  const h = cfg.header;
@@ -735,6 +604,51 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
735
604
  };
736
605
  }, [store, key, isNew, board, openFile]);
737
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
+
738
652
  return (
739
653
  <FileLinkContext.Provider value={resolveFile}>
740
654
  <FileOpenContext.Provider value={openFile}>
@@ -831,9 +745,9 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
831
745
  </div>
832
746
  </div>
833
747
  )}
834
- {hasAbove && (
748
+ {hiddenAbove > 0 && (
835
749
  <div ref={topSentinelRef} className="load-older">
836
- <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>
837
751
  </div>
838
752
  )}
839
753
  {shown.map((it) =>
@@ -896,36 +810,16 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
896
810
  uploadDocument={uploadDocument}
897
811
  key={key ?? "new"}
898
812
  draft={draftScope}
899
- context={(!!shownRefs?.length || !!viewing) && <div className="cli-refs composer-references" aria-label="下一条消息的引用">
900
- {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 }); }} />}
901
- {(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 }); }} />)}
902
- </div>}
813
+ context={composerReferences}
903
814
  placeholder={fillTemplate(key && !isNew ? cfg.composer.placeholder : cfg.composer.placeholderNew, { title: convTitle, agent: agentText })}
904
815
  disabled={composerDisabled || settingsBusy}
905
816
  disabledHint={cfg.composer.placeholderDisabled ? fillTemplate(cfg.composer.placeholderDisabled, { title: convTitle, agent: agentText }) : disabledHint}
906
817
  running={running || batchPending}
907
- toolbar={<>
908
- <RunControls task={{ id: draftScope, agent: execution.config?.agent ?? agent ?? initialOptions?.agent ?? undefined,
909
- model: execution.config?.model, reasoning: execution.config?.reasoning, permission_mode: execution.config?.permission_mode }}
910
- agents={execution.config?.agents} caps={executionAgent?.capabilities} defaults={executionAgent?.defaults}
911
- showAgent={cfg.composer.agentSelector} showPermission={cfg.composer.executionMode}
912
- unavailableReason={batch ? "一次性任务沿用任务执行设置,不支持在对话中修改。" : executionReadOnly ? "当前接入仅支持读取执行设置。" : execution.error ?? (!executionEnabled || execution.loading ? "正在读取执行设置…" : undefined)}
913
- disabled={!executionEnabled || composerDisabled || running || batchPending || !!configurationBusy || execution.loading || executionReadOnly}
914
- onSetAgent={agent => execution.save({ agent, model: null, reasoning: null, permission_mode: null })}
915
- onSetModel={model => execution.save({ model })} onSetReasoning={reasoning => execution.save({ reasoning })}
916
- onSetPermission={permission_mode => execution.save({ permission_mode })} />
917
- {slots?.composerToolbar}
918
- </>}
919
- queued={<QueuedMessages scopeKey={key ?? "new"} messages={queued} onCancel={handlers.onCancelQueued} onSendNow={queueSendNow} sendNowUnavailable={queueSendNowHint} onEdit={handlers.onCancelQueued ? editQueued : undefined} attachmentUrl={handlers.attachmentUrl} />}
920
- 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}
921
821
  onStop={cfg.composer.stop && key && conv && runningTurn(conv) ? stop : undefined}
922
- taskControl={cfg.composer.stop && !readOnlyProp && controlTask && jobId && store.client.pauseTask && store.client.resumeTask ? {
923
- paused: controlTask.status === "paused",
924
- onToggle: async () => {
925
- const action = controlTask.status === "paused" ? store.client.resumeTask! : store.client.pauseTask!;
926
- await report(action(controlTask.id).then(() => store.refreshJob(jobId, true)), controlTask.status === "paused" ? "继续任务失败" : "暂停任务失败");
927
- },
928
- } : undefined}
822
+ taskControl={pauseResume}
929
823
  options={composerOptions}
930
824
  />
931
825
  )}
@@ -935,7 +829,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
935
829
  readOnly={readOnly || externalBoardOptions?.readOnly} availableTabs={externalBoardOptions?.availableTabs}
936
830
  acceptOpen={fileOpen !== "modal" && fileOpen !== "link"} theme={theme}
937
831
  visible={workspaceActive} onExpand={showWorkspace} onQuoteToChat={quoteToChat}
938
- inlineLayout={mobileWorkspace ? undefined : { tabsHost, active: workspaceActive, onActivate: showWorkspace, onChat: showChat }} />}
832
+ inlineLayout={inlineLayout} />}
939
833
  </WorkspaceDrawer>
940
834
  </div>
941
835
  </SubagentChatScope>
@@ -943,7 +837,8 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
943
837
  <div className="scrim" onClick={(e) => { if (e.target === e.currentTarget) setPopup(null); }}>
944
838
  <div className={`modal cli-popup ${popup.kind}`} role="dialog" aria-modal="true" aria-label={popup.kind === "file" ? popup.path : "任务浏览器"}>
945
839
  {popup.kind === "file" ? (
946
- <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} />
947
842
  ) : (
948
843
  <>
949
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
+ });
@@ -78,6 +78,8 @@ export interface ConvState {
78
78
  * 断线重连后补齐时 +1。首次连上不算。看板的「改动」据此重读 diff。
79
79
  */
80
80
  settled: number;
81
+ /** 运行中工具执行完的次数(节点 tool_result 事件):工具可能改了文件,看板的「改动」据此在一轮当中节流重读。 */
82
+ edits: number;
81
83
  resolvedModel: string | null;
82
84
  /** 上次刷新任务详情之后,实时流里累计的用量(usage 事件)。 */
83
85
  liveUsage: { fresh: number; cacheRead: number };
@@ -99,7 +101,7 @@ export interface ConvState {
99
101
  }
100
102
 
101
103
  export const emptyConv = (key: string): ConvState => ({
102
- key, ...parseConvKey(key), job: null, task: null, turns: [], msgs: [], hasTranscript: false, transcriptError: null, 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,
103
105
  liveUsage: { fresh: 0, cacheRead: 0 }, stream: "idle", streamError: null, endReason: null, continuedBy: null, localAnswers: {},
104
106
  batch: false, batchTask: null, remoteTaskId: null,
105
107
  });
@@ -230,6 +232,12 @@ const localId = (prefix: string) => `${prefix}-${Date.now()}-${++synthetic}`;
230
232
 
231
233
  /** 节点的一个回合事件。与 Operator 的 handleStreamEvent 同一套规则。 */
232
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 {
233
241
  const e = ev as Record<string, any>;
234
242
  const gen = typeof e.generation === "number" ? e.generation : 1;
235
243
  const set = (msgs: Msg[]) => ({ ...conv, msgs });