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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user