@spzxg/cli-ui 0.6.2 → 0.6.6

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 (133) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +165 -19
  3. package/dist/cli-ui-embed-BAqonuoD.js +45952 -0
  4. package/dist/{cli-ui-index-BY5i3wId.js → cli-ui-index-b8wF7wrN.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-m01fexHW.js → cli-ui-jszip.min-B0uLrCHg.js} +2 -2
  6. package/dist/{cli-ui-jszip.min-BZsUwI-s.js → cli-ui-jszip.min-ggP8OokP.js} +1 -1
  7. package/dist/{cli-ui-xlsx-CpWicJLT.js → cli-ui-xlsx-DEDdHrJJ.js} +1 -1
  8. package/dist/cli-ui.css +32 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +17 -10
  11. package/src/board/BrowserPane.tsx +346 -115
  12. package/src/board/ChangesPane.tsx +2 -2
  13. package/src/board/CliBoard.tsx +79 -50
  14. package/src/board/FileViewer.tsx +54 -31
  15. package/src/board/FilesPane.tsx +32 -15
  16. package/src/board/PanelControls.tsx +28 -0
  17. package/src/board/blocks.tsx +5 -5
  18. package/src/board/context.ts +65 -0
  19. package/src/board/fileOps.ts +26 -3
  20. package/src/board/fileWatch.ts +94 -0
  21. package/src/board/folderDownload.ts +30 -14
  22. package/src/board/frameQuote.ts +2 -2
  23. package/src/board/panels.ts +33 -0
  24. package/src/board/textLoad.ts +9 -7
  25. package/src/board/workspace.ts +53 -14
  26. package/src/chat/CliChat.tsx +93 -40
  27. package/src/chat/Composer.tsx +44 -42
  28. package/src/chat/RunControls.tsx +99 -0
  29. package/src/chat/configuration.ts +15 -0
  30. package/src/chat/draft.ts +41 -0
  31. package/src/chat/export.ts +2 -2
  32. package/src/components.tsx +13 -0
  33. package/src/core/blocks.ts +17 -6
  34. package/src/core/callbacks.ts +8 -0
  35. package/src/core/client.ts +103 -19
  36. package/src/core/conversation.ts +4 -3
  37. package/src/core/id.ts +15 -0
  38. package/src/core/identity.ts +33 -0
  39. package/src/core/provider.tsx +31 -14
  40. package/src/core/store.ts +151 -53
  41. package/src/core/types.ts +64 -9
  42. package/src/elements.tsx +135 -29
  43. package/src/embed.ts +6 -8
  44. package/src/index.ts +24 -17
  45. package/src/layout/CliSplit.tsx +1 -1
  46. package/src/layout/split.ts +67 -30
  47. package/src/msglist/CliMsgList.tsx +19 -13
  48. package/src/operator/branchFlow.ts +35 -0
  49. package/src/operator/index.ts +9 -0
  50. package/src/operator/liveStream.ts +1 -0
  51. package/src/operator/node/AgentConnect.tsx +624 -0
  52. package/src/operator/node/AppearancePanel.tsx +35 -0
  53. package/src/operator/node/BranchHistory.tsx +67 -0
  54. package/src/operator/node/BranchNavigator.tsx +36 -0
  55. package/src/operator/node/BrowserPane.tsx +128 -0
  56. package/src/operator/node/BrowserPopup.tsx +22 -0
  57. package/src/operator/node/Chat.tsx +517 -0
  58. package/src/operator/node/CommandPalette.tsx +213 -0
  59. package/src/operator/node/Composer.tsx +287 -0
  60. package/src/operator/node/InsightsView.tsx +831 -0
  61. package/src/operator/node/Layout.tsx +190 -0
  62. package/src/operator/node/Markdown.tsx +61 -0
  63. package/src/operator/node/Modal.tsx +179 -0
  64. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  65. package/src/operator/node/OnboardingWizard.tsx +250 -0
  66. package/src/operator/node/Orchestrator.tsx +634 -0
  67. package/src/operator/node/ProjectLanding.tsx +86 -0
  68. package/src/operator/node/ProjectsColumn.tsx +137 -0
  69. package/src/operator/node/ProviderAuth.tsx +561 -0
  70. package/src/operator/node/RunControls.tsx +2 -0
  71. package/src/operator/node/RunSwitch.tsx +113 -0
  72. package/src/operator/node/Services.tsx +245 -0
  73. package/src/operator/node/SessionRail.tsx +260 -0
  74. package/src/operator/node/SessionView.tsx +288 -0
  75. package/src/operator/node/SettingsView.tsx +690 -0
  76. package/src/operator/node/TaskBoard.tsx +352 -0
  77. package/src/operator/node/TaskChanges.tsx +530 -0
  78. package/src/operator/node/TaskHero.tsx +50 -0
  79. package/src/operator/node/TasksColumn.tsx +200 -0
  80. package/src/operator/node/Terminal.tsx +143 -0
  81. package/src/operator/node/Transcript.tsx +468 -0
  82. package/src/operator/node/ViewFile.tsx +79 -0
  83. package/src/operator/node/Welcome.tsx +101 -0
  84. package/src/operator/node/WorktreePrune.tsx +333 -0
  85. package/src/operator/node/agents.ts +58 -0
  86. package/src/operator/node/api.ts +40 -0
  87. package/src/operator/node/boardBackend.ts +82 -0
  88. package/src/operator/node/boardEvents.ts +22 -0
  89. package/src/operator/node/boardInteraction.ts +15 -0
  90. package/src/operator/node/boardState.ts +57 -0
  91. package/src/operator/node/boardWorkspace.ts +12 -0
  92. package/src/operator/node/branchFlow.ts +1 -0
  93. package/src/operator/node/composerDraft.ts +52 -0
  94. package/src/operator/node/fileLinks.ts +81 -0
  95. package/src/operator/node/format.ts +290 -0
  96. package/src/operator/node/github.tsx +234 -0
  97. package/src/operator/node/icons.tsx +88 -0
  98. package/src/operator/node/liveStream.ts +270 -0
  99. package/src/operator/node/modals.tsx +720 -0
  100. package/src/operator/node/navHistory.ts +70 -0
  101. package/src/operator/node/persist.ts +26 -0
  102. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  103. package/src/operator/node/protocol/agents/types.ts +394 -0
  104. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  105. package/src/operator/node/protocol/authFailure.ts +66 -0
  106. package/src/operator/node/protocol/customModels.ts +23 -0
  107. package/src/operator/node/protocol/events.ts +13 -0
  108. package/src/operator/node/protocol/features.ts +70 -0
  109. package/src/operator/node/protocol/internalUsage.ts +1 -0
  110. package/src/operator/node/protocol/promptLimits.ts +52 -0
  111. package/src/operator/node/protocol/storageRetention.ts +13 -0
  112. package/src/operator/node/protocol/types.ts +332 -0
  113. package/src/operator/node/protocol/usageLimit.ts +72 -0
  114. package/src/operator/node/shared.tsx +89 -0
  115. package/src/operator/node/transcriptModel.ts +129 -0
  116. package/src/operator/node/types.ts +296 -0
  117. package/src/operator/node/useGlobalEvents.ts +97 -0
  118. package/src/operator/node/useOrchestrator.ts +696 -0
  119. package/src/operator/node/usePrefs.ts +114 -0
  120. package/src/operator/node/useRecaps.ts +91 -0
  121. package/src/operator/node/useTaskStream.ts +280 -0
  122. package/src/operator.base.css +1806 -0
  123. package/src/operator.css +194 -16
  124. package/src/styles.css +49 -1
  125. package/src/ui/Markdown.tsx +7 -2
  126. package/src/ui/Popover.tsx +82 -0
  127. package/src/ui/TextBlock.tsx +3 -3
  128. package/src/ui/Transcript.tsx +17 -10
  129. package/src/ui/fileLinks.ts +6 -0
  130. package/src/ui/format.ts +1 -1
  131. package/src/ui/shared.tsx +1 -1
  132. package/src/ui/transcriptModel.ts +2 -2
  133. package/dist/cli-ui-embed-6IJ_jRVh.js +0 -41223
@@ -0,0 +1,517 @@
1
+ "use client";
2
+ import { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3
+ import type { AskQuestion, AskAnswers } from "./protocol/types";
4
+ import { Icon } from "./icons";
5
+ import { agentLabel } from "./agents";
6
+ import { Avatar, Skel } from "./shared";
7
+ import { splitAttachments } from "./format";
8
+ import type { ProjectRow, TaskRow, Msg, AgentsBundle } from "./types";
9
+ import { MessageView, SessionBreak } from "./Transcript";
10
+ import { RENDER_MAX, RENDER_STEP, RENDER_WINDOW, buildTranscriptItems, hasOpenAsk } from "./transcriptModel";
11
+ import { OperatorBoardContext } from "./boardInteraction";
12
+ import { toolDataOf } from "./transcriptModel";
13
+ import { FileLinkContext } from "./fileLinks";
14
+ import { Composer } from "./Composer";
15
+ import { BranchHistory, useBranchHistory } from "./BranchHistory";
16
+ import { BranchNavigator } from "./BranchNavigator";
17
+ import type { Quote } from "./composerDraft";
18
+ import type { RunControlProps } from "./RunControls";
19
+ const QUIET_AFTER_MS = 180_000;
20
+ function RunningIndicator({ agent, startedAt, lastActivityRef }: { agent: string | null; startedAt: number | null; lastActivityRef: React.MutableRefObject<number> }) {
21
+ const [now, setNow] = useState(() => Date.now());
22
+ useEffect(() => {
23
+ const t = setInterval(() => setNow(Date.now()), 1000);
24
+ return () => clearInterval(t);
25
+ }, []);
26
+ const elapsed = startedAt ? Math.max(0, now - startedAt) : null;
27
+ const quiet = now - lastActivityRef.current;
28
+ const clock = (ms: number) => {
29
+ const s = Math.floor(ms / 1000);
30
+ const h = Math.floor(s / 3600);
31
+ const m = Math.floor((s % 3600) / 60);
32
+ const ss = String(s % 60).padStart(2, "0");
33
+ return h ? `${h}:${String(m).padStart(2, "0")}:${ss}` : `${m}:${ss}`;
34
+ };
35
+ return (
36
+ <div className="msg assistant">
37
+ <div className="who"><Avatar who="cc" agent={agent} /> 智能体</div>
38
+ <div className="msg-body run-ind">
39
+ <span className="typing"><i /><i /><i /></span>
40
+ {elapsed !== null && <span className="run-ind-t">运行中 · {clock(elapsed)}</span>}
41
+ {quiet >= QUIET_AFTER_MS && <span className="run-ind-quiet">已 {Math.floor(quiet / 60_000)} 分钟没有新输出(可能在执行耗时操作);确认卡住时可点输入框右侧的停止按钮</span>}
42
+ </div>
43
+ </div>
44
+ );
45
+ }
46
+
47
+ // Ref-backed identity-stable wrapper: Orchestrator passes fresh inline handlers
48
+ // on every render, which would defeat MessageView's memo — the wrapper keeps one
49
+ // function identity for the component's lifetime while always invoking the
50
+ // latest handler.
51
+ function useStableHandler<A extends unknown[]>(fn?: (...args: A) => void): (...args: A) => void {
52
+ const ref = useRef(fn);
53
+ ref.current = fn;
54
+ return useCallback((...args: A) => { ref.current?.(...args); }, []);
55
+ }
56
+
57
+ // Same, for handlers whose result the caller awaits (the input card's submit).
58
+ function useStableAsync<A extends unknown[], R>(fn: (...args: A) => Promise<R>): (...args: A) => Promise<R> {
59
+ const ref = useRef(fn);
60
+ ref.current = fn;
61
+ return useCallback((...args: A) => ref.current(...args), []);
62
+ }
63
+
64
+ export type ChatProps = {
65
+ project: ProjectRow; task: TaskRow; tasks: TaskRow[]; agents: AgentsBundle; messages: Msg[];
66
+ running: boolean; blockedBy?: string[]; transcriptLoading?: boolean;
67
+ older?: number; onLoadOlder?: (beforeApply?: () => void) => Promise<boolean>;
68
+ controls: RunControlProps; configBusy: boolean; active?: boolean;
69
+ onSelectTask: (id: string) => void; onFork: (text: string, sourceIds: string[], requestId: string) => Promise<boolean>;
70
+ onSend: (text: string) => Promise<boolean>; onStop: () => Promise<boolean>;
71
+ onStart: () => void; onEdit: () => void; onClear: () => void; onSetSendContext: (v: boolean) => void;
72
+ onReconnect?: () => void; onAnswer: (askId: string, questions: AskQuestion[], answers: AskAnswers) => void;
73
+ onCancelQueued: (pendingId: string) => void;
74
+ };
75
+ export function Chat(props: ChatProps) { return <ChatSession key={props.task.id} {...props} />; }
76
+ function ChatSession({ project, task, tasks, agents, messages, running, transcriptLoading, older=0, onLoadOlder,
77
+ controls, configBusy, active=true, onSelectTask, onFork, onSend, onStop, onClear, onReconnect, onAnswer, onCancelQueued }: ChatProps) {
78
+ const scrollRef = useRef<HTMLDivElement>(null);
79
+ const [quoteRequest, setQuoteRequest] = useState<Quote|null>(null);
80
+ const flow=useBranchHistory(task,tasks);
81
+ const fileScope=useMemo(()=>({taskId:task.id}),[task.id]);
82
+ const hasSession=true;
83
+ const view=active?'chat':'changes';
84
+ const quote=(m: Msg, source: TaskRow=task, selected?:string)=>{
85
+ const text=selected||splitAttachments(m.content).text;
86
+ if(text.trim()) setQuoteRequest({id:m.id, author:`${m.role==='user'?'你':agentLabel(agents,source.agent)}${source.id===task.id?'':` · ${source.title}`}`,text});
87
+ };
88
+ const openBoard=useContext(OperatorBoardContext);
89
+ const handoffs=useRef<Set<string>|null>(null);
90
+ useEffect(()=>{
91
+ if(transcriptLoading)return;
92
+ const current=messages.filter(m=>m.role==='system'&&m.content.startsWith('🖐')||m.role==='tool'&&toolDataOf(m).input?.status==='pending').map(m=>m.id);
93
+ if(handoffs.current===null){handoffs.current=new Set(current);return;}
94
+ const fresh=current.some(id=>!handoffs.current!.has(id));
95
+ current.forEach(id=>handoffs.current!.add(id));
96
+ if(fresh)openBoard?.({kind:'browser',taskId:task.id});
97
+ },[messages,transcriptLoading,openBoard,task.id]);
98
+ const stableAnswer = useStableHandler(onAnswer);
99
+ const stableCancelQueued = useStableHandler(onCancelQueued);
100
+ const stableClear = useStableHandler(onClear);
101
+ const stableReconnect = useStableHandler(onReconnect);
102
+ // Retry for an approval-blocked failure (Transcript's APPROVAL_BLOCKED_NOTICE
103
+ // branch): resend the user message that preceded the failure line — the Codex
104
+ // driver has since negotiated a working approval policy, so the same message
105
+ // goes through on the second attempt. Resolved at click time so the memoized
106
+ // MessageView never needs the surrounding messages.
107
+ const stableRetry = useStableHandler((msgId: string) => {
108
+ const at = messages.findIndex((m) => m.id === msgId);
109
+ for (let j = (at === -1 ? messages.length : at) - 1; j >= 0; j--) {
110
+ if (messages[j].role === "user") { onSend(messages[j].content); return; }
111
+ }
112
+ });
113
+ // browser_ask_user card → POST the values (lib/browserInput.ts); returns an
114
+ // error line for the card, or null once the agent has it.
115
+ const stableInput = useStableAsync(async (inputId: string, values: string[]): Promise<string | null> => {
116
+ try {
117
+ const r = await fetch(`/api/tasks/${task.id}/input`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ inputId, values }) });
118
+ if (r.ok) return null;
119
+ const j = await r.json().catch(() => null);
120
+ return j?.error || `提交失败(${r.status})`;
121
+ } catch (e) {
122
+ return e instanceof Error ? e.message : String(e);
123
+ }
124
+ });
125
+ // The 🖐 handoff line's "hand back" button — same action as the browser panel's.
126
+ const stableBrowser = useStableAsync(async (action: "handback"): Promise<string | null> => {
127
+ try {
128
+ const r = await fetch(`/api/tasks/${task.id}/browser`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action }) });
129
+ if (r.ok) return null;
130
+ const j = await r.json().catch(() => null);
131
+ return j?.error || `操作失败(${r.status})`;
132
+ } catch (e) {
133
+ return e instanceof Error ? e.message : String(e);
134
+ }
135
+ });
136
+ // True while a question card is still unanswered — hides the "thinking" dots,
137
+ // since Claude is parked on the user, not working. Scans the newest rows with
138
+ // cached parses (transcriptModel), not every tool row's JSON on each event.
139
+ const awaitingAnswer = useMemo(() => hasOpenAsk(messages), [messages]);
140
+ // When the current turn started (its user message) and when anything last
141
+ // arrived — the running indicator shows both, so a stuck turn is told apart
142
+ // from a busy one.
143
+ //
144
+ // A turn this view watched begin is timed on the browser's own clock from
145
+ // that moment (the new user message may not have echoed back yet, and the
146
+ // last user message would then be the PREVIOUS turn's). A turn already
147
+ // running when the view opened is timed from its user message.
148
+ const lastUserTs = useMemo(() => {
149
+ for (let i = messages.length - 1; i >= 0; i--) if (messages[i].role === "user") return messages[i].ts ?? null;
150
+ return null;
151
+ }, [messages]);
152
+ const [runSeenAt, setRunSeenAt] = useState<number | null>(null);
153
+ const lastActivityRef = useRef(Date.now());
154
+ const wasRunningRef = useRef(running);
155
+ useEffect(() => {
156
+ if (running && !wasRunningRef.current) {
157
+ setRunSeenAt(Date.now());
158
+ lastActivityRef.current = Date.now();
159
+ } else if (!running) {
160
+ setRunSeenAt(null);
161
+ }
162
+ wasRunningRef.current = running;
163
+ }, [running]);
164
+ useEffect(() => { lastActivityRef.current = Date.now(); }, [messages]);
165
+ const turnStartedAt = runSeenAt ?? lastUserTs;
166
+
167
+ // Auto-scroll only while the user is parked at the bottom. If they scroll up to
168
+ // read earlier output, we leave their position alone even as new messages stream
169
+ // in, and surface a "jump to bottom" button instead.
170
+ const pinned = useRef(true);
171
+ const [atBottom, setAtBottom] = useState(true);
172
+
173
+ // ---------- windowed transcript ----------
174
+ // Only a window of rows is in the DOM — never more than RENDER_MAX — so a task
175
+ // with thousands of messages renders, scrolls and streams like a short one.
176
+ // While following the output it's the newest RENDER_WINDOW rows. Scrolling back
177
+ // reveals RENDER_STEP more above at a time (paging older messages in from the
178
+ // server once the loaded ones run out) and drops rows far below; scrolling down
179
+ // brings those back. "Jump to latest" returns to following.
180
+ const items = useMemo(() => buildTranscriptItems(messages, older > 0), [messages, older]);
181
+ // First / last rendered row by key; null = derived (newest rows).
182
+ const [win, setWin] = useState<{ start: string | null; end: string | null }>({ start: null, end: null });
183
+ const { startIdx, endIdx } = useMemo(() => {
184
+ const find = (key: string | null) => (key ? items.findIndex((it) => it.key === key) : -1);
185
+ const e = find(win.end);
186
+ const last = e >= 0 ? e : items.length - 1;
187
+ let s = find(win.start);
188
+ // The window is pinned on message rows (labels move when older pages load);
189
+ // show the session label / break right above its first message.
190
+ if (s >= 0) while (s > 0 && items[s - 1].kind !== "msg") s--;
191
+ return { startIdx: s >= 0 && s <= last ? s : Math.max(0, last + 1 - RENDER_WINDOW), endIdx: last };
192
+ }, [items, win]);
193
+ const shown = useMemo(() => items.slice(startIdx, endIdx + 1), [items, startIdx, endIdx]);
194
+ const hiddenAbove = useMemo(() => {
195
+ let n = older;
196
+ for (let i = 0; i < startIdx; i++) if (items[i].kind === "msg") n++;
197
+ return n;
198
+ }, [items, startIdx, older]);
199
+ const hiddenBelow = useMemo(() => {
200
+ let n = 0;
201
+ for (let i = endIdx + 1; i < items.length; i++) if (items[i].kind === "msg") n++;
202
+ return n;
203
+ }, [items, endIdx]);
204
+ const [loadingOlder, setLoadingOlder] = useState(false);
205
+ const [expandReq, setExpandReq] = useState(0);
206
+ const itemsRef = useRef(items);
207
+ itemsRef.current = items;
208
+ const rangeRef = useRef({ startIdx, endIdx });
209
+ rangeRef.current = { startIdx, endIdx };
210
+ const winRef = useRef(win);
211
+ winRef.current = win;
212
+ const olderRef = useRef(older);
213
+ olderRef.current = older;
214
+ const hiddenRef = useRef({ above: hiddenAbove, below: hiddenBelow });
215
+ hiddenRef.current = { above: hiddenAbove, below: hiddenBelow };
216
+ const loadingOlderRef = useRef(false);
217
+ // A rendered row and where it sat on screen, measured right before rows are
218
+ // added or dropped above it and restored once THAT window change commits, so
219
+ // the reader's view stays put.
220
+ type Anchor = { key: string; top: number };
221
+ const anchorRef = useRef<(Anchor & { win: typeof win }) | null>(null);
222
+ // Measured just before an older page is applied (see revealEarlier).
223
+ const pageAnchorRef = useRef<Anchor | null>(null);
224
+ // A message-nav click that had to reveal rows first: finish it afterwards.
225
+ const navAfterRevealRef = useRef<0 | -1 | 1>(0);
226
+
227
+ const measure = useCallback((key?: string): Anchor | null => {
228
+ const el = scrollRef.current;
229
+ const node = key ? el?.querySelector<HTMLElement>(`[data-tkey="${CSS.escape(key)}"]`) : el?.querySelector<HTMLElement>("[data-tkey][data-msg]");
230
+ return node?.dataset.tkey ? { key: node.dataset.tkey, top: node.getBoundingClientRect().top } : null;
231
+ }, []);
232
+ // Window edges are stored as MESSAGE keys: a session label's position shifts
233
+ // when older messages are paged in above it, a message's never does.
234
+ const setRange = useCallback((s: number, e: number, anchor?: Anchor | null) => {
235
+ const its = itemsRef.current;
236
+ let a = s;
237
+ while (a < its.length && its[a].kind !== "msg") a++;
238
+ let b = e;
239
+ while (b > 0 && its[b].kind !== "msg") b--;
240
+ const next = { start: its[a]?.key ?? null, end: e >= its.length - 1 ? null : its[b]?.key ?? null };
241
+ // Same window: no state change (and no anchor left waiting for a commit that won't come).
242
+ if (winRef.current.start === next.start && winRef.current.end === next.end) return;
243
+ if (anchor) anchorRef.current = { ...anchor, win: next };
244
+ setWin(next);
245
+ }, []);
246
+
247
+ // Grow the window upward over rows already loaded (dropping rows far below).
248
+ // The current first row stays rendered (RENDER_MAX > RENDER_STEP) and anchors.
249
+ const growUp = useCallback((anchor?: Anchor | null): boolean => {
250
+ const { startIdx: s, endIdx: e } = rangeRef.current;
251
+ if (s <= 0) return false;
252
+ const ns = Math.max(0, s - RENDER_STEP);
253
+ setRange(ns, Math.min(e, ns + RENDER_MAX - 1), anchor ?? measure());
254
+ return true;
255
+ }, [measure, setRange]);
256
+
257
+ // Show earlier rows: loaded ones first, then a page from the server.
258
+ const revealEarlier = useCallback(async () => {
259
+ if (loadingOlderRef.current || growUp()) return;
260
+ if (olderRef.current > 0 && onLoadOlder) {
261
+ // Hold the window on its current rows so the prepended page lands above
262
+ // it; the expansion effect below then reveals the bottom of that page,
263
+ // anchored on where the first row sat right before the page went in.
264
+ const { startIdx: s, endIdx: e } = rangeRef.current;
265
+ setRange(s, e);
266
+ loadingOlderRef.current = true;
267
+ setLoadingOlder(true);
268
+ try {
269
+ if (await onLoadOlder(() => { pageAnchorRef.current = measure(); })) setExpandReq((n) => n + 1);
270
+ } finally {
271
+ loadingOlderRef.current = false;
272
+ setLoadingOlder(false);
273
+ }
274
+ }
275
+ }, [growUp, setRange, measure, onLoadOlder]);
276
+ // Show later rows again (after scrolling back), dropping rows far above —
277
+ // anchored on the first row that stays.
278
+ const revealLater = useCallback(() => {
279
+ const its = itemsRef.current;
280
+ const { startIdx: s, endIdx: e } = rangeRef.current;
281
+ if (e >= its.length - 1) return;
282
+ const ne = Math.min(its.length - 1, e + RENDER_STEP);
283
+ const ns = Math.max(s, ne - RENDER_MAX + 1);
284
+ setRange(ns, ne, ns > s ? measure(its[ns].key) : null);
285
+ }, [measure, setRange]);
286
+ const revealRef = useRef({ earlier: revealEarlier, later: revealLater });
287
+ revealRef.current = { earlier: revealEarlier, later: revealLater };
288
+
289
+ // A page of older messages arrived above the window: reveal the bottom of it.
290
+ useLayoutEffect(() => {
291
+ if (!expandReq) return;
292
+ const anchor = pageAnchorRef.current;
293
+ pageAnchorRef.current = null;
294
+ growUp(anchor);
295
+ }, [expandReq, growUp]);
296
+
297
+ const onScroll = useCallback(() => {
298
+ const el = scrollRef.current;
299
+ if (!el) return;
300
+ // "At the bottom" means at the newest output — not merely at the end of a
301
+ // window that stops short of it.
302
+ const bottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60 && winRef.current.end === null;
303
+ pinned.current = bottom;
304
+ setAtBottom(bottom);
305
+ if (!bottom && winRef.current.start === null) {
306
+ // Reading back: freeze the window, so new output arriving below doesn't
307
+ // drop the rows above the reader.
308
+ const first = itemsRef.current.slice(rangeRef.current.startIdx).find((it) => it.kind === "msg");
309
+ if (first) setWin((w) => ({ ...w, start: first.key }));
310
+ } else if (bottom && winRef.current.start !== null) {
311
+ // Back at the newest output: follow it again (sheds the rows revealed above).
312
+ setWin({ start: null, end: null });
313
+ }
314
+ }, []);
315
+
316
+ // Reveal more as the reader nears either end of the window (never while
317
+ // following the output).
318
+ const topSentinelRef = useRef<HTMLDivElement>(null);
319
+ const bottomSentinelRef = useRef<HTMLDivElement>(null);
320
+ const hasAbove = hiddenAbove > 0;
321
+ const hasBelow = hiddenBelow > 0;
322
+ useEffect(() => {
323
+ const root = scrollRef.current;
324
+ if (!root || typeof IntersectionObserver === "undefined") return;
325
+ const watch = (target: HTMLElement | null, margin: string, go: () => void) => {
326
+ if (!target) return null;
327
+ const io = new IntersectionObserver((entries) => {
328
+ if (!pinned.current && entries.some((e) => e.isIntersecting)) go();
329
+ }, { root, rootMargin: margin });
330
+ io.observe(target);
331
+ return io;
332
+ };
333
+ const up = hasAbove ? watch(topSentinelRef.current, "1800px 0px 0px 0px", () => void revealRef.current.earlier()) : null;
334
+ const down = hasBelow ? watch(bottomSentinelRef.current, "0px 0px 1800px 0px", () => revealRef.current.later()) : null;
335
+ return () => { up?.disconnect(); down?.disconnect(); };
336
+ // `view`: on mobile the chat pane remounts when toggling chat/changes.
337
+ }, [hasAbove, hasBelow, view]);
338
+
339
+ // Reading back while a long turn keeps streaming: the window's bottom edge
340
+ // follows the output, so cap it at RENDER_MAX. Reader in the upper half →
341
+ // stop extending (new output waits below the "show later" control); in the
342
+ // lower half → drop the oldest rows above instead, holding the view in place.
343
+ useLayoutEffect(() => {
344
+ // +2: a session break and label may sit above the first message row.
345
+ if (win.start === null || win.end !== null || endIdx - startIdx + 1 <= RENDER_MAX + 2) return;
346
+ const el = scrollRef.current;
347
+ if (!el) return;
348
+ if (el.scrollTop < (el.scrollHeight - el.clientHeight) / 2) {
349
+ setRange(startIdx, startIdx + RENDER_MAX - 1);
350
+ } else {
351
+ const ns = endIdx - RENDER_MAX + 1;
352
+ setRange(ns, endIdx, measure(itemsRef.current[ns].key));
353
+ }
354
+ }, [win, startIdx, endIdx, setRange, measure]);
355
+
356
+ const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
357
+ const el = scrollRef.current;
358
+ if (!el) return;
359
+ pinned.current = true;
360
+ setAtBottom(true);
361
+ // Not showing the newest rows: switch back to following; the pinned layout
362
+ // effect below lands on the bottom once they render.
363
+ if (winRef.current.start !== null || winRef.current.end !== null) { setWin({ start: null, end: null }); return; }
364
+ el.scrollTo({ top: el.scrollHeight, behavior });
365
+ }, []);
366
+
367
+ // Jump between the user's own messages in the transcript. dir < 0 goes to the
368
+ // previous one above the current scroll position, dir > 0 to the next below it;
369
+ // queued (not-yet-sent) bubbles are excluded so nav only lands on real turns.
370
+ // Past the edge of the rendered window it reveals more rows first.
371
+ const scrollToUserMsg = useCallback((dir: -1 | 1) => {
372
+ const el = scrollRef.current;
373
+ if (!el) return;
374
+ const base = el.getBoundingClientRect().top;
375
+ const tops = Array.from(el.querySelectorAll<HTMLElement>(".msg.user:not(.queued)"))
376
+ .map((n) => n.getBoundingClientRect().top - base + el.scrollTop);
377
+ const cur = el.scrollTop;
378
+ const eps = 8;
379
+ const candidates = dir < 0 ? tops.filter((t) => t < cur - eps) : tops.filter((t) => t > cur + eps);
380
+ if (!candidates.length && (dir < 0 ? hiddenRef.current.above : hiddenRef.current.below) > 0) {
381
+ navAfterRevealRef.current = dir;
382
+ if (dir < 0) void revealRef.current.earlier();
383
+ else revealRef.current.later();
384
+ return;
385
+ }
386
+ if (!tops.length) return;
387
+ const target = candidates.length ? (dir < 0 ? candidates[candidates.length - 1] : candidates[0]) : dir < 0 ? tops[0] : tops[tops.length - 1];
388
+ el.scrollTo({ top: Math.max(0, target - 12), behavior: "smooth" });
389
+ }, []);
390
+
391
+ // The window moved: put the anchored row back where it was (a no-op where the
392
+ // browser's own scroll anchoring already did it), then finish a pending nav.
393
+ // Only the commit of the window change the anchor was measured for restores
394
+ // it — an intermediate commit (an older page landing) must not consume it.
395
+ useLayoutEffect(() => {
396
+ const a = anchorRef.current;
397
+ const el = scrollRef.current;
398
+ if (a && a.win === win) {
399
+ anchorRef.current = null;
400
+ const node = el?.querySelector<HTMLElement>(`[data-tkey="${CSS.escape(a.key)}"]`);
401
+ if (el && node) {
402
+ const delta = node.getBoundingClientRect().top - a.top;
403
+ if (Math.abs(delta) > 1) el.scrollTop += delta;
404
+ }
405
+ }
406
+ const nav = navAfterRevealRef.current;
407
+ if (nav && !anchorRef.current) {
408
+ navAfterRevealRef.current = 0;
409
+ scrollToUserMsg(nav);
410
+ }
411
+ }, [win, scrollToUserMsg]);
412
+
413
+ // Following the output: stay on the newest row as content arrives or the
414
+ // window sheds rows above. Layout effect, so it lands before paint (no jump).
415
+ useLayoutEffect(() => {
416
+ if (pinned.current && scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
417
+ }, [messages, running, startIdx, endIdx]);
418
+ // …and when the pane itself resizes (the composer growing, a banner
419
+ // appearing). Content resizes are left alone, so expanding a tool card at
420
+ // the bottom doesn't yank its top out of view.
421
+ useEffect(() => {
422
+ const el = scrollRef.current;
423
+ if (!el || typeof ResizeObserver === "undefined") return;
424
+ const ro = new ResizeObserver(() => { if (pinned.current) el.scrollTop = el.scrollHeight; });
425
+ ro.observe(el);
426
+ return () => ro.disconnect();
427
+ }, [hasSession, view]);
428
+
429
+ const flowPositioned=useRef(!task.parent_task_id);
430
+ useLayoutEffect(()=>{
431
+ if(!active || !flow.ready || flowPositioned.current) return;
432
+ const el=scrollRef.current;
433
+ const boundary=Array.from(el?.querySelectorAll<HTMLElement>(".branch-nav")??[]).find(e=>e.dataset.parentId===task.parent_task_id);
434
+ if(!el || !boundary) return;
435
+ el.scrollTop+=boundary.getBoundingClientRect().top-el.getBoundingClientRect().top-60;
436
+ pinned.current=false; setAtBottom(false); flowPositioned.current=true;
437
+ },[active,flow.ready,task.parent_task_id]);
438
+ useEffect(()=>{if(!task.parent_task_id && active) scrollToBottom("auto");},[active,task.id]);
439
+ return (<FileLinkContext.Provider value={fileScope}>
440
+ <>
441
+ <div className="transcript-wrap">
442
+ <div className="transcript" ref={scrollRef} onScroll={onScroll}>
443
+ <div className="tw">
444
+ <BranchHistory entries={flow.entries} tasks={tasks} agents={agents} onSelect={onSelectTask} onRetry={flow.retry} onQuote={(m,source,selected)=>quote(m,source,selected)} />
445
+ {task.parent_task_id && <div className="flow-task-heading current"><span>沿此分支继续</span><strong>{task.title}</strong></div>}
446
+ {transcriptLoading && messages.length === 0 && (
447
+ // Snapshot hasn't streamed in yet — sketch a user turn and an agent
448
+ // reply so opening a started task never flashes an empty chat.
449
+ <div aria-hidden>
450
+ <div className="session-label"><span className="ln" />正在加载会话<span className="ln" /></div>
451
+ <div className="msg user" style={{ opacity: .7 }}>
452
+ <div className="who"><Skel w={18} h={18} r={5} /><Skel w={34} h={9} /></div>
453
+ <div className="msg-body"><Skel w="72%" h={12} /><Skel w="46%" h={12} style={{ marginTop: 9 }} /></div>
454
+ </div>
455
+ <div className="msg" style={{ opacity: .7 }}>
456
+ <div className="who"><Skel w={18} h={18} r={5} /><Skel w={70} h={9} /></div>
457
+ <Skel w="90%" h={11} />
458
+ <Skel w="83%" h={11} style={{ marginTop: 8 }} />
459
+ <Skel w="58%" h={11} style={{ marginTop: 8 }} />
460
+ <Skel w="100%" h={34} r="var(--r)" style={{ marginTop: 12 }} />
461
+ </div>
462
+ </div>
463
+ )}
464
+ {hasAbove && (
465
+ <div ref={topSentinelRef} className="load-older">
466
+ <button className="btn btn-ghost btn-sm" onClick={() => void revealEarlier()} disabled={loadingOlder}>
467
+ {loadingOlder ? "正在加载更早的消息…" : `加载更早的消息(还有 ${hiddenAbove} 条)`}
468
+ </button>
469
+ </div>
470
+ )}
471
+ {shown.map((it) =>
472
+ it.kind === "label" ? (
473
+ <div key={it.key} data-tkey={it.key} className="session-label"><span className="ln" />第 {it.n} 次会话{it.current ? " · 当前" : ""}<span className="ln" /></div>
474
+ ) : it.kind === "break" ? (
475
+ <div key={it.key} data-tkey={it.key}><SessionBreak summary={it.summary} /></div>
476
+ ) : (
477
+ <div key={it.key} data-tkey={it.key} data-msg="">
478
+ <MessageView m={it.m} initial={it.initial} hideWho={it.hideWho} running={running} agent={task.agent} agentLabel={agentLabel(agents, task.agent)} onAnswer={stableAnswer} onCancelQueued={stableCancelQueued} onClear={stableClear} onReconnect={stableReconnect} onRetry={stableRetry} onInput={stableInput} onBrowser={stableBrowser} onQuote={(m,selected)=>quote(m,task,selected)} />
479
+ </div>
480
+ )
481
+ )}
482
+ {hasBelow && (
483
+ <div ref={bottomSentinelRef} className="load-older">
484
+ <button className="btn btn-ghost btn-sm" onClick={revealLater}>显示后面的消息(还有 {hiddenBelow} 条)</button>
485
+ <button className="btn btn-ghost btn-sm" onClick={() => scrollToBottom("auto")}>跳到最新</button>
486
+ </div>
487
+ )}
488
+ {running && !awaitingAnswer && !hasBelow && (
489
+ <RunningIndicator agent={task.agent} startedAt={turnStartedAt} lastActivityRef={lastActivityRef} />
490
+ )}
491
+ {/* Follow-ups queued mid-turn, pinned below the live turn — they
492
+ send in order once it ends. */}
493
+ {!hasBelow && messages.filter((m) => m.role === "queued").map((m) => (
494
+ <MessageView key={m.id} m={m} initial={false} hideWho={false} onAnswer={stableAnswer} onCancelQueued={stableCancelQueued} />
495
+ ))}
496
+ {!hasBelow && <BranchNavigator sourceId={task.id} projectId={project.id} tasks={tasks} completed={task.status==='done'} onSelect={onSelectTask} />}
497
+ </div>
498
+ </div>
499
+ <div className="msg-nav">
500
+ <button className="msg-nav-btn" onClick={() => scrollToUserMsg(-1)} title="上一条消息" aria-label="滚动到上一条消息">
501
+ {Icon.chevUp()}
502
+ </button>
503
+ <button className="msg-nav-btn" onClick={() => scrollToUserMsg(1)} title="下一条消息" aria-label="滚动到下一条消息">
504
+ {Icon.chevDown()}
505
+ </button>
506
+ {!atBottom && (
507
+ <button className="msg-nav-btn" onClick={() => scrollToBottom()} title="跳到最新" aria-label="跳到最新">
508
+ {Icon.toBottom()}
509
+ </button>
510
+ )}
511
+ </div>
512
+ </div>
513
+ <Composer tasks={tasks} onFork={onFork} task={task} agentLabel={agentLabel(agents, task.agent)} disabled={task.started!==1} configBusy={configBusy} running={running} onSend={onSend} onStop={onStop} onClear={onClear} quoteRequest={quoteRequest} onQuoteHandled={()=>setQuoteRequest(null)} controls={controls} />
514
+ </></FileLinkContext.Provider>
515
+ );
516
+
517
+ }