@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.
- package/CHANGELOG.md +24 -0
- package/README.md +21 -1
- package/dist/{cli-ui-embed-vIXVNs6k.js → cli-ui-embed-D9xgur2y.js} +18299 -18587
- package/dist/{cli-ui-index-R24x28OX.js → cli-ui-index-WBtxmw23.js} +2 -2
- package/dist/{cli-ui-jszip.min-DUghzByz.js → cli-ui-jszip.min-CU3ZzoXb.js} +1 -1
- package/dist/{cli-ui-jszip.min-DfH6rdQD.js → cli-ui-jszip.min-DdU0X3VJ.js} +2 -2
- package/dist/{cli-ui-xlsx-CP0lJhff.js → cli-ui-xlsx-Dx6T7qhC.js} +1 -1
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +1 -1
- package/src/board/ChangesPane.tsx +10 -4
- package/src/board/CliBoard.tsx +48 -16
- package/src/board/FileViewer.tsx +34 -1
- package/src/board/diffRefresh.ts +63 -0
- package/src/board/linkage.ts +68 -0
- package/src/board/pathResolve.ts +108 -0
- package/src/chat/CliChat.tsx +76 -181
- package/src/chat/Composer.tsx +4 -3
- package/src/core/conversation.ts +9 -1
- package/src/core/store.ts +112 -38
- package/src/msglist/CliMsgList.tsx +3 -2
- package/src/operator/node/AgentConnect.tsx +19 -15
- package/src/operator/node/AppearancePanel.tsx +1 -1
- package/src/operator/node/BranchNavigator.tsx +1 -1
- package/src/operator/node/BrowserPane.tsx +1 -1
- package/src/operator/node/Chat.tsx +16 -261
- package/src/operator/node/CommandPalette.tsx +1 -1
- package/src/operator/node/Composer.tsx +1 -1
- package/src/operator/node/Layout.tsx +1 -1
- package/src/operator/node/Markdown.tsx +6 -36
- package/src/operator/node/Modal.tsx +1 -1
- package/src/operator/node/OnboardingWizard.tsx +9 -5
- package/src/operator/node/Orchestrator.tsx +1 -1
- package/src/operator/node/ProjectLanding.tsx +1 -1
- package/src/operator/node/ProjectsColumn.tsx +1 -1
- package/src/operator/node/ProviderAuth.tsx +12 -10
- package/src/operator/node/RunSwitch.tsx +1 -1
- package/src/operator/node/Services.tsx +1 -1
- package/src/operator/node/SessionRail.tsx +27 -8
- package/src/operator/node/SessionView.tsx +1 -1
- package/src/operator/node/SettingsView.tsx +1 -1
- package/src/operator/node/TaskBoard.tsx +1 -1
- package/src/operator/node/TaskChanges.tsx +34 -5
- package/src/operator/node/TaskHero.tsx +1 -1
- package/src/operator/node/TasksColumn.tsx +1 -1
- package/src/operator/node/Transcript.tsx +10 -237
- package/src/operator/node/Welcome.tsx +1 -1
- package/src/operator/node/WorktreePrune.tsx +1 -1
- package/src/operator/node/boardInteraction.ts +6 -3
- package/src/operator/node/boardState.ts +12 -13
- package/src/operator/node/fileLinks.ts +4 -38
- package/src/operator/node/format.ts +12 -104
- package/src/operator/node/github.tsx +10 -8
- package/src/operator/node/modals.tsx +15 -13
- package/src/operator/node/recheckOnReturn.ts +28 -0
- package/src/operator/node/shared.tsx +10 -50
- package/src/operator/node/useTaskStream.ts +2 -2
- package/src/styles.css +5 -0
- package/src/ui/Markdown.tsx +36 -21
- package/src/ui/Transcript.tsx +8 -5
- package/src/ui/fileLinks.ts +6 -3
- package/src/ui/format.ts +11 -5
- package/src/ui/shared.tsx +11 -7
- package/src/ui/transcriptModel.ts +40 -9
- package/src/ui/useTranscriptWindow.ts +253 -0
- package/src/operator/node/boundedPoll.ts +0 -47
- package/src/operator/node/icons.tsx +0 -88
- package/src/operator/node/transcriptModel.ts +0 -129
|
@@ -4,15 +4,16 @@ import { parentMessages, type SubagentTranscript } from "../../core/subagents";
|
|
|
4
4
|
import { jget } from "./api";
|
|
5
5
|
import { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
6
6
|
import type { AskQuestion, AskAnswers } from "./protocol/types";
|
|
7
|
-
import { Icon } from "
|
|
7
|
+
import { Icon } from "../../ui/icons";
|
|
8
8
|
import { agentLabel } from "./agents";
|
|
9
9
|
import { Avatar, Skel } from "./shared";
|
|
10
10
|
import { splitAttachments } from "./format";
|
|
11
11
|
import type { ProjectRow, TaskRow, Msg, AgentsBundle } from "./types";
|
|
12
12
|
import { MessageView, SessionBreak } from "./Transcript";
|
|
13
|
-
import {
|
|
13
|
+
import { buildTranscriptItems, hasOpenAsk } from "../../ui/transcriptModel";
|
|
14
|
+
import { useTranscriptWindow } from "../../ui/useTranscriptWindow";
|
|
14
15
|
import { OperatorBoardContext } from "./boardInteraction";
|
|
15
|
-
import { toolDataOf } from "
|
|
16
|
+
import { toolDataOf } from "../../ui/transcriptModel";
|
|
16
17
|
import { FileLinkContext } from "./fileLinks";
|
|
17
18
|
import { Composer } from "./Composer";
|
|
18
19
|
import { QueuedMessages } from "../../ui/QueuedMessages";
|
|
@@ -187,267 +188,16 @@ function ChatSession({ project, task, tasks, agents, messages, running, transcri
|
|
|
187
188
|
useEffect(() => { lastActivityRef.current = Date.now(); }, [messages]);
|
|
188
189
|
const turnStartedAt = runSeenAt ?? lastUserTs;
|
|
189
190
|
|
|
190
|
-
//
|
|
191
|
-
// read earlier output, we leave their position alone even as new messages stream
|
|
192
|
-
// in, and surface a "jump to bottom" button instead.
|
|
193
|
-
const pinned = useRef(true);
|
|
194
|
-
const [atBottom, setAtBottom] = useState(true);
|
|
195
|
-
|
|
196
|
-
// ---------- windowed transcript ----------
|
|
191
|
+
// ---------- windowed transcript (shared with CliChat: ui/useTranscriptWindow) ----------
|
|
197
192
|
// Only a window of rows is in the DOM — never more than RENDER_MAX — so a task
|
|
198
193
|
// with thousands of messages renders, scrolls and streams like a short one.
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
// First / last rendered row by key; null = derived (newest rows).
|
|
205
|
-
const [win, setWin] = useState<{ start: string | null; end: string | null }>({ start: null, end: null });
|
|
206
|
-
const { startIdx, endIdx } = useMemo(() => {
|
|
207
|
-
const find = (key: string | null) => (key ? items.findIndex((it) => it.key === key) : -1);
|
|
208
|
-
const e = find(win.end);
|
|
209
|
-
const last = e >= 0 ? e : items.length - 1;
|
|
210
|
-
let s = find(win.start);
|
|
211
|
-
// The window is pinned on message rows (labels move when older pages load);
|
|
212
|
-
// show the session label / break right above its first message.
|
|
213
|
-
if (s >= 0) while (s > 0 && items[s - 1].kind !== "msg") s--;
|
|
214
|
-
return { startIdx: s >= 0 && s <= last ? s : Math.max(0, last + 1 - RENDER_WINDOW), endIdx: last };
|
|
215
|
-
}, [items, win]);
|
|
216
|
-
const shown = useMemo(() => items.slice(startIdx, endIdx + 1), [items, startIdx, endIdx]);
|
|
217
|
-
const hiddenAbove = useMemo(() => {
|
|
218
|
-
let n = older;
|
|
219
|
-
for (let i = 0; i < startIdx; i++) if (items[i].kind === "msg") n++;
|
|
220
|
-
return n;
|
|
221
|
-
}, [items, startIdx, older]);
|
|
222
|
-
const hiddenBelow = useMemo(() => {
|
|
223
|
-
let n = 0;
|
|
224
|
-
for (let i = endIdx + 1; i < items.length; i++) if (items[i].kind === "msg") n++;
|
|
225
|
-
return n;
|
|
226
|
-
}, [items, endIdx]);
|
|
227
|
-
const [loadingOlder, setLoadingOlder] = useState(false);
|
|
228
|
-
const [expandReq, setExpandReq] = useState(0);
|
|
229
|
-
const itemsRef = useRef(items);
|
|
230
|
-
itemsRef.current = items;
|
|
231
|
-
const rangeRef = useRef({ startIdx, endIdx });
|
|
232
|
-
rangeRef.current = { startIdx, endIdx };
|
|
233
|
-
const winRef = useRef(win);
|
|
234
|
-
winRef.current = win;
|
|
235
|
-
const olderRef = useRef(older);
|
|
236
|
-
olderRef.current = older;
|
|
237
|
-
const hiddenRef = useRef({ above: hiddenAbove, below: hiddenBelow });
|
|
238
|
-
hiddenRef.current = { above: hiddenAbove, below: hiddenBelow };
|
|
239
|
-
const loadingOlderRef = useRef(false);
|
|
240
|
-
// A rendered row and where it sat on screen, measured right before rows are
|
|
241
|
-
// added or dropped above it and restored once THAT window change commits, so
|
|
242
|
-
// the reader's view stays put.
|
|
243
|
-
type Anchor = { key: string; top: number };
|
|
244
|
-
const anchorRef = useRef<(Anchor & { win: typeof win }) | null>(null);
|
|
245
|
-
// Measured just before an older page is applied (see revealEarlier).
|
|
246
|
-
const pageAnchorRef = useRef<Anchor | null>(null);
|
|
247
|
-
// A message-nav click that had to reveal rows first: finish it afterwards.
|
|
248
|
-
const navAfterRevealRef = useRef<0 | -1 | 1>(0);
|
|
249
|
-
|
|
250
|
-
const measure = useCallback((key?: string): Anchor | null => {
|
|
251
|
-
const el = scrollRef.current;
|
|
252
|
-
const node = key ? el?.querySelector<HTMLElement>(`[data-tkey="${CSS.escape(key)}"]`) : el?.querySelector<HTMLElement>("[data-tkey][data-msg]");
|
|
253
|
-
return node?.dataset.tkey ? { key: node.dataset.tkey, top: node.getBoundingClientRect().top } : null;
|
|
254
|
-
}, []);
|
|
255
|
-
// Window edges are stored as MESSAGE keys: a session label's position shifts
|
|
256
|
-
// when older messages are paged in above it, a message's never does.
|
|
257
|
-
const setRange = useCallback((s: number, e: number, anchor?: Anchor | null) => {
|
|
258
|
-
const its = itemsRef.current;
|
|
259
|
-
let a = s;
|
|
260
|
-
while (a < its.length && its[a].kind !== "msg") a++;
|
|
261
|
-
let b = e;
|
|
262
|
-
while (b > 0 && its[b].kind !== "msg") b--;
|
|
263
|
-
const next = { start: its[a]?.key ?? null, end: e >= its.length - 1 ? null : its[b]?.key ?? null };
|
|
264
|
-
// Same window: no state change (and no anchor left waiting for a commit that won't come).
|
|
265
|
-
if (winRef.current.start === next.start && winRef.current.end === next.end) return;
|
|
266
|
-
if (anchor) anchorRef.current = { ...anchor, win: next };
|
|
267
|
-
setWin(next);
|
|
268
|
-
}, []);
|
|
269
|
-
|
|
270
|
-
// Grow the window upward over rows already loaded (dropping rows far below).
|
|
271
|
-
// The current first row stays rendered (RENDER_MAX > RENDER_STEP) and anchors.
|
|
272
|
-
const growUp = useCallback((anchor?: Anchor | null): boolean => {
|
|
273
|
-
const { startIdx: s, endIdx: e } = rangeRef.current;
|
|
274
|
-
if (s <= 0) return false;
|
|
275
|
-
const ns = Math.max(0, s - RENDER_STEP);
|
|
276
|
-
setRange(ns, Math.min(e, ns + RENDER_MAX - 1), anchor ?? measure());
|
|
277
|
-
return true;
|
|
278
|
-
}, [measure, setRange]);
|
|
279
|
-
|
|
280
|
-
// Show earlier rows: loaded ones first, then a page from the server.
|
|
281
|
-
const revealEarlier = useCallback(async () => {
|
|
282
|
-
if (loadingOlderRef.current || growUp()) return;
|
|
283
|
-
if (olderRef.current > 0 && onLoadOlder) {
|
|
284
|
-
// Hold the window on its current rows so the prepended page lands above
|
|
285
|
-
// it; the expansion effect below then reveals the bottom of that page,
|
|
286
|
-
// anchored on where the first row sat right before the page went in.
|
|
287
|
-
const { startIdx: s, endIdx: e } = rangeRef.current;
|
|
288
|
-
setRange(s, e);
|
|
289
|
-
loadingOlderRef.current = true;
|
|
290
|
-
setLoadingOlder(true);
|
|
291
|
-
try {
|
|
292
|
-
if (await onLoadOlder(() => { pageAnchorRef.current = measure(); })) setExpandReq((n) => n + 1);
|
|
293
|
-
} finally {
|
|
294
|
-
loadingOlderRef.current = false;
|
|
295
|
-
setLoadingOlder(false);
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
}, [growUp, setRange, measure, onLoadOlder]);
|
|
299
|
-
// Show later rows again (after scrolling back), dropping rows far above —
|
|
300
|
-
// anchored on the first row that stays.
|
|
301
|
-
const revealLater = useCallback(() => {
|
|
302
|
-
const its = itemsRef.current;
|
|
303
|
-
const { startIdx: s, endIdx: e } = rangeRef.current;
|
|
304
|
-
if (e >= its.length - 1) return;
|
|
305
|
-
const ne = Math.min(its.length - 1, e + RENDER_STEP);
|
|
306
|
-
const ns = Math.max(s, ne - RENDER_MAX + 1);
|
|
307
|
-
setRange(ns, ne, ns > s ? measure(its[ns].key) : null);
|
|
308
|
-
}, [measure, setRange]);
|
|
309
|
-
const revealRef = useRef({ earlier: revealEarlier, later: revealLater });
|
|
310
|
-
revealRef.current = { earlier: revealEarlier, later: revealLater };
|
|
311
|
-
|
|
312
|
-
// A page of older messages arrived above the window: reveal the bottom of it.
|
|
313
|
-
useLayoutEffect(() => {
|
|
314
|
-
if (!expandReq) return;
|
|
315
|
-
const anchor = pageAnchorRef.current;
|
|
316
|
-
pageAnchorRef.current = null;
|
|
317
|
-
growUp(anchor);
|
|
318
|
-
}, [expandReq, growUp]);
|
|
319
|
-
|
|
320
|
-
const onScroll = useCallback(() => {
|
|
321
|
-
const el = scrollRef.current;
|
|
322
|
-
if (!el) return;
|
|
323
|
-
// "At the bottom" means at the newest output — not merely at the end of a
|
|
324
|
-
// window that stops short of it.
|
|
325
|
-
const bottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60 && winRef.current.end === null;
|
|
326
|
-
pinned.current = bottom;
|
|
327
|
-
setAtBottom(bottom);
|
|
328
|
-
if (!bottom && winRef.current.start === null) {
|
|
329
|
-
// Reading back: freeze the window, so new output arriving below doesn't
|
|
330
|
-
// drop the rows above the reader.
|
|
331
|
-
const first = itemsRef.current.slice(rangeRef.current.startIdx).find((it) => it.kind === "msg");
|
|
332
|
-
if (first) setWin((w) => ({ ...w, start: first.key }));
|
|
333
|
-
} else if (bottom && winRef.current.start !== null) {
|
|
334
|
-
// Back at the newest output: follow it again (sheds the rows revealed above).
|
|
335
|
-
setWin({ start: null, end: null });
|
|
336
|
-
}
|
|
337
|
-
}, []);
|
|
338
|
-
|
|
339
|
-
// Reveal more as the reader nears either end of the window (never while
|
|
340
|
-
// following the output).
|
|
341
|
-
const topSentinelRef = useRef<HTMLDivElement>(null);
|
|
342
|
-
const bottomSentinelRef = useRef<HTMLDivElement>(null);
|
|
194
|
+
// This adapter does not draw the folded browser-wait rows, so it asks for none.
|
|
195
|
+
const items = useMemo(() => buildTranscriptItems(parent, older > 0, { fold: false }), [parent, older]);
|
|
196
|
+
const loadOlderPage = useCallback((beforeApply: () => void) => onLoadOlder ? onLoadOlder(beforeApply) : Promise.resolve(false), [onLoadOlder]);
|
|
197
|
+
const { shown, hiddenAbove, hiddenBelow, loadingOlder, atBottom, onScroll, revealEarlier, revealLater, scrollToBottom, scrollToUserMsg, unpin, topSentinelRef, bottomSentinelRef } =
|
|
198
|
+
useTranscriptWindow({ scrollRef, items, older, loadOlder: loadOlderPage, messages, running, remountKey: `${hasSession}|${view}` });
|
|
343
199
|
const hasAbove = hiddenAbove > 0;
|
|
344
200
|
const hasBelow = hiddenBelow > 0;
|
|
345
|
-
useEffect(() => {
|
|
346
|
-
const root = scrollRef.current;
|
|
347
|
-
if (!root || typeof IntersectionObserver === "undefined") return;
|
|
348
|
-
const watch = (target: HTMLElement | null, margin: string, go: () => void) => {
|
|
349
|
-
if (!target) return null;
|
|
350
|
-
const io = new IntersectionObserver((entries) => {
|
|
351
|
-
if (!pinned.current && entries.some((e) => e.isIntersecting)) go();
|
|
352
|
-
}, { root, rootMargin: margin });
|
|
353
|
-
io.observe(target);
|
|
354
|
-
return io;
|
|
355
|
-
};
|
|
356
|
-
const up = hasAbove ? watch(topSentinelRef.current, "1800px 0px 0px 0px", () => void revealRef.current.earlier()) : null;
|
|
357
|
-
const down = hasBelow ? watch(bottomSentinelRef.current, "0px 0px 1800px 0px", () => revealRef.current.later()) : null;
|
|
358
|
-
return () => { up?.disconnect(); down?.disconnect(); };
|
|
359
|
-
// `view`: on mobile the chat pane remounts when toggling chat/changes.
|
|
360
|
-
}, [hasAbove, hasBelow, view]);
|
|
361
|
-
|
|
362
|
-
// Reading back while a long turn keeps streaming: the window's bottom edge
|
|
363
|
-
// follows the output, so cap it at RENDER_MAX. Reader in the upper half →
|
|
364
|
-
// stop extending (new output waits below the "show later" control); in the
|
|
365
|
-
// lower half → drop the oldest rows above instead, holding the view in place.
|
|
366
|
-
useLayoutEffect(() => {
|
|
367
|
-
// +2: a session break and label may sit above the first message row.
|
|
368
|
-
if (win.start === null || win.end !== null || endIdx - startIdx + 1 <= RENDER_MAX + 2) return;
|
|
369
|
-
const el = scrollRef.current;
|
|
370
|
-
if (!el) return;
|
|
371
|
-
if (el.scrollTop < (el.scrollHeight - el.clientHeight) / 2) {
|
|
372
|
-
setRange(startIdx, startIdx + RENDER_MAX - 1);
|
|
373
|
-
} else {
|
|
374
|
-
const ns = endIdx - RENDER_MAX + 1;
|
|
375
|
-
setRange(ns, endIdx, measure(itemsRef.current[ns].key));
|
|
376
|
-
}
|
|
377
|
-
}, [win, startIdx, endIdx, setRange, measure]);
|
|
378
|
-
|
|
379
|
-
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
|
|
380
|
-
const el = scrollRef.current;
|
|
381
|
-
if (!el) return;
|
|
382
|
-
pinned.current = true;
|
|
383
|
-
setAtBottom(true);
|
|
384
|
-
// Not showing the newest rows: switch back to following; the pinned layout
|
|
385
|
-
// effect below lands on the bottom once they render.
|
|
386
|
-
if (winRef.current.start !== null || winRef.current.end !== null) { setWin({ start: null, end: null }); return; }
|
|
387
|
-
el.scrollTo({ top: el.scrollHeight, behavior });
|
|
388
|
-
}, []);
|
|
389
|
-
|
|
390
|
-
// Jump between the user's own messages in the transcript. dir < 0 goes to the
|
|
391
|
-
// previous one above the current scroll position, dir > 0 to the next below it;
|
|
392
|
-
// queued (not-yet-sent) bubbles are excluded so nav only lands on real turns.
|
|
393
|
-
// Past the edge of the rendered window it reveals more rows first.
|
|
394
|
-
const scrollToUserMsg = useCallback((dir: -1 | 1) => {
|
|
395
|
-
const el = scrollRef.current;
|
|
396
|
-
if (!el) return;
|
|
397
|
-
const base = el.getBoundingClientRect().top;
|
|
398
|
-
const tops = Array.from(el.querySelectorAll<HTMLElement>(".msg.user:not(.queued)"))
|
|
399
|
-
.map((n) => n.getBoundingClientRect().top - base + el.scrollTop);
|
|
400
|
-
const cur = el.scrollTop;
|
|
401
|
-
const eps = 8;
|
|
402
|
-
const candidates = dir < 0 ? tops.filter((t) => t < cur - eps) : tops.filter((t) => t > cur + eps);
|
|
403
|
-
if (!candidates.length && (dir < 0 ? hiddenRef.current.above : hiddenRef.current.below) > 0) {
|
|
404
|
-
navAfterRevealRef.current = dir;
|
|
405
|
-
if (dir < 0) void revealRef.current.earlier();
|
|
406
|
-
else revealRef.current.later();
|
|
407
|
-
return;
|
|
408
|
-
}
|
|
409
|
-
if (!tops.length) return;
|
|
410
|
-
const target = candidates.length ? (dir < 0 ? candidates[candidates.length - 1] : candidates[0]) : dir < 0 ? tops[0] : tops[tops.length - 1];
|
|
411
|
-
el.scrollTo({ top: Math.max(0, target - 12), behavior: "smooth" });
|
|
412
|
-
}, []);
|
|
413
|
-
|
|
414
|
-
// The window moved: put the anchored row back where it was (a no-op where the
|
|
415
|
-
// browser's own scroll anchoring already did it), then finish a pending nav.
|
|
416
|
-
// Only the commit of the window change the anchor was measured for restores
|
|
417
|
-
// it — an intermediate commit (an older page landing) must not consume it.
|
|
418
|
-
useLayoutEffect(() => {
|
|
419
|
-
const a = anchorRef.current;
|
|
420
|
-
const el = scrollRef.current;
|
|
421
|
-
if (a && a.win === win) {
|
|
422
|
-
anchorRef.current = null;
|
|
423
|
-
const node = el?.querySelector<HTMLElement>(`[data-tkey="${CSS.escape(a.key)}"]`);
|
|
424
|
-
if (el && node) {
|
|
425
|
-
const delta = node.getBoundingClientRect().top - a.top;
|
|
426
|
-
if (Math.abs(delta) > 1) el.scrollTop += delta;
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
const nav = navAfterRevealRef.current;
|
|
430
|
-
if (nav && !anchorRef.current) {
|
|
431
|
-
navAfterRevealRef.current = 0;
|
|
432
|
-
scrollToUserMsg(nav);
|
|
433
|
-
}
|
|
434
|
-
}, [win, scrollToUserMsg]);
|
|
435
|
-
|
|
436
|
-
// Following the output: stay on the newest row as content arrives or the
|
|
437
|
-
// window sheds rows above. Layout effect, so it lands before paint (no jump).
|
|
438
|
-
useLayoutEffect(() => {
|
|
439
|
-
if (pinned.current && scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
440
|
-
}, [messages, running, startIdx, endIdx, win]);
|
|
441
|
-
// …and when the pane itself resizes (the composer growing, a banner
|
|
442
|
-
// appearing). Content resizes are left alone, so expanding a tool card at
|
|
443
|
-
// the bottom doesn't yank its top out of view.
|
|
444
|
-
useEffect(() => {
|
|
445
|
-
const el = scrollRef.current;
|
|
446
|
-
if (!el || typeof ResizeObserver === "undefined") return;
|
|
447
|
-
const ro = new ResizeObserver(() => { if (pinned.current) el.scrollTop = el.scrollHeight; });
|
|
448
|
-
ro.observe(el);
|
|
449
|
-
return () => ro.disconnect();
|
|
450
|
-
}, [hasSession, view]);
|
|
451
201
|
|
|
452
202
|
const flowPositioned=useRef(!task.parent_task_id);
|
|
453
203
|
useLayoutEffect(()=>{
|
|
@@ -456,7 +206,7 @@ function ChatSession({ project, task, tasks, agents, messages, running, transcri
|
|
|
456
206
|
const boundary=Array.from(el?.querySelectorAll<HTMLElement>(".branch-nav")??[]).find(e=>e.dataset.parentId===task.parent_task_id);
|
|
457
207
|
if(!el || !boundary) return;
|
|
458
208
|
el.scrollTop+=boundary.getBoundingClientRect().top-el.getBoundingClientRect().top-60;
|
|
459
|
-
|
|
209
|
+
unpin(); flowPositioned.current=true;
|
|
460
210
|
},[active,flow.ready,task.parent_task_id]);
|
|
461
211
|
useEffect(()=>{if(!task.parent_task_id && active) scrollToBottom("auto");},[active,task.id]);
|
|
462
212
|
const queued = <QueuedMessages scopeKey={task.id} messages={messages.filter(message => message.role === "queued")} onCancel={message => cancelQueued(message.id)}
|
|
@@ -498,6 +248,11 @@ function ChatSession({ project, task, tasks, agents, messages, running, transcri
|
|
|
498
248
|
<div key={it.key} data-tkey={it.key} className="session-label"><span className="ln" />第 {it.n} 次会话{it.current ? " · 当前" : ""}<span className="ln" /></div>
|
|
499
249
|
) : it.kind === "break" ? (
|
|
500
250
|
<div key={it.key} data-tkey={it.key}><SessionBreak summary={it.summary} /></div>
|
|
251
|
+
) : it.kind === "fold" ? (
|
|
252
|
+
// Not requested here ({ fold: false }); kept total so a folded row can never vanish.
|
|
253
|
+
<div key={it.key} data-tkey={it.key} data-msg="">
|
|
254
|
+
{it.msgs.map((m) => <MessageView key={m.id} m={m} initial={false} 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={(msg,selected)=>quote(msg,task,selected)} />)}
|
|
255
|
+
</div>
|
|
501
256
|
) : (
|
|
502
257
|
<div key={it.key} data-tkey={it.key} data-msg="">
|
|
503
258
|
<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)} />
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
-
import { Icon } from "
|
|
4
|
+
import { Icon } from "../../ui/icons";
|
|
5
5
|
import { jget } from "./api";
|
|
6
6
|
import type { PaletteTaskRow, ProjectRow } from "./types";
|
|
7
7
|
import { StatusDot } from "./shared";
|
|
@@ -4,7 +4,7 @@ import { useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type
|
|
|
4
4
|
import { DOCUMENT_MAX_BYTES, FILE_UPLOAD_ACCEPT, isOriginalDocument, uploadFileType, UPLOAD_OTHER_MAX_BYTES } from "../../core/documentUpload";
|
|
5
5
|
import { Popover } from "./shared";
|
|
6
6
|
import type { TaskRow } from "./types";
|
|
7
|
-
import { Icon } from "
|
|
7
|
+
import { Icon } from "../../ui/icons";
|
|
8
8
|
import { attachmentMarker, fileAttachmentMarker } from "./format";
|
|
9
9
|
import { MAX_MESSAGE_CHARS, PASTE_ATTACH_THRESHOLD } from "./protocol/promptLimits";
|
|
10
10
|
import { RunControls, type RunControlProps } from "./RunControls";
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import remarkGfm from "remark-gfm";
|
|
6
|
-
import rehypeHighlight from "rehype-highlight";
|
|
7
|
-
import type { ComponentProps } from "react";
|
|
8
|
-
import { inlineFilePath } from "../../ui/fileLinks";
|
|
3
|
+
import { useContext, type ComponentProps } from "react";
|
|
4
|
+
import { createMarkdown } from "../../ui/Markdown";
|
|
9
5
|
import { OperatorBoardContext } from "./boardInteraction";
|
|
10
6
|
import { FileLinkContext, localFilePath, rewriteFileHref, rewriteFileSrc } from "./fileLinks";
|
|
11
7
|
|
|
@@ -22,11 +18,6 @@ function MdLink({ href, ...props }: ComponentProps<"a">) {
|
|
|
22
18
|
if(scope&&path&&open?.({kind:'file',taskId:scope.taskId,path}))event.preventDefault();}} />;
|
|
23
19
|
}
|
|
24
20
|
|
|
25
|
-
function MdCode({children, ...props}: ComponentProps<"code">) {
|
|
26
|
-
const path=inlineFilePath(children);
|
|
27
|
-
return path ? <MdLink href={path}><code {...props}>{children}</code></MdLink> : <code {...props}>{children}</code>;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
21
|
function MdImg({ src, alt, ...props }: ComponentProps<"img">) {
|
|
31
22
|
const scope = useContext(FileLinkContext);
|
|
32
23
|
const local = typeof src === "string" ? rewriteFileSrc(scope, src) : null;
|
|
@@ -34,28 +25,7 @@ function MdImg({ src, alt, ...props }: ComponentProps<"img">) {
|
|
|
34
25
|
return <img {...props} alt={alt ?? ""} src={local ?? src} />;
|
|
35
26
|
}
|
|
36
27
|
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
// Renders Claude's markdown output: headings, lists, tables, fenced code blocks
|
|
42
|
-
// (syntax-highlighted), inline code, links. Used for assistant + user messages.
|
|
43
|
-
//
|
|
44
|
-
// Perf: `detect: false` — only fenced blocks with an explicit language get
|
|
45
|
-
// highlighted; hljs auto-detection ran over every bare code block and was a
|
|
46
|
-
// hot spot during streaming turns. Memoized so messages whose text hasn't
|
|
47
|
-
// changed skip the whole markdown parse + highlight on transcript re-renders.
|
|
48
|
-
export const Markdown = memo(function Markdown({ children }: { children: string }) {
|
|
49
|
-
return (
|
|
50
|
-
<div className="md">
|
|
51
|
-
<ReactMarkdown
|
|
52
|
-
remarkPlugins={[remarkGfm]}
|
|
53
|
-
rehypePlugins={[[rehypeHighlight, { detect: false, ignoreMissing: true }]]}
|
|
54
|
-
components={{ a: MdLink, img: MdImg, code: MdCode }}
|
|
55
|
-
urlTransform={urlTransform}
|
|
56
|
-
>
|
|
57
|
-
{children}
|
|
58
|
-
</ReactMarkdown>
|
|
59
|
-
</div>
|
|
60
|
-
);
|
|
61
|
-
});
|
|
28
|
+
// Rendering rules (plugins, highlighting, inline file-path code, memo) are shared
|
|
29
|
+
// with the center adapter (ui/Markdown createMarkdown); only how a local file
|
|
30
|
+
// link / image resolves is node-specific: the task's file route and board.
|
|
31
|
+
export const Markdown = createMarkdown({ Link: MdLink, Image: MdImg });
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
4
|
import type { Priority } from "./protocol/types";
|
|
5
|
-
import { Icon } from "
|
|
5
|
+
import { Icon } from "../../ui/icons";
|
|
6
6
|
import { jget, jsend } from "./api";
|
|
7
7
|
import { SLABEL, type FsListing, type TaskRow } from "./types";
|
|
8
8
|
import { StatusDot, Skel, ErrNote } from "./shared";
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useState } from "react";
|
|
4
|
-
import { Icon } from "
|
|
4
|
+
import { Icon } from "../../ui/icons";
|
|
5
5
|
import { jget, jsend } from "./api";
|
|
6
6
|
import { AgentConnect } from "./AgentConnect";
|
|
7
7
|
import type { AgentInfoT, AgentsResponseT, ClaudeVerifyT, OnboardingT, OnbStep } from "./types";
|
|
8
|
-
import {
|
|
8
|
+
import { useRecheckOnReturn } from "./recheckOnReturn";
|
|
9
9
|
|
|
10
10
|
// The first-run wizard, trimmed to the two irreducible auth steps: connect a
|
|
11
11
|
// coding agent, then verify it responds. "An agent" — not Claude specifically:
|
|
@@ -48,9 +48,9 @@ export function OnboardingWizard({
|
|
|
48
48
|
|
|
49
49
|
// While nothing is connected yet, re-check: a login can settle server-side
|
|
50
50
|
// after a missed onConnected (e.g. the connect card was remounted mid-login),
|
|
51
|
-
// and without this the Continue button stays locked until a reload.
|
|
52
|
-
//
|
|
53
|
-
|
|
51
|
+
// and without this the Continue button stays locked until a reload. Checked
|
|
52
|
+
// when the user comes back to the tab or presses 重新检查 — no polling.
|
|
53
|
+
useRecheckOnReturn(step === "connect" && connected.length === 0, load);
|
|
54
54
|
const verifyAgent =
|
|
55
55
|
(justConnected && connected.find((a) => a.id === justConnected)) || connected[0] || null;
|
|
56
56
|
|
|
@@ -91,6 +91,7 @@ export function OnboardingWizard({
|
|
|
91
91
|
bundle={bundle}
|
|
92
92
|
done={connected.length > 0}
|
|
93
93
|
onConnected={(id) => { setJustConnected(id); load(); }}
|
|
94
|
+
onRecheck={load}
|
|
94
95
|
onContinue={() => goto("verify")}
|
|
95
96
|
/>
|
|
96
97
|
)}
|
|
@@ -125,11 +126,13 @@ function ConnectStep({
|
|
|
125
126
|
bundle,
|
|
126
127
|
done,
|
|
127
128
|
onConnected,
|
|
129
|
+
onRecheck,
|
|
128
130
|
onContinue,
|
|
129
131
|
}: {
|
|
130
132
|
bundle: AgentsResponseT | null;
|
|
131
133
|
done: boolean;
|
|
132
134
|
onConnected: (agentId: string) => void;
|
|
135
|
+
onRecheck: () => void;
|
|
133
136
|
onContinue: () => void;
|
|
134
137
|
}) {
|
|
135
138
|
const agents = bundle?.agents ?? [];
|
|
@@ -165,6 +168,7 @@ function ConnectStep({
|
|
|
165
168
|
)}
|
|
166
169
|
</div>
|
|
167
170
|
<div className="wiz-foot">
|
|
171
|
+
{!done && bundle && <button className="linkbtn" onClick={onRecheck} title="在别处登录过了?重新读取连接状态">已经登录过?重新检查</button>}
|
|
168
172
|
<span className="spacer" />
|
|
169
173
|
<button className="btn btn-accent" disabled={!done} onClick={onContinue}>
|
|
170
174
|
继续 {Icon.chevRight()}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useRef } from "react";
|
|
4
|
-
import { Icon } from "
|
|
4
|
+
import { Icon } from "../../ui/icons";
|
|
5
5
|
import { TerminalView, type TermApi } from "./Terminal";
|
|
6
6
|
import { PROJ_W, TASK_W, DEFAULT_LAYOUT } from "./types";
|
|
7
7
|
import { useOrchestrator } from "./useOrchestrator";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useState } from "react";
|
|
4
|
-
import { Icon } from "
|
|
4
|
+
import { Icon } from "../../ui/icons";
|
|
5
5
|
import { fmtCost, relTime } from "./format";
|
|
6
6
|
import { SEARCH_MIN, type AgentInfo, type ProjectRow } from "./types";
|
|
7
7
|
import { SearchBar } from "./shared";
|
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
// routes; rendered by AgentConnect when capabilities.supportsProviderAuth.
|
|
8
8
|
|
|
9
9
|
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
|
10
|
-
import { Icon } from "
|
|
10
|
+
import { Icon } from "../../ui/icons";
|
|
11
11
|
import { jget, jsend } from "./api";
|
|
12
12
|
import type { AgentInfoT, ClaudeVerifyT } from "./types";
|
|
13
13
|
import type { AgentLoginEvent, AgentLoginPrompt, AgentProviderInfo, AgentProviderLogin } from "./protocol/agents/types";
|
|
14
|
-
import {
|
|
14
|
+
import { useRecheckOnReturn } from "./recheckOnReturn";
|
|
15
15
|
|
|
16
16
|
// The global .field rules style text/number inputs only; selects and password
|
|
17
17
|
// inputs here get the same look inline instead of widening those shared rules.
|
|
@@ -92,14 +92,16 @@ export function ProviderAuthPanel({ agent, onConnected }: { agent: AgentInfoT; o
|
|
|
92
92
|
}
|
|
93
93
|
}, [agent.connected, agent.id, agent.label, load, onConnected]);
|
|
94
94
|
|
|
95
|
-
// Re-check while the flow runs —
|
|
96
|
-
//
|
|
97
|
-
|
|
95
|
+
// Re-check while the flow runs — when the user comes back to this tab or presses
|
|
96
|
+
// 检查状态 (useRecheckOnReturn; no polling); finish once it settles. Answers to the
|
|
97
|
+
// flow's prompts return the next state directly.
|
|
98
|
+
const checkLogin = useCallback(() => {
|
|
98
99
|
jget<AgentProviderLogin | null>(`${base}/login`).then((l) => {
|
|
99
100
|
if (!l) { setLogin(null); return; }
|
|
100
101
|
setLogin(l);
|
|
101
102
|
}).catch(() => {});
|
|
102
|
-
},
|
|
103
|
+
}, [base]);
|
|
104
|
+
useRecheckOnReturn(login?.status === "running", checkLogin);
|
|
103
105
|
|
|
104
106
|
useEffect(() => {
|
|
105
107
|
if (login?.status === "success") { setNote({ ok: true, text: `已登录 ${nameOf(login.provider)}。` }); void afterChange(); }
|
|
@@ -290,7 +292,7 @@ export function ProviderAuthPanel({ agent, onConnected }: { agent: AgentInfoT; o
|
|
|
290
292
|
<KeyForm provider={picked} onSave={(input) => saveKey(picked, input)} onCancel={() => setKeyFor(null)} />
|
|
291
293
|
</div>
|
|
292
294
|
)}
|
|
293
|
-
{login && <LoginFlow login={login} name={nameOf(login.provider)} busy={busy === "answer"} onAnswer={answer} onCancel={cancel} onDismiss={() => setLogin(null)} />}
|
|
295
|
+
{login && <LoginFlow login={login} name={nameOf(login.provider)} busy={busy === "answer"} onAnswer={answer} onCancel={cancel} onDismiss={() => setLogin(null)} onCheck={checkLogin} />}
|
|
294
296
|
</div>
|
|
295
297
|
|
|
296
298
|
{/* ---- user-defined endpoints ---- */}
|
|
@@ -391,9 +393,9 @@ function PromptInput({ prompt, busy, onAnswer }: { prompt: AgentLoginPrompt; bus
|
|
|
391
393
|
);
|
|
392
394
|
}
|
|
393
395
|
|
|
394
|
-
function LoginFlow({ login, name, busy, onAnswer, onCancel, onDismiss }: {
|
|
396
|
+
function LoginFlow({ login, name, busy, onAnswer, onCancel, onDismiss, onCheck }: {
|
|
395
397
|
login: AgentProviderLogin; name: string; busy: boolean;
|
|
396
|
-
onAnswer: (p: AgentLoginPrompt, v: string) => void; onCancel: () => void; onDismiss: () => void;
|
|
398
|
+
onAnswer: (p: AgentLoginPrompt, v: string) => void; onCancel: () => void; onDismiss: () => void; onCheck: () => void;
|
|
397
399
|
}) {
|
|
398
400
|
if (login.status === "success") {
|
|
399
401
|
return <div className="wiz-connected" style={{ marginTop: 10 }}><span className="wiz-ok">{Icon.check()}</span><div className="wiz-ok-t">已登录 {name}</div></div>;
|
|
@@ -416,7 +418,7 @@ function LoginFlow({ login, name, busy, onAnswer, onCancel, onDismiss }: {
|
|
|
416
418
|
{login.prompt ? (
|
|
417
419
|
<PromptInput prompt={login.prompt} busy={busy} onAnswer={onAnswer} />
|
|
418
420
|
) : (
|
|
419
|
-
<div className="wiz-verify" style={{ marginTop: 6 }}><span className="wiz-spin" /> <span
|
|
421
|
+
<div className="wiz-verify" style={{ marginTop: 6 }}><span className="wiz-spin" /> <span>等待授权完成…回到这个页面时会自动检查。</span> <button className="linkbtn" onClick={onCheck}>检查状态</button></div>
|
|
420
422
|
)}
|
|
421
423
|
<button className="linkbtn" style={{ marginTop: 10 }} onClick={onCancel}>取消</button>
|
|
422
424
|
</div>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { Fragment, useState } from "react";
|
|
4
|
-
import { Icon } from "
|
|
4
|
+
import { Icon } from "../../ui/icons";
|
|
5
5
|
import { Popover } from "./shared";
|
|
6
6
|
import { agentLabel, agentStateHint, capsFor } from "./agents";
|
|
7
7
|
import { modelLabel } from "./format";
|