mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
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:
parent
662426ec3d
commit
e0325cf769
23 changed files with 1356 additions and 85 deletions
|
|
@ -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"
|
||||
|
|
|
|||
22
apps/desktop/src/app/hooks/use-config-record.ts
Normal file
22
apps/desktop/src/app/hooks/use-config-record.ts
Normal 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 })
|
||||
15
apps/desktop/src/app/hooks/use-debounced.ts
Normal file
15
apps/desktop/src/app/hooks/use-debounced.ts
Normal 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
|
||||
}
|
||||
24
apps/desktop/src/app/hooks/use-on-profile-switch.ts
Normal file
24
apps/desktop/src/app/hooks/use-on-profile-switch.ts
Normal 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])
|
||||
}
|
||||
404
apps/desktop/src/app/master-detail.tsx
Normal file
404
apps/desktop/src/app/master-detail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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 ?? ''}?`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
96
apps/desktop/src/components/chat/json-document-editor.tsx
Normal file
96
apps/desktop/src/components/chat/json-document-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
67
apps/desktop/src/components/chat/log-tail.tsx
Normal file
67
apps/desktop/src/components/chat/log-tail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
apps/desktop/src/components/ui/empty-state.tsx
Normal file
24
apps/desktop/src/components/ui/empty-state.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
24
apps/desktop/src/lib/format.ts
Normal file
24
apps/desktop/src/lib/format.ts
Normal 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)}`
|
||||
}
|
||||
26
apps/desktop/src/lib/json-format.test.ts
Normal file
26
apps/desktop/src/lib/json-format.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
15
apps/desktop/src/lib/json-format.ts
Normal file
15
apps/desktop/src/lib/json-format.ts
Normal 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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
15
apps/desktop/src/lib/text.ts
Normal file
15
apps/desktop/src/lib/text.ts
Normal 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)
|
||||
74
apps/desktop/src/lib/time.ts
Normal file
74
apps/desktop/src/lib/time.ts
Normal 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) }
|
||||
}
|
||||
|
|
@ -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%;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue