feat(desktop): Capabilities foundation — shared utils, master-detail, editors, primitives

The reusable base the Capabilities rework sits on:
- lib/{text,time,format,json-format}: consolidate ~30 hand-rolled string/date/
  number/JSON helpers behind one set of tested utilities.
- master-detail scaffold (MasterDetail / ListColumn / DetailColumn / DetailPane /
  CapRow / ListStrip / ToolChip / ICON_BUTTON) + row-hover; tabs-as-data
  PageSearchShell; EmptyState + ErrorBanner; a shared LogTail terminal surface.
- framed CodeEditor + JsonDocumentEditor, wired into every in-app markdown/JSON
  edit surface (profile SOUL.md, memory nodes, right-click sidebar profile).
- shared React Query cache helper (writeCache) + per-profile-switch/ debounce hooks.
This commit is contained in:
Brooklyn Nicholson 2026-07-03 05:08:13 -05:00
parent 662426ec3d
commit e0325cf769
23 changed files with 1356 additions and 85 deletions

View file

@ -22,17 +22,21 @@ import { useStore } from '@nanostores/react'
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { CodeEditor } from '@/components/chat/code-editor'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ColorSwatches } from '@/components/ui/color-swatches'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { getProfileSoul, updateProfileSoul } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { PROFILE_SWATCHES, profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import {
$activeGatewayProfile,
$profileColors,
@ -106,6 +110,7 @@ export function ProfileRail() {
const [createOpen, setCreateOpen] = useState(false)
const [pendingRename, setPendingRename] = useState<null | ProfileInfo>(null)
const [pendingDelete, setPendingDelete] = useState<null | ProfileInfo>(null)
const [pendingSoul, setPendingSoul] = useState<null | string>(null)
const scrollRef = useRef<HTMLDivElement>(null)
// Too many profiles for the square strip → collapse to the select. Declared
@ -277,6 +282,7 @@ export function ProfileRail() {
key={profile.name}
label={profile.name}
onDelete={() => setPendingDelete(profile)}
onEditSoul={() => setPendingSoul(profile.name)}
onRecolor={color => setProfileColor(profile.name, color)}
onRename={() => setPendingRename(profile)}
onSelect={() => selectProfile(profile.name)}
@ -322,10 +328,89 @@ export function ProfileRail() {
open={pendingDelete !== null}
profile={pendingDelete}
/>
<EditSoulDialog onClose={() => setPendingSoul(null)} profileName={pendingSoul} />
</div>
)
}
// Right-click → Edit SOUL.md for a sidebar profile — the same in-app markdown
// editor as the memory-graph node edit, so a profile's persona is editable
// without opening the Manage overlay.
function EditSoulDialog({ onClose, profileName }: { onClose: () => void; profileName: null | string }) {
const { t } = useI18n()
const p = t.profiles
const [content, setContent] = useState('')
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!profileName) {
return
}
let cancelled = false
setLoading(true)
setContent('')
getProfileSoul(profileName)
.then(soul => !cancelled && setContent(soul.content))
.catch(err => !cancelled && notifyError(err, p.failedLoadSoul))
.finally(() => !cancelled && setLoading(false))
return () => void (cancelled = true)
}, [p, profileName])
const save = async () => {
if (!profileName) {
return
}
setSaving(true)
try {
await updateProfileSoul(profileName, content)
notify({ kind: 'success', title: p.soulSaved, message: profileName })
onClose()
} catch (err) {
notifyError(err, p.failedSaveSoul)
} finally {
setSaving(false)
}
}
return (
<Dialog onOpenChange={open => !open && !saving && onClose()} open={profileName !== null}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{profileName} · SOUL.md</DialogTitle>
</DialogHeader>
<div className="h-80">
{!loading && profileName && (
<CodeEditor
filePath="SOUL.md"
framed
initialValue={content}
key={profileName}
onCancel={() => !saving && onClose()}
onChange={setContent}
onSave={() => void save()}
/>
)}
</div>
<DialogFooter>
<Button disabled={saving} onClick={onClose} type="button" variant="ghost">
{t.common.cancel}
</Button>
<Button disabled={saving || loading} onClick={() => void save()}>
{saving ? p.saving : p.saveSoul}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// The "+" create button, shared by both rail render paths.
function AddProfileButton({ label, onClick }: { label: string; onClick: () => void }) {
return (
@ -427,6 +512,7 @@ interface ProfileSquareProps {
onSelect: () => void
onRecolor: (color: null | string) => void
onRename: () => void
onEditSoul: () => void
onDelete: () => void
}
@ -441,7 +527,16 @@ const LONG_PRESS_MS = 450
// right-click to rename/delete. The button carries both the tooltip and
// context-menu triggers via nested asChild Slots, so a single element keeps the
// dnd listeners, hover tip, and right-click menu.
function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, onSelect }: ProfileSquareProps) {
function ProfileSquare({
active,
color,
label,
onDelete,
onEditSoul,
onRecolor,
onRename,
onSelect
}: ProfileSquareProps) {
const { t } = useI18n()
const p = t.profiles
const hue = color ?? 'var(--ui-text-quaternary)'
@ -565,8 +660,12 @@ function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, on
<span>{p.color}</span>
</ContextMenuItem>
<ContextMenuItem onSelect={onRename}>
<Codicon name="text-size" size="0.875rem" />
<span>{p.renameMenu}</span>
</ContextMenuItem>
<ContextMenuItem onSelect={onEditSoul}>
<Codicon name="edit" size="0.875rem" />
<span>{p.rename}</span>
<span>{p.editSoul}</span>
</ContextMenuItem>
<ContextMenuItem
className="text-destructive focus:text-destructive"

View file

@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query'
import { getHermesConfigRecord } from '@/hermes'
import { queryClient, writeCache } from '@/lib/query-client'
import type { HermesConfigRecord } from '@/types/hermes'
// One shared cache for the whole profile config record (`GET /api/config`).
// Every settings surface (MCP, model, config) reads and writes through this key
// so a save in one shows in the others, and revisiting a tab paints the cache
// instead of blanking on a fresh fetch.
//
// Distinct from session/hooks/use-hermes-config.ts, which is side-effecting —
// it pushes personality/cwd/voice/… into the session stores for live chat.
export const HERMES_CONFIG_KEY = ['hermes-config-record'] as const
// staleTime 0 → serve cache instantly, background-revalidate on every mount.
export const useHermesConfigRecord = () =>
useQuery({ queryKey: HERMES_CONFIG_KEY, queryFn: getHermesConfigRecord, staleTime: 0 })
export const setHermesConfigCache = writeCache<HermesConfigRecord>(HERMES_CONFIG_KEY)
export const invalidateHermesConfig = () => queryClient.invalidateQueries({ queryKey: HERMES_CONFIG_KEY })

View file

@ -0,0 +1,15 @@
import { useEffect, useState } from 'react'
/** Debounce a fast-changing value (search input, slider, ) so effects/queries
* keyed on it only fire once the value settles. */
export function useDebounced<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const handle = setTimeout(() => setDebounced(value), delayMs)
return () => clearTimeout(handle)
}, [value, delayMs])
return debounced
}

View file

@ -0,0 +1,24 @@
import { useStore } from '@nanostores/react'
import { useEffect, useRef } from 'react'
import { $activeGatewayProfile } from '@/store/profile'
/** Run `onSwitch` when the active gateway profile changes never on first
* mount. For dropping per-profile view state (probes, cached usage, drafts)
* when the backend the app talks to swaps underneath a still-mounted view. */
export function useOnProfileSwitch(onSwitch: () => void): void {
const profile = useStore($activeGatewayProfile)
const first = useRef(true)
useEffect(() => {
if (first.current) {
first.current = false
return
}
onSwitch()
// Fire on profile change only; onSwitch identity is intentionally ignored.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [profile])
}

View file

@ -0,0 +1,404 @@
import { useStore } from '@nanostores/react'
import { type ReactNode, type PointerEvent as ReactPointerEvent, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { RowButton } from '@/components/ui/row-button'
import { Switch } from '@/components/ui/switch'
import { cn } from '@/lib/utils'
import { $paneHeightOverride, $paneState, setPaneHeightOverride } from '@/store/panes'
// Monospace capability chip (tool name, transport, …). Shared by the Skills
// and MCP tabs so the pill reads identically everywhere.
export function ToolChip({ children, title }: { children: ReactNode; title?: string }) {
return (
<span
className="rounded-md bg-(--ui-bg-quinary) px-1.5 py-0.5 font-mono text-[0.65rem] text-(--ui-text-tertiary)"
title={title}
>
{children}
</span>
)
}
// Masterdetail page scaffolding (14rem rail, p-2, centered max-w-2xl detail):
// dense uniform rows on the left, roomy inspector on the right. Shared by the
// Capabilities and Messaging pages — pages bring their own row/detail content
// (CapRow here is the toggle-row flavor; Messaging has its own avatar rows).
// `pane` docks a full-bleed work surface (editor, log viewer, terminal) below
// the whole masterdetail grid — the app's bottom-pane pattern, page-local.
// The wide-rail track shared by every Capabilities tab (skills/tools/mcp) so
// the three read as one page. Exported for pages that build their own grid
// (the MCP tab's cursor-driven layout) but must stay in step.
export const MASTER_DETAIL_WIDE_COLS = 'sm:grid-cols-[minmax(0,0.75fr)_minmax(0,1fr)]'
// `split="wide"` gives list-heavy pages a rail that shares the page with a
// sparse detail (skills/tools/mcp); the default 14rem rail suits pages whose
// detail carries the weight (messaging).
export function MasterDetail({
children,
pane,
split = 'rail'
}: {
children: ReactNode
pane?: ReactNode
split?: 'rail' | 'wide'
}) {
return (
<div className="flex h-full min-h-0 flex-col">
<div
className={cn(
'grid min-h-0 flex-1 grid-cols-1',
split === 'wide' ? MASTER_DETAIL_WIDE_COLS : 'sm:grid-cols-[14rem_minmax(0,1fr)]'
)}
>
{children}
</div>
{pane}
</div>
)
}
export function ListColumn({ children, header }: { children: ReactNode; header?: ReactNode }) {
return (
<aside className="flex min-h-0 flex-col p-2">
{header}
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-gutter:stable]">{children}</div>
</aside>
)
}
// `footer` pins one quiet caption below the scroll (e.g. "changes apply to
// new sessions") so per-item detail components never repeat it themselves.
// `actionBar` pins a real control row (save/toggle) below the scroll instead.
export function DetailColumn({
actionBar,
children,
footer
}: {
actionBar?: ReactNode
children: ReactNode
footer?: ReactNode
}) {
return (
<main className="flex min-h-0 flex-col overflow-hidden">
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-gutter:stable]">
<div className="mx-auto max-w-2xl space-y-5 px-5 py-4">{children}</div>
</div>
{footer && (
<div className="mx-auto w-full max-w-2xl shrink-0 px-5 pb-3 pt-1.5 text-right text-[0.65rem] text-muted-foreground/50">
{footer}
</div>
)}
{actionBar && (
<footer className="shrink-0 bg-(--ui-chat-surface-background) px-5 py-2.5">
<div className="mx-auto flex max-w-2xl flex-wrap items-center gap-2">{actionBar}</div>
</footer>
)}
</main>
)
}
// Full-bleed docked bottom pane: title strip + actions + close, drag-resizable
// on its top edge like every other pane (height persisted through the same
// pane-state store the terminal uses). No min height — drag (or the chevron)
// collapses it down to just the header. Content swaps freely: JSON editor
// today, stdio/log viewers tomorrow.
const DETAIL_PANE_DEFAULT_BODY_PX = 288
const DETAIL_PANE_MAX_VH = 0.7
const DETAIL_PANE_COLLAPSED_PX = 4
// Ghost icon-button on the kebab-trigger scale (pane headers, list-strip menu,
// per-server MCP actions, JSON editor format button). MUST stay a class string
// (not a CSS @utility): the leading `size-5` is what tailwind-merge uses to
// strip <Button size="icon">'s larger built-in size — a custom utility class
// isn't size-merge-aware, so Button's icon size would leak and blow it up.
// Compose extra state (data-[state=open], hover:text-destructive) with cn().
export const ICON_BUTTON =
'size-5 cursor-pointer rounded-[4px] text-muted-foreground/70 hover:bg-(--ui-control-active-background) hover:text-foreground'
export function DetailPane({
actions,
children,
defaultCollapsed = false,
defaultHeight = DETAIL_PANE_DEFAULT_BODY_PX,
id,
onClose,
title
}: {
actions?: ReactNode
children: ReactNode
/** Start collapsed to the header the first time this pane is ever shown.
* Only seeds when the id has no saved state a later expand/collapse
* persists and wins, so it's "collapsed by default", not "always collapsed". */
defaultCollapsed?: boolean
/** Default body height in px (before any user resize). */
defaultHeight?: number
/** Pane-store key — height overrides persist under it. */
id: string
/** Omit for permanent panes (collapsible to the header, never removed). */
onClose?: () => void
title: ReactNode
}) {
const override = useStore($paneHeightOverride(id))
useEffect(() => {
if (defaultCollapsed && $paneState(id).get() === undefined) {
setPaneHeightOverride(id, 0)
}
}, [defaultCollapsed, id])
const height = override ?? defaultHeight
const collapsed = height <= DETAIL_PANE_COLLAPSED_PX
// Sash drag mirrors the shell's y-axis pane resize: pointer capture on the
// top edge, clamped to [0, 70vh]; double-click resets to the default.
const [dragging, setDragging] = useState(false)
const startDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault()
const startY = event.clientY
const startHeight = height
const max = Math.round(window.innerHeight * DETAIL_PANE_MAX_VH)
setDragging(true)
const onMove = (move: globalThis.PointerEvent) => {
setPaneHeightOverride(id, Math.min(max, Math.max(0, Math.round(startHeight + (startY - move.clientY)))))
}
const onUp = () => {
window.removeEventListener('pointermove', onMove)
setDragging(false)
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp, { once: true })
}
return (
<section className="relative flex shrink-0 flex-col border-t border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background)">
<div
className="group/sash absolute inset-x-0 top-0 z-10 h-1 -translate-y-1/2 cursor-row-resize"
onDoubleClick={() => setPaneHeightOverride(id, undefined)}
onPointerDown={startDrag}
>
<div
className={cn(
'absolute inset-x-0 top-1/2 h-px -translate-y-1/2 transition-colors',
dragging ? 'bg-(--ui-stroke-secondary)' : 'group-hover/sash:bg-(--ui-stroke-secondary)'
)}
/>
</div>
<header className="flex h-9 shrink-0 items-center gap-2 px-3">
<span className="min-w-0 truncate text-xs font-medium text-foreground">{title}</span>
<div className="ml-auto flex shrink-0 items-center gap-1.5">
{actions}
<Button
aria-expanded={!collapsed}
// TODO(i18n): literals until the UX settles.
aria-label={collapsed ? 'Expand' : 'Collapse'}
className={ICON_BUTTON}
onClick={() => setPaneHeightOverride(id, collapsed ? undefined : 0)}
size="icon"
variant="ghost"
>
<Codicon name={collapsed ? 'chevron-up' : 'chevron-down'} size="0.8125rem" />
</Button>
{onClose && (
// TODO(i18n): literal until the UX settles.
<Button aria-label="Close" className={ICON_BUTTON} onClick={onClose} size="icon" variant="ghost">
<Codicon name="close" size="0.8125rem" />
</Button>
)}
</div>
</header>
<div className="min-h-0 overflow-hidden" style={{ height: collapsed ? 0 : height }}>
{children}
</div>
</section>
)
}
// One-line control strip pinned above the list: sort/primary action on the
// left, overflow kebab on the right.
export function ListStrip({ left, right }: { left?: ReactNode; right?: ReactNode }) {
return (
<div className="mb-1 flex h-6 shrink-0 items-center justify-between gap-2 pl-2 pr-1">
<div className="flex min-w-0 items-center gap-1.5">{left}</div>
<div className="flex shrink-0 items-center gap-1.5">{right}</div>
</div>
)
}
export interface ListStripMenuItem {
disabled?: boolean
label: string
onSelect: () => void
}
export interface ListStripMenuToggle {
checked: boolean
disabled?: boolean
label: string
onToggle: (checked: boolean) => void
}
// Overflow kebab for list-wide actions. `toggle` renders as the first row —
// one label + switch line covering enable-all/disable-all (checked = every
// visible item on; mixed reads as off so one flip always means "all on").
export function ListStripMenu({
items = [],
label,
toggle
}: {
items?: ListStripMenuItem[]
label: string
toggle?: ListStripMenuToggle
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={label}
className={cn(
ICON_BUTTON,
'data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground'
)}
size="icon"
title={label}
variant="ghost"
>
<Codicon name="kebab-vertical" size="0.8125rem" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44" sideOffset={6}>
{toggle && (
<DropdownMenuItem
disabled={toggle.disabled}
onSelect={event => {
// Keep the menu open so the switch is seen flipping.
event.preventDefault()
toggle.onToggle(!toggle.checked)
}}
>
<span className="min-w-0 flex-1 truncate">{toggle.label}</span>
<Switch
checked={toggle.checked}
className={cn('pointer-events-none shrink-0', !toggle.checked && 'opacity-60')}
size="xs"
tabIndex={-1}
/>
</DropdownMenuItem>
)}
{items.map(item => (
<DropdownMenuItem disabled={item.disabled} key={item.label} onSelect={item.onSelect}>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
export function ListStripButton({
active,
children,
disabled,
onClick
}: {
active?: boolean
children: ReactNode
disabled?: boolean
onClick: () => void
}) {
return (
<button
className={cn(
'cursor-pointer text-[0.68rem] font-medium transition-colors disabled:opacity-40',
active ? 'text-foreground' : 'text-muted-foreground/70 hover:text-foreground'
)}
disabled={disabled}
onClick={onClick}
type="button"
>
{children}
</button>
)
}
interface CapRowProps {
active: boolean
busy?: boolean
enabled: boolean
meta?: ReactNode
onSelect: () => void
onToggle: (checked: boolean) => void
rowId?: string
/** Second line under the name (category, description, status). Rows grow to h-11. */
subtitle?: ReactNode
title: string
toggleLabel: string
}
// The one row used by all three lists. Fixed height, always-visible switch —
// state reads from the switch + dimmed title, toggling never requires
// selecting first. Off rows dim; the switch itself dims when off.
export function CapRow({
active,
busy,
enabled,
meta,
onSelect,
onToggle,
rowId,
subtitle,
title,
toggleLabel
}: CapRowProps) {
return (
<div
className={cn(
'group/row row-hover flex w-full shrink-0 items-center rounded-md hover:text-foreground',
subtitle ? 'h-11' : 'h-8',
active ? 'bg-(--ui-row-active-background) text-foreground' : 'text-(--ui-text-secondary)'
)}
id={rowId}
>
<RowButton
className="flex h-full min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md pl-2 pr-1.5 text-left"
onClick={onSelect}
>
<span className="min-w-0 flex-1">
<span
className={cn(
'block truncate text-[0.78rem]',
enabled ? 'font-medium text-foreground/85' : 'font-normal text-muted-foreground/60'
)}
>
{title}
</span>
{subtitle != null && (
<span className="flex min-w-0 items-center gap-1 text-[0.62rem] text-muted-foreground/50">
{typeof subtitle === 'string' ? <span className="truncate">{subtitle}</span> : subtitle}
</span>
)}
</span>
{meta != null && (
<span className="shrink-0 rounded bg-(--ui-bg-quinary) px-1 py-px text-[0.6rem] tabular-nums leading-3.5 text-(--ui-text-tertiary)">
{meta}
</span>
)}
</RowButton>
<Switch
aria-label={toggleLabel}
checked={enabled}
className={cn('mr-1.5 shrink-0 cursor-pointer', !enabled && 'opacity-60')}
disabled={busy}
onCheckedChange={onToggle}
size="xs"
title={toggleLabel}
/>
</div>
)
}

View file

@ -92,6 +92,8 @@ interface PanelListProps {
onSearchChange?: (value: string) => void
searchLabel?: string
searchPlaceholder?: string
/** Data-derived rotating placeholder nudges (see SearchField.hints). */
searchHints?: string[]
searchValue?: string
}
@ -104,6 +106,7 @@ export function PanelList({
onSearchChange,
searchLabel,
searchPlaceholder,
searchHints,
searchValue
}: PanelListProps) {
return (
@ -112,6 +115,7 @@ export function PanelList({
<SearchField
aria-label={searchLabel ?? searchPlaceholder ?? ''}
containerClassName="mb-1 w-full shrink-0"
hints={searchHints}
onChange={onSearchChange}
placeholder={searchPlaceholder ?? ''}
value={searchValue ?? ''}
@ -156,10 +160,8 @@ export function PanelListRow({
return (
<div
className={cn(
'group/row relative flex h-7 w-full items-center rounded-md text-[0.78rem] transition-colors duration-100 ease-out',
active
? 'bg-(--ui-row-active-background) text-foreground'
: 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground'
'group/row row-hover relative flex h-7 w-full items-center rounded-md text-[0.78rem] hover:text-foreground',
active ? 'bg-(--ui-row-active-background) text-foreground' : 'text-(--ui-text-secondary)'
)}
data-panel-row={rowKey}
>

View file

@ -1,34 +1,110 @@
import type { ReactNode } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { SearchField } from '@/components/ui/search-field'
import { CountSkeleton } from '@/components/ui/skeleton'
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
import { compactNumber } from '@/lib/format'
import { cn } from '@/lib/utils'
// Tabs are data, not nodes: the shell owns their presentation so every page
// gets the same behavior — a centered TextTab row on wide viewports that
// collapses into a dropdown when the header can't fit both search and tabs.
export interface PageShellTab {
id: string
label: string
/** Count badge. `null` = still loading (renders a skeleton); `undefined` = no badge. */
meta?: string | number | null
}
// null = loading (pulsing chip instead of a fake 0); numbers render compact.
const metaContent = (meta: string | number | null) =>
meta === null ? <CountSkeleton /> : typeof meta === 'number' ? compactNumber(meta) : meta
interface PageSearchShellProps extends React.ComponentProps<'section'> {
children: ReactNode
/** Primary tabs shown on the top row, beside the search. */
tabs?: ReactNode
tabs?: PageShellTab[]
activeTab?: string
onTabChange?: (id: string) => void
/** Secondary filters shown full-width on their own row below (expands). */
filters?: ReactNode
onSearchChange: (value: string) => void
searchPlaceholder: string
searchTrailingAction?: ReactNode
/** Data-derived rotating placeholder nudges (see SearchField.hints). */
searchHints?: string[]
searchValue: string
/** Hide the search field when there's nothing to search (empty dataset). */
searchHidden?: boolean
}
function ShellTabs({
tabs,
activeTab,
onTabChange
}: {
tabs: PageShellTab[]
activeTab?: string
onTabChange?: (id: string) => void
}) {
const active = tabs.find(tab => tab.id === activeTab) ?? tabs[0]
return (
<>
<div className="hidden min-w-0 flex-wrap items-center justify-center gap-x-2 gap-y-1 md:flex">
{tabs.map(tab => (
<TextTab active={tab.id === activeTab} key={tab.id} onClick={() => onTabChange?.(tab.id)}>
{tab.label}
{/* Direct TextTabMeta child TextTab type-checks for it to keep the
count outside the active-underline span. */}
{tab.meta !== undefined && <TextTabMeta>{metaContent(tab.meta)}</TextTabMeta>}
</TextTab>
))}
</div>
<div className="md:hidden">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex h-7 cursor-pointer items-center gap-1 px-1 text-[length:var(--conversation-caption-font-size)] font-medium text-foreground [-webkit-app-region:no-drag]"
type="button"
>
{active.label}
{active.meta !== undefined && <TextTabMeta>{metaContent(active.meta)}</TextTabMeta>}
<Codicon className="text-muted-foreground" name="chevron-down" size="0.75rem" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-44" sideOffset={6}>
{tabs.map(tab => (
<DropdownMenuItem key={tab.id} onSelect={() => onTabChange?.(tab.id)}>
<span className="min-w-0 flex-1 truncate">{tab.label}</span>
{tab.meta !== undefined && (
<span className="text-xs text-muted-foreground">{metaContent(tab.meta)}</span>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
)
}
export function PageSearchShell({
children,
className,
tabs,
activeTab,
onTabChange,
filters,
onSearchChange,
searchPlaceholder,
searchTrailingAction,
searchHints,
searchValue,
searchHidden = false,
...props
}: PageSearchShellProps) {
const hasTabs = (tabs?.length ?? 0) > 0
return (
<section
{...props}
@ -37,9 +113,8 @@ export function PageSearchShell({
{/*
Header lives in the page body, below the window chrome (the shell floats
traffic lights over the top titlebar-height strip, which the `pt` clears
and leaves draggable). Top row: primary tabs + search. Second row:
secondary filters, full-width so they expand. Interactive bits opt out
of the drag region.
and leaves draggable). Search left, tabs centered on the page via the
1fr/auto/1fr grid; the trailing 1fr keeps the center honest.
*/}
{/*
IMPORTANT: do NOT put `-webkit-app-region: drag` on this header. It spans
@ -51,20 +126,21 @@ export function PageSearchShell({
(see app-shell.tsx), so window dragging still works here.
*/}
<div className="shrink-0">
{(tabs || !searchHidden) && (
<div className="flex items-center gap-3 px-3 pb-2 pt-[calc(var(--titlebar-height)+0.5rem)]">
{tabs ? <div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">{tabs}</div> : null}
{!searchHidden && (
<div className={cn('flex shrink-0 items-center', !tabs && 'flex-1')}>
{(hasTabs || !searchHidden) && (
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 px-3 pb-2 pt-[calc(var(--titlebar-height)+0.5rem)]">
<div className="flex min-w-0 items-center justify-start">
{!searchHidden && (
<SearchField
containerClassName="max-w-[45vw]"
hints={searchHints}
onChange={onSearchChange}
placeholder={searchPlaceholder}
trailingAction={searchTrailingAction}
value={searchValue}
/>
</div>
)}
)}
</div>
{hasTabs ? <ShellTabs activeTab={activeTab} onTabChange={onTabChange} tabs={tabs!} /> : <span />}
<span />
</div>
)}
{filters ? <div className="flex flex-wrap items-center gap-x-2 gap-y-1 px-3 pb-2">{filters}</div> : null}

View file

@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { CodeEditor } from '@/components/chat/code-editor'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
@ -15,7 +16,6 @@ import {
} from '@/components/ui/dialog'
import { SanitizedInput } from '@/components/ui/sanitized-input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
createProfile,
deleteProfile,
@ -28,6 +28,7 @@ import { useI18n } from '@/i18n'
import { AlertTriangle, Save } from '@/lib/icons'
import { profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
import { slug } from '@/lib/sanitize'
import { normalize } from '@/lib/text'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { $profileColors, refreshProfiles } from '@/store/profile'
@ -100,7 +101,7 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
}, [profiles, selectedName])
const visibleProfiles = useMemo(() => {
const q = query.trim().toLowerCase()
const q = normalize(query)
if (!profiles || !q) {
return profiles ?? []
@ -202,7 +203,7 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
profile.is_default
? []
: [
{ icon: 'edit', label: p.rename, onSelect: () => setPendingRename(profile) },
{ icon: 'edit', label: p.renameMenu, onSelect: () => setPendingRename(profile) },
{
icon: 'trash',
label: t.common.delete,
@ -415,7 +416,6 @@ function SoulEditor({ profileName }: { profileName: string }) {
}, [p, profileName])
const dirty = content !== original
const isEmpty = !content.trim()
async function handleSave() {
setSaving(true)
@ -445,12 +445,16 @@ function SoulEditor({ profileName }: { profileName: string }) {
{loading ? (
<PageLoader className="min-h-44" label={p.loadingSoul} />
) : (
<Textarea
className="min-h-48 font-mono text-xs leading-5"
onChange={event => setContent(event.target.value)}
placeholder={isEmpty ? p.emptySoul : undefined}
value={content}
/>
<div className="min-h-48">
<CodeEditor
filePath="SOUL.md"
framed
initialValue={content}
key={profileName}
onChange={setContent}
onSave={() => void handleSave()}
/>
</div>
)}
{error && (

View file

@ -1,10 +1,13 @@
import { useState } from 'react'
import { ArchiveSkillConfirmDialog, fireOptimistic } from '@/app/learning/archive-skill-confirm-dialog'
import { CodeEditor } from '@/components/chat/code-editor'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Textarea } from '@/components/ui/textarea'
import { deleteLearningNode, editLearningNode, getLearningNode } from '@/hermes'
import { notifyError } from '@/store/notifications'
import { evictStarmapNode, loadStarmapGraph } from '@/store/starmap'
export interface NodeMenuTarget {
id: string
@ -15,8 +18,8 @@ export interface NodeMenuTarget {
}
interface NodeContextMenuProps {
onChanged: () => void
onClose: () => void
onNodeRemoved: () => void
target: NodeMenuTarget | null
}
@ -27,9 +30,9 @@ interface EditState {
}
/** Right-click actions for a star-map node: edit (modal) or delete (confirm). */
export function NodeContextMenu({ onChanged, onClose, target }: NodeContextMenuProps) {
export function NodeContextMenu({ onClose, onNodeRemoved, target }: NodeContextMenuProps) {
const [editing, setEditing] = useState<EditState | null>(null)
const [deleting, setDeleting] = useState<{ id: string; label: string } | null>(null)
const [deleting, setDeleting] = useState<Omit<NodeMenuTarget, 'x' | 'y'> | null>(null)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
@ -43,6 +46,7 @@ export function NodeContextMenu({ onChanged, onClose, target }: NodeContextMenuP
setLoading(true)
setError(null)
try {
const detail = await getLearningNode(target.id)
setEditing({ content: detail.content, id: target.id, label: target.label })
@ -61,13 +65,16 @@ export function NodeContextMenu({ onChanged, onClose, target }: NodeContextMenuP
setSaving(true)
setError(null)
try {
const res = await editLearningNode(editing.id, editing.content)
if (!res.ok) {
throw new Error(res.message)
}
setEditing(null)
onChanged()
void loadStarmapGraph(true)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
@ -82,13 +89,16 @@ export function NodeContextMenu({ onChanged, onClose, target }: NodeContextMenuP
{menuOpen ? (
<>
<div className="fixed inset-0 z-50" onClick={onClose} onContextMenu={e => e.preventDefault()} />
{/* Styled to DropdownMenuContent/Item scale (rounded-lg card, p-1,
text-xs rows) the hand-rolled fixed positioning stays because
the target is a canvas point, not a DOM anchor. */}
<div
className="fixed z-50 min-w-36 overflow-hidden rounded-md border border-border bg-popover py-1 text-sm shadow-md"
className="fixed z-50 min-w-36 rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 shadow-md backdrop-blur-md"
style={{ left: target.x, top: target.y }}
>
<div className="truncate px-3 py-1 text-xs text-muted-foreground">{target.label}</div>
<div className="truncate px-2 py-1 text-[0.68rem] text-muted-foreground">{target.label}</div>
<button
className="block w-full px-3 py-1 text-left hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
className="block w-full cursor-pointer rounded-md px-2 py-1 text-left text-xs hover:bg-(--ui-control-active-background) hover:text-foreground disabled:opacity-50"
disabled={loading}
onClick={() => void openEdit()}
type="button"
@ -96,14 +106,14 @@ export function NodeContextMenu({ onChanged, onClose, target }: NodeContextMenuP
Edit {noun}
</button>
<button
className="block w-full px-3 py-1 text-left text-destructive hover:bg-destructive/10"
className="block w-full cursor-pointer rounded-md px-2 py-1 text-left text-xs text-destructive hover:bg-destructive/10"
onClick={() => {
setDeleting({ id: target.id, label: target.label })
setDeleting({ id: target.id, kind: target.kind, label: target.label })
onClose()
}}
type="button"
>
Delete {noun}
{target.kind === 'skill' ? 'Archive skill' : 'Delete memory'}
</button>
</div>
</>
@ -114,11 +124,19 @@ export function NodeContextMenu({ onChanged, onClose, target }: NodeContextMenuP
<DialogHeader>
<DialogTitle>Edit {editing?.label}</DialogTitle>
</DialogHeader>
<Textarea
className="h-80 font-mono text-xs"
onChange={e => setEditing(prev => (prev ? { ...prev, content: e.target.value } : prev))}
value={editing?.content ?? ''}
/>
<div className="h-80">
{editing && (
<CodeEditor
filePath={noun === 'skill' ? 'SKILL.md' : 'memory.md'}
framed
initialValue={editing.content}
key={editing.id}
onCancel={() => !saving && setEditing(null)}
onChange={content => setEditing(prev => (prev ? { ...prev, content } : prev))}
onSave={() => void save()}
/>
)}
</div>
{error ? <p className="text-xs text-destructive">{error}</p> : null}
<DialogFooter>
<Button disabled={saving} onClick={() => setEditing(null)} type="button" variant="ghost">
@ -131,29 +149,49 @@ export function NodeContextMenu({ onChanged, onClose, target }: NodeContextMenuP
</DialogContent>
</Dialog>
<ConfirmDialog
confirmLabel="Delete"
description={
noun === 'skill'
? 'The skill is archived and can be restored with `hermes curator restore`.'
: 'This memory is removed permanently.'
}
destructive
onClose={() => setDeleting(null)}
onConfirm={async () => {
if (!deleting) {
return
}
{deleting?.kind === 'skill' ? (
<ArchiveSkillConfirmDialog
onApply={() => {
onNodeRemoved()
const res = await deleteLearningNode(deleting.id)
if (!res.ok) {
throw new Error(res.message)
}
onChanged()
}}
open={Boolean(deleting)}
title={`Delete ${deleting?.label ?? ''}?`}
/>
return evictStarmapNode(deleting.id)
}}
onClose={() => setDeleting(null)}
onFailure={(err, name) => notifyError(err, name)}
open
skillId={deleting.id}
skillName={deleting.label}
/>
) : (
<ConfirmDialog
confirmLabel="Delete"
description="This memory is removed permanently."
destructive
dismissOnConfirm
onClose={() => setDeleting(null)}
onConfirm={() => {
if (!deleting) {
return
}
const { id, label } = deleting
const rollback = evictStarmapNode(id)
onNodeRemoved()
fireOptimistic(
deleteLearningNode(id).then(res => {
if (!res.ok) {
throw new Error(res.message)
}
}),
rollback,
err => notifyError(err, label)
)
}}
open={Boolean(deleting)}
title={`Delete ${deleting?.label ?? ''}?`}
/>
)}
</>
)
}

View file

@ -2,25 +2,88 @@ import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirro
import { bracketMatching, indentOnInput, LanguageDescription } from '@codemirror/language'
import { languages } from '@codemirror/language-data'
import { Compartment, EditorState } from '@codemirror/state'
import { drawSelection, EditorView, keymap, lineNumbers } from '@codemirror/view'
import { useEffect, useRef } from 'react'
import { Decoration, drawSelection, EditorView, keymap, lineNumbers } from '@codemirror/view'
import { type RefObject, useEffect, useRef } from 'react'
import { tryFormatJson } from '@/lib/json-format'
import { cn } from '@/lib/utils'
import { useTheme } from '@/themes/context'
import { githubEditorTheme } from './code-editor-theme'
type FormatOutcome = { ok: true } | { ok: false; error: string }
function applyFormatJson(view: EditorView, onError?: (error: string) => void): FormatOutcome {
const text = view.state.doc.toString()
const result = tryFormatJson(text)
if (!result.ok) {
onError?.(result.error)
return result
}
if (result.text !== text) {
view.dispatch({ changes: { from: 0, insert: result.text, to: view.state.doc.length } })
}
return { ok: true }
}
/** Imperative surface for callers that drive selection from outside (e.g. a
* config list focusing its block in the document). */
export interface CodeEditorApi {
formatJson: () => FormatOutcome
setCursor: (pos: number) => void
}
interface CodeEditorProps {
apiRef?: RefObject<CodeEditorApi | null>
className?: string
/** Mod-Shift-F + `apiRef.formatJson()`. In-memory JSON docs only. */
formatJson?: boolean
/**
* Standalone chrome: rounded border on an outer shell. The CodeMirror surface
* inside is identical to pane previews (no extra inset). Off by default.
*/
framed?: boolean
filePath: string
/** Character range to wash with a subtle background (the "you are here" block). */
highlight?: null | { from: number; to: number }
// Read once at mount. To load a different file or discard edits, remount the
// component (give it a new React `key`) rather than pushing a new value in.
initialValue: string
onCancel?: () => void
onChange: (value: string) => void
/** Button or Mod-Shift-F. */
onFormatJsonError?: (error: string) => void
/** Fires with the primary cursor offset whenever the selection moves. */
onCursorChange?: (pos: number) => void
onSave?: () => void
}
// Focus treatment for the active range: a subtle wash on its lines, and
// everything OUTSIDE dimmed — the document recedes so the block you're in
// reads as "you are here".
function blockHighlight(range: { from: number; to: number }) {
return EditorView.decorations.compute([], state => {
const clamp = (pos: number) => Math.max(0, Math.min(pos, state.doc.length))
const active = Decoration.line({ class: 'cm-hermes-active-block' })
// Inline style, not a theme class: theme rules are scoped per-extension
// and line opacity must never lose that fight.
const dimmed = Decoration.line({ attributes: { style: 'opacity:0.5;transition:opacity 120ms ease-out' } })
const first = state.doc.lineAt(clamp(range.from)).number
const last = state.doc.lineAt(clamp(range.to)).number
const marks = []
for (let n = 1; n <= state.doc.lines; n++) {
marks.push((n >= first && n <= last ? active : dimmed).range(state.doc.line(n).from))
}
return Decoration.set(marks)
})
}
function baseName(filePath: string): string {
const cleaned = filePath.replace(/[\\/]+$/, '')
@ -49,12 +112,16 @@ const LAYOUT_THEME = EditorView.theme({
backgroundColor: 'transparent',
height: '100%'
},
// CM's base theme ships `.cm-content { padding: 4px 0 }` (~5px top/bottom).
// Zero it explicitly so pane + framed interiors match SourceView flush-top.
'.cm-content': {
fontFamily: MONO_FONT,
fontSize: CODE_SIZE,
fontWeight: '400',
lineHeight: ROW_HEIGHT,
padding: '0'
padding: '0',
paddingBottom: '0',
paddingTop: '0'
},
'.cm-gutters': {
backgroundColor: 'transparent',
@ -85,27 +152,58 @@ const LAYOUT_THEME = EditorView.theme({
fontSize: CODE_SIZE,
lineHeight: ROW_HEIGHT,
overflow: 'auto'
},
'.cm-hermes-active-block': {
backgroundColor: 'color-mix(in srgb, var(--dt-foreground) 5%, transparent)'
}
})
// Framed = prose editing (SOUL.md, skills, memories): no line-number gutter (it
// shoved text right and made the left inset dwarf the top), and zero the line's
// own horizontal padding so the host's uniform `p-2` is the ONLY inset — even
// breathing room on all four sides. Long lines wrap rather than scroll.
const FRAMED_THEME = EditorView.theme({
'.cm-line': { padding: '0' }
})
// A deliberately small CodeMirror 6 surface for *spot edits* — not an IDE: line
// numbers, history, selection, bracket matching, syntax highlighting. No fold
// gutter, autocomplete, or active-line chrome, so it reads like the preview it
// replaces. It owns its own buffer; the parent tracks dirty via `onChange` and
// resets by remounting. ⌘/Ctrl+S and ⌘/Ctrl+Enter save; Esc cancels; the app's
// light/dark mode is followed live without losing the cursor.
export function CodeEditor({ className, filePath, initialValue, onCancel, onChange, onSave }: CodeEditorProps) {
export function CodeEditor({
apiRef,
className,
formatJson = false,
framed = false,
filePath,
highlight,
initialValue,
onCancel,
onChange,
onCursorChange,
onFormatJsonError,
onSave
}: CodeEditorProps) {
const { resolvedMode } = useTheme()
const hostRef = useRef<HTMLDivElement | null>(null)
const viewRef = useRef<EditorView | null>(null)
const languageConf = useRef(new Compartment())
const themeConf = useRef(new Compartment())
const highlightConf = useRef(new Compartment())
const onCancelRef = useRef(onCancel)
const onChangeRef = useRef(onChange)
const onCursorChangeRef = useRef(onCursorChange)
const onFormatJsonErrorRef = useRef(onFormatJsonError)
const onSaveRef = useRef(onSave)
const formatJsonRef = useRef(formatJson)
onCancelRef.current = onCancel
onChangeRef.current = onChange
onCursorChangeRef.current = onCursorChange
onFormatJsonErrorRef.current = onFormatJsonError
onSaveRef.current = onSave
formatJsonRef.current = formatJson
useEffect(() => {
const host = hostRef.current
@ -122,10 +220,21 @@ export function CodeEditor({ className, filePath, initialValue, onCancel, onChan
return true
}
const runFormatJson = () => {
if (!formatJsonRef.current || !viewRef.current) {
return false
}
applyFormatJson(viewRef.current, error => onFormatJsonErrorRef.current?.(error))
return true
}
const state = EditorState.create({
doc: initialValue,
extensions: [
lineNumbers(),
// Gutter only outside framed mode — framed prose reads better flush.
...(framed ? [] : [lineNumbers()]),
history(),
drawSelection(),
indentOnInput(),
@ -136,6 +245,7 @@ export function CodeEditor({ className, filePath, initialValue, onCancel, onChan
indentWithTab,
{ key: 'Mod-s', preventDefault: true, run: save },
{ key: 'Mod-Enter', preventDefault: true, run: save },
...(formatJson ? [{ key: 'Mod-Shift-f', preventDefault: true, run: runFormatJson }] : []),
{
key: 'Escape',
run: () => {
@ -151,17 +261,46 @@ export function CodeEditor({ className, filePath, initialValue, onCancel, onChan
]),
languageConf.current.of([]),
themeConf.current.of(githubEditorTheme(isDark)),
highlightConf.current.of([]),
EditorView.updateListener.of(update => {
if (update.docChanged) {
onChangeRef.current(update.state.doc.toString())
}
if (update.selectionSet || update.docChanged) {
onCursorChangeRef.current?.(update.state.selection.main.head)
}
}),
LAYOUT_THEME
LAYOUT_THEME,
// Standalone edits (SOUL.md, skills, memories) are prose, not code —
// wrap long lines instead of scrolling horizontally, and drop the gutter
// inset. Pane previews stay flush/scrolling to mirror their SourceView.
...(framed ? [EditorView.lineWrapping, FRAMED_THEME] : [])
]
})
const view = new EditorView({ parent: host, state })
viewRef.current = view
if (apiRef) {
apiRef.current = {
formatJson: () => {
const view = viewRef.current
if (!view || !formatJsonRef.current) {
return { ok: false, error: 'JSON formatting is not enabled for this editor' }
}
return applyFormatJson(view)
},
setCursor: pos => {
const clamped = Math.max(0, Math.min(pos, view.state.doc.length))
view.dispatch({ scrollIntoView: true, selection: { anchor: clamped } })
view.focus()
}
}
}
// Focus on mount so entering edit mode (button or double-click) lands the
// caret in the buffer ready to type, no extra click required.
view.focus()
@ -169,6 +308,10 @@ export function CodeEditor({ className, filePath, initialValue, onCancel, onChan
return () => {
view.destroy()
viewRef.current = null
if (apiRef) {
apiRef.current = null
}
}
// Created once per mount; the parent remounts (via `key`) to load a new
// file or discard. Theme/language are applied reactively below.
@ -203,5 +346,37 @@ export function CodeEditor({ className, filePath, initialValue, onCancel, onChan
})
}, [resolvedMode])
return <div className={cn('h-full min-h-0 overflow-hidden', className)} ref={hostRef} />
const highlightFrom = highlight?.from
const highlightTo = highlight?.to
useEffect(() => {
viewRef.current?.dispatch({
effects: highlightConf.current.reconfigure(
highlightFrom !== undefined && highlightTo !== undefined
? blockHighlight({ from: highlightFrom, to: highlightTo })
: []
)
})
}, [highlightFrom, highlightTo])
if (!framed) {
return <div className={cn('h-full min-h-0 overflow-hidden', className)} ref={hostRef} />
}
// Border on the shell only — inner body matches preview-file / DetailPane:
// <div className="min-h-0 flex-1 overflow-hidden"><CodeEditor /></div>
return (
<div
className={cn(
'flex h-full min-h-0 flex-col overflow-hidden rounded-md border border-(--ui-stroke-tertiary)',
className
)}
>
{/* Padding lives on the CM *mount node* itself outside CodeMirror's
DOM entirely, so its `.cm-content { padding: 0 }` can't fight it. This
is why every prior attempt (Tailwind on .cm-content, scroller padding)
lost: they targeted CM-owned nodes. This div isn't one. */}
<div className="min-h-0 flex-1 overflow-hidden p-2" ref={hostRef} />
</div>
)
}

View file

@ -0,0 +1,96 @@
import type * as React from 'react'
import { type RefObject, useRef } from 'react'
import { CodeEditor, type CodeEditorApi } from '@/components/chat/code-editor'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
// Kept a string (not a shared CSS utility): the `size-5` prefix lets
// tailwind-merge override <Button size="icon">'s larger built-in size.
const ICON_BUTTON =
'size-5 cursor-pointer rounded-[4px] text-muted-foreground/70 hover:bg-(--ui-control-active-background) hover:text-foreground'
interface JsonDocumentEditorProps {
apiRef?: RefObject<CodeEditorApi | null>
className?: string
disabled?: boolean
filePath?: string
header?: React.ReactNode
highlight?: null | { from: number; to: number }
initialValue: string
onChange: (value: string) => void
onCursorChange?: (pos: number) => void
onFormatJsonError: (error: string) => void
onSave?: () => void
remountKey?: number | string
trailing?: React.ReactNode
}
/** In-memory JSON editor — not for on-disk file previews in the right rail. */
export function JsonDocumentEditor({
apiRef,
className,
disabled,
filePath = 'document.json',
header,
highlight,
initialValue,
onChange,
onCursorChange,
onFormatJsonError,
onSave,
remountKey,
trailing
}: JsonDocumentEditorProps) {
const { t } = useI18n()
const localApi = useRef<CodeEditorApi | null>(null)
const editorApi = apiRef ?? localApi
return (
<div className={cn('flex min-h-0 flex-1 flex-col overflow-hidden', className)}>
<div className="flex h-8 shrink-0 items-center gap-2 px-3">
{header ? (
<span className="flex min-w-0 items-center gap-1.5 text-[0.68rem] text-(--ui-text-tertiary)">{header}</span>
) : null}
<div className="ml-auto flex items-center gap-1">
<Tip label={t.common.formatJson}>
<Button
aria-label={t.common.formatJson}
className={ICON_BUTTON}
disabled={disabled}
onClick={() => {
const result = editorApi.current?.formatJson()
if (result && !result.ok) {
onFormatJsonError(result.error)
}
}}
size="icon"
variant="ghost"
>
<Codicon name="json" size="0.8125rem" />
</Button>
</Tip>
{trailing}
</div>
</div>
<div className="min-h-0 flex-1">
<CodeEditor
apiRef={editorApi}
filePath={filePath}
formatJson
highlight={highlight}
initialValue={initialValue}
key={remountKey}
onChange={onChange}
onCursorChange={onCursorChange}
onFormatJsonError={onFormatJsonError}
onSave={onSave}
/>
</div>
</div>
)
}

View file

@ -0,0 +1,67 @@
import { useEffect, useRef } from 'react'
import { CodeCardBody } from '@/components/chat/code-card'
import { CopyButton } from '@/components/ui/copy-button'
import { cn } from '@/lib/utils'
interface LogTailProps {
/** null = still loading (shows the loading glyph); [] = loaded-but-empty
* (shows `emptyLabel`); non-empty renders as a tailing terminal log. */
lines: null | string[]
emptyLabel: string
className?: string
}
/** The shared terminal-log surface: CodeCardBody typography, a hover-reveal copy
* button, and follow-the-tail scrolling (releases when the user scrolls up).
* One component behind every log pane MCP stdio/agent, hub action logs, etc.
* so they all read, copy, and scroll identically. */
export function LogTail({ className, emptyLabel, lines }: LogTailProps) {
const scrollRef = useRef<HTMLDivElement | null>(null)
const stickRef = useRef(true)
useEffect(() => {
const el = scrollRef.current
if (el && stickRef.current) {
el.scrollTop = el.scrollHeight
}
}, [lines])
return (
<div className={cn('group/logs relative h-full min-h-0', className)}>
<CopyButton
appearance="inline"
className="absolute right-2.5 top-1.5 z-10 h-5 gap-0 rounded-md px-1 opacity-5 transition-opacity group-hover/logs:opacity-100 hover:opacity-100 focus-visible:opacity-100"
iconClassName="size-3"
showLabel={false}
text={() => (lines ?? []).join('\n')}
/>
<div
className="h-full min-h-0 overflow-y-auto [scrollbar-gutter:stable]"
data-selectable-text="true"
onScroll={event => {
const el = event.currentTarget
stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24
}}
ref={scrollRef}
>
{lines === null || lines.length === 0 ? (
<p className="px-2 py-1.5 font-mono text-[0.7rem] leading-relaxed text-muted-foreground/50">
{lines === null ? '…' : emptyLabel}
</p>
) : (
<CodeCardBody>
<pre className="whitespace-pre-wrap break-words">
{lines.map((line, index) => (
<span className={cn('block', line.startsWith('=====') && 'mt-1 text-(--ui-text-tertiary)')} key={index}>
{line}
</span>
))}
</pre>
</CodeCardBody>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,24 @@
import { cn } from '@/lib/utils'
// Canonical centered empty state (title + description). The default for "no
// results / nothing here yet" page bodies. For richer master-detail lists that
// want an icon + action, use PanelEmpty (overlays/panel); the file-tree's
// inline uppercase error state is its own deliberately-distinct treatment.
export function EmptyState({
title,
description,
className
}: {
title: string
description?: string
className?: string
}) {
return (
<div className={cn('grid min-h-48 place-items-center text-center', className)}>
<div>
<div className="text-sm font-medium">{title}</div>
{description && <div className="mt-1 text-xs text-muted-foreground">{description}</div>}
</div>
</div>
)
}

View file

@ -1,6 +1,7 @@
import type { ReactNode } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { AlertTriangle } from '@/lib/icons'
import { cn } from '@/lib/utils'
// The single canonical error glyph (codicon's filled error mark). Use this
@ -10,6 +11,23 @@ export function ErrorIcon({ className, size = '1.75rem' }: { className?: string;
return <Codicon className={cn('text-destructive', className)} name="error" size={size} />
}
// Inline error banner for detail panes (born in Messaging's platform error,
// now shared with the MCP config pane): warn glyph + tinted rounded box.
// For centered full-surface failures use ErrorState below instead.
export function ErrorBanner({ children, className }: { children: ReactNode; className?: string }) {
return (
<div
className={cn(
'flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-destructive',
className
)}
>
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span className="min-w-0 whitespace-pre-wrap break-words">{children}</span>
</div>
)
}
export interface ErrorStateProps {
/** Optional actions row/stack rendered below the copy. */
children?: ReactNode

View file

@ -1,4 +1,4 @@
import type { ReactNode, RefObject } from 'react'
import { type ReactNode, type RefObject, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
@ -10,6 +10,12 @@ interface SearchFieldProps {
placeholder: string
value: string
onChange: (value: string) => void
/**
* Data-driven placeholder suggestions ("Try \u201ccreative\u201d") one is picked at
* random per mount, the nudge that search understands more than names.
* Falls back to `placeholder` when absent/empty.
*/
hints?: string[]
containerClassName?: string
inputClassName?: string
loading?: boolean
@ -22,12 +28,14 @@ interface SearchFieldProps {
/**
* Shared search field used everywhere (sessions sidebar, pages, overlays,
* command center, cron). No box borderless until focus, then an underline.
* Width/placement come from `containerClassName`.
* Rests at low opacity until focused or filled. Width/placement come from
* `containerClassName`.
*/
export function SearchField({
placeholder,
value,
onChange,
hints,
containerClassName,
inputClassName,
loading = false,
@ -39,25 +47,37 @@ export function SearchField({
const { t } = useI18n()
const clear = onClear ?? (() => onChange(''))
// One hint per mount, picked at random — fresh nudge every visit, no
// mid-page carousel.
const [hintIndex] = useState(() => Math.floor(Math.random() * 4096))
const hintCount = hints?.length ?? 0
const effectivePlaceholder = hintCount > 0 ? hints![hintIndex % hintCount] : placeholder
return (
<div
className={cn(
'inline-flex max-w-full items-center gap-1.5 border-b border-transparent px-0.5 transition-colors focus-within:border-(--ui-stroke-secondary)',
// min-w-0 is load-bearing: without it the content-sized input sets the
// container's flex min-width and the field bulldozes its siblings
// instead of shrinking to fit its context.
'inline-flex min-w-0 max-w-full items-center gap-1.5 border-b border-transparent px-0.5 transition-[color,border-color,opacity]',
// Recede until the user reaches for it.
!value && 'opacity-30 focus-within:opacity-100',
containerClassName
)}
>
<Search className="pointer-events-none size-3.5 shrink-0 text-muted-foreground/70" />
<input
aria-label={ariaLabel}
aria-label={ariaLabel ?? placeholder}
className={cn(
// `field-sizing: content` grows the input to fit the placeholder/typed
// text, capped by the container's max-width — no awkward empty space.
// text; min-w-0 lets it shrink back below content size when the
// context is narrower — long queries scroll inside the field.
// text-xs matches the form controls (Input/Select via controlVariants).
'h-7 max-w-full bg-transparent text-xs text-foreground [field-sizing:content] placeholder:text-muted-foreground focus:outline-none',
'h-7 min-w-0 max-w-full bg-transparent text-xs text-foreground [field-sizing:content] placeholder:text-muted-foreground focus:outline-none',
inputClassName
)}
onChange={event => onChange(event.target.value)}
placeholder={placeholder}
placeholder={effectivePlaceholder}
ref={inputRef}
type="text"
value={value}

View file

@ -4,4 +4,15 @@ function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return <div className={cn('animate-pulse rounded-md bg-accent', className)} data-slot="skeleton" {...props} />
}
export { Skeleton }
/** Inline pulsing chip standing in for a small count/badge while it loads. */
function CountSkeleton({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
className={cn('inline-block h-2 w-3.5 translate-y-px animate-pulse rounded-sm bg-current/25', className)}
data-slot="count-skeleton"
{...props}
/>
)
}
export { CountSkeleton, Skeleton }

View file

@ -0,0 +1,24 @@
// THE compact-number formatter — every user-facing count/token figure goes
// through here. 999 → "999", 1000 → "1k", 1230 → "1.2k", 10000 → "10k",
// 1_500_000 → "1.5M". Do not hand-roll `/ 1000` display math elsewhere.
export function compactNumber(value: null | number | undefined): string {
const num = Number(value ?? 0)
if (!Number.isFinite(num) || num <= 0) {
return '0'
}
const scaled = (v: number, suffix: string) => `${v.toFixed(1).replace(/\.0$/, '')}${suffix}`
// Thresholds sit just under the unit boundary so rounding can't produce
// "1000k" or "1000" — those promote to the next unit instead.
if (num >= 999_950) {
return scaled(num / 1_000_000, 'M')
}
if (num >= 999.5) {
return scaled(num / 1_000, 'k')
}
return `${Math.round(num)}`
}

View file

@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { tryFormatJson } from './json-format'
describe('tryFormatJson', () => {
it('pretty-prints compact JSON', () => {
expect(tryFormatJson('{"a":1,"b":[2,3]}')).toEqual({
ok: true,
text: '{\n "a": 1,\n "b": [\n 2,\n 3\n ]\n}'
})
})
it('leaves empty input unchanged', () => {
expect(tryFormatJson(' ')).toEqual({ ok: true, text: ' ' })
})
it('reports parse errors', () => {
const result = tryFormatJson('{bad')
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.error.length).toBeGreaterThan(0)
}
})
})

View file

@ -0,0 +1,15 @@
export type FormatJsonResult = { ok: true; text: string } | { ok: false; error: string }
export function tryFormatJson(raw: string): FormatJsonResult {
const text = raw.trim()
if (!text) {
return { ok: true, text: raw }
}
try {
return { ok: true, text: JSON.stringify(JSON.parse(text) as unknown, null, 2) }
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) }
}
}

View file

@ -1,4 +1,4 @@
import { QueryClient } from '@tanstack/react-query'
import { QueryClient, type QueryKey } from '@tanstack/react-query'
// Shared React Query client. Lives in its own module (not main.tsx) so non-React
// code — e.g. the profile store on a gateway swap — can invalidate cached,
@ -11,3 +11,10 @@ export const queryClient = new QueryClient({
}
}
})
// Curried, setState-shaped cache writer for optimistic write-through: keeps
// mutation sites terse (`setX(next)` or `setX(prev => …)`) over one query key.
export const writeCache =
<T>(key: QueryKey) =>
(next: T | undefined | ((prev: T | undefined) => T | undefined)): void =>
void queryClient.setQueryData<T>(key, next)

View file

@ -0,0 +1,15 @@
// Canonical text micro-helpers. Do not redefine these per-page.
export const asText = (v: unknown): string => (typeof v === 'string' ? v : v == null ? '' : String(v))
export const includesQuery = (v: unknown, q: string) => asText(v).toLowerCase().includes(q)
export const prettyName = (v: string) => v.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
/** Search-key normalization: the exact `value.trim().toLowerCase()` idiom that
* was hand-written at ~30 filter/lookup sites. */
export const normalize = (v: unknown): string => asText(v).trim().toLowerCase()
/** Uppercase the first character, leave the rest. Matches the
* `s.charAt(0).toUpperCase() + s.slice(1)` idiom (empty-safe). */
export const capitalize = (v: string): string => (v ? v.charAt(0).toUpperCase() + v.slice(1) : v)

View file

@ -0,0 +1,74 @@
// Canonical time/date formatting. Shared `Intl` instances (created once, not
// per-render) + relative-time helpers. Every surface that shows a timestamp or
// an age pulls from here so the rendered strings stay consistent app-wide.
export const SECOND = 1000
export const MINUTE = 60_000
export const HOUR = 3_600_000
export const DAY = 86_400_000
// ── Absolute date/time formatters ──────────────────────────────────────────
// `hh:mm` clock (thread today/yesterday lines).
export const fmtClock = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' })
// Compact "day + clock", no year/seconds (artifacts, thread fallback, cron runs).
export const fmtDayTime = new Intl.DateTimeFormat(undefined, {
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
month: 'short'
})
// Medium date + short time (command center session detail).
export const fmtDateTime = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' })
// Date only, "5 Jun 2026" (starmap tooltip).
export const fmtDate = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short', year: 'numeric' })
// ── Relative time ──────────────────────────────────────────────────────────
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' })
// Localized bidirectional "in 5 min" / "2 hr ago" — coarsest sensible unit so a
// daily job reads "in 14 hr", not "in 840 min".
export function relativeTime(targetMs: number, nowMs = Date.now()): string {
const diff = targetMs - nowMs
const abs = Math.abs(diff)
const sign = diff < 0 ? -1 : 1
if (abs < MINUTE) {
return rtf.format(sign * Math.round(abs / SECOND), 'second')
}
if (abs < HOUR) {
return rtf.format(sign * Math.round(abs / MINUTE), 'minute')
}
if (abs < DAY) {
return rtf.format(sign * Math.round(abs / HOUR), 'hour')
}
return rtf.format(sign * Math.round(abs / DAY), 'day')
}
export type ElapsedUnit = 'day' | 'hour' | 'minute' | 'second'
// Coarsest elapsed bucket for a (clamped-nonnegative) duration, floored. The
// caller owns rendering — compact "5m", "5m ago", etc. — so no format is baked
// in here.
export function coarseElapsed(deltaMs: number): { unit: ElapsedUnit; value: number } {
const ms = Math.max(0, deltaMs)
if (ms >= DAY) {
return { unit: 'day', value: Math.floor(ms / DAY) }
}
if (ms >= HOUR) {
return { unit: 'hour', value: Math.floor(ms / HOUR) }
}
if (ms >= MINUTE) {
return { unit: 'minute', value: Math.floor(ms / MINUTE) }
}
return { unit: 'second', value: Math.floor(ms / SECOND) }
}

View file

@ -552,6 +552,21 @@
}
}
/* Interactive list-row hover the sessions-list interaction, shared by every
row list (sessions, capabilities, messaging, file trees, timeline): the
highlight lands instantly on hover-in and fades out over 100ms on leave. */
@utility row-hover {
cursor: pointer;
transition:
color 100ms ease-out,
background-color 100ms ease-out;
&:hover {
background-color: var(--ui-row-hover-background);
transition: none;
}
}
@keyframes arc-border {
0% {
background-position: 15% 15%;