refactor(desktop): give the sidebar row geometry and a bounded cache one owner

Row geometry lived inside chrome.tsx as private consts, so anything that
wanted to line up with a session row copied the literals instead — the lead
cell alone appeared verbatim in six files. Its own comment warns that owning
height anywhere else makes rows float 1-2px off sessions, which is exactly
what a copy invites. Split the measurements into row-geometry.ts, keep the
public names re-exported from chrome.tsx, and put the lead cell and
ConnectionGlyph on the plugin SDK so a plugin composes the same box rather
than re-deriving it. ConnectionGlyph takes a className now so a caller can
tint it without forking the component.

LruCache is the same move for a different leak: katex-memo.ts had a private
one with a hardcoded ceiling, and the bot rail had two Maps that never
evicted. One shared class, exported, katex's twin deleted.
This commit is contained in:
Brooklyn Nicholson
2026-08-27 21:46:40 -05:00
parent 0fececa733
commit 610d1ed0da
7 changed files with 246 additions and 79 deletions
+23 -39
View File
@@ -9,6 +9,14 @@ import { compactNumber } from '@/lib/format'
import { cn } from '@/lib/utils'
import { $sidebarRowMeta } from '@/store/layout'
import {
SIDEBAR_ROW_INSET,
SIDEBAR_ROW_LABEL,
SIDEBAR_ROW_LEAD,
SIDEBAR_ROW_MIN_H,
SIDEBAR_ROW_PAD_TRAIL
} from './row-geometry'
// Shared, content-agnostic sidebar chrome — used by both the flat session
// sections and the project/workspace tree, so it lives outside either to keep
// imports one-directional (no index <-> projects cycle).
@@ -18,39 +26,10 @@ export function SidebarSectionMeta({ children }: { children: React.ReactNode })
return <span className="shrink-0 text-[0.6875rem] font-medium text-(--ui-text-quaternary)">{children}</span>
}
// ── Row geometry (session row is canonical — everything composes these) ─────
//
// Height lives ONLY on SidebarRowShell (min-h-[1.625rem]). Inset children
// stretch to fill the cell and center content internally — never items-center
// on the shell grid, or short clusters (projects) float 1–2px off sessions.
//
// `rowPadX` is the BODY's padding: the lead's inset, plus the gap the label
// keeps from the actions column, both inside the row's click target.
// `rowPadTrail` is the row's own trailing inset and belongs to the SHELL — the
// only box containing both the actions column AND the card's in-body cluster,
// so one class insets every trailing thing a row can render. Owned anywhere
// else, the age / chips / kebab sit flush on the border box, which is exactly
// where a working row paints its arc (`.arc-row` has zero standoff) — the ring
// ran through the text.
const rowMinH = 'min-h-[1.625rem]'
const rowPadX = 'pl-2 pr-2'
const rowPadTrail = 'pr-2'
const rowGap = 'gap-1.5'
const rowLead = 'grid size-3.5 shrink-0 place-items-center'
const rowInset = cn(rowPadX, rowGap, 'flex h-full min-w-0 items-center self-stretch py-0.5')
// `truncate` is overflow:hidden. `leading-none` (line-height: 1) makes the
// line box equal the em-square, so glyph ink that sticks out — Segoe UI on
// Windows is ~1.33em — gets shaved. 1.35 leaves room; the shell still owns
// row height, so the extra leading just centers.
export const SIDEBAR_TRUNCATED_LEADING = 'leading-[1.35]' as const
const rowLabel = cn('min-w-0 truncate text-[0.8125rem] text-(--ui-text-secondary)', SIDEBAR_TRUNCATED_LEADING)
/** Inbox-style card (workspace + age, title + preview, model + size). */
export const SIDEBAR_ROW_CARD_MIN_H = 'min-h-[3.375rem]' as const
/** Codicon size in sidebar row leads — matches the file tree (`tree.tsx`). */
export const SIDEBAR_LEAD_ICON_SIZE = '0.875rem' as const
// Row geometry lives in `row-geometry.ts` — see that file for why each class
// belongs to the box it belongs to. Re-exported here because this module is
// where callers already look for row chrome.
export { SIDEBAR_LEAD_ICON_SIZE, SIDEBAR_ROW_CARD_MIN_H, SIDEBAR_TRUNCATED_LEADING } from './row-geometry'
/** Vertical stack of rows (gap-px, single column). */
export function SidebarRowStack({ className, ...props }: React.ComponentProps<'div'>) {
@@ -100,7 +79,12 @@ export function SidebarRowShell({
}: React.ComponentProps<'div'> & { actions?: React.ReactNode; actionsClassName?: string }) {
return (
<div
className={cn(rowMinH, rowPadTrail, 'grid grid-cols-[minmax(0,1fr)_auto] items-stretch rounded-md', className)}
className={cn(
SIDEBAR_ROW_MIN_H,
SIDEBAR_ROW_PAD_TRAIL,
'grid grid-cols-[minmax(0,1fr)_auto] items-stretch rounded-md',
className
)}
{...props}
>
{children}
@@ -115,12 +99,12 @@ export function SidebarRowShell({
/** Multi-control left cluster (project rows). */
export function SidebarRowCluster({ className, ...props }: React.ComponentProps<'div'>) {
return <div className={cn(rowInset, className)} {...props} />
return <div className={cn(SIDEBAR_ROW_INSET, className)} {...props} />
}
/** Session row main tap target. */
export function SidebarRowBody({ className, ...props }: React.ComponentProps<'button'>) {
return <RowButton className={cn(rowInset, 'bg-transparent text-left', className)} {...props} />
return <RowButton className={cn(SIDEBAR_ROW_INSET, 'bg-transparent text-left', className)} {...props} />
}
/** Tappable label — underline/truncate live on the inner span, not the button. */
@@ -132,19 +116,19 @@ export function SidebarRowLink({
}: React.ComponentProps<'button'> & { labelClassName?: string }) {
return (
<RowButton className={cn('min-w-0 shrink bg-transparent p-0 text-left', className)} {...props}>
<span className={cn(rowLabel, labelClassName)}>{children}</span>
<span className={cn(SIDEBAR_ROW_LABEL, labelClassName)}>{children}</span>
</RowButton>
)
}
/** Fixed leading column (dot, icon, drag handle). */
export function SidebarRowLead({ className, ...props }: React.ComponentProps<'span'>) {
return <span className={cn(rowLead, className)} {...props} />
return <span className={cn(SIDEBAR_ROW_LEAD, className)} {...props} />
}
/** Standard row label typography. */
export function SidebarRowLabel({ className, ...props }: React.ComponentProps<'span'>) {
return <span className={cn(rowLabel, className)} {...props} />
return <span className={cn(SIDEBAR_ROW_LABEL, className)} {...props} />
}
/** What a group's sessions add up to, for the Show options that count something. */
@@ -1,11 +1,21 @@
import type { DesktopRegistryConnection } from '@/global'
import { Cloud, Monitor, Network, Terminal } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { SIDEBAR_ROW_LEAD } from './row-geometry'
// One glyph per connection kind — device, cloud, network, terminal — shared by
// the statusbar switcher, its menu, and the fleet profile rail so a gateway
// looks the same wherever it is named. Dependency-free on purpose (icons and
// a type only) so light components can use it without pulling in stores.
export function ConnectionGlyph({ connection }: { connection: Pick<DesktopRegistryConnection, 'kind'> }) {
// the statusbar switcher, its menu, the fleet profile rail and the Bots rail so
// a gateway looks the same wherever it is named. Dependency-free on purpose
// (icons, a type and class strings) so light components can use it without
// pulling in stores.
export function ConnectionGlyph({
className,
connection
}: {
className?: string
connection: Pick<DesktopRegistryConnection, 'kind'>
}) {
const Icon =
connection.kind === 'local'
? Monitor
@@ -18,7 +28,7 @@ export function ConnectionGlyph({ connection }: { connection: Pick<DesktopRegist
return (
<span
aria-hidden="true"
className="grid size-3.5 shrink-0 place-items-center text-(--ui-text-quaternary)"
className={cn(SIDEBAR_ROW_LEAD, 'text-(--ui-text-quaternary)', className)}
data-connection-kind={connection.kind}
data-slot="connection-glyph"
>
@@ -0,0 +1,55 @@
import { cn } from '@/lib/utils'
// The sidebar row's measurements, in one place. The session row is canonical
// and everything else composes these — a row that spells its own geometry
// floats 1–2px off the sessions above it, which is how the drift starts.
//
// Split out of `chrome.tsx` so the literals are reachable without the
// components: light, store-free callers (a connection glyph) and the plugin
// SDK both need the lead cell, and neither should have to pull nanostores to
// line up with a session row.
//
// Height lives ONLY on the shell (`SIDEBAR_ROW_MIN_H`). Inset children stretch
// to fill the cell and center content internally — never `items-center` on the
// shell grid, or short clusters (projects) float off sessions.
//
// `SIDEBAR_ROW_PAD_X` is the BODY's padding: the lead's inset, plus the gap the
// label keeps from the actions column, both inside the row's click target.
// `SIDEBAR_ROW_PAD_TRAIL` is the row's own trailing inset and belongs to the
// SHELL — the only box containing both the actions column AND the card's
// in-body cluster, so one class insets every trailing thing a row can render.
// Owned anywhere else, the age / chips / kebab sit flush on the border box,
// which is exactly where a working row paints its arc (`.arc-row` has zero
// standoff) — the ring ran through the text.
export const SIDEBAR_ROW_MIN_H = 'min-h-[1.625rem]' as const
export const SIDEBAR_ROW_PAD_X = 'pl-2 pr-2' as const
export const SIDEBAR_ROW_PAD_TRAIL = 'pr-2' as const
export const SIDEBAR_ROW_GAP = 'gap-1.5' as const
/** Fixed leading cell — dot, icon, drag handle. Every row's label starts at the
* same left edge because they all reserve this exact box. */
export const SIDEBAR_ROW_LEAD = 'grid size-3.5 shrink-0 place-items-center' as const
export const SIDEBAR_ROW_INSET = cn(
SIDEBAR_ROW_PAD_X,
SIDEBAR_ROW_GAP,
'flex h-full min-w-0 items-center self-stretch py-0.5'
)
// `truncate` is overflow:hidden. `leading-none` (line-height: 1) makes the line
// box equal the em-square, so glyph ink that sticks out — Segoe UI on Windows
// is ~1.33em — gets shaved. 1.35 leaves room; the shell still owns row height,
// so the extra leading just centers.
export const SIDEBAR_TRUNCATED_LEADING = 'leading-[1.35]' as const
export const SIDEBAR_ROW_LABEL = cn(
'min-w-0 truncate text-[0.8125rem] text-(--ui-text-secondary)',
SIDEBAR_TRUNCATED_LEADING
)
/** Inbox-style card (workspace + age, title + preview, model + size). */
export const SIDEBAR_ROW_CARD_MIN_H = 'min-h-[3.375rem]' as const
/** Codicon size in sidebar row leads — matches the file tree (`tree.tsx`). */
export const SIDEBAR_LEAD_ICON_SIZE = '0.875rem' as const
+3 -34
View File
@@ -45,6 +45,8 @@ import type { Pluggable } from 'unified'
import { SKIP, visitParents } from 'unist-util-visit-parents'
import type { VFile } from 'vfile'
import { LruCache } from '@/lib/lru-cache'
interface KatexMemoOptions {
/**
* Color used for KaTeX errors when we fall back to the lenient parser.
@@ -69,40 +71,7 @@ type CachedRender = ElementContent[]
const CACHE_LIMIT = 512
class LruCache<K, V> {
private readonly map = new Map<K, V>()
get(key: K): undefined | V {
const value = this.map.get(key)
if (value === undefined) {
return undefined
}
// Refresh recency by re-inserting at the tail. Map iteration order is
// insertion order, so the oldest entry is at the head.
this.map.delete(key)
this.map.set(key, value)
return value
}
set(key: K, value: V): void {
if (this.map.has(key)) {
this.map.delete(key)
} else if (this.map.size >= CACHE_LIMIT) {
const oldest = this.map.keys().next().value
if (oldest !== undefined) {
this.map.delete(oldest)
}
}
this.map.set(key, value)
}
}
const cache = new LruCache<string, CachedRender>()
const cache = new LruCache<string, CachedRender>(CACHE_LIMIT)
function cacheKey(displayMode: boolean, value: string): string {
// `\u0001` is a control character that (a) won't appear in normal
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import { LruCache } from './lru-cache'
describe('LruCache', () => {
it('never grows past its ceiling', () => {
const cache = new LruCache<number, string>(3)
for (let key = 0; key < 100; key += 1) {
cache.set(key, `v${key}`)
}
expect(cache.size).toBe(3)
expect([...cache.keys()]).toEqual([97, 98, 99])
})
it('evicts the least recently READ entry, not the least recently written', () => {
const cache = new LruCache<string, number>(2)
cache.set('a', 1)
cache.set('b', 2)
cache.get('a')
cache.set('c', 3)
expect(cache.get('a')).toBe(1)
expect(cache.get('b')).toBeUndefined()
expect(cache.get('c')).toBe(3)
})
it('overwrites in place rather than evicting a live neighbour', () => {
const cache = new LruCache<string, number>(2)
cache.set('a', 1)
cache.set('b', 2)
cache.set('a', 9)
expect(cache.size).toBe(2)
expect(cache.get('a')).toBe(9)
expect(cache.get('b')).toBe(2)
})
it('answers membership without promoting the entry', () => {
const cache = new LruCache<string, number>(2)
cache.set('a', 1)
cache.set('b', 2)
expect(cache.has('a')).toBe(true)
cache.set('c', 3)
expect(cache.has('a')).toBe(false)
expect(cache.has('b')).toBe(true)
})
it('frees the slot on delete', () => {
const cache = new LruCache<string, number>(2)
cache.set('a', 1)
expect(cache.delete('a')).toBe(true)
expect(cache.delete('a')).toBe(false)
expect(cache.size).toBe(0)
cache.set('b', 2)
cache.set('c', 3)
expect([...cache.keys()]).toEqual(['b', 'c'])
})
})
+67
View File
@@ -0,0 +1,67 @@
/**
* A `Map` with a ceiling. The renderer stays open for days, so a module-level
* cache keyed by anything user-driven (a URL, a connection id, an equation)
* grows for the life of the window unless something evicts.
*
* Only for values that can be REGENERATED: eviction costs a recompute or a
* refetch, never correctness. State whose loss changes behaviour — an unsent
* draft, an unread watermark, a freshness fence — is a record, not a cache,
* and belongs in a plain `Map`.
*/
export class LruCache<K, V> {
private readonly entries = new Map<K, V>()
private readonly max: number
constructor(max: number) {
this.max = max
}
get size(): number {
return this.entries.size
}
/** Read and mark most-recently-used. */
get(key: K): undefined | V {
const value = this.entries.get(key)
if (value === undefined) {
return undefined
}
// Map iterates in insertion order, so re-inserting moves the entry to the
// tail and leaves the least recently used one at the head.
this.entries.delete(key)
this.entries.set(key, value)
return value
}
/** Membership WITHOUT touching recency — for the `has` / `set` / `get`
* populate-on-miss shape, where the `get` does the touching. */
has(key: K): boolean {
return this.entries.has(key)
}
/** Write, evicting the least recently used entry once the cache is full. */
set(key: K, value: V): void {
if (this.entries.has(key)) {
this.entries.delete(key)
} else if (this.entries.size >= this.max) {
const oldest = this.entries.keys().next().value
if (oldest !== undefined) {
this.entries.delete(oldest)
}
}
this.entries.set(key, value)
}
delete(key: K): boolean {
return this.entries.delete(key)
}
keys(): IterableIterator<K> {
return this.entries.keys()
}
}
+15 -1
View File
@@ -1351,6 +1351,16 @@ export {
* circle beside it — a plugin's own dot inverts core's color vocabulary the
* moment either side moves. */
export { SessionStatusDot, type SessionStatusDotProps } from '@/app/chat/session-status-dot'
/** The sidebar row's leading cell — the fixed box a dot, icon or handle sits in.
* Reserve it and your label starts on the same left edge as every session row
* above you; spell the classes yourself and the row drifts. The session row is
* canonical; `row-geometry.ts` explains what each measurement belongs to. */
export { SidebarRowLead } from '@/app/chat/sidebar/chrome'
/** One glyph per gateway kind — device, cloud, terminal, network. The statusbar
* switcher, the fleet profile rail and any plugin rail listing gateways share
* it, so a connection looks the same wherever it is named. */
export { ConnectionGlyph } from '@/app/chat/sidebar/connection-glyph'
export { SIDEBAR_ROW_LEAD, SIDEBAR_TRUNCATED_LEADING } from '@/app/chat/sidebar/row-geometry'
export { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib'
/** THE master-detail toolkit core uses for list+inspector surfaces (Scheduled
* jobs, Kanban, …): a dense left `PanelList` of `PanelListRow`s beside a
@@ -1379,6 +1389,7 @@ export {
PanelSectionLabel
} from '@/app/overlays/panel'
export { type RouteContribution, ROUTES_AREA, SIDEBAR_NAV_AREA, type SidebarNavContribution } from '@/app/routes'
/** THE full per-toolset config panel core Settings renders — provider picker,
* env vars / API keys, model catalog picker, and post-setup runners. Route-
* decoupled (the "manage keys" deep link is a no-op outside the router); pass
@@ -1397,7 +1408,6 @@ export {
type ModelMenuController
} from '@/app/shell/model-catalog-menu'
export type { StatusbarItem } from '@/app/shell/statusbar-controls'
export type { TitlebarTool } from '@/app/shell/titlebar-controls'
/** THE whole Capabilities surface (Skills / Tools / MCP tabs, installed
* lists, full-skill detail pane, embedded hub picker with one-click
@@ -1560,6 +1570,10 @@ export type { HermesOpenTarget } from '@/lib/hermes-open-target'
export * as icons from '@/lib/icons'
export { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
export { formatModifierToken } from '@/lib/keybinds/combo'
/** A `Map` with a ceiling, for the module-level caches a plugin keeps across
* a renderer that stays open for days. Only for values that can be
* regenerated — eviction costs a recompute or a refetch, never correctness. */
export { LruCache } from '@/lib/lru-cache'
/** The app's deterministic identity color for a name (profiles, assignees,
* authors), its translucent tag fill, and the curated picker swatches — so
* plugin-rendered identities read the same hue as everywhere else. The