From e0325cf769c25c81068150b722f3fbf3010e92e5 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 3 Jul 2026 05:08:13 -0500 Subject: [PATCH 1/6] =?UTF-8?q?feat(desktop):=20Capabilities=20foundation?= =?UTF-8?q?=20=E2=80=94=20shared=20utils,=20master-detail,=20editors,=20pr?= =?UTF-8?q?imitives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/app/chat/sidebar/profile-switcher.tsx | 103 ++++- .../src/app/hooks/use-config-record.ts | 22 + apps/desktop/src/app/hooks/use-debounced.ts | 15 + .../src/app/hooks/use-on-profile-switch.ts | 24 ++ apps/desktop/src/app/master-detail.tsx | 404 ++++++++++++++++++ apps/desktop/src/app/overlays/panel.tsx | 10 +- apps/desktop/src/app/page-search-shell.tsx | 106 ++++- apps/desktop/src/app/profiles/index.tsx | 24 +- .../src/app/starmap/node-context-menu.tsx | 114 +++-- .../src/components/chat/code-editor.tsx | 189 +++++++- .../components/chat/json-document-editor.tsx | 96 +++++ apps/desktop/src/components/chat/log-tail.tsx | 67 +++ .../desktop/src/components/ui/empty-state.tsx | 24 ++ .../desktop/src/components/ui/error-state.tsx | 18 + .../src/components/ui/search-field.tsx | 34 +- apps/desktop/src/components/ui/skeleton.tsx | 13 +- apps/desktop/src/lib/format.ts | 24 ++ apps/desktop/src/lib/json-format.test.ts | 26 ++ apps/desktop/src/lib/json-format.ts | 15 + apps/desktop/src/lib/query-client.ts | 9 +- apps/desktop/src/lib/text.ts | 15 + apps/desktop/src/lib/time.ts | 74 ++++ apps/desktop/src/styles.css | 15 + 23 files changed, 1356 insertions(+), 85 deletions(-) create mode 100644 apps/desktop/src/app/hooks/use-config-record.ts create mode 100644 apps/desktop/src/app/hooks/use-debounced.ts create mode 100644 apps/desktop/src/app/hooks/use-on-profile-switch.ts create mode 100644 apps/desktop/src/app/master-detail.tsx create mode 100644 apps/desktop/src/components/chat/json-document-editor.tsx create mode 100644 apps/desktop/src/components/chat/log-tail.tsx create mode 100644 apps/desktop/src/components/ui/empty-state.tsx create mode 100644 apps/desktop/src/lib/format.ts create mode 100644 apps/desktop/src/lib/json-format.test.ts create mode 100644 apps/desktop/src/lib/json-format.ts create mode 100644 apps/desktop/src/lib/text.ts create mode 100644 apps/desktop/src/lib/time.ts diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx index 4c919b144..c3016ec67 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx @@ -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) const [pendingDelete, setPendingDelete] = useState(null) + const [pendingSoul, setPendingSoul] = useState(null) const scrollRef = useRef(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} /> + + setPendingSoul(null)} profileName={pendingSoul} /> ) } +// 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 ( + !open && !saving && onClose()} open={profileName !== null}> + + + {profileName} · SOUL.md + +
+ {!loading && profileName && ( + !saving && onClose()} + onChange={setContent} + onSave={() => void save()} + /> + )} +
+ + + + +
+
+ ) +} + // 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 {p.color} + + {p.renameMenu} + + - {p.rename} + {p.editSoul} + useQuery({ queryKey: HERMES_CONFIG_KEY, queryFn: getHermesConfigRecord, staleTime: 0 }) + +export const setHermesConfigCache = writeCache(HERMES_CONFIG_KEY) + +export const invalidateHermesConfig = () => queryClient.invalidateQueries({ queryKey: HERMES_CONFIG_KEY }) diff --git a/apps/desktop/src/app/hooks/use-debounced.ts b/apps/desktop/src/app/hooks/use-debounced.ts new file mode 100644 index 000000000..1fc80bbe7 --- /dev/null +++ b/apps/desktop/src/app/hooks/use-debounced.ts @@ -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(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value) + + useEffect(() => { + const handle = setTimeout(() => setDebounced(value), delayMs) + + return () => clearTimeout(handle) + }, [value, delayMs]) + + return debounced +} diff --git a/apps/desktop/src/app/hooks/use-on-profile-switch.ts b/apps/desktop/src/app/hooks/use-on-profile-switch.ts new file mode 100644 index 000000000..04662a8be --- /dev/null +++ b/apps/desktop/src/app/hooks/use-on-profile-switch.ts @@ -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]) +} diff --git a/apps/desktop/src/app/master-detail.tsx b/apps/desktop/src/app/master-detail.tsx new file mode 100644 index 000000000..ef2045612 --- /dev/null +++ b/apps/desktop/src/app/master-detail.tsx @@ -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 ( + + {children} + + ) +} + +// Master–detail 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 master–detail 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 ( +
+
+ {children} +
+ {pane} +
+ ) +} + +export function ListColumn({ children, header }: { children: ReactNode; header?: ReactNode }) { + return ( + + ) +} + +// `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 ( +
+
+
{children}
+
+ {footer && ( +
+ {footer} +
+ )} + {actionBar && ( +
+
{actionBar}
+
+ )} +
+ ) +} + +// 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 + {onClose && ( + // TODO(i18n): literal until the UX settles. + + )} + + +
+ {children} +
+ + ) +} + +// 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 ( +
+
{left}
+
{right}
+
+ ) +} + +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 ( + + + + + + {toggle && ( + { + // Keep the menu open so the switch is seen flipping. + event.preventDefault() + toggle.onToggle(!toggle.checked) + }} + > + {toggle.label} + + + )} + {items.map(item => ( + + {item.label} + + ))} + + + ) +} + +export function ListStripButton({ + active, + children, + disabled, + onClick +}: { + active?: boolean + children: ReactNode + disabled?: boolean + onClick: () => void +}) { + return ( + + ) +} + +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 ( +
+ + + + {title} + + {subtitle != null && ( + + {typeof subtitle === 'string' ? {subtitle} : subtitle} + + )} + + {meta != null && ( + + {meta} + + )} + + +
+ ) +} diff --git a/apps/desktop/src/app/overlays/panel.tsx b/apps/desktop/src/app/overlays/panel.tsx index ae4ee5fde..7697f20a4 100644 --- a/apps/desktop/src/app/overlays/panel.tsx +++ b/apps/desktop/src/app/overlays/panel.tsx @@ -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({ diff --git a/apps/desktop/src/app/page-search-shell.tsx b/apps/desktop/src/app/page-search-shell.tsx index f20b5bae9..9d850a715 100644 --- a/apps/desktop/src/app/page-search-shell.tsx +++ b/apps/desktop/src/app/page-search-shell.tsx @@ -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 ? : 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 ( + <> +
+ {tabs.map(tab => ( + 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 && {metaContent(tab.meta)}} + + ))} +
+
+ + + + + + {tabs.map(tab => ( + onTabChange?.(tab.id)}> + {tab.label} + {tab.meta !== undefined && ( + {metaContent(tab.meta)} + )} + + ))} + + +
+ + ) +} + 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 (
- {(tabs || !searchHidden) && ( -
- {tabs ?
{tabs}
: null} - {!searchHidden && ( -
+ {(hasTabs || !searchHidden) && ( +
+
+ {!searchHidden && ( -
- )} + )} +
+ {hasTabs ? : } +
)} {filters ?
{filters}
: null} diff --git a/apps/desktop/src/app/profiles/index.tsx b/apps/desktop/src/app/profiles/index.tsx index df5b58751..8f777b046 100644 --- a/apps/desktop/src/app/profiles/index.tsx +++ b/apps/desktop/src/app/profiles/index.tsx @@ -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 ? ( ) : ( -