feat(desktop): add shared project UI primitives

This commit is contained in:
Brooklyn Nicholson 2026-06-25 16:40:27 -05:00
parent e2b8018729
commit 344415892f
38 changed files with 1867 additions and 440 deletions

View file

@ -17,5 +17,5 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
"iconLibrary": "tabler"
}

View file

@ -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 =
<T,>(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<HTMLElement>(VIEWPORT)
const node = viewport?.querySelector<HTMLElement>(`[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<number | null>(null)
const [open, setOpen] = useState(false)
const closeTimerRef = useRef<number | undefined>(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<HTMLElement>(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 = () => {
<TimelineTicks
activeIndex={activeIndex}
entries={entries}
onHover={setHoverIndex}
onHover={paint}
onJump={scrollToPrompt}
tickRefs={tickRefs}
/>
<TimelinePopover
activeIndex={activeIndex}
entries={entries}
hoverIndex={hoverIndex}
onHover={setHoverIndex}
onHover={paint}
onJump={scrollToPrompt}
open={open}
rowRefs={rowRefs}
/>
</div>
)
@ -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 }) => (
<div
className={cn(
POPOVER_SHELL,
@ -216,55 +262,49 @@ const TimelinePopover: FC<{
)}
data-slot="thread-timeline-popover"
>
{entries.map((entry, index) => {
const hovered = index === hoverIndex
const active = index === activeIndex
return (
<button
aria-label={entry.preview}
className={cn(
ROW_CLASS,
active && 'bg-(--ui-row-active-background) text-foreground',
hovered && 'bg-(--ui-row-hover-background) text-foreground transition-none'
)}
key={entry.id}
onClick={() => onJump(entry.id)}
onMouseEnter={() => onHover(index)}
type="button"
>
<span className="block w-full min-w-0 truncate font-medium leading-snug text-foreground">
{entry.preview}
</span>
</button>
)
})}
{entries.map((entry, index) => (
<button
aria-label={entry.preview}
className={cn(ROW_CLASS, index === activeIndex && 'bg-(--ui-row-active-background) text-foreground')}
key={entry.id}
onClick={() => onJump(entry.id)}
ref={listRef(rowRefs, index)}
type="button"
{...hoverProps(index, onHover)}
>
<span className="block w-full min-w-0 truncate font-medium leading-snug text-foreground">
{entry.preview}
</span>
</button>
))}
</div>
)
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 }) => (
<div className="flex flex-col items-end py-1" data-slot="thread-timeline-ticks">
{entries.map((entry, index) => (
<button
aria-label={entry.preview}
className="group/tick flex h-2 w-7 cursor-pointer items-center justify-end pr-1"
className="flex h-2 w-7 cursor-pointer items-center justify-end pr-1"
key={entry.id}
onClick={() => onJump(entry.id)}
onMouseEnter={() => onHover(index)}
type="button"
{...hoverProps(index, onHover)}
>
<span
className={cn(
'block h-px w-3 transition-opacity duration-100 ease-out',
index === activeIndex
? 'bg-(--theme-primary)'
: 'dither text-(--ui-text-quaternary) opacity-70 group-hover/tick:opacity-100 group-hover/tick:transition-none'
: 'dither text-(--ui-text-quaternary) opacity-70'
)}
ref={listRef(tickRefs, index)}
/>
</button>
))}

View file

@ -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> | void
onDismissError?: (messageId: string) => void
onRestoreToMessage?: (messageId: string) => Promise<void> | void
onRestoreToMessage?: (messageId: string, target?: RestoreMessageTarget) => Promise<void> | 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: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} onDismissError={onDismissError} />,
SystemMessage,
UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />,
UserMessage: () => <UserMessage onCancel={onCancel} onRestoreToMessage={onRestoreToMessage} />
UserMessage: () => (
<UserMessage
onCancel={onCancel}
onRequestRestoreConfirm={onRestoreToMessage ? requestRestoreConfirm : undefined}
/>
)
}),
[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' && <CenteredThreadSpinner />}
<ThreadTimeline />
<ConfirmDialog
confirmLabel={copy.restoreConfirm}
description={copy.restoreBody}
destructive
onClose={closeRestoreConfirm}
onConfirm={confirmRestore}
open={Boolean(restoreConfirmTarget)}
title={copy.restoreTitle}
/>
</div>
)
}
@ -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 = <IconPlayerStopFilled aria-hidden className="size-3.5 -translate-y-px" />
const StopGlyph = <StopFilled aria-hidden className="size-3.5 -translate-y-px" />
// 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> | void
onRestoreToMessage?: (messageId: string) => Promise<void> | 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 (
<MessagePrimitive.Root asChild>
<StickyHumanMessageContainer
messageId={messageId}
attachments={
// Attachments live BELOW the sticky bubble in normal flow, so they
// scroll away behind the pinned bubble instead of riding along with
@ -1012,6 +1071,7 @@ const UserMessage: FC<{
</div>
) : null
}
messageId={messageId}
>
<ActionBarPrimitive.Root className="relative w-full max-w-full" data-slot="aui_user-bubble-actions">
<div className="human-message-with-todos-wrapper flex w-full flex-col gap-0">
@ -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<{
</BranchPickerPrimitive.Root>
</div>
</ActionBarPrimitive.Root>
{showRestore && (
<ConfirmDialog
confirmLabel={copy.restoreConfirm}
description={copy.restoreBody}
destructive
onClose={() => setRestoreConfirmOpen(false)}
onConfirm={() => onRestoreToMessage?.(messageId)}
open={restoreConfirmOpen}
title={copy.restoreTitle}
/>
)}
</StickyHumanMessageContainer>
</MessagePrimitive.Root>
)

View file

@ -1291,10 +1291,6 @@ function toolDetailLabel(toolName: string): string {
return 'Snapshot summary'
}
if (toolName === 'terminal' || toolName === 'execute_code') {
return 'Command output'
}
return ''
}

View file

@ -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 (
<div
className={cn(
'min-w-0 max-w-full overflow-hidden text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)',
'group/tool-block min-w-0 max-w-full overflow-hidden text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)',
open && TOOL_EXPANDED_SHELL_CLASS
)}
data-file-edit={isFileEdit && open ? '' : undefined}
@ -472,7 +472,7 @@ function ToolEntry({ part }: ToolEntryProps) {
{copyAction.text && (
<CopyButton
appearance="inline"
className="absolute right-1.5 top-1.5 z-10 h-5 gap-0 rounded-md border border-(--ui-stroke-tertiary) bg-background/80 px-1 opacity-100 backdrop-blur-sm transition-opacity hover:opacity-100 focus-visible:opacity-100"
className="absolute right-1.5 top-1.5 z-10 h-5 gap-0 rounded-md px-1 opacity-5 transition-opacity group-hover/tool-block:opacity-100 hover:opacity-100 focus-visible:opacity-100"
iconClassName="size-3"
label={copyAction.label}
showLabel={false}
@ -491,7 +491,9 @@ function ToolEntry({ part }: ToolEntryProps) {
<SearchResultsList hits={view.searchHits} />
</div>
)}
{view.inlineDiff && <FileDiffPanel diff={view.inlineDiff} path={isFileEdit ? view.subtitle : undefined} />}
{view.inlineDiff && (
<FileDiffPanel className="-mt-1.5" diff={view.inlineDiff} path={isFileEdit ? view.subtitle : undefined} />
)}
{showDetail &&
toolViewMode !== 'technical' &&
(view.status === 'error' ? (

View file

@ -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<DiffKind, string> = {
}
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<number>()
const oldNoByNewNo = new Map<number, number>()
const removalsByNewNo = new Map<number, DiffLine[]>()
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<LineChunk<DiffLine>>
tokens?: ThemedToken[][] | null
}) {
return (
<>
{beforeLines > 0 && <div aria-hidden style={{ height: beforeLines * PREVIEW_LINE_PX }} />}
{chunks.map(chunk => (
<div className="block" key={chunk.start}>
{chunk.lines.map((line, offset) => {
const index = chunk.start + offset
const rowTokens = tokens?.[index] ?? []
return (
<span className={cn(PREVIEW_DIFF_LINE_BASE, DIFF_KIND_TINT[line.kind])} key={`${index}-${line.text}`}>
{rowTokens.length > 0
? rowTokens.map((token, tokenIndex) => (
<span key={`${tokenIndex}-${token.offset}`} style={tokenStyle(token)}>
{token.content}
</span>
))
: line.text || ' '}
</span>
)
})}
</div>
))}
{afterLines > 0 && <div aria-hidden style={{ height: afterLines * PREVIEW_LINE_PX }} />}
</>
)
}
function TokenizedDiffBody({
afterLines,
beforeLines,
chunked = false,
chunks,
language,
lines
}: {
afterLines?: number
beforeLines?: number
chunked?: boolean
chunks?: Array<LineChunk<DiffLine>>
language: string
lines: DiffLine[]
}) {
const code = React.useMemo(() => lines.map(line => line.text).join('\n'), [lines])
const theme = useThemeName()
const [tokens, setTokens] = React.useState<ThemedToken[][] | null>(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 ? (
<PreviewDiffRows
afterLines={afterLines}
beforeLines={beforeLines}
chunks={chunks ?? chunkLines(lines, PREVIEW_CHUNK_LINES)}
/>
) : (
<DiffBody lines={lines} />
)
}
if (chunked) {
return (
<PreviewDiffRows
afterLines={afterLines}
beforeLines={beforeLines}
chunks={chunks ?? chunkLines(lines, PREVIEW_CHUNK_LINES)}
tokens={tokens}
/>
)
}
return (
<>
{lines.map((line, index) => {
const rowTokens = tokens[index] ?? []
return (
<span className={cn(PREVIEW_DIFF_LINE_BASE, DIFF_KIND_TINT[line.kind])} key={`${index}-${line.text}`}>
{rowTokens.length > 0
? rowTokens.map((token, tokenIndex) => (
<span key={`${tokenIndex}-${token.offset}`} style={tokenStyle(token)}>
{token.content}
</span>
))
: line.text || ' '}
</span>
)
})}
</>
)
}
// 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 (
<div className={DIFF_BOX_CLASS} data-slot="file-diff-panel">
{canHighlight ? <SyntaxDiff language={language} lines={lines} /> : <DiffBody lines={lines} />}
<div aria-hidden className="pointer-events-none absolute top-0 right-0 bottom-0 w-1.5 opacity-80">
{/* 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. */}
<div
className="relative w-full"
style={{ height: `min(100%, ${lines.length * PREVIEW_LINE_PX}px)` }}
>
{runs.map((run, index) => (
<div
className={cn('absolute inset-x-0', run.kind === 'add' ? 'bg-(--ui-green)' : 'bg-(--ui-red)')}
key={index}
style={{ height: `max(0.125rem, ${run.sizePct}%)`, top: `${run.startPct}%` }}
/>
))}
</div>
</div>
)
}
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 ? (
<PreviewDiffRows afterLines={afterRows} beforeLines={beforeRows} chunks={visibleLineChunks} />
) : (
<DiffBody lines={lines} />
)
) : fullText != null ? (
<TokenizedDiffBody
afterLines={afterRows}
beforeLines={beforeRows}
chunked={showLineNumbers}
chunks={visibleLineChunks}
language={language}
lines={lines}
/>
) : (
<SyntaxDiff language={language} lines={lines} />
)
if (!showLineNumbers) {
return (
<div className={cn(DIFF_BOX_CLASS, className)} data-slot="file-diff-panel">
{body}
</div>
)
}
// 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 (
<div className={cn(DIFF_BOX_CLASS, 'relative overflow-hidden', className)} data-slot="file-diff-panel">
<div className="absolute inset-0 overflow-auto pr-2.5" onScroll={onScroll} ref={scrollerRef}>
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)]">
<div className="sticky left-0 z-1 select-none bg-(--ui-editor-surface-background) py-3 text-muted-foreground/55">
{beforeRows > 0 && (
<div aria-hidden style={{ height: beforeRows * PREVIEW_LINE_PX }} />
)}
{visibleLineChunks.map(chunk => (
<div className="block" key={chunk.start}>
{chunk.lines.map((line, offset) => {
const index = chunk.start + offset
return (
<div
className="h-5 w-9 pr-2 text-right leading-5 tabular-nums"
key={`${index}-${line.oldNo}-${line.newNo}`}
>
{line.newNo ?? ''}
</div>
)
})}
</div>
))}
{afterRows > 0 && <div aria-hidden style={{ height: afterRows * PREVIEW_LINE_PX }} />}
</div>
<div className="min-w-0">{body}</div>
</div>
</div>
<DiffOverviewRuler lines={lines} />
</div>
)
}

View file

@ -0,0 +1,155 @@
import type { RefObject, UIEvent } from 'react'
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
export interface LineChunk<T> {
lines: T[]
start: number
}
export interface TextLineChunk extends LineChunk<string> {
text: string
}
interface FixedRowWindowOptions {
overscanRows: number
rowPx: number
rowsPerChunk: number
totalRows: number
}
export interface FixedRowWindow {
afterRows: number
beforeRows: number
endChunk: number
onScroll: (event: UIEvent<HTMLDivElement>) => void
scrollerRef: RefObject<HTMLDivElement | null>
startChunk: number
}
export function chunkLines<T>(lines: T[], perChunk: number): Array<LineChunk<T>> {
if (lines.length <= perChunk) {
return [{ lines, start: 0 }]
}
const chunks: Array<LineChunk<T>> = []
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<FixedRowWindow, 'afterRows' | 'beforeRows' | 'endChunk' | 'startChunk'>
export function useFixedRowWindow({
overscanRows,
rowPx,
rowsPerChunk,
totalRows
}: FixedRowWindowOptions): FixedRowWindow {
const scrollerRef = useRef<HTMLDivElement | null>(null)
const rafRef = useRef<number | null>(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<ChunkWindow>(() => 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<HTMLDivElement>) => {
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 }
}

View file

@ -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 (
<div className="flex min-h-0 flex-1 flex-col gap-2 px-3 py-2.5" data-slot="tree-skeleton">
{TREE_ROWS.map((row, index) => (
<div
className="flex items-center gap-2"
key={`${index}-${row.width}`}
style={{ paddingLeft: `${row.indent * 12}px` }}
>
<Skeleton className="size-3.5 shrink-0 rounded-[3px]" />
<Skeleton className="h-3" style={{ width: row.width }} />
</div>
))}
</div>
)
}
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 (
<div className="flex flex-col gap-1.5 px-3 py-2" data-slot="diff-skeleton" style={style}>
{DIFF_ROWS.map((width, index) => (
<Skeleton className="h-3" key={`${index}-${width}`} style={{ width }} />
))}
</div>
)
}

View file

@ -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

View file

@ -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 <div>recovered</div>
}
}
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(
<ErrorBoundary label="root">
<Bomb />
</ErrorBoundary>
)
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(
<ErrorBoundary label="root">
<Bomb />
</ErrorBoundary>
)
// 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(
<ErrorBoundary fallback={() => <div>scoped-fallback</div>} label="thread">
<Bomb />
</ErrorBoundary>
)
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(
<ErrorBoundary label="root">
<Bomb />
</ErrorBoundary>
)
await waitFor(() => expect(screen.getByRole(RELOAD_WINDOW.role, { name: RELOAD_WINDOW.name })).toBeTruthy())
expect(countRecoverWarnings(warnSpy.mock.calls)).toBe(0)
})
})

View file

@ -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<ErrorBoundaryProps, ErrorBoundaryState> {
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<ErrorBoundaryProps, ErrorBoundarySt
const tag = this.props.label ? `[error-boundary:${this.props.label}]` : '[error-boundary]'
console.error(tag, error, info.componentStack)
this.props.onError?.(error, info)
if (this.props.label === 'root' && isRecoverableDesktopRenderError(error) && this.canRecover()) {
console.warn(`${tag} auto-recovering from transient render error`, error.message)
this.scheduleRecover()
}
}
componentWillUnmount() {
this.clearRecoverTimer()
}
reset = () => {
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 })
}

View file

@ -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 (
<PaneShellContext.Provider value={{ mainColumn: ctxValue.mainColumn, paneById: ctxValue.paneById }}>
<div className={cn('relative grid h-full min-h-0', className)} style={composedStyle}>
<div className={cn('relative grid h-full min-h-0', className)} data-pane-shell="" style={composedStyle}>
{children}
</div>
</PaneShellContext.Provider>
@ -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<HTMLDivElement | null>(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({
>
<div
aria-hidden="true"
className={cn(
'absolute inset-y-0 z-30 [-webkit-app-region:no-drag]',
hoverRevealSuppressed ? 'pointer-events-none' : 'pointer-events-auto'
)}
className="pointer-events-auto absolute inset-y-0 z-30 [-webkit-app-region:no-drag]"
data-pane-reveal-trigger=""
style={{ [edge]: HOVER_REVEAL_EDGE_GUTTER, width: HOVER_REVEAL_TRIGGER_WIDTH }}
/>
@ -392,8 +399,7 @@ export function Pane({
className={cn(
'pointer-events-none absolute inset-y-0 z-30 overflow-hidden transition-transform delay-0',
offscreen,
!hoverRevealSuppressed &&
'group-hover/reveal:pointer-events-auto group-hover/reveal:translate-x-0 group-hover/reveal:delay-[var(--reveal-enter-delay)] group-hover/reveal:shadow-[var(--reveal-shadow)]',
'group-hover/reveal:pointer-events-auto group-hover/reveal:translate-x-0 group-hover/reveal:delay-[var(--reveal-enter-delay)] group-hover/reveal:shadow-[var(--reveal-shadow)]',
'group-data-[forced]/reveal:pointer-events-auto group-data-[forced]/reveal:translate-x-0 group-data-[forced]/reveal:delay-0 group-data-[forced]/reveal:shadow-[var(--reveal-shadow)]'
)}
key={edge}

View file

@ -1,3 +1,4 @@
import type { Icon } from '@tabler/icons-react'
import type * as React from 'react'
import { cn } from '@/lib/utils'
@ -18,3 +19,13 @@ export function Codicon({ className, name, size, spinning, style, ...props }: Co
/>
)
}
/** Wrap a codicon as a Tabler-shaped icon for nav rows that expect `IconComponent`. */
export function codiconIcon(name: string): Icon {
function CodiconIcon({ className }: { className?: string }) {
return <Codicon aria-hidden className={cn('leading-none', className)} name={name} size="1em" />
}
CodiconIcon.displayName = `Codicon(${name})`
return CodiconIcon as Icon
}

View file

@ -0,0 +1,50 @@
import { Codicon } from './codicon'
interface ColorSwatchesProps {
swatches: readonly string[]
value: null | string
onChange: (color: null | string) => void
clearLabel: string
clearIcon?: string
swatchLabel?: (color: string) => string
}
// Shared swatch grid + clear row used by the profile rail and the project
// dialog, so color picking looks and behaves identically everywhere.
export function ColorSwatches({
swatches,
value,
onChange,
clearLabel,
clearIcon = 'circle-slash',
swatchLabel
}: ColorSwatchesProps) {
return (
<div>
<div className="grid grid-cols-6 gap-1.5">
{swatches.map(swatch => (
<button
aria-label={swatchLabel?.(swatch) ?? swatch}
className="size-5 rounded-full transition-transform hover:scale-110"
key={swatch}
onClick={() => onChange(swatch)}
style={{
backgroundColor: swatch,
boxShadow: swatch === value ? '0 0 0 2px var(--ui-bg-elevated), 0 0 0 3.5px currentColor' : undefined,
color: swatch
}}
type="button"
/>
))}
</div>
<button
className="mt-2 flex w-full items-center justify-center gap-1.5 rounded-md py-1 text-xs text-(--ui-text-tertiary) transition hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={() => onChange(null)}
type="button"
>
<Codicon name={clearIcon} size="0.75rem" />
{clearLabel}
</button>
</div>
)
}

View file

@ -175,7 +175,7 @@ function DialogTitle({
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title> & {
// Pass a lucide icon to get the canonical dialog-header glyph: a plain
// Pass an icon (from `@/lib/icons`) to get the canonical dialog-header glyph: a plain
// primary-tinted icon inline with the title (no bg chip / ring). This is the
// single source of truth for dialog header icons — don't hand-roll wrappers.
icon?: React.ComponentType<{ className?: string }>

View file

@ -0,0 +1,52 @@
import { motion, useSpring, useTransform } from 'motion/react'
import { useEffect } from 'react'
import { cn } from '@/lib/utils'
// Snappy spring — fast transitions per the design.
const SPRING = { stiffness: 320, damping: 30, mass: 0.5 } as const
// A single integer that springs to its value via Motion (renders the motion
// value straight to the DOM, no per-frame React re-render). It initialises AT
// its value, so mounting/navigating shows it instantly — only a real change to
// the number (a live edit) springs it up/down. Switching threads in the same
// worktree (same numbers) therefore doesn't animate.
function AnimatedInt({ value }: { value: number }) {
const spring = useSpring(value, SPRING)
const text = useTransform(spring, latest => Math.round(latest).toString())
useEffect(() => {
spring.set(value)
}, [value, spring])
return <motion.span>{text}</motion.span>
}
interface DiffCountProps {
added: number
removed: number
className?: string
}
/** Animated `+A B` line-count, green/red via the top-level theme vars. Each
* number springs up/down via Motion (0 value on first mount). */
export function DiffCount({ added, removed, className }: DiffCountProps) {
if (!added && !removed) {
return null
}
return (
<span className={cn('flex shrink-0 items-center gap-1 tabular-nums', className)}>
{added > 0 && (
<span className="text-(--ui-green)">
+<AnimatedInt value={added} />
</span>
)}
{removed > 0 && (
<span className="text-(--ui-red)">
<AnimatedInt value={removed} />
</span>
)}
</span>
)
}

View file

@ -7,12 +7,18 @@ import { type ControlVariantProps, controlVariants } from './control'
function Input({ className, type, size, ...props }: Omit<React.ComponentProps<'input'>, 'size'> & ControlVariantProps) {
return (
<input
// Off by default for every consumer — these are code/config/search fields,
// not prose. Callers can re-enable per-instance by passing the prop.
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className={cn(
controlVariants({ size }),
'selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-xs file:font-medium file:text-foreground',
className
)}
data-slot="input"
spellCheck={false}
type={type}
{...props}
/>

View file

@ -17,6 +17,11 @@ function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitiv
function PopoverContent({
align = 'center',
// Keeps the arrow clear of the rounded corners (rounded-lg = 8px): Radix
// clamps the arrow this far from each edge and shifts the popover to
// compensate, so the arrow never jams into a corner on start/end alignment.
arrowPadding = 12,
children,
className,
collisionPadding = 8,
sideOffset = 6,
@ -26,17 +31,30 @@ function PopoverContent({
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align={align}
// Mirrors DropdownMenuContent: themed elevated surface, viewport-aware
// (Radix flips/shifts off edges), with the standard open/close motion.
arrowPadding={arrowPadding}
// Themed glass surface, viewport-aware (Radix flips/shifts off edges),
// standard open/close motion. Border-only (no shadow).
className={cn(
'z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-2 text-popover-foreground shadow-md backdrop-blur-md outline-hidden data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
'z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-lg border border-(--ui-stroke-secondary) bg-[var(--popover-surface)] p-2 text-popover-foreground backdrop-blur-md outline-hidden data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 [--popover-surface:color-mix(in_srgb,var(--ui-bg-elevated)_92%,transparent)]',
className
)}
collisionPadding={collisionPadding}
data-slot="popover-content"
sideOffset={sideOffset}
{...props}
/>
>
{children}
{/* CSS arrow that truly inherits the surface: a rotated square sharing the
body's exact bg + backdrop-blur (so it matches even through glass), with
the border on its two outer edges only. Radix authors the child pointing
"down" and rotates the wrapper per side, so the V always faces outward.
The square's inner half tucks under the body, opening the border seam. */}
<PopoverPrimitive.Arrow asChild height={7} width={16}>
<span className="relative block h-[7px] w-4 overflow-visible">
<span className="absolute top-0 left-1/2 size-[11px] -translate-x-1/2 -translate-y-1/2 rotate-45 border-r border-b border-(--ui-stroke-secondary) bg-[var(--popover-surface)] backdrop-blur-md" />
</span>
</PopoverPrimitive.Arrow>
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
)
}

View file

@ -0,0 +1,17 @@
import type * as React from 'react'
import { Input } from './input'
interface SanitizedInputProps extends Omit<React.ComponentProps<typeof Input>, 'onChange' | 'value'> {
value: string
onValueChange: (value: string) => void
// A formatter from `@/lib/sanitize` (gitRef, slug, …) run on every keystroke.
sanitize: (raw: string) => string
}
// An <Input> that can only ever hold a valid value: every keystroke is run
// through `sanitize`, so callers never have to validate-then-reject (a space in
// a branch name becomes "-" as you type instead of erroring at submit).
export function SanitizedInput({ value, onValueChange, sanitize, ...props }: SanitizedInputProps) {
return <Input {...props} onChange={event => onValueChange(sanitize(event.target.value))} value={value} />
}

View file

@ -0,0 +1,98 @@
import type { VariantProps } from 'class-variance-authority'
import type { ReactNode } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
import type { buttonVariants } from './button';
import { Button } from './button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './dropdown-menu'
export interface SplitButtonAction {
id: string
label: string
icon?: ReactNode
}
interface SplitButtonProps {
actions: SplitButtonAction[]
/** The id of the action the primary button runs (the user's current default). */
value: string
/** Picking from the menu changes the default (so the next primary click repeats it). */
onValueChange: (id: string) => void
/** Run an action by id (primary click or menu pick both call this). */
onTrigger: (id: string) => void
disabled?: boolean
className?: string
/** Icon shown on the primary button only (e.g. a ✓ for Commit). */
primaryIcon?: ReactNode
variant?: VariantProps<typeof buttonVariants>['variant']
size?: VariantProps<typeof buttonVariants>['size']
}
/**
* A primary action fused to a caret that opens alternates VS Code's
* Commit / Commit & Push pattern. The primary button runs `value`; picking a
* menu item runs it AND makes it the new default, so the control adapts to how
* the user works without a separate settings toggle.
*/
export function SplitButton({
actions,
value,
onValueChange,
onTrigger,
disabled,
className,
primaryIcon,
variant = 'secondary',
size = 'sm'
}: SplitButtonProps) {
const active = actions.find(action => action.id === value) ?? actions[0]
if (!active) {
return null
}
return (
<div className={cn('inline-flex min-w-0', className)}>
<Button
className="min-w-0 flex-1 rounded-r-none"
disabled={disabled}
onClick={() => onTrigger(active.id)}
size={size}
variant={variant}
>
{primaryIcon ?? active.icon}
<span className="truncate">{active.label}</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="More actions"
className="rounded-l-none border-l border-current/25 px-2"
disabled={disabled}
size={size}
variant={variant}
>
<Codicon name="chevron-down" size="0.8rem" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-44">
{actions.map(action => (
<DropdownMenuItem
key={action.id}
onSelect={() => {
onValueChange(action.id)
onTrigger(action.id)
}}
>
{action.icon}
<span className="flex-1 truncate">{action.label}</span>
{action.id === value && <Codicon className="text-(--ui-text-tertiary)" name="check" size="0.75rem" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}

View file

@ -5,7 +5,19 @@ import { cn } from '@/lib/utils'
import { type ControlVariantProps, controlVariants } from './control'
function Textarea({ className, size, ...props }: React.ComponentProps<'textarea'> & ControlVariantProps) {
return <textarea className={cn(controlVariants({ size }), 'min-h-16', className)} data-slot="textarea" {...props} />
return (
<textarea
// Off by default for every consumer — these are code/config/prompt fields,
// not prose. Callers can re-enable per-instance by passing the prop.
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className={cn(controlVariants({ size }), 'min-h-16', className)}
data-slot="textarea"
spellCheck={false}
{...props}
/>
)
}
export { Textarea }

View file

@ -0,0 +1,26 @@
import { useEffect, useState } from 'react'
/**
* Returns true only after `active` has stayed true continuously for `delayMs`.
* Flips back to false the instant `active` goes false. Use it to gate loading
* skeletons so a fast operation doesn't flash one the UI just stays blank for
* the (sub-perceptible) delay window, and the skeleton appears only when a load
* is genuinely slow.
*/
export function useDelayedTrue(active: boolean, delayMs = 180): boolean {
const [shown, setShown] = useState(false)
useEffect(() => {
if (!active) {
setShown(false)
return
}
const id = window.setTimeout(() => setShown(true), delayMs)
return () => window.clearTimeout(id)
}, [active, delayMs])
return shown
}

View file

@ -1,68 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { uniqueCwds, type WorktreeResolver } from '@/app/chat/sidebar/workspace-groups'
import type { HermesWorktreeInfo } from '@/global'
import type { SessionInfo } from '@/hermes'
import { desktopFsCacheKey, desktopWorktrees } from '@/lib/desktop-fs'
type WorktreeMap = Record<string, HermesWorktreeInfo | null>
/**
* Probe the local filesystem for the git-worktree identity of each session cwd
* and return a resolver the grouping uses to build `parent → worktree`. Results
* are cached per cwd (and reset when the backend connection changes), so a probe
* runs once per directory. Unresolved cwds (probe pending, remote backend, or
* non-git dirs) fall back to the path-name heuristic in `workspaceTreeFor`.
*/
export function useWorktreeInfo(sessions: SessionInfo[], enabled: boolean): WorktreeResolver {
const [map, setMap] = useState<WorktreeMap>({})
const cacheRef = useRef<{ data: WorktreeMap; key: string }>({ data: {}, key: '' })
useEffect(() => {
if (!enabled) {
return
}
const key = desktopFsCacheKey()
if (cacheRef.current.key !== key) {
cacheRef.current = { data: {}, key }
setMap({})
}
const missing = uniqueCwds(sessions).filter(cwd => !(cwd in cacheRef.current.data))
if (!missing.length) {
return
}
let cancelled = false
void desktopWorktrees(missing)
.then(result => {
if (cancelled) {
return
}
// Record every probed cwd (null when absent) so we never re-probe it.
const next: WorktreeMap = { ...cacheRef.current.data }
for (const cwd of missing) {
next[cwd] = result[cwd] ?? null
}
cacheRef.current = { data: next, key }
setMap(next)
})
.catch(() => {
// Bridge unavailable / probe failed — leave cwds unresolved so the
// heuristic fallback handles them.
})
return () => {
cancelled = true
}
}, [sessions, enabled])
return useMemo<WorktreeResolver>(() => (cwd: string) => map[cwd], [map])
}

View file

@ -69,6 +69,9 @@ export type GatewayEventPayload = {
count?: number
// status.update (kind=process → background process completion/watch-match)
kind?: string
// session.title (live auto-title push) — stored session id + generated title
session_id?: string
title?: string
}
export function textPart(text: string): ChatMessagePart {

View file

@ -2,8 +2,7 @@ import type {
HermesConnection,
HermesReadDirResult,
HermesReadFileTextResult,
HermesSelectPathsOptions,
HermesWorktreeInfo
HermesSelectPathsOptions
} from '@/global'
import { $connection } from '@/store/session'
@ -21,6 +20,7 @@ function connectionCacheKey(connection: HermesConnection | null) {
if (!connection) {
return 'local:'
}
return `${connection.mode || 'local'}:${connection.profile || ''}:${connection.baseUrl || ''}`
}
@ -38,61 +38,58 @@ function fsPath(endpoint: string, filePath: string) {
function bridge() {
const desktop = window.hermesDesktop
if (!desktop) {
throw new Error('Hermes Desktop bridge is unavailable')
}
return desktop
}
export async function readDesktopDir(path: string): Promise<HermesReadDirResult> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.readDir(path)
}
return desktop.api<HermesReadDirResult>({ path: fsPath('list', path) })
}
export async function readDesktopFileText(path: string): Promise<HermesReadFileTextResult> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.readFileText(path)
}
return desktop.api<HermesReadFileTextResult>({ path: fsPath('read-text', path) })
}
export async function readDesktopFileDataUrl(path: string): Promise<string> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.readFileDataUrl(path)
}
const result = await desktop.api<string | { dataUrl?: string }>({ path: fsPath('read-data-url', path) })
return typeof result === 'string' ? result : result.dataUrl || ''
}
export async function desktopGitRoot(path: string): Promise<string | null> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.gitRoot ? desktop.gitRoot(path) : null
}
const result = await desktop.api<{ root: string | null }>({ path: fsPath('git-root', path) })
return result.root
}
// Worktree detection runs against the LOCAL filesystem (the electron main
// process). For a remote backend the session cwds live on another machine, so
// we can't resolve them here — callers fall back to the path-name heuristic.
export async function desktopWorktrees(cwds: string[]): Promise<Record<string, HermesWorktreeInfo | null>> {
if (isDesktopFsRemoteMode()) {
return {}
}
const desktop = bridge()
return desktop.worktrees ? desktop.worktrees(cwds) : {}
}
export async function desktopDefaultCwd(): Promise<{ branch: string; cwd: string } | null> {
if (!isDesktopFsRemoteMode()) {
return null
@ -101,13 +98,61 @@ export async function desktopDefaultCwd(): Promise<{ branch: string; cwd: string
return bridge().api<{ branch: string; cwd: string }>({ path: '/api/fs/default-cwd' })
}
// Reveal a path in the OS file manager (Finder / Explorer / Files). Local only.
export async function revealDesktopPath(path: string): Promise<void> {
await bridge().revealPath?.(path)
}
// Rename a file/folder in place; returns the new absolute path. Local only.
export async function renameDesktopPath(path: string, newName: string): Promise<string> {
const desktop = bridge()
if (!desktop.renamePath) {
throw new Error('Rename is not available')
}
const result = await desktop.renamePath(path, newName)
return result.path
}
// Move a file/folder to the OS trash (recoverable). Local only.
export async function trashDesktopPath(path: string): Promise<void> {
const desktop = bridge()
if (!desktop.trashPath) {
throw new Error('Delete is not available')
}
await desktop.trashPath(path)
}
export async function copyTextToClipboard(text: string): Promise<void> {
await bridge().writeClipboard(text)
}
// Working-tree-vs-HEAD diff for one file. Empty when unchanged / not a repo /
// remote backend (the diff view simply doesn't show then). Local only.
export async function desktopFileDiff(repoRoot: string, filePath: string): Promise<string> {
const desktop = bridge()
if (isDesktopFsRemoteMode() || !desktop.git?.fileDiff) {
return ''
}
return desktop.git.fileDiff(repoRoot, filePath)
}
export async function selectDesktopPaths(options?: HermesSelectPathsOptions): Promise<string[]> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.selectPaths(options)
}
if (!options?.directories || options.multiple !== false) {
return []
}
return remotePicker ? remotePicker.selectPaths(options) : []
}

View file

@ -0,0 +1,45 @@
// Always hidden across the file tree and review (git) tree, regardless of
// .gitignore: the VCS internals, heavyweight dep/build/cache dirs, and OS noise.
// These bloat both trees and are never worth browsing or reviewing — even in
// repos that track them, and in plain non-git folders.
export const ALWAYS_EXCLUDED = new Set([
'.git',
'.hg',
'.svn',
'node_modules',
'bower_components',
'.venv',
'venv',
'env',
'__pycache__',
'.mypy_cache',
'.pytest_cache',
'.ruff_cache',
'.tox',
'.gradle',
'.idea',
'dist',
'build',
'out',
'target',
'vendor',
'Pods',
'.next',
'.nuxt',
'.svelte-kit',
'.output',
'.turbo',
'.parcel-cache',
'.cache',
'.terraform',
'.expo',
'.angular',
'coverage',
'.DS_Store',
'Thumbs.db'
])
// True when any segment of a relative path is excluded (review rows like
// `node_modules/.bin/foo` or a bare `.DS_Store`). Handles `/` and `\`.
export const isExcludedPath = (relPath: string): boolean =>
relPath.split(/[/\\]/).some(seg => ALWAYS_EXCLUDED.has(seg))

View file

@ -10,6 +10,8 @@ import {
IconWaveSine as AudioLines,
IconChartBar as BarChart3,
IconBell as Bell,
IconBookmark as Bookmark,
IconBookmarkFilled as BookmarkFilled,
IconBrain as Brain,
IconBug as Bug,
IconCheck as Check,
@ -44,6 +46,7 @@ import {
IconPhoto as ImageIcon,
IconInfoCircle as Info,
IconKey as KeyRound,
IconLayoutDashboard as LayoutDashboard,
IconLayersIntersect2 as Layers3,
IconLink as Link,
IconLink as Link2,
@ -85,12 +88,13 @@ import {
IconSettings as Settings,
IconSettings2 as Settings2,
IconAdjustmentsHorizontal as SlidersHorizontal,
IconSparkles as Sparkles,
IconSquare as Square,
IconPlayerStopFilled as StopFilled,
IconSteeringWheel as SteeringWheel,
IconSun as Sun,
IconTerminal2 as Terminal,
IconTrash as Trash2,
IconUpload as Upload,
IconUsers as Users,
IconVolume2 as Volume2,
IconVolume2 as Volume2Icon,
@ -115,6 +119,8 @@ export {
AudioLines,
BarChart3,
Bell,
Bookmark,
BookmarkFilled,
Brain,
Bug,
Check,
@ -149,6 +155,7 @@ export {
ImageIcon,
Info,
KeyRound,
LayoutDashboard,
Layers3,
Link,
Link2,
@ -190,12 +197,13 @@ export {
Settings,
Settings2,
SlidersHorizontal,
Sparkles,
Square,
StopFilled,
SteeringWheel,
Sun,
Terminal,
Trash2,
Upload,
Users,
Volume2,
Volume2Icon,

View file

@ -82,6 +82,8 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
...SESSION_SLOT_ACTIONS,
{ id: 'session.focusSearch', category: 'session', defaults: ['mod+shift+f'] },
{ id: 'session.togglePin', category: 'session', defaults: [] },
// ⌘⇧B — "b" for branch: spin up a new git worktree from the active repo.
{ id: 'workspace.newWorktree', category: 'session', defaults: ['mod+shift+b'] },
// ── Navigation ───────────────────────────────────────────────────────────
{ id: 'nav.commandPalette', category: 'navigation', defaults: ['mod+k', 'mod+p'] },
@ -97,6 +99,8 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
// ── View (layout + appearance + the shortcuts panel itself) ───────────────
{ id: 'view.toggleSidebar', category: 'view', defaults: ['mod+b'] },
{ id: 'view.toggleRightSidebar', category: 'view', defaults: ['mod+j'] },
// ⌘G — "g" for git; the review pane is the source-control view.
{ id: 'view.toggleReview', category: 'view', defaults: ['mod+g'] },
{ id: 'view.showFiles', category: 'view', defaults: [] },
{ id: 'view.showTerminal', category: 'view', defaults: TERMINAL_TOGGLE_DEFAULTS },
// ⌘\ — the backslash reads like a mirror line flipping the layout.

View file

@ -0,0 +1,58 @@
import { $gateway } from '@/store/gateway'
import { $activeSessionId } from '@/store/session'
// Shared client for one-off ("one-shot") LLM requests: a single stateless model
// call that runs OUTSIDE the conversation. It never appends to session history,
// so prompt caching stays intact. Use it for small generative chores (commit
// messages, rename ideas, summaries) where an agent turn would be wrong.
//
// Pair with a registered backend template (agent/oneshot.py PROMPT_TEMPLATES)
// for reusable prompt engineering, or pass raw instructions/input ad hoc.
export interface OneShotRequest {
/** Registered backend template id (e.g. 'commit_message'). */
template?: string
/** Variables for the template. */
variables?: Record<string, unknown>
/** Raw system prompt (used when no template is given). */
instructions?: string
/** Raw user content (used when no template is given). */
input?: string
/** Auxiliary task name for model routing (defaults backend-side). */
task?: string
maxTokens?: number
temperature?: number
/**
* Session whose model to inherit. Defaults to the active session so output
* matches the model the user is coding with; pass null to force the
* configured auxiliary backend instead.
*/
sessionId?: string | null
}
/**
* Send a one-off request to Hermes and return the generated text.
* Throws when the gateway is offline or the backend reports an error.
*/
export async function requestOneShot(req: OneShotRequest): Promise<string> {
const gateway = $gateway.get()
if (!gateway) {
throw new Error('Gateway not connected')
}
const sessionId = req.sessionId === undefined ? $activeSessionId.get() : req.sessionId
const result = await gateway.request<{ text?: string }>('llm.oneshot', {
input: req.input,
instructions: req.instructions,
max_tokens: req.maxTokens,
session_id: sessionId ?? undefined,
task: req.task,
temperature: req.temperature,
template: req.template,
variables: req.variables
})
return (result?.text ?? '').trim()
}

View file

@ -0,0 +1,78 @@
import { atom, type WritableAtom } from 'nanostores'
import { readKey, writeKey } from './storage'
// A nanostore that auto-persists. Reads its seed from localStorage through the
// storage choke point (so every read/write is observable in one place) and
// writes back on every change — no per-atom subscribe boilerplate.
//
// export const $foo = persistentAtom('hermes.desktop.foo', false, Codecs.bool)
// Maps a value to/from its stored string form. `decode` only ever sees a real
// stored string (absence falls back); `encode` returning null removes the key.
export interface Codec<T> {
decode(raw: string): T
encode(value: T): null | string
}
export const Codecs = {
bool: { decode: raw => raw === 'true', encode: (value: boolean) => String(value) } as Codec<boolean>,
nullableText: { decode: raw => raw, encode: value => value } as Codec<null | string>,
text: { decode: raw => raw, encode: (value: string) => value } as Codec<string>,
// Mirrors storedStringArray/persistStringArray: drops non-strings, empty → removed.
stringArray: {
decode: raw => {
const parsed = JSON.parse(raw) as unknown
return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === 'string' && item.length > 0)
: []
},
encode: value => (value.length === 0 ? null : JSON.stringify(value))
} as Codec<string[]>,
// Mirrors storedStringRecord/persistStringRecord: keeps only string values.
stringRecord: {
decode: raw => {
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {}
}
return Object.fromEntries(
Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[1] === 'string')
)
},
encode: value => JSON.stringify(value)
} as Codec<Record<string, string>>,
/** JSON with an optional sanitizer for untrusted persisted shapes. */
json<T>(sanitize?: (value: unknown) => T): Codec<T> {
return {
decode: raw => {
const parsed = JSON.parse(raw) as unknown
return sanitize ? sanitize(parsed) : (parsed as T)
},
encode: value => JSON.stringify(value)
}
}
}
export function persistentAtom<T>(key: string, fallback: T, codec: Codec<T> = Codecs.json<T>()): WritableAtom<T> {
const raw = readKey(key)
let initial = fallback
if (raw !== null) {
try {
initial = codec.decode(raw)
} catch {
initial = fallback
}
}
const $value = atom<T>(initial)
$value.subscribe(value => writeKey(key, codec.encode(value)))
return $value
}

View file

@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { mapPool } from './pool'
describe('mapPool', () => {
it('preserves input order regardless of completion order', async () => {
const out = await mapPool([30, 10, 20], 3, async ms => {
await new Promise(r => setTimeout(r, ms))
return ms
})
expect(out).toEqual([30, 10, 20])
})
it('never exceeds the concurrency limit', async () => {
let active = 0
let peak = 0
await mapPool([...Array(10).keys()], 3, async () => {
active += 1
peak = Math.max(peak, active)
await new Promise(r => setTimeout(r, 5))
active -= 1
})
expect(peak).toBeLessThanOrEqual(3)
})
})

View file

@ -0,0 +1,20 @@
/**
* `Promise.all(items.map(fn))` with a concurrency cap: at most `limit` calls run
* at once, results stay in input order. Keeps a many-repo probe from spawning a
* `git` process per repo all at once.
*/
export async function mapPool<T, R>(items: readonly T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const out = new Array<R>(items.length)
let next = 0
const worker = async () => {
while (next < items.length) {
const i = next++
out[i] = await fn(items[i])
}
}
await Promise.all(Array.from({ length: Math.min(Math.max(1, limit), items.length) }, worker))
return out
}

View file

@ -0,0 +1,116 @@
// Fun starter ideas for the new-project dialog. Pills prefill IDEA.md; the set
// shown is a random handful from this pool (reshuffled on open / via the dice),
// so creating a project always feels a little playful. Pure content — edit
// freely, order doesn't matter.
export interface ProjectIdeaTemplate {
emoji: string
label: string
idea: string
}
export const PROJECT_IDEA_TEMPLATES: ProjectIdeaTemplate[] = [
{
emoji: '🎮',
label: 'Game jam',
idea: 'A tiny browser game built in a weekend.\n\n- One core mechanic, juicy feedback\n- No build step — single HTML/JS file\n- Playable in under 60 seconds',
},
{
emoji: '📚',
label: 'Novel',
idea: 'A novel-in-progress.\n\n- Track chapters, characters, and timeline\n- Daily word-count goal\n- Keep research notes beside the draft',
},
{
emoji: '🤖',
label: 'Discord bot',
idea: 'A Discord bot for a small community.\n\n- Slash commands + a fun daily ritual\n- Lightweight persistence\n- Deploy somewhere free',
},
{
emoji: '📊',
label: 'Data viz',
idea: 'An interactive visualization of a dataset I care about.\n\n- Pick the dataset and the one question it answers\n- Clean → chart → annotate\n- Shareable as a single page',
},
{
emoji: '🎨',
label: 'Generative art',
idea: 'A generative art piece.\n\n- One algorithm, lots of seeds\n- Export high-res stills\n- A gallery of the best outputs',
},
{
emoji: '🍳',
label: 'Recipe box',
idea: 'A personal recipe collection.\n\n- Searchable by ingredient and mood\n- Scale servings on the fly\n- Auto-build a shopping list',
},
{
emoji: '🧪',
label: 'Research log',
idea: 'A research notebook for an open question.\n\n- Log experiments, results, and dead ends\n- Cite sources inline\n- Weekly synthesis of what I learned',
},
{
emoji: '💸',
label: 'Budget tracker',
idea: 'A no-nonsense budget tracker.\n\n- Import transactions, tag them fast\n- Monthly burn vs. plan\n- One chart that tells the truth',
},
{
emoji: '🌱',
label: 'Habit tracker',
idea: 'A habit tracker that actually sticks.\n\n- A handful of daily checkboxes\n- Streaks without guilt\n- A calm weekly review',
},
{
emoji: '🗺️',
label: 'Trip planner',
idea: 'A trip planner for an upcoming adventure.\n\n- Day-by-day itinerary\n- Map of pins + notes\n- Packing + budget checklist',
},
{
emoji: '🎵',
label: 'Music toy',
idea: 'A little music-making toy.\n\n- One instrument or sequencer\n- Web Audio, no installs\n- Record + share a loop',
},
{
emoji: '🧩',
label: 'Puzzle maker',
idea: 'A generator for a puzzle I love.\n\n- Procedurally make solvable puzzles\n- Difficulty dial\n- Printable + playable',
},
{
emoji: '📝',
label: 'Digital garden',
idea: 'A digital garden / personal wiki.\n\n- Atomic notes that link to each other\n- Grows over time, never "done"\n- Publish the public ones',
},
{
emoji: '🛰️',
label: 'API wrapper',
idea: 'A clean wrapper around an API I keep reaching for.\n\n- Typed client + sensible defaults\n- One example per endpoint\n- Publish it',
},
{
emoji: '🏋️',
label: 'Workout plan',
idea: 'A workout planner / logger.\n\n- Build a weekly split\n- Log sets fast on mobile\n- Track progress over months',
},
{
emoji: '🧠',
label: 'Flashcards',
idea: 'A spaced-repetition flashcard app.\n\n- Quick card capture\n- Simple SM-2 scheduling\n- A daily review that fits in 5 minutes',
},
{
emoji: '✍️',
label: 'Screenplay',
idea: 'A short screenplay.\n\n- Logline → beats → scenes\n- Proper format, distraction-free\n- A table read by the end',
},
{
emoji: '🔭',
label: 'Learn-by-building',
idea: "A project to learn a thing I've been avoiding.\n\n- Smallest real thing that teaches it\n- Notes on every gotcha\n- A writeup when it works",
},
]
// A shuffled slice of the pool — the pills shown at any moment.
export function randomIdeaTemplates(count = 6): ProjectIdeaTemplate[] {
const pool = [...PROJECT_IDEA_TEMPLATES]
for (let i = pool.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[pool[i], pool[j]] = [pool[j], pool[i]]
}
return pool.slice(0, Math.min(count, pool.length))
}

View file

@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { gitRef, slug } from './sanitize'
describe('gitRef', () => {
it('turns spaces into hyphens and keeps slashes', () => {
expect(gitRef('beach vibes')).toBe('beach-vibes')
expect(gitRef('feat/cool thing')).toBe('feat/cool-thing')
})
it('drops chars git refs forbid and collapses separators', () => {
expect(gitRef('wip~^:?*[]')).toBe('wip')
expect(gitRef('a b///c..d')).toBe('a-b/c.d')
})
it('strips a leading separator but stays typeable (keeps a trailing one)', () => {
expect(gitRef('/foo')).toBe('foo')
expect(gitRef('feat/')).toBe('feat/')
})
})
describe('slug', () => {
it('lowercases and kebabs runs of non-alphanumerics', () => {
expect(slug('My Profile')).toBe('my-profile')
expect(slug('a__b c')).toBe('a-b-c')
})
it('strips a leading separator but keeps a trailing one while typing', () => {
expect(slug('--x')).toBe('x')
expect(slug('work ')).toBe('work-')
})
})

View file

@ -0,0 +1,21 @@
// Format enforcers for identifier-style inputs, applied live (per keystroke) via
// <SanitizedInput>. They're intentionally lenient on a trailing separator so a
// value stays typeable (e.g. "feat/" then keep going); the final trim happens on
// submit / in the backend.
/** A git-ref-safe branch name: spaces → "-", drop chars git forbids, keep "/". */
export const gitRef = (raw: string): string =>
raw
.replace(/\s+/g, '-')
.replace(/[^\w./-]/g, '') // \w = [A-Za-z0-9_]
.replace(/-{2,}/g, '-')
.replace(/\/{2,}/g, '/')
.replace(/\.{2,}/g, '.')
.replace(/^[-./]+/, '')
/** A kebab slug: lowercase, runs of non-alphanumerics → a single "-". */
export const slug = (raw: string): string =>
raw
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+/, '')

View file

@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import type { SessionInfo } from '@/types/hermes'
import { flattenSessionsWithBranches } from './session-branch-tree'
const session = (id: string, overrides: Partial<SessionInfo> = {}): SessionInfo =>
({
ended_at: null,
id,
input_tokens: 0,
is_active: false,
last_active: 0,
message_count: 1,
model: null,
output_tokens: 0,
preview: null,
source: 'cli',
started_at: 0,
title: id,
tool_call_count: 0,
...overrides
}) as SessionInfo
describe('flattenSessionsWithBranches', () => {
it('nests branch rows under their parent with tree stems', () => {
const parent = session('parent', { last_active: 20 })
const branchA = session('branch-a', { last_active: 15, parent_session_id: 'parent' })
const branchB = session('branch-b', { last_active: 10, parent_session_id: 'parent' })
expect(flattenSessionsWithBranches([parent, branchA, branchB])).toEqual([
{ session: parent },
{ branchStem: '├─ ', session: branchA },
{ branchStem: '└─ ', session: branchB }
])
})
it('follows a compressed parent via lineage root id', () => {
const tip = session('tip', { _lineage_root_id: 'root', last_active: 30 })
const branch = session('branch', { parent_session_id: 'root', last_active: 10 })
expect(flattenSessionsWithBranches([tip, branch])).toEqual([
{ session: tip },
{ branchStem: '└─ ', session: branch }
])
})
it('keeps orphan branches at the top level when the parent is missing', () => {
const branch = session('branch', { parent_session_id: 'missing' })
expect(flattenSessionsWithBranches([branch])).toEqual([{ session: branch }])
})
})

View file

@ -0,0 +1,100 @@
import type { SessionInfo } from '@/types/hermes'
export interface SidebarSessionEntry {
branchStem?: string
session: SessionInfo
}
const recency = (session: SessionInfo): number => session.last_active || session.started_at || 0
/** Flat list with branch/fork sessions nested visually under their parent. */
export function flattenSessionsWithBranches(sessions: readonly SessionInfo[]): SidebarSessionEntry[] {
if (sessions.length < 2) {
return sessions.map(session => ({ session }))
}
const byVisibleId = new Map<string, SessionInfo>()
for (const session of sessions) {
byVisibleId.set(session.id, session)
const rootId = session._lineage_root_id?.trim()
if (rootId) {
byVisibleId.set(rootId, session)
}
}
const childrenByParent = new Map<string, SessionInfo[]>()
const nestedIds = new Set<string>()
for (const session of sessions) {
const parentId = session.parent_session_id?.trim()
if (!parentId) {
continue
}
const parent = byVisibleId.get(parentId)
if (!parent || parent.id === session.id) {
continue
}
nestedIds.add(session.id)
const siblings = childrenByParent.get(parent.id) ?? []
siblings.push(session)
childrenByParent.set(parent.id, siblings)
}
for (const siblings of childrenByParent.values()) {
siblings.sort((left, right) => recency(right) - recency(left))
}
// A group sorts by its freshest member, so activity on any branch lifts the
// whole parent→branches cluster together instead of stranding the parent at
// its own stale timestamp. Memoized — each subtree is folded at most once.
const groupRecencyMemo = new Map<string, number>()
const groupRecency = (session: SessionInfo): number => {
const cached = groupRecencyMemo.get(session.id)
if (cached !== undefined) {
return cached
}
groupRecencyMemo.set(session.id, recency(session)) // cycle guard
const max = (childrenByParent.get(session.id) ?? []).reduce(
(acc, child) => Math.max(acc, groupRecency(child)),
recency(session)
)
groupRecencyMemo.set(session.id, max)
return max
}
// Depth-first so a branch-of-a-branch still renders under its own parent. The
// `seen` set guards against pathological parent cycles, and the trailing sweep
// emits anything the walk somehow missed — nothing in the input is ever dropped.
const out: SidebarSessionEntry[] = []
const seen = new Set<string>()
const emit = (session: SessionInfo, branchStem?: string) => {
if (seen.has(session.id)) {
return
}
seen.add(session.id)
out.push(branchStem ? { branchStem, session } : { session })
const children = childrenByParent.get(session.id)
children?.forEach((child, index) => emit(child, index === children.length - 1 ? '└─ ' : '├─ '))
}
sessions
.filter(session => !nestedIds.has(session.id))
.map((session, index) => ({ index, session }))
.sort((a, b) => groupRecency(b.session) - groupRecency(a.session) || a.index - b.index)
.forEach(({ session }) => emit(session))
for (const session of sessions) {
if (!seen.has(session.id)) {
out.push({ session })
}
}
return out
}

View file

@ -1,30 +1,48 @@
export function storedBoolean(key: string, fallback: boolean): boolean {
try {
const value = window.localStorage.getItem(key)
// ── Persistence choke point ─────────────────────────────────────────────────
// Every persisted read/write in the app funnels through readKey/writeKey, so a
// single subscriber (telemetry, cross-window sync, an audit log) can observe all
// of it without instrumenting each call site. No listeners by default → no cost.
return value === null ? fallback : value === 'true'
} catch {
return fallback
export interface PersistenceEvent {
key: string
op: 'read' | 'remove' | 'write'
value: null | string
}
type PersistenceListener = (event: PersistenceEvent) => void
const persistenceListeners = new Set<PersistenceListener>()
/** Observe every persisted get/set (e.g. pipe into telemetry/sync). */
export function onPersistenceEvent(listener: PersistenceListener): () => void {
persistenceListeners.add(listener)
return () => void persistenceListeners.delete(listener)
}
function emitPersistence(event: PersistenceEvent) {
for (const listener of persistenceListeners) {
listener(event)
}
}
export function persistBoolean(key: string, value: boolean) {
/** Raw read. Returns null when absent or storage is unavailable. */
export function readKey(key: string): null | string {
let value: null | string = null
try {
window.localStorage.setItem(key, String(value))
value = window.localStorage.getItem(key)
} catch {
// Local storage is a convenience; ignore failures in restricted contexts.
// Restricted contexts (private mode, disabled storage) read as absent.
}
emitPersistence({ key, op: 'read', value })
return value
}
export function storedString(key: string): null | string {
try {
return window.localStorage.getItem(key)
} catch {
return null
}
}
export function persistString(key: string, value: null | string) {
/** Raw write. A null value removes the key. Best-effort. */
export function writeKey(key: string, value: null | string) {
try {
if (value === null) {
window.localStorage.removeItem(key)
@ -32,18 +50,38 @@ export function persistString(key: string, value: null | string) {
window.localStorage.setItem(key, value)
}
} catch {
// Storage is best-effort.
// Storage is best-effort; never let a quota/permission error break the UI.
}
emitPersistence({ key, op: value === null ? 'remove' : 'write', value })
}
export function storedBoolean(key: string, fallback: boolean): boolean {
const value = readKey(key)
return value === null ? fallback : value === 'true'
}
export function persistBoolean(key: string, value: boolean) {
writeKey(key, String(value))
}
export function storedString(key: string): null | string {
return readKey(key)
}
export function persistString(key: string, value: null | string) {
writeKey(key, value)
}
export function storedStringArray(key: string): string[] {
const value = readKey(key)
if (!value) {
return []
}
try {
const value = window.localStorage.getItem(key)
if (!value) {
return []
}
const parsed = JSON.parse(value)
if (!Array.isArray(parsed)) {
@ -57,25 +95,17 @@ export function storedStringArray(key: string): string[] {
}
export function persistStringArray(key: string, value: string[]) {
try {
if (value.length === 0) {
window.localStorage.removeItem(key)
} else {
window.localStorage.setItem(key, JSON.stringify(value))
}
} catch {
// Pins are a local preference; restricted storage should not break chat.
}
writeKey(key, value.length === 0 ? null : JSON.stringify(value))
}
export function storedStringRecord(key: string): Record<string, string> {
const value = readKey(key)
if (!value) {
return {}
}
try {
const value = window.localStorage.getItem(key)
if (!value) {
return {}
}
const parsed = JSON.parse(value)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
@ -91,11 +121,7 @@ export function storedStringRecord(key: string): Record<string, string> {
}
export function persistStringRecord(key: string, value: Record<string, string>) {
try {
window.localStorage.setItem(key, JSON.stringify(value))
} catch {
// Local preference; restricted storage should not break the app.
}
writeKey(key, JSON.stringify(value))
}
export function arraysEqual(left: string[], right: string[]) {