@xgspz/cli-ui 0.6.14 → 0.6.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +37 -4
  2. package/README.md +29 -4
  3. package/dist/{cli-ui-embed-D-3DvyTN.js → cli-ui-embed-D9xgur2y.js} +18376 -18648
  4. package/dist/{cli-ui-index-C34qFb4u.js → cli-ui-index-WBtxmw23.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-DojbZPiE.js → cli-ui-jszip.min-CU3ZzoXb.js} +1 -1
  6. package/dist/{cli-ui-jszip.min-BObcE2nn.js → cli-ui-jszip.min-DdU0X3VJ.js} +2 -2
  7. package/dist/{cli-ui-xlsx-JVLtx0qR.js → cli-ui-xlsx-Dx6T7qhC.js} +1 -1
  8. package/dist/cli-ui.css +1 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +1 -1
  11. package/src/board/ChangesPane.tsx +10 -4
  12. package/src/board/CliBoard.tsx +48 -16
  13. package/src/board/FileViewer.tsx +34 -1
  14. package/src/board/diffRefresh.ts +63 -0
  15. package/src/board/linkage.ts +68 -0
  16. package/src/board/pathResolve.ts +108 -0
  17. package/src/chat/CliChat.tsx +105 -182
  18. package/src/chat/Composer.tsx +4 -3
  19. package/src/core/conversation.ts +16 -4
  20. package/src/core/store.ts +120 -40
  21. package/src/core/types.ts +2 -0
  22. package/src/msglist/CliMsgList.tsx +3 -2
  23. package/src/operator/node/AgentConnect.tsx +19 -15
  24. package/src/operator/node/AppearancePanel.tsx +1 -1
  25. package/src/operator/node/BranchNavigator.tsx +1 -1
  26. package/src/operator/node/BrowserPane.tsx +1 -1
  27. package/src/operator/node/Chat.tsx +16 -261
  28. package/src/operator/node/CommandPalette.tsx +1 -1
  29. package/src/operator/node/Composer.tsx +1 -1
  30. package/src/operator/node/Layout.tsx +1 -1
  31. package/src/operator/node/Markdown.tsx +6 -36
  32. package/src/operator/node/Modal.tsx +1 -1
  33. package/src/operator/node/OnboardingWizard.tsx +9 -5
  34. package/src/operator/node/Orchestrator.tsx +1 -1
  35. package/src/operator/node/ProjectLanding.tsx +1 -1
  36. package/src/operator/node/ProjectsColumn.tsx +1 -1
  37. package/src/operator/node/ProviderAuth.tsx +12 -10
  38. package/src/operator/node/RunSwitch.tsx +1 -1
  39. package/src/operator/node/Services.tsx +1 -1
  40. package/src/operator/node/SessionRail.tsx +27 -8
  41. package/src/operator/node/SessionView.tsx +1 -1
  42. package/src/operator/node/SettingsView.tsx +1 -1
  43. package/src/operator/node/TaskBoard.tsx +1 -1
  44. package/src/operator/node/TaskChanges.tsx +34 -5
  45. package/src/operator/node/TaskHero.tsx +1 -1
  46. package/src/operator/node/TasksColumn.tsx +1 -1
  47. package/src/operator/node/Transcript.tsx +10 -237
  48. package/src/operator/node/Welcome.tsx +1 -1
  49. package/src/operator/node/WorktreePrune.tsx +1 -1
  50. package/src/operator/node/boardInteraction.ts +6 -3
  51. package/src/operator/node/boardState.ts +12 -13
  52. package/src/operator/node/fileLinks.ts +4 -38
  53. package/src/operator/node/format.ts +12 -104
  54. package/src/operator/node/github.tsx +10 -8
  55. package/src/operator/node/modals.tsx +15 -13
  56. package/src/operator/node/recheckOnReturn.ts +28 -0
  57. package/src/operator/node/shared.tsx +10 -50
  58. package/src/operator/node/useTaskStream.ts +2 -2
  59. package/src/operator.base.css +3 -0
  60. package/src/operator.css +3 -0
  61. package/src/styles.css +5 -0
  62. package/src/ui/Markdown.tsx +36 -21
  63. package/src/ui/Transcript.tsx +8 -5
  64. package/src/ui/fileLinks.ts +6 -3
  65. package/src/ui/format.ts +11 -5
  66. package/src/ui/shared.tsx +11 -7
  67. package/src/ui/transcriptModel.ts +40 -9
  68. package/src/ui/useTranscriptWindow.ts +253 -0
  69. package/src/operator/node/boundedPoll.ts +0 -47
  70. package/src/operator/node/icons.tsx +0 -88
  71. package/src/operator/node/transcriptModel.ts +0 -129
@@ -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 "./icons";
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 { RENDER_MAX, RENDER_STEP, RENDER_WINDOW, buildTranscriptItems, hasOpenAsk } from "./transcriptModel";
13
+ import { buildTranscriptItems, hasOpenAsk } from "../../ui/transcriptModel";
14
+ import { useTranscriptWindow } from "../../ui/useTranscriptWindow";
14
15
  import { OperatorBoardContext } from "./boardInteraction";
15
- import { toolDataOf } from "./transcriptModel";
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
- // Auto-scroll only while the user is parked at the bottom. If they scroll up to
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
- // While following the output it's the newest RENDER_WINDOW rows. Scrolling back
200
- // reveals RENDER_STEP more above at a time (paging older messages in from the
201
- // server once the loaded ones run out) and drops rows far below; scrolling down
202
- // brings those back. "Jump to latest" returns to following.
203
- const items = useMemo(() => buildTranscriptItems(parent, older > 0), [parent, older]);
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
- pinned.current=false; setAtBottom(false); flowPositioned.current=true;
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 "./icons";
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 "./icons";
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,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useRef, useState } from "react";
4
- import { Icon } from "./icons";
4
+ import { Icon } from "../../ui/icons";
5
5
  import { TerminalView } from "./Terminal";
6
6
  import { Skel } from "./shared";
7
7
 
@@ -1,11 +1,7 @@
1
1
  "use client";
2
2
 
3
- import { memo, useContext } from "react";
4
- import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
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
- // The default transform blanks any non-web scheme; let file: (and C:\ paths)
38
- // through so MdLink can map them to the task's file route.
39
- const urlTransform = (url: string) => (/^file:/i.test(url) || /^[a-z]:[\\/]/i.test(url) ? url : defaultUrlTransform(url));
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 "./icons";
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 "./icons";
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 { useBoundedPoll } from "./boundedPoll";
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. Bounded:
52
- // a window of checks, renewed when the user comes back to the tab.
53
- useBoundedPoll(step === "connect" && connected.length === 0, load, 3_000, 10 * 60_000);
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 "./icons";
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,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { Icon } from "./icons";
3
+ import { Icon } from "../../ui/icons";
4
4
  import { Markdown } from "./Markdown";
5
5
  import { relTime } from "./format";
6
6
  import { ErrNote } from "./shared";
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useState } from "react";
4
- import { Icon } from "./icons";
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 "./icons";
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 { useBoundedPoll } from "./boundedPoll";
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 — for a bounded window, and again when the user
96
- // comes back to this tab (useBoundedPoll); finish once it settles.
97
- useBoundedPoll(login?.status === "running", () => {
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
- }, 1500, 10 * 60_000);
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>等待授权完成…</span></div>
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 "./icons";
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";
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useRef, useState } from "react";
4
- import { Icon } from "./icons";
4
+ import { Icon } from "../../ui/icons";
5
5
  import { jsend } from "./api";
6
6
  import { openLive } from "./liveStream";
7
7
  import { LoadNote } from "./shared";