feat: more ui qa

This commit is contained in:
Brooklyn Nicholson
2026-05-16 21:26:50 -05:00
parent 64ab17182a
commit c7e6a48bfb
84 changed files with 939 additions and 1120 deletions
+40 -25
View File
@@ -36,12 +36,7 @@ function statusGlyph(status: SubagentStatus): ReactNode {
return <AlertCircle aria-label="Failed" className="size-3.5 shrink-0 text-destructive" />
}
return (
<CheckCircle2
aria-label="Done"
className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85"
/>
)
return <CheckCircle2 aria-label="Done" className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85" />
}
const STREAM_TONE: Record<SubagentStreamEntry['kind'], string> = {
@@ -65,7 +60,11 @@ function streamGlyph(entry: SubagentStreamEntry): ReactNode {
}
if (entry.kind === 'thinking') {
return <span aria-hidden className="font-mono text-[0.7rem] leading-none text-muted-foreground/70"></span>
return (
<span aria-hidden className="font-mono text-[0.7rem] leading-none text-muted-foreground/70">
</span>
)
}
return <span aria-hidden className="mt-0.5 size-1 shrink-0 rounded-full bg-muted-foreground/55" />
@@ -103,8 +102,13 @@ export function AgentsView({ onClose }: AgentsViewProps) {
}
const fmtDuration = (seconds?: number) => {
if (!seconds || seconds <= 0) return ''
if (seconds < 60) return `${seconds.toFixed(1)}s`
if (!seconds || seconds <= 0) {
return ''
}
if (seconds < 60) {
return `${seconds.toFixed(1)}s`
}
const m = Math.floor(seconds / 60)
const s = Math.round(seconds % 60)
@@ -113,18 +117,29 @@ const fmtDuration = (seconds?: number) => {
}
const fmtTokens = (value?: number) => {
if (!value) return ''
if (!value) {
return ''
}
return value >= 1000 ? `${(value / 1000).toFixed(1)}k tok` : `${value} tok`
}
const fmtAge = (updatedAt: number, nowMs: number) => {
const s = Math.max(0, Math.round((nowMs - updatedAt) / 1000))
if (s < 2) return 'now'
if (s < 60) return `${s}s ago`
if (s < 2) {
return 'now'
}
if (s < 60) {
return `${s}s ago`
}
const m = Math.floor(s / 60)
if (m < 60) return `${m}m ago`
if (m < 60) {
return `${m}m ago`
}
return `${Math.floor(m / 60)}h ago`
}
@@ -152,12 +167,14 @@ function groupDelegations(roots: readonly SubagentNode[]): RootGroup[] {
if (prev && sameShape && closeInTime && uniqueStep) {
prev.nodes.push(node)
continue
}
if (node.taskCount > 1) {
n += 1
groups.push({ id: `delegation-${n}`, label: `Delegation ${n}`, nodes: [node], taskCount: node.taskCount })
continue
}
@@ -180,7 +197,9 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) {
const cost = flat.reduce((sum, n) => sum + (n.costUsd ?? 0), 0)
useEffect(() => {
if (active <= 0 || typeof window === 'undefined') return
if (active <= 0 || typeof window === 'undefined') {
return
}
const id = window.setInterval(() => setNowMs(Date.now()), 500)
@@ -261,10 +280,7 @@ function StreamLine({
const tone = entry.isError ? 'text-destructive' : STREAM_TONE[entry.kind]
return (
<div
className="flex min-w-0 items-baseline gap-2 text-[0.72rem] leading-relaxed"
ref={enterRef}
>
<div className="flex min-w-0 items-baseline gap-2 text-[0.72rem] leading-relaxed" ref={enterRef}>
<span className="flex h-[0.95rem] shrink-0 items-center">{streamGlyph(entry)}</span>
<span className={cn('min-w-0 flex-1 wrap-anywhere', tone, isMono && 'font-mono text-[0.69rem]')}>
{entry.text}
@@ -283,13 +299,17 @@ function StreamLine({
function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: number; nowMs: number }) {
const running = node.status === 'running' || node.status === 'queued'
const elapsed = useElapsedSeconds(running, `subagent:${node.id}`)
const durationSeconds =
typeof node.durationSeconds === 'number' ? Math.max(0, Math.round(node.durationSeconds)) : elapsed
const [open, setOpen] = useState(() => running || depth < 2)
const enterRef = useEnterAnimation(true, `subagent-row:${node.id}`)
useEffect(() => {
if (running) setOpen(true)
if (running) {
setOpen(true)
}
}, [running])
const visibleRows = open ? node.stream.slice(-10) : node.stream.slice(-2)
@@ -304,11 +324,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
].filter(Boolean)
return (
<div
className={cn('grid min-w-0 max-w-full gap-2', depth > 0 && 'pl-4')}
data-slot="tool-block"
ref={enterRef}
>
<div className={cn('grid min-w-0 max-w-full gap-2', depth > 0 && 'pl-4')} data-slot="tool-block" ref={enterRef}>
<button
aria-expanded={open}
className="group flex w-full min-w-0 items-start gap-2.5 text-left"
@@ -374,4 +390,3 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
</div>
)
}
+30 -67
View File
@@ -5,7 +5,9 @@ import { useNavigate } from 'react-router-dom'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
import {
Pagination,
PaginationButton,
@@ -18,8 +20,7 @@ import {
import { getSessionMessages, listSessions } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
import { Codicon } from '@/components/ui/codicon'
import { FileImage, FileText, FolderOpen, Layers3, Link2 } from '@/lib/icons'
import { FileImage, FileText, FolderOpen, Link2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import type { SessionInfo, SessionMessage } from '@/types/hermes'
@@ -364,10 +365,7 @@ interface ArtifactsViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup
}
export function ArtifactsView({
setStatusbarItemGroup: _setStatusbarItemGroup,
...props
}: ArtifactsViewProps) {
export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: ArtifactsViewProps) {
const navigate = useNavigate()
const [artifacts, setArtifacts] = useState<ArtifactRecord[] | null>(null)
const [query, setQuery] = useState('')
@@ -506,39 +504,26 @@ export function ArtifactsView({
{...props}
filters={
<>
<FilterButton
active={kindFilter === 'all'}
icon={Layers3}
label={`All (${counts.all})`}
onClick={() => setKindFilter('all')}
/>
<FilterButton
active={kindFilter === 'image'}
icon={FileImage}
label={`Images (${counts.image})`}
onClick={() => setKindFilter('image')}
/>
<FilterButton
active={kindFilter === 'file'}
icon={FileText}
label={`Files (${counts.file})`}
onClick={() => setKindFilter('file')}
/>
<FilterButton
active={kindFilter === 'link'}
icon={Link2}
label={`Links (${counts.link})`}
onClick={() => setKindFilter('link')}
/>
<TextTab active={kindFilter === 'all'} onClick={() => setKindFilter('all')}>
All <TextTabMeta>({counts.all})</TextTabMeta>
</TextTab>
<TextTab active={kindFilter === 'image'} onClick={() => setKindFilter('image')}>
Images <TextTabMeta>({counts.image})</TextTabMeta>
</TextTab>
<TextTab active={kindFilter === 'file'} onClick={() => setKindFilter('file')}>
Files <TextTabMeta>({counts.file})</TextTabMeta>
</TextTab>
<TextTab active={kindFilter === 'link'} onClick={() => setKindFilter('link')}>
Links <TextTabMeta>({counts.link})</TextTabMeta>
</TextTab>
</>
}
onSearchChange={setQuery}
searchPlaceholder="Search artifacts..."
searchValue={query}
searchTrailingAction={
<Button
aria-label={refreshing ? 'Refreshing artifacts' : 'Refresh artifacts'}
className="text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground"
disabled={refreshing}
onClick={() => void refreshArtifacts()}
size="icon-xs"
@@ -549,6 +534,7 @@ export function ArtifactsView({
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
</Button>
}
searchValue={query}
>
{!artifacts ? (
<PageLoader label="Indexing recent session artifacts" />
@@ -602,7 +588,7 @@ export function ArtifactsView({
total={visibleFileArtifacts.length}
/>
</div>
<div className="overflow-x-auto rounded-lg border border-(--ui-stroke-tertiary) bg-(--glass-chat-bubble-background) shadow-sm">
<div className="overflow-x-auto rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) shadow-sm">
<ArtifactTable artifacts={pagedFileArtifacts} ctx={cellCtx} filter={kindFilter} />
</div>
</section>
@@ -665,34 +651,6 @@ function ArtifactsPagination({ className, itemLabel, onPageChange, page, pageSiz
)
}
function FilterButton({
active,
icon: Icon,
label,
onClick
}: {
active: boolean
icon: typeof Layers3
label: string
onClick: () => void
}) {
return (
<Button
className={cn(
'h-7 gap-1.5 rounded-md px-2 text-[length:var(--conversation-caption-font-size)]',
active ? 'bg-(--ui-bg-tertiary) text-foreground' : 'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
onClick={onClick}
size="sm"
type="button"
variant="ghost"
>
<Icon className="size-3.5" />
{label}
</Button>
)
}
interface ArtifactImageCardProps {
artifact: ArtifactRecord
failedImage: boolean
@@ -704,7 +662,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
return (
<article
className={cn(
'group/artifact overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--glass-chat-bubble-background) shadow-sm'
'group/artifact overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) shadow-sm'
)}
>
<div
@@ -733,7 +691,9 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
<FileImage className="size-3" />
{artifact.kind}
</div>
<div className="truncate text-[length:var(--conversation-caption-font-size)] font-medium">{artifact.label}</div>
<div className="truncate text-[length:var(--conversation-caption-font-size)] font-medium">
{artifact.label}
</div>
<div className="mt-0.5 truncate text-[0.625rem] text-(--ui-text-tertiary)">{artifact.value}</div>
</div>
@@ -769,7 +729,7 @@ function ArtifactCellAction({
if (href) {
return (
<ExternalLink
className="flex h-full w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) font-normal text-(--ui-text-secondary) no-underline transition-colors hover:text-foreground hover:underline"
className="flex h-full w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) font-normal text-(--ui-text-secondary) no-underline underline-offset-4 decoration-current/20 transition-colors hover:text-foreground hover:underline"
href={href}
showExternalIcon={false}
title={title}
@@ -782,7 +742,7 @@ function ArtifactCellAction({
return (
<button
className={cn(
'flex h-full w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) font-normal text-(--ui-text-secondary) no-underline transition-colors hover:text-foreground hover:underline',
'flex h-full w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) font-normal text-(--ui-text-secondary) no-underline underline-offset-4 decoration-current/20 transition-colors hover:text-foreground hover:underline',
'cursor-pointer'
)}
onClick={onClick}
@@ -825,7 +785,10 @@ function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx })
return (
<div className="group/location flex min-w-0 items-center gap-1.5">
<div
className={cn('min-w-0 flex-1 truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)', isLink ? 'font-normal' : 'font-mono')}
className={cn(
'min-w-0 flex-1 truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)',
isLink ? 'font-normal' : 'font-mono'
)}
title={artifact.value}
>
{value}
@@ -902,7 +865,7 @@ function ArtifactTable({
</thead>
<tbody className="divide-y divide-(--ui-stroke-quaternary)">
{artifacts.map(artifact => (
<tr className="group/artifact transition-colors hover:bg-(--chrome-action-hover)" key={artifact.id}>
<tr className="group/artifact" key={artifact.id}>
{ARTIFACT_COLUMNS.map(col => {
const Cell = col.Cell
@@ -11,7 +11,7 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Clipboard, FileText, FolderOpen, ImageIcon, Link, type IconComponent, MessageSquareText } from '@/lib/icons'
import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { GHOST_ICON_BTN } from './controls'
@@ -39,7 +39,10 @@ export function ContextMenu({
<DropdownMenuTrigger asChild>
<Button
aria-label={state.tools.label}
className={cn(GHOST_ICON_BTN, 'data-[state=open]:bg-(--chrome-action-hover) data-[state=open]:text-foreground')}
className={cn(
GHOST_ICON_BTN,
'data-[state=open]:bg-(--chrome-action-hover) data-[state=open]:text-foreground'
)}
disabled={!state.tools.enabled}
size="icon"
title={state.tools.label}
@@ -93,7 +93,7 @@ export function ComposerControls({
<span className="block size-3 rounded-[0.1875rem] bg-current" />
)
) : (
<Codicon name="arrow-up" size="1rem" />
<Codicon name="arrow-up" size="1rem" />
)}
</Button>
)}
@@ -21,7 +21,9 @@ const entryPreview = (entry: QueuedPromptEntry) =>
export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendNow }: QueuePanelProps) {
const [collapsed, setCollapsed] = useState(false)
if (entries.length === 0) return null
if (entries.length === 0) {
return null
}
return (
<div className="rounded-2xl border border-border/65 bg-[color-mix(in_srgb,var(--dt-card)_70%,transparent)] py-0.5 shadow-[0_0_0_1px_color-mix(in_srgb,var(--dt-card)_30%,transparent)_inset]">
@@ -89,10 +89,7 @@ export function ComposerTriggerPopover({
return (
<button
className={cn(
COMPLETION_DRAWER_ROW_CLASS,
index === activeIndex && 'bg-(--ui-bg-tertiary)'
)}
className={cn(COMPLETION_DRAWER_ROW_CLASS, index === activeIndex && 'bg-(--ui-bg-tertiary)')}
data-highlighted={index === activeIndex ? '' : undefined}
key={item.id}
onClick={() => onPick(item)}
@@ -102,9 +99,7 @@ export function ComposerTriggerPopover({
<span className="grid size-3.5 shrink-0 place-items-center text-(--ui-text-tertiary)">
<Codicon name={completionIcon(kind, item)} size="0.875rem" />
</span>
<span className="min-w-0 shrink truncate font-mono font-medium leading-5 text-foreground">
{display}
</span>
<span className="min-w-0 shrink truncate font-mono font-medium leading-5 text-foreground">{display}</span>
{description && (
<span className="min-w-0 flex-1 truncate leading-5 text-(--ui-text-tertiary)">{description}</span>
)}
@@ -1,13 +1,13 @@
import { useCallback } from 'react'
import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus'
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus'
import {
addComposerAttachment,
setComposerTerminalSelection,
type ComposerAttachment,
removeComposerAttachment
removeComposerAttachment,
setComposerTerminalSelection
} from '@/store/composer'
import { notify, notifyError } from '@/store/notifications'
+6 -4
View File
@@ -11,12 +11,13 @@ import { Suspense, useMemo, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import { Thread } from '@/components/assistant-ui/thread'
import { Backdrop } from '@/components/Backdrop'
import { NotificationStack } from '@/components/notifications'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
import type { ChatMessage } from '@/lib/chat-messages'
import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime'
import { Codicon } from '@/components/ui/codicon'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
import { cn } from '@/lib/utils'
import type { ComposerAttachment } from '@/store/composer'
@@ -108,7 +109,7 @@ function ChatHeader({
title={title}
>
<Button
className="pointer-events-auto h-6 min-w-0 gap-1 rounded-md border border-transparent bg-(--ui-bg-quinary) px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-bg-tertiary) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-bg-tertiary) [-webkit-app-region:no-drag]"
className="pointer-events-auto h-6 min-w-0 gap-1 rounded-md border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
type="button"
variant="ghost"
>
@@ -269,10 +270,11 @@ export function ChatView({
return (
<div
className={cn(
'relative flex h-full min-w-0 flex-col overflow-hidden bg-(--glass-chat-surface-background)',
'relative isolate flex h-full min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background)',
className
)}
>
<Backdrop />
<ChatHeader
activeSessionId={activeSessionId}
isRoutedSessionView={isRoutedSessionView}
@@ -283,7 +285,7 @@ export function ChatView({
<NotificationStack />
<div className="relative min-h-0 max-w-full flex-1 overflow-hidden bg-(--glass-chat-surface-background) contain-[layout_paint]">
<div className="relative min-h-0 max-w-full flex-1 overflow-hidden bg-(--ui-chat-surface-background) contain-[layout_paint]">
<AssistantRuntimeProvider runtime={runtime}>
<Thread
clampToComposer={showChatBar}
@@ -2,10 +2,10 @@ import { useStore } from '@nanostores/react'
import type { CSSProperties, MutableRefObject, PointerEvent as ReactPointerEvent, RefObject } from 'react'
import { useEffect, useMemo, useRef } from 'react'
import { requestComposerInsert } from '@/app/chat/composer/focus'
import { CopyButton } from '@/components/ui/copy-button'
import { PanelBottom, Send, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { requestComposerInsert } from '@/app/chat/composer/focus'
import { notify } from '@/store/notifications'
import type { ConsoleEntry, PreviewConsoleState } from './preview-console-state'
@@ -100,7 +100,7 @@ export function PreviewEmptyState({
)}
{secondaryAction && (
<button
className="text-[0.6875rem] font-medium text-muted-foreground underline decoration-muted-foreground/25 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/55 disabled:cursor-default disabled:text-muted-foreground/55 disabled:no-underline"
className="text-[0.6875rem] font-medium text-muted-foreground underline decoration-current/20 underline-offset-4 transition-colors hover:text-foreground disabled:cursor-default disabled:text-muted-foreground/55 disabled:no-underline"
disabled={secondaryAction.disabled}
onClick={secondaryAction.onClick}
type="button"
@@ -298,7 +298,7 @@ function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: ()
return (
<div className="sticky top-0 z-10 flex justify-end border-b border-border/40 bg-transparent px-3 py-1 backdrop-blur">
<button
className="text-[0.625rem] font-bold text-muted-foreground underline decoration-muted-foreground/25 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/55"
className="text-[0.625rem] font-bold text-muted-foreground underline decoration-current/20 underline-offset-4 transition-colors hover:text-foreground"
onClick={onToggle}
type="button"
>
@@ -13,7 +13,6 @@ describe('PreviewPane console state', () => {
const rendered = render(
<PreviewPane
onClose={vi.fn()}
setTitlebarToolGroup={setTitlebarToolGroup}
target={{
kind: 'url',
@@ -3,7 +3,6 @@ import type { PointerEvent as ReactPointerEvent } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
import { Codicon } from '@/components/ui/codicon'
import { Bug } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@@ -31,7 +30,6 @@ type PreviewWebview = HTMLElement & {
interface PreviewPaneProps {
embedded?: boolean
onClose: () => void
onRestartServer?: (url: string, context?: string) => Promise<string>
reloadRequest?: number
setTitlebarToolGroup?: SetTitlebarToolGroup
@@ -85,7 +83,7 @@ function PreviewLoadError({
body={
<>
<a
className="pointer-events-auto block cursor-pointer font-mono text-muted-foreground/90 underline decoration-muted-foreground/30 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/70"
className="pointer-events-auto block cursor-pointer font-mono text-muted-foreground/90 underline decoration-current/20 underline-offset-4 transition-colors hover:text-foreground"
href={error.url}
onClick={event => {
event.preventDefault()
@@ -118,7 +116,6 @@ const TITLEBAR_GROUP_ID = 'preview'
export function PreviewPane({
embedded = false,
onClose,
onRestartServer,
reloadRequest = 0,
setTitlebarToolGroup,
@@ -300,35 +297,13 @@ export function PreviewPane({
onSelect: toggleDevTools
}
]
: []),
{
icon: <Codicon name="refresh" spinning={loading} />,
id: `${TITLEBAR_GROUP_ID}-reload`,
label: 'Reload preview',
onSelect: reloadPreview
},
{
icon: <Codicon name="close" />,
id: `${TITLEBAR_GROUP_ID}-close`,
label: 'Close preview',
onSelect: onClose
}
: [])
]
setTitlebarToolGroup(TITLEBAR_GROUP_ID, tools)
return () => setTitlebarToolGroup(TITLEBAR_GROUP_ID, [])
}, [
consoleOpen,
consoleState,
devtoolsOpen,
isWebPreview,
loading,
onClose,
reloadPreview,
setTitlebarToolGroup,
toggleDevTools
])
}, [consoleOpen, consoleState, devtoolsOpen, isWebPreview, setTitlebarToolGroup, toggleDevTools])
useEffect(() => {
if (!consoleOpen) {
@@ -633,7 +608,7 @@ export function PreviewPane({
<div className="pointer-events-none flex min-h-(--titlebar-height) items-center gap-1.5 border-b border-border/60 bg-background px-2 py-1">
<div className="min-w-0 flex-1">
<a
className="pointer-events-auto inline max-w-full cursor-pointer truncate text-left text-xs font-medium text-foreground underline-offset-4 transition-colors hover:text-primary hover:underline"
className="pointer-events-auto inline max-w-full cursor-pointer truncate text-left text-xs font-medium text-foreground underline-offset-4 decoration-current/20 transition-colors hover:text-primary hover:underline"
href={currentUrl}
rel="noreferrer"
target="_blank"
@@ -14,7 +14,7 @@ import {
$filePreviewTabs,
$previewReloadRequest,
$previewTarget,
closeActiveRightRailTab,
closeRightRail,
closeRightRailTab,
type PreviewTarget
} from '@/store/preview'
@@ -79,57 +79,69 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
const isPreview = activeTab.id === RIGHT_RAIL_PREVIEW_TAB_ID
return (
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-(--ui-stroke-tertiary) bg-(--glass-editor-surface-background) text-(--ui-text-tertiary)">
<div
className="flex h-(--titlebar-height) shrink-0 overflow-x-auto overflow-y-hidden overscroll-x-contain border-b border-(--ui-stroke-tertiary) bg-(--glass-sidebar-surface-background) [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
role="tablist"
>
{tabs.map(tab => {
const active = tab.id === activeTab.id
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-(--ui-stroke-tertiary) bg-(--ui-editor-surface-background) text-(--ui-text-tertiary)">
<div className="group/rail-tabs flex h-(--titlebar-height) shrink-0 border-b border-(--ui-stroke-tertiary) bg-(--ui-sidebar-surface-background)">
<div
className="flex min-w-0 flex-1 overflow-x-auto overflow-y-hidden overscroll-x-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
role="tablist"
>
{tabs.map(tab => {
const active = tab.id === activeTab.id
return (
<div
className={cn(
'group/tab relative flex h-full min-w-0 max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag]',
active
? 'bg-(--glass-editor-surface-background) text-foreground [--tab-bg:var(--glass-editor-surface-background)]'
: 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--glass-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground hover:[--tab-bg:var(--chrome-action-hover)]'
)}
key={tab.id}
>
{active && <span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />}
<button
aria-selected={active}
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
onClick={() => selectRightRailTab(tab.id)}
role="tab"
title={tab.label}
type="button"
return (
<div
className={cn(
'group/tab relative flex h-full min-w-0 max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag] last:border-r last:border-(--ui-stroke-quaternary)',
active
? 'bg-(--ui-editor-surface-background) text-foreground [--tab-bg:var(--ui-editor-surface-background)]'
: 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--ui-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
key={tab.id}
>
<span className="block min-w-0 truncate">{tab.label}</span>
</button>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
/>
<button
aria-label={`Close ${tab.label}`}
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"
onClick={() => closeRightRailTab(tab.id)}
title={`Close ${tab.label}`}
type="button"
>
<Codicon name="close" size="0.75rem" />
</button>
</div>
)
})}
{active && (
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />
)}
<button
aria-selected={active}
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
onClick={() => selectRightRailTab(tab.id)}
role="tab"
title={tab.label}
type="button"
>
<span className="block min-w-0 truncate">{tab.label}</span>
</button>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
/>
<button
aria-label={`Close ${tab.label}`}
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"
onClick={() => closeRightRailTab(tab.id)}
title={`Close ${tab.label}`}
type="button"
>
<Codicon name="close" size="0.75rem" />
</button>
</div>
)
})}
</div>
<button
aria-label="Close preview pane"
className="mr-1.5 grid size-6 shrink-0 self-center place-items-center rounded-md text-(--ui-text-tertiary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring group-hover/rail-tabs:opacity-100 [-webkit-app-region:no-drag]"
onClick={closeRightRail}
title="Close preview pane"
type="button"
>
<Codicon name="close" size="0.75rem" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-hidden">
<PreviewPane
embedded
onClose={closeActiveRightRailTab}
onRestartServer={isPreview ? onRestartServer : undefined}
reloadRequest={previewReloadRequest}
setTitlebarToolGroup={setTitlebarToolGroup}
+25 -33
View File
@@ -365,31 +365,28 @@ export function CommandCenterView({
}
}, [])
const refreshUsage = useCallback(
async (days: UsagePeriod) => {
const requestId = usageRequestRef.current + 1
usageRequestRef.current = requestId
setUsageLoading(true)
setUsageError('')
const refreshUsage = useCallback(async (days: UsagePeriod) => {
const requestId = usageRequestRef.current + 1
usageRequestRef.current = requestId
setUsageLoading(true)
setUsageError('')
try {
const response = await getUsageAnalytics(days)
try {
const response = await getUsageAnalytics(days)
if (usageRequestRef.current === requestId) {
setUsage(response)
}
} catch (error) {
if (usageRequestRef.current === requestId) {
setUsageError(error instanceof Error ? error.message : String(error))
}
} finally {
if (usageRequestRef.current === requestId) {
setUsageLoading(false)
}
if (usageRequestRef.current === requestId) {
setUsage(response)
}
},
[]
)
} catch (error) {
if (usageRequestRef.current === requestId) {
setUsageError(error instanceof Error ? error.message : String(error))
}
} finally {
if (usageRequestRef.current === requestId) {
setUsageLoading(false)
}
}
}, [])
useEffect(() => {
if (!debouncedQuery) {
@@ -583,7 +580,10 @@ export function CommandCenterView({
const beginAuxiliaryEdit = useCallback(
(task: string) => {
const current = auxiliary?.tasks.find(entry => entry.task === task)
const initialProvider = current?.provider && current.provider !== 'auto' ? current.provider : mainModel?.provider ?? ''
const initialProvider =
current?.provider && current.provider !== 'auto' ? current.provider : (mainModel?.provider ?? '')
const initialModel = current?.model || mainModel?.model || ''
setAuxDraft({ provider: initialProvider, model: initialModel })
setEditingAuxTask(task)
@@ -658,15 +658,7 @@ export function CommandCenterView({
{SECTIONS.map(value => (
<OverlayNavItem
active={section === value}
icon={
value === 'sessions'
? Pin
: value === 'system'
? Activity
: value === 'models'
? Cpu
: BarChart3
}
icon={value === 'sessions' ? Pin : value === 'system' ? Activity : value === 'models' ? Cpu : BarChart3}
key={value}
label={SECTION_LABELS[value]}
onClick={() => setSection(value)}
@@ -1168,7 +1160,7 @@ function UsagePanel({ error, loading, onPeriodChange, onRefresh, period, usage }
) : (
<div className="text-xs text-muted-foreground">
No usage in the last {period} days.{' '}
<button className="underline" onClick={onRefresh} type="button">
<button className="underline underline-offset-4 decoration-current/20" onClick={onRefresh} type="button">
Retry
</button>
</div>
+29 -18
View File
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
Dialog,
DialogContent,
@@ -24,7 +25,6 @@ import {
triggerCronJob,
updateCronJob
} from '@/hermes'
import { Codicon } from '@/components/ui/codicon'
import { AlertTriangle, Clock, Pause, Pencil, Play, Trash2, Zap } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@@ -216,11 +216,23 @@ function scheduleOptionForExpr(expr: string): ScheduleOption {
return SCHEDULE_OPTIONS.find(option => option.value === 'weekdays') ?? SCHEDULE_OPTIONS[0]
}
if (dayOfMonth === '*' && month === '*' && isIntegerToken(dayOfWeek) && isIntegerToken(minute) && isIntegerToken(hour)) {
if (
dayOfMonth === '*' &&
month === '*' &&
isIntegerToken(dayOfWeek) &&
isIntegerToken(minute) &&
isIntegerToken(hour)
) {
return SCHEDULE_OPTIONS.find(option => option.value === 'weekly') ?? SCHEDULE_OPTIONS[0]
}
if (month === '*' && dayOfWeek === '*' && isIntegerToken(dayOfMonth) && isIntegerToken(minute) && isIntegerToken(hour)) {
if (
month === '*' &&
dayOfWeek === '*' &&
isIntegerToken(dayOfMonth) &&
isIntegerToken(minute) &&
isIntegerToken(hour)
) {
return SCHEDULE_OPTIONS.find(option => option.value === 'monthly') ?? SCHEDULE_OPTIONS[0]
}
@@ -297,10 +309,7 @@ interface CronViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup
}
export function CronView({
setStatusbarItemGroup: _setStatusbarItemGroup,
...props
}: CronViewProps) {
export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: CronViewProps) {
const [jobs, setJobs] = useState<CronJob[] | null>(null)
const [query, setQuery] = useState('')
const [refreshing, setRefreshing] = useState(false)
@@ -475,9 +484,7 @@ export function CronView({
</div>
</div>
)}
<div className="hidden">
{totalCount === 0 ? 'No scheduled jobs' : `${enabledCount}/${totalCount} active`}
</div>
<div className="hidden">{totalCount === 0 ? 'No scheduled jobs' : `${enabledCount}/${totalCount} active`}</div>
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
@@ -488,7 +495,8 @@ export function CronView({
<DialogDescription>
{pendingDelete ? (
<>
This will remove <span className="font-medium text-foreground">{truncate(jobTitle(pendingDelete), 60)}</span>{' '}
This will remove{' '}
<span className="font-medium text-foreground">{truncate(jobTitle(pendingDelete), 60)}</span>{' '}
permanently. It will stop firing immediately.
</>
) : null}
@@ -586,13 +594,14 @@ function CronJobRow({
)
}
function IconAction({
children,
className,
...props
}: Omit<React.ComponentProps<typeof Button>, 'size' | 'variant'>) {
function IconAction({ children, className, ...props }: Omit<React.ComponentProps<typeof Button>, 'size' | 'variant'>) {
return (
<Button className={cn('size-7 text-muted-foreground hover:text-foreground', className)} size="icon" variant="ghost" {...props}>
<Button
className={cn('size-7 text-muted-foreground hover:text-foreground', className)}
size="icon"
variant="ghost"
{...props}
>
{children}
</Button>
)
@@ -600,7 +609,9 @@ function IconAction({
function StatePill({ children, tone }: { children: string; tone: keyof typeof PILL_TONE }) {
return (
<span className={cn('inline-flex items-center rounded-full px-1.5 py-0.5 text-[0.64rem] capitalize', PILL_TONE[tone])}>
<span
className={cn('inline-flex items-center rounded-full px-1.5 py-0.5 text-[0.64rem] capitalize', PILL_TONE[tone])}
>
{children}
</span>
)
+47 -44
View File
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { StatusDot, type StatusTone } from '@/components/status-dot'
import { Button } from '@/components/ui/button'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import {
@@ -12,7 +13,6 @@ import {
type MessagingPlatformInfo,
updateMessagingPlatform
} from '@/hermes'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@@ -206,10 +206,7 @@ function fieldCopy(field: MessagingEnvVarInfo) {
}
}
export function MessagingView({
setStatusbarItemGroup: _setStatusbarItemGroup,
...props
}: MessagingViewProps) {
export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: MessagingViewProps) {
const [platforms, setPlatforms] = useState<MessagingPlatformInfo[] | null>(null)
const [edits, setEdits] = useState<EditMap>({})
const [query, setQuery] = useState('')
@@ -375,42 +372,42 @@ export function MessagingView({
<PageLoader label="Loading messaging platforms..." />
) : (
<div className="grid h-full min-h-0 grid-cols-1 lg:grid-cols-[14rem_minmax(0,1fr)]">
<aside className="min-h-0 overflow-y-auto border-b border-(--ui-stroke-tertiary) p-2 lg:border-b-0 lg:border-r">
<ul className="space-y-1">
{visiblePlatforms.map(platform => (
<li key={platform.id}>
<PlatformRow
active={selected?.id === platform.id}
onSelect={() => setSelectedId(platform.id)}
platform={platform}
/>
</li>
))}
</ul>
</aside>
<aside className="min-h-0 overflow-y-auto border-b border-(--ui-stroke-tertiary) p-2 lg:border-b-0 lg:border-r">
<ul className="space-y-1">
{visiblePlatforms.map(platform => (
<li key={platform.id}>
<PlatformRow
active={selected?.id === platform.id}
onSelect={() => setSelectedId(platform.id)}
platform={platform}
/>
</li>
))}
</ul>
</aside>
<main className="min-h-0 overflow-hidden">
{selected && (
<PlatformDetail
edits={edits[selected.id] || {}}
onClear={key => void handleClear(selected, key)}
onEdit={(key, value) =>
setEdits(current => ({
...current,
[selected.id]: {
...(current[selected.id] || {}),
[key]: value
}
}))
}
onSave={() => void handleSave(selected)}
onToggle={enabled => void handleToggle(selected, enabled)}
platform={selected}
saving={saving}
/>
)}
</main>
</div>
<main className="min-h-0 overflow-hidden">
{selected && (
<PlatformDetail
edits={edits[selected.id] || {}}
onClear={key => void handleClear(selected, key)}
onEdit={(key, value) =>
setEdits(current => ({
...current,
[selected.id]: {
...(current[selected.id] || {}),
[key]: value
}
}))
}
onSave={() => void handleSave(selected)}
onToggle={enabled => void handleToggle(selected, enabled)}
platform={selected}
saving={saving}
/>
)}
</main>
</div>
)}
</PageSearchShell>
)
@@ -429,7 +426,9 @@ function PlatformRow({
<button
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors',
active ? 'bg-(--ui-bg-tertiary) text-foreground' : 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
active
? 'bg-(--ui-bg-tertiary) text-foreground'
: 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
onClick={onSelect}
type="button"
@@ -490,7 +489,9 @@ function PlatformDetail({
<PlatformAvatar platformId={platform.id} platformName={platform.name} />
<div className="min-w-0 flex-1">
<h3 className="text-[0.9375rem] font-semibold tracking-tight">{platform.name}</h3>
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">{platform.description}</p>
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{platform.description}
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<StatePill tone={stateTone(platform)}>{stateLabel(platform.state)}</StatePill>
<SetupPill active={platform.configured}>
@@ -511,7 +512,9 @@ function PlatformDetail({
<section>
<SectionTitle>Get your credentials</SectionTitle>
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">{introCopy(platform)}</p>
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{introCopy(platform)}
</p>
<div className="mt-3">
<Button asChild size="sm" variant="outline">
<a href={platform.docs_url} rel="noreferrer" target="_blank">
@@ -591,7 +594,7 @@ function PlatformDetail({
</div>
</div>
<footer className="border-t border-(--ui-stroke-tertiary) bg-(--glass-chat-surface-background) px-5 py-2.5">
<footer className="border-t border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-5 py-2.5">
<div className="mx-auto flex max-w-2xl flex-wrap items-center gap-2">
<label className="flex shrink-0 items-center gap-2 rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-2.5 py-1.5 text-[length:var(--conversation-text-font-size)]">
<Switch
@@ -43,7 +43,7 @@ export function OverlaySidebar({ children, className }: OverlaySidebarProps) {
return (
<aside
className={cn(
'flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-(--glass-sidebar-surface-background) px-2.5 py-3',
'flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-(--ui-sidebar-surface-background) px-2.5 py-3',
className
)}
>
@@ -32,7 +32,9 @@ export function OverlayView({
// Settings still closes the picker first instead of the underlying overlay.
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || event.defaultPrevented) return
if (event.key !== 'Escape' || event.defaultPrevented) {
return
}
event.preventDefault()
triggerHaptic('close')
@@ -56,7 +58,7 @@ export function OverlayView({
>
<div
className={cn(
'relative flex h-full min-h-0 flex-col overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--glass-chat-surface-background) shadow-md',
'relative flex h-full min-h-0 flex-col overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-surface-background) shadow-md',
rootClassName
)}
>
+2 -2
View File
@@ -26,7 +26,7 @@ export function PageSearchShell({
return (
<section
{...props}
className={cn('flex h-full min-w-0 flex-col overflow-hidden bg-(--glass-chat-surface-background)', className)}
className={cn('flex h-full min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background)', className)}
>
<div className="relative z-10 grid gap-2 border-b border-(--ui-stroke-tertiary) px-3 py-2.5">
<PageSearchInput
@@ -37,7 +37,7 @@ export function PageSearchShell({
/>
{filters ? <div className="flex flex-wrap items-center justify-center gap-1.5">{filters}</div> : null}
</div>
<div className="min-h-0 flex-1 overflow-hidden bg-(--glass-chat-surface-background)">{children}</div>
<div className="min-h-0 flex-1 overflow-hidden bg-(--ui-chat-surface-background)">{children}</div>
</section>
)
}
+5 -14
View File
@@ -3,6 +3,7 @@ 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,
@@ -23,7 +24,6 @@ import {
renameProfile,
updateProfileSoul
} from '@/hermes'
import { Codicon } from '@/components/ui/codicon'
import { AlertTriangle, Pencil, Save, Terminal, Trash2, Users } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@@ -254,15 +254,7 @@ export function ProfilesView({
)
}
function ProfileRow({
active,
onSelect,
profile
}: {
active: boolean
onSelect: () => void
profile: ProfileInfo
}) {
function ProfileRow({ active, onSelect, profile }: { active: boolean; onSelect: () => void; profile: ProfileInfo }) {
return (
<button
className={cn(
@@ -364,9 +356,7 @@ function ProfileDetail({
{profile.model ? (
<>
<span className="font-mono">{profile.model}</span>
{profile.provider && (
<span className="text-muted-foreground"> · {profile.provider}</span>
)}
{profile.provider && <span className="text-muted-foreground"> · {profile.provider}</span>}
</>
) : (
<span className="text-muted-foreground">Not set</span>
@@ -673,7 +663,8 @@ function RenameProfileDialog({
<DialogHeader>
<DialogTitle>Rename profile</DialogTitle>
<DialogDescription>
Renaming updates the profile directory and any wrapper scripts in <span className="font-mono">~/.local/bin</span>.
Renaming updates the profile directory and any wrapper scripts in{' '}
<span className="font-mono">~/.local/bin</span>.
</DialogDescription>
</DialogHeader>
@@ -143,8 +143,8 @@ function ProjectTreeRow({
aria-expanded={isFolder ? node.isOpen : undefined}
aria-selected={node.isSelected}
className={cn(
'group/row flex h-full cursor-pointer select-none items-center gap-1 border border-transparent px-3 text-xs font-normal leading-(--file-tree-row-height) text-(--ui-text-secondary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground',
node.isSelected && 'bg-(--ui-bg-tertiary) text-foreground',
'group/row flex h-full cursor-pointer select-none items-center gap-1 border border-transparent px-3 text-xs font-normal leading-(--file-tree-row-height) text-(--ui-text-secondary) transition-colors hover:bg-(--ui-row-hover-background) hover:text-foreground',
node.isSelected && 'bg-(--ui-row-active-background) text-foreground',
isPlaceholder && 'pointer-events-none italic text-muted-foreground/70'
)}
draggable={!isPlaceholder}
@@ -192,7 +192,11 @@ function ProjectTreeRow({
>
{isFolder && !isPlaceholder && (
<span aria-hidden className="flex w-3 items-center justify-center">
<Codicon className="text-(--ui-text-tertiary)" name={node.isOpen ? 'chevron-down' : 'chevron-right'} size="0.75rem" />
<Codicon
className="text-(--ui-text-tertiary)"
name={node.isOpen ? 'chevron-down' : 'chevron-right'}
size="0.75rem"
/>
</span>
)}
{!isFolder && <span aria-hidden className="w-3 shrink-0" />}
+23 -21
View File
@@ -85,7 +85,7 @@ export function RightSidebarPane({
return (
<aside
aria-label="Right sidebar"
className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-(--ui-stroke-secondary) bg-(--glass-sidebar-surface-background) pt-(--titlebar-height) text-(--ui-text-tertiary) shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]"
className="before:pointer-events-none relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-(--ui-stroke-secondary) bg-(--ui-sidebar-surface-background) pt-(--titlebar-height) text-(--ui-text-tertiary) shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)] before:absolute before:inset-x-0 before:top-(--titlebar-height) before:z-1 before:h-px before:bg-(--ui-stroke-tertiary)"
>
<RightSidebarChrome activeTab={activeTab} branch={currentBranch} />
@@ -116,26 +116,28 @@ export function RightSidebarPane({
function RightSidebarChrome({ activeTab, branch }: { activeTab: RightSidebarTabId; branch: string }) {
return (
<header className="shrink-0 bg-transparent text-[0.75rem]">
<div className="flex h-8 items-center gap-2 border-b border-(--ui-stroke-tertiary) px-3">
{RIGHT_SIDEBAR_TABS.map(tab => (
<button
aria-label={tab.label}
aria-pressed={tab.id === activeTab}
className={cn(
'grid size-6 shrink-0 place-items-center rounded-md text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring active:bg-sidebar-accent active:text-sidebar-accent-foreground',
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground'
)}
data-active={tab.id === activeTab}
key={tab.id}
onClick={() => setRightSidebarTab(tab.id)}
title={tab.label}
type="button"
>
<Codicon name={tab.icon} size="0.875rem" />
</button>
))}
<div className="flex items-center gap-2 border-b border-(--ui-stroke-tertiary) px-2.5 py-1">
<nav aria-label="Right sidebar panels" className="flex min-w-0 items-center gap-1">
{RIGHT_SIDEBAR_TABS.map(tab => (
<button
aria-label={tab.label}
aria-pressed={tab.id === activeTab}
className={cn(
'grid size-6 shrink-0 place-items-center rounded-lg text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring active:bg-(--ui-control-active-background) active:text-foreground',
'data-[active=true]:bg-(--ui-control-active-background) data-[active=true]:text-foreground'
)}
data-active={tab.id === activeTab}
key={tab.id}
onClick={() => setRightSidebarTab(tab.id)}
title={tab.label}
type="button"
>
<Codicon name={tab.icon} size="0.875rem" />
</button>
))}
</nav>
{branch && (
<span className="ml-auto flex min-w-0 items-center gap-1.5 text-[0.6875rem] text-(--ui-text-tertiary)">
<span className="ml-auto flex min-w-0 items-center gap-1 text-[0.6875rem] text-(--ui-text-tertiary)">
<Codicon className="shrink-0" name="git-branch" size="0.75rem" />
<span className="truncate">{branch}</span>
</span>
@@ -208,7 +210,7 @@ function FilesystemTab({
}
export function RightSidebarSectionHeader({ children }: { children: ReactNode }) {
return <div className="flex h-7 shrink-0 items-center px-3">{children}</div>
return <div className="flex h-7 shrink-0 items-center px-2">{children}</div>
}
interface FileTreeBodyProps {
@@ -27,7 +27,12 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
<div className="relative min-h-0 flex-1 px-2 pb-2">
{status === 'starting' && (
<div className="pointer-events-none absolute inset-0 z-10 grid place-items-center">
<Loader className="size-8 text-(--ui-text-tertiary)" pathSteps={180} strokeScale={0.68} type="spiral-search" />
<Loader
className="size-8 text-(--ui-text-tertiary)"
pathSteps={180}
strokeScale={0.68}
type="spiral-search"
/>
</div>
)}
{selection.trim() && (
@@ -67,7 +67,7 @@ export function AppearanceSettings() {
</p>
</div>
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--glass-chat-bubble-background) p-3 shadow-sm">
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">Color Mode</div>
@@ -105,14 +105,16 @@ export function AppearanceSettings() {
)}
</div>
<div className="mt-2 text-[length:var(--conversation-text-font-size)] font-medium">{label}</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">{description}</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{description}
</div>
</button>
)
})}
</div>
</section>
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--glass-chat-bubble-background) p-3 shadow-sm">
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">Tool Call Display</div>
@@ -160,14 +162,16 @@ export function AppearanceSettings() {
</span>
)}
</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">{option.description}</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{option.description}
</div>
</button>
)
})}
</div>
</section>
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--glass-chat-bubble-background) p-3 shadow-sm">
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">Theme</div>
@@ -197,7 +201,9 @@ export function AppearanceSettings() {
<ThemePreview name={theme.name} />
<div className="mt-3 flex items-start justify-between gap-3 px-1">
<div className="min-w-0">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">{theme.label}</div>
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{theme.label}
</div>
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{theme.description}
</div>
+1 -1
View File
@@ -1,7 +1,7 @@
import {
Brain,
Lock,
type IconComponent,
Lock,
MessageCircle,
Mic,
Monitor,
@@ -60,7 +60,9 @@ function ModeCard({
<span>{title}</span>
{active ? <Check className="ml-auto size-4 text-primary" /> : null}
</div>
<p className="mt-1.5 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">{description}</p>
<p className="mt-1.5 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{description}
</p>
</button>
)
}
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Input } from '@/components/ui/input'
import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes'
import { Codicon } from '@/components/ui/codicon'
import { Check, Eye, EyeOff, Save, Settings2, Trash2, Zap } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
+10 -3
View File
@@ -1,13 +1,13 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useState } from 'react'
import { OverlayActionButton, OverlayCard } from '@/app/overlays/overlay-chrome'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { getHermesConfigRecord, saveHermesConfig, type HermesGateway } from '@/hermes'
import { getHermesConfigRecord, type HermesGateway, saveHermesConfig } from '@/hermes'
import { Package, Wrench } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import { $activeSessionId } from '@/store/session'
import { useStore } from '@nanostores/react'
import type { HermesConfigRecord } from '@/types/hermes'
import { includesQuery } from './helpers'
@@ -64,7 +64,10 @@ export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps)
getHermesConfigRecord()
.then(next => {
if (cancelled) return
if (cancelled) {
return
}
setConfig(next)
const first = Object.keys(getServers(next)).sort()[0] ?? null
setSelected(first)
@@ -76,6 +79,7 @@ export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps)
const servers = useMemo(() => getServers(config), [config])
const names = useMemo(() => Object.keys(servers).sort(), [servers])
const filtered = useMemo(
() => names.filter(serverName => serverMatches(serverName, servers[serverName], query.trim().toLowerCase())),
[names, query, servers]
@@ -97,6 +101,7 @@ export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps)
if (!nextName) {
notify({ kind: 'error', title: 'Name required', message: 'Give this MCP server a config key.' })
return
}
@@ -112,6 +117,7 @@ export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps)
parsed = raw as Record<string, unknown>
} catch (err) {
notifyError(err, 'Invalid MCP JSON')
return
}
@@ -161,6 +167,7 @@ export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps)
const reloadMcp = async () => {
if (!gateway) {
notify({ kind: 'warning', title: 'Gateway unavailable', message: 'Reconnect the gateway before reloading MCP.' })
return
}
+8 -2
View File
@@ -53,7 +53,9 @@ export function NavLink({
<Button
className={cn(
'flex min-h-7 w-full justify-start gap-2 rounded-md px-2 text-left text-[length:var(--conversation-text-font-size)] transition',
active ? 'bg-(--ui-bg-tertiary) text-foreground' : 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
active
? 'bg-(--ui-bg-tertiary) text-foreground'
: 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
onClick={onClick}
size="sm"
@@ -90,7 +92,11 @@ export function ListRow({
>
<div className="min-w-0">
<div className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">{title}</div>
{description && <div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">{description}</div>}
{description && (
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{description}
</div>
)}
{hint && <div className="mt-1 block font-mono text-[0.68rem] text-muted-foreground/45">{hint}</div>}
{below}
</div>
+1 -1
View File
@@ -138,7 +138,7 @@ export function AppShell({
tools={titlebarTools}
/>
<main className="relative z-3 flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-(--glass-chat-surface-background) transition-none">
<main className="relative z-3 flex min-h-0 w-full flex-1 flex-col overflow-hidden transition-none">
<PaneShell className="min-h-0 flex-1">
<div
aria-hidden="true"
@@ -42,11 +42,13 @@ export function GatewayMenuPanel({
const gatewayOpen = gatewayState === 'open'
const gatewayConnecting = gatewayState === 'connecting'
const inferenceReady = gatewayOpen && inferenceStatus?.ready === true
const connectionLabel = gatewayOpen
? 'Connected'
: gatewayConnecting
? 'Connecting'
: prettyState(gatewayState || 'offline')
const inferenceLabel = gatewayOpen
? inferenceStatus?.ready
? 'Inference ready'
@@ -54,6 +56,7 @@ export function GatewayMenuPanel({
? 'Inference not ready'
: 'Checking inference'
: 'Disconnected'
const platforms = Object.entries(statusSnapshot?.gateway_platforms || {}).sort(([l], [r]) => l.localeCompare(r))
const recentLogs = logLines.slice(-5)
@@ -89,7 +89,11 @@ export function useStatusbarItems({
const failed = actions.filter(t => !t.status.running && (t.status.exit_code ?? 0) !== 0).length
const previewRunning = previewServerRestartStatus === 'running' ? 1 : 0
const previewFailed = previewServerRestartStatus === 'error' ? 1 : 0
const subagentsRunning = Object.values(subagentsBySession).reduce((sum, items) => sum + activeSubagentCount(items), 0)
const subagentsRunning = Object.values(subagentsBySession).reduce(
(sum, items) => sum + activeSubagentCount(items),
0
)
return {
bgFailed: failed + previewFailed,
@@ -275,16 +279,7 @@ export function useStatusbarItems({
},
versionItem
],
[
busy,
contextBar,
contextUsage,
currentModel,
currentProvider,
sessionStartedAt,
turnStartedAt,
versionItem
]
[busy, contextBar, contextUsage, currentModel, currentProvider, sessionStartedAt, turnStartedAt, versionItem]
)
const leftStatusbarItems = useMemo(
+6 -3
View File
@@ -2,17 +2,20 @@ import type * as React from 'react'
import { cn } from '@/lib/utils'
type SidebarPanelLabelProps = React.ComponentProps<'span'>
interface SidebarPanelLabelProps extends React.ComponentProps<'span'> {
dotClassName?: string
}
export function SidebarPanelLabel({ children, className, ...props }: SidebarPanelLabelProps) {
export function SidebarPanelLabel({ children, className, dotClassName, ...props }: SidebarPanelLabelProps) {
return (
<span
className={cn(
'flex min-w-0 items-center gap-1.5 text-[0.6875rem] font-medium text-sidebar-foreground/55',
'flex min-w-0 items-center gap-2 pl-2 text-[0.64rem] font-semibold uppercase tracking-[0.16em] text-(--theme-primary)',
className
)}
{...props}
>
<span aria-hidden="true" className={cn('dither inline-block size-2 shrink-0 rounded-[1px]', dotClassName)} />
<span className="min-w-0 truncate leading-none">{children}</span>
</span>
)
@@ -49,7 +49,7 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr
return (
<footer
className={cn(
'flex h-5 shrink-0 items-stretch justify-between gap-2 border-t border-(--ui-stroke-tertiary) bg-(--glass-sidebar-surface-background) px-1 py-0 text-(--ui-text-tertiary) [-webkit-app-region:no-drag]',
'flex h-5 shrink-0 items-stretch justify-between gap-2 border-t border-(--ui-stroke-tertiary) bg-(--ui-sidebar-surface-background) px-1 py-0 text-(--ui-text-tertiary) [-webkit-app-region:no-drag]',
className
)}
{...props}
@@ -183,10 +183,7 @@ function ProfilesMenuButton({ navigate }: { navigate: ReturnType<typeof useNavig
<DropdownMenuTrigger asChild>
<button
aria-label="Profiles"
className={cn(
titlebarButtonClass,
'grid place-items-center bg-transparent select-none [&_svg]:size-4'
)}
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent select-none [&_svg]:size-4')}
onPointerDown={event => event.stopPropagation()}
title="Profiles"
type="button"
@@ -220,7 +217,7 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
const className = cn(
titlebarButtonClass,
'grid place-items-center bg-transparent select-none [&_svg]:size-4',
tool.active && 'bg-(--ui-bg-tertiary)! text-foreground!',
tool.active && 'bg-(--ui-control-active-background)! text-foreground!',
tool.className
)
+1 -3
View File
@@ -21,8 +21,6 @@ describe('titlebarControlsPosition', () => {
})
it('uses the macOS fallback while the initial window state is unknown', () => {
expect(titlebarControlsPosition(undefined).left).toBe(
TITLEBAR_FALLBACK_WINDOW_BUTTON_X + TITLEBAR_CONTROL_OFFSET_X
)
expect(titlebarControlsPosition(undefined).left).toBe(TITLEBAR_FALLBACK_WINDOW_BUTTON_X + TITLEBAR_CONTROL_OFFSET_X)
})
})
+3 -3
View File
@@ -13,13 +13,13 @@ export const TITLEBAR_FALLBACK_WINDOW_BUTTON_X = 24
export const TITLEBAR_EDGE_INSET = 14
export const titlebarButtonClass =
'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] rounded-md text-muted-foreground/85 transition-colors hover:bg-(--ui-bg-tertiary) hover:text-foreground'
'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] rounded-md text-muted-foreground/85 transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground'
export const titlebarHeaderBaseClass =
'pointer-events-none relative z-3 flex h-(--titlebar-height) shrink-0 items-center justify-start gap-3 border-b border-(--ui-stroke-tertiary) bg-(--glass-chat-surface-background) px-[max(0.75rem,var(--titlebar-content-inset,0rem))]'
'pointer-events-none relative z-3 flex h-(--titlebar-height) shrink-0 items-center justify-start gap-3 border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-[max(0.75rem,var(--titlebar-content-inset,0rem))]'
export const titlebarHeaderShadowClass =
"after:pointer-events-none after:absolute after:left-0 after:right-0 after:top-full after:h-4 after:bg-linear-to-b after:from-(--glass-chat-surface-background) after:to-transparent after:content-['']"
"after:pointer-events-none after:absolute after:left-0 after:right-0 after:top-full after:h-4 after:bg-linear-to-b after:from-(--ui-chat-surface-background) after:to-transparent after:content-['']"
export function titlebarControlsPosition(
windowButtonPosition: HermesConnection['windowButtonPosition'] | undefined,
+102 -175
View File
@@ -3,11 +3,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { getSkills, getToolsets, toggleSkill } from '@/hermes'
import { Codicon } from '@/components/ui/codicon'
import { Brain, Wrench } from '@/lib/icons'
import type { IconComponent } from '@/lib/icons'
import { Switch } from '@/components/ui/switch'
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
import { getSkills, getToolsets, toggleSkill } from '@/hermes'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
@@ -65,10 +64,7 @@ interface SkillsViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup
}
export function SkillsView({
setStatusbarItemGroup: _setStatusbarItemGroup,
...props
}: SkillsViewProps) {
export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: SkillsViewProps) {
const [mode, setMode] = useRouteEnumParam('tab', SKILLS_MODES, 'skills')
const [query, setQuery] = useState('')
@@ -157,31 +153,27 @@ export function SkillsView({
{...props}
filters={
<>
<div className="flex flex-wrap items-center justify-center gap-1.5">
<ModeButton active={mode === 'skills'} icon={Brain} onClick={() => setMode('skills')} text="Skills" />
<ModeButton
active={mode === 'toolsets'}
icon={Wrench}
onClick={() => setMode('toolsets')}
text="Toolsets"
/>
<div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1">
<TextTab active={mode === 'skills'} onClick={() => setMode('skills')}>
Skills
</TextTab>
<TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}>
Toolsets
</TextTab>
</div>
{mode === 'skills' && categories.length > 0 && (
<div className="flex flex-wrap justify-center gap-1.5">
<CategoryButton
active={activeCategory === null}
count={totalSkills}
label="All"
onClick={() => setActiveCategory(null)}
/>
<div className="flex flex-wrap justify-center gap-x-2 gap-y-1">
<TextTab active={activeCategory === null} onClick={() => setActiveCategory(null)}>
All <TextTabMeta>{totalSkills}</TextTabMeta>
</TextTab>
{categories.map(category => (
<CategoryButton
<TextTab
active={activeCategory === category.key}
count={category.count}
key={category.key}
label={prettyName(category.key)}
onClick={() => setActiveCategory(activeCategory === category.key ? null : category.key)}
/>
>
{prettyName(category.key)} <TextTabMeta>{category.count}</TextTabMeta>
</TextTab>
))}
</div>
)}
@@ -192,7 +184,7 @@ export function SkillsView({
searchTrailingAction={
<Button
aria-label={refreshing ? 'Refreshing skills' : 'Refresh skills'}
className="text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground"
disabled={refreshing}
onClick={() => void refreshCapabilities()}
size="icon-xs"
@@ -205,167 +197,102 @@ export function SkillsView({
}
searchValue={query}
>
{!skills || !toolsets ? (
<PageLoader label="Loading capabilities..." />
) : mode === 'skills' ? (
<div className="h-full overflow-y-auto px-4 py-3">
{visibleSkills.length === 0 ? (
<EmptyState description="Try a broader search or different category." title="No skills found" />
) : (
<div className="space-y-4">
{skillGroups.map(([category, list]) => (
<div className="space-y-1.5" key={category}>
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{prettyName(category)}
</div>
<div className="divide-y divide-(--ui-stroke-quaternary)">
{list.map(skill => (
<div
className="grid gap-3 px-0 py-2.5 transition-colors hover:bg-(--ui-bg-quinary) sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
key={skill.name}
>
<div className="min-w-0">
<div className="truncate text-sm font-medium">{skill.name}</div>
<p className="mt-0.5 text-xs text-muted-foreground">
{asText(skill.description) || 'No description.'}
</p>
</div>
<Switch
checked={skill.enabled}
disabled={savingSkill === skill.name}
onCheckedChange={checked => void handleToggleSkill(skill, checked)}
/>
</div>
))}
</div>
{!skills || !toolsets ? (
<PageLoader label="Loading capabilities..." />
) : mode === 'skills' ? (
<div className="h-full overflow-y-auto px-4 py-3">
{visibleSkills.length === 0 ? (
<EmptyState description="Try a broader search or different category." title="No skills found" />
) : (
<div className="space-y-4">
{skillGroups.map(([category, list]) => (
<div className="space-y-1.5" key={category}>
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{prettyName(category)}
</div>
))}
</div>
)}
</div>
) : (
<div className="h-full overflow-y-auto px-4 py-3">
{visibleToolsets.length === 0 ? (
<EmptyState description="Try a broader search query." title="No toolsets found" />
) : (
<div className="space-y-2">
<div className="text-xs text-muted-foreground">
{enabledToolsets}/{toolsets.length} toolsets enabled
</div>
<div className="divide-y divide-(--ui-stroke-quaternary)">
{visibleToolsets.map(toolset => {
const tools = toolNames(toolset)
const label = asText(toolset.label || toolset.name)
return (
<div className="px-0 py-2.5 transition-colors hover:bg-(--ui-bg-quinary)" key={toolset.name}>
<div className="flex items-center justify-between gap-2">
<div className="truncate text-sm font-medium">{label}</div>
<div className="flex items-center gap-1.5">
<StatusPill active={toolset.enabled}>{toolset.enabled ? 'Enabled' : 'Disabled'}</StatusPill>
<StatusPill active={toolset.configured}>
{toolset.configured ? 'Configured' : 'Needs keys'}
</StatusPill>
</div>
<div className="divide-y divide-(--ui-stroke-quaternary)">
{list.map(skill => (
<div
className="grid gap-3 px-0 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
key={skill.name}
>
<div className="min-w-0">
<div className="truncate text-sm font-medium">{skill.name}</div>
<p className="mt-0.5 text-xs text-muted-foreground">
{asText(skill.description) || 'No description.'}
</p>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{asText(toolset.description) || 'No description.'}
</p>
{tools.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{tools.map(name => (
<span
className="rounded-md bg-(--ui-bg-quinary) px-1.5 py-0.5 font-mono text-[0.65rem] text-(--ui-text-tertiary)"
key={name}
>
{name}
</span>
))}
</div>
)}
<Switch
checked={skill.enabled}
disabled={savingSkill === skill.name}
onCheckedChange={checked => void handleToggleSkill(skill, checked)}
/>
</div>
)
})}
))}
</div>
</div>
))}
</div>
)}
</div>
) : (
<div className="h-full overflow-y-auto px-4 py-3">
{visibleToolsets.length === 0 ? (
<EmptyState description="Try a broader search query." title="No toolsets found" />
) : (
<div className="space-y-2">
<div className="text-xs text-muted-foreground">
{enabledToolsets}/{toolsets.length} toolsets enabled
</div>
)}
</div>
)}
<div className="divide-y divide-(--ui-stroke-quaternary)">
{visibleToolsets.map(toolset => {
const tools = toolNames(toolset)
const label = asText(toolset.label || toolset.name)
return (
<div className="px-0 py-2.5" key={toolset.name}>
<div className="flex items-center justify-between gap-2">
<div className="truncate text-sm font-medium">{label}</div>
<div className="flex items-center gap-1.5">
<StatusPill active={toolset.enabled}>{toolset.enabled ? 'Enabled' : 'Disabled'}</StatusPill>
<StatusPill active={toolset.configured}>
{toolset.configured ? 'Configured' : 'Needs keys'}
</StatusPill>
</div>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{asText(toolset.description) || 'No description.'}
</p>
{tools.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{tools.map(name => (
<span
className="rounded-md bg-(--ui-bg-quinary) px-1.5 py-0.5 font-mono text-[0.65rem] text-(--ui-text-tertiary)"
key={name}
>
{name}
</span>
))}
</div>
)}
</div>
)
})}
</div>
</div>
)}
</div>
)}
</PageSearchShell>
)
}
function ModeButton({
active,
icon: Icon,
onClick,
text
}: {
active: boolean
icon: IconComponent
onClick: () => void
text: string
}) {
return (
<Button
className={cn(
'h-8 gap-1.5 rounded-md px-2.5 text-xs',
active
? 'bg-(--ui-bg-tertiary) text-foreground'
: 'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
onClick={onClick}
size="sm"
type="button"
variant="ghost"
>
<Icon className="size-3.5" />
{text}
</Button>
)
}
function CategoryButton({
active,
count,
label,
onClick
}: {
active: boolean
count: number
label: string
onClick: () => void
}) {
return (
<button
className={cn(
'inline-flex h-7 items-center gap-1 rounded-md bg-transparent px-1.5 text-[0.68rem] transition-colors',
active
? 'bg-(--ui-bg-tertiary) text-foreground'
: 'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
onClick={onClick}
type="button"
>
<span
className={cn('underline-offset-4 decoration-current', active ? 'font-medium underline' : 'hover:underline')}
>
{label}
</span>
<span className="text-[0.62rem] text-muted-foreground/80 no-underline">{count}</span>
</button>
)
}
function StatusPill({ active, children }: { active: boolean; children: string }) {
return (
<span
className={cn(
'inline-flex items-center rounded-full px-1.5 py-0.5 text-[0.64rem]',
active
? 'bg-(--ui-bg-tertiary) text-(--ui-text-secondary)'
: 'bg-(--ui-bg-quinary) text-(--ui-text-tertiary)'
active ? 'bg-(--ui-bg-tertiary) text-(--ui-text-secondary)' : 'bg-(--ui-bg-quinary) text-(--ui-text-tertiary)'
)}
>
{children}
+1 -7
View File
@@ -52,13 +52,7 @@ export type CommandDispatchResponse =
| SkillCommandDispatchResponse
| SendCommandDispatchResponse
export type SidebarNavId =
| 'artifacts'
| 'command-center'
| 'messaging'
| 'new-session'
| 'settings'
| 'skills'
export type SidebarNavId = 'artifacts' | 'command-center' | 'messaging' | 'new-session' | 'settings' | 'skills'
export interface SidebarNavItem {
id: SidebarNavId