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

View file

@ -323,7 +323,7 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
<ChevronDown className="opacity-60" />
) : undefined
}
className="self-start min-w-0 px-0 py-0 normal-case tracking-normal text-sm font-medium hover:underline disabled:no-underline"
className="self-start min-w-0 px-0 py-0 normal-case tracking-normal text-sm font-medium underline-offset-4 decoration-current/40 hover:underline disabled:no-underline"
title={info.model ?? "switch model"}
>
<span className="truncate">{modelLabel}</span>

View file

@ -331,7 +331,7 @@ function InlineContent({
href={node.href}
target="_blank"
rel="noreferrer"
className="text-primary underline underline-offset-2 decoration-primary/30 hover:decoration-primary/60 transition-colors"
className="text-primary underline underline-offset-4 decoration-current/40 transition-colors"
>
{node.text}
</a>

View file

@ -510,7 +510,7 @@ export default function AnalyticsPage() {
<span className="font-mono">
dashboard.show_token_analytics: true
</span>{" "}
in <a href="/config" className="underline">Config</a>.
in <a href="/config" className="underline underline-offset-4 decoration-current/40">Config</a>.
</p>
</div>
</CardContent>

View file

@ -148,7 +148,7 @@ function EnvVarRow({
href={info.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary underline-offset-4 decoration-current/40 hover:underline"
>
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
</a>
@ -184,7 +184,7 @@ function EnvVarRow({
href={info.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary underline-offset-4 decoration-current/40 hover:underline"
>
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
</a>
@ -217,7 +217,7 @@ function EnvVarRow({
href={info.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary underline-offset-4 decoration-current/40 hover:underline"
>
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
</a>
@ -407,7 +407,7 @@ function ProviderGroupCard({
href={keyUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary underline-offset-4 decoration-current/40 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />

View file

@ -927,7 +927,7 @@ export default function ModelsPage() {
) and provider retries, so they diverge from your provider
bill. Enable{" "}
<span className="font-mono">dashboard.show_token_analytics</span>{" "}
in <a href="/config" className="underline">Config</a> to
in <a href="/config" className="underline underline-offset-4 decoration-current/40">Config</a> to
show the local debug estimate anyway.
</p>
)}

View file

@ -346,7 +346,7 @@ export default function PluginsPage() {
{!m.tab?.hidden ? (
<Link className="ml-3 inline-flex items-center gap-1 underline" to={m.tab.path}>
<Link className="ml-3 inline-flex items-center gap-1 underline underline-offset-4 decoration-current/40" to={m.tab.path}>
<ExternalLink className="h-3 w-3 opacity-65" />

View file

@ -617,11 +617,10 @@ function findSystemPython() {
if (pyExe) {
for (const version of SUPPORTED_VERSIONS) {
try {
const out = execFileSync(
pyExe,
[`-${version}`, '-c', 'import sys; print(sys.executable)'],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
)
const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
})
const candidate = out.trim()
if (candidate && fileExists(candidate)) return candidate
} catch {

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>
)
}

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

View file

@ -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}

View file

@ -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>
)}

View file

@ -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]">

View file

@ -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>
)}

View file

@ -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'

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}

View file

@ -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'

View file

@ -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"
>

View file

@ -13,7 +13,6 @@ describe('PreviewPane console state', () => {
const rendered = render(
<PreviewPane
onClose={vi.fn()}
setTitlebarToolGroup={setTitlebarToolGroup}
target={{
kind: 'url',

View file

@ -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"

View file

@ -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}

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>

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>
)

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

View file

@ -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
)}
>

View file

@ -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
)}
>

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>
)
}

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>

View file

@ -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" />}

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 {

View file

@ -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() && (

View file

@ -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>

View file

@ -1,7 +1,7 @@
import {
Brain,
Lock,
type IconComponent,
Lock,
MessageCircle,
Mic,
Monitor,

View file

@ -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>
)
}

View file

@ -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'

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
}

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>

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"

View file

@ -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)

View file

@ -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(

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>
)

View file

@ -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}

View file

@ -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
)

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)
})
})

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,

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}

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

View file

@ -1,8 +1,5 @@
import { useGpuTier } from '@nous-research/ui/hooks/use-gpu-tier'
import { Leva, useControls } from 'leva'
import { type CSSProperties, useEffect, useMemo, useState } from 'react'
import { ThemeControls } from './ThemeControls'
import { type CSSProperties, useEffect, useState } from 'react'
const BLEND_MODES = [
'normal',
@ -25,43 +22,7 @@ const BLEND_MODES = [
type BlendMode = (typeof BLEND_MODES)[number]
function binaryNoiseDataUrl(tile: number, density: number, size: number, color: string): string {
if (typeof document === 'undefined') {
return ''
}
// Cap at 1.5x to match the design-language overlay perf work (PR #14):
// with `image-rendering: pixelated` there's no visible win above 1.5x, and
// a full retina (2x) PNG is ~78% larger to keep resident in compositor memory.
const dpr = Math.min(window.devicePixelRatio || 1, 1.5)
const physTile = Math.round(tile * dpr)
const block = Math.max(1, Math.round(size * dpr))
const canvas = document.createElement('canvas')
canvas.width = physTile
canvas.height = physTile
const ctx = canvas.getContext('2d')
if (!ctx) {
return ''
}
ctx.fillStyle = color
for (let y = 0; y < physTile; y += block) {
for (let x = 0; x < physTile; x += block) {
if (Math.random() < density) {
ctx.fillRect(x, y, block, block)
}
}
}
return `url("${canvas.toDataURL('image/png')}")`
}
export function Backdrop() {
const gpuTier = useGpuTier()
const [controlsOpen, setControlsOpen] = useState(false)
useEffect(() => {
@ -94,9 +55,7 @@ export function Backdrop() {
const shape = useControls(
'UI / Shape',
{
radiusScalar: { value: 0.2, min: 0, max: 2, step: 0.1, label: 'radius scalar' }
},
{ radiusScalar: { value: 0.2, min: 0, max: 2, step: 0.1, label: 'radius scalar' } },
{ collapsed: true }
)
@ -108,7 +67,7 @@ export function Backdrop() {
'Backdrop / Statue',
{
enabled: { value: true, label: 'on' },
opacity: { value: 0.04, min: 0, max: 1, step: 0.005 },
opacity: { value: 0.025, min: 0, max: 1, step: 0.005 },
blendMode: { value: 'difference' as BlendMode, options: BLEND_MODES, label: 'blend' },
invert: { value: true, label: 'invert color' },
saturate: { value: 1, min: 0, max: 3, step: 0.05, label: 'saturate' },
@ -123,55 +82,14 @@ export function Backdrop() {
{ collapsed: true }
)
const vignette = useControls(
'Backdrop / Vignette',
{
enabled: { value: true, label: 'on' },
opacity: { value: 0.22, min: 0, max: 1, step: 0.01 },
blendMode: { value: 'lighten' as BlendMode, options: BLEND_MODES, label: 'blend' },
useTheme: { value: true, label: 'use --warm-glow' },
color: { value: '#ffbd38', label: 'color (override)' },
origin: {
value: '0% 0%',
options: ['0% 0%', '100% 0%', '50% 0%', '0% 100%', '100% 100%', '50% 50%'],
label: 'corner'
},
transparentStop: { value: 60, min: 0, max: 100, step: 1, label: 'fade start %' }
},
{ collapsed: true }
)
const noise = useControls(
'Backdrop / Noise',
{
enabled: { value: false, label: 'on' },
opacity: { value: 0.21, min: 0, max: 1.5, step: 0.01, label: 'opacity (× mul)' },
blendMode: { value: 'color-dodge' as BlendMode, options: BLEND_MODES, label: 'blend' },
color: { value: '#eaeaea', label: 'dot color' },
density: { value: 0.11, min: 0, max: 1, step: 0.005, label: 'density' },
size: { value: 1, min: 1, max: 10, step: 1, label: 'block px' },
tile: { value: 256, min: 64, max: 1024, step: 32, label: 'tile px' },
reroll: { value: 0, min: 0, max: 100, step: 1, label: 'reroll' }
},
{ collapsed: true }
)
const noiseUrl = useMemo(
() => binaryNoiseDataUrl(noise.tile, noise.density, noise.size, noise.color),
// eslint-disable-next-line react-hooks/exhaustive-deps
[noise.tile, noise.density, noise.size, noise.color, noise.reroll]
)
return (
<>
<Leva collapsed hidden={!import.meta.env.DEV || !controlsOpen} titleBar={{ title: 'backdrop', drag: true }} />
{import.meta.env.DEV && <ThemeControls />}
{statue.enabled && gpuTier > 0 && (
{statue.enabled && (
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-2"
className="pointer-events-none absolute inset-0 z-2"
style={{
mixBlendMode: statue.blendMode as CSSProperties['mixBlendMode'],
opacity: statue.opacity
@ -190,33 +108,6 @@ export function Backdrop() {
/>
</div>
)}
{vignette.enabled && (
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-99"
style={{
background: `radial-gradient(ellipse at ${vignette.origin}, transparent ${vignette.transparentStop}%, ${vignette.useTheme ? 'var(--warm-glow)' : vignette.color} 100%)`,
mixBlendMode: vignette.blendMode as CSSProperties['mixBlendMode'],
opacity: vignette.opacity
}}
/>
)}
{noise.enabled && gpuTier > 0 && noiseUrl && (
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-101"
style={{
backgroundImage: noiseUrl,
backgroundSize: `${noise.tile}px ${noise.tile}px`,
backgroundRepeat: 'repeat',
imageRendering: 'pixelated',
mixBlendMode: noise.blendMode as CSSProperties['mixBlendMode'],
opacity: `calc(${noise.opacity} * var(--noise-opacity-mul, 1))`
}}
/>
)}
</>
)
}

View file

@ -1,152 +0,0 @@
/**
* Leva-driven palette fine-tuning, dev-mode only.
*
* Two folders (`Theme / Light` and `Theme / Dark`) expose the seed colors
* and mix percentages that drive the glass token derivation. Edits are live
* only; use them to tune values before copying them back into presets/CSS.
*/
import { button, useControls } from 'leva'
import { useEffect, useMemo } from 'react'
import { getBaseColors, useTheme } from '@/themes/context'
interface ThemeTuningValues {
accentFill: number
accentSoft: string
backgroundSeed: string
bubbleMix: number
bubbleSeed: string
cardMix: number
cardSeed: string
chromeMix: number
elevatedMix: number
elevatedSeed: string
foreground: string
midground: string
primary: string
primaryFill: number
primaryStroke: number
quaternaryFill: number
quaternaryStroke: number
quinaryFill: number
secondary: string
secondaryFill: number
secondaryStroke: number
sidebarMix: number
sidebarSeed: string
tertiaryFill: number
tertiaryStroke: number
warm: string
}
const HEX_RE = /^#[0-9a-f]{6}$/i
const swatch = (value: string | undefined) =>
typeof value === 'string' && HEX_RE.test(value.trim()) ? value : '#444444'
const pct = (value: number) => `${value}%`
const defaultsFor = (mode: 'light' | 'dark') => ({
bubbleMix: mode === 'dark' ? 48 : 30,
cardMix: mode === 'dark' ? 38 : 22,
chromeMix: mode === 'dark' ? 36 : 44,
elevatedMix: mode === 'dark' ? 46 : 28,
primaryFill: 16,
primaryStroke: 24,
quaternaryFill: 5,
quaternaryStroke: 6,
quinaryFill: 3,
secondaryFill: 11,
secondaryStroke: 16,
sidebarMix: mode === 'dark' ? 42 : 36,
tertiaryFill: 8,
tertiaryStroke: 10
})
const setCss = (name: string, value: string) => document.documentElement.style.setProperty(name, value)
function applyTuning(values: ThemeTuningValues) {
setCss('--theme-foreground', values.foreground)
setCss('--theme-primary', values.primary)
setCss('--theme-secondary', values.secondary)
setCss('--theme-accent-soft', values.accentSoft)
setCss('--theme-midground', values.midground)
setCss('--theme-warm', values.warm)
setCss('--theme-background-seed', values.backgroundSeed)
setCss('--theme-sidebar-seed', values.sidebarSeed)
setCss('--theme-card-seed', values.cardSeed)
setCss('--theme-elevated-seed', values.elevatedSeed)
setCss('--theme-bubble-seed', values.bubbleSeed)
setCss('--theme-mix-chrome', pct(values.chromeMix))
setCss('--theme-mix-sidebar', pct(values.sidebarMix))
setCss('--theme-mix-card', pct(values.cardMix))
setCss('--theme-mix-elevated', pct(values.elevatedMix))
setCss('--theme-mix-bubble', pct(values.bubbleMix))
setCss('--theme-fill-primary-accent-mix', pct(values.primaryFill))
setCss('--theme-fill-secondary-accent-mix', pct(values.secondaryFill))
setCss('--theme-fill-tertiary-accent-mix', pct(values.tertiaryFill))
setCss('--theme-fill-quaternary-accent-mix', pct(values.quaternaryFill))
setCss('--theme-fill-quinary-accent-mix', pct(values.quinaryFill))
setCss('--theme-stroke-primary-accent-mix', pct(values.primaryStroke))
setCss('--theme-stroke-secondary-accent-mix', pct(values.secondaryStroke))
setCss('--theme-stroke-tertiary-accent-mix', pct(values.tertiaryStroke))
setCss('--theme-stroke-quaternary-accent-mix', pct(values.quaternaryStroke))
}
function buildSchema(skinName: string, mode: 'light' | 'dark') {
const base = getBaseColors(skinName, mode)
const mix = defaultsFor(mode)
const schema = {
foreground: { value: swatch(base.foreground), label: 'text base' },
primary: { value: swatch(base.primary), label: 'primary' },
secondary: { value: swatch(base.secondary), label: 'secondary' },
accentSoft: { value: swatch(base.accent), label: 'accent soft' },
midground: { value: swatch(base.midground ?? base.ring), label: 'midground' },
warm: { value: swatch(base.primary), label: 'warm glow' },
backgroundSeed: { value: swatch(base.background), label: 'chrome seed' },
sidebarSeed: { value: swatch(base.sidebarBackground ?? base.background), label: 'sidebar seed' },
cardSeed: { value: swatch(base.card), label: 'card seed' },
elevatedSeed: { value: swatch(base.popover), label: 'elevated seed' },
bubbleSeed: { value: swatch(base.userBubble ?? base.popover), label: 'bubble seed' },
chromeMix: { value: mix.chromeMix, min: 0, max: 100, step: 1, label: 'chrome mix %' },
sidebarMix: { value: mix.sidebarMix, min: 0, max: 100, step: 1, label: 'sidebar mix %' },
cardMix: { value: mix.cardMix, min: 0, max: 100, step: 1, label: 'card mix %' },
elevatedMix: { value: mix.elevatedMix, min: 0, max: 100, step: 1, label: 'elevated mix %' },
bubbleMix: { value: mix.bubbleMix, min: 0, max: 100, step: 1, label: 'bubble mix %' },
primaryFill: { value: mix.primaryFill, min: 0, max: 40, step: 1, label: 'fill primary %' },
secondaryFill: { value: mix.secondaryFill, min: 0, max: 40, step: 1, label: 'fill secondary %' },
tertiaryFill: { value: mix.tertiaryFill, min: 0, max: 40, step: 1, label: 'fill tertiary %' },
quaternaryFill: { value: mix.quaternaryFill, min: 0, max: 40, step: 1, label: 'fill quaternary %' },
quinaryFill: { value: mix.quinaryFill, min: 0, max: 40, step: 1, label: 'fill quinary %' },
primaryStroke: { value: mix.primaryStroke, min: 0, max: 50, step: 1, label: 'stroke primary %' },
secondaryStroke: { value: mix.secondaryStroke, min: 0, max: 50, step: 1, label: 'stroke secondary %' },
tertiaryStroke: { value: mix.tertiaryStroke, min: 0, max: 50, step: 1, label: 'stroke tertiary %' },
quaternaryStroke: { value: mix.quaternaryStroke, min: 0, max: 50, step: 1, label: 'stroke quaternary %' }
}
return {
...schema,
'apply defaults': button(() => applyTuning(valuesFromSchema(schema)))
} as Parameters<typeof useControls>[1]
}
function valuesFromSchema(schema: Record<string, { value: number | string }>): ThemeTuningValues {
return Object.fromEntries(Object.entries(schema).map(([key, field]) => [key, field.value])) as unknown as ThemeTuningValues
}
/** Renders nothing — Leva's UI is a portal driven by `useControls`. */
export function ThemeControls() {
const { resolvedMode, themeName } = useTheme()
const light = useMemo(() => buildSchema(themeName, 'light'), [themeName])
const dark = useMemo(() => buildSchema(themeName, 'dark'), [themeName])
const lightValues = useControls('Theme / Light', light, { collapsed: resolvedMode !== 'light' }, [themeName])
const darkValues = useControls('Theme / Dark', dark, { collapsed: resolvedMode !== 'dark' }, [themeName])
useEffect(() => {
applyTuning((resolvedMode === 'light' ? lightValues : darkValues) as ThemeTuningValues)
}, [darkValues, lightValues, resolvedMode])
return null
}

View file

@ -273,7 +273,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
<div className="flex items-center justify-between text-[0.6875rem] text-muted-foreground/85">
<span>1{choices.length} to pick</span>
<button
className="bg-transparent text-muted-foreground/85 underline-offset-2 hover:text-foreground hover:underline disabled:opacity-50"
className="bg-transparent text-muted-foreground/85 underline-offset-4 decoration-current/20 hover:text-foreground hover:underline disabled:opacity-50"
disabled={!ready || submitting}
onClick={() => void respond('')}
type="button"

View file

@ -63,7 +63,7 @@ async function mediaSrc(path: string): Promise<string> {
function OpenMediaButton({ kind, path }: { kind: 'audio' | 'video'; path: string }) {
return (
<button
className="mt-2 bg-transparent text-xs font-medium text-muted-foreground underline underline-offset-4 hover:text-foreground"
className="mt-2 bg-transparent text-xs font-medium text-muted-foreground underline underline-offset-4 decoration-current/20 hover:text-foreground"
onClick={() => void window.hermesDesktop?.openExternal(mediaExternalUrl(path))}
type="button"
>
@ -146,7 +146,7 @@ function MediaAttachment({ path }: { path: string }) {
return (
<a
className="font-semibold text-foreground underline underline-offset-4 decoration-current wrap-anywhere"
className="font-semibold text-foreground underline underline-offset-4 decoration-current/20 wrap-anywhere"
href="#"
onClick={event => {
event.preventDefault()
@ -189,7 +189,7 @@ function MarkdownLink({ children, className, href, ...props }: ComponentProps<'a
return (
<a
className={cn(
'font-semibold text-foreground underline underline-offset-4 decoration-current wrap-anywhere',
'font-semibold text-foreground underline underline-offset-4 decoration-current/20 wrap-anywhere',
className
)}
href={href}
@ -266,8 +266,12 @@ const MarkdownTextImpl = () => {
{...props}
/>
),
ul: ({ className, ...props }: ComponentProps<'ul'>) => <ul className={cn('my-1 gap-0', className)} {...props} />,
ol: ({ className, ...props }: ComponentProps<'ol'>) => <ol className={cn('my-1 gap-0', className)} {...props} />,
ul: ({ className, ...props }: ComponentProps<'ul'>) => (
<ul className={cn('my-1 gap-0', className)} {...props} />
),
ol: ({ className, ...props }: ComponentProps<'ol'>) => (
<ol className={cn('my-1 gap-0', className)} {...props} />
),
li: ({ className, ...props }: ComponentProps<'li'>) => (
<li className={cn('leading-(--dt-line-height)', className)} {...props} />
),

View file

@ -16,11 +16,13 @@ export function todosFromMessageContent(content: unknown): TodoItem[] {
if (!part || typeof part !== 'object') {
continue
}
const row = part as Record<string, unknown>
if (row.type !== 'tool-call' || row.toolName !== 'todo') {
continue
}
const parsed = parseTodos(row.result) ?? parseTodos(row.args)
if (parsed !== null) {
@ -70,6 +72,7 @@ export const HoistedTodoPanel: FC<{ todos: TodoItem[] }> = ({ todos }) => {
if (!todos.length) {
return null
}
const label = headerLabel(todos)
return (

View file

@ -834,9 +834,17 @@ export function inlineDiffFromResult(result: unknown): string {
// Falls back to a string only when there's something concrete to render —
// counts of opaque items/fields are noise, not signal.
function minimalValueSummary(value: unknown): string {
if (value == null) return ''
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
if (value == null) {
return ''
}
if (typeof value === 'string') {
return value
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}
return ''
}
@ -1193,6 +1201,7 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
const searchHits =
part.toolName === 'web_search' && status !== 'error' ? extractSearchResults(part.result) : undefined
const resultCount = status === 'error' ? null : toolResultCount(part, argsRecord, resultRecord)
return {

View file

@ -60,8 +60,7 @@ const ToolEmbedContext = createContext(false)
const TOOL_HEADER_TITLE_CLASS =
'text-[length:var(--conversation-tool-font-size)] font-medium leading-(--conversation-line-height) text-(--ui-text-secondary)'
const TOOL_HEADER_DURATION_CLASS =
'shrink-0 text-[0.625rem] tabular-nums text-(--ui-text-tertiary)'
const TOOL_HEADER_DURATION_CLASS = 'shrink-0 text-[0.625rem] tabular-nums text-(--ui-text-tertiary)'
const TOOL_HEADER_SUBTITLE_CLASS =
'text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)'
@ -71,8 +70,7 @@ const TOOL_HEADER_GLYPH_WRAP_CLASS = 'grid size-3.5 shrink-0 place-items-center
// Glass-style section label that sits above any pre/JSON/output block.
// Lowercase tracking + tiny size so it reads as a quiet field label rather
// than a chrome heading. Used for "COMMAND OUTPUT", "INPUT", "OUTPUT", etc.
const TOOL_SECTION_LABEL_CLASS =
'mb-1 text-[0.65rem] font-medium uppercase tracking-[0.08em] text-(--ui-text-tertiary)'
const TOOL_SECTION_LABEL_CLASS = 'mb-1 text-[0.65rem] font-medium uppercase tracking-[0.08em] text-(--ui-text-tertiary)'
// Inset scroll surface for any detail body. The expanded tool row owns the
// border; the payload itself is just clipped raw text.
@ -138,7 +136,9 @@ function ToolGlyph({ icon, status }: { icon?: string; status?: ToolStatus }) {
// Which status (if any) should pre-empt the tool's icon in the leading
// slot. Success is silent — the row reads as "done" without a checkmark.
function leadingStatus(isPending: boolean, status: ToolStatus): ToolStatus | undefined {
if (isPending) return 'running'
if (isPending) {
return 'running'
}
return status === 'success' ? undefined : status
}
@ -162,9 +162,7 @@ function SearchResultsList({ hits }: { hits: SearchResultRow[] }) {
) : (
<span className={TOOL_HEADER_TITLE_CLASS}>{trimmedTitle}</span>
)}
{hit.snippet && (
<p className={cn(TOOL_HEADER_SUBTITLE_CLASS, 'm-0 line-clamp-3')}>{hit.snippet}</p>
)}
{hit.snippet && <p className={cn(TOOL_HEADER_SUBTITLE_CLASS, 'm-0 line-clamp-3')}>{hit.snippet}</p>}
</li>
)
})}
@ -303,9 +301,7 @@ function ToolEntry({ part }: ToolEntryProps) {
>
{view.title}
</FadeText>
{!isPending && view.countLabel && (
<span className={TOOL_HEADER_DURATION_CLASS}>{view.countLabel}</span>
)}
{!isPending && view.countLabel && <span className={TOOL_HEADER_DURATION_CLASS}>{view.countLabel}</span>}
{!isPending && view.durationLabel && (
<span className={TOOL_HEADER_DURATION_CLASS}>{view.durationLabel}</span>
)}
@ -324,9 +320,7 @@ function ToolEntry({ part }: ToolEntryProps) {
)}
{hasSearchHits && view.searchHits && (
<div className="max-w-full text-xs leading-relaxed text-(--ui-text-secondary)">
{searchResultsLabel && (
<p className={TOOL_SECTION_LABEL_CLASS}>{searchResultsLabel}</p>
)}
{searchResultsLabel && <p className={TOOL_SECTION_LABEL_CLASS}>{searchResultsLabel}</p>}
<SearchResultsList hits={view.searchHits} />
</div>
)}
@ -354,22 +348,15 @@ function ToolEntry({ part }: ToolEntryProps) {
<div className="max-w-full text-xs leading-relaxed text-(--ui-text-secondary)">
{view.detailLabel && <p className={TOOL_SECTION_LABEL_CLASS}>{view.detailLabel}</p>}
{renderDetailAsCode ? (
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
{view.detail}
</pre>
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>{view.detail}</pre>
) : (
<CompactMarkdown
className={cn(TOOL_SECTION_SURFACE_CLASS, 'wrap-anywhere')}
text={view.detail}
/>
<CompactMarkdown className={cn(TOOL_SECTION_SURFACE_CLASS, 'wrap-anywhere')} text={view.detail} />
)}
</div>
))}
{showRawSearchDrilldown && (
<details className="max-w-full">
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'cursor-pointer mb-0')}>
Raw response
</summary>
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'cursor-pointer mb-0')}>Raw response</summary>
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'mt-1 whitespace-pre-wrap wrap-anywhere')}>
{view.rawResult}
</pre>
@ -424,6 +411,7 @@ export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex:
if (!p || typeof p !== 'object') {
return false
}
const row = p as { toolName?: unknown; type?: unknown }
return row.type === 'tool-call' && typeof row.toolName === 'string' && !SPECIAL_TOOL_NAMES.has(row.toolName)
@ -484,9 +472,7 @@ export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex:
>
{groupTitle(visibleParts)}
</FadeText>
{totalDurationLabel && (
<span className={TOOL_HEADER_DURATION_CLASS}>{totalDurationLabel}</span>
)}
{totalDurationLabel && <span className={TOOL_HEADER_DURATION_CLASS}>{totalDurationLabel}</span>}
</span>
{statusSummary && (
<FadeText
@ -529,4 +515,3 @@ export const ToolFallback = ({ toolCallId, toolName, args, isError, result }: To
return <ToolEntry part={part} />
}

View file

@ -9,11 +9,13 @@ function startedAt(key?: string): number {
if (!key) {
return Date.now()
}
const existing = startedAtByKey.get(key)
if (existing !== undefined) {
return existing
}
const now = Date.now()
startedAtByKey.set(key, now)

View file

@ -58,11 +58,7 @@ function CodeCardIcon({ className, ...props }: CodiconProps) {
function CodeCardSubtitle({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
className={cn('font-normal text-muted-foreground', className)}
data-slot="code-card-subtitle"
{...props}
/>
<span className={cn('font-normal text-muted-foreground', className)} data-slot="code-card-subtitle" {...props} />
)
}

View file

@ -41,14 +41,18 @@ function tagged<T extends keyof typeof TAG_CLASSES>(Tag: T) {
function MarkdownAnchor({ children, className, href, ...rest }: ComponentProps<'a'>) {
if (!href || !/^https?:\/\//i.test(href)) {
return (
<a className={cn('font-medium underline underline-offset-4 decoration-current', className)} href={href} {...rest}>
<a
className={cn('font-medium underline underline-offset-4 decoration-current/20', className)}
href={href}
{...rest}
>
{children}
</a>
)
}
return (
<ExternalLink className={cn('decoration-current', className)} href={href} showExternalIcon={false}>
<ExternalLink className={cn('decoration-current/20', className)} href={href} showExternalIcon={false}>
{children}
<ExternalLinkIcon />
</ExternalLink>

View file

@ -14,7 +14,10 @@ interface DiffLineKind {
}
const DIFF_LINE_KINDS: DiffLineKind[] = [
{ className: 'text-emerald-700 dark:text-emerald-300', match: line => line.startsWith('+') && !line.startsWith('+++') },
{
className: 'text-emerald-700 dark:text-emerald-300',
match: line => line.startsWith('+') && !line.startsWith('+++')
},
{ className: 'text-rose-700 dark:text-rose-300', match: line => line.startsWith('-') && !line.startsWith('---') },
{ className: 'text-sky-700 dark:text-sky-300', match: line => line.startsWith('@@') },
{

View file

@ -57,7 +57,9 @@ export function DisclosureRow({
</span>
)}
</button>
{trailing && <span className="absolute right-1 top-0 flex h-(--conversation-line-height) items-center">{trailing}</span>}
{trailing && (
<span className="absolute right-1 top-0 flex h-(--conversation-line-height) items-center">{trailing}</span>
)}
</div>
)
}

View file

@ -144,8 +144,8 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
const showPicker = flow.status === 'idle' || flow.status === 'success'
return (
<div className="fixed inset-0 z-1300 flex items-center justify-center bg-(--glass-chat-surface-background) p-6">
<div className="w-full max-w-[45rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--glass-chat-bubble-background) shadow-sm">
<div className="fixed inset-0 z-1300 flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
<div className="w-full max-w-[45rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
<Header />
<div className="grid gap-5 p-6">
{reason ? <ReasonNotice reason={reason} /> : null}
@ -209,7 +209,7 @@ function Preparing({ boot }: { boot: DesktopBootState }) {
function Header() {
return (
<div className="border-b border-(--ui-stroke-tertiary) bg-(--glass-chat-bubble-background) px-6 py-5">
<div className="border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) px-6 py-5">
<div className="flex items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)">
<Sparkles className="size-5" />
@ -252,7 +252,7 @@ function FooterLink({ children, onClick }: { children: React.ReactNode; onClick:
return (
<div className="pt-2 text-center">
<button
className="text-sm font-semibold text-foreground underline-offset-4 hover:underline"
className="text-sm font-semibold text-foreground underline-offset-4 decoration-current/20 hover:underline"
onClick={onClick}
type="button"
>

View file

@ -5,7 +5,7 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import { triggerHaptic } from '@/lib/haptics'
import { AlertCircle, AlertTriangle, CheckCircle2, Info, type IconComponent } from '@/lib/icons'
import { AlertCircle, AlertTriangle, CheckCircle2, type IconComponent, Info } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$notifications,

View file

@ -295,7 +295,7 @@ export function Pane({
role="separator"
tabIndex={0}
>
<span className="absolute inset-y-0 left-1/2 w-(--vscode-sash-hover-size,0.25rem) -translate-x-1/2 bg-(--glass-sash-hover-border) opacity-0 transition-opacity duration-100 group-hover:opacity-100 group-focus-visible:opacity-100" />
<span className="absolute inset-y-0 left-1/2 w-(--vscode-sash-hover-size,0.25rem) -translate-x-1/2 bg-(--ui-sash-hover-border) opacity-0 transition-opacity duration-100 group-hover:opacity-100 group-focus-visible:opacity-100" />
</div>
)}
{children}

View file

@ -16,7 +16,7 @@ const buttonVariants = cva(
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline'
link: 'text-primary underline-offset-4 decoration-current/20 hover:underline'
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',

View file

@ -46,7 +46,7 @@ function DialogContent({
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-3 rounded-xl border border-(--ui-stroke-secondary) bg-(--glass-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-md duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-3 rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-md duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
data-slot="dialog-content"
@ -100,7 +100,10 @@ function DialogTitle({ className, ...props }: React.ComponentProps<typeof Dialog
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
className={cn('text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)', className)}
className={cn(
'text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)',
className
)}
data-slot="dialog-description"
{...props}
/>

View file

@ -52,7 +52,7 @@ function DropdownMenuItem({
return (
<DropdownMenuPrimitive.Item
className={cn(
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-bg-tertiary) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-(--ui-text-tertiary) data-[variant=destructive]:*:[svg]:text-destructive!",
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-(--ui-text-tertiary) data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
data-inset={inset}
@ -73,7 +73,7 @@ function DropdownMenuCheckboxItem({
<DropdownMenuPrimitive.CheckboxItem
checked={checked}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-md py-1 pr-2 pl-7 text-xs outline-hidden select-none focus:bg-(--ui-bg-tertiary) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
"relative flex cursor-default items-center gap-2 rounded-md py-1 pr-2 pl-7 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className
)}
data-slot="dropdown-menu-checkbox-item"
@ -101,7 +101,7 @@ function DropdownMenuRadioItem({
return (
<DropdownMenuPrimitive.RadioItem
className={cn(
"relative flex cursor-default items-center gap-2 rounded-md py-1 pr-2 pl-7 text-xs outline-hidden select-none focus:bg-(--ui-bg-tertiary) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
"relative flex cursor-default items-center gap-2 rounded-md py-1 pr-2 pl-7 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className
)}
data-slot="dropdown-menu-radio-item"
@ -169,7 +169,7 @@ function DropdownMenuSubTrigger({
return (
<DropdownMenuPrimitive.SubTrigger
className={cn(
"flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-bg-tertiary) focus:text-foreground data-[inset]:pl-7 data-[state=open]:bg-(--ui-bg-tertiary) data-[state=open]:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-(--ui-text-tertiary)",
"flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[inset]:pl-7 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-(--ui-text-tertiary)",
className
)}
data-inset={inset}

View file

@ -50,7 +50,7 @@ function SheetContent({
<SheetOverlay />
<SheetPrimitive.Content
className={cn(
'fixed z-50 flex flex-col gap-3 border-(--ui-stroke-secondary) bg-(--glass-sidebar-surface-background) text-[length:var(--conversation-text-font-size)] shadow-md transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500',
'fixed z-50 flex flex-col gap-3 border-(--ui-stroke-secondary) bg-(--ui-sidebar-surface-background) text-[length:var(--conversation-text-font-size)] shadow-md transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500',
side === 'right' &&
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
side === 'left' &&
@ -97,7 +97,10 @@ function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPr
function SheetDescription({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
className={cn('text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)', className)}
className={cn(
'text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)',
className
)}
data-slot="sheet-description"
{...props}
/>

View file

@ -0,0 +1,43 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function TextTabMeta({ className, ...props }: React.ComponentProps<'span'>) {
return <span className={cn('text-[0.72em] font-normal text-(--ui-text-tertiary)', className)} {...props} />
}
interface TextTabProps extends React.ComponentProps<'button'> {
active?: boolean
}
function TextTab({ active = false, children, className, type = 'button', ...props }: TextTabProps) {
return (
<button
className={cn(
'group/text-tab inline-flex h-7 items-center gap-1 bg-transparent px-1 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary) transition-colors hover:bg-transparent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring disabled:pointer-events-none disabled:opacity-50',
active && 'text-foreground',
className
)}
data-active={active}
type={type}
{...props}
>
{React.Children.map(children, child =>
React.isValidElement(child) && child.type === TextTabMeta ? (
child
) : (
<span
className={cn(
'underline-offset-4 decoration-current/25',
active ? 'underline' : 'group-hover/text-tab:underline'
)}
>
{child}
</span>
)
)}
</button>
)
}
export { TextTab, TextTabMeta }

View file

@ -394,9 +394,7 @@ export function getProfiles(): Promise<ProfilesResponse> {
})
}
export function createProfile(
body: ProfileCreatePayload
): Promise<{ name: string; ok: boolean; path: string }> {
export function createProfile(body: ProfileCreatePayload): Promise<{ name: string; ok: boolean; path: string }> {
return window.hermesDesktop.api<{ name: string; ok: boolean; path: string }>({
path: '/api/profiles',
method: 'POST',
@ -404,10 +402,7 @@ export function createProfile(
})
}
export function renameProfile(
name: string,
newName: string
): Promise<{ name: string; ok: boolean; path: string }> {
export function renameProfile(name: string, newName: string): Promise<{ name: string; ok: boolean; path: string }> {
return window.hermesDesktop.api<{ name: string; ok: boolean; path: string }>({
path: `/api/profiles/${encodeURIComponent(name)}`,
method: 'PATCH',

View file

@ -318,7 +318,11 @@ export function toRuntimeMessage(message: ChatMessage): ThreadMessage {
role,
content: message.parts as Extract<ThreadMessage, { role: 'assistant' }>['content'],
createdAt,
status: message.pending ? { type: 'running' } : { type: 'complete', reason: 'stop' },
status: message.error
? { type: 'incomplete', reason: 'error', error: message.error }
: message.pending
? { type: 'running' }
: { type: 'complete', reason: 'stop' },
metadata: {
unstable_state: null,
unstable_annotations: [],

View file

@ -143,6 +143,7 @@ describe('external link helpers', () => {
it('ignores error-like fetched titles and falls back to slug label', async () => {
const bridge = vi.fn().mockResolvedValue('GetYourGuide Error')
installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['hermesDesktop']['fetchLinkTitle'] })
const url =
'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'

View file

@ -216,7 +216,7 @@ export function ExternalLink({
return (
<a
className={cn('font-semibold text-foreground underline underline-offset-4 decoration-current', className)}
className={cn('font-semibold text-foreground underline underline-offset-4 decoration-current/20', className)}
href={target}
onClick={event => {
event.stopPropagation()

View file

@ -180,9 +180,7 @@ function createMemoizedRehypeKatex(options: KatexMemoOptions = {}): Pluggable {
return () =>
function transform(tree: Root, file: VFile): undefined {
visitParents(tree, 'element', (element, parents) => {
const classes = Array.isArray(element.properties?.className)
? (element.properties.className as string[])
: []
const classes = Array.isArray(element.properties?.className) ? (element.properties.className as string[]) : []
// Match the same class set rehype-katex looks for. `language-math`
// is the markdown ` ```math ` form, `math-inline` is what
@ -201,12 +199,7 @@ function createMemoizedRehypeKatex(options: KatexMemoOptions = {}): Pluggable {
// For ` ```math ` the scope walks up to the wrapping <pre> and
// we treat it as display math. Same logic rehype-katex uses.
if (
languageMath &&
parent &&
parent.type === 'element' &&
(parent as Element).tagName === 'pre'
) {
if (languageMath && parent && parent.type === 'element' && (parent as Element).tagName === 'pre') {
scope = parent as Element
parent = parents[parents.length - 2]
displayMode = true
@ -253,10 +246,7 @@ function createMemoizedRehypeKatex(options: KatexMemoOptions = {}): Pluggable {
* wrapper. Drop-in for `@streamdown/math`'s `createMathPlugin`.
*/
export function createMemoizedMathPlugin(config: MathPluginConfig = {}) {
const remarkPlugin: Pluggable = [
remarkMath,
{ singleDollarTextMath: config.singleDollarTextMath ?? false }
]
const remarkPlugin: Pluggable = [remarkMath, { singleDollarTextMath: config.singleDollarTextMath ?? false }]
const rehypePlugin = createMemoizedRehypeKatex({ errorColor: config.errorColor })

View file

@ -310,7 +310,9 @@ const LATEX_INLINE_RE = /\\\(([^\n]+?)\\\)/g
const LATEX_DISPLAY_RE = /\\\[([\s\S]+?)\\\]/g
function rewriteLatexBracketDelimiters(text: string): string {
return text.replace(LATEX_INLINE_RE, (_, body: string) => `$${body}$`).replace(LATEX_DISPLAY_RE, (_, body: string) => `$$${body}$$`)
return text
.replace(LATEX_INLINE_RE, (_, body: string) => `$${body}$`)
.replace(LATEX_DISPLAY_RE, (_, body: string) => `$$${body}$$`)
}
// Escape `$<digit>` patterns so they don't get eaten as math delimiters.
@ -340,14 +342,19 @@ export function preprocessMarkdown(text: string): string {
.split(CODE_FENCE_SPLIT_RE)
.map(part => {
// Fence blocks pass through untouched.
if (/^(?:```|~~~)/.test(part)) {return part}
if (/^(?:```|~~~)/.test(part)) {
return part
}
// Whitespace-only segments (e.g. the `\n\n` between two adjacent
// fences) must NOT go through stripPreviewTargets — its internal
// .trim() would collapse them to '' and glue the surrounding
// fences together, producing things like ``````math which the
// markdown parser then reads as a single 6-backtick block.
if (!part.trim()) {return part}
if (!part.trim()) {
return part
}
// Preserve leading/trailing whitespace around the prose body so
// that fence-prose-fence sequences keep their blank-line gaps.
// stripPreviewTargets internally calls .trim() on its result for

View file

@ -4,16 +4,16 @@ import { isProviderSetupErrorMessage } from './provider-setup-errors'
describe('isProviderSetupErrorMessage', () => {
it('matches generic missing-provider copy', () => {
expect(isProviderSetupErrorMessage('No inference provider configured. Run `hermes model` to choose one.')).toBe(true)
expect(isProviderSetupErrorMessage('No inference provider configured. Run `hermes model` to choose one.')).toBe(
true
)
expect(isProviderSetupErrorMessage('No inference provider is configured.')).toBe(true)
expect(isProviderSetupErrorMessage('set an API key (OPENROUTER_API_KEY) in ~/.hermes/.env')).toBe(true)
})
it('does not match non-provider runtime failures', () => {
expect(
isProviderSetupErrorMessage(
'Selected runtime is not available. setup.status reports configured credentials.'
)
isProviderSetupErrorMessage('Selected runtime is not available. setup.status reports configured credentials.')
).toBe(false)
})

View file

@ -16,6 +16,7 @@ function parseArray(value: unknown[]): TodoItem[] {
if (!isRecord(item) || !isStatus(item.status)) {
return []
}
const id = String(item.id ?? '').trim()
const content = String(item.content ?? '').trim()

View file

@ -2,6 +2,7 @@
// mode still gets the raw JSON section.
const WRAPPER_KEYS = ['data', 'result', 'output', 'response', 'payload'] as const
const PRIORITY_KEYS = [
'title',
'name',
@ -17,6 +18,7 @@ const PRIORITY_KEYS = [
'summary',
'description'
] as const
const ERROR_KEYS = ['error', 'errors', 'failure', 'exception'] as const
const ERROR_MSG_KEYS = ['message', 'reason', 'detail', 'stderr'] as const
const NON_ERROR_TEXT = new Set(['', '0', 'false', 'none', 'null', 'nil', 'ok', 'success', 'n/a', 'na'])
@ -66,6 +68,7 @@ function clipBlock(value: string, maxChars = 1800, maxLines = 18): string {
if (!t) {
return ''
}
const lines = t.split('\n')
let text = lines.slice(0, maxLines).join('\n')
const clipped = lines.length > maxLines || text.length > maxChars
@ -187,11 +190,13 @@ function formatFieldValue(value: unknown, depth: number): string {
if (!v.length) {
return ''
}
const scalars = v.map(summarizeScalar).filter(Boolean)
if (scalars.length === v.length && v.length <= 4) {
return clipInline(scalars.join(', '))
}
const first = summarizeListItem(v[0], depth + 1)
return first ? `${pluralize(v.length, 'item')} (${first})` : pluralize(v.length, 'item')
@ -207,16 +212,21 @@ function formatFieldValue(value: unknown, depth: number): string {
// "Returned N items" / "0 items" / "Returned an empty object" are all
// noise — better to render nothing and let the title carry the signal.
function formatArraySummary(value: unknown[], depth: number): string {
if (!value.length) return ''
if (!value.length) {
return ''
}
const max = 6
const lines = value
.slice(0, max)
.map(item => summarizeListItem(item, depth + 1))
.filter(Boolean)
.map(l => `- ${l}`)
if (!lines.length) return ''
if (!lines.length) {
return ''
}
if (value.length > max) {
const remaining = value.length - max
@ -228,7 +238,10 @@ function formatArraySummary(value: unknown[], depth: number): string {
function formatRecordSummary(record: Json, depth: number): string {
const keys = Object.keys(record)
if (!keys.length) return ''
if (!keys.length) {
return ''
}
if (depth <= 2) {
const direct = firstString(record, ['message', 'summary', 'description', 'preview', 'text', 'content'])
@ -249,6 +262,7 @@ function formatRecordSummary(record: Json, depth: number): string {
if (!v) {
continue
}
lines.push(`- ${titleCase(k)}: ${v}`)
if (lines.length >= max) {
@ -256,7 +270,9 @@ function formatRecordSummary(record: Json, depth: number): string {
}
}
if (!lines.length) return ''
if (!lines.length) {
return ''
}
if (candidates.length > lines.length) {
const remaining = candidates.length - lines.length
@ -270,6 +286,7 @@ function formatSummaryValue(value: unknown, depth: number): string {
if (depth > 4) {
return ''
}
const v = norm(value)
if (typeof v === 'string') {
@ -383,11 +400,13 @@ function findNestedError(value: unknown, depth: number, seen: Set<unknown>): str
if (depth > 5) {
return ''
}
const v = norm(value)
if (!v || typeof v !== 'object' || seen.has(v)) {
return ''
}
seen.add(v)
if (Array.isArray(v)) {
@ -408,6 +427,7 @@ function findNestedError(value: unknown, depth: number, seen: Set<unknown>): str
if (!hasMeaningfulErrorValue(record[k])) {
continue
}
const text = valueErrorText(record[k])
if (text) {

View file

@ -14,7 +14,10 @@ type QueueState = Record<string, QueuedPromptEntry[]>
const STORAGE_KEY = 'hermes.desktop.composerQueue.v1'
const load = (): QueueState => {
if (typeof window === 'undefined') return {}
if (typeof window === 'undefined') {
return {}
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
const parsed = raw ? JSON.parse(raw) : null
@ -26,10 +29,16 @@ const load = (): QueueState => {
}
const save = (state: QueueState) => {
if (typeof window === 'undefined') return
if (typeof window === 'undefined') {
return
}
try {
if (Object.keys(state).length === 0) window.localStorage.removeItem(STORAGE_KEY)
else window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
if (Object.keys(state).length === 0) {
window.localStorage.removeItem(STORAGE_KEY)
} else {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
}
} catch {
// best-effort: storage may be unavailable, queue still works in-memory
}
@ -41,8 +50,11 @@ const writeSession = (sid: string, queue: QueuedPromptEntry[]) => {
const current = $queuedPromptsBySession.get()
const next = { ...current }
if (queue.length === 0) delete next[sid]
else next[sid] = queue
if (queue.length === 0) {
delete next[sid]
} else {
next[sid] = queue
}
$queuedPromptsBySession.set(next)
save(next)
@ -72,7 +84,9 @@ export const enqueueQueuedPrompt = (
): null | QueuedPromptEntry => {
const sid = sidOf(key)
if (!sid) return null
if (!sid) {
return null
}
const entry: QueuedPromptEntry = {
id: nextId(),
@ -89,11 +103,15 @@ export const enqueueQueuedPrompt = (
export const dequeueQueuedPrompt = (key: string | null | undefined): null | QueuedPromptEntry => {
const sid = sidOf(key)
if (!sid) return null
if (!sid) {
return null
}
const [head, ...rest] = queueFor(sid)
if (!head) return null
if (!head) {
return null
}
writeSession(sid, rest)
@ -103,12 +121,16 @@ export const dequeueQueuedPrompt = (key: string | null | undefined): null | Queu
export const removeQueuedPrompt = (key: string | null | undefined, id: string): boolean => {
const sid = sidOf(key)
if (!sid) return false
if (!sid) {
return false
}
const queue = queueFor(sid)
const next = queue.filter(e => e.id !== id)
if (next.length === queue.length) return false
if (next.length === queue.length) {
return false
}
writeSession(sid, next)
@ -122,24 +144,32 @@ export const updateQueuedPrompt = (
): boolean => {
const sid = sidOf(key)
if (!sid) return false
if (!sid) {
return false
}
const queue = queueFor(sid)
let changed = false
const next = queue.map(entry => {
if (entry.id !== id) return entry
if (entry.id !== id) {
return entry
}
const attachments = update.attachments ? cloneAttachments(update.attachments) : entry.attachments
if (entry.text === update.text && !update.attachments) return entry
if (entry.text === update.text && !update.attachments) {
return entry
}
changed = true
return { ...entry, text: update.text, attachments }
})
if (!changed) return false
if (!changed) {
return false
}
writeSession(sid, next)
@ -152,7 +182,9 @@ export const updateQueuedPromptText = (key: string | null | undefined, id: strin
export const clearQueuedPrompts = (key: string | null | undefined) => {
const sid = sidOf(key)
if (!sid || !(sid in $queuedPromptsBySession.get())) return
if (!sid || !(sid in $queuedPromptsBySession.get())) {
return
}
writeSession(sid, [])
}

View file

@ -1,6 +1,13 @@
import { atom, computed, type ReadableAtom } from 'nanostores'
import { arraysEqual, insertUniqueId, persistBoolean, persistStringArray, storedBoolean, storedStringArray } from '@/lib/storage'
import {
arraysEqual,
insertUniqueId,
persistBoolean,
persistStringArray,
storedBoolean,
storedStringArray
} from '@/lib/storage'
import { $paneStates, ensurePaneRegistered, setPaneOpen, setPaneWidthOverride, togglePane } from './panes'

View file

@ -164,7 +164,9 @@ async function fetchProviderDefaultModel(
// returned (model.options orders by recency / authenticated state, so
// the just-authenticated provider is usually first anyway).
const lower = preferredSlugs.map(s => s.toLowerCase())
const matched = providers.find((p: ModelOptionProvider) => lower.includes(String(p.slug).toLowerCase())) ?? providers[0]
const matched =
providers.find((p: ModelOptionProvider) => lower.includes(String(p.slug).toLowerCase())) ?? providers[0]
const models = matched.models ?? []

View file

@ -400,6 +400,15 @@ export function closeRightRailTab(tabId: RightRailTabId) {
export const closeActiveRightRailTab = () => closeRightRailTab($rightRailActiveTabId.get())
/** Dismisses the active preview + every file tab so the rail pane unmounts. */
export function closeRightRail() {
if ($previewTarget.get()) {
dismissPreviewTarget()
}
$filePreviewTabs.set([])
}
export function clearSessionPreviewRegistry() {
$sessionPreviewRegistry.set({})
setPreviewTarget(null)

View file

@ -54,7 +54,13 @@ describe('subagent store', () => {
)
upsertSubagent(
's1',
{ status: 'running', subagent_id: 'a1', task_index: 0, tool_name: 'search_files', tool_preview: 'pattern=hermes' },
{
status: 'running',
subagent_id: 'a1',
task_index: 0,
tool_name: 'search_files',
tool_preview: 'pattern=hermes'
},
false,
'subagent.tool'
)

View file

@ -56,15 +56,24 @@ const asStatus = (v: unknown): SubagentStatus =>
const compact = (text: string, max = PREVIEW_MAX) => {
const line = text.replace(/\s+/g, ' ').trim()
if (!line) return ''
if (!line) {
return ''
}
return line.length > max ? `${line.slice(0, max - 1)}` : line
}
const toolLabel = (name: string) =>
name.split('_').filter(Boolean).map(p => p[0]!.toUpperCase() + p.slice(1)).join(' ') || name
name
.split('_')
.filter(Boolean)
.map(p => p[0]!.toUpperCase() + p.slice(1))
.join(' ') || name
const formatTool = (name: string, preview = '') => {
const snippet = compact(preview, TOOL_PREVIEW_MAX)
return snippet ? `${toolLabel(name)}("${snippet}")` : toolLabel(name)
}
@ -90,7 +99,10 @@ const idOf = (p: SubagentPayload) =>
const appendStream = (stream: SubagentStreamEntry[], entry: SubagentStreamEntry) => {
const last = stream.at(-1)
if (last?.kind === entry.kind && last.text === entry.text && last.isError === entry.isError) return stream
if (last?.kind === entry.kind && last.text === entry.text && last.isError === entry.isError) {
return stream
}
return [...stream, entry].slice(-MAX_STREAM)
}
@ -108,19 +120,29 @@ function streamFromPayload(
for (const tail of asTail(payload.output_tail)) {
const line = tail.tool ? formatTool(tail.tool, tail.preview ?? '') : compact(tail.preview ?? '')
if (line) out.push({ at, isError: tail.isError, kind: tail.tool ? 'tool' : 'progress', text: line })
if (line) {
out.push({ at, isError: tail.isError, kind: tail.tool ? 'tool' : 'progress', text: line })
}
}
if (tool) out.push({ at, isError: !!payload.error, kind: 'tool', text: formatTool(tool, preview) })
if (tool) {
out.push({ at, isError: !!payload.error, kind: 'tool', text: formatTool(tool, preview) })
}
if (eventType === 'subagent.progress' && text)
if (eventType === 'subagent.progress' && text) {
out.push({ at, isError: !!payload.error, kind: 'progress', text })
}
if (eventType === 'subagent.thinking' && text) out.push({ at, kind: 'thinking', text })
if (eventType === 'subagent.thinking' && text) {
out.push({ at, kind: 'thinking', text })
}
const summary = compact(str(payload.summary) || str(payload.text))
if (TERMINAL.has(status) && summary)
if (TERMINAL.has(status) && summary) {
out.push({ at, isError: status === 'failed', kind: 'summary', text: summary })
}
return out
}
@ -158,7 +180,10 @@ function toProgress(payload: SubagentPayload, prev: SubagentProgress | undefined
export function clearSessionSubagents(sid: string) {
const map = $subagentsBySession.get()
if (!(sid in map)) return
if (!(sid in map)) {
return
}
const { [sid]: _drop, ...rest } = map
$subagentsBySession.set(rest)
@ -167,10 +192,16 @@ export function clearSessionSubagents(sid: string) {
export function pruneDelegateFallbackSubagents(sid: string) {
const map = $subagentsBySession.get()
const list = map[sid]
if (!list?.length) return
if (!list?.length) {
return
}
const next = list.filter(item => !item.id.startsWith('delegate-tool:'))
if (next.length === list.length) return
if (next.length === list.length) {
return
}
$subagentsBySession.set({ ...map, [sid]: next })
}
@ -180,10 +211,16 @@ export function upsertSubagent(sid: string, payload: SubagentPayload, createIfMi
const list = map[sid] ?? []
const id = idOf(payload)
const idx = list.findIndex(item => item.id === id)
if (idx < 0 && !createIfMissing) return
if (idx < 0 && !createIfMissing) {
return
}
const prev = idx >= 0 ? list[idx] : undefined
if (prev && TERMINAL.has(prev.status)) return
if (prev && TERMINAL.has(prev.status)) {
return
}
const next = toProgress(payload, prev, eventType)
const nextList = idx >= 0 ? list.map(item => (item.id === id ? next : item)) : [...list, next]
@ -193,17 +230,26 @@ export function upsertSubagent(sid: string, payload: SubagentPayload, createIfMi
export function buildSubagentTree(items: readonly SubagentProgress[]): SubagentNode[] {
const nodes = new Map<string, SubagentNode>()
for (const item of items) nodes.set(item.id, { ...item, children: [] })
for (const item of items) {
nodes.set(item.id, { ...item, children: [] })
}
const roots: SubagentNode[] = []
for (const node of nodes.values()) {
const parent = node.parentId ? nodes.get(node.parentId) : null
if (parent) parent.children.push(node)
else roots.push(node)
if (parent) {
parent.children.push(node)
} else {
roots.push(node)
}
}
const sort = (a: SubagentNode, b: SubagentNode) =>
a.startedAt - b.startedAt || a.taskIndex - b.taskIndex || a.goal.localeCompare(b.goal)
const walk = (node: SubagentNode) => node.children.sort(sort).forEach(walk)
roots.sort(sort).forEach(walk)

View file

@ -103,11 +103,11 @@
--theme-neutral-chrome: #f3f3f3;
--theme-neutral-sidebar: #f3f3f3;
--theme-neutral-card: #fcfcfc;
--theme-mix-chrome: 44%;
--theme-mix-sidebar: 36%;
--theme-mix-chrome: 92%;
--theme-mix-sidebar: 100%;
--theme-mix-card: 22%;
--theme-mix-elevated: 28%;
--theme-mix-bubble: 30%;
--theme-mix-bubble: 0%;
--theme-fill-primary-accent-mix: 16%;
--theme-fill-secondary-accent-mix: 11%;
--theme-fill-tertiary-accent-mix: 8%;
@ -117,6 +117,10 @@
--theme-stroke-secondary-accent-mix: 16%;
--theme-stroke-tertiary-accent-mix: 10%;
--theme-stroke-quaternary-accent-mix: 6%;
--theme-row-hover-accent-mix: 4%;
--theme-row-active-accent-mix: 8%;
--theme-control-hover-accent-mix: 6%;
--theme-control-active-accent-mix: 8%;
--ui-base: var(--theme-foreground);
--ui-accent: var(--theme-midground);
@ -164,6 +168,26 @@
var(--ui-accent) var(--theme-fill-quinary-accent-mix),
color-mix(in srgb, var(--ui-base) 3%, transparent)
);
--ui-row-hover-background: color-mix(
in srgb,
var(--ui-accent) var(--theme-row-hover-accent-mix),
color-mix(in srgb, var(--ui-base) 3%, transparent)
);
--ui-row-active-background: color-mix(
in srgb,
var(--ui-accent) var(--theme-row-active-accent-mix),
color-mix(in srgb, var(--ui-base) 5%, transparent)
);
--ui-control-hover-background: color-mix(
in srgb,
var(--ui-accent) var(--theme-control-hover-accent-mix),
color-mix(in srgb, var(--ui-base) 4%, transparent)
);
--ui-control-active-background: color-mix(
in srgb,
var(--ui-accent) var(--theme-control-active-accent-mix),
color-mix(in srgb, var(--ui-base) 5%, transparent)
);
--ui-text-primary: color-mix(in srgb, var(--ui-base) 94%, transparent);
--ui-text-secondary: color-mix(in srgb, var(--ui-base) 74%, transparent);
--ui-text-tertiary: color-mix(in srgb, var(--ui-base) 54%, transparent);
@ -188,17 +212,18 @@
var(--ui-accent) var(--theme-stroke-quaternary-accent-mix),
color-mix(in srgb, var(--ui-base) 3%, transparent)
);
--glass-sash-hover-border: color-mix(in srgb, var(--ui-accent) 18%, var(--ui-stroke-tertiary));
--glass-sash-hover-background: color-mix(in srgb, var(--ui-accent) 6%, transparent);
--glass-surface-background: var(--ui-bg-editor);
--glass-sidebar-surface-background: var(--ui-bg-sidebar);
--glass-chat-surface-background: var(--ui-bg-chrome);
--glass-editor-surface-background: var(--ui-bg-chrome);
--glass-chat-bubble-background: color-mix(in srgb, var(--theme-bubble-seed) var(--theme-mix-bubble), var(--theme-neutral-card));
--glass-chat-bubble-opaque-background: var(--ui-bg-editor);
--glass-inline-code-background: color-mix(in srgb, #141414 5%, transparent);
--glass-inline-code-border: color-mix(in srgb, #141414 8%, transparent);
--glass-inline-code-foreground: color-mix(in srgb, #141414 88%, transparent);
--ui-sash-hover-border: color-mix(in srgb, var(--ui-accent) 18%, var(--ui-stroke-tertiary));
--ui-sash-hover-background: color-mix(in srgb, var(--ui-accent) 6%, transparent);
--ui-surface-background: var(--ui-bg-editor);
--ui-sidebar-surface-background: var(--ui-bg-sidebar);
--ui-chat-surface-background: var(--ui-bg-chrome);
--ui-editor-surface-background: var(--ui-bg-chrome);
--ui-chat-bubble-background: color-mix(in srgb, var(--theme-bubble-seed) var(--theme-mix-bubble), var(--theme-neutral-card));
--ui-chat-bubble-opaque-background: var(--ui-bg-editor);
--ui-inline-code-background: color-mix(in srgb, #141414 5%, transparent);
--ui-inline-code-border: color-mix(in srgb, #141414 8%, transparent);
--ui-inline-code-foreground: color-mix(in srgb, #141414 88%, transparent);
--ui-selection-background: color-mix(in srgb, #ffd24a 55%, transparent);
--dt-background: var(--ui-bg-chrome);
--dt-foreground: var(--ui-text-primary);
@ -217,12 +242,13 @@
--dt-border: var(--ui-stroke-secondary);
--dt-input: var(--ui-stroke-primary);
--dt-ring: var(--ui-stroke-primary);
--dt-midground: var(--theme-midground);
--dt-composer-ring: var(--ui-base);
--dt-destructive: #cf2d56;
--dt-destructive-foreground: #ffffff;
--dt-sidebar-bg: var(--ui-bg-sidebar);
--dt-sidebar-border: var(--ui-stroke-secondary);
--dt-user-bubble: var(--ui-bg-editor);
--dt-user-bubble: var(--ui-chat-bubble-background);
--dt-user-bubble-border: var(--ui-stroke-tertiary);
--dt-font-sans: 'Segoe WPC', 'Segoe UI', -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif;
@ -280,12 +306,12 @@
--sidebar-foreground: var(--dt-foreground);
--sidebar-primary: var(--dt-primary);
--sidebar-primary-foreground: var(--dt-primary-foreground);
--sidebar-accent: var(--dt-accent);
--sidebar-accent: var(--ui-control-active-background);
--sidebar-accent-foreground: var(--dt-accent-foreground);
--sidebar-border: var(--dt-sidebar-border);
--sidebar-ring: var(--dt-ring);
--sidebar-edge-border: color-mix(in srgb, var(--ui-base) 7.5%, transparent);
--chrome-action-hover: var(--ui-bg-tertiary);
--chrome-action-hover: var(--ui-control-hover-background);
--midground: var(--dt-midground);
--background: var(--dt-background);
@ -298,50 +324,28 @@
}
:root.dark {
--ui-base: #ffe6cb;
--ui-accent: #0053fd;
--ui-accent-secondary: #ffe6cb;
--ui-warm: #ffe6cb;
/* Per-mode mix knobs — overridden inline by `applyTheme()` per skin. */
--theme-mix-chrome: 74%;
--theme-mix-card: 38%;
--theme-mix-elevated: 46%;
--theme-mix-bubble: 46%;
--theme-neutral-chrome: #0d0d0e;
--theme-neutral-sidebar: #0a0a0b;
--theme-neutral-card: #161618;
/* Dark-only accent palette overrides. */
--ui-red: #e75e78;
--ui-orange: #db704b;
--ui-yellow: #c08532;
--ui-green: #55a583;
--ui-cyan: #6f9ba6;
--ui-blue: #0053fd;
--ui-purple: #9e94d5;
--ui-bg-chrome: #0d1d3a;
--ui-bg-sidebar: #0a1833;
--ui-bg-editor: #101827;
--ui-bg-elevated: #121d32;
--ui-bg-input: #131316;
--dt-background: var(--ui-bg-chrome);
--dt-foreground: var(--ui-text-primary);
--dt-card: var(--ui-bg-elevated);
--dt-card-foreground: var(--ui-text-primary);
--dt-muted: var(--ui-bg-tertiary);
--dt-muted-foreground: var(--ui-text-tertiary);
--dt-popover: color-mix(in srgb, var(--ui-bg-elevated) 96%, transparent);
--dt-popover-foreground: var(--ui-text-primary);
--dt-primary: var(--ui-accent);
--dt-primary-foreground: #0b0b0c;
--dt-secondary: var(--ui-bg-secondary);
--dt-secondary-foreground: var(--ui-text-secondary);
--dt-accent: var(--ui-bg-tertiary);
--dt-accent-foreground: var(--ui-text-primary);
--dt-border: var(--ui-stroke-secondary);
--dt-input: var(--ui-stroke-primary);
--dt-ring: var(--ui-stroke-primary);
--dt-composer-ring: var(--ui-base);
--dt-sidebar-bg: var(--ui-bg-sidebar);
--dt-sidebar-border: var(--ui-stroke-secondary);
--dt-user-bubble: var(--ui-bg-elevated);
--dt-user-bubble-border: var(--ui-stroke-tertiary);
--sidebar-edge-border: color-mix(in srgb, var(--ui-base) 12%, transparent);
--composer-ring-strength: 1.3;
--backdrop-invert-mul: 0;
--glass-inline-code-background: color-mix(in srgb, #ffffff 7%, transparent);
--glass-inline-code-border: color-mix(in srgb, #ffffff 10%, transparent);
--glass-inline-code-foreground: color-mix(in srgb, #ffffff 88%, transparent);
--ui-inline-code-background: color-mix(in srgb, #ffffff 7%, transparent);
--ui-inline-code-border: color-mix(in srgb, #ffffff 10%, transparent);
--ui-inline-code-foreground: color-mix(in srgb, #ffffff 88%, transparent);
--ui-selection-background: color-mix(in srgb, #ffd24a 38%, transparent);
}
* {
@ -361,7 +365,7 @@
body {
margin: 0;
background: var(--glass-chat-surface-background);
background: var(--ui-chat-surface-background);
color: var(--dt-foreground);
font-family: var(--dt-font-sans);
font-size: 0.8125rem;
@ -378,9 +382,22 @@
font: inherit;
}
:where(
a,
.underline,
[class~='hover:underline'],
[class~='focus:underline'],
[class~='focus-visible:underline'],
[class~='group-hover:underline'],
[class~='peer-hover:underline']
) {
text-decoration-color: color-mix(in srgb, currentColor 20%, transparent);
text-underline-offset: 0.25rem;
}
*::selection {
background: var(--dt-midground);
color: var(--dt-midground-foreground);
background: var(--ui-selection-background);
color: inherit;
}
}
@ -742,7 +759,7 @@ canvas {
background: linear-gradient(
to bottom,
transparent,
color-mix(in srgb, var(--glass-chat-surface-background) 88%, transparent)
color-mix(in srgb, var(--ui-chat-surface-background) 88%, transparent)
) !important;
}
@ -751,12 +768,6 @@ canvas {
box-shadow: var(--shadow-composer) !important;
}
[data-slot='composer-surface'] > [aria-hidden='true'] {
background: var(--glass-chat-bubble-background) !important;
backdrop-filter: blur(0.75rem) saturate(1.08);
-webkit-backdrop-filter: blur(0.75rem) saturate(1.08);
}
[data-slot='composer-fade'] {
min-height: 2.375rem;
}
@ -771,7 +782,7 @@ canvas {
}
[data-slot='composer-root']:focus-within [data-slot='composer-surface'] > [aria-hidden='true'] {
background: var(--glass-chat-bubble-background) !important;
background: var(--ui-chat-bubble-background) !important;
}
/* Tool/thinking blocks now live at message-text alignment (no leading
@ -818,9 +829,9 @@ canvas {
}
[data-slot='aui_assistant-message-content'] .aui-md :not(pre) > code {
border: 0.0625rem solid var(--glass-inline-code-border);
background: var(--glass-inline-code-background);
color: var(--glass-inline-code-foreground);
border: 0.0625rem solid var(--ui-inline-code-border);
background: var(--ui-inline-code-background);
color: var(--ui-inline-code-foreground);
}
[data-slot='aui_assistant-message-content'] .aui-md :where(.aui-shiki, .aui-shiki > pre) {
@ -843,18 +854,18 @@ canvas {
}
/* Tool / thinking blocks are scaffolding around the model's reply, so we
fade them slightly. The reading column (prose) stays at full strength;
scaffolding recedes and lifts back to full opacity on hover/focus so it
stays legible when the user actually wants to read it. */
[data-slot='aui_assistant-message-content']
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) {
keep them transparent and fade them slightly. The reading column (prose)
stays at full strength; scaffolding lifts back to full opacity on
hover/focus so it stays legible when the user actually wants to read it. */
[data-slot='tool-block'],
[data-slot='aui_thinking-disclosure'] {
background: transparent !important;
opacity: 0.67;
transition: opacity 120ms ease-out;
}
[data-slot='tool-block'] {
background: transparent !important;
[data-slot='aui_assistant-message-content']
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) {
opacity: 0.67;
transition: opacity 120ms ease-out;
}
[data-slot='aui_assistant-message-content']
@ -909,10 +920,15 @@ canvas {
min-height: 0;
min-width: 0;
flex-shrink: 0;
cursor: pointer;
color: var(--color-muted-foreground);
opacity: 0.5;
}
[data-slot='aui_msg-actions'] button:disabled {
cursor: default;
}
[data-slot='aui_msg-actions'] button:hover {
background: transparent;
color: var(--color-foreground);

View file

@ -148,6 +148,17 @@ function renderedModeFor(colors: DesktopThemeColors, mode: 'light' | 'dark'): 'l
// ─── CSS application ────────────────────────────────────────────────────────
// Per-mode mix knobs. Light/dark fallbacks live in styles.css `:root` /
// `:root.dark`; setting them inline keeps active-skin overrides surviving
// the boot-time paint.
const mixesFor = (isDark: boolean): Record<string, string> => ({
'--theme-mix-chrome': isDark ? '74%' : '92%',
'--theme-mix-sidebar': '100%',
'--theme-mix-card': isDark ? '38%' : '22%',
'--theme-mix-elevated': isDark ? '46%' : '28%',
'--theme-mix-bubble': isDark ? '46%' : '0%'
})
function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
if (typeof document === 'undefined') {
return
@ -157,95 +168,54 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
const c = theme.colors
const typo = { ...DEFAULT_TYPOGRAPHY, ...nousTheme.typography, ...theme.typography }
const rendered = renderedModeFor(c, mode)
const isDark = rendered === 'dark'
const midground = c.midground ?? c.ring
const skinName = theme.name.endsWith(`-${mode}`) ? theme.name.slice(0, -mode.length - 1) : theme.name
const neutralChrome = rendered === 'dark' ? '#0d0d0e' : '#f3f3f3'
const neutralSidebar = rendered === 'dark' ? '#0a0a0b' : '#f3f3f3'
const neutralCard = rendered === 'dark' ? '#161618' : '#fcfcfc'
const chromeMix = rendered === 'dark' ? 36 : 44
const sidebarMix = rendered === 'dark' ? 42 : 36
const cardMix = rendered === 'dark' ? 38 : 22
const elevatedMix = rendered === 'dark' ? 46 : 28
const bubbleMix = rendered === 'dark' ? 48 : 30
root.style.setProperty('color-scheme', rendered)
root.dataset.hermesTheme = skinName
root.dataset.hermesMode = rendered
root.classList.toggle('dark', rendered === 'dark')
root.classList.toggle('dark', isDark)
// Brand seeds feed every glass + shadcn token via `color-mix()` in styles.css.
const seeds: Record<string, string> = {
'--theme-foreground': c.foreground,
'--theme-primary': c.primary,
'--theme-secondary': c.secondary,
'--theme-accent-soft': c.accent,
'--theme-midground': midground,
'--theme-warm': c.primary,
'--theme-background-seed': c.background,
'--theme-sidebar-seed': c.sidebarBackground ?? c.background,
'--theme-card-seed': c.card,
'--theme-elevated-seed': c.popover,
'--theme-bubble-seed': c.userBubble ?? c.popover
}
// shadcn/Tailwind tokens that aren't derived from the seed chain.
const palette: Record<string, string> = {
'--dt-primary-foreground': c.primaryForeground,
'--dt-secondary-foreground': c.secondaryForeground,
'--dt-accent-foreground': c.accentForeground,
'--dt-border': c.border,
'--dt-input': c.input,
'--dt-ring': c.ring,
'--dt-muted': c.muted,
'--dt-midground-foreground': c.midgroundForeground ?? readableOn(midground),
'--dt-composer-ring': c.composerRing ?? midground,
'--dt-destructive': c.destructive,
'--dt-destructive-foreground': c.destructiveForeground,
'--dt-sidebar-border': c.sidebarBorder ?? c.border,
'--dt-user-bubble-border': c.userBubbleBorder ?? c.border,
'--dt-font-sans': typo.fontSans,
'--dt-font-mono': typo.fontMono,
'--noise-opacity-mul': isDark ? 'calc(0.04 / 0.21)' : 'calc(0.34 / 0.21)'
}
for (const [k, v] of Object.entries({ ...seeds, ...mixesFor(isDark), ...palette })) {
root.style.setProperty(k, v)
}
const set = (k: string, v: string) => root.style.setProperty(k, v)
set('--theme-foreground', c.foreground)
set('--theme-primary', c.primary)
set('--theme-secondary', c.secondary)
set('--theme-accent-soft', c.accent)
set('--theme-midground', midground)
set('--theme-warm', c.primary)
set('--theme-background-seed', c.background)
set('--theme-sidebar-seed', c.sidebarBackground ?? c.background)
set('--theme-card-seed', c.card)
set('--theme-elevated-seed', c.popover)
set('--theme-bubble-seed', c.userBubble ?? c.popover)
set('--theme-neutral-chrome', neutralChrome)
set('--theme-neutral-sidebar', neutralSidebar)
set('--theme-neutral-card', neutralCard)
set('--theme-mix-chrome', `${chromeMix}%`)
set('--theme-mix-sidebar', `${sidebarMix}%`)
set('--theme-mix-card', `${cardMix}%`)
set('--theme-mix-elevated', `${elevatedMix}%`)
set('--theme-mix-bubble', `${bubbleMix}%`)
set('--theme-fill-primary-accent-mix', '16%')
set('--theme-fill-secondary-accent-mix', '11%')
set('--theme-fill-tertiary-accent-mix', '8%')
set('--theme-fill-quaternary-accent-mix', '5%')
set('--theme-fill-quinary-accent-mix', '3%')
set('--theme-stroke-primary-accent-mix', '24%')
set('--theme-stroke-secondary-accent-mix', '16%')
set('--theme-stroke-tertiary-accent-mix', '10%')
set('--theme-stroke-quaternary-accent-mix', '6%')
set('--ui-base', 'var(--theme-foreground)')
set('--ui-accent', 'var(--theme-midground)')
set('--ui-accent-secondary', 'var(--theme-primary)')
set('--ui-warm', 'var(--theme-warm)')
set('--ui-bg-chrome', 'color-mix(in srgb, var(--theme-background-seed) var(--theme-mix-chrome), var(--theme-neutral-chrome))')
set('--ui-bg-sidebar', 'color-mix(in srgb, var(--theme-sidebar-seed) var(--theme-mix-sidebar), var(--theme-neutral-sidebar))')
set('--ui-bg-editor', 'color-mix(in srgb, var(--theme-card-seed) var(--theme-mix-card), var(--theme-neutral-card))')
set('--ui-bg-elevated', 'color-mix(in srgb, var(--theme-elevated-seed) var(--theme-mix-elevated), var(--theme-neutral-card))')
set('--ui-bg-input', 'var(--ui-bg-editor)')
set('--glass-surface-background', 'var(--ui-bg-editor)')
set('--glass-sidebar-surface-background', 'var(--ui-bg-sidebar)')
set('--glass-chat-surface-background', 'var(--ui-bg-chrome)')
set('--glass-editor-surface-background', 'var(--ui-bg-chrome)')
set('--glass-chat-bubble-background', 'color-mix(in srgb, var(--theme-bubble-seed) var(--theme-mix-bubble), var(--theme-neutral-card))')
set('--glass-chat-bubble-opaque-background', 'var(--ui-bg-editor)')
set('--dt-background', 'var(--ui-bg-chrome)')
set('--dt-foreground', 'var(--ui-text-primary)')
set('--dt-card', 'var(--ui-bg-editor)')
set('--dt-card-foreground', 'var(--ui-text-primary)')
set('--dt-muted', c.muted)
set('--dt-muted-foreground', 'var(--ui-text-tertiary)')
set('--dt-popover', 'var(--ui-bg-elevated)')
set('--dt-popover-foreground', 'var(--ui-text-primary)')
set('--dt-primary', 'var(--theme-primary)')
set('--dt-primary-foreground', c.primaryForeground)
set('--dt-secondary', 'var(--theme-secondary)')
set('--dt-secondary-foreground', c.secondaryForeground)
set('--dt-accent', 'var(--theme-accent-soft)')
set('--dt-accent-foreground', c.accentForeground)
set('--dt-border', c.border)
set('--dt-input', c.input)
set('--dt-ring', c.ring)
set('--dt-midground', 'var(--theme-midground)')
set('--dt-midground-foreground', c.midgroundForeground ?? readableOn(midground))
set('--dt-composer-ring', c.composerRing ?? midground)
set('--dt-destructive', c.destructive)
set('--dt-destructive-foreground', c.destructiveForeground)
set('--dt-sidebar-bg', 'var(--ui-bg-sidebar)')
set('--dt-sidebar-border', c.sidebarBorder ?? c.border)
set('--dt-user-bubble', 'var(--glass-chat-bubble-background)')
set('--dt-user-bubble-border', c.userBubbleBorder ?? c.border)
set('--dt-font-sans', typo.fontSans)
set('--dt-font-mono', typo.fontMono)
set('--noise-opacity-mul', rendered === 'dark' ? 'calc(0.04 / 0.21)' : 'calc(0.34 / 0.21)')
window.hermesDesktop?.setTitleBarTheme?.({
background: c.background,
foreground: c.foreground

View file

@ -242,7 +242,11 @@
text-decoration: none;
font-weight: 600;
}
.footer a:hover { text-decoration: underline; }
.footer a:hover {
text-decoration: underline;
text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
text-underline-offset: 4px;
}
.empty {
padding: 3rem 1rem;