From 931fbea46dced09f9ef210ae025b244dcd932237 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 31 Jul 2026 19:52:43 +0800 Subject: [PATCH 1/7] refactor(ui): share the side-anchored preview card popup The chat minimap needs the same side-capable fork the sidebar already had; move it up instead of copying the popup's class list a third time. --- ...review-card.tsx => preview-card-popup.tsx} | 27 ++++++++++++------- .../ui/src/shell/sidebar/group-header.tsx | 6 ++--- .../ui/src/shell/sidebar/thread-row.tsx | 6 ++--- 3 files changed, 24 insertions(+), 15 deletions(-) rename packages/presentation/ui/src/{shell/sidebar/preview-card.tsx => preview-card-popup.tsx} (65%) diff --git a/packages/presentation/ui/src/shell/sidebar/preview-card.tsx b/packages/presentation/ui/src/preview-card-popup.tsx similarity index 65% rename from packages/presentation/ui/src/shell/sidebar/preview-card.tsx rename to packages/presentation/ui/src/preview-card-popup.tsx index 9e6a1da1b..725094a47 100644 --- a/packages/presentation/ui/src/shell/sidebar/preview-card.tsx +++ b/packages/presentation/ui/src/preview-card-popup.tsx @@ -1,25 +1,34 @@ import { PreviewCardPrimitive } from 'coss-ui/components/preview-card'; -import { cn } from '../../lib/cn'; +import { cn } from './lib/cn'; + +export type SidePreviewCardPopupProps = PreviewCardPrimitive.Popup.Props & { + side?: PreviewCardPrimitive.Positioner.Props['side']; + align?: PreviewCardPrimitive.Positioner.Props['align']; + sideOffset?: PreviewCardPrimitive.Positioner.Props['sideOffset']; +}; /** * Minimal fork of coss-ui's `PreviewCardPopup` (same popup styling) that exposes the positioner's - * `side`, which the vendored component hard-defaults to `bottom`. Sidebar cards open to the right - * of their row — like the row dropdown menus — so they never cover the list they annotate. - * Compose with `PreviewCard` + `PreviewCardTrigger` from coss-ui. + * `side`, which the vendored component hard-defaults to `bottom`. Cards annotating a list open + * beside it so they never cover the rows they describe. Compose with `PreviewCard` + + * `PreviewCardTrigger` from coss-ui. */ -export function SidebarPreviewCardPopup({ +export function SidePreviewCardPopup({ className, children, + side = 'right', + align = 'start', + sideOffset = 8, ...props -}: PreviewCardPrimitive.Popup.Props): React.ReactNode { +}: SidePreviewCardPopupProps): React.ReactNode { return ( {workspace && ( - +
{title}
@@ -206,7 +206,7 @@ export function ThreadGroupHeader({
-
+ )} )} diff --git a/packages/presentation/ui/src/shell/sidebar/thread-row.tsx b/packages/presentation/ui/src/shell/sidebar/thread-row.tsx index e1bead2f2..46181bee6 100644 --- a/packages/presentation/ui/src/shell/sidebar/thread-row.tsx +++ b/packages/presentation/ui/src/shell/sidebar/thread-row.tsx @@ -8,10 +8,10 @@ import { ClockIcon, EllipsisIcon, FolderIcon, GitBranchIcon, PinIcon, XIcon } fr import { useTranslations } from 'use-intl'; import { AGENT_LABELS, AgentIcon } from '../../chat/agent-icon'; import { cn } from '../../lib/cn'; +import { SidePreviewCardPopup } from '../../preview-card-popup'; import { repositoryLabel } from '../../repository-label'; import { useRelativeTimeLabel } from '../use-relative-time-label'; import type { BranchStatusComponentType } from './branch-status'; -import { SidebarPreviewCardPopup } from './preview-card'; import { ROW_ACTION_CLASS, ROW_HOVER_PE_CLASS, @@ -99,7 +99,7 @@ export function ThreadRow({ {title} - +
{title}
@@ -120,7 +120,7 @@ export function ThreadRow({
-
+ {ImMenuComponent && ( From 37f73af9736aabf9e0a8fa5172462bc93d2f30c4 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 31 Jul 2026 19:53:28 +0800 Subject: [PATCH 2/7] feat(chat): expose the conversation virtualizer handle Row offsets, imperative scrolling, and a scroll callback, so a viewport-pinned overlay can navigate the stream. The named container sizes such overlays against the pane rather than the window. --- packages/presentation/ui/src/chat/conversation.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/presentation/ui/src/chat/conversation.tsx b/packages/presentation/ui/src/chat/conversation.tsx index de3bfc674..c42f1e4d9 100644 --- a/packages/presentation/ui/src/chat/conversation.tsx +++ b/packages/presentation/ui/src/chat/conversation.tsx @@ -9,6 +9,7 @@ import { import { ArrowDownIcon } from 'lucide-react'; import { useCallback } from 'react'; import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'; +import type { VirtualizerHandle } from 'virtua'; import { Virtualizer } from 'virtua'; import { cn } from '../lib/cn'; @@ -17,7 +18,9 @@ export type ConversationProps = React.ComponentProps; export function Conversation({ className, ...props }: ConversationProps): React.ReactNode { return ( { children: (item: T, index: number) => React.ReactElement; /** Rendered after the virtualized rows, inside the scrolled column (e.g. the thinking row). */ trailing?: React.ReactNode; + /** Row offsets and imperative scrolling, for overlays that navigate the stream (the minimap). */ + virtualizerRef?: React.Ref; + /** Scroll offset of the virtualized column. This version of virtua has no range callback, so + * consumers resolve the visible range themselves via the handle. */ + onScroll?: (offset: number) => void; } /** @@ -49,6 +57,8 @@ export function ConversationContent({ data, children, trailing, + virtualizerRef, + onScroll, }: ConversationContentProps): React.ReactNode { const { scrollRef } = useStickToBottomContext(); return ( @@ -57,7 +67,7 @@ export function ConversationContent({ // The browser's own scroll anchoring fights both scroll owners. scrollClassName="[overflow-anchor:none]" > - + {children} {trailing} From 10b12204d66ee66f65c3d34e30be85066e725d30 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 31 Jul 2026 19:53:41 +0800 Subject: [PATCH 3/7] feat(chat): add conversation minimap geometry Dock-style magnification falloff and the rail's own scroll position, kept pure so the curve and the clamping are testable without layout. --- .../chat/__tests__/minimap-geometry.test.ts | 61 +++++++++++++++++++ .../ui/src/chat/minimap-geometry.ts | 57 +++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 packages/presentation/ui/src/chat/__tests__/minimap-geometry.test.ts create mode 100644 packages/presentation/ui/src/chat/minimap-geometry.ts diff --git a/packages/presentation/ui/src/chat/__tests__/minimap-geometry.test.ts b/packages/presentation/ui/src/chat/__tests__/minimap-geometry.test.ts new file mode 100644 index 000000000..43633a2bd --- /dev/null +++ b/packages/presentation/ui/src/chat/__tests__/minimap-geometry.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { + fisheyeFactor, + fisheyeSize, + MINIMAP_FISHEYE_SPREAD, + MINIMAP_ROW_HEIGHT, + MINIMAP_TURN_TICK, + railScrollTopFor, +} from '../minimap-geometry'; + +describe('fisheyeFactor', () => { + it('peaks under the pointer and dies at the spread', () => { + expect(fisheyeFactor(0)).toBe(1); + expect(fisheyeFactor(MINIMAP_FISHEYE_SPREAD)).toBe(0); + expect(fisheyeFactor(MINIMAP_FISHEYE_SPREAD * 4)).toBe(0); + }); + + it('is symmetric, so ticks above and below the pointer grow alike', () => { + expect(fisheyeFactor(-20)).toBe(fisheyeFactor(20)); + }); + + it('decreases monotonically across the falloff', () => { + const samples = [0, 10, 20, 30, 40, 50, 54].map((d) => fisheyeFactor(d)); + for (let i = 1; i < samples.length; i++) expect(samples[i]).toBeLessThan(samples[i - 1]); + }); + + it('eases out — the near half of the falloff keeps most of the magnification', () => { + expect(fisheyeFactor(MINIMAP_FISHEYE_SPREAD / 2)).toBeGreaterThan(0.5); + }); +}); + +describe('fisheyeSize', () => { + it('spans exactly base to peak', () => { + expect(fisheyeSize(MINIMAP_TURN_TICK, 0)).toEqual({ width: 12, height: 3 }); + expect(fisheyeSize(MINIMAP_TURN_TICK, 1)).toEqual({ width: 39, height: 6 }); + }); + + it('interpolates both axes together', () => { + expect(fisheyeSize(MINIMAP_TURN_TICK, 0.5)).toEqual({ width: 25.5, height: 4.5 }); + }); +}); + +describe('railScrollTopFor', () => { + const railHeight = 180; // 10 rows + + it('does not scroll while every turn fits', () => { + expect(railScrollTopFor({ start: 0, end: 4 }, 8, railHeight)).toBe(0); + }); + + it('centers the visible range once the rail overflows', () => { + // Rows 20..29 → center at row 25 → 25 * 18 - 90. + expect(railScrollTopFor({ start: 20, end: 29 }, 100, railHeight)).toBe(360); + }); + + it('clamps at both ends instead of overscrolling', () => { + expect(railScrollTopFor({ start: 0, end: 3 }, 100, railHeight)).toBe(0); + expect(railScrollTopFor({ start: 96, end: 99 }, 100, railHeight)).toBe( + 100 * MINIMAP_ROW_HEIGHT - railHeight, + ); + }); +}); diff --git a/packages/presentation/ui/src/chat/minimap-geometry.ts b/packages/presentation/ui/src/chat/minimap-geometry.ts new file mode 100644 index 000000000..2b8597ab6 --- /dev/null +++ b/packages/presentation/ui/src/chat/minimap-geometry.ts @@ -0,0 +1,57 @@ +import { clamp } from 'foxts/clamp'; + +/** Row pitch. One turn per row regardless of its length, so a tick's hit area never shrinks. */ +export const MINIMAP_ROW_HEIGHT = 18; + +/** Pointer distance at which magnification reaches zero — three rows. */ +export const MINIMAP_FISHEYE_SPREAD = 54; + +export interface MinimapTickDims { + baseWidth: number; + baseHeight: number; + peakWidth: number; + peakHeight: number; +} + +export const MINIMAP_TURN_TICK: MinimapTickDims = { + baseWidth: 12, + baseHeight: 3, + peakWidth: 39, + peakHeight: 6, +}; + +/** + * Dock-style magnification: 1 under the pointer, 0 at `spread` and beyond, eased so the falloff + * reads as a curve rather than a cone. + */ +export function fisheyeFactor(distance: number, spread = MINIMAP_FISHEYE_SPREAD): number { + const t = 1 - Math.abs(distance) / spread; + return t <= 0 ? 0 : 1 - (1 - t) ** 3; +} + +export function fisheyeSize( + dims: MinimapTickDims, + factor: number, +): { width: number; height: number } { + return { + width: dims.baseWidth + (dims.peakWidth - dims.baseWidth) * factor, + height: dims.baseHeight + (dims.peakHeight - dims.baseHeight) * factor, + }; +} + +/** + * Where the rail scrolls itself so the conversation's visible turns sit centered in it. Ticks are + * evenly spaced, so a long thread outgrows the rail and the rail follows the viewport instead of + * compressing — `end` is inclusive. + */ +export function railScrollTopFor( + visible: { start: number; end: number }, + count: number, + railHeight: number, + rowHeight = MINIMAP_ROW_HEIGHT, +): number { + const overflow = count * rowHeight - railHeight; + if (overflow <= 0) return 0; + const center = ((visible.start + visible.end + 1) / 2) * rowHeight; + return clamp(center - railHeight / 2, 0, overflow); +} From 3ee89eff3027f8c5bd7fe00f92d5a8ae606ecaab Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 31 Jul 2026 19:53:51 +0800 Subject: [PATCH 4/7] feat(chat): add the conversation minimap rail One evenly spaced tick per turn in the reading column's left gutter, magnifying under the pointer, previewing the turn on hover, and jumping to it on click. Ticks are indexed rather than proportional because a virtualized row's offset is an estimate until it mounts. --- packages/presentation/i18n/src/locales/en.ts | 4 + .../presentation/i18n/src/locales/zh-cn.ts | 4 + .../ui/src/chat/conversation-minimap.tsx | 269 ++++++++++++++++++ .../ui/src/chat/conversation-view.tsx | 17 ++ 4 files changed, 294 insertions(+) create mode 100644 packages/presentation/ui/src/chat/conversation-minimap.tsx diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index a37e6b7f1..18cba893a 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -132,6 +132,10 @@ export const en = { compacting: 'Compacting context…', compacted: 'Context compacted', compactedTokens: '{pre} → {post} tokens', + minimap: { + label: 'Conversation navigation', + turn: 'Turn {index}', + }, }, tool: { input: 'Input', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index feb96c84b..1d56dcc28 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -128,6 +128,10 @@ export const zhCN = { compacting: '正在压缩上下文…', compacted: '上下文已压缩', compactedTokens: '{pre} → {post} tokens', + minimap: { + label: '对话导航', + turn: '第 {index} 轮', + }, }, tool: { input: '输入', diff --git a/packages/presentation/ui/src/chat/conversation-minimap.tsx b/packages/presentation/ui/src/chat/conversation-minimap.tsx new file mode 100644 index 000000000..139f8750e --- /dev/null +++ b/packages/presentation/ui/src/chat/conversation-minimap.tsx @@ -0,0 +1,269 @@ +import { + PreviewCard, + PreviewCardPrimitive, + PreviewCardTrigger, +} from 'coss-ui/components/preview-card'; +import { clamp } from 'foxts/clamp'; +import { useCallback, useMemo, useRef, useState } from 'react'; +import { useTranslations } from 'use-intl'; +import type { VirtualizerHandle } from 'virtua'; +import { cn } from '../lib/cn'; +import { SidePreviewCardPopup } from '../preview-card-popup'; +import { useRenderPrefs } from '../render-prefs'; +import { Markdown } from './markdown'; +import { + fisheyeFactor, + fisheyeSize, + MINIMAP_ROW_HEIGHT, + MINIMAP_TURN_TICK, + railScrollTopFor, +} from './minimap-geometry'; +import type { TurnSegment } from './turn-edits'; + +/** Beyond this many rows a smooth jump pages the whole window through the viewport. */ +const SMOOTH_JUMP_ROWS = 10; + +/** Markdown excerpt fed to the preview card; the card masks off whatever overflows. */ +const PREVIEW_BODY_CHARS = 600; + +export interface MinimapVisibleRange { + start: number; + /** Inclusive. */ + end: number; +} + +/** + * Wires the rail to the virtualized column. One callback resolves the visible range *and* scrolls + * the rail, so nothing has to watch either with an effect. + */ +export function useConversationMinimap(count: number): { + virtualizerRef: React.RefObject; + railRef: React.RefObject; + visible: MinimapVisibleRange; + onScroll: (offset: number) => void; + onSelect: (index: number) => void; +} { + const virtualizerRef = useRef(null); + const railRef = useRef(null); + const [range, setRange] = useState(null); + const { reduceMotion } = useRenderPrefs(); + + const onScroll = useCallback( + (offset: number) => { + const virtualizer = virtualizerRef.current; + if (!virtualizer) return; + const next = { + start: virtualizer.findItemIndex(offset), + end: virtualizer.findItemIndex(offset + virtualizer.viewportSize), + }; + setRange((prev) => (prev?.start === next.start && prev.end === next.end ? prev : next)); + const rail = railRef.current; + if (rail) rail.scrollTop = railScrollTopFor(next, count, rail.clientHeight); + }, + [count], + ); + + const onSelect = useCallback( + (index: number) => { + const virtualizer = virtualizerRef.current; + if (!virtualizer) return; + const from = virtualizer.findItemIndex(virtualizer.scrollOffset); + virtualizer.scrollToIndex(index, { + align: 'start', + smooth: !reduceMotion && Math.abs(index - from) <= SMOOTH_JUMP_ROWS, + }); + }, + [reduceMotion], + ); + + return { + virtualizerRef, + railRef, + onScroll, + onSelect, + // Until the first scroll every turn is on screen: true for a thread short enough never to + // scroll, and corrected immediately by the initial jump to bottom on one that does. + visible: range ?? { start: 0, end: count - 1 }, + }; +} + +function segmentText(segment: TurnSegment, role: 'user' | 'assistant'): string { + for (const item of segment.items) { + if (item.kind !== 'message' || item.role !== role) continue; + const text = item.blocks + .map((block) => (block.type === 'text' ? block.text : '')) + .join('') + .trim(); + if (text) return text; + } + return ''; +} + +export interface ConversationMinimapProps { + segments: readonly TurnSegment[]; + visible: MinimapVisibleRange; + railRef: React.RefObject; + onSelect: (index: number) => void; + className?: string; +} + +/** + * Turn navigation rail in the reading column's left gutter — one evenly spaced tick per turn, not a + * proportional minimap: under virtualization an unmounted row's offset is an estimate that shifts + * as it measures, so mapping ticks to document height makes them drift. + */ +export function ConversationMinimap({ + segments, + visible, + railRef, + onSelect, + className, +}: ConversationMinimapProps): React.ReactNode { + const t = useTranslations('workbench.conversation.minimap'); + const { reduceMotion } = useRenderPrefs(); + // One card serving every tick, so only the open turn's excerpt is ever built. Not a ref: base-ui + // reads the handle while rendering both the triggers and the card. + const handle = useMemo(() => PreviewCardPrimitive.createHandle(), []); + const listRef = useRef(null); + const tickNodesRef = useRef>([]); + const buttonNodesRef = useRef>([]); + const pointerYRef = useRef(null); + const frameRef = useRef(null); + const [focused, setFocused] = useState(0); + + const count = segments.length; + const focusIndex = Math.min(focused, count - 1); + + // Magnification is a continuous per-frame value across every tick; routing it through state + // would re-render the whole rail each frame, so the falloff is written straight to the nodes. + // Row pitch is fixed, so one rect read locates all of them. + const paint = useCallback(() => { + frameRef.current = null; + const list = listRef.current; + if (!list) return; + const pointerY = pointerYRef.current; + const listTop = list.getBoundingClientRect().top; + tickNodesRef.current.forEach((tick, index) => { + if (!tick) return; + if (pointerY === null) { + tick.style.width = ''; + tick.style.height = ''; + return; + } + const center = listTop + index * MINIMAP_ROW_HEIGHT + MINIMAP_ROW_HEIGHT / 2; + const { width, height } = fisheyeSize(MINIMAP_TURN_TICK, fisheyeFactor(center - pointerY)); + tick.style.width = `${width.toFixed(2)}px`; + tick.style.height = `${height.toFixed(2)}px`; + }); + }, []); + + const schedulePaint = useCallback( + (pointerY: number | null) => { + pointerYRef.current = pointerY; + frameRef.current ??= requestAnimationFrame(paint); + }, + [paint], + ); + + const handlePointerMove = useCallback( + (event: React.PointerEvent) => { + if (!reduceMotion) schedulePaint(event.clientY); + }, + [reduceMotion, schedulePaint], + ); + + const handlePointerLeave = useCallback(() => { + schedulePaint(null); + }, [schedulePaint]); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + const delta = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; + if (delta === 0) return; + event.preventDefault(); + const next = clamp(focusIndex + delta, 0, count - 1); + setFocused(next); + buttonNodesRef.current[next]?.focus(); + }, + [count, focusIndex], + ); + + if (count === 0) return null; + + return ( + <> + + + {({ payload }) => { + const segment = payload === undefined ? undefined : segments[payload]; + if (!segment) return null; + const title = segmentText(segment, 'user'); + const body = segmentText(segment, 'assistant').slice(0, PREVIEW_BODY_CHARS); + return ( + +

+ {title || t('turn', { index: (payload ?? 0) + 1 })} +

+ {body ? ( +
+ {body} +
+ ) : null} +
+ ); + }} +
+ + ); +} diff --git a/packages/presentation/ui/src/chat/conversation-view.tsx b/packages/presentation/ui/src/chat/conversation-view.tsx index 2a85a59d9..3334fa5b2 100644 --- a/packages/presentation/ui/src/chat/conversation-view.tsx +++ b/packages/presentation/ui/src/chat/conversation-view.tsx @@ -9,6 +9,7 @@ import { ConversationEmptyState, ConversationScrollButton, } from './conversation'; +import { ConversationMinimap, useConversationMinimap } from './conversation-minimap'; import { SubagentViewer } from './subagent-viewer'; import { partitionSubagentItems } from './subagents'; import { TurnSegmentView } from './turn-segment-view'; @@ -53,6 +54,14 @@ export function ConversationView({ awaitingAnswer, questionsByToolCall, } = useTimelineModel(conversation); + // Destructured, not held as a bag: React Compiler infers any object a ref is read off as a ref. + const { + virtualizerRef, + railRef, + visible: visibleTurns, + onScroll: onTimelineScroll, + onSelect: onSelectTurn, + } = useConversationMinimap(segments.length); if (items.length === 0) { return ( @@ -87,6 +96,8 @@ export function ConversationView({ > @@ -116,6 +127,12 @@ export function ConversationView({ /> )} + Date: Fri, 31 Jul 2026 20:48:36 +0800 Subject: [PATCH 5/7] feat(mock): seed a long thread in the dev mock host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces that only misbehave at length — the minimap rail's own scrolling, the virtualizer's windowing — were unreachable in dev without a real agent. Emitted in one burst; turn sizes vary so the rail shows an uneven rhythm. --- .../workbench/src/mock/data/long-thread.ts | 64 +++++++++++++++++++ .../workbench/src/mock/data/sessions.ts | 10 +++ .../workbench/src/mock/dev-mock-host.ts | 15 +++++ 3 files changed, 89 insertions(+) create mode 100644 packages/client/workbench/src/mock/data/long-thread.ts diff --git a/packages/client/workbench/src/mock/data/long-thread.ts b/packages/client/workbench/src/mock/data/long-thread.ts new file mode 100644 index 000000000..fb7681d11 --- /dev/null +++ b/packages/client/workbench/src/mock/data/long-thread.ts @@ -0,0 +1,64 @@ +import type { AgentEvent, MessageId } from '@linkcode/schema'; +import { textBlock } from '@linkcode/schema'; + +/** Enough turns to overflow the conversation minimap's rail, which only scrolls past ~38 of them. */ +export const LONG_THREAD_TURNS = 48; + +const SUBJECTS = [ + 'the reconnect backoff', + 'the wire envelope', + 'the fleet table query', + 'the PTY sidecar handshake', + 'the artifact viewer', + 'the approval policy', + 'the compaction marker', + 'the terminal credit window', +]; + +const ASKS = [ + 'Walk me through', + 'What breaks in', + 'Summarize', + 'Find the regression in', + 'Explain the invariant behind', +]; + +/** + * A long settled transcript, so surfaces that only misbehave at length — the minimap rail, the + * virtualizer's windowing — are reachable in dev without a real agent. Turn bodies vary in size on + * purpose: a rail whose ticks all look alike hides nothing. + */ +export function createLongThreadScript(messageId: (slug: string) => MessageId): AgentEvent[] { + const script: AgentEvent[] = [{ type: 'status', status: 'running' }]; + for (let turn = 0; turn < LONG_THREAD_TURNS; turn++) { + const subject = SUBJECTS[turn % SUBJECTS.length]; + const ask = ASKS[turn % ASKS.length]; + script.push({ + type: 'user-message', + messageId: messageId(`mock-long-user-${turn}`), + content: [textBlock(`${ask} ${subject}.`)], + }); + script.push({ + type: 'agent-message-chunk', + messageId: messageId(`mock-long-reply-${turn}`), + content: textBlock(replyBody(turn, subject)), + }); + } + script.push({ type: 'stop', stopReason: 'end_turn' }); + script.push({ type: 'status', status: 'idle' }); + return script; +} + +function replyBody(turn: number, subject: string): string { + const lines = [`Turn ${turn + 1} — notes on ${subject}.`, '']; + // Every third turn is a long one, so the rail shows an uneven, realistic rhythm. + const paragraphs = turn % 3 === 0 ? 4 : 1; + for (let i = 0; i < paragraphs; i++) { + lines.push( + `${subject} settles once the caller owns the retry budget; paragraph ${i + 1} of turn ${turn + 1} exists to give this reply real height.`, + '', + ); + } + if (turn % 4 === 0) lines.push('- one', '- two', '- three', ''); + return lines.join('\n'); +} diff --git a/packages/client/workbench/src/mock/data/sessions.ts b/packages/client/workbench/src/mock/data/sessions.ts index 5c5a3c890..5c4c52e6c 100644 --- a/packages/client/workbench/src/mock/data/sessions.ts +++ b/packages/client/workbench/src/mock/data/sessions.ts @@ -15,6 +15,8 @@ export interface SeedSession { status: SessionStatus; ageMs: number; showcase?: boolean; + /** Seeds a long settled transcript instead of the scripted showcase (see `long-thread.ts`). */ + longThread?: boolean; terminalId?: string; resources?: SeedSessionResource[]; } @@ -86,6 +88,14 @@ export const SEED_SESSIONS: SeedSession[] = [ }, ], }, + { + kind: 'claude-code', + cwd: '/mock/linkcode', + title: 'Long thread · navigation testbed', + status: 'idle', + ageMs: 8 * 60000, + longThread: true, + }, { kind: 'claude-code', cwd: '/mock/linkcode', diff --git a/packages/client/workbench/src/mock/dev-mock-host.ts b/packages/client/workbench/src/mock/dev-mock-host.ts index b95aea963..cebde602f 100644 --- a/packages/client/workbench/src/mock/dev-mock-host.ts +++ b/packages/client/workbench/src/mock/dev-mock-host.ts @@ -46,6 +46,7 @@ import { MOCK_COMMAND_CATALOG, mockCommandFixture } from './data/commands'; import { MOCK_WORKSPACE_FILES, mockFileFixture } from './data/files'; import { gitFixtureFor } from './data/git'; import { SEED_HISTORY } from './data/history'; +import { createLongThreadScript } from './data/long-thread'; import { SEED_MODEL_CATALOGS } from './data/models'; import { CHUNK_LATENCY_MS, @@ -120,6 +121,8 @@ interface MockSession extends SessionInfo { epoch: number; showcase?: boolean; showcaseSeeded?: boolean; + longThread?: boolean; + longThreadSeeded?: boolean; terminalId?: string; } @@ -310,6 +313,7 @@ export class DevMockHost { }); // Start after the list reply so the UI can subscribe before scripted frames arrive. this.startShowcase(); + this.seedLongThreads(); break; case 'session.attach': this.attachSession(p.sessionId); @@ -1158,6 +1162,17 @@ export class DevMockHost { this.sendSuccess(replyTo); } + /** Emitted in one burst, not streamed: this transcript exists to be long, not to look live. */ + private seedLongThreads(): void { + for (const session of this.sessions.values()) { + if (!session.longThread || session.longThreadSeeded) continue; + session.longThreadSeeded = true; + for (const event of createLongThreadScript((slug) => this.nextMessageId(slug))) { + this.emit(session.sessionId, event); + } + } + } + private startShowcase(): void { for (const session of this.sessions.values()) { if (!session.showcase) continue; From f4cf7830fb68076a89c21c735e50af082c2fefb2 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 31 Jul 2026 22:24:36 +0800 Subject: [PATCH 6/7] fix(chat): tune the minimap rail down to its quiet form Tightened after driving it in the desktop app: everything shrinks a notch, magnification acts on width alone (a 2px hairline swelling in height reads as a blob), off-screen turns rest at the hairline tone and no longer lift when the rail is hovered, and a thread with a single turn hides the rail entirely. --- .../chat/__tests__/minimap-geometry.test.ts | 27 ++++++++----- .../ui/src/chat/conversation-minimap.tsx | 40 +++++++++++-------- .../ui/src/chat/minimap-geometry.ts | 37 +++++------------ 3 files changed, 50 insertions(+), 54 deletions(-) diff --git a/packages/presentation/ui/src/chat/__tests__/minimap-geometry.test.ts b/packages/presentation/ui/src/chat/__tests__/minimap-geometry.test.ts index 43633a2bd..98ddd786e 100644 --- a/packages/presentation/ui/src/chat/__tests__/minimap-geometry.test.ts +++ b/packages/presentation/ui/src/chat/__tests__/minimap-geometry.test.ts @@ -1,10 +1,12 @@ +import { createFixedArray } from 'foxts/create-fixed-array'; import { describe, expect, it } from 'vitest'; import { fisheyeFactor, - fisheyeSize, + fisheyeWidth, MINIMAP_FISHEYE_SPREAD, MINIMAP_ROW_HEIGHT, - MINIMAP_TURN_TICK, + MINIMAP_TICK_BASE_WIDTH, + MINIMAP_TICK_PEAK_WIDTH, railScrollTopFor, } from '../minimap-geometry'; @@ -20,7 +22,8 @@ describe('fisheyeFactor', () => { }); it('decreases monotonically across the falloff', () => { - const samples = [0, 10, 20, 30, 40, 50, 54].map((d) => fisheyeFactor(d)); + const step = MINIMAP_FISHEYE_SPREAD / 6; + const samples = createFixedArray(7).map((_, i) => fisheyeFactor(i * step)); for (let i = 1; i < samples.length; i++) expect(samples[i]).toBeLessThan(samples[i - 1]); }); @@ -29,27 +32,29 @@ describe('fisheyeFactor', () => { }); }); -describe('fisheyeSize', () => { +describe('fisheyeWidth', () => { it('spans exactly base to peak', () => { - expect(fisheyeSize(MINIMAP_TURN_TICK, 0)).toEqual({ width: 12, height: 3 }); - expect(fisheyeSize(MINIMAP_TURN_TICK, 1)).toEqual({ width: 39, height: 6 }); + expect(fisheyeWidth(0)).toBe(MINIMAP_TICK_BASE_WIDTH); + expect(fisheyeWidth(1)).toBe(MINIMAP_TICK_PEAK_WIDTH); }); - it('interpolates both axes together', () => { - expect(fisheyeSize(MINIMAP_TURN_TICK, 0.5)).toEqual({ width: 25.5, height: 4.5 }); + it('interpolates linearly between them', () => { + expect(fisheyeWidth(0.5)).toBe((MINIMAP_TICK_BASE_WIDTH + MINIMAP_TICK_PEAK_WIDTH) / 2); }); }); describe('railScrollTopFor', () => { - const railHeight = 180; // 10 rows + const railHeight = MINIMAP_ROW_HEIGHT * 10; it('does not scroll while every turn fits', () => { expect(railScrollTopFor({ start: 0, end: 4 }, 8, railHeight)).toBe(0); }); it('centers the visible range once the rail overflows', () => { - // Rows 20..29 → center at row 25 → 25 * 18 - 90. - expect(railScrollTopFor({ start: 20, end: 29 }, 100, railHeight)).toBe(360); + // Rows 20..29 → center at row 25 → 25 pitches, less half a rail. + expect(railScrollTopFor({ start: 20, end: 29 }, 100, railHeight)).toBe( + 25 * MINIMAP_ROW_HEIGHT - railHeight / 2, + ); }); it('clamps at both ends instead of overscrolling', () => { diff --git a/packages/presentation/ui/src/chat/conversation-minimap.tsx b/packages/presentation/ui/src/chat/conversation-minimap.tsx index 139f8750e..2943044e9 100644 --- a/packages/presentation/ui/src/chat/conversation-minimap.tsx +++ b/packages/presentation/ui/src/chat/conversation-minimap.tsx @@ -13,9 +13,8 @@ import { useRenderPrefs } from '../render-prefs'; import { Markdown } from './markdown'; import { fisheyeFactor, - fisheyeSize, + fisheyeWidth, MINIMAP_ROW_HEIGHT, - MINIMAP_TURN_TICK, railScrollTopFor, } from './minimap-geometry'; import type { TurnSegment } from './turn-edits'; @@ -134,9 +133,9 @@ export function ConversationMinimap({ const count = segments.length; const focusIndex = Math.min(focused, count - 1); - // Magnification is a continuous per-frame value across every tick; routing it through state - // would re-render the whole rail each frame, so the falloff is written straight to the nodes. - // Row pitch is fixed, so one rect read locates all of them. + // A continuous per-frame value across every tick: routing it through state would re-render the + // whole rail each frame, so the falloff is written straight to the nodes. The row pitch is fixed, + // so one rect read locates all of them. const paint = useCallback(() => { frameRef.current = null; const list = listRef.current; @@ -146,14 +145,12 @@ export function ConversationMinimap({ tickNodesRef.current.forEach((tick, index) => { if (!tick) return; if (pointerY === null) { + // Back to the resting width in the class, which the transition eases into. tick.style.width = ''; - tick.style.height = ''; return; } const center = listTop + index * MINIMAP_ROW_HEIGHT + MINIMAP_ROW_HEIGHT / 2; - const { width, height } = fisheyeSize(MINIMAP_TURN_TICK, fisheyeFactor(center - pointerY)); - tick.style.width = `${width.toFixed(2)}px`; - tick.style.height = `${height.toFixed(2)}px`; + tick.style.width = `${fisheyeWidth(fisheyeFactor(center - pointerY)).toFixed(2)}px`; }); }, []); @@ -188,14 +185,17 @@ export function ConversationMinimap({ [count, focusIndex], ); - if (count === 0) return null; + // A single turn has nowhere to navigate to, so the rail would be decoration. + if (count <= 1) return null; return ( <>