diff --git a/apps/desktop/src/app/agents/index.tsx b/apps/desktop/src/app/agents/index.tsx index 8f6c2349f83..fd13758599b 100644 --- a/apps/desktop/src/app/agents/index.tsx +++ b/apps/desktop/src/app/agents/index.tsx @@ -19,7 +19,7 @@ import { type SubagentStreamEntry } from '@/store/subagents' -import { OverlayView } from '../overlays/overlay-view' +import { Panel, PanelEmpty, PanelHeader } from '../overlays/panel' // Mirrors statusGlyph() in tool-fallback.tsx so subagent rows speak the // same visual vocabulary as the chat tool blocks. @@ -86,18 +86,16 @@ export function AgentsView({ onClose }: AgentsViewProps) { const tree = useMemo(() => buildSubagentTree(allSubagents(subagentsBySession)), [subagentsBySession]) return ( - -
-

{t.agents.title}

-

{t.agents.subtitle}

-
- -
+ + {tree.length === 0 ? ( + + ) : ( + <> + + + + )} + ) } diff --git a/apps/desktop/src/app/command-center/index.tsx b/apps/desktop/src/app/command-center/index.tsx index 9eacc0f41ee..f6f2ed0324a 100644 --- a/apps/desktop/src/app/command-center/index.tsx +++ b/apps/desktop/src/app/command-center/index.tsx @@ -9,7 +9,16 @@ import { getActionStatus, getLogs, getStatus, getUsageAnalytics, restartGateway, import type { ActionStatusResponse, AnalyticsResponse, StatusResponse } from '@/hermes' import { useI18n } from '@/i18n' import { sessionTitle } from '@/lib/chat-runtime' -import { Activity, AlertCircle, BarChart3, Bookmark, BookmarkFilled, Download, Pin, Trash2 } from '@/lib/icons' +import { + Activity, + AlertCircle, + BarChart3, + Bookmark, + BookmarkFilled, + Download, + MessageCircle, + Trash2 +} from '@/lib/icons' import { exportSession } from '@/lib/session-export' import { cn } from '@/lib/utils' import { upsertDesktopActionTask } from '@/store/activity' @@ -263,7 +272,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on {SECTIONS.map(value => ( setSection(value)} @@ -361,7 +370,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on /> ) : (
-
+
{status ? (
@@ -406,7 +415,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on )}
-
+
{cc.recentLogs} @@ -503,7 +512,7 @@ function UsagePanel({ error, loading, onRefresh, period, usage }: UsagePanelProp )} -
+
-
+
({ diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index 342f53ee042..eb298bde175 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -14,7 +14,6 @@ import { DialogTitle } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' -import { SearchField } from '@/components/ui/search-field' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Textarea } from '@/components/ui/textarea' import { @@ -30,14 +29,28 @@ import { updateCronJob } from '@/hermes' import { type Translations, useI18n } from '@/i18n' -import { AlertTriangle, Clock } from '@/lib/icons' -import { cn } from '@/lib/utils' +import { AlertTriangle } from '@/lib/icons' import { $cronFocusJobId, $cronJobs, setCronFocusJobId, setCronJobs, updateCronJobs } from '@/store/cron' import { notify, notifyError } from '@/store/notifications' import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' -import { OverlayMain, OverlayNewButton, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout' -import { OverlayView } from '../overlays/overlay-view' +import { + Panel, + PanelAction, + PanelAddButton, + PanelBlock, + PanelBody, + PanelDetail, + PanelEmpty, + PanelHeader, + PanelList, + PanelListRow, + PanelMeta, + PanelPill, + type PanelPillTone, + PanelRowMenu, + PanelSectionLabel +} from '../overlays/panel' import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' import { jobState, jobTitle, STATE_DOT } from './job-state' @@ -56,7 +69,7 @@ const SCHEDULE_OPTIONS: ReadonlyArray = [ { value: 'custom' } ] -const STATE_TONE: Record = { +const STATE_TONE: Record = { enabled: 'good', scheduled: 'good', running: 'good', @@ -66,13 +79,6 @@ const STATE_TONE: Record = { completed: 'muted' } -const PILL_TONE: Record<'good' | 'muted' | 'warn' | 'bad', string> = { - good: 'bg-primary/10 text-primary', - muted: 'bg-muted text-muted-foreground', - warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300', - bad: 'bg-destructive/10 text-destructive' -} - const asText = (value: unknown): string => (typeof value === 'string' ? value : '') const truncate = (value: string, max = 80): string => (value.length > max ? `${value.slice(0, max)}…` : value) @@ -321,7 +327,7 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt pendingScrollRef.current = null requestAnimationFrame(() => { - document.querySelector(`[data-cron-row="${CSS.escape(target)}"]`)?.scrollIntoView({ block: 'nearest' }) + document.querySelector(`[data-panel-row="${CSS.escape(target)}"]`)?.scrollIntoView({ block: 'nearest' }) }) }, [selectedJob]) @@ -406,60 +412,66 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt } return ( - + {loading && jobs.length === 0 ? ( + ) : totalCount === 0 ? ( + setEditor({ mode: 'create' })} size="sm"> + {c.newCron} + + } + description={c.emptyDescNew} + icon="watch" + title={c.emptyTitleNew} + /> ) : ( - - - setEditor({ mode: 'create' })} /> - {totalCount > 0 && ( - - )} - {visibleJobs.map(job => ( - setSelectedJobId(job.id)} - /> - ))} - {visibleJobs.length === 0 && ( -

- {totalCount === 0 ? c.emptyTitleNew : c.emptyTitleSearch} -

- )} -
+ <> + + + + {visibleJobs.map(job => ( + setEditor({ mode: 'edit', job }) }, + { icon: 'trash', label: t.common.delete, onSelect: () => setPendingDelete(job), tone: 'danger' } + ]} + /> + } + onSelect={() => setSelectedJobId(job.id)} + /> + ))} + {visibleJobs.length === 0 && ( +

{c.emptyTitleSearch}

+ )} + setEditor({ mode: 'create' })} /> +
- {selectedJob ? ( setPendingDelete(selectedJob)} - onEdit={() => setEditor({ mode: 'edit', job: selectedJob })} onOpenSession={onOpenSession} onPauseResume={() => void handlePauseResume(selectedJob)} onTrigger={() => void handleTrigger(selectedJob)} /> ) : ( -
-
- -

{totalCount === 0 ? c.emptyDescNew : c.emptyDescSearch}

-
-
+ )} -
-
+ + )} setEditor({ mode: 'closed' })} onSave={handleEditorSave} /> @@ -488,42 +500,32 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt -
+ ) } function CronJobListRow({ active, - c, job, + menu, onSelect }: { active: boolean - c: Translations['cron'] job: CronJob + menu?: React.ReactNode onSelect: () => void }) { const state = jobState(job) return ( - + ) } @@ -531,8 +533,6 @@ function CronJobDetail({ busy, c, job, - onDelete, - onEdit, onOpenSession, onPauseResume, onTrigger @@ -540,8 +540,6 @@ function CronJobDetail({ busy: boolean c: Translations['cron'] job: CronJob - onDelete: () => void - onEdit: () => void onOpenSession?: (sessionId: string) => void onPauseResume: () => void onTrigger: () => void @@ -552,69 +550,49 @@ function CronJobDetail({ const prompt = jobPrompt(job) return ( -
-
-
-
-
-
-
-

{jobTitle(job)}

- {c.states[state] ?? state} - {deliver && deliver !== DEFAULT_DELIVER && ( - {c.deliveryLabels[deliver] ?? deliver} - )} -
-
- - - {jobScheduleDisplay(job)} - - - {c.last} {formatTime(job.last_run_at)} - - - {c.next} {formatTime(job.next_run_at)} - -
-
-
- - - - -
-
- - {prompt &&

{prompt}

} - {job.last_error && ( -

- - {job.last_error} -

- )} -
- - + +
+
+
+

{jobTitle(job)}

+ {c.states[state] ?? state} +
+
+ + {isPaused ? c.resumeTitle : c.pauseTitle} + + + {c.triggerNow} + +
-
-
+ + + + {job.last_error ? ( +
+ + {job.last_error} +
+ ) : null} + + + {prompt ? ( +
+ {c.promptLabel} + {prompt} +
+ ) : null} + + + ) } @@ -685,10 +663,10 @@ function CronJobRuns({ return (
-
+ {c.runHistory} {runs && runs.length > 0 ? ` · ${runs.length}` : ''} -
+ {runs === null ? (
@@ -699,13 +677,13 @@ function CronJobRuns({
{runs.map(run => ( @@ -716,16 +694,6 @@ function CronJobRuns({ ) } -function StatePill({ children, tone }: { children: string; tone: keyof typeof PILL_TONE }) { - return ( - - {children} - - ) -} - function CronEditorDialog({ editor, onClose, diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index f9dc2d8320b..854abadda5a 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -581,7 +581,7 @@ export function DesktopController() { } }, []) - const { gatewayLogLines, inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, requestGateway) + const { inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, requestGateway) const updateActiveSessionRuntimeInfo = useCallback( (info: { branch?: string; cwd?: string }) => { @@ -1061,7 +1061,6 @@ export function DesktopController() { commandCenterOpen, extraLeftItems: statusbarItemGroups.flat.left, extraRightItems: statusbarItemGroups.flat.right, - gatewayLogLines, gatewayState, inferenceStatus, openAgents, diff --git a/apps/desktop/src/app/overlays/overlay-chrome.tsx b/apps/desktop/src/app/overlays/overlay-chrome.tsx index 23a57da4eb5..5a28e4fb80e 100644 --- a/apps/desktop/src/app/overlays/overlay-chrome.tsx +++ b/apps/desktop/src/app/overlays/overlay-chrome.tsx @@ -1,26 +1,11 @@ -import type { ButtonHTMLAttributes, ComponentProps, ReactNode } from 'react' +import type { ButtonHTMLAttributes, ReactNode } from 'react' import { cn } from '@/lib/utils' -export const overlayCardClass = - 'rounded-lg border border-[color-mix(in_srgb,var(--dt-border)_52%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)] shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_34%,transparent)]' - -interface OverlayCardProps extends ComponentProps<'div'> { - children: ReactNode -} - interface OverlayActionButtonProps extends ButtonHTMLAttributes { tone?: 'default' | 'danger' | 'subtle' } -export function OverlayCard({ children, className, ...props }: OverlayCardProps) { - return ( -
- {children} -
- ) -} - export function OverlayActionButton({ children, className, diff --git a/apps/desktop/src/app/overlays/overlay-search-input.tsx b/apps/desktop/src/app/overlays/overlay-search-input.tsx deleted file mode 100644 index 4f82b0918bc..00000000000 --- a/apps/desktop/src/app/overlays/overlay-search-input.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import type { RefObject } from 'react' - -import { SearchField } from '@/components/ui/search-field' - -interface OverlaySearchInputProps { - containerClassName?: string - inputRef?: RefObject - loading?: boolean - onChange: (value: string) => void - placeholder: string - value: string -} - -// Borderless underline search — matches the tools/skills page (PageSearchShell). -export function OverlaySearchInput({ - containerClassName, - inputRef, - loading = false, - onChange, - placeholder, - value -}: OverlaySearchInputProps) { - return ( - - ) -} diff --git a/apps/desktop/src/app/overlays/overlay-split-layout.tsx b/apps/desktop/src/app/overlays/overlay-split-layout.tsx index fd562b40e28..6b95e0e4830 100644 --- a/apps/desktop/src/app/overlays/overlay-split-layout.tsx +++ b/apps/desktop/src/app/overlays/overlay-split-layout.tsx @@ -1,7 +1,5 @@ import type { ReactNode } from 'react' -import { Button } from '@/components/ui/button' -import { Codicon } from '@/components/ui/codicon' import type { IconComponent } from '@/lib/icons' import { cn } from '@/lib/utils' @@ -50,9 +48,10 @@ export function OverlaySidebar({ children, className }: OverlaySidebarProps) { return (
+ ) +} + +export interface PanelMenuItem { + disabled?: boolean + icon?: string + label: string + onSelect: () => void + tone?: 'danger' | 'default' +} + +// Per-row "⋮" actions menu — mirrors the sidebar session row's settled pattern +// (size-5 ghost trigger + kebab-vertical codicon + w-40 content). Hidden until +// the row is hovered/focused (or the menu is open). Returns null with no items +// (e.g. the default profile, which can't be renamed/deleted). +export function PanelRowMenu({ items, label = 'Actions' }: { items: PanelMenuItem[]; label?: string }) { + if (items.length === 0) { + return null + } + + return ( + + + + + + {items.map(item => ( + + {item.icon ? : null} + {item.label} + + ))} + + + ) +} + +// Scrolling detail region. Fills the column (no right rail here, unlike the +// trace inspector), so the content stretches the full available width. +export function PanelDetail({ children, className }: { children: ReactNode; className?: string }) { + return ( +
+
{children}
+
+ ) +} + +interface PanelEmptyProps { + action?: ReactNode + description?: ReactNode + // Codicon glyph name (e.g. 'hubot', 'warning', 'loading~spin'). + icon?: string + title?: ReactNode +} + +export function PanelEmpty({ action, description, icon = 'inbox', title }: PanelEmptyProps) { + return ( +
+
+ + {title ?

{title}

: null} + {description ? ( +

{description}

+ ) : null} + {action ?
{action}
: null} +
+
+ ) +} + +export function PanelSectionLabel({ children, className }: { children: ReactNode; className?: string }) { + return ( +
+ {children} +
+ ) +} + +// Inspector-style key/value grid (mirrors the trace span inspector's
). +export interface PanelMetaRow { + label: ReactNode + value: ReactNode +} + +export function PanelMeta({ className, rows }: { className?: string; rows: PanelMetaRow[] }) { + return ( +
+ {rows.map((row, i) => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+ ) +} + +// Monospace content block (job prompt, etc.) — mirrors the inspector's +// input/output
 blocks: subtle bg, no border.
+export function PanelBlock({ children, className }: { children: ReactNode; className?: string }) {
+  return (
+    
+      {children}
+    
+ ) +} + +export type PanelPillTone = 'bad' | 'good' | 'muted' | 'warn' + +const PILL_TONE: Record = { + bad: 'bg-destructive/10 text-destructive', + good: 'bg-primary/10 text-primary', + muted: 'bg-foreground/10 text-muted-foreground', + warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300' +} + +export function PanelPill({ children, tone = 'muted' }: { children: ReactNode; tone?: PanelPillTone }) { + return ( + + {children} + + ) +} + +// Self-describing centered "+" that sits as the LAST item in a PanelList. The +// label rides aria/title only — no visible text. +export function PanelAddButton({ + icon = 'add', + label, + onClick +}: { + icon?: string + label: string + onClick: () => void +}) { + return ( + + ) +} + +// Visible ghost action for a detail header (cron pause/resume/trigger, …). +export function PanelAction({ + children, + disabled, + icon, + onClick +}: { + children: ReactNode + disabled?: boolean + icon: string + onClick: () => void +}) { + return ( + + ) +} diff --git a/apps/desktop/src/app/profiles/index.tsx b/apps/desktop/src/app/profiles/index.tsx index c66bfe8e362..3e44f7fd912 100644 --- a/apps/desktop/src/app/profiles/index.tsx +++ b/apps/desktop/src/app/profiles/index.tsx @@ -1,8 +1,10 @@ +import { useStore } from '@nanostores/react' import type * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { PageLoader } from '@/components/page-loader' import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' import { Dialog, DialogContent, @@ -18,21 +20,34 @@ import { createProfile, deleteProfile, getProfiles, - getProfileSetupCommand, getProfileSoul, type ProfileInfo, renameProfile, updateProfileSoul } from '@/hermes' import { useI18n } from '@/i18n' -import { AlertTriangle, Pencil, Save, Terminal, Trash2, Users } from '@/lib/icons' +import { AlertTriangle, Save } from '@/lib/icons' +import { profileColorSoft, resolveProfileColor } from '@/lib/profile-color' import { slug } from '@/lib/sanitize' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' +import { $profileColors } from '@/store/profile' import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' -import { OverlayMain, OverlayNewButton, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout' -import { OverlayView } from '../overlays/overlay-view' +import { + Panel, + PanelAddButton, + PanelBody, + PanelDetail, + PanelEmpty, + PanelHeader, + PanelList, + PanelListRow, + PanelMeta, + PanelPill, + PanelRowMenu, + PanelSectionLabel +} from '../overlays/panel' const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/ @@ -49,7 +64,9 @@ export function ProfilesView({ onClose }: ProfilesViewProps) { const p = t.profiles const [profiles, setProfiles] = useState(null) const [selectedName, setSelectedName] = useState(null) + const [query, setQuery] = useState('') const [createOpen, setCreateOpen] = useState(false) + const [pendingRename, setPendingRename] = useState(null) const [pendingDelete, setPendingDelete] = useState(null) const [deleting, setDeleting] = useState(false) @@ -83,6 +100,18 @@ export function ProfilesView({ onClose }: ProfilesViewProps) { return profiles.find(p => p.name === selectedName) ?? profiles[0] ?? null }, [profiles, selectedName]) + const visibleProfiles = useMemo(() => { + const q = query.trim().toLowerCase() + + if (!profiles || !q) { + return profiles ?? [] + } + + return profiles.filter( + profile => profile.name.toLowerCase().includes(q) || (profile.model ?? '').toLowerCase().includes(q) + ) + }, [profiles, query]) + const handleCreate = useCallback( async (name: string, cloneFrom: null | string) => { const trimmed = name.trim() @@ -140,46 +169,79 @@ export function ProfilesView({ onClose }: ProfilesViewProps) { }, [p, pendingDelete, refresh]) return ( - + {!profiles ? ( + ) : profiles.length === 0 ? ( + setCreateOpen(true)} size="sm"> + {p.newProfile} + + } + description={p.createDesc} + icon="organization" + title={p.noProfiles} + /> ) : ( - - - setCreateOpen(true)} /> - {profiles.map(profile => ( - setSelectedName(profile.name)} - profile={profile} - /> - ))} - {profiles.length === 0 && ( -

{p.noProfiles}

- )} -
+ <> + + + + {visibleProfiles.map(profile => ( + setPendingRename(profile) }, + { + icon: 'trash', + label: t.common.delete, + onSelect: () => setPendingDelete(profile), + tone: 'danger' + } + ] + } + /> + } + onSelect={() => setSelectedName(profile.name)} + profile={profile} + /> + ))} + setCreateOpen(true)} /> + - {selected ? ( - setPendingDelete(selected)} - onRename={newName => handleRename(selected.name, newName)} - profile={selected} - /> + ) : ( -
-
- -

{p.selectPrompt}

-
-
+ )} -
-
+ + )} + setPendingRename(null)} + onRename={async newName => { + if (pendingRename) { + await handleRename(pendingRename.name, newName) + setPendingRename(null) + } + }} + open={pendingRename !== null} + /> + setCreateOpen(false)} onCreate={async (name, cloneFrom) => handleCreate(name, cloneFrom)} @@ -213,150 +275,106 @@ export function ProfilesView({ onClose }: ProfilesViewProps) { -
+ ) } -function ProfileRow({ active, onSelect, profile }: { active: boolean; onSelect: () => void; profile: ProfileInfo }) { - const { t } = useI18n() - const p = t.profiles - - return ( - - ) -} - -function ProfileDetail({ - onDelete, - onRename, +function ProfileRow({ + active, + menu, + onSelect, profile }: { - onDelete: () => void - onRename: (newName: string) => Promise + active: boolean + menu?: React.ReactNode + onSelect: () => void profile: ProfileInfo }) { - const { t } = useI18n() - const p = t.profiles - const [renameOpen, setRenameOpen] = useState(false) - const [copying, setCopying] = useState(false) - - const handleCopySetup = useCallback(async () => { - setCopying(true) - - try { - const { command } = await getProfileSetupCommand(profile.name) - await navigator.clipboard.writeText(command) - notify({ kind: 'success', title: p.setupCopied, message: command }) - } catch (err) { - notifyError(err, p.failedCopy) - } finally { - setCopying(false) - } - }, [p, profile.name]) + const colors = useStore($profileColors) return ( -
-
-
-
-
-
-
-

{profile.name}

- {profile.is_default && ( - - {p.defaultBadge} - - )} - {profile.has_env && ( - - .env - - )} -
-

- {profile.path} -

-
-
- {!profile.is_default && ( - - )} - - {!profile.is_default && ( - - )} -
-
- -
- - {profile.model ? ( - <> - {profile.model} - {profile.provider && · {profile.provider}} - - ) : ( - {p.notSet} - )} - - {profile.skill_count} -
-
- - -
-
- - setRenameOpen(false)} - onRename={async newName => { - await onRename(newName) - setRenameOpen(false) - }} - open={renameOpen} - /> -
+ + } + menu={menu} + onSelect={onSelect} + rowKey={profile.name} + title={profile.name} + /> ) } -function DetailRow({ children, label }: { children: React.ReactNode; label: string }) { +// Leading glyph for a profile row, mirroring the sidebar rail: the default +// profile gets the `home` icon; named profiles get a soft color-tinted square +// with their initial in the profile's color. +function ProfileGlyph({ color, isDefault, name }: { color: null | string; isDefault: boolean; name: string }) { + if (isDefault) { + return + } + + const hue = color ?? 'var(--ui-text-quaternary)' + + const initial = + name + .replace(/[^a-z0-9]/gi, '') + .charAt(0) + .toUpperCase() || '?' + return ( -
-
{label}
-
{children}
-
+ + ) +} + +function ProfileDetail({ profile }: { profile: ProfileInfo }) { + const { t } = useI18n() + const p = t.profiles + + return ( + +
+
+
+

{profile.name}

+ {profile.is_default && {p.defaultBadge}} + {profile.has_env && .env} +
+

+ {profile.path} +

+
+ + + {profile.model} + {profile.provider ? · {profile.provider} : null} + + ) : ( + {p.notSet} + ) + }, + { label: p.skillsLabel, value: profile.skill_count } + ]} + /> +
+ + +
) } @@ -419,7 +437,7 @@ function SoulEditor({ profileName }: { profileName: string }) {
-

SOUL.md

+ SOUL.md

{p.soulDesc}

{dirty && {p.unsavedChanges}} @@ -429,7 +447,7 @@ function SoulEditor({ profileName }: { profileName: string }) { ) : (