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; 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/__tests__/minimap-geometry.test.ts b/packages/presentation/ui/src/chat/__tests__/minimap-geometry.test.ts new file mode 100644 index 000000000..e92bdd280 --- /dev/null +++ b/packages/presentation/ui/src/chat/__tests__/minimap-geometry.test.ts @@ -0,0 +1,77 @@ +import { createFixedArray } from 'foxts/create-fixed-array'; +import { describe, expect, it } from 'vitest'; +import { + fisheyeFactor, + fisheyeScaleX, + fisheyeWidth, + MINIMAP_FISHEYE_SPREAD, + MINIMAP_ROW_HEIGHT, + MINIMAP_TICK_BASE_WIDTH, + MINIMAP_TICK_PEAK_WIDTH, + 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 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]); + }); + + it('eases out — the near half of the falloff keeps most of the magnification', () => { + expect(fisheyeFactor(MINIMAP_FISHEYE_SPREAD / 2)).toBeGreaterThan(0.5); + }); +}); + +describe('fisheyeWidth', () => { + it('spans exactly base to peak', () => { + expect(fisheyeWidth(0)).toBe(MINIMAP_TICK_BASE_WIDTH); + expect(fisheyeWidth(1)).toBe(MINIMAP_TICK_PEAK_WIDTH); + }); + + it('interpolates linearly between them', () => { + expect(fisheyeWidth(0.5)).toBe((MINIMAP_TICK_BASE_WIDTH + MINIMAP_TICK_PEAK_WIDTH) / 2); + }); +}); + +describe('fisheyeScaleX', () => { + it('leaves a resting tick untransformed', () => { + expect(fisheyeScaleX(0)).toBe(1); + }); + + it('reaches the peak width without the box changing size', () => { + expect(fisheyeScaleX(1) * MINIMAP_TICK_BASE_WIDTH).toBe(MINIMAP_TICK_PEAK_WIDTH); + }); +}); + +describe('railScrollTopFor', () => { + 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 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', () => { + 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/conversation-minimap.tsx b/packages/presentation/ui/src/chat/conversation-minimap.tsx new file mode 100644 index 000000000..ea541004b --- /dev/null +++ b/packages/presentation/ui/src/chat/conversation-minimap.tsx @@ -0,0 +1,277 @@ +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 { useInputModality } from '../input-modality'; +import { cn } from '../lib/cn'; +import { SidePreviewCardPopup } from '../preview-card-popup'; +import { useRenderPrefs } from '../render-prefs'; +import { Markdown } from './markdown'; +import { + fisheyeFactor, + fisheyeScaleX, + MINIMAP_ROW_HEIGHT, + 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 modality = useInputModality(); + + 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', + // Enter and Space reach this through the same click, and a keyed jump repeats far too often + // to animate. + smooth: + modality === 'pointer' && !reduceMotion && Math.abs(index - from) <= SMOOTH_JUMP_ROWS, + }); + }, + [modality, 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); + + // 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; + if (!list) return; + const pointerY = pointerYRef.current; + const listTop = list.getBoundingClientRect().top; + tickNodesRef.current.forEach((tick, index) => { + if (!tick) return; + if (pointerY === null) { + // Back to the resting size in the class, which the transition eases into. + tick.style.transform = ''; + return; + } + const center = listTop + index * MINIMAP_ROW_HEIGHT + MINIMAP_ROW_HEIGHT / 2; + tick.style.transform = `scaleX(${fisheyeScaleX(fisheyeFactor(center - pointerY)).toFixed(3)})`; + }); + }, []); + + 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(); + // `onFocus` carries the roving index, so a tick focused by a click stays the arrows' origin. + buttonNodesRef.current[clamp(focusIndex + delta, 0, count - 1)]?.focus(); + }, + [count, focusIndex], + ); + + // A single turn has nowhere to navigate to, so the rail would be decoration. + if (count <= 1) 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({ /> )} + ; 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} 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..5eb3e377a --- /dev/null +++ b/packages/presentation/ui/src/chat/minimap-geometry.ts @@ -0,0 +1,51 @@ +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 = 14; + +/** Resting and fully magnified tick width. Height never changes — a 2px hairline swelling into a + * 5px lozenge reads as a blob, where width alone reads as the line extending. */ +export const MINIMAP_TICK_BASE_WIDTH = 8; +export const MINIMAP_TICK_PEAK_WIDTH = 28; + +/** Pointer distance at which magnification reaches zero — three rows either side. */ +export const MINIMAP_FISHEYE_SPREAD = 42; + +/** + * Dock-style magnification: 1 under the pointer, 0 at `spread` and beyond, eased so neighbours fall + * away along 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 fisheyeWidth(factor: number): number { + return MINIMAP_TICK_BASE_WIDTH + (MINIMAP_TICK_PEAK_WIDTH - MINIMAP_TICK_BASE_WIDTH) * factor; +} + +/** + * The same stretch as a horizontal scale on a resting tick. The rail animates `transform`, never + * `width`: a tick's box has to stay fixed, or the fit-content column it sits in widens under the + * pointer and drags every other tick sideways. + */ +export function fisheyeScaleX(factor: number): number { + return fisheyeWidth(factor) / MINIMAP_TICK_BASE_WIDTH; +} + +/** + * 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); +} 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 && (