From 344415892f5d1de80fe4141e4ba3dfb76d167124 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 25 Jun 2026 16:40:27 -0500 Subject: [PATCH] feat(desktop): add shared project UI primitives --- apps/desktop/components.json | 2 +- .../assistant-ui/thread-timeline.tsx | 146 ++++-- .../src/components/assistant-ui/thread.tsx | 102 +++- .../assistant-ui/tool-fallback-model.ts | 4 - .../components/assistant-ui/tool-fallback.tsx | 10 +- .../src/components/chat/diff-lines.tsx | 491 +++++++++++++++++- .../src/components/chat/fixed-row-window.ts | 155 ++++++ .../desktop/src/components/chat/skeletons.tsx | 47 ++ .../src/components/chat/status-row.tsx | 10 +- .../src/components/error-boundary.test.tsx | 114 ---- .../desktop/src/components/error-boundary.tsx | 69 --- .../src/components/pane-shell/pane-shell.tsx | 26 +- apps/desktop/src/components/ui/codicon.tsx | 11 + .../src/components/ui/color-swatches.tsx | 50 ++ apps/desktop/src/components/ui/dialog.tsx | 2 +- apps/desktop/src/components/ui/diff-count.tsx | 52 ++ apps/desktop/src/components/ui/input.tsx | 6 + apps/desktop/src/components/ui/popover.tsx | 26 +- .../src/components/ui/sanitized-input.tsx | 17 + .../src/components/ui/split-button.tsx | 98 ++++ apps/desktop/src/components/ui/textarea.tsx | 14 +- apps/desktop/src/hooks/use-delayed-true.ts | 26 + apps/desktop/src/hooks/use-worktree-info.ts | 68 --- apps/desktop/src/lib/chat-messages.ts | 3 + apps/desktop/src/lib/desktop-fs.ts | 75 ++- apps/desktop/src/lib/excluded-paths.ts | 45 ++ apps/desktop/src/lib/icons.ts | 12 +- apps/desktop/src/lib/keybinds/actions.ts | 4 + apps/desktop/src/lib/oneshot.ts | 58 +++ apps/desktop/src/lib/persisted.ts | 78 +++ apps/desktop/src/lib/pool.test.ts | 28 + apps/desktop/src/lib/pool.ts | 20 + .../desktop/src/lib/project-idea-templates.ts | 116 +++++ apps/desktop/src/lib/sanitize.test.ts | 32 ++ apps/desktop/src/lib/sanitize.ts | 21 + .../src/lib/session-branch-tree.test.ts | 53 ++ apps/desktop/src/lib/session-branch-tree.ts | 100 ++++ apps/desktop/src/lib/storage.ts | 116 +++-- 38 files changed, 1867 insertions(+), 440 deletions(-) create mode 100644 apps/desktop/src/components/chat/fixed-row-window.ts create mode 100644 apps/desktop/src/components/chat/skeletons.tsx delete mode 100644 apps/desktop/src/components/error-boundary.test.tsx create mode 100644 apps/desktop/src/components/ui/color-swatches.tsx create mode 100644 apps/desktop/src/components/ui/diff-count.tsx create mode 100644 apps/desktop/src/components/ui/sanitized-input.tsx create mode 100644 apps/desktop/src/components/ui/split-button.tsx create mode 100644 apps/desktop/src/hooks/use-delayed-true.ts delete mode 100644 apps/desktop/src/hooks/use-worktree-info.ts create mode 100644 apps/desktop/src/lib/excluded-paths.ts create mode 100644 apps/desktop/src/lib/oneshot.ts create mode 100644 apps/desktop/src/lib/persisted.ts create mode 100644 apps/desktop/src/lib/pool.test.ts create mode 100644 apps/desktop/src/lib/pool.ts create mode 100644 apps/desktop/src/lib/project-idea-templates.ts create mode 100644 apps/desktop/src/lib/sanitize.test.ts create mode 100644 apps/desktop/src/lib/sanitize.ts create mode 100644 apps/desktop/src/lib/session-branch-tree.test.ts create mode 100644 apps/desktop/src/lib/session-branch-tree.ts diff --git a/apps/desktop/components.json b/apps/desktop/components.json index 3ad19817cdd..545360ae7a2 100644 --- a/apps/desktop/components.json +++ b/apps/desktop/components.json @@ -17,5 +17,5 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "iconLibrary": "lucide" + "iconLibrary": "tabler" } diff --git a/apps/desktop/src/components/assistant-ui/thread-timeline.tsx b/apps/desktop/src/components/assistant-ui/thread-timeline.tsx index e330cb6d755..f52c27d1adb 100644 --- a/apps/desktop/src/components/assistant-ui/thread-timeline.tsx +++ b/apps/desktop/src/components/assistant-ui/thread-timeline.tsx @@ -4,7 +4,6 @@ import { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'reac import { composerPanelCard } from '@/components/chat/composer-dock' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' -import { setPaneHoverRevealSuppressed } from '@/store/panes' import { activeTimelineIndex, @@ -60,6 +59,51 @@ function userPromptText(content: unknown): string { return out } +/** Index-keyed ref-array setter — `ref={listRef(refs, i)}`. */ +const listRef = + (refs: React.RefObject<(T | null)[]>, index: number) => + (node: T | null) => { + refs.current[index] = node + } + +/** Mouse enter/leave pair forwarding `on` to the shared paint(). */ +const hoverProps = (index: number, paint: (index: number, on: boolean) => void) => ({ + onMouseEnter: () => paint(index, true), + onMouseLeave: () => paint(index, false) +}) + +// Constant-duration jump (eased), NOT native `behavior:'smooth'` — Chromium's +// smooth scroll animates proportional to distance, so jumping across a long +// thread crawls for seconds. A fixed ~260ms feels instant near or far. A +// shared rAF handle cancels a prior jump so rapid tick clicks don't fight. +let jumpRaf = 0 + +function jumpScroll(viewport: HTMLElement, top: number, duration = 170): void { + cancelAnimationFrame(jumpRaf) + const start = viewport.scrollTop + const delta = top - start + + if (Math.abs(delta) < 2) { + viewport.scrollTop = top + + return + } + + const t0 = performance.now() + const ease = (t: number) => 1 - (1 - t) ** 3 // easeOutCubic + + const step = (now: number) => { + const p = Math.min(1, (now - t0) / duration) + viewport.scrollTop = start + delta * ease(p) + + if (p < 1) { + jumpRaf = requestAnimationFrame(step) + } + } + + jumpRaf = requestAnimationFrame(step) +} + function scrollToPrompt(id: string) { const viewport = document.querySelector(VIEWPORT) const node = viewport?.querySelector(`[data-message-id="${CSS.escape(id)}"]`) @@ -71,7 +115,7 @@ function scrollToPrompt(id: string) { const top = viewport.scrollTop + (node.getBoundingClientRect().top - viewport.getBoundingClientRect().top) - 8 triggerHaptic('selection') - viewport.scrollTo({ behavior: 'smooth', top: Math.max(0, top) }) + jumpScroll(viewport, Math.max(0, top)) } /** Right-edge prompt rail — hover previews, click to jump. ≥4 user turns only. */ @@ -96,36 +140,36 @@ export const ThreadTimeline: FC = () => { ) const [activeIndex, setActiveIndex] = useState(0) - const [hoverIndex, setHoverIndex] = useState(null) const [open, setOpen] = useState(false) const closeTimerRef = useRef(undefined) + // Hover sync lives on the DOM, not in React state — the tick and its popover + // row are siblings in different subtrees, so a shared index-keyed paint() lights + // both without a re-render (and without coupling them through a parent atom). + const tickRefs = useRef<(HTMLSpanElement | null)[]>([]) + const rowRefs = useRef<(HTMLButtonElement | null)[]>([]) + + const paint = useCallback((index: number, on: boolean) => { + const tick = tickRefs.current[index] + + if (tick) { + tick.style.opacity = on ? '1' : '' + } + + rowRefs.current[index]?.classList.toggle('bg-(--ui-row-hover-background)', on) + }, []) + const keepOpen = useCallback(() => { window.clearTimeout(closeTimerRef.current) - setPaneHoverRevealSuppressed(true) setOpen(true) }, []) const closeSoon = useCallback(() => { window.clearTimeout(closeTimerRef.current) - setHoverIndex(null) - setPaneHoverRevealSuppressed(false) closeTimerRef.current = window.setTimeout(() => setOpen(false), HOVER_CLOSE_MS) }, []) - useEffect( - () => () => { - window.clearTimeout(closeTimerRef.current) - setPaneHoverRevealSuppressed(false) - }, - [] - ) - - useEffect(() => { - if (entries.length < MIN_ENTRIES) { - setPaneHoverRevealSuppressed(false) - } - }, [entries.length]) + useEffect(() => () => window.clearTimeout(closeTimerRef.current), []) useEffect(() => { const viewport = document.querySelector(VIEWPORT) @@ -179,6 +223,7 @@ export const ThreadTimeline: FC = () => { aria-label="Conversation timeline" className="group/timeline pointer-events-auto absolute right-0 top-1/2 z-40 flex -translate-y-1/2 flex-col items-end" data-slot="thread-timeline" + data-suppress-pane-reveal="" onMouseEnter={keepOpen} onMouseLeave={closeSoon} role="navigation" @@ -186,16 +231,17 @@ export const ThreadTimeline: FC = () => { ) @@ -204,11 +250,11 @@ export const ThreadTimeline: FC = () => { const TimelinePopover: FC<{ activeIndex: number entries: TimelineEntry[] - hoverIndex: number | null - onHover: (index: number) => void + onHover: (index: number, on: boolean) => void onJump: (id: string) => void open: boolean -}> = ({ activeIndex, entries, hoverIndex, onHover, onJump, open }) => ( + rowRefs: React.RefObject<(HTMLButtonElement | null)[]> +}> = ({ activeIndex, entries, onHover, onJump, open, rowRefs }) => (
- {entries.map((entry, index) => { - const hovered = index === hoverIndex - const active = index === activeIndex - - return ( - - ) - })} + {entries.map((entry, index) => ( + + ))}
) const TimelineTicks: FC<{ activeIndex: number entries: TimelineEntry[] - onHover: (index: number) => void + onHover: (index: number, on: boolean) => void onJump: (id: string) => void -}> = ({ activeIndex, entries, onHover, onJump }) => ( + tickRefs: React.RefObject<(HTMLSpanElement | null)[]> +}> = ({ activeIndex, entries, onHover, onJump, tickRefs }) => (
{entries.map((entry, index) => ( ))} diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index 6057307dec3..66bb707766b 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -11,7 +11,6 @@ import { useMessageRuntime } from '@assistant-ui/react' import { useStore } from '@nanostores/react' -import { IconPlayerStopFilled } from '@tabler/icons-react' import { type ClipboardEvent, type ComponentProps, @@ -92,7 +91,7 @@ import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runti import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { LinkifiedText } from '@/lib/external-link' import { triggerHaptic } from '@/lib/haptics' -import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon, XIcon } from '@/lib/icons' +import { GitBranchIcon, Loader2Icon, StopFilled, Volume2Icon, VolumeXIcon, XIcon } from '@/lib/icons' import { extractPreviewTargets } from '@/lib/preview-targets' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' @@ -105,6 +104,10 @@ import { notifyThreadEditClose, notifyThreadEditOpen } from '@/store/thread-scro import { $voicePlayback } from '@/store/voice-playback' type ThreadLoadingState = 'response' | 'session' +interface RestoreMessageTarget { + text: string + userOrdinal: number | null +} interface MessageActionProps { messageId: string @@ -171,7 +174,7 @@ export const Thread: FC<{ onBranchInNewChat?: (messageId: string) => void onCancel?: () => Promise | void onDismissError?: (messageId: string) => void - onRestoreToMessage?: (messageId: string) => Promise | void + onRestoreToMessage?: (messageId: string, target?: RestoreMessageTarget) => Promise | void sessionId?: string | null sessionKey?: string | null }> = ({ @@ -187,14 +190,45 @@ export const Thread: FC<{ sessionId = null, sessionKey }) => { + const { t } = useI18n() + const copy = t.assistant.thread + + const [restoreConfirmTarget, setRestoreConfirmTarget] = useState<(RestoreMessageTarget & { messageId: string }) | null>( + null + ) + + const closeRestoreConfirm = useCallback(() => setRestoreConfirmTarget(null), []) + + const confirmRestore = useCallback(() => { + if (!restoreConfirmTarget || !onRestoreToMessage) { + throw new Error('Restore is unavailable for this message.') + } + + const { messageId, text, userOrdinal } = restoreConfirmTarget + + closeRestoreConfirm() + void Promise.resolve(onRestoreToMessage(messageId, { text, userOrdinal })).catch((error: unknown) => { + notifyError(error, 'Restore failed') + }) + }, [closeRestoreConfirm, onRestoreToMessage, restoreConfirmTarget]) + + const requestRestoreConfirm = useCallback((messageId: string, target: RestoreMessageTarget) => { + setRestoreConfirmTarget({ messageId, ...target }) + }, []) + const messageComponents = useMemo( () => ({ AssistantMessage: () => , SystemMessage, UserEditComposer: () => , - UserMessage: () => + UserMessage: () => ( + + ) }), - [cwd, gateway, onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage, sessionId] + [cwd, gateway, onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage, requestRestoreConfirm, sessionId] ) const emptyPlaceholder = intro ? ( @@ -214,6 +248,15 @@ export const Thread: FC<{ /> {loading === 'session' && } +
) } @@ -844,7 +887,7 @@ const USER_ACTION_ICON_BUTTON_CLASS = 'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70' const USER_ACTION_ICON_SIZE = '0.6875rem' -const StopGlyph = +const StopGlyph = // Background-process notifications are injected into the conversation as user // messages (the agent must react to them, and message-role alternation forbids @@ -884,11 +927,10 @@ const ProcessNotificationNote: FC<{ text: string }> = ({ text }) => { const UserMessage: FC<{ onCancel?: () => Promise | void - onRestoreToMessage?: (messageId: string) => Promise | void -}> = ({ onCancel, onRestoreToMessage }) => { + onRequestRestoreConfirm?: (messageId: string, target: RestoreMessageTarget) => void +}> = ({ onCancel, onRequestRestoreConfirm }) => { const { t } = useI18n() const copy = t.assistant.thread - const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false) const messageId = useAuiState(s => s.message.id) const content = useAuiState(s => s.message.content) const messageText = messageContentText(content) @@ -906,6 +948,24 @@ const UserMessage: FC<{ return null }) + const runtimeUserOrdinal = useAuiState(s => { + let ordinal = 0 + + for (const message of s.thread.messages) { + if (message.role !== 'user') { + continue + } + + if (message.id === s.message.id) { + return ordinal + } + + ordinal += 1 + } + + return null + }) + const attachmentRefs = useAuiState(s => { const custom = (s.message.metadata?.custom ?? {}) as { attachmentRefs?: unknown } @@ -976,7 +1036,7 @@ const UserMessage: FC<{ // Restore (re-run this exact prompt) is available everywhere the Stop button // isn't — including mid-stream on older prompts, since the action interrupts // the live turn before rewinding. - const showRestore = !showStop && Boolean(onRestoreToMessage) && hasBody + const showRestore = !showStop && Boolean(onRequestRestoreConfirm) && hasBody const bubbleClassName = cn( USER_BUBBLE_BASE_CLASS, @@ -1001,7 +1061,6 @@ const UserMessage: FC<{ return ( ) : null } + messageId={messageId} >
@@ -1054,7 +1114,14 @@ const UserMessage: FC<{ event.preventDefault() event.stopPropagation() triggerHaptic('selection') - setRestoreConfirmOpen(true) + onRequestRestoreConfirm?.(messageId, { + text: messageText, + userOrdinal: runtimeUserOrdinal + }) + }} + onPointerDown={event => { + event.preventDefault() + event.stopPropagation() }} title={copy.restoreFromHere} type="button" @@ -1088,17 +1155,6 @@ const UserMessage: FC<{
- {showRestore && ( - setRestoreConfirmOpen(false)} - onConfirm={() => onRestoreToMessage?.(messageId)} - open={restoreConfirmOpen} - title={copy.restoreTitle} - /> - )}
) diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts index 5305c594f96..9a3dcee0a65 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts +++ b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts @@ -1291,10 +1291,6 @@ function toolDetailLabel(toolName: string): string { return 'Snapshot summary' } - if (toolName === 'terminal' || toolName === 'execute_code') { - return 'Command output' - } - return '' } diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx index 2d2eea54e54..599cc2fbbd5 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx @@ -71,7 +71,7 @@ const TOOL_HEADER_GLYPH_WRAP_CLASS = 'grid size-3.5 shrink-0 place-items-center // Glass-style section label that sits above any pre/JSON/output block. // Lowercase tracking + tiny size so it reads as a quiet field label rather -// than a chrome heading. Used for "COMMAND OUTPUT", "INPUT", "OUTPUT", etc. +// than a chrome heading. Used for "stdout", "stderr", "Search results", etc. const TOOL_SECTION_LABEL_CLASS = 'mb-1 text-[0.65rem] font-medium uppercase tracking-[0.08em] text-(--ui-text-tertiary)' // Inset scroll surface for any detail body. The expanded tool row owns the @@ -423,7 +423,7 @@ function ToolEntry({ part }: ToolEntryProps) { return (
)} - {view.inlineDiff && } + {view.inlineDiff && ( + + )} {showDetail && toolViewMode !== 'technical' && (view.status === 'error' ? ( diff --git a/apps/desktop/src/components/chat/diff-lines.tsx b/apps/desktop/src/components/chat/diff-lines.tsx index 767e6029c6e..5f71a4398df 100644 --- a/apps/desktop/src/components/chat/diff-lines.tsx +++ b/apps/desktop/src/components/chat/diff-lines.tsx @@ -3,8 +3,9 @@ import type { ReactNode } from 'react' import * as React from 'react' import { useShikiHighlighter } from 'react-shiki' -import type { ShikiTransformer } from 'shiki' +import { type BundledLanguage, codeToTokens, type ShikiTransformer, type ThemedToken } from 'shiki' +import { chunkLines, type LineChunk, useFixedRowWindow } from '@/components/chat/fixed-row-window' import { exceedsHighlightBudget, SHIKI_THEME } from '@/components/chat/shiki-highlighter' import { shikiLanguageForFilename } from '@/lib/markdown-code' import { cn } from '@/lib/utils' @@ -20,9 +21,20 @@ import { cn } from '@/lib/utils' */ type DiffKind = 'add' | 'context' | 'remove' -interface DiffLine { +export interface DiffLine { kind: DiffKind text: string + /** 1-based line number in the old/new file (absent on the "other" side of an + * add/remove, and on hunk-separator blanks). Only used when line numbers are + * shown (the preview's full diff). */ + newNo?: number + oldNo?: number +} + +interface ParsedHunk { + lines: Array<{ kind: DiffKind; text: string }> + newStart: number + oldStart: number } // Tint + 2px gutter accent per change kind. Text color is included for the @@ -41,12 +53,19 @@ const DIFF_KIND_TEXT: Record = { } const DIFF_LINE_BASE = 'block min-w-max whitespace-pre border-l-2 px-2.5 py-px' +const PREVIEW_DIFF_LINE_BASE = 'block h-5 min-w-max whitespace-pre px-2.5 leading-5' +const PREVIEW_CHUNK_LINES = 200 +const PREVIEW_LINE_PX = 20 +const PREVIEW_OVERSCAN_LINES = 400 // Bleed out of the tool-card body's `p-1.5` so tints/borders run flush to the // card edges (rounded corners clip via the card's overflow); compact height // with internal scroll like a code block. +// `overscroll-y-auto` so reaching the box's top/bottom hands the wheel back to +// the page (no scroll-trap); `overscroll-x-contain` keeps a trackpad's sideways +// overscroll on long code lines from firing browser back/forward navigation. const DIFF_BOX_CLASS = - '-mx-1.5 -mb-1.5 max-h-[12rem] max-w-none min-w-0 overflow-auto overscroll-contain font-mono text-[0.7rem] leading-relaxed text-(--ui-text-secondary)' + '-mx-1.5 -mb-1.5 max-h-[12rem] max-w-none min-w-0 overflow-auto overscroll-x-contain overscroll-y-auto font-mono text-[0.7rem] leading-relaxed text-(--ui-text-secondary)' function diffKind(line: string): DiffKind { if (line.startsWith('+') && !line.startsWith('+++')) { @@ -75,7 +94,16 @@ function stripDiffMarker(line: string): string { // arrow line. That preamble just repeats the path (which the tool row already // shows) and reads especially badly for absolute paths (`a//Users/…`). Strip // the leading header zone up to the first hunk. -const DIFF_HEADER_PREFIXES = ['diff --git', 'index ', '--- ', '+++ ', 'similarity ', 'rename ', 'new file', 'deleted file'] +const DIFF_HEADER_PREFIXES = [ + 'diff --git', + 'index ', + '--- ', + '+++ ', + 'similarity ', + 'rename ', + 'new file', + 'deleted file' +] function isArrowHeaderLine(line: string): boolean { const trimmed = line.trim() @@ -105,23 +133,144 @@ export function stripDiffFileHeaders(diff: string): string { return lines.slice(start).join('\n') } -// Cleaned diff → renderable lines: file-headers + `@@` hunks dropped (a blank -// separator kept between hunks), markers stripped, kind recorded. -function parseDiff(diff: string): DiffLine[] { - const out: DiffLine[] = [] - let emitted = false +function parseHunks(diff: string): ParsedHunk[] { + const hunks: ParsedHunk[] = [] + let active: null | ParsedHunk = null for (const line of stripDiffFileHeaders(diff).split('\n')) { if (line.startsWith('@@')) { - if (emitted) { - out.push({ kind: 'context', text: '' }) + const match = /@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line) + + if (!match) { + active = null + + continue } + active = { oldStart: Number(match[1]), newStart: Number(match[2]), lines: [] } + hunks.push(active) + continue } - out.push({ kind: diffKind(line), text: stripDiffMarker(line) }) - emitted = true + if (!active || line.startsWith('\\')) { + continue + } + + active.lines.push({ kind: diffKind(line), text: stripDiffMarker(line) }) + } + + return hunks +} + +// Cleaned diff → renderable lines: file-headers + `@@` hunks dropped (a blank +// separator kept between hunks), markers stripped, kind recorded. Old/new line +// numbers are tracked from each `@@ -a,b +c,d @@` header so a caller that wants +// a gutter (the preview) can render them; the blank separator carries none. +function parseDiff(diff: string): DiffLine[] { + const hunks = parseHunks(diff) + + if (hunks.length === 0) { + // Fallback for unexpected non-hunk payloads. + return stripDiffFileHeaders(diff) + .split('\n') + .map(line => ({ kind: diffKind(line), text: stripDiffMarker(line) })) + } + + const out: DiffLine[] = [] + let emitted = false + let oldNo = 1 + let newNo = 1 + + for (const hunk of hunks) { + oldNo = hunk.oldStart + newNo = hunk.newStart + + if (emitted) { + out.push({ kind: 'context', text: '' }) + } + + for (const line of hunk.lines) { + const entry: DiffLine = { kind: line.kind, text: line.text } + + if (line.kind === 'add') { + entry.newNo = newNo++ + } else if (line.kind === 'remove') { + entry.oldNo = oldNo++ + } else { + entry.oldNo = oldNo++ + entry.newNo = newNo++ + } + + out.push(entry) + emitted = true + } + } + + return out +} + +// Build a full-file diff view anchored to the CURRENT file text. Every current +// line is emitted from `fullText` with its real new-file line number; hunks only +// mark those rows as added and insert deleted rows between them. That keeps the +// preview's SOURCE and DIFF views on the same line map even when git returns +// compact hunks or removed-only rows. +function parseFullFileDiff(diff: string, fullText: string): DiffLine[] { + const hunks = parseHunks(diff) + const fullLines = fullText.split('\n') + + if (hunks.length === 0) { + return fullLines.map((text, index) => ({ kind: 'context', newNo: index + 1, oldNo: index + 1, text })) + } + + const added = new Set() + const oldNoByNewNo = new Map() + const removalsByNewNo = new Map() + const out: DiffLine[] = [] + + for (const hunk of hunks) { + let oldNo = hunk.oldStart + let newNo = hunk.newStart + + for (const line of hunk.lines) { + if (line.kind === 'add') { + added.add(newNo) + newNo += 1 + } else if (line.kind === 'remove') { + const anchor = Math.max(1, Math.min(newNo, fullLines.length + 1)) + const bucket = removalsByNewNo.get(anchor) ?? [] + + bucket.push({ kind: 'remove', oldNo, text: line.text }) + removalsByNewNo.set(anchor, bucket) + oldNo += 1 + } else { + oldNoByNewNo.set(newNo, oldNo) + oldNo += 1 + newNo += 1 + } + } + } + + for (let index = 0; index < fullLines.length; index += 1) { + const newNo = index + 1 + const removals = removalsByNewNo.get(newNo) + + if (removals) { + out.push(...removals) + } + + out.push({ + kind: added.has(newNo) ? 'add' : 'context', + newNo, + oldNo: oldNoByNewNo.get(newNo), + text: fullLines[index] ?? '' + }) + } + + const trailingRemovals = removalsByNewNo.get(fullLines.length + 1) + + if (trailingRemovals) { + out.push(...trailingRemovals) } return out @@ -142,6 +291,159 @@ function DiffBody({ lines, syntax }: { lines: DiffLine[]; syntax?: boolean }) { ) } +// shiki FontStyle is a bitmask: Italic=1, Bold=2, Underline=4. +function tokenStyle({ bgColor, color, fontStyle = 0 }: ThemedToken): React.CSSProperties | undefined { + if (!color && !bgColor && !fontStyle) { + return undefined + } + + return { + backgroundColor: bgColor, + color, + fontStyle: fontStyle & 1 ? 'italic' : undefined, + fontWeight: fontStyle & 2 ? 700 : undefined, + textDecorationLine: fontStyle & 4 ? 'underline' : undefined + } +} + +function useThemeName() { + const current = () => (document.documentElement.classList.contains('dark') ? SHIKI_THEME.dark : SHIKI_THEME.light) + const [theme, setTheme] = React.useState(current) + + React.useEffect(() => { + const observer = new MutationObserver(() => setTheme(current())) + + observer.observe(document.documentElement, { attributeFilter: ['class'], attributes: true }) + + return () => observer.disconnect() + }, []) + + return theme +} + +function PreviewDiffRows({ + afterLines = 0, + beforeLines = 0, + chunks, + tokens +}: { + afterLines?: number + beforeLines?: number + chunks: Array> + tokens?: ThemedToken[][] | null +}) { + return ( + <> + {beforeLines > 0 &&
} + {chunks.map(chunk => ( +
+ {chunk.lines.map((line, offset) => { + const index = chunk.start + offset + const rowTokens = tokens?.[index] ?? [] + + return ( + + {rowTokens.length > 0 + ? rowTokens.map((token, tokenIndex) => ( + + {token.content} + + )) + : line.text || ' '} + + ) + })} +
+ ))} + {afterLines > 0 &&
} + + ) +} + +function TokenizedDiffBody({ + afterLines, + beforeLines, + chunked = false, + chunks, + language, + lines +}: { + afterLines?: number + beforeLines?: number + chunked?: boolean + chunks?: Array> + language: string + lines: DiffLine[] +}) { + const code = React.useMemo(() => lines.map(line => line.text).join('\n'), [lines]) + const theme = useThemeName() + const [tokens, setTokens] = React.useState(null) + + React.useEffect(() => { + let cancelled = false + + setTokens(null) + void codeToTokens(code, { lang: language as BundledLanguage, theme }) + .then(result => { + if (!cancelled) { + setTokens(result.tokens) + } + }) + .catch(() => { + if (!cancelled) { + setTokens([]) + } + }) + + return () => { + cancelled = true + } + }, [code, language, theme]) + + if (!tokens) { + return chunked ? ( + + ) : ( + + ) + } + + if (chunked) { + return ( + + ) + } + + return ( + <> + {lines.map((line, index) => { + const rowTokens = tokens[index] ?? [] + + return ( + + {rowTokens.length > 0 + ? rowTokens.map((token, tokenIndex) => ( + + {token.content} + + )) + : line.text || ' '} + + ) + })} + + ) +} + // Shiki transformer: tag each `.line` with the diff tint for its kind, so the // syntax-highlighted output keeps add/remove backgrounds + the gutter accent. function diffLineTransformer(kinds: DiffKind[]): ShikiTransformer { @@ -187,19 +489,164 @@ export function DiffLines({ className, text, ...props }: DiffLinesProps) { ) } -interface FileDiffPanelProps { - diff: string - path?: string +// Coalesce consecutive same-kind changed rows into runs, each placed by line +// fraction (no DOM measurement). Context rows produce no tick. +function overviewRuns(lines: DiffLine[]): { kind: 'add' | 'remove'; sizePct: number; startPct: number }[] { + const total = lines.length || 1 + const runs: { kind: 'add' | 'remove'; sizePct: number; startPct: number }[] = [] + + for (let i = 0; i < lines.length; ) { + const kind = lines[i].kind + + if (kind === 'context') { + i += 1 + + continue + } + + let j = i + 1 + + while (j < lines.length && lines[j].kind === kind) { + j += 1 + } + + runs.push({ kind, sizePct: ((j - i) / total) * 100, startPct: (i / total) * 100 }) + i = j + } + + return runs } -export function FileDiffPanel({ diff, path }: FileDiffPanelProps) { - const lines = React.useMemo(() => parseDiff(diff), [diff]) - const language = shikiLanguageForFilename(path) - const canHighlight = Boolean(language) && !exceedsHighlightBudget(diff) +// VS Code-style overview ruler: a thin strip pinned to the diff's right edge with +// a green/red tick per change, positioned by line fraction. Pinned to the +// viewport (not the scrolled content) by living as an absolute sibling of the +// scroller inside a relative wrapper — so no scroll listener or measurement. +function DiffOverviewRuler({ lines }: { lines: DiffLine[] }) { + const runs = React.useMemo(() => overviewRuns(lines), [lines]) + + if (runs.length === 0) { + return null + } return ( -
- {canHighlight ? : } +
+ {/* Cap the tick field to the diff's natural height (rows × line px) so a + short diff renders thin, line-aligned ticks instead of stretching a few + changes into gross full-height blocks. A long diff hits the 100% cap and + compresses into a true overview. */} +
+ {runs.map((run, index) => ( +
+ ))} +
+
+ ) +} + +interface FileDiffPanelProps { + /** Override the default (tool-card) box styling — the full-height preview + * cancels the bleed/clamp so the diff fills its pane. */ + className?: string + diff: string + /** Current file text. When provided, the panel expands hunked diffs into a + * full-file view so unchanged lines are preserved between hunks. */ + fullText?: string + path?: string + /** Render an old/new line-number gutter (the full preview diff). The compact + * tool-card + inline review diff leave this off. */ + showLineNumbers?: boolean +} + +export function FileDiffPanel({ className, diff, fullText, path, showLineNumbers = false }: FileDiffPanelProps) { + const lines = React.useMemo( + () => (fullText != null ? parseFullFileDiff(diff, fullText) : parseDiff(diff)), + [diff, fullText] + ) + + const lineChunks = React.useMemo(() => chunkLines(lines, PREVIEW_CHUNK_LINES), [lines]) + + const { afterRows, beforeRows, endChunk, onScroll, scrollerRef, startChunk } = useFixedRowWindow({ + overscanRows: PREVIEW_OVERSCAN_LINES, + rowPx: PREVIEW_LINE_PX, + rowsPerChunk: PREVIEW_CHUNK_LINES, + totalRows: lines.length + }) + + const visibleLineChunks = lineChunks.slice(startChunk, endChunk + 1) + + const language = shikiLanguageForFilename(path) + const canHighlight = Boolean(language) && !exceedsHighlightBudget(fullText ?? diff) + + // Full-file preview: we own the rows (tokens rendered inside) so blank lines + // can't collapse. Compact tool/review diffs let Shiki own the rows. + const body = !canHighlight ? ( + showLineNumbers ? ( + + ) : ( + + ) + ) : fullText != null ? ( + + ) : ( + + ) + + if (!showLineNumbers) { + return ( +
+ {body} +
+ ) + } + + // A single line-number gutter (VS Code's inline-diff style): each row shows its + // own file's number — the new number for context/adds, the old number for + // removals — with an overview ruler pinned to the right edge. The inner div + // owns the scroll so the ruler (an absolute sibling) stays viewport-fixed. + return ( +
+
+
+
+ {beforeRows > 0 && ( +
+ )} + {visibleLineChunks.map(chunk => ( +
+ {chunk.lines.map((line, offset) => { + const index = chunk.start + offset + + return ( +
+ {line.newNo ?? ''} +
+ ) + })} +
+ ))} + {afterRows > 0 &&
} +
+
{body}
+
+
+
) } diff --git a/apps/desktop/src/components/chat/fixed-row-window.ts b/apps/desktop/src/components/chat/fixed-row-window.ts new file mode 100644 index 00000000000..93e1eefebdc --- /dev/null +++ b/apps/desktop/src/components/chat/fixed-row-window.ts @@ -0,0 +1,155 @@ +import type { RefObject, UIEvent } from 'react' +import { useCallback, useLayoutEffect, useRef, useState } from 'react' + +export interface LineChunk { + lines: T[] + start: number +} + +export interface TextLineChunk extends LineChunk { + text: string +} + +interface FixedRowWindowOptions { + overscanRows: number + rowPx: number + rowsPerChunk: number + totalRows: number +} + +export interface FixedRowWindow { + afterRows: number + beforeRows: number + endChunk: number + onScroll: (event: UIEvent) => void + scrollerRef: RefObject + startChunk: number +} + +export function chunkLines(lines: T[], perChunk: number): Array> { + if (lines.length <= perChunk) { + return [{ lines, start: 0 }] + } + + const chunks: Array> = [] + + for (let start = 0; start < lines.length; start += perChunk) { + chunks.push({ lines: lines.slice(start, start + perChunk), start }) + } + + return chunks +} + +export function chunkTextLines(text: string, perChunk: number): TextLineChunk[] { + return chunkLines(text.split('\n'), perChunk).map(chunk => ({ + ...chunk, + text: chunk.lines.join('\n') + })) +} + +type ChunkWindow = Pick + +export function useFixedRowWindow({ + overscanRows, + rowPx, + rowsPerChunk, + totalRows +}: FixedRowWindowOptions): FixedRowWindow { + const scrollerRef = useRef(null) + const rafRef = useRef(null) + + // Derive the visible chunk window from a node's scroll geometry. Pure so we + // can compare results and skip a re-render unless the window actually moved. + const compute = useCallback( + (node: HTMLDivElement | null): ChunkWindow => { + const height = node?.clientHeight || 800 + const scrollTop = node?.scrollTop ?? 0 + const firstRow = Math.max(0, Math.floor(scrollTop / rowPx) - overscanRows) + const lastRow = Math.min(totalRows, Math.ceil((scrollTop + height) / rowPx) + overscanRows) + const startChunk = Math.floor(firstRow / rowsPerChunk) + const endChunk = Math.max(startChunk, Math.floor(Math.max(firstRow, lastRow - 1) / rowsPerChunk)) + + return { + afterRows: Math.max(0, totalRows - Math.min(totalRows, (endChunk + 1) * rowsPerChunk)), + beforeRows: Math.min(totalRows, startChunk * rowsPerChunk), + endChunk, + startChunk + } + }, + [overscanRows, rowPx, rowsPerChunk, totalRows] + ) + + const [win, setWin] = useState(() => compute(null)) + + // Only commit a new window when a boundary is crossed — scrolling within the + // current chunk span (the common case, every rAF) keeps the same object and + // re-renders nothing. + const sync = useCallback( + (node: HTMLDivElement | null = scrollerRef.current) => { + if (!node) { + return + } + + const next = compute(node) + + setWin(prev => + prev.startChunk === next.startChunk && + prev.endChunk === next.endChunk && + prev.beforeRows === next.beforeRows && + prev.afterRows === next.afterRows + ? prev + : next + ) + }, + [compute] + ) + + const cancelFrame = useCallback(() => { + if (rafRef.current == null) { + return + } + + cancelAnimationFrame(rafRef.current) + rafRef.current = null + }, []) + + const onScroll = useCallback( + (event: UIEvent) => { + const node = event.currentTarget + + cancelFrame() + rafRef.current = requestAnimationFrame(() => { + rafRef.current = null + sync(node) + }) + }, + [cancelFrame, sync] + ) + + // Re-sync on mount, on resize, and whenever the row geometry changes (new + // file/diff → `compute` identity changes → effect re-runs). + useLayoutEffect(() => { + const node = scrollerRef.current + + if (!node) { + return + } + + sync(node) + + if (typeof ResizeObserver === 'undefined') { + return cancelFrame + } + + const observer = new ResizeObserver(() => sync(node)) + + observer.observe(node) + + return () => { + observer.disconnect() + cancelFrame() + } + }, [cancelFrame, sync]) + + return { ...win, onScroll, scrollerRef } +} diff --git a/apps/desktop/src/components/chat/skeletons.tsx b/apps/desktop/src/components/chat/skeletons.tsx new file mode 100644 index 00000000000..b6dcfdab341 --- /dev/null +++ b/apps/desktop/src/components/chat/skeletons.tsx @@ -0,0 +1,47 @@ +import type { CSSProperties } from 'react' + +import { Skeleton } from '@/components/ui/skeleton' + +// Shared loading skeletons for the file/git trees and diffs — quieter than a +// spinner and shaped like the content that's about to land. + +const TREE_ROWS: { indent: number; width: string }[] = [ + { indent: 0, width: '55%' }, + { indent: 1, width: '72%' }, + { indent: 1, width: '46%' }, + { indent: 0, width: '60%' }, + { indent: 1, width: '52%' }, + { indent: 2, width: '40%' }, + { indent: 0, width: '64%' } +] + +/** Rows of icon + label bars, mimicking a file tree mid-load. */ +export function TreeSkeleton() { + return ( +
+ {TREE_ROWS.map((row, index) => ( +
+ + +
+ ))} +
+ ) +} + +const DIFF_ROWS: string[] = ['72%', '40%', '88%', '55%', '64%', '30%', '80%', '48%', '60%', '36%', '70%'] + +/** Stacked line bars, mimicking a unified diff mid-load. */ +export function DiffSkeleton({ style }: { style?: CSSProperties }) { + return ( +
+ {DIFF_ROWS.map((width, index) => ( + + ))} +
+ ) +} diff --git a/apps/desktop/src/components/chat/status-row.tsx b/apps/desktop/src/components/chat/status-row.tsx index ad4769c458f..af77fc910ce 100644 --- a/apps/desktop/src/components/chat/status-row.tsx +++ b/apps/desktop/src/components/chat/status-row.tsx @@ -1,4 +1,4 @@ -import { type ReactNode } from 'react' +import { type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import { cn } from '@/lib/utils' @@ -8,8 +8,10 @@ interface StatusRowProps { /** Leading glyph slot (spinner / status dot / selection circle). */ leading?: ReactNode /** Makes the whole row activatable (adds `cursor-pointer` + keyboard a11y). - * Trailing-slot buttons should `stopPropagation` so they don't also fire it. */ - onActivate?: () => void + * Receives the originating event so consumers can branch on modifier keys + * (e.g. ⌘/Ctrl-click). Trailing-slot buttons should `stopPropagation` so + * they don't also fire it. */ + onActivate?: (event: KeyboardEvent | MouseEvent) => void /** Right-aligned actions. Revealed on row hover/focus unless `trailingVisible`. */ trailing?: ReactNode trailingVisible?: boolean @@ -43,7 +45,7 @@ export function StatusRow({ ? event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() - onActivate() + onActivate(event) } } : undefined diff --git a/apps/desktop/src/components/error-boundary.test.tsx b/apps/desktop/src/components/error-boundary.test.tsx deleted file mode 100644 index cd5f3a547bb..00000000000 --- a/apps/desktop/src/components/error-boundary.test.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { cleanup, render, screen, waitFor } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' - -import { ErrorBoundary } from './error-boundary' - -// The real assistant-ui stale-index throw the root boundary must survive -// (open chat / session switch render race), reproduced verbatim so the -// recoverable-pattern match is exercised against the actual error text. -const TAP_ERROR = 'tapClientLookup: Index 23 out of bounds (length: 18)' - -// Throws purely from `box.error` so a render replay (React dev) throws -// identically; the test mutates the box only from timers, never during render — -// modelling a transient race that clears once the boundary remounts against -// fresh state. -function makeBomb(box: { error: Error | null }) { - return function Bomb() { - if (box.error) { - throw box.error - } - - return
recovered
- } -} - -const RELOAD_WINDOW = { name: 'Reload window', role: 'button' } as const - -const countRecoverWarnings = (calls: unknown[][]) => - calls.filter(call => call.some(value => String(value).includes('auto-recovering from transient render error'))).length - -describe('ErrorBoundary root auto-recovery', () => { - afterEach(() => { - cleanup() - vi.restoreAllMocks() - }) - - it('recovers the root boundary from a transient stale-index render race', async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const box: { error: Error | null } = { error: new Error(TAP_ERROR) } - const Bomb = makeBomb(box) - - // Disarm before the scheduled next-tick reset re-renders the subtree, so the - // race genuinely resolves on recovery instead of throwing forever. - queueMicrotask(() => { - box.error = null - }) - - render( - - - - ) - - await waitFor(() => expect(screen.getByText('recovered')).toBeTruthy()) - expect(screen.queryByRole(RELOAD_WINDOW.role, { name: RELOAD_WINDOW.name })).toBeNull() - expect(countRecoverWarnings(warnSpy.mock.calls)).toBeGreaterThanOrEqual(1) - }) - - it('stops auto-recovering a persistent error after the cap and leaves the fallback up', async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - // Never disarmed: the boundary must not spin a reset -> throw -> reset loop - // forever — it caps recovery and surfaces the fallback to the user. - const box: { error: Error | null } = { error: new Error(TAP_ERROR) } - const Bomb = makeBomb(box) - - render( - - - - ) - - // The fallback showing up at all IS the cap working: with unbounded recovery - // the boundary would reset -> throw -> reset forever and 'Reload window' - // would never render (this waitFor would hang). The recovery attempts are - // bounded by MAX_RECOVERIES (3), never an unbounded storm. - await waitFor(() => expect(screen.getByRole(RELOAD_WINDOW.role, { name: RELOAD_WINDOW.name })).toBeTruthy()) - const warnings = countRecoverWarnings(warnSpy.mock.calls) - expect(warnings).toBeGreaterThanOrEqual(1) - expect(warnings).toBeLessThanOrEqual(3) - }) - - it('does not auto-recover a non-root boundary', async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const box: { error: Error | null } = { error: new Error(TAP_ERROR) } - const Bomb = makeBomb(box) - - render( -
scoped-fallback
} label="thread"> - -
- ) - - await waitFor(() => expect(screen.getByText('scoped-fallback')).toBeTruthy()) - expect(countRecoverWarnings(warnSpy.mock.calls)).toBe(0) - }) - - it('does not auto-recover an unrecognized error even at the root', async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const box: { error: Error | null } = { error: new Error('some unrelated application error') } - const Bomb = makeBomb(box) - - render( - - - - ) - - await waitFor(() => expect(screen.getByRole(RELOAD_WINDOW.role, { name: RELOAD_WINDOW.name })).toBeTruthy()) - expect(countRecoverWarnings(warnSpy.mock.calls)).toBe(0) - }) -}) diff --git a/apps/desktop/src/components/error-boundary.tsx b/apps/desktop/src/components/error-boundary.tsx index f607760427f..87b6b7743c5 100644 --- a/apps/desktop/src/components/error-boundary.tsx +++ b/apps/desktop/src/components/error-boundary.tsx @@ -20,31 +20,8 @@ interface ErrorBoundaryState { error: Error | null } -// assistant-ui can momentarily render a stale message index against a thread -// that just shrank (session switch / teardown), throwing a render-race error -// that latches the WHOLE app on the root "Reload window" screen. These throws -// clear themselves on the next render against fresh state, so the root boundary -// recovers itself once the storm settles instead of stranding the user. -const RECOVERABLE_ERROR_PATTERNS = [ - /tapClientLookup: Index \d+\s+out of bounds \(length:\s*\d+\)/i, - /Cannot read properties of undefined \(reading 'type'\)/i, - /Tried to unmount a fiber that is already unmounted/i -] - -const isRecoverableDesktopRenderError = (error: Error): boolean => - RECOVERABLE_ERROR_PATTERNS.some(pattern => pattern.test(error.message)) - -// Bound auto-recovery so a *persistent* (non-transient) error can't spin the -// boundary in a reset -> throw -> reset loop: at most MAX_RECOVERIES attempts -// inside RECOVERY_WINDOW_MS, after which the fallback is left up for the user. -const MAX_RECOVERIES = 3 -const RECOVERY_WINDOW_MS = 5_000 - export class ErrorBoundary extends Component { state: ErrorBoundaryState = { error: null } - private recoverTimer: null | number = null - private recoverCount = 0 - private recoverWindowStart = 0 static getDerivedStateFromError(error: Error): ErrorBoundaryState { return { error } @@ -54,55 +31,9 @@ export class ErrorBoundary extends Component { - this.clearRecoverTimer() - // A manual retry (button) starts a clean recovery budget. - this.recoverCount = 0 - this.recoverWindowStart = 0 - this.setState({ error: null }) - } - - // True while the boundary still has recovery budget. Each storm gets a fresh - // window; auto-recovery (autoReset) deliberately does NOT reset the count, so - // a tight reset -> throw loop is capped at MAX_RECOVERIES and then falls back. - private canRecover(): boolean { - const now = Date.now() - - if (now - this.recoverWindowStart > RECOVERY_WINDOW_MS) { - this.recoverWindowStart = now - this.recoverCount = 0 - } - - this.recoverCount += 1 - - return this.recoverCount <= MAX_RECOVERIES - } - - private clearRecoverTimer() { - if (this.recoverTimer !== null) { - window.clearTimeout(this.recoverTimer) - this.recoverTimer = null - } - } - - private scheduleRecover() { - this.clearRecoverTimer() - this.recoverTimer = window.setTimeout(this.autoReset, 0) - } - - private autoReset = () => { - this.recoverTimer = null this.setState({ error: null }) } diff --git a/apps/desktop/src/components/pane-shell/pane-shell.tsx b/apps/desktop/src/components/pane-shell/pane-shell.tsx index 804d560880c..25ca6d03e41 100644 --- a/apps/desktop/src/components/pane-shell/pane-shell.tsx +++ b/apps/desktop/src/components/pane-shell/pane-shell.tsx @@ -15,7 +15,7 @@ import { } from 'react' import { cn } from '@/lib/utils' -import { $paneHoverRevealSuppressed, $paneStates, ensurePaneRegistered, setPaneWidthOverride } from '@/store/panes' +import { $paneStates, ensurePaneRegistered, setPaneWidthOverride } from '@/store/panes' import { PaneShellContext, type PaneShellContextValue, type PaneSlot } from './context' @@ -38,6 +38,8 @@ export interface PaneProps { forceCollapsed?: boolean /** When collapsed, float the contents over the main column on hover/focus instead of hiding them (track stays 0px). */ hoverReveal?: boolean + /** Width of the collapsed-overlay panel. Defaults to the docked width (or its resize override); set this to render a narrower overlay than the docked pane (e.g. min width on mobile). */ + overlayWidth?: WidthValue /** Called with true while the pane is a collapsed hover-reveal overlay, so the consumer can keep contents mounted (ready to slide). */ onOverlayActiveChange?: (overlayActive: boolean) => void id: string @@ -227,7 +229,7 @@ export function PaneShell({ children, className, style }: PaneShellProps) { return ( -
+
{children}
@@ -241,6 +243,7 @@ export function Pane({ divider = false, disabled = false, hoverReveal = false, + overlayWidth: overlayWidthProp, id, maxWidth, minWidth, @@ -250,7 +253,6 @@ export function Pane({ }: PaneProps) { const ctx = useContext(PaneShellContext) const paneStates = useStore($paneStates) - const hoverRevealSuppressed = useStore($paneHoverRevealSuppressed) const registered = useRef(false) const paneRef = useRef(null) // Keyboard (mod+b / mod+j) pins the reveal open while collapsed; hover is CSS. @@ -263,7 +265,14 @@ export function Pane({ // hover/focus instead of hiding them. Honors any persisted resize width. const overlayActive = !open && hoverReveal && !disabled const override = resizable ? paneStates[id]?.widthOverride : undefined - const overlayWidth = override !== undefined ? `${override}px` : widthToCss(width, DEFAULT_WIDTH) + // Overlay width: an explicit `overlayWidth` (e.g. min width on mobile) wins, + // else the persisted resize override, else the docked width. + const overlayWidth = + overlayWidthProp !== undefined + ? widthToCss(overlayWidthProp, DEFAULT_WIDTH) + : override !== undefined + ? `${override}px` + : widthToCss(width, DEFAULT_WIDTH) useEffect(() => { if (registered.current) { @@ -379,10 +388,8 @@ export function Pane({ >