diff --git a/apps/desktop/src/app/chat/sidebar/chrome.tsx b/apps/desktop/src/app/chat/sidebar/chrome.tsx
index 5688736004..3b6d90c9bb 100644
--- a/apps/desktop/src/app/chat/sidebar/chrome.tsx
+++ b/apps/desktop/src/app/chat/sidebar/chrome.tsx
@@ -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 {children}
}
-// ── 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 (
{children}
@@ -115,12 +99,12 @@ export function SidebarRowShell({
/** Multi-control left cluster (project rows). */
export function SidebarRowCluster({ className, ...props }: React.ComponentProps<'div'>) {
- return
+ return
}
/** Session row main tap target. */
export function SidebarRowBody({ className, ...props }: React.ComponentProps<'button'>) {
- return
+ return
}
/** 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 (
- {children}
+ {children}
)
}
/** Fixed leading column (dot, icon, drag handle). */
export function SidebarRowLead({ className, ...props }: React.ComponentProps<'span'>) {
- return
+ return
}
/** Standard row label typography. */
export function SidebarRowLabel({ className, ...props }: React.ComponentProps<'span'>) {
- return
+ return
}
/** What a group's sessions add up to, for the Show options that count something. */
diff --git a/apps/desktop/src/app/chat/sidebar/connection-glyph.tsx b/apps/desktop/src/app/chat/sidebar/connection-glyph.tsx
index f553d7f6f0..4fd338145a 100644
--- a/apps/desktop/src/app/chat/sidebar/connection-glyph.tsx
+++ b/apps/desktop/src/app/chat/sidebar/connection-glyph.tsx
@@ -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
}) {
+// 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
+}) {
const Icon =
connection.kind === 'local'
? Monitor
@@ -18,7 +28,7 @@ export function ConnectionGlyph({ connection }: { connection: Pick
diff --git a/apps/desktop/src/app/chat/sidebar/row-geometry.ts b/apps/desktop/src/app/chat/sidebar/row-geometry.ts
new file mode 100644
index 0000000000..f6be97af36
--- /dev/null
+++ b/apps/desktop/src/app/chat/sidebar/row-geometry.ts
@@ -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
diff --git a/apps/desktop/src/lib/katex-memo.ts b/apps/desktop/src/lib/katex-memo.ts
index 7143fbff90..a7bba94f9b 100644
--- a/apps/desktop/src/lib/katex-memo.ts
+++ b/apps/desktop/src/lib/katex-memo.ts
@@ -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 {
- private readonly map = new Map()
-
- 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()
+const cache = new LruCache(CACHE_LIMIT)
function cacheKey(displayMode: boolean, value: string): string {
// `\u0001` is a control character that (a) won't appear in normal
diff --git a/apps/desktop/src/lib/lru-cache.test.ts b/apps/desktop/src/lib/lru-cache.test.ts
new file mode 100644
index 0000000000..f052913092
--- /dev/null
+++ b/apps/desktop/src/lib/lru-cache.test.ts
@@ -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(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(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(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(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(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'])
+ })
+})
diff --git a/apps/desktop/src/lib/lru-cache.ts b/apps/desktop/src/lib/lru-cache.ts
new file mode 100644
index 0000000000..1036bc219e
--- /dev/null
+++ b/apps/desktop/src/lib/lru-cache.ts
@@ -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 {
+ private readonly entries = new Map()
+ 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 {
+ return this.entries.keys()
+ }
+}
diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts
index ae6e5ae4fd..0e597e4198 100644
--- a/apps/desktop/src/sdk/index.ts
+++ b/apps/desktop/src/sdk/index.ts
@@ -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