Merge pull request #27227 from NousResearch/bb/gui-glass

Desktop glass UI lift
This commit is contained in:
brooklyn! 2026-05-16 22:42:24 -05:00 committed by GitHub
commit c058ac6677
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
142 changed files with 7105 additions and 2950 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

@ -31,6 +31,14 @@ const {
resolveTimeoutMs
} = require('./hardening.cjs')
let nodePty = null
try {
nodePty = require('@homebridge/node-pty-prebuilt-multiarch')
} catch {
nodePty = null
}
const USER_DATA_OVERRIDE = process.env.HERMES_DESKTOP_USER_DATA_DIR
if (USER_DATA_OVERRIDE) {
const resolvedUserData = path.resolve(USER_DATA_OVERRIDE)
@ -133,6 +141,7 @@ const APP_ICON_PATHS = [
]
let rendererTitleBarTheme = null
const terminalSessions = new Map()
function isHexColor(value) {
return typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value)
@ -608,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 {
@ -2792,7 +2800,21 @@ ipcMain.handle('hermes:fetchLinkTitle', (_event, url) => fetchLinkTitle(url))
// Always-hidden noise (covers non-git projects too — gitignore would catch
// these anyway when present, but we want the same hygiene without one).
const FS_READDIR_HIDDEN = new Set(['.git', '.hg', '.svn', 'node_modules', '__pycache__', '.next', '.venv', 'venv'])
const FS_READDIR_HIDDEN = new Set([
'.git',
'.hg',
'.svn',
'.cache',
'.next',
'.turbo',
'.venv',
'__pycache__',
'build',
'dist',
'node_modules',
'target',
'venv'
])
function findGitRoot(start) {
let dir = start
@ -2818,6 +2840,76 @@ function findGitRoot(start) {
return null
}
function terminalShellCommand() {
if (IS_WINDOWS) {
return { args: [], command: process.env.COMSPEC || 'cmd.exe' }
}
const configuredShell = process.env.SHELL || ''
const shellPath =
(path.isAbsolute(configuredShell) && fs.existsSync(configuredShell) && configuredShell) ||
['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => fs.existsSync(candidate)) ||
'/bin/sh'
const shellName = path.basename(shellPath)
const interactiveArgs = shellName.includes('zsh') || shellName.includes('bash') ? ['-il'] : ['-i']
return { args: interactiveArgs, command: shellPath, name: shellName }
}
function safeTerminalCwd(cwd) {
const candidate = path.resolve(String(cwd || app.getPath('home')))
try {
const stat = fs.statSync(candidate)
return stat.isDirectory() ? candidate : path.dirname(candidate)
} catch {
return app.getPath('home')
}
}
function terminalShellEnv() {
const env = { ...process.env }
// Electron is commonly launched through `npm run dev`; do not leak npm's
// managed prefix into a user's interactive shell (nvm/proto warn loudly).
for (const key of Object.keys(env)) {
if (key === 'npm_config_prefix' || key.startsWith('npm_config_') || key.startsWith('npm_package_')) {
delete env[key]
}
}
env.COLORTERM = env.COLORTERM || 'truecolor'
env.LC_CTYPE = env.LC_CTYPE || 'UTF-8'
env.TERM = 'xterm-256color'
env.TERM_PROGRAM = 'Hermes'
env.TERM_PROGRAM_VERSION = app.getVersion()
return env
}
function terminalChannel(id, suffix) {
return `hermes:terminal:${id}:${suffix}`
}
function disposeTerminalSession(id) {
const sessionInfo = terminalSessions.get(id)
if (!sessionInfo) {
return false
}
terminalSessions.delete(id)
try {
sessionInfo.pty.kill()
} catch {
// Process may already be gone.
}
return true
}
ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => {
const resolved = path.resolve(String(dirPath || ''))
@ -2859,6 +2951,72 @@ ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => {
}
})
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
if (!nodePty) {
throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.')
}
const id = crypto.randomUUID()
const { args, command, name } = terminalShellCommand()
const cwd = safeTerminalCwd(payload?.cwd)
const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80)
const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24)
const ptyProcess = nodePty.spawn(command, args, {
cols,
cwd,
env: terminalShellEnv(),
name: 'xterm-256color',
rows
})
terminalSessions.set(id, { pty: ptyProcess, webContentsId: event.sender.id })
const send = (suffix, payload) => {
if (event.sender.isDestroyed()) {
return
}
event.sender.send(terminalChannel(id, suffix), payload)
}
ptyProcess.onData(data => send('data', data))
ptyProcess.onExit(({ exitCode, signal }) => {
terminalSessions.delete(id)
send('exit', { code: exitCode, signal: signal || null })
})
event.sender.once('destroyed', () => disposeTerminalSession(id))
return { cwd, id, shell: name }
})
ipcMain.handle('hermes:terminal:write', (_event, id, data) => {
const sessionInfo = terminalSessions.get(String(id || ''))
if (!sessionInfo) {
return false
}
sessionInfo.pty.write(String(data || ''))
return true
})
ipcMain.handle('hermes:terminal:resize', (_event, id, size = {}) => {
const sessionInfo = terminalSessions.get(String(id || ''))
if (!sessionInfo) {
return false
}
const cols = Math.max(2, Number.parseInt(String(size?.cols || 80), 10) || 80)
const rows = Math.max(2, Number.parseInt(String(size?.rows || 24), 10) || 24)
sessionInfo.pty.resize(cols, rows)
return true
})
ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || '')))
ipcMain.handle('hermes:updates:check', async () =>
checkUpdates().catch(error => ({
supported: true,

View file

@ -33,6 +33,24 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url),
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
terminal: {
dispose: id => ipcRenderer.invoke('hermes:terminal:dispose', id),
resize: (id, size) => ipcRenderer.invoke('hermes:terminal:resize', id, size),
start: options => ipcRenderer.invoke('hermes:terminal:start', options),
write: (id, data) => ipcRenderer.invoke('hermes:terminal:write', id, data),
onData: (id, callback) => {
const channel = `hermes:terminal:${id}:data`
const listener = (_event, payload) => callback(payload)
ipcRenderer.on(channel, listener)
return () => ipcRenderer.removeListener(channel, listener)
},
onExit: (id, callback) => {
const channel = `hermes:terminal:${id}:exit`
const listener = (_event, payload) => callback(payload)
ipcRenderer.on(channel, listener)
return () => ipcRenderer.removeListener(channel, listener)
}
},
onClosePreviewRequested: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:close-preview-requested', listener)

View file

@ -45,7 +45,11 @@
"@assistant-ui/react-streamdown": "^0.1.11",
"@audiowave/react": "^0.6.2",
"@chenglou/pretext": "^0.0.6",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hermes/shared": "file:../shared",
"@homebridge/node-pty-prebuilt-multiarch": "^0.13.1",
"@nanostores/react": "^1.1.0",
"@nous-research/ui": "^0.13.0",
"@radix-ui/react-slot": "^1.2.4",
@ -54,6 +58,12 @@
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.4",
"@tanstack/react-query": "^5.100.6",
"@tanstack/react-virtual": "^3.13.24",
"@vscode/codicons": "^0.0.45",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@ -62,7 +72,6 @@
"ignore": "^7.0.5",
"katex": "^0.16.45",
"leva": "^0.10.1",
"lucide-react": "^0.577.0",
"motion": "^12.38.0",
"nanostores": "^1.3.0",
"radix-ui": "^1.4.3",
@ -80,7 +89,6 @@
"unicode-animations": "^1.0.3",
"unified": "^11.0.5",
"unist-util-visit-parents": "^6.0.2",
"use-stick-to-bottom": "^1.1.4",
"vfile": "^6.0.3",
"web-haptics": "^0.0.6"
},

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,8 +5,8 @@ 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 { Input } from '@/components/ui/input'
import {
Pagination,
PaginationButton,
@ -16,19 +16,19 @@ import {
PaginationNext,
PaginationPrevious
} from '@/components/ui/pagination'
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
import { getSessionMessages, listSessions } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
import { FileImage, FileText, FolderOpen, Layers3, Link2, RefreshCw, Search, X } 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'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { PageSearchShell } from '../page-search-shell'
import { sessionRoute } from '../routes'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { titlebarHeaderBaseClass } from '../shell/titlebar'
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
type ArtifactKind = 'image' | 'file' | 'link'
type ArtifactFilter = 'all' | ArtifactKind
@ -363,14 +363,9 @@ const itemsLabel = (f: ArtifactFilter) => (f === 'link' ? 'links' : f === 'file'
interface ArtifactsViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup
setTitlebarToolGroup?: SetTitlebarToolGroup
}
export function ArtifactsView({
setStatusbarItemGroup: _setStatusbarItemGroup,
setTitlebarToolGroup,
...props
}: ArtifactsViewProps) {
export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: ArtifactsViewProps) {
const navigate = useNavigate()
const [artifacts, setArtifacts] = useState<ArtifactRecord[] | null>(null)
const [query, setQuery] = useState('')
@ -412,24 +407,6 @@ export function ArtifactsView({
void refreshArtifacts()
}, [refreshArtifacts])
useEffect(() => {
if (!setTitlebarToolGroup) {
return
}
setTitlebarToolGroup('artifacts', [
{
disabled: refreshing,
icon: <RefreshCw className={cn(refreshing && 'animate-spin')} />,
id: 'refresh-artifacts',
label: refreshing ? 'Refreshing artifacts' : 'Refresh artifacts',
onSelect: () => void refreshArtifacts()
}
])
return () => setTitlebarToolGroup('artifacts', [])
}, [refreshArtifacts, refreshing, setTitlebarToolGroup])
useEffect(() => {
setImagePage(1)
setFilePage(1)
@ -523,127 +500,103 @@ export function ArtifactsView({
}
return (
<section {...props} className="flex h-full min-w-0 flex-col overflow-hidden rounded-b-[0.9375rem] bg-background">
<header className={titlebarHeaderBaseClass}>
<h2 className="pointer-events-auto text-base font-semibold leading-none tracking-tight">Artifacts</h2>
<span className="pointer-events-auto text-xs text-muted-foreground">{counts.all} found</span>
</header>
<div className="min-h-0 flex-1 overflow-hidden rounded-b-[1.0625rem] border border-border/50 bg-background/85">
<div className="border-b border-border/50 px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<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')}
/>
<div className="ml-auto w-full max-w-sm min-w-64">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
className="h-8 rounded-lg pl-8 pr-8 text-sm"
onChange={event => setQuery(event.target.value)}
placeholder="Search artifacts..."
value={query}
/>
{query && (
<Button
aria-label="Clear search"
className="absolute right-1 top-1/2 h-6 w-6 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setQuery('')}
size="icon"
type="button"
variant="ghost"
>
<X className="size-3.5" />
</Button>
)}
</div>
<PageSearchShell
{...props}
filters={
<>
<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..."
searchTrailingAction={
<Button
aria-label={refreshing ? 'Refreshing artifacts' : 'Refresh artifacts'}
className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground"
disabled={refreshing}
onClick={() => void refreshArtifacts()}
size="icon-xs"
title={refreshing ? 'Refreshing artifacts' : 'Refresh artifacts'}
type="button"
variant="ghost"
>
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
</Button>
}
searchValue={query}
>
{!artifacts ? (
<PageLoader label="Indexing recent session artifacts" />
) : visibleArtifacts.length === 0 ? (
<div className="grid h-full place-items-center px-6 text-center">
<div>
<div className="text-sm font-medium">No artifacts found</div>
<div className="mt-1 text-xs text-muted-foreground">
Generated images and file outputs will appear here as sessions produce them.
</div>
</div>
</div>
{!artifacts ? (
<PageLoader label="Indexing recent session artifacts" />
) : visibleArtifacts.length === 0 ? (
<div className="grid h-full place-items-center px-6 text-center">
<div>
<div className="text-sm font-medium">No artifacts found</div>
<div className="mt-1 text-xs text-muted-foreground">
Generated images and file outputs will appear here as sessions produce them.
</div>
</div>
</div>
) : (
<div className="h-full overflow-y-auto">
<div className="flex flex-col gap-4 px-2 pb-2">
{visibleImageArtifacts.length > 0 && (
<section className="flex flex-col">
<div className="sticky top-0 z-10 -mx-2 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3">
<ArtifactsPagination
className="ml-auto justify-end px-0"
itemLabel="images"
onPageChange={setImagePage}
page={currentImagePage}
pageSize={24}
total={visibleImageArtifacts.length}
) : (
<div className="h-full overflow-y-auto">
<div className="flex flex-col gap-3 px-2 pb-2">
{visibleImageArtifacts.length > 0 && (
<section className="flex flex-col">
<div className="sticky top-0 z-10 -mx-2 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3">
<ArtifactsPagination
className="ml-auto justify-end px-0"
itemLabel="images"
onPageChange={setImagePage}
page={currentImagePage}
pageSize={24}
total={visibleImageArtifacts.length}
/>
</div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] items-start gap-2 pt-1.5">
{pagedImageArtifacts.map(artifact => (
<ArtifactImageCard
artifact={artifact}
failedImage={failedImageIds.has(artifact.id)}
key={artifact.id}
onImageError={markImageFailed}
onOpenChat={sessionId => navigate(sessionRoute(sessionId))}
/>
</div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(12rem,1fr))] items-start gap-2 pt-1.5">
{pagedImageArtifacts.map(artifact => (
<ArtifactImageCard
artifact={artifact}
failedImage={failedImageIds.has(artifact.id)}
key={artifact.id}
onImageError={markImageFailed}
onOpenChat={sessionId => navigate(sessionRoute(sessionId))}
/>
))}
</div>
</section>
)}
))}
</div>
</section>
)}
{visibleFileArtifacts.length > 0 && (
<section className="flex flex-col">
<div className="sticky top-0 z-10 -mx-2 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3">
<ArtifactsPagination
className="ml-auto justify-end px-0"
itemLabel={itemsLabel(kindFilter)}
onPageChange={setFilePage}
page={currentFilePage}
pageSize={100}
total={visibleFileArtifacts.length}
/>
</div>
<div className="overflow-x-auto rounded-lg border border-border/50 bg-background/70 shadow-[0_0.125rem_0.5rem_color-mix(in_srgb,black_3%,transparent)]">
<ArtifactTable artifacts={pagedFileArtifacts} ctx={cellCtx} filter={kindFilter} />
</div>
</section>
)}
</div>
{visibleFileArtifacts.length > 0 && (
<section className="flex flex-col">
<div className="sticky top-0 z-10 -mx-2 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3">
<ArtifactsPagination
className="ml-auto justify-end px-0"
itemLabel={itemsLabel(kindFilter)}
onPageChange={setFilePage}
page={currentFilePage}
pageSize={100}
total={visibleFileArtifacts.length}
/>
</div>
<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>
)}
</div>
)}
</div>
</section>
</div>
)}
</PageSearchShell>
)
}
@ -698,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-8 gap-1.5 rounded-md px-2.5 text-xs',
active ? 'bg-accent text-foreground' : 'text-muted-foreground hover:text-foreground'
)}
onClick={onClick}
size="sm"
type="button"
variant="ghost"
>
<Icon className="size-3.5" />
{label}
</Button>
)
}
interface ArtifactImageCardProps {
artifact: ArtifactRecord
failedImage: boolean
@ -737,13 +662,12 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
return (
<article
className={cn(
'group/artifact overflow-hidden rounded-lg border border-border/50 bg-background/70 shadow-[0_0.125rem_0.5rem_color-mix(in_srgb,black_3%,transparent)]',
'bg-muted/20'
'group/artifact overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) shadow-sm'
)}
>
<div
className={cn(
'relative flex h-44 w-full items-center justify-center overflow-hidden border-b border-border/50 bg-[color-mix(in_srgb,var(--dt-muted)_58%,var(--dt-background))] p-1.5',
'relative flex h-40 w-full items-center justify-center overflow-hidden border-b border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-1.5',
failedImage && 'cursor-default'
)}
>
@ -763,15 +687,17 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
<div className="space-y-1.5 p-2">
<div className="min-w-0">
<div className="mb-0.5 flex items-center gap-1 text-[0.62rem] uppercase tracking-[0.08em] text-muted-foreground">
<div className="mb-0.5 flex items-center gap-1 text-[0.625rem] uppercase tracking-[0.08em] text-(--ui-text-tertiary)">
<FileImage className="size-3" />
{artifact.kind}
</div>
<div className="truncate text-xs font-medium">{artifact.label}</div>
<div className="mt-0.5 truncate text-[0.62rem] text-muted-foreground">{artifact.value}</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>
<div className="truncate text-[0.62rem] text-muted-foreground">
<div className="truncate text-[0.625rem] text-(--ui-text-tertiary)">
{artifact.sessionTitle} · {formatArtifactTime(artifact.timestamp)}
</div>
@ -803,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-sm leading-snug font-medium text-foreground/90 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}
@ -816,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-sm leading-snug font-medium text-foreground/90 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}
@ -840,7 +766,7 @@ function PrimaryCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx
onClick={isLink ? undefined : () => void ctx.onOpen(artifact.href)}
title={label}
>
<span className="grid size-7 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground">
<span className="mt-0.5 grid size-6 shrink-0 place-items-center self-start rounded-md bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)">
<Icon className="size-3.5" />
</span>
<span className={cn('min-w-0 flex-1', isLink ? 'wrap-anywhere' : 'truncate')}>
@ -859,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-xs text-muted-foreground/85', isLink ? 'font-medium' : '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}
@ -882,7 +811,7 @@ function SessionCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx
<ArtifactCellAction onClick={() => ctx.onOpenChat(artifact.sessionId)} title={artifact.sessionTitle}>
<span className="flex min-w-0 flex-col">
<span className="truncate">{artifact.sessionTitle}</span>
<span className="truncate text-xs font-normal text-muted-foreground/75">
<span className="truncate text-[0.6875rem] font-normal text-(--ui-text-tertiary)">
{formatArtifactTime(artifact.timestamp)}
</span>
</span>
@ -924,8 +853,8 @@ function ArtifactTable({
filter: ArtifactFilter
}) {
return (
<table className="w-full min-w-176 table-fixed text-left text-xs">
<thead className="border-b border-border/50 bg-muted/35 text-[0.62rem] uppercase tracking-[0.08em] text-muted-foreground">
<table className="w-full min-w-176 table-fixed text-left text-[length:var(--conversation-caption-font-size)]">
<thead className="border-b border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) text-[0.625rem] uppercase tracking-[0.08em] text-(--ui-text-tertiary)">
<tr>
{ARTIFACT_COLUMNS.map(col => (
<th className={cn(col.width(filter), 'px-2.5 py-1.5 font-medium')} key={col.id}>
@ -934,9 +863,9 @@ function ArtifactTable({
))}
</tr>
</thead>
<tbody className="divide-y divide-border/45">
<tbody className="divide-y divide-(--ui-stroke-quaternary)">
{artifacts.map(artifact => (
<tr className="group/artifact transition-colors hover:bg-muted/30" key={artifact.id}>
<tr className="group/artifact" key={artifact.id}>
{ARTIFACT_COLUMNS.map(col => {
const Cell = col.Cell

View file

@ -1,6 +1,7 @@
import { useStore } from '@nanostores/react'
import { FileText, FolderOpen, ImageIcon, Link, X } from '@/lib/icons'
import { Codicon } from '@/components/ui/codicon'
import { FileText, FolderOpen, ImageIcon, Link, Terminal } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import type { ComposerAttachment } from '@/store/composer'
import { notifyError } from '@/store/notifications'
@ -16,17 +17,17 @@ export function AttachmentList({
}) {
return (
<div className="flex max-w-full flex-wrap gap-1.5 px-1 pt-1" data-slot="composer-attachments">
{attachments.map(a => (
<AttachmentPill attachment={a} key={a.id} onRemove={onRemove} />
{attachments.map(attachment => (
<AttachmentPill attachment={attachment} key={attachment.id} onRemove={onRemove} />
))}
</div>
)
}
function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachment; onRemove?: (id: string) => void }) {
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText }[attachment.kind]
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind]
const cwd = useStore($currentCwd)
const canPreview = attachment.kind !== 'folder'
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal'
const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined
async function openPreview() {
@ -101,7 +102,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
onClick={() => onRemove(attachment.id)}
type="button"
>
<X className="size-2.5" />
<Codicon name="close" size="0.625rem" />
</button>
)}
</div>

View file

@ -3,23 +3,30 @@ import { ComposerPrimitive } from '@assistant-ui/react'
import type { ReactNode } from 'react'
export const COMPLETION_DRAWER_CLASS = [
'absolute inset-x-0 bottom-[calc(100%-0.5rem)] z-50',
'absolute bottom-[calc(100%+0.25rem)] left-0 z-50',
'w-60 max-w-[calc(100vw-2rem)]',
'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
'border border-b-0',
'border-[color-mix(in_srgb,var(--dt-ring)_45%,transparent)]',
'bg-[color-mix(in_srgb,var(--dt-popover)_96%,transparent)]',
'px-1.5 pb-3 pt-1.5 text-popover-foreground',
'backdrop-blur-[0.75rem] backdrop-saturate-[1.1]',
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.1)]',
'data-[state=open]:-mb-2',
'data-[state=open]:shadow-[0_-0.0625rem_0_0.0625rem_color-mix(in_srgb,var(--dt-ring)_35%,transparent),0_-1rem_2.25rem_-1.75rem_color-mix(in_srgb,var(--dt-foreground)_34%,transparent),0_-0.3125rem_0.875rem_-0.6875rem_color-mix(in_srgb,var(--dt-foreground)_22%,transparent)]'
'rounded-lg border border-(--ui-stroke-secondary)',
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)]',
'p-1 text-xs text-popover-foreground shadow-md',
'backdrop-blur-md'
].join(' ')
export const COMPLETION_DRAWER_BELOW_CLASS = [
'absolute left-0 top-[calc(100%+0.25rem)] z-50',
'w-60 max-w-[calc(100vw-2rem)]',
'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
'rounded-lg border border-(--ui-stroke-secondary)',
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)]',
'p-1 text-xs text-popover-foreground shadow-md',
'backdrop-blur-md'
].join(' ')
export const COMPLETION_DRAWER_ROW_CLASS = [
'flex w-full min-w-0 items-baseline gap-2 rounded-md px-2.5 py-1',
'text-left text-xs transition-colors',
'hover:bg-[color-mix(in_srgb,var(--dt-accent)_70%,transparent)]',
'data-[highlighted]:bg-[color-mix(in_srgb,var(--dt-accent)_70%,transparent)]'
'relative flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1',
'w-full min-w-0 text-left text-xs outline-hidden transition-colors',
'hover:bg-(--ui-bg-tertiary)',
'data-[highlighted]:bg-(--ui-bg-tertiary) data-[highlighted]:text-foreground'
].join(' ')
export function ComposerCompletionDrawer({
@ -48,9 +55,9 @@ export function ComposerCompletionDrawer({
export function CompletionDrawerEmpty({ children, title }: { children?: ReactNode; title: string }) {
return (
<div className="px-3 py-3 text-sm text-muted-foreground">
<div className="px-3 py-3 text-xs text-(--ui-text-tertiary)">
<p>{title}</p>
{children && <p className="mt-1 text-xs text-muted-foreground/80">{children}</p>}
{children && <p className="mt-1 text-xs text-(--ui-text-tertiary)">{children}</p>}
</div>
)
}

View file

@ -1,4 +1,5 @@
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
DropdownMenu,
DropdownMenuContent,
@ -10,7 +11,7 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Clipboard, FileText, FolderOpen, ImageIcon, Link, type LucideIcon, MessageSquareText, Plus } 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'
@ -38,14 +39,17 @@ export function ContextMenu({
<DropdownMenuTrigger asChild>
<Button
aria-label={state.tools.label}
className={cn(GHOST_ICON_BTN, 'data-[state=open]:bg-accent 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}
type="button"
variant="ghost"
>
<Plus size={18} />
<Codicon name="add" size="1rem" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-60" side="top" sideOffset={10}>
@ -107,7 +111,7 @@ export function ContextMenuItem({
}: {
children: string
disabled?: boolean
icon: LucideIcon
icon: IconComponent
onSelect?: () => void
}) {
return (

View file

@ -1,13 +1,17 @@
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { triggerHaptic } from '@/lib/haptics'
import { ArrowUp, AudioLines, Layers3, Loader2, Mic, MicOff, Square } from '@/lib/icons'
import { AudioLines, Layers3, Loader2, Square } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { ConversationStatus } from './hooks/use-voice-conversation'
import type { ChatBarState, VoiceStatus } from './types'
export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-full'
export const GHOST_ICON_BTN = cn(ICON_BTN, 'text-muted-foreground hover:bg-accent hover:text-foreground')
export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-md'
export const GHOST_ICON_BTN = cn(
ICON_BTN,
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)
// Send/voice-conversation primary: solid foreground-on-background circle
// (reads as black-on-white in light mode, white-on-black in dark mode) to
// match the reference composer's high-contrast CTA. Keeps the pill itself
@ -89,7 +93,7 @@ export function ComposerControls({
<span className="block size-3 rounded-[0.1875rem] bg-current" />
)
) : (
<ArrowUp size={18} />
<Codicon name="arrow-up" size="1rem" />
)}
</Button>
)}
@ -136,7 +140,7 @@ function ConversationPill({
type="button"
variant="ghost"
>
{muted ? <MicOff size={16} /> : <Mic size={16} />}
<Codicon name={muted ? 'mic-off' : 'mic'} size="1rem" />
</Button>
{listening && (
<Button
@ -246,7 +250,7 @@ function DictationButton({
) : status === 'transcribing' ? (
<Loader2 className="animate-spin" size={16} />
) : (
<Mic size={16} />
<Codicon name="mic" size="1rem" />
)}
</Button>
)

View file

@ -0,0 +1,2 @@
export const COMPOSER_DROP_FADE_CLASS = 'transition-opacity duration-150 ease-out'
export const COMPOSER_DROP_ACTIVE_CLASS = 'opacity-60'

View file

@ -1,49 +1,103 @@
export type ComposerFocusTarget = 'main' | 'edit'
/**
* Composer focus + external-insert bus.
*
* Mutations from outside the composer (sidebar attach, drag drop, terminal
* Cmd+L, preview console, etc.) dispatch through here. Each composer subscribes
* and routes the work back into its own ref/state.
*
* `dispatch` defers to a macrotask so synchronous click/keydown handlers
* (react-arborist row focus, picker `node.select()`) finish first and don't
* steal focus from the composer effect.
*/
interface ComposerFocusRequestDetail {
target: ComposerFocusTarget
export type ComposerTarget = 'edit' | 'main'
export type ComposerInsertMode = 'block' | 'inline'
interface FocusDetail {
target: ComposerTarget
}
const COMPOSER_FOCUS_REQUEST_EVENT = 'hermes:composer-focus-request'
let activeComposerTarget: ComposerFocusTarget = 'main'
function resolveTarget(target: ComposerFocusTarget | 'active'): ComposerFocusTarget {
return target === 'active' ? activeComposerTarget : target
interface InsertDetail {
mode: ComposerInsertMode
target: ComposerTarget
text: string
}
export function markActiveComposer(target: ComposerFocusTarget) {
activeComposerTarget = target
}
const FOCUS_EVENT = 'hermes:composer-focus'
const INSERT_EVENT = 'hermes:composer-insert'
export function requestComposerFocus(target: ComposerFocusTarget | 'active' = 'active') {
let activeTarget: ComposerTarget = 'main'
const resolve = (target: ComposerTarget | 'active') => (target === 'active' ? activeTarget : target)
const dispatch = <T>(name: string, detail: T) => {
if (typeof window === 'undefined') {
return
}
const resolvedTarget = resolveTarget(target)
const event = new CustomEvent<ComposerFocusRequestDetail>(COMPOSER_FOCUS_REQUEST_EVENT, {
detail: { target: resolvedTarget }
})
window.dispatchEvent(event)
window.setTimeout(() => window.dispatchEvent(new CustomEvent<T>(name, { detail })), 0)
}
export function onComposerFocusRequest(handler: (target: ComposerFocusTarget) => void) {
const subscribe = <T>(name: string, handler: (detail: T) => void) => {
if (typeof window === 'undefined') {
return () => undefined
}
const listener = (event: Event) => {
const detail = (event as CustomEvent<ComposerFocusRequestDetail>).detail
const detail = (event as CustomEvent<T>).detail
if (detail?.target === 'main' || detail?.target === 'edit') {
handler(detail.target)
if (detail) {
handler(detail)
}
}
window.addEventListener(COMPOSER_FOCUS_REQUEST_EVENT, listener)
window.addEventListener(name, listener)
return () => window.removeEventListener(COMPOSER_FOCUS_REQUEST_EVENT, listener)
return () => window.removeEventListener(name, listener)
}
export const markActiveComposer = (target: ComposerTarget) => {
activeTarget = target
}
export const requestComposerFocus = (target: ComposerTarget | 'active' = 'active') =>
dispatch<FocusDetail>(FOCUS_EVENT, { target: resolve(target) })
export const requestComposerInsert = (
text: string,
{ mode = 'block', target = 'active' }: { mode?: ComposerInsertMode; target?: ComposerTarget | 'active' } = {}
) => {
const trimmed = text.trim()
if (!trimmed) {
return
}
dispatch<InsertDetail>(INSERT_EVENT, { mode, target: resolve(target), text: trimmed })
}
export const onComposerFocusRequest = (handler: (target: ComposerTarget) => void) =>
subscribe<FocusDetail>(FOCUS_EVENT, ({ target }) => handler(target))
export const onComposerInsertRequest = (handler: (detail: InsertDetail) => void) =>
subscribe<InsertDetail>(INSERT_EVENT, handler)
/**
* Focus a composer input across React commit + browser focus restore.
*
* The triple-call survives:
* - sync: contenteditable already mounted
* - rAF: React just committed a `renderComposerContents` swap
* - 0ms: browser focus reclaim from a click target inside an external panel
*/
export const focusComposerInput = (el: HTMLElement | null) => {
if (!el) {
return
}
const focus = () => el.focus({ preventScroll: true })
focus()
window.requestAnimationFrame(focus)
window.setTimeout(focus, 0)
}

View file

@ -8,16 +8,16 @@ import {
type DragEvent as ReactDragEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState
} from 'react'
import { formatRefValue, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { Button } from '@/components/ui/button'
import { useMediaQuery } from '@/hooks/use-media-query'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { chatMessageText } from '@/lib/chat-messages'
import { contextPath } from '@/lib/chat-runtime'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
@ -25,28 +25,39 @@ import {
$composerAttachments,
$composerDraft,
clearComposerAttachments,
type ComposerAttachment
type ComposerAttachment,
reconcileComposerTerminalSelections
} from '@/store/composer'
import {
$queuedPromptsBySession,
enqueueQueuedPrompt,
removeQueuedPrompt,
type QueuedPromptEntry,
removeQueuedPrompt,
updateQueuedPrompt
} from '@/store/composer-queue'
import { $messages } from '@/store/session'
import { $threadScrolledUp } from '@/store/thread-scroll'
import { type DroppedFile, extractDroppedFiles, HERMES_PATHS_MIME } from '../hooks/use-composer-actions'
import { extractDroppedFiles, HERMES_PATHS_MIME } from '../hooks/use-composer-actions'
import { AttachmentList } from './attachments'
import { ContextMenu } from './context-menu'
import { ComposerControls } from './controls'
import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-affordance'
import {
type ComposerInsertMode,
focusComposerInput,
markActiveComposer,
onComposerFocusRequest,
onComposerInsertRequest
} from './focus'
import { HelpHint } from './help-hint'
import { useAtCompletions } from './hooks/use-at-completions'
import { useSlashCompletions } from './hooks/use-slash-completions'
import { useVoiceConversation } from './hooks/use-voice-conversation'
import { useVoiceRecorder } from './hooks/use-voice-recorder'
import { dragHasAttachments, droppedFileInlineRef, insertInlineRefsIntoEditor } from './inline-refs'
import { QueuePanel } from './queue-panel'
import {
composerPlainText,
placeCaretEnd,
@ -54,7 +65,6 @@ import {
renderComposerContents,
RICH_INPUT_SLOT
} from './rich-editor'
import { QueuePanel } from './queue-panel'
import { SkinSlashPopover } from './skin-slash-popover'
import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils'
import { ComposerTriggerPopover } from './trigger-popover'
@ -104,7 +114,11 @@ export function ChatBar({
const queuedPromptsBySession = useStore($queuedPromptsBySession)
const scrolledUp = useStore($threadScrolledUp)
const activeQueueSessionKey = queueSessionKey || sessionId || null
const queuedPrompts = activeQueueSessionKey ? (queuedPromptsBySession[activeQueueSessionKey] ?? []) : []
const queuedPrompts = useMemo(
() => (activeQueueSessionKey ? (queuedPromptsBySession[activeQueueSessionKey] ?? []) : []),
[activeQueueSessionKey, queuedPromptsBySession]
)
const composerRef = useRef<HTMLFormElement | null>(null)
const composerSurfaceRef = useRef<HTMLDivElement | null>(null)
@ -121,10 +135,11 @@ export function ChatBar({
const [tight, setTight] = useState(false)
const [dragActive, setDragActive] = useState(false)
const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(null)
const [focusRequestId, setFocusRequestId] = useState(0)
const dragDepthRef = useRef(0)
const lastSpokenIdRef = useRef<string | null>(null)
const narrow = useMediaQuery('(max-width: 480px)')
const narrow = useMediaQuery('(max-width: 30rem)')
const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null })
const slash = useSlashCompletions({ gateway: gateway ?? null })
@ -132,23 +147,81 @@ export function ChatBar({
const stacked = expanded || narrow || tight
const hasComposerPayload = draft.trim().length > 0 || attachments.length > 0
const canSubmit = busy || hasComposerPayload
const editingQueuedPrompt = queueEdit ? queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null : null
const editingQueuedPrompt = queueEdit ? (queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null) : null
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
const showHelpHint = draft === '?'
const placeholder = disabled ? 'Starting Hermes…' : 'Ask anything'
const placeholder = disabled ? 'Starting Hermes...' : 'Send follow-up'
const focusInput = () => window.requestAnimationFrame(() => editorRef.current?.focus({ preventScroll: true }))
const focusInput = useCallback(() => {
focusComposerInput(editorRef.current)
markActiveComposer('main')
}, [])
const requestMainFocus = useCallback(() => {
setFocusRequestId(id => id + 1)
}, [])
const appendExternalText = useCallback(
(text: string, mode: ComposerInsertMode) => {
const value = text.trim()
if (!value) {
return
}
const base = mode === 'inline' ? draftRef.current.trimEnd() : draftRef.current
const sep = mode === 'inline' ? (base ? ' ' : '') : base && !base.endsWith('\n') ? '\n\n' : ''
const next = `${base}${sep}${value}`
draftRef.current = next
aui.composer().setText(next)
const editor = editorRef.current
if (editor) {
renderComposerContents(editor, next)
placeCaretEnd(editor)
}
setFocusRequestId(id => id + 1)
},
[aui]
)
useEffect(() => {
if (!disabled) {
focusInput()
}
}, [disabled, focusKey])
}, [disabled, focusInput, focusKey, focusRequestId])
useEffect(() => {
if (disabled) {
return undefined
}
const offFocus = onComposerFocusRequest(target => {
if (target === 'main') {
setFocusRequestId(id => id + 1)
}
})
const offInsert = onComposerInsertRequest(({ mode, target, text }) => {
if (target === 'main') {
appendExternalText(text, mode)
}
})
return () => {
offFocus()
offInsert()
}
}, [appendExternalText, disabled])
useEffect(() => {
draftRef.current = draft
$composerDraft.set(draft)
reconcileComposerTerminalSelections(draft)
const editor = editorRef.current
@ -232,114 +305,33 @@ export function ChatBar({
draftRef.current = nextDraft
aui.composer().setText(nextDraft)
focusInput()
requestMainFocus()
}
const insertInlineRefs = (refs: string[]) => {
const editor = editorRef.current
if (!refs.length || !editor) {
if (!editor) {
return false
}
const inline = refs.join(' ')
const selection = window.getSelection()
const nextDraft = insertInlineRefsIntoEditor(editor, refs)
const range =
selection?.rangeCount && editor.contains(selection.getRangeAt(0).commonAncestorContainer)
? selection.getRangeAt(0)
: null
editor.focus({ preventScroll: true })
if (range) {
const beforeRange = range.cloneRange()
beforeRange.selectNodeContents(editor)
beforeRange.setEnd(range.startContainer, range.startOffset)
const beforeContainer = document.createElement('div')
beforeContainer.appendChild(beforeRange.cloneContents())
const afterRange = range.cloneRange()
afterRange.selectNodeContents(editor)
afterRange.setStart(range.endContainer, range.endOffset)
const afterContainer = document.createElement('div')
afterContainer.appendChild(afterRange.cloneContents())
const beforeText = composerPlainText(beforeContainer)
const afterText = composerPlainText(afterContainer)
const needsBeforeSpace = beforeText.length > 0 && !/\s$/.test(beforeText)
const needsAfterSpace = afterText.length === 0 || !/^\s/.test(afterText)
range.deleteContents()
const fragment = document.createDocumentFragment()
if (needsBeforeSpace) {
fragment.appendChild(document.createTextNode(' '))
}
refs.forEach((ref, index) => {
const match = ref.match(/^@([^:]+):(.+)$/)
fragment.appendChild(match ? refChipElement(match[1], match[2]) : document.createTextNode(ref))
if (index < refs.length - 1) {
fragment.appendChild(document.createTextNode(' '))
}
})
const trailingSpace = needsAfterSpace ? document.createTextNode(' ') : null
if (trailingSpace) {
fragment.appendChild(trailingSpace)
}
range.insertNode(fragment)
const nextRange = document.createRange()
if (trailingSpace) {
nextRange.setStart(trailingSpace, trailingSpace.length)
} else {
nextRange.setStartAfter(fragment.lastChild || range.startContainer)
}
nextRange.collapse(true)
selection?.removeAllRanges()
selection?.addRange(nextRange)
} else {
const current = composerPlainText(editor)
renderComposerContents(editor, `${current}${current && !/\s$/.test(current) ? ' ' : ''}${inline} `)
placeCaretEnd(editor)
if (nextDraft === null) {
return false
}
const nextDraft = composerPlainText(editor)
draftRef.current = nextDraft
aui.composer().setText(nextDraft)
requestMainFocus()
return true
}
const droppedFileInlineRef = (candidate: DroppedFile) => {
if (!candidate.path) {
return null
}
const rel = contextPath(candidate.path, cwd || '')
if (candidate.line) {
const { line, lineEnd } = candidate
const range = lineEnd && lineEnd > line ? `${line}-${lineEnd}` : `${line}`
return `@line:${formatRefValue(`${rel}:${range}`)}`
}
const kind = candidate.isDirectory ? 'folder' : 'file'
return `@${kind}:${formatRefValue(rel)}`
}
const selectSkinSlashCommand = (command: string) => {
draftRef.current = command
aui.composer().setText(command)
focusInput()
requestMainFocus()
}
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
@ -453,6 +445,7 @@ export function ChatBar({
const finish = () => {
draftRef.current = composerPlainText(editor)
aui.composer().setText(draftRef.current)
requestMainFocus()
starter ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
}
@ -498,7 +491,9 @@ export function ChatBar({
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'k') {
event.preventDefault()
if (!busy) void drainNextQueued()
if (!busy) {
void drainNextQueued()
}
return
}
@ -554,29 +549,13 @@ export function ChatBar({
window.setTimeout(refreshTrigger, 0)
}
const dragHasAttachments = (transfer: DataTransfer | null) => {
if (!transfer) {
return false
}
if (Array.from(transfer.types || []).includes(HERMES_PATHS_MIME)) {
return true
}
if (Array.from(transfer.types || []).includes('Files')) {
return true
}
return Array.from(transfer.items || []).some(item => item.kind === 'file')
}
const resetDragState = () => {
dragDepthRef.current = 0
setDragActive(false)
}
const handleDragEnter = (event: ReactDragEvent<HTMLFormElement>) => {
if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer)) {
if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
return
}
@ -589,7 +568,7 @@ export function ChatBar({
}
const handleDragOver = (event: ReactDragEvent<HTMLFormElement>) => {
if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer)) {
if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
return
}
@ -625,7 +604,9 @@ export function ChatBar({
}
if (Array.from(event.dataTransfer.types || []).includes(HERMES_PATHS_MIME)) {
const refs = candidates.map(droppedFileInlineRef).filter((ref): ref is string => Boolean(ref))
const refs = candidates
.map(candidate => droppedFileInlineRef(candidate, cwd))
.filter((ref): ref is string => Boolean(ref))
if (insertInlineRefs(refs)) {
triggerHaptic('selection')
@ -637,13 +618,13 @@ export function ChatBar({
void Promise.resolve(onAttachDroppedItems(candidates)).then(attached => {
if (attached) {
triggerHaptic('selection')
focusInput()
requestMainFocus()
}
})
}
const handleInputDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
if (!dragHasAttachments(event.dataTransfer)) {
if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
return
}
@ -653,12 +634,15 @@ export function ChatBar({
}
const handleInputDrop = (event: ReactDragEvent<HTMLDivElement>) => {
if (!dragHasAttachments(event.dataTransfer)) {
if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
return
}
const candidates = extractDroppedFiles(event.dataTransfer)
const refs = candidates.map(droppedFileInlineRef).filter((ref): ref is string => Boolean(ref))
const refs = candidates
.map(candidate => droppedFileInlineRef(candidate, cwd))
.filter((ref): ref is string => Boolean(ref))
if (!refs.length) {
return
@ -673,14 +657,14 @@ export function ChatBar({
}
}
const clearDraft = () => {
const clearDraft = useCallback(() => {
aui.composer().setText('')
draftRef.current = ''
if (editorRef.current) {
editorRef.current.replaceChildren()
}
}
}, [aui])
const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => {
draftRef.current = text
@ -696,7 +680,9 @@ export function ChatBar({
}
const beginQueuedEdit = (entry: QueuedPromptEntry) => {
if (!activeQueueSessionKey || queueEdit) return
if (!activeQueueSessionKey || queueEdit) {
return
}
setQueueEdit({
attachments: cloneAttachments($composerAttachments.get()),
@ -710,13 +696,17 @@ export function ChatBar({
}
const exitQueuedEdit = (action: 'cancel' | 'save'): boolean => {
if (!queueEdit) return false
if (!queueEdit) {
return false
}
if (action === 'save') {
const text = draftRef.current
const next = cloneAttachments($composerAttachments.get())
if (!text.trim() && next.length === 0) return false
if (!text.trim() && next.length === 0) {
return false
}
const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, { attachments: next, text })
triggerHaptic(saved ? 'success' : 'selection')
@ -732,32 +722,45 @@ export function ChatBar({
}
const queueCurrentDraft = useCallback(() => {
if (!activeQueueSessionKey || (!draft.trim() && attachments.length === 0)) return false
if (!enqueueQueuedPrompt(activeQueueSessionKey, { text: draft, attachments })) return false
if (!activeQueueSessionKey || (!draft.trim() && attachments.length === 0)) {
return false
}
if (!enqueueQueuedPrompt(activeQueueSessionKey, { text: draft, attachments })) {
return false
}
clearDraft()
clearComposerAttachments()
triggerHaptic('selection')
return true
}, [activeQueueSessionKey, attachments, draft])
}, [activeQueueSessionKey, attachments, clearDraft, draft])
// All queue drain paths share one lock + send-then-remove sequence.
// `pickEntry` lets each caller choose head, by-id, or skip-edited.
const runDrain = useCallback(
async (pickEntry: (entries: QueuedPromptEntry[]) => QueuedPromptEntry | undefined): Promise<boolean> => {
if (drainingQueueRef.current || !activeQueueSessionKey) return false
if (drainingQueueRef.current || !activeQueueSessionKey) {
return false
}
const entry = pickEntry(queuedPrompts)
if (!entry) return false
if (!entry) {
return false
}
drainingQueueRef.current = true
try {
const accepted = await Promise.resolve(onSubmit(entry.text, { attachments: entry.attachments, fromQueue: true }))
const accepted = await Promise.resolve(
onSubmit(entry.text, { attachments: entry.attachments, fromQueue: true })
)
if (accepted === false) return false
if (accepted === false) {
return false
}
removeQueuedPrompt(activeQueueSessionKey, entry.id)
@ -785,7 +788,9 @@ export function ChatBar({
)
const interruptAndSendNextQueued = useCallback(async () => {
if (queuedPrompts.length === 0) return false
if (queuedPrompts.length === 0) {
return false
}
await Promise.resolve(onCancel())
@ -797,15 +802,22 @@ export function ChatBar({
const wasBusy = previousBusyRef.current
previousBusyRef.current = busy
if (busy || !wasBusy || queuedPrompts.length === 0) return
if (busy || !wasBusy || queuedPrompts.length === 0) {
return
}
void drainNextQueued()
}, [busy, drainNextQueued, queuedPrompts.length])
// Clean up queue edit when its target disappears (session swap or external delete).
useEffect(() => {
if (!queueEdit) return
if (queueEdit.sessionKey === activeQueueSessionKey && editingQueuedPrompt) return
if (!queueEdit) {
return
}
if (queueEdit.sessionKey === activeQueueSessionKey && editingQueuedPrompt) {
return
}
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
setQueueEdit(null)
@ -815,9 +827,11 @@ export function ChatBar({
if (queueEdit) {
exitQueuedEdit('save')
} else if (busy) {
if (hasComposerPayload) queueCurrentDraft()
else if (queuedPrompts.length > 0) void interruptAndSendNextQueued()
else {
if (hasComposerPayload) {
queueCurrentDraft()
} else if (queuedPrompts.length > 0) {
void interruptAndSendNextQueued()
} else {
triggerHaptic('cancel')
void Promise.resolve(onCancel())
}
@ -966,6 +980,7 @@ export function ChatBar({
onBlur={() => window.setTimeout(closeTrigger, 80)}
onDragOver={handleInputDragOver}
onDrop={handleInputDrop}
onFocus={() => markActiveComposer('main')}
onInput={handleEditorInput}
onKeyDown={handleEditorKeyDown}
onKeyUp={handleEditorKeyUp}
@ -1033,10 +1048,11 @@ export function ChatBar({
<div
className={cn(
'relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] shadow-composer transition-[border-color,box-shadow] duration-200 ease-out',
COMPOSER_DROP_FADE_CLASS,
'group-focus-within/composer:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)] group-focus-within/composer:shadow-composer-focus',
'group-has-data-[state=open]/composer:border-t-transparent',
'group-has-data-[state=open]/composer:shadow-[0_0.0625rem_0_0.0625rem_color-mix(in_srgb,var(--dt-composer-ring)_calc(35%*var(--composer-ring-strength)),transparent),0_0.5rem_1.5rem_color-mix(in_srgb,var(--shadow-ink)_6%,transparent)]',
dragActive && 'border-midground/70 shadow-composer-focus ring-2 ring-midground/40'
dragActive && COMPOSER_DROP_ACTIVE_CLASS
)}
data-slot="composer-surface"
ref={composerSurfaceRef}
@ -1053,14 +1069,6 @@ export function ChatBar({
'group-focus-within/composer:bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)]'
)}
/>
{dragActive && (
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-3 flex items-center justify-center bg-midground/10 text-sm font-semibold uppercase tracking-[0.18em] text-midground backdrop-blur-[1px]"
>
Drop files to attach
</div>
)}
<div
className={cn(
'relative z-1 flex min-h-0 w-full flex-col gap-(--composer-row-gap) overflow-hidden rounded-[inherit] px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out',
@ -1074,7 +1082,9 @@ export function ChatBar({
<VoicePlaybackActivity />
{queueEdit && editingQueuedPrompt && (
<div className="flex items-center justify-between gap-2 rounded-lg border border-[color-mix(in_srgb,var(--dt-composer-ring)_32%,transparent)] bg-accent/18 px-2 py-1">
<div className="min-w-0 text-[0.7rem] text-muted-foreground/88">Editing queued turn in composer</div>
<div className="min-w-0 text-[0.7rem] text-muted-foreground/88">
Editing queued turn in composer
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
className="h-6 rounded-md px-2 text-[0.68rem]"

View file

@ -0,0 +1,91 @@
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { contextPath } from '@/lib/chat-runtime'
import type { DroppedFile } from '../hooks/use-composer-actions'
import { composerPlainText, escapeHtml, placeCaretEnd, refChipHtml } from './rich-editor'
export function dragHasAttachments(transfer: DataTransfer | null, pathsMime: string) {
if (!transfer) {
return false
}
if (Array.from(transfer.types || []).includes(pathsMime)) {
return true
}
if (Array.from(transfer.types || []).includes('Files')) {
return true
}
return Array.from(transfer.items || []).some(item => item.kind === 'file')
}
export function droppedFileInlineRef(candidate: DroppedFile, cwd: string | null | undefined) {
if (!candidate.path) {
return null
}
const rel = contextPath(candidate.path, cwd || '')
if (candidate.line) {
const { line, lineEnd } = candidate
const range = lineEnd && lineEnd > line ? `${line}-${lineEnd}` : `${line}`
return `@line:${formatRefValue(`${rel}:${range}`)}`
}
const kind = candidate.isDirectory ? 'folder' : 'file'
return `@${kind}:${formatRefValue(rel)}`
}
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly string[]) {
if (!refs.length) {
return null
}
const refsHtml = refs
.map(ref => {
const match = ref.match(/^@([^:]+):(.+)$/)
return match ? refChipHtml(match[1], match[2]) : escapeHtml(ref)
})
.join(' ')
const selection = window.getSelection()
const range =
selection?.rangeCount && editor.contains(selection.getRangeAt(0).commonAncestorContainer)
? selection.getRangeAt(0)
: null
editor.focus({ preventScroll: true })
if (range) {
const beforeRange = range.cloneRange()
beforeRange.selectNodeContents(editor)
beforeRange.setEnd(range.startContainer, range.startOffset)
const beforeContainer = document.createElement('div')
beforeContainer.appendChild(beforeRange.cloneContents())
const afterRange = range.cloneRange()
afterRange.selectNodeContents(editor)
afterRange.setStart(range.endContainer, range.endOffset)
const afterContainer = document.createElement('div')
afterContainer.appendChild(afterRange.cloneContents())
const beforeText = composerPlainText(beforeContainer)
const afterText = composerPlainText(afterContainer)
const needsBeforeSpace = beforeText.length > 0 && !/\s$/.test(beforeText)
const needsAfterSpace = afterText.length === 0 || !/^\s/.test(afterText)
document.execCommand('insertHTML', false, `${needsBeforeSpace ? ' ' : ''}${refsHtml}${needsAfterSpace ? ' ' : ''}`)
} else {
const current = composerPlainText(editor)
placeCaretEnd(editor)
document.execCommand('insertHTML', false, `${current && !/\s$/.test(current) ? ' ' : ''}${refsHtml} `)
}
return composerPlainText(editor)
}

View file

@ -1,7 +1,8 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { ArrowUp, ChevronDown, Pencil, Trash2 } from '@/lib/icons'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { ArrowUp, Pencil, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { QueuedPromptEntry } from '@/store/composer-queue'
@ -20,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]">
@ -29,7 +32,7 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
onClick={() => setCollapsed(open => !open)}
type="button"
>
<ChevronDown className={cn('shrink-0 transition-transform', collapsed && '-rotate-90')} size={14} />
<DisclosureCaret className="shrink-0" open={!collapsed} size="0.875rem" />
<span className="truncate">{entries.length} Queued</span>
</button>

View file

@ -15,7 +15,7 @@ import {
export const RICH_INPUT_SLOT = 'composer-rich-input'
export const REF_RE = /@(file|folder|url|image|tool|line):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
export const REF_RE = /@(file|folder|url|image|tool|line|terminal):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
const ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }

View file

@ -1,8 +1,45 @@
import type { Unstable_TriggerItem } from '@assistant-ui/core'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
import { COMPLETION_DRAWER_CLASS, COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty } from './completion-drawer'
import {
COMPLETION_DRAWER_BELOW_CLASS,
COMPLETION_DRAWER_CLASS,
COMPLETION_DRAWER_ROW_CLASS,
CompletionDrawerEmpty
} from './completion-drawer'
const AT_ICON_BY_TYPE: Record<string, string> = {
diff: 'diff',
file: 'book',
folder: 'folder',
git: 'git-branch',
image: 'file-media',
simple: 'symbol-misc',
staged: 'diff-added',
tool: 'tools',
url: 'globe'
}
function completionIcon(kind: '@' | '/', item: Unstable_TriggerItem) {
if (kind === '/') {
return 'terminal'
}
const meta = item.metadata as { rawText?: string } | undefined
const raw = meta?.rawText || item.label
if (raw.startsWith('@diff')) {
return AT_ICON_BY_TYPE.diff
}
if (raw.startsWith('@staged')) {
return AT_ICON_BY_TYPE.staged
}
return AT_ICON_BY_TYPE[item.type] || AT_ICON_BY_TYPE.simple
}
interface ComposerTriggerPopoverProps {
activeIndex: number
@ -11,6 +48,7 @@ interface ComposerTriggerPopoverProps {
loading: boolean
onHover: (index: number) => void
onPick: (item: Unstable_TriggerItem) => void
placement?: 'bottom' | 'top'
}
export function ComposerTriggerPopover({
@ -19,11 +57,12 @@ export function ComposerTriggerPopover({
kind,
loading,
onHover,
onPick
onPick,
placement = 'top'
}: ComposerTriggerPopoverProps) {
return (
<div
className={COMPLETION_DRAWER_CLASS}
className={placement === 'bottom' ? COMPLETION_DRAWER_BELOW_CLASS : COMPLETION_DRAWER_CLASS}
data-slot="composer-completion-drawer"
data-state="open"
onMouseDown={event => event.preventDefault()}
@ -50,19 +89,19 @@ export function ComposerTriggerPopover({
return (
<button
className={cn(
COMPLETION_DRAWER_ROW_CLASS,
index === activeIndex && 'bg-[color-mix(in_srgb,var(--dt-accent)_70%,transparent)]'
)}
className={cn(COMPLETION_DRAWER_ROW_CLASS, index === activeIndex && 'bg-(--ui-bg-tertiary)')}
data-highlighted={index === activeIndex ? '' : undefined}
key={item.id}
onClick={() => onPick(item)}
onMouseEnter={() => onHover(index)}
type="button"
>
<span className="shrink-0 truncate font-mono font-medium leading-5 text-foreground">{display}</span>
<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>
{description && (
<span className="min-w-0 truncate leading-5 text-muted-foreground/80">{description}</span>
<span className="min-w-0 flex-1 truncate leading-5 text-(--ui-text-tertiary)">{description}</span>
)}
</button>
)

View file

@ -1,8 +1,14 @@
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 { addComposerAttachment, type ComposerAttachment, removeComposerAttachment } from '@/store/composer'
import {
addComposerAttachment,
type ComposerAttachment,
removeComposerAttachment,
setComposerTerminalSelection
} from '@/store/composer'
import { notify, notifyError } from '@/store/notifications'
import type { ImageDetachResponse } from '../../types'
@ -180,19 +186,38 @@ interface ComposerActionsOptions {
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}
/** Add to the main composer and focus it. All sidebar/picker/drop attach paths funnel through here. */
const attachToMain = (attachment: ComposerAttachment) => {
addComposerAttachment(attachment)
requestComposerFocus('main')
}
export function useComposerActions({ activeSessionId, currentCwd, requestGateway }: ComposerActionsOptions) {
const addTextToDraft = useCallback((text: string) => {
requestComposerInsert(text, { mode: 'block' })
}, [])
const addTerminalSelectionAttachment = useCallback((text: string, label = 'selection') => {
const trimmed = text.trim()
const normalizedLabel = label.trim() || 'selection'
const refText = `@terminal:${formatRefValue(normalizedLabel)}`
if (!trimmed) {
return
}
setComposerTerminalSelection(normalizedLabel, trimmed)
requestComposerInsert(refText, { mode: 'inline' })
}, [])
const addContextRefAttachment = useCallback((refText: string, label?: string, detail?: string) => {
let kind: ComposerAttachment['kind'] = 'file'
const kind: ComposerAttachment['kind'] = refText.startsWith('@folder:')
? 'folder'
: refText.startsWith('@url:')
? 'url'
: 'file'
if (refText.startsWith('@folder:')) {
kind = 'folder'
}
if (refText.startsWith('@url:')) {
kind = 'url'
}
addComposerAttachment({
attachToMain({
id: attachmentId(kind, refText),
kind,
label: label || refText.replace(/^@(file|folder|url):/, ''),
@ -216,7 +241,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
for (const path of paths) {
const rel = contextPath(path, currentCwd)
addComposerAttachment({
attachToMain({
id: attachmentId(kind, rel),
kind,
label: pathLabel(path),
@ -237,7 +262,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
const rel = contextPath(filePath, currentCwd)
addComposerAttachment({
attachToMain({
id: attachmentId('file', rel),
kind: 'file',
label: pathLabel(filePath),
@ -264,7 +289,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
path: filePath
}
addComposerAttachment(baseAttachment)
attachToMain(baseAttachment)
try {
const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
@ -361,7 +386,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
const rel = contextPath(folderPath, currentCwd)
addComposerAttachment({
attachToMain({
id: attachmentId('folder', rel),
kind: 'folder',
label: pathLabel(folderPath),
@ -482,7 +507,10 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
return {
addContextRefAttachment,
addTerminalSelectionAttachment,
addTextToDraft,
attachContextFilePath,
attachContextFolderPath,
attachDroppedItems,
attachImageBlob,
attachImagePath,

View file

@ -11,16 +11,17 @@ 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 { ChevronDown } from '@/lib/icons'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
import { cn } from '@/lib/utils'
import { $pinnedSessionIds } from '@/store/layout'
import type { ComposerAttachment } from '@/store/composer'
import { $pinnedSessionIds } from '@/store/layout'
import {
$activeSessionId,
$awaitingResponse,
@ -92,32 +93,30 @@ function ChatHeader({
const sessions = useStore($sessions)
const pinnedSessionIds = useStore($pinnedSessionIds)
const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null
const title = activeStoredSession ? sessionTitle(activeStoredSession) : ''
const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New agent'
const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false
return (
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
<div className="min-w-0 flex-1">
{title && (
<SessionActionsMenu
align="start"
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
onPin={selectedSessionId ? onToggleSelectedPin : undefined}
pinned={selectedIsPinned}
sessionId={selectedSessionId || activeSessionId || ''}
sideOffset={8}
title={title}
<SessionActionsMenu
align="start"
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
onPin={selectedSessionId ? onToggleSelectedPin : undefined}
pinned={selectedIsPinned}
sessionId={selectedSessionId || activeSessionId || ''}
sideOffset={8}
title={title}
>
<Button
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"
>
<Button
className="pointer-events-auto h-7 min-w-0 gap-1.5 rounded-lg px-1 py-0 text-foreground hover:bg-accent/70 data-[state=open]:bg-accent/70 [-webkit-app-region:no-drag]"
type="button"
variant="ghost"
>
<h2 className="max-w-[62vw] truncate text-base font-semibold leading-none tracking-tight">{title}</h2>
<ChevronDown className="shrink-0 text-foreground/75" size={16} />
</Button>
</SessionActionsMenu>
)}
<h2 className="max-w-[52vw] truncate text-[0.75rem] font-medium leading-none">{title}</h2>
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="chevron-down" size="0.8125rem" />
</Button>
</SessionActionsMenu>
</div>
</header>
)
@ -271,10 +270,11 @@ export function ChatView({
return (
<div
className={cn(
'relative flex h-full min-w-0 flex-col overflow-hidden rounded-b-[0.9375rem] bg-transparent',
'relative isolate flex h-full min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background)',
className
)}
>
<Backdrop />
<ChatHeader
activeSessionId={activeSessionId}
isRoutedSessionView={isRoutedSessionView}
@ -285,13 +285,17 @@ export function ChatView({
<NotificationStack />
<div className="relative min-h-0 max-w-full flex-1 overflow-hidden rounded-b-[1.0625rem] bg-transparent 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}
cwd={currentCwd}
gateway={gateway}
intro={showIntro ? { personality: introPersonality, seed: introSeed } : undefined}
loading={threadLoading}
onBranchInNewChat={onBranchInNewChat}
onCancel={onCancel}
sessionId={activeSessionId}
sessionKey={threadKey}
/>
{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 { $composerDraft, setComposerDraft } from '@/store/composer'
import { notify } from '@/store/notifications'
import type { ConsoleEntry, PreviewConsoleState } from './preview-console-state'
@ -186,10 +186,8 @@ export function PreviewConsolePanel({
}
const block = ['Preview console:', '```', ...entries.map(formatLogLine), '```'].join('\n')
const draft = $composerDraft.get()
const next = draft && !draft.endsWith('\n') ? `${draft}\n\n${block}` : `${draft}${block}`
setComposerDraft(next)
requestComposerInsert(block, { mode: 'block', target: 'main' })
consoleState.clearSelection()
notify({
kind: 'success',

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"
@ -296,9 +296,9 @@ function MarkdownPreview({ text }: { text: string }) {
function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) {
return (
<div className="sticky top-0 z-10 flex justify-end border-b border-border/40 bg-background/90 px-3 py-1 backdrop-blur">
<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"
>
@ -379,7 +379,7 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
)
})}
</div>
<div className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3">
<div className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!">
{selection && (
<div
aria-hidden
@ -512,7 +512,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
if (isImage && state.dataUrl) {
return (
<div className="flex h-full w-full items-center justify-center overflow-auto bg-[color-mix(in_srgb,var(--dt-card)_42%,transparent)] p-4">
<div className="flex h-full w-full items-center justify-center overflow-auto bg-transparent p-4">
<img
alt={target.label}
className="max-h-full max-w-full rounded-lg object-contain shadow-sm"
@ -528,7 +528,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
const showRendered = isMarkdown && !renderMarkdownAsSource
return (
<div className="h-full overflow-auto bg-background">
<div className="h-full overflow-auto bg-transparent">
{state.truncated && (
<div className="border-b border-border/60 bg-muted/35 px-3 py-1.5 text-[0.68rem] text-muted-foreground">
Showing first 512 KB.

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,7 @@ import type { PointerEvent as ReactPointerEvent } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
import { Bug, RefreshCw, X } from '@/lib/icons'
import { Bug } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { $previewServerRestart, failPreviewServerRestart, type PreviewTarget } from '@/store/preview'
@ -30,7 +30,6 @@ type PreviewWebview = HTMLElement & {
interface PreviewPaneProps {
embedded?: boolean
onClose: () => void
onRestartServer?: (url: string, context?: string) => Promise<string>
reloadRequest?: number
setTitlebarToolGroup?: SetTitlebarToolGroup
@ -84,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()
@ -117,7 +116,6 @@ const TITLEBAR_GROUP_ID = 'preview'
export function PreviewPane({
embedded = false,
onClose,
onRestartServer,
reloadRequest = 0,
setTitlebarToolGroup,
@ -299,35 +297,13 @@ export function PreviewPane({
onSelect: toggleDevTools
}
]
: []),
{
icon: <RefreshCw className={cn(loading && 'animate-spin')} />,
id: `${TITLEBAR_GROUP_ID}-reload`,
label: 'Reload preview',
onSelect: reloadPreview
},
{
icon: <X />,
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) {
@ -534,7 +510,7 @@ export function PreviewPane({
}
const webview = document.createElement('webview') as PreviewWebview
webview.className = 'flex h-full w-full flex-1 bg-background'
webview.className = 'flex h-full w-full flex-1 bg-transparent'
webview.setAttribute('partition', 'persist:hermes-preview')
webview.setAttribute('src', target.url)
webview.setAttribute('webpreferences', 'contextIsolation=yes,nodeIntegration=no,sandbox=yes')
@ -626,13 +602,13 @@ export function PreviewPane({
}, [appendConsoleEntry, consoleState, isWebPreview, target.url])
return (
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden bg-background text-muted-foreground">
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden bg-transparent text-muted-foreground">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{!embedded && (
<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"
@ -645,12 +621,12 @@ export function PreviewPane({
)}
<div
className="pointer-events-auto relative min-h-0 flex-1 overflow-hidden bg-background"
className="pointer-events-auto relative min-h-0 flex-1 overflow-hidden bg-transparent"
ref={previewContentRef}
>
<div
className={cn(
'absolute inset-0 flex bg-background',
'absolute inset-0 flex bg-transparent',
(!isWebPreview || loadError) && 'pointer-events-none opacity-0'
)}
ref={hostRef}

View file

@ -2,7 +2,7 @@ import { useStore } from '@nanostores/react'
import { useEffect, useMemo } from 'react'
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
import { X } from '@/lib/icons'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
import {
$rightRailActiveTabId,
@ -14,7 +14,7 @@ import {
$filePreviewTabs,
$previewReloadRequest,
$previewTarget,
closeActiveRightRailTab,
closeRightRail,
closeRightRailTab,
type PreviewTarget
} from '@/store/preview'
@ -30,7 +30,7 @@ const INTRINSIC = `clamp(${PREVIEW_RAIL_MIN_WIDTH}, 36vw, 32rem)`
// against --chat-min-width so the chat surface never gets squeezed below it.
// Subtracts the project browser width so preview yields rather than crushing
// the chat when both right-side panes are open.
export const PREVIEW_RAIL_PANE_WIDTH = `min(${INTRINSIC}, max(0px, calc(100vw - var(--pane-chat-sidebar-width) - var(--pane-file-browser-width, 0px) - var(--chat-min-width))))`
export const PREVIEW_RAIL_PANE_WIDTH = `min(${INTRINSIC}, max(0rem, calc(100vw - var(--pane-chat-sidebar-width) - var(--pane-file-browser-width, 0rem) - var(--chat-min-width))))`
interface ChatPreviewRailProps {
onRestartServer?: (url: string, context?: string) => Promise<string>
@ -79,56 +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-border/60 bg-background text-muted-foreground">
<div
className="flex h-(--titlebar-height) shrink-0 overflow-x-auto overflow-y-hidden overscroll-x-contain border-b border-border/60 bg-[color-mix(in_srgb,var(--dt-sidebar-bg)_94%,transparent)] [-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 max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag]',
active
? 'bg-background text-foreground'
: 'border-r border-border/40 text-muted-foreground hover:bg-accent/30 hover:text-foreground'
)}
key={tab.id}
>
{active && <span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-primary/70" />}
<button
aria-selected={active}
className="flex h-full min-w-0 flex-1 items-center truncate pl-3 pr-1.5 text-left outline-none"
onClick={() => selectRightRailTab(tab.id)}
role="tab"
title={tab.label}
type="button"
>
{tab.label}
</button>
<button
aria-label={`Close ${tab.label}`}
return (
<div
className={cn(
'mr-1.5 hidden size-4 shrink-0 place-items-center rounded-sm text-muted-foreground/55 transition-colors hover:bg-accent hover:text-foreground focus-visible:grid group-hover/tab:grid',
active && 'grid'
'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'
)}
onClick={() => closeRightRailTab(tab.id)}
title={`Close ${tab.label}`}
type="button"
key={tab.id}
>
<X className="size-3" />
</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

@ -1,8 +1,28 @@
import {
closestCenter,
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { KbdGroup } from '@/components/ui/kbd'
import {
Sidebar,
SidebarContent,
@ -14,42 +34,119 @@ import {
} from '@/components/ui/sidebar'
import { Skeleton } from '@/components/ui/skeleton'
import type { SessionInfo } from '@/hermes'
import { Brain, ChevronDown, Layers3, MessageCircle, Plus, RefreshCw } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$pinnedSessionIds,
$sidebarAgentsGrouped,
$sidebarOpen,
$sidebarPinsOpen,
$sidebarRecentsOpen,
pinSession,
reorderPinnedSession,
setSidebarAgentsGrouped,
setSidebarPinsOpen,
setSidebarRecentsOpen,
SIDEBAR_SESSIONS_PAGE_SIZE,
unpinSession
} from '@/store/layout'
import { $selectedStoredSessionId, $sessions, $sessionsLoading, $workingSessionIds } from '@/store/session'
import {
$selectedStoredSessionId,
$sessions,
$sessionsLoading,
$sessionsTotal,
$workingSessionIds
} from '@/store/session'
import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes'
import { SidebarPanelLabel } from '../../shell/sidebar-label'
import type { SidebarNavItem } from '../../types'
import { SidebarSessionRow } from './session-row'
import { VirtualSessionList } from './virtual-session-list'
const VIRTUALIZE_THRESHOLD = 25
const SIDEBAR_NAV: SidebarNavItem[] = [
{
id: 'new-session',
label: 'New chat',
icon: Plus,
action: 'new-session'
},
{ id: 'skills', label: 'Skills', icon: Brain, route: SKILLS_ROUTE },
{ id: 'messaging', label: 'Messaging', icon: MessageCircle, route: MESSAGING_ROUTE },
{ id: 'artifacts', label: 'Artifacts', icon: Layers3, route: ARTIFACTS_ROUTE }
{ id: 'new-session', label: 'New agent', icon: props => <Codicon name="robot" {...props} />, action: 'new-session' },
{ id: 'skills', label: 'Skills', icon: props => <Codicon name="symbol-misc" {...props} />, route: SKILLS_ROUTE },
{ id: 'messaging', label: 'Messaging', icon: props => <Codicon name="comment" {...props} />, route: MESSAGING_ROUTE },
{ id: 'artifacts', label: 'Artifacts', icon: props => <Codicon name="files" {...props} />, route: ARTIFACTS_ROUTE }
]
const WORKSPACE_PAGE = 5
const WS_ID_PREFIX = 'workspace:'
const wsId = (id: string) => `${WS_ID_PREFIX}${id}`
const parseWsId = (id: string) => (id.startsWith(WS_ID_PREFIX) ? id.slice(WS_ID_PREFIX.length) : null)
const countLabel = (loaded: number, total: number) => (total > loaded ? `${loaded}/${total}` : String(loaded))
const sessionTime = (s: SessionInfo) => s.last_active || s.started_at || 0
function orderByIds<T>(items: T[], getId: (item: T) => string, orderIds: string[]): T[] {
if (!orderIds.length) {
return items
}
const byId = new Map(items.map(item => [getId(item), item]))
const seen = new Set<string>()
const out: T[] = []
for (const id of orderIds) {
const item = byId.get(id)
if (item) {
out.push(item)
seen.add(id)
}
}
for (const item of items) {
if (!seen.has(getId(item))) {
out.push(item)
}
}
return out
}
const baseName = (path: string) =>
path
.replace(/[/\\]+$/, '')
.split(/[/\\]/)
.filter(Boolean)
.pop()
function workspaceGroupsFor(sessions: SessionInfo[]): SidebarSessionGroup[] {
const groups = new Map<string, SidebarSessionGroup>()
for (const session of sessions) {
const path = session.cwd?.trim() || ''
const id = path || '__no_workspace__'
const label = baseName(path) || path || 'No workspace'
const group = groups.get(id) ?? { id, label, path: path || null, sessions: [] }
group.sessions.push(session)
groups.set(id, group)
}
return [...groups.values()]
}
function useSortableBindings(id: string) {
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id })
return {
dragging: isDragging,
dragHandleProps: { ...attributes, ...listeners },
ref: setNodeRef,
reorderable: true as const,
style: { transform: CSS.Transform.toString(transform), transition }
}
}
interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
currentView: AppView
onNavigate: (item: SidebarNavItem) => void
onRefreshSessions: () => void
onLoadMoreSessions: () => void
onResumeSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void
}
@ -57,58 +154,131 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
export function ChatSidebar({
currentView,
onNavigate,
onRefreshSessions,
onLoadMoreSessions,
onResumeSession,
onDeleteSession
}: ChatSidebarProps) {
const sidebarOpen = useStore($sidebarOpen)
const agentsGrouped = useStore($sidebarAgentsGrouped)
const pinnedSessionIds = useStore($pinnedSessionIds)
const pinsOpen = useStore($sidebarPinsOpen)
const recentsOpen = useStore($sidebarRecentsOpen)
const agentsOpen = useStore($sidebarRecentsOpen)
const selectedSessionId = useStore($selectedStoredSessionId)
const activeSidebarSessionId = currentView === 'chat' ? selectedSessionId : null
const sessions = useStore($sessions)
const sessionsLoading = useStore($sessionsLoading)
const sessionsTotal = useStore($sessionsTotal)
const workingSessionIds = useStore($workingSessionIds)
const [agentOrderIds, setAgentOrderIds] = useState<string[]>([])
const [workspaceOrderIds, setWorkspaceOrderIds] = useState<string[]>([])
const sortedSessions = useMemo(
() =>
[...sessions].sort((a, b) => {
const aTime = a.last_active || a.started_at || 0
const bTime = b.last_active || b.started_at || 0
const activeSidebarSessionId = currentView === 'chat' ? selectedSessionId : null
return bTime - aTime
}),
[sessions]
const dndSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
const sessionsById = useMemo(() => new Map(sessions.map(session => [session.id, session])), [sessions])
const sortedSessions = useMemo(() => [...sessions].sort((a, b) => sessionTime(b) - sessionTime(a)), [sessions])
const sessionsById = useMemo(() => new Map(sessions.map(s => [s.id, s])), [sessions])
const workingSessionIdSet = useMemo(() => new Set(workingSessionIds), [workingSessionIds])
const visiblePinnedIds = pinnedSessionIds.filter(id => sessionsById.has(id))
const visiblePinnedIdSet = new Set(visiblePinnedIds)
const pinnedSessions = visiblePinnedIds
.map(id => sessionsById.get(id))
.filter((session): session is SessionInfo => Boolean(session))
const visiblePinnedIds = useMemo(
() => pinnedSessionIds.filter(id => sessionsById.has(id)),
[pinnedSessionIds, sessionsById]
)
const recentSessions = sortedSessions.filter(session => !visiblePinnedIdSet.has(session.id))
const visiblePinnedIdSet = useMemo(() => new Set(visiblePinnedIds), [visiblePinnedIds])
const pinnedSessions = useMemo(
() => visiblePinnedIds.map(id => sessionsById.get(id)!).filter(Boolean),
[visiblePinnedIds, sessionsById]
)
const unpinnedAgentSessions = useMemo(
() => sortedSessions.filter(s => !visiblePinnedIdSet.has(s.id)),
[sortedSessions, visiblePinnedIdSet]
)
const agentSessions = useMemo(
() => orderByIds(unpinnedAgentSessions, s => s.id, agentOrderIds),
[unpinnedAgentSessions, agentOrderIds]
)
const agentGroups = useMemo(
() => orderByIds(workspaceGroupsFor(agentSessions), g => g.id, workspaceOrderIds),
[agentSessions, workspaceOrderIds]
)
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
const knownSessionTotal = Math.max(sessionsTotal, sortedSessions.length)
const hasMoreSessions = knownSessionTotal > sortedSessions.length
const remainingSessionCount = Math.max(0, knownSessionTotal - sortedSessions.length)
const handlePinnedDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) {
return
}
const newIndex = pinnedSessions.findIndex(s => s.id === String(over.id))
if (newIndex < 0) {
return
}
reorderPinnedSession(String(active.id), newIndex)
}
const handleAgentDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) {
return
}
const activeId = String(active.id)
const overId = String(over.id)
const activeWs = parseWsId(activeId)
const overWs = parseWsId(overId)
if (activeWs && overWs) {
const oldIdx = agentGroups.findIndex(g => g.id === activeWs)
const newIdx = agentGroups.findIndex(g => g.id === overWs)
if (oldIdx < 0 || newIdx < 0) {
return
}
setWorkspaceOrderIds(arrayMove(agentGroups, oldIdx, newIdx).map(g => g.id))
return
}
if (activeWs || overWs) {
return
}
const oldIdx = agentSessions.findIndex(s => s.id === activeId)
const newIdx = agentSessions.findIndex(s => s.id === overId)
if (oldIdx < 0 || newIdx < 0) {
return
}
setAgentOrderIds(arrayMove(agentSessions, oldIdx, newIdx).map(s => s.id))
}
return (
<Sidebar
className={cn(
'relative h-full min-w-0 overflow-hidden border-r border-t-0 border-b-0 border-l-0 text-foreground transition-none [backdrop-filter:blur(1.5rem)_saturate(1.08)]',
'relative h-full min-w-0 overflow-hidden border-r border-t-0 border-b-0 border-l-0 text-foreground transition-none',
sidebarOpen
? 'border-(--sidebar-edge-border) bg-[color-mix(in_srgb,var(--dt-sidebar-bg)_97%,transparent)] opacity-100'
? 'border-(--sidebar-edge-border) bg-(--ui-sidebar-surface-background) opacity-100'
: 'pointer-events-none border-transparent bg-transparent opacity-0'
)}
collapsible="none"
>
<SidebarContent className="gap-0 overflow-hidden bg-transparent px-(--sidebar-content-inline-padding)">
<SidebarGroup className="shrink-0 p-0 pb-2 pt-[calc(var(--titlebar-height)+0.25rem)]">
<SidebarPanelLabel className="pb-1 pt-1">Workspace</SidebarPanelLabel>
<SidebarContent className="gap-0 overflow-hidden bg-transparent px-2.5">
<SidebarGroup className="shrink-0 p-0 pb-2 pt-[calc(var(--titlebar-height)+0.375rem)]">
<SidebarGroupContent>
<SidebarMenu className="gap-px">
{SIDEBAR_NAV.map(item => {
@ -124,9 +294,9 @@ export function ChatSidebar({
<SidebarMenuButton
aria-disabled={!isInteractive}
className={cn(
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-sm font-medium text-sidebar-foreground/78 transition-colors duration-300 ease-out hover:border-[color-mix(in_srgb,var(--dt-border)_60%,transparent)] hover:bg-(--chrome-action-hover) hover:text-foreground hover:transition-none',
'flex h-7 w-full cursor-pointer justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
active &&
'border-[color-mix(in_srgb,var(--dt-midground)_34%,var(--dt-border))] bg-[color-mix(in_srgb,var(--dt-midground)_10%,var(--dt-card))] text-foreground shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_40%,transparent)] hover:border-[color-mix(in_srgb,var(--dt-midground)_34%,var(--dt-border))]!',
'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
!isInteractive &&
'cursor-default hover:border-transparent hover:bg-transparent hover:text-inherit'
)}
@ -135,7 +305,14 @@ export function ChatSidebar({
type="button"
>
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
{sidebarOpen && <span className="max-[46.25rem]:hidden">{item.label}</span>}
{sidebarOpen && (
<>
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">{item.label}</span>
{item.id === 'new-session' && (
<KbdGroup className="ml-auto max-[46.25rem]:hidden" keys={['⇧', 'N']} />
)}
</>
)}
</SidebarMenuButton>
</SidebarMenuItem>
)
@ -145,102 +322,103 @@ export function ChatSidebar({
</SidebarGroup>
{sidebarOpen && showSessionSections && (
<SidebarGroup className="shrink-0 p-0 pb-1">
<SidebarSectionHeader label="Pinned" onToggle={() => setSidebarPinsOpen(!pinsOpen)} open={pinsOpen} />
{pinsOpen && (
<SidebarGroupContent className="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1">
{pinnedSessions.length === 0 && (
<div className="italic flex min-h-7 items-center gap-2 rounded-lg pl-2 text-xs text-muted-foreground/80">
<span>Shift click to pin a chat</span>
</div>
)}
{pinnedSessions.map(session => (
<SidebarSessionRow
isPinned
isSelected={session.id === activeSidebarSessionId}
isWorking={workingSessionIdSet.has(session.id)}
key={session.id}
onDelete={() => onDeleteSession(session.id)}
onPin={() => unpinSession(session.id)}
onResume={() => onResumeSession(session.id)}
session={session}
/>
))}
</SidebarGroupContent>
)}
</SidebarGroup>
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1"
dndSensors={dndSensors}
emptyState={<SidebarPinnedEmptyState />}
label="Pinned"
onDeleteSession={onDeleteSession}
onReorder={handlePinnedDragEnd}
onResumeSession={onResumeSession}
onToggle={() => setSidebarPinsOpen(!pinsOpen)}
onTogglePin={unpinSession}
open={pinsOpen}
pinned
rootClassName="shrink-0 p-0 pb-1"
sessions={pinnedSessions}
sortable={pinnedSessions.length > 1}
workingSessionIdSet={workingSessionIdSet}
/>
)}
{sidebarOpen && showSessionSections && (
<SidebarGroup className="min-h-0 flex-1 p-0">
<SidebarSectionHeader
action={
<Button
aria-label={sessionsLoading ? 'Refreshing sessions' : 'Refresh sessions'}
className="size-4 rounded-sm p-0 text-muted-foreground opacity-10 hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:opacity-100 disabled:opacity-35 [&_svg]:size-3!"
disabled={sessionsLoading}
onClick={event => {
event.stopPropagation()
setSidebarRecentsOpen(true)
onRefreshSessions()
}}
size="icon-xs"
variant="ghost"
>
<RefreshCw className={cn(sessionsLoading && 'animate-spin')} />
</Button>
}
label="Recent chats"
onToggle={() => setSidebarRecentsOpen(!recentsOpen)}
open={recentsOpen}
/>
{recentsOpen && (
<SidebarGroupContent className="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75">
{showSessionSkeletons && <SidebarSessionSkeletons />}
{!showSessionSkeletons && recentSessions.length === 0 && <SidebarAllPinnedState />}
{recentSessions.map(session => (
<SidebarSessionRow
isPinned={false}
isSelected={session.id === activeSidebarSessionId}
isWorking={workingSessionIdSet.has(session.id)}
key={session.id}
onDelete={() => onDeleteSession(session.id)}
onPin={() => pinSession(session.id)}
onResume={() => onResumeSession(session.id)}
session={session}
/>
))}
</SidebarGroupContent>
)}
</SidebarGroup>
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
dndSensors={dndSensors}
emptyState={showSessionSkeletons ? <SidebarSessionSkeletons /> : <SidebarAllPinnedState />}
footer={
!agentsGrouped && !showSessionSkeletons && hasMoreSessions ? (
<SidebarLoadMoreRow
loading={sessionsLoading}
onClick={onLoadMoreSessions}
step={Math.min(SIDEBAR_SESSIONS_PAGE_SIZE, remainingSessionCount)}
/>
) : null
}
forceEmptyState={showSessionSkeletons}
groups={agentsGrouped ? agentGroups : undefined}
headerAction={
<Button
aria-label={agentsGrouped ? 'Show agents as a single list' : 'Group agents by workspace'}
className={cn(
'cursor-pointer text-(--ui-text-tertiary) opacity-0 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100 group-hover/section:opacity-100',
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
)}
onClick={event => {
event.stopPropagation()
setSidebarRecentsOpen(true)
setSidebarAgentsGrouped(!agentsGrouped)
}}
size="icon-xs"
title={agentsGrouped ? 'Ungroup agents' : 'Group by workspace'}
variant="ghost"
>
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
</Button>
}
label="Agents"
labelMeta={countLabel(agentSessions.length, knownSessionTotal)}
onDeleteSession={onDeleteSession}
onReorder={handleAgentDragEnd}
onResumeSession={onResumeSession}
onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
onTogglePin={pinSession}
open={agentsOpen}
pinned={false}
rootClassName="min-h-0 flex-1 p-0"
sessions={agentSessions}
sortable={agentSessions.length > 1}
workingSessionIdSet={workingSessionIdSet}
/>
)}
</SidebarContent>
</Sidebar>
)
}
interface SidebarSectionHeaderProps extends React.ComponentProps<'div'> {
interface SidebarSectionHeaderProps {
label: string
open: boolean
onToggle: () => void
action?: React.ReactNode
meta?: React.ReactNode
}
function SidebarSectionHeader({ label, open, onToggle, action }: SidebarSectionHeaderProps) {
function SidebarSectionHeader({ label, open, onToggle, action, meta }: SidebarSectionHeaderProps) {
return (
<div className="flex shrink-0 items-center justify-between pb-1 pt-1.5">
<div className="group/section flex shrink-0 items-center justify-between pb-1 pt-1.5">
<button
className="group/section-label flex w-fit items-center gap-2 bg-transparent text-left leading-none"
className="group/section-label flex w-fit cursor-pointer items-center gap-1 bg-transparent text-left leading-none"
onClick={onToggle}
type="button"
>
<SidebarPanelLabel>{label}</SidebarPanelLabel>
<ChevronDown
className={cn(
'size-3 text-muted-foreground/70 opacity-0 transition group-hover/section-label:opacity-100',
!open && '-rotate-90'
)}
{meta && <SidebarCount>{meta}</SidebarCount>}
<DisclosureCaret
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/section-label:opacity-100"
open={open}
/>
</button>
{action}
@ -262,7 +440,295 @@ function SidebarSessionSkeletons() {
}
const SidebarAllPinnedState = () => (
<div className="grid min-h-24 place-items-center rounded-lg text-center text-xs text-muted-foreground">
<div className="grid min-h-24 place-items-center rounded-lg text-center text-xs text-(--ui-text-tertiary)">
Everything here is pinned. Unpin a chat to show it in recents.
</div>
)
function SidebarPinnedEmptyState() {
return (
<div className="flex min-h-7 items-center gap-1.5 rounded-lg pl-2 text-[0.75rem] text-(--ui-text-tertiary)">
<span className="grid w-3.5 shrink-0 place-items-center text-(--ui-text-quaternary)">
<Codicon name="pin" size="0.75rem" />
</span>
<span>Shift click to pin a chat</span>
</div>
)
}
interface SidebarSessionGroup {
id: string
label: string
path: null | string
sessions: SessionInfo[]
}
interface SidebarSessionsSectionProps {
label: string
open: boolean
onToggle: () => void
sessions: SessionInfo[]
activeSessionId: null | string
workingSessionIdSet: Set<string>
onResumeSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void
onTogglePin: (sessionId: string) => void
pinned: boolean
rootClassName?: string
contentClassName?: string
emptyState: React.ReactNode
forceEmptyState?: boolean
headerAction?: React.ReactNode
footer?: React.ReactNode
groups?: SidebarSessionGroup[]
labelMeta?: React.ReactNode
sortable?: boolean
onReorder?: (event: DragEndEvent) => void
dndSensors?: ReturnType<typeof useSensors>
}
function SidebarSessionsSection({
label,
open,
onToggle,
sessions,
activeSessionId,
workingSessionIdSet,
onResumeSession,
onDeleteSession,
onTogglePin,
pinned,
rootClassName,
contentClassName,
emptyState,
forceEmptyState = false,
headerAction,
footer,
groups,
labelMeta,
sortable = false,
onReorder,
dndSensors
}: SidebarSessionsSectionProps) {
const showEmptyState = forceEmptyState || sessions.length === 0
const dndActive = sortable && !!onReorder
const renderRow = (session: SessionInfo) => {
const rowProps = {
isPinned: pinned,
isSelected: session.id === activeSessionId,
isWorking: workingSessionIdSet.has(session.id),
onDelete: () => onDeleteSession(session.id),
onPin: () => onTogglePin(session.id),
onResume: () => onResumeSession(session.id),
session
}
return sortable ? (
<SortableSidebarSessionRow key={session.id} {...rowProps} />
) : (
<SidebarSessionRow key={session.id} {...rowProps} />
)
}
const renderRows = (items: SessionInfo[]) => items.map(renderRow)
const renderSessionList = (items: SessionInfo[]) =>
dndActive ? (
<SortableContext items={items.map(s => s.id)} strategy={verticalListSortingStrategy}>
{renderRows(items)}
</SortableContext>
) : (
renderRows(items)
)
const flatVirtualized = !showEmptyState && !groups?.length && sessions.length >= VIRTUALIZE_THRESHOLD
let inner: React.ReactNode
if (showEmptyState) {
inner = emptyState
} else if (groups?.length) {
const groupNodes = groups.map(group =>
dndActive ? (
<SortableSidebarWorkspaceGroup group={group} key={group.id} renderRows={renderSessionList} />
) : (
<SidebarWorkspaceGroup group={group} key={group.id} renderRows={renderSessionList} />
)
)
inner = dndActive ? (
<SortableContext items={groups.map(g => wsId(g.id))} strategy={verticalListSortingStrategy}>
{groupNodes}
</SortableContext>
) : (
groupNodes
)
} else if (flatVirtualized) {
inner = (
<VirtualSessionList
activeSessionId={activeSessionId}
onDeleteSession={onDeleteSession}
onResumeSession={onResumeSession}
onTogglePin={onTogglePin}
pinned={pinned}
sessions={sessions}
sortable={sortable}
workingSessionIdSet={workingSessionIdSet}
/>
)
} else {
inner = renderSessionList(sessions)
}
const body =
dndActive && !showEmptyState ? (
<DndContext collisionDetection={closestCenter} onDragEnd={onReorder} sensors={dndSensors}>
{inner}
</DndContext>
) : (
inner
)
// The virtualizer owns its own scroller, so suppress the wrapper's overflow
// to avoid a double scroll container.
const resolvedContentClassName = cn(contentClassName, flatVirtualized && 'overflow-y-visible')
return (
<SidebarGroup className={rootClassName}>
<SidebarSectionHeader action={headerAction} label={label} meta={labelMeta} onToggle={onToggle} open={open} />
{open && (
<SidebarGroupContent className={resolvedContentClassName}>
{body}
{footer}
</SidebarGroupContent>
)}
</SidebarGroup>
)
}
interface SidebarWorkspaceGroupProps extends React.ComponentProps<'div'> {
group: SidebarSessionGroup
renderRows: (sessions: SessionInfo[]) => React.ReactNode
reorderable?: boolean
dragging?: boolean
dragHandleProps?: React.HTMLAttributes<HTMLElement>
}
function SidebarWorkspaceGroup({
group,
renderRows,
reorderable = false,
dragging = false,
dragHandleProps,
className,
style,
ref,
...rest
}: SidebarWorkspaceGroupProps) {
const [open, setOpen] = useState(true)
const [visibleCount, setVisibleCount] = useState(WORKSPACE_PAGE)
const visibleSessions = group.sessions.slice(0, visibleCount)
const hiddenCount = Math.max(0, group.sessions.length - visibleSessions.length)
const nextCount = Math.min(WORKSPACE_PAGE, hiddenCount)
return (
<div className={cn('grid gap-px', dragging && 'z-10 opacity-60', className)} ref={ref} style={style} {...rest}>
<button
className="group/workspace flex min-h-6 cursor-pointer items-center gap-1 px-2 pt-1 text-left text-[0.6875rem] font-medium text-(--ui-text-tertiary) hover:text-(--ui-text-secondary)"
onClick={() => setOpen(value => !value)}
title={group.path ?? undefined}
type="button"
>
<span className="truncate">{group.label}</span>
<SidebarCount>{group.sessions.length}</SidebarCount>
<DisclosureCaret
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
open={open}
/>
{reorderable && (
<span
{...dragHandleProps}
aria-label={`Reorder workspace ${group.label}`}
className="ml-auto -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing"
onClick={event => event.stopPropagation()}
>
<Codicon
className={cn(
'text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover/workspace:opacity-80 hover:text-(--ui-text-secondary)',
dragging && 'text-(--ui-text-secondary) opacity-100'
)}
name="grabber"
size="0.75rem"
/>
</span>
)}
</button>
{open && (
<>
{renderRows(visibleSessions)}
{hiddenCount > 0 && (
<button
aria-label={`Show ${nextCount} more in ${group.label}`}
className="ml-auto grid size-5 cursor-pointer place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={() => setVisibleCount(count => count + WORKSPACE_PAGE)}
title={`Show ${nextCount} more in ${group.label}`}
type="button"
>
<Codicon name="ellipsis" size="0.75rem" />
</button>
)}
</>
)}
</div>
)
}
interface SortableWorkspaceProps {
group: SidebarSessionGroup
renderRows: (sessions: SessionInfo[]) => React.ReactNode
}
function SortableSidebarWorkspaceGroup(props: SortableWorkspaceProps) {
return <SidebarWorkspaceGroup {...props} {...useSortableBindings(wsId(props.group.id))} />
}
function SidebarCount({ children }: { children: React.ReactNode }) {
return <span className="text-[0.6875rem] font-medium text-(--ui-text-quaternary)">{children}</span>
}
interface SortableSessionRowProps {
session: SessionInfo
isPinned: boolean
isSelected: boolean
isWorking: boolean
onDelete: () => void
onPin: () => void
onResume: () => void
}
function SortableSidebarSessionRow(props: SortableSessionRowProps) {
return <SidebarSessionRow {...props} {...useSortableBindings(props.session.id)} />
}
interface SidebarLoadMoreRowProps {
loading: boolean
onClick: () => void
step: number
}
function SidebarLoadMoreRow({ loading, onClick, step }: SidebarLoadMoreRowProps) {
const label = loading ? 'Loading…' : step > 0 ? `Load ${step} more` : 'Load more'
return (
<button
className="flex min-h-5 cursor-pointer items-center gap-1 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
disabled={loading}
onClick={onClick}
type="button"
>
<Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} />
<span>{label}</span>
</button>
)
}

View file

@ -1,10 +1,10 @@
import { IconBookmark, IconBookmarkFilled, IconCircleX, IconFileDownload, IconPencil } from '@tabler/icons-react'
import { useEffect, useRef, useState } from 'react'
import type * as React from 'react'
import type { ReactNode } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { CopyButton } from '@/components/ui/copy-button'
import { Codicon } from '@/components/ui/codicon'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
import { writeClipboardText } from '@/components/ui/copy-button'
import {
Dialog,
DialogContent,
@ -13,111 +13,144 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { renameSession } from '@/hermes'
import { triggerHaptic } from '@/lib/haptics'
import { exportSession } from '@/lib/session-export'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { setSessions } from '@/store/session'
interface SessionActionsMenuProps extends Pick<
React.ComponentProps<typeof DropdownMenuContent>,
'align' | 'sideOffset'
> {
children: ReactNode
title: string
interface SessionActions {
sessionId: string
title: string
pinned?: boolean
onPin?: () => void
onDelete?: () => void
}
export function SessionActionsMenu({
children,
title,
sessionId,
pinned = false,
onPin,
onDelete,
align = 'end',
sideOffset = 6
}: SessionActionsMenuProps) {
type MenuItem = typeof DropdownMenuItem | typeof ContextMenuItem
interface ItemSpec {
className?: string
disabled: boolean
icon: string
label: string
onSelect: (event: Event) => void
variant?: 'destructive'
}
function useSessionActions({ sessionId, title, pinned = false, onPin, onDelete }: SessionActions) {
const [renameOpen, setRenameOpen] = useState(false)
const items: ItemSpec[] = [
{
disabled: !onPin,
icon: 'pin',
label: pinned ? 'Unpin' : 'Pin',
onSelect: () => {
triggerHaptic('selection')
onPin?.()
}
},
{
disabled: !sessionId,
icon: 'copy',
label: 'Copy ID',
onSelect: event => {
event.preventDefault()
triggerHaptic('selection')
void writeClipboardText(sessionId).catch(err => notifyError(err, 'Could not copy session ID'))
}
},
{
disabled: !sessionId,
icon: 'cloud-download',
label: 'Export',
onSelect: () => {
triggerHaptic('selection')
void exportSession(sessionId, { title })
}
},
{
disabled: !sessionId,
icon: 'edit',
label: 'Rename',
onSelect: () => {
triggerHaptic('selection')
setRenameOpen(true)
}
},
{
className: 'text-destructive focus:text-destructive',
disabled: !onDelete,
icon: 'trash',
label: 'Delete',
onSelect: () => {
triggerHaptic('warning')
onDelete?.()
},
variant: 'destructive'
}
]
const renderItems = (Item: MenuItem) =>
items.map(({ className, disabled, icon, label, onSelect, variant }) => (
<Item className={className} disabled={disabled} key={label} onSelect={onSelect} variant={variant}>
<Codicon name={icon} size="0.875rem" />
<span>{label}</span>
</Item>
))
const renameDialog = (
<RenameSessionDialog currentTitle={title} onOpenChange={setRenameOpen} open={renameOpen} sessionId={sessionId} />
)
return { renameDialog, renderItems }
}
interface SessionActionsMenuProps
extends SessionActions, Pick<React.ComponentProps<typeof DropdownMenuContent>, 'align' | 'sideOffset'> {
children: React.ReactNode
}
export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ...actions }: SessionActionsMenuProps) {
const { renameDialog, renderItems } = useSessionActions(actions)
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent align={align} aria-label={`Actions for ${title}`} className="w-44" sideOffset={sideOffset}>
<DropdownMenuItem
className="gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4"
disabled={!onPin}
onSelect={() => {
triggerHaptic('selection')
onPin?.()
}}
>
{pinned ? <IconBookmarkFilled /> : <IconBookmark />}
<span>{pinned ? 'Unpin' : 'Pin'}</span>
</DropdownMenuItem>
<CopyButton
appearance="menu-item"
className="gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4"
disabled={!sessionId}
errorMessage="Could not copy session ID"
label="Copy ID"
text={sessionId}
/>
<DropdownMenuItem
className="gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4"
disabled={!sessionId}
onSelect={() => {
triggerHaptic('selection')
void exportSession(sessionId, { title })
}}
>
<IconFileDownload />
<span>Export</span>
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4"
disabled={!sessionId}
onSelect={() => {
triggerHaptic('selection')
setRenameOpen(true)
}}
>
<IconPencil />
<span>Rename</span>
</DropdownMenuItem>
<DropdownMenuSeparator className="my-3" />
<DropdownMenuItem
className={cn(
'gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4',
'text-destructive focus:text-destructive'
)}
disabled={!onDelete}
onSelect={() => {
triggerHaptic('warning')
onDelete?.()
}}
variant="destructive"
>
<IconCircleX />
<span>Delete</span>
</DropdownMenuItem>
<DropdownMenuContent
align={align}
aria-label={`Actions for ${actions.title}`}
className="w-40"
sideOffset={sideOffset}
>
{renderItems(DropdownMenuItem)}
</DropdownMenuContent>
</DropdownMenu>
{renameDialog}
</>
)
}
<RenameSessionDialog currentTitle={title} onOpenChange={setRenameOpen} open={renameOpen} sessionId={sessionId} />
interface SessionContextMenuProps extends SessionActions {
children: React.ReactNode
}
export function SessionContextMenu({ children, ...actions }: SessionContextMenuProps) {
const { renameDialog, renderItems } = useSessionActions(actions)
return (
<>
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent aria-label={`Actions for ${actions.title}`} className="w-40">
{renderItems(ContextMenuItem)}
</ContextMenuContent>
</ContextMenu>
{renameDialog}
</>
)
}
@ -160,7 +193,7 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle }: Re
const result = await renameSession(sessionId, next)
const finalTitle = result.title || next || ''
setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s)))
notify({ kind: 'success', message: 'Renamed', durationMs: 2_000 })
notify({ durationMs: 2_000, kind: 'success', message: 'Renamed' })
onOpenChange(false)
} catch (err) {
notifyError(err, 'Rename failed')

View file

@ -1,13 +1,13 @@
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import type { SessionInfo } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { MoreVertical } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { SessionActionsMenu } from './session-actions-menu'
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
session: SessionInfo
@ -17,6 +17,27 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
onDelete: () => void
onPin: () => void
onResume: () => void
reorderable?: boolean
dragging?: boolean
dragHandleProps?: React.HTMLAttributes<HTMLElement>
}
const AGE_TICKS: ReadonlyArray<[number, string]> = [
[86_400_000, 'd'],
[3_600_000, 'h'],
[60_000, 'm']
]
function formatAge(seconds: number): string {
const delta = Math.max(0, Date.now() - seconds * 1000)
for (const [ms, suffix] of AGE_TICKS) {
if (delta >= ms) {
return `${Math.floor(delta / ms)}${suffix}`
}
}
return 'now'
}
export function SidebarSessionRow({
@ -26,59 +47,115 @@ export function SidebarSessionRow({
isWorking,
onDelete,
onPin,
onResume
onResume,
reorderable = false,
dragging = false,
dragHandleProps,
className,
style,
ref,
...rest
}: SidebarSessionRowProps) {
const title = sessionTitle(session)
const age = formatAge(session.last_active || session.started_at)
const handleLabel = `Reorder ${title}`
return (
<div
className={cn(
'group relative grid min-h-7 cursor-pointer grid-cols-[minmax(0,1fr)_1.5rem] items-center rounded-lg transition-colors duration-300 ease-out hover:bg-(--chrome-action-hover) hover:transition-none',
'after:pointer-events-none after:absolute after:inset-y-0 after:right-0 after:z-1 after:w-18 after:rounded-[inherit] after:bg-linear-to-r after:from-transparent after:via-[color-mix(in_srgb,var(--dt-sidebar-bg)_78%,transparent)] after:to-[color-mix(in_srgb,var(--dt-sidebar-bg)_96%,transparent)] after:opacity-0 after:transition-opacity after:duration-200 after:ease-out hover:after:opacity-100 focus-within:after:opacity-100',
isSelected && 'bg-accent',
isWorking && 'text-foreground'
)}
data-working={isWorking ? 'true' : undefined}
>
{isWorking && <span aria-hidden="true" className="arc-border" />}
<button
className="z-0 flex min-w-0 cursor-pointer items-center gap-1.5 bg-transparent py-1 pl-2 text-left"
onClick={event => {
if (event.shiftKey) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
onPin()
return
}
onResume()
}}
type="button"
>
{isWorking && (
<span
aria-label="Session running"
className="relative size-1.5 shrink-0 rounded-full bg-midground shadow-[0_0_0.625rem_color-mix(in_srgb,var(--dt-midground)_65%,transparent)] before:absolute before:inset-0 before:rounded-full before:bg-midground before:opacity-75 before:content-[''] before:animate-ping"
role="status"
/>
<SessionContextMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}>
<div
className={cn(
'group relative grid min-h-[1.625rem] cursor-pointer grid-cols-[minmax(0,1fr)_1.375rem] items-center rounded-md transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none',
isSelected && 'bg-(--ui-row-active-background)',
isWorking && 'text-foreground',
dragging && 'z-10 cursor-grabbing opacity-60 shadow-sm',
className
)}
<span className="truncate text-sm font-medium text-foreground/90">{title}</span>
</button>
<div className="relative z-2 grid w-6 place-items-center">
<SessionActionsMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}>
<Button
aria-label={`Actions for ${title}`}
className="size-6 rounded-md bg-transparent text-transparent transition-colors duration-150 hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-accent data-[state=open]:text-foreground group-hover:text-muted-foreground"
size="icon"
title="Session actions"
variant="ghost"
>
<MoreVertical size={15} />
</Button>
</SessionActionsMenu>
data-working={isWorking ? 'true' : undefined}
ref={ref}
style={style}
{...rest}
>
{isWorking && <span aria-hidden="true" className="arc-border" />}
<button
className="z-0 flex min-w-0 cursor-pointer items-center gap-1.5 bg-transparent py-0.5 pl-2 pr-1 text-left group-hover:pr-12"
onClick={event => {
if (event.shiftKey) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
onPin()
return
}
onResume()
}}
type="button"
>
{reorderable ? (
<span
{...dragHandleProps}
aria-label={handleLabel}
className="relative -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing"
onClick={event => event.stopPropagation()}
>
<SidebarRowDot
className="transition-opacity group-hover:opacity-0 group-focus-within:opacity-0"
isWorking={isWorking}
/>
<Codicon
className={cn(
'absolute text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover:opacity-80 group-focus-within:opacity-80 hover:text-(--ui-text-secondary)',
dragging && 'text-(--ui-text-secondary) opacity-100'
)}
name="grabber"
size="0.75rem"
/>
</span>
) : (
<span className="grid w-3.5 shrink-0 place-items-center overflow-hidden">
<SidebarRowDot isWorking={isWorking} />
</span>
)}
<span className="truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
{title}
</span>
</button>
<div className="relative z-2 grid w-[1.375rem] place-items-center">
{!isWorking && (
<span className="pointer-events-none absolute right-6 top-1/2 min-w-6 -translate-y-1/2 text-right text-[0.625rem] leading-none text-(--ui-text-tertiary) opacity-0 transition-opacity group-hover:opacity-100">
{age}
</span>
)}
<SessionActionsMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}>
<Button
aria-label={`Actions for ${title}`}
className="size-5 rounded-md bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
size="icon"
title="Session actions"
variant="ghost"
>
<Codicon name="ellipsis" size="0.875rem" />
</Button>
</SessionActionsMenu>
</div>
</div>
</div>
</SessionContextMenu>
)
}
function SidebarRowDot({ isWorking, className }: { isWorking: boolean; className?: string }) {
return (
<span
aria-label={isWorking ? 'Session running' : undefined}
className={cn(
'rounded-full',
isWorking
? "relative size-1.5 bg-(--ui-accent) shadow-[0_0_0.625rem_color-mix(in_srgb,var(--ui-accent)_55%,transparent)] before:absolute before:inset-0 before:animate-ping before:rounded-full before:bg-(--ui-accent) before:opacity-70 before:content-['']"
: 'size-1 bg-(--ui-text-quaternary) opacity-80',
className
)}
role={isWorking ? 'status' : undefined}
/>
)
}

View file

@ -0,0 +1,149 @@
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { useVirtualizer } from '@tanstack/react-virtual'
import { type FC, useCallback, useMemo, useRef } from 'react'
import type { SessionInfo } from '@/hermes'
import { cn } from '@/lib/utils'
import { SidebarSessionRow } from './session-row'
interface SessionRowCommonProps {
isPinned: boolean
isSelected: boolean
isWorking: boolean
onDelete: () => void
onPin: () => void
onResume: () => void
}
interface VirtualSessionListProps {
activeSessionId: null | string
className?: string
onDeleteSession: (sessionId: string) => void
onResumeSession: (sessionId: string) => void
onTogglePin: (sessionId: string) => void
pinned: boolean
sessions: SessionInfo[]
sortable: boolean
workingSessionIdSet: Set<string>
}
const ROW_ESTIMATE_PX = 28
const OVERSCAN_ROWS = 12
export const VirtualSessionList: FC<VirtualSessionListProps> = ({
activeSessionId,
className,
onDeleteSession,
onResumeSession,
onTogglePin,
pinned,
sessions,
sortable,
workingSessionIdSet
}) => {
const scrollerRef = useRef<HTMLDivElement | null>(null)
const ids = useMemo(() => sessions.map(s => s.id), [sessions])
const virtualizer = useVirtualizer({
count: sessions.length,
estimateSize: () => ROW_ESTIMATE_PX,
getItemKey: index => sessions[index]?.id ?? index,
getScrollElement: () => scrollerRef.current,
// jsdom-friendly default; the real rect takes over on first observe.
initialRect: { height: 600, width: 240 },
overscan: OVERSCAN_ROWS
})
const virtualItems = virtualizer.getVirtualItems()
const totalSize = virtualizer.getTotalSize()
const paddingTop = virtualItems[0]?.start ?? 0
const paddingBottom = Math.max(0, totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0))
const rows = virtualItems.map(virtualItem => {
const session = sessions[virtualItem.index]
if (!session) {
return null
}
const commonProps: SessionRowCommonProps = {
isPinned: pinned,
isSelected: session.id === activeSessionId,
isWorking: workingSessionIdSet.has(session.id),
onDelete: () => onDeleteSession(session.id),
onPin: () => onTogglePin(session.id),
onResume: () => onResumeSession(session.id)
}
return sortable ? (
<VirtualSortableRow
index={virtualItem.index}
key={session.id}
measureRef={virtualizer.measureElement}
rowProps={commonProps}
session={session}
/>
) : (
<SidebarSessionRow
{...commonProps}
data-index={virtualItem.index}
key={session.id}
ref={virtualizer.measureElement}
session={session}
/>
)
})
const list = (
<div className={cn('relative min-h-0 flex-1 overflow-y-auto overscroll-contain', className)} ref={scrollerRef}>
<div className="grid gap-px" style={{ paddingBottom: `${paddingBottom}px`, paddingTop: `${paddingTop}px` }}>
{rows}
</div>
</div>
)
return sortable ? (
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{list}
</SortableContext>
) : (
list
)
}
interface VirtualSortableRowProps {
index: number
measureRef: (node: Element | null) => void
rowProps: SessionRowCommonProps
session: SessionInfo
}
function VirtualSortableRow({ index, measureRef, rowProps, session }: VirtualSortableRowProps) {
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id: session.id })
// Merge dnd-kit's setNodeRef with the virtualizer's measureElement so
// the row participates in both DnD hit-testing and TanStack height
// measurement.
const refMerged = useCallback(
(node: HTMLDivElement | null) => {
setNodeRef(node)
measureRef(node)
},
[measureRef, setNodeRef]
)
return (
<SidebarSessionRow
{...rowProps}
data-index={index}
dragging={isDragging}
dragHandleProps={{ ...attributes, ...listeners }}
ref={refMerged}
reorderable
session={session}
style={{ transform: CSS.Transform.toString(transform), transition }}
/>
)
}

View file

@ -113,7 +113,7 @@ interface SectionSearchEntry {
}
const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [
{ id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New chat', detail: 'Start a fresh session' },
{ id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New agent', detail: 'Start a fresh session' },
{ id: 'nav-settings', route: SETTINGS_ROUTE, title: 'Settings', detail: 'Configure Hermes desktop' },
{ id: 'nav-skills', route: SKILLS_ROUTE, title: 'Skills', detail: 'Enable and inspect skills' },
{
@ -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,13 +25,12 @@ import {
triggerCronJob,
updateCronJob
} from '@/hermes'
import { AlertTriangle, Clock, Pause, Pencil, Play, Plus, RefreshCw, Search, Trash2, X, Zap } from '@/lib/icons'
import { AlertTriangle, Clock, Pause, Pencil, Play, Trash2, Zap } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { PageSearchShell } from '../page-search-shell'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { titlebarHeaderBaseClass } from '../shell/titlebar'
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
const DEFAULT_DELIVER = 'local'
@ -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]
}
@ -295,14 +307,9 @@ function matchesQuery(job: CronJob, q: string): boolean {
interface CronViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup
setTitlebarToolGroup?: SetTitlebarToolGroup
}
export function CronView({
setStatusbarItemGroup: _setStatusbarItemGroup,
setTitlebarToolGroup,
...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)
@ -329,24 +336,6 @@ export function CronView({
void refresh()
}, [refresh])
useEffect(() => {
if (!setTitlebarToolGroup) {
return
}
setTitlebarToolGroup('cron', [
{
disabled: refreshing,
icon: <RefreshCw className={cn(refreshing && 'animate-spin')} />,
id: 'refresh-cron',
label: refreshing ? 'Refreshing cron jobs' : 'Refresh cron jobs',
onSelect: () => void refresh()
}
])
return () => setTitlebarToolGroup('cron', [])
}, [refresh, refreshing, setTitlebarToolGroup])
const visibleJobs = useMemo(() => {
if (!jobs) {
return []
@ -437,79 +426,65 @@ export function CronView({
}
return (
<section {...props} className="flex h-full min-w-0 flex-col overflow-hidden rounded-b-[0.9375rem] bg-background">
<header className={titlebarHeaderBaseClass}>
<h2 className="pointer-events-auto text-base font-semibold leading-none tracking-tight">Cron</h2>
<span className="pointer-events-auto text-xs text-muted-foreground">
{totalCount === 0 ? 'No scheduled jobs' : `${enabledCount}/${totalCount} active`}
</span>
</header>
<div className="min-h-0 flex-1 overflow-hidden rounded-b-[1.0625rem] border border-border/50 bg-background/85">
<div className="border-b border-border/50 px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
<Plus />
New cron
</Button>
<div className="ml-auto w-full max-w-sm min-w-64">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
className="h-8 rounded-lg pl-8 pr-8 text-sm"
onChange={event => setQuery(event.target.value)}
placeholder="Search cron jobs..."
value={query}
/>
{query && (
<Button
aria-label="Clear search"
className="absolute right-1 top-1/2 h-6 w-6 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setQuery('')}
size="icon"
type="button"
variant="ghost"
>
<X className="size-3.5" />
</Button>
)}
</div>
</div>
<PageSearchShell
{...props}
filters={
<div className="flex flex-wrap items-center justify-center gap-2">
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
<Codicon name="add" />
New cron
</Button>
</div>
}
onSearchChange={setQuery}
searchPlaceholder="Search cron jobs..."
searchTrailingAction={
<Button
aria-label={refreshing ? 'Refreshing cron jobs' : 'Refresh cron jobs'}
className="text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
disabled={refreshing}
onClick={() => void refresh()}
size="icon-xs"
title={refreshing ? 'Refreshing cron jobs' : 'Refresh cron jobs'}
type="button"
variant="ghost"
>
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
</Button>
}
searchValue={query}
>
{!jobs ? (
<PageLoader label="Loading cron jobs..." />
) : visibleJobs.length === 0 ? (
<EmptyState
actionLabel={totalCount === 0 ? 'Create first cron' : undefined}
description={
totalCount === 0
? 'Schedule a prompt to run on a cron expression. Hermes will run it and deliver results to the destination you pick.'
: 'Try a broader search query.'
}
onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined}
title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'}
/>
) : (
<div className="h-full overflow-y-auto px-4 py-3">
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
{visibleJobs.map(job => (
<CronJobRow
busy={busyJobId === job.id}
job={job}
key={job.id}
onDelete={() => setPendingDelete(job)}
onEdit={() => setEditor({ mode: 'edit', job })}
onPauseResume={() => void handlePauseResume(job)}
onTrigger={() => void handleTrigger(job)}
/>
))}
</div>
</div>
{!jobs ? (
<PageLoader label="Loading cron jobs..." />
) : visibleJobs.length === 0 ? (
<EmptyState
actionLabel={totalCount === 0 ? 'Create first cron' : undefined}
description={
totalCount === 0
? 'Schedule a prompt to run on a cron expression. Hermes will run it and deliver results to the destination you pick.'
: 'Try a broader search query.'
}
onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined}
title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'}
/>
) : (
<div className="h-full overflow-y-auto px-4 py-3">
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
{visibleJobs.map(job => (
<CronJobRow
busy={busyJobId === job.id}
job={job}
key={job.id}
onDelete={() => setPendingDelete(job)}
onEdit={() => setEditor({ mode: 'edit', job })}
onPauseResume={() => void handlePauseResume(job)}
onTrigger={() => void handleTrigger(job)}
/>
))}
</div>
</div>
)}
</div>
)}
<div className="hidden">{totalCount === 0 ? 'No scheduled jobs' : `${enabledCount}/${totalCount} active`}</div>
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
@ -520,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}
@ -536,7 +512,7 @@ export function CronView({
</DialogFooter>
</DialogContent>
</Dialog>
</section>
</PageSearchShell>
)
}
@ -618,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>
)
@ -632,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>
)
@ -656,7 +635,7 @@ function EmptyState({
<p className="text-xs text-muted-foreground">{description}</p>
{actionLabel && onAction && (
<Button className="mt-2" onClick={onAction} size="sm">
<Plus />
<Codicon name="add" />
{actionLabel}
</Button>
)}

View file

@ -9,9 +9,11 @@ import { useSkinCommand } from '@/themes/use-skin-command'
import { formatRefValue } from '../components/assistant-ui/directive-text'
import { getSessionMessages, listSessions } from '../hermes'
import { toChatMessages } from '../lib/chat-messages'
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
import {
$pinnedSessionIds,
$sessionsLimit,
bumpSessionsLimit,
FILE_BROWSER_DEFAULT_WIDTH,
FILE_BROWSER_MAX_WIDTH,
FILE_BROWSER_MIN_WIDTH,
@ -33,7 +35,8 @@ import {
setCurrentProvider,
setMessages,
setSessions,
setSessionsLoading
setSessionsLoading,
setSessionsTotal
} from '../store/session'
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates'
@ -46,10 +49,10 @@ import {
PREVIEW_RAIL_PANE_WIDTH
} from './chat/right-rail'
import { ChatSidebar } from './chat/sidebar'
import { FileBrowserPane } from './file-browser'
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
import { ModelPickerOverlay } from './model-picker-overlay'
import { RightSidebarPane } from './right-sidebar'
import { NEW_CHAT_ROUTE, routeSessionId, sessionRoute } from './routes'
import { useContextSuggestions } from './session/hooks/use-context-suggestions'
import { useCwdActions } from './session/hooks/use-cwd-actions'
@ -182,10 +185,12 @@ export function DesktopController() {
setSessionsLoading(true)
try {
const result = await listSessions(50)
const limit = $sessionsLimit.get()
const result = await listSessions(limit)
if (refreshSessionsRequestRef.current === requestId) {
setSessions(result.sessions)
setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length)
}
} finally {
if (refreshSessionsRequestRef.current === requestId) {
@ -194,6 +199,11 @@ export function DesktopController() {
}
}, [])
const loadMoreSessions = useCallback(() => {
bumpSessionsLimit()
void refreshSessions()
}, [refreshSessions])
const toggleSelectedPin = useCallback(() => {
const sessionId = $selectedStoredSessionId.get()
@ -210,10 +220,27 @@ export function DesktopController() {
const { gatewayLogLines, inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, requestGateway)
const { browseSessionCwd, changeSessionCwd, refreshProjectBranch } = useCwdActions({
const updateActiveSessionRuntimeInfo = useCallback(
(info: { branch?: string; cwd?: string }) => {
const sessionId = activeSessionIdRef.current
if (!sessionId) {
return
}
updateSessionState(sessionId, state => ({
...state,
branch: info.branch ?? state.branch,
cwd: info.cwd ?? state.cwd
}))
},
[activeSessionIdRef, updateSessionState]
)
const { changeSessionCwd, refreshProjectBranch } = useCwdActions({
activeSessionId,
activeSessionIdRef,
currentCwd,
onSessionRuntimeInfo: updateActiveSessionRuntimeInfo,
requestGateway
})
@ -253,7 +280,7 @@ export function DesktopController() {
runtimeSessionId,
state => ({
...state,
messages: toChatMessages(latest.messages)
messages: preserveLocalAssistantErrors(toChatMessages(latest.messages), state.messages)
}),
storedSessionId
)
@ -315,6 +342,31 @@ export function DesktopController() {
updateSessionState
})
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null
const editing =
target?.isContentEditable ||
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement
if (editing || event.defaultPrevented || event.repeat || event.altKey || event.ctrlKey || event.metaKey) {
return
}
if (event.shiftKey && event.code === 'KeyN') {
event.preventDefault()
startFreshSessionDraft()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [startFreshSessionDraft])
const composer = useComposerActions({
activeSessionId,
currentCwd,
@ -388,7 +440,6 @@ export function DesktopController() {
const { leftStatusbarItems, statusbarItems } = useStatusbarItems({
agentsOpen,
browseSessionCwd,
commandCenterOpen,
extraLeftItems: statusbarItemGroups.flat.left,
extraRightItems: statusbarItemGroups.flat.right,
@ -405,8 +456,8 @@ export function DesktopController() {
<ChatSidebar
currentView={currentView}
onDeleteSession={sessionId => void removeSession(sessionId)}
onLoadMoreSessions={loadMoreSessions}
onNavigate={selectSidebarItem}
onRefreshSessions={() => void refreshSessions()}
onResumeSession={sessionId => navigate(sessionRoute(sessionId))}
/>
)
@ -497,8 +548,10 @@ export function DesktopController() {
return (
<AppShell
commandCenterOpen={commandCenterOpen}
leftStatusbarItems={leftStatusbarItems}
leftTitlebarTools={titlebarToolGroups.flat.left}
onOpenSearch={() => openCommandCenterSection('sessions')}
onOpenSettings={openSettings}
overlays={overlays}
statusbarItems={statusbarItems}
@ -521,7 +574,7 @@ export function DesktopController() {
<Route
element={
<Suspense fallback={null}>
<SkillsView setStatusbarItemGroup={setStatusbarItemGroup} setTitlebarToolGroup={setTitlebarToolGroup} />
<SkillsView setStatusbarItemGroup={setStatusbarItemGroup} />
</Suspense>
}
path="skills"
@ -529,10 +582,7 @@ export function DesktopController() {
<Route
element={
<Suspense fallback={null}>
<MessagingView
setStatusbarItemGroup={setStatusbarItemGroup}
setTitlebarToolGroup={setTitlebarToolGroup}
/>
<MessagingView setStatusbarItemGroup={setStatusbarItemGroup} />
</Suspense>
}
path="messaging"
@ -540,10 +590,7 @@ export function DesktopController() {
<Route
element={
<Suspense fallback={null}>
<ArtifactsView
setStatusbarItemGroup={setStatusbarItemGroup}
setTitlebarToolGroup={setTitlebarToolGroup}
/>
<ArtifactsView setStatusbarItemGroup={setStatusbarItemGroup} />
</Suspense>
}
path="artifacts"
@ -551,7 +598,7 @@ export function DesktopController() {
<Route
element={
<Suspense fallback={null}>
<CronView setStatusbarItemGroup={setStatusbarItemGroup} setTitlebarToolGroup={setTitlebarToolGroup} />
<CronView setStatusbarItemGroup={setStatusbarItemGroup} />
</Suspense>
}
path="cron"
@ -590,6 +637,7 @@ export function DesktopController() {
</Pane>
<Pane
defaultOpen={false}
disabled={!chatOpen}
id="file-browser"
maxWidth={FILE_BROWSER_MAX_WIDTH}
minWidth={FILE_BROWSER_MIN_WIDTH}
@ -597,7 +645,12 @@ export function DesktopController() {
side="right"
width={FILE_BROWSER_DEFAULT_WIDTH}
>
<FileBrowserPane onActivateFile={composer.attachContextFilePath} onChangeCwd={changeSessionCwd} />
<RightSidebarPane
onActivateFile={composer.attachContextFilePath}
onActivateFolder={composer.attachContextFolderPath}
onAddTerminalSelection={composer.addTerminalSelectionAttachment}
onChangeCwd={changeSessionCwd}
/>
</Pane>
</AppShell>
)

View file

@ -1,173 +0,0 @@
import { useStore } from '@nanostores/react'
import { Button } from '@/components/ui/button'
import { FadeText } from '@/components/ui/fade-text'
import { FolderOpen, RefreshCw } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import { setCurrentSessionPreviewTarget } from '@/store/preview'
import { $currentCwd } from '@/store/session'
import { SidebarPanelLabel } from '../shell/sidebar-label'
import { ProjectTree } from './tree'
import { useProjectTree } from './use-project-tree'
interface FileBrowserPaneProps {
/** Activates a file row — drops the path into the composer as `@file:` ref. */
onActivateFile: (path: string) => void
onChangeCwd: (path: string) => Promise<void> | void
}
export function FileBrowserPane({ onActivateFile, onChangeCwd }: FileBrowserPaneProps) {
const currentCwd = useStore($currentCwd).trim()
const hasCwd = currentCwd.length > 0
const cwdName = hasCwd
? (currentCwd
.split(/[\\/]+/)
.filter(Boolean)
.pop() ?? currentCwd)
: 'No folder selected'
const { data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } = useProjectTree(currentCwd)
const chooseFolder = async () => {
const selected = await window.hermesDesktop?.selectPaths({
title: 'Change working directory',
defaultPath: hasCwd ? currentCwd : undefined,
directories: true,
multiple: false
})
if (selected?.[0]) {
await onChangeCwd(selected[0])
}
}
const previewFile = async (path: string) => {
try {
const preview = await normalizeOrLocalPreviewTarget(path, currentCwd || undefined)
if (!preview) {
throw new Error(`Could not preview ${path}`)
}
setCurrentSessionPreviewTarget(preview, 'file-browser', path)
} catch (error) {
notifyError(error, 'Preview unavailable')
}
}
return (
<aside
aria-label="File browser"
className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-border/60 bg-[color-mix(in_srgb,var(--dt-sidebar-bg)_94%,transparent)] px-(--sidebar-content-inline-padding) pt-[calc(var(--titlebar-height)-0.625rem)] text-muted-foreground [backdrop-filter:blur(1.5rem)_saturate(1.08)]"
>
<header className="group/project-header shrink-0 pb-1 pt-0">
<div className="flex items-center gap-1.5">
<FadeText
className="flex flex-1 items-center px-2 pb-1 pt-1"
title={hasCwd ? currentCwd : 'No folder selected'}
>
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
</FadeText>
<Button
aria-label="Change working directory"
className="pointer-events-none size-6 shrink-0 text-muted-foreground/75 opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100"
onClick={() => void chooseFolder()}
size="icon"
title="Change working directory"
variant="ghost"
>
<FolderOpen className="size-3.5" />
</Button>
<Button
aria-label="Refresh tree"
className="pointer-events-none size-6 shrink-0 text-muted-foreground/75 opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100"
disabled={!hasCwd || rootLoading}
onClick={() => void refreshRoot()}
size="icon"
title="Refresh tree"
variant="ghost"
>
<RefreshCw className={cn('size-3.5', rootLoading && 'animate-spin')} />
</Button>
</div>
</header>
<FileTreeBody
cwd={currentCwd}
data={data}
error={rootError}
loading={rootLoading}
onActivateFile={onActivateFile}
onLoadChildren={loadChildren}
onNodeOpenChange={setNodeOpen}
onPreviewFile={previewFile}
openState={openState}
/>
</aside>
)
}
interface FileTreeBodyProps {
cwd: string
data: ReturnType<typeof useProjectTree>['data']
error: string | null
loading: boolean
onActivateFile: (path: string) => void
onLoadChildren: (id: string) => void | Promise<void>
onNodeOpenChange: (id: string, open: boolean) => void
onPreviewFile?: (path: string) => void
openState: ReturnType<typeof useProjectTree>['openState']
}
function FileTreeBody({
cwd,
data,
error,
loading,
onActivateFile,
onLoadChildren,
onNodeOpenChange,
onPreviewFile,
openState
}: FileTreeBodyProps) {
if (!cwd) {
return <EmptyState body="Set a working directory from the status bar to browse files." title="No project" />
}
if (error) {
return <EmptyState body={`Could not read this folder (${error}).`} title="Unreadable" />
}
if (loading && data.length === 0) {
return <EmptyState body="Reading project…" title="Loading" />
}
if (data.length === 0) {
return <EmptyState body="This folder is empty." title="Empty" />
}
return (
<ProjectTree
data={data}
onActivateFile={onActivateFile}
onLoadChildren={onLoadChildren}
onNodeOpenChange={onNodeOpenChange}
onPreviewFile={onPreviewFile}
openState={openState}
/>
)
}
function EmptyState({ body, title }: { body: string; title: string }) {
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-1 px-4 text-center">
<div className="text-[0.7rem] font-semibold uppercase tracking-[0.07em] text-muted-foreground/75">{title}</div>
<div className="text-[0.68rem] leading-relaxed text-muted-foreground/65">{body}</div>
</div>
)
}

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,18 +13,16 @@ import {
type MessagingPlatformInfo,
updateMessagingPlatform
} from '@/hermes'
import { AlertTriangle, ChevronDown, ExternalLink, RefreshCw, Save, Trash2 } from '@/lib/icons'
import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { PageSearchShell } from '../page-search-shell'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { titlebarHeaderBaseClass } from '../shell/titlebar'
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
interface MessagingViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup
setTitlebarToolGroup?: SetTitlebarToolGroup
}
type EditMap = Record<string, Record<string, string>>
@ -207,13 +206,10 @@ function fieldCopy(field: MessagingEnvVarInfo) {
}
}
export function MessagingView({
setStatusbarItemGroup: _setStatusbarItemGroup,
setTitlebarToolGroup,
...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('')
const [refreshing, setRefreshing] = useState(false)
const [saving, setSaving] = useState<string | null>(null)
const platformIds = useMemo(() => platforms?.map(p => p.id) ?? [], [platforms])
@ -263,24 +259,6 @@ export function MessagingView({
}
}, [refreshPlatforms])
useEffect(() => {
if (!setTitlebarToolGroup) {
return
}
setTitlebarToolGroup('messaging', [
{
disabled: refreshing,
icon: <RefreshCw className={cn(refreshing && 'animate-spin')} />,
id: 'refresh-messaging',
label: refreshing ? 'Refreshing messaging' : 'Refresh messaging',
onSelect: () => void refreshPlatforms()
}
])
return () => setTitlebarToolGroup('messaging', [])
}, [refreshPlatforms, refreshing, setTitlebarToolGroup])
const selected = useMemo(() => {
if (!platforms) {
return null
@ -289,7 +267,23 @@ export function MessagingView({
return platforms.find(platform => platform.id === selectedId) || platforms[0] || null
}, [platforms, selectedId])
const enabledCount = platforms?.filter(platform => platform.enabled).length || 0
const visiblePlatforms = useMemo(() => {
if (!platforms) {
return []
}
const q = query.trim().toLowerCase()
if (!q) {
return platforms
}
return platforms.filter(platform =>
[platform.id, platform.name, platform.description, platform.state]
.filter(Boolean)
.some(value => String(value).toLowerCase().includes(q))
)
}, [platforms, query])
async function handleToggle(platform: MessagingPlatformInfo, enabled: boolean) {
setSaving(`enabled:${platform.id}`)
@ -367,58 +361,55 @@ export function MessagingView({
}
return (
<section {...props} className="flex h-full min-w-0 flex-col overflow-hidden rounded-b-[0.9375rem] bg-background">
<header className={titlebarHeaderBaseClass}>
<h2 className="pointer-events-auto text-base font-semibold leading-none tracking-tight">Messaging</h2>
<span className="pointer-events-auto text-xs text-muted-foreground">
{enabledCount === 0 ? 'No platforms enabled' : `${enabledCount} enabled`}
</span>
</header>
<PageSearchShell
{...props}
onSearchChange={setQuery}
searchPlaceholder="Search messaging..."
searchTrailingAction={null}
searchValue={query}
>
{!platforms ? (
<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>
<div className="min-h-0 flex-1 overflow-hidden rounded-b-[1.0625rem] border border-border/50 bg-background/85">
{!platforms ? (
<PageLoader label="Loading messaging platforms..." />
) : (
<div className="grid h-full min-h-0 grid-cols-1 lg:grid-cols-[16rem_minmax(0,1fr)]">
<aside className="min-h-0 overflow-y-auto border-b border-border/50 p-2 lg:border-b-0 lg:border-r">
<ul className="space-y-1">
{platforms.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>
)}
</div>
</section>
<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>
)
}
@ -434,15 +425,17 @@ function PlatformRow({
return (
<button
className={cn(
'flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left transition-colors',
active ? 'bg-accent text-foreground' : 'text-foreground/85 hover:bg-accent/60'
'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'
)}
onClick={onSelect}
type="button"
>
<PlatformAvatar platformId={platform.id} platformName={platform.name} />
<span className="flex min-w-0 flex-1 items-center justify-between gap-2">
<span className="truncate text-sm font-medium">{platform.name}</span>
<span className="truncate text-[length:var(--conversation-text-font-size)] font-normal">{platform.name}</span>
<StatusDot tone={stateTone(platform)} />
</span>
</button>
@ -453,8 +446,8 @@ function PlatformAvatar({ platformId, platformName }: { platformId: string; plat
return (
<span
className={cn(
'inline-flex size-7 shrink-0 items-center justify-center rounded-md text-sm font-semibold',
PLATFORM_TINTS[platformId] || 'bg-muted text-muted-foreground'
'inline-flex size-6 shrink-0 items-center justify-center rounded-md text-[length:var(--conversation-caption-font-size)] font-medium',
PLATFORM_TINTS[platformId] || 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)'
)}
>
{platformName.charAt(0).toUpperCase()}
@ -491,12 +484,14 @@ function PlatformDetail({
return (
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-2xl space-y-7 px-6 py-6">
<header className="flex items-start gap-4">
<div className="mx-auto max-w-2xl space-y-5 px-5 py-4">
<header className="flex items-start gap-3">
<PlatformAvatar platformId={platform.id} platformName={platform.name} />
<div className="min-w-0 flex-1">
<h3 className="text-xl font-semibold tracking-tight">{platform.name}</h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground">{platform.description}</p>
<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>
<div className="mt-3 flex flex-wrap items-center gap-2">
<StatePill tone={stateTone(platform)}>{stateLabel(platform.state)}</StatePill>
<SetupPill active={platform.configured}>
@ -509,7 +504,7 @@ function PlatformDetail({
</header>
{platform.error_message && (
<div className="flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-xs leading-5 text-destructive">
<div className="flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{platform.error_message}</span>
</div>
@ -517,7 +512,9 @@ function PlatformDetail({
<section>
<SectionTitle>Get your credentials</SectionTitle>
<p className="mt-1 text-sm leading-6 text-muted-foreground">{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">
@ -543,7 +540,7 @@ function PlatformDetail({
/>
))
) : (
<p className="text-sm leading-6 text-muted-foreground">
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
This platform does not need a token here. Use the setup guide above, then enable it below.
</p>
)}
@ -576,7 +573,7 @@ function PlatformDetail({
type="button"
>
<span>Advanced ({hiddenCount})</span>
<ChevronDown className={cn('size-3.5 transition-transform', !showAdvanced && '-rotate-90')} />
<DisclosureCaret open={showAdvanced} size="0.875rem" />
</button>
{showAdvanced && (
<div className="mt-3 space-y-4">
@ -597,9 +594,9 @@ function PlatformDetail({
</div>
</div>
<footer className="border-t border-border/50 bg-background/95 px-6 py-3 backdrop-blur">
<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-lg border border-border/50 bg-muted/25 px-3 py-1.5 text-sm">
<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
aria-label={platform.enabled ? `Disable ${platform.name}` : `Enable ${platform.name}`}
checked={platform.enabled}

View file

@ -1,8 +1,9 @@
import type { RefObject } from 'react'
import type { ReactNode, RefObject } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Input } from '@/components/ui/input'
import { Loader2, Search, X } from '@/lib/icons'
import { Loader2, Search } from '@/lib/icons'
import { cn } from '@/lib/utils'
interface OverlaySearchInputProps {
@ -14,6 +15,7 @@ interface OverlaySearchInputProps {
loading?: boolean
onClear?: () => void
inputRef?: RefObject<HTMLInputElement | null>
trailingAction?: ReactNode
}
export function OverlaySearchInput({
@ -24,33 +26,52 @@ export function OverlaySearchInput({
inputClassName,
loading = false,
onClear,
inputRef
inputRef,
trailingAction
}: OverlaySearchInputProps) {
const clear = onClear ?? (() => onChange(''))
const hasTrailing = Boolean(trailingAction)
return (
<div className={cn('relative', containerClassName)}>
<Search className="pointer-events-none absolute left-3 top-1/2 z-1 size-3.5 -translate-y-1/2 text-muted-foreground/80" />
<Input
className={cn('relative z-0 h-8 rounded-lg py-2 pl-8 pr-12 text-sm', inputClassName)}
className={cn(
'relative z-0 h-8 rounded-lg py-2 pl-8 text-[length:var(--conversation-text-font-size)]',
hasTrailing || loading || value ? 'pr-16' : 'pr-8',
inputClassName
)}
onChange={event => onChange(event.target.value)}
placeholder={placeholder}
ref={inputRef}
value={value}
/>
{loading ? (
<Loader2 className="pointer-events-none absolute right-3 top-1/2 z-1 size-3.5 -translate-y-1/2 animate-spin text-muted-foreground/70" />
) : value ? (
<Button
aria-label="Clear search"
className="absolute right-1.5 top-1/2 z-1 -translate-y-1/2 text-muted-foreground/85 hover:bg-accent/60 hover:text-foreground"
onClick={clear}
size="icon-xs"
variant="ghost"
>
<X className="size-3.5" />
</Button>
) : null}
<div className="absolute right-1.5 top-1/2 z-1 flex -translate-y-1/2 items-center gap-0.5">
{trailingAction}
{loading ? (
<Loader2 className="pointer-events-none size-3.5 animate-spin text-muted-foreground/70" />
) : value ? (
<Button
aria-label="Clear search"
className="text-muted-foreground/85 hover:bg-accent/60 hover:text-foreground"
onClick={clear}
size="icon-xs"
variant="ghost"
>
<Codicon name="close" size="0.875rem" />
</Button>
) : null}
</div>
</div>
)
}
export function PageSearchInput(props: OverlaySearchInputProps) {
return (
<OverlaySearchInput
{...props}
containerClassName={cn('mx-auto w-[min(36rem,calc(100%-2rem))] min-w-0', props.containerClassName)}
inputClassName={cn('h-8 rounded-lg py-2 pl-8', props.inputClassName)}
/>
)
}

View file

@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import type { LucideIcon } from '@/lib/icons'
import type { IconComponent } from '@/lib/icons'
import { cn } from '@/lib/utils'
interface OverlaySplitLayoutProps {
@ -20,7 +20,7 @@ interface OverlayMainProps {
interface OverlayNavItemProps {
active: boolean
icon: LucideIcon
icon: IconComponent
label: string
onClick: () => void
trailing?: ReactNode
@ -30,7 +30,7 @@ export function OverlaySplitLayout({ children, className }: OverlaySplitLayoutPr
return (
<div
className={cn(
'grid h-full min-h-0 flex-1 grid-cols-[13rem_minmax(0,1fr)] overflow-hidden rounded-[0.95rem] border border-[color-mix(in_srgb,var(--dt-border)_58%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_94%,transparent)] shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_46%,transparent),0_0.5rem_1.5rem_-1rem_color-mix(in_srgb,#000_22%,transparent)] max-[760px]:grid-cols-1 dark:border-[color-mix(in_srgb,var(--dt-border)_36%,transparent)] dark:shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_10%,transparent),0_0.5rem_1.5rem_-1rem_color-mix(in_srgb,#000_45%,transparent)]',
'grid h-full min-h-0 flex-1 grid-cols-[13rem_minmax(0,1fr)] overflow-hidden bg-transparent max-[47.5rem]:grid-cols-1',
className
)}
>
@ -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 border-r border-[color-mix(in_srgb,var(--dt-border)_48%,transparent)] bg-[color-mix(in_srgb,var(--dt-muted)_55%,var(--dt-card))] px-3.5 py-4',
'flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-(--ui-sidebar-surface-background) px-2.5 py-3',
className
)}
>
@ -54,7 +54,7 @@ export function OverlaySidebar({ children, className }: OverlaySidebarProps) {
export function OverlayMain({ children, className }: OverlayMainProps) {
return (
<main className={cn('flex min-h-0 flex-1 flex-col overflow-hidden bg-transparent p-4', className)}>{children}</main>
<main className={cn('flex min-h-0 flex-1 flex-col overflow-hidden bg-transparent p-3', className)}>{children}</main>
)
}
@ -62,10 +62,10 @@ export function OverlayNavItem({ active, icon: Icon, label, onClick, trailing }:
return (
<button
className={cn(
'flex h-8 w-full items-center justify-start gap-2 rounded-md border px-2.5 text-left text-sm font-medium transition-colors',
'flex h-7 w-full items-center justify-start gap-2 rounded-md border px-2 text-left text-[length:var(--conversation-text-font-size)] font-normal transition-colors',
active
? 'border-[color-mix(in_srgb,var(--dt-primary)_34%,var(--dt-border))] bg-[color-mix(in_srgb,var(--dt-primary)_10%,var(--dt-card))] text-foreground shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_40%,transparent)]'
: 'border-transparent bg-transparent text-foreground/78 hover:border-[color-mix(in_srgb,var(--dt-border)_60%,transparent)] hover:bg-[color-mix(in_srgb,var(--dt-card)_78%,transparent)] hover:text-foreground'
? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary) text-foreground'
: 'border-transparent bg-transparent text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
onClick={onClick}
type="button"

View file

@ -1,8 +1,8 @@
import { type ReactNode, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { triggerHaptic } from '@/lib/haptics'
import { X } from '@/lib/icons'
import { cn } from '@/lib/utils'
interface OverlayViewProps {
@ -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')
@ -46,7 +48,7 @@ export function OverlayView({
return (
<div
className="fixed inset-0 z-50 bg-black/22 p-3 backdrop-blur-[2px] sm:p-8"
className="fixed inset-0 z-50 bg-black/22 p-3 backdrop-blur-[0.125rem] sm:p-6"
onClick={event => {
if (event.target === event.currentTarget) {
closeOverlay()
@ -56,7 +58,7 @@ export function OverlayView({
>
<div
className={cn(
'relative flex h-full min-h-0 flex-col overflow-hidden rounded-2xl border border-[color-mix(in_srgb,var(--dt-border)_60%,transparent)] bg-background/96 shadow-[0_1.5rem_4rem_-2rem_color-mix(in_srgb,#000_40%,transparent)]',
'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
)}
>
@ -69,12 +71,12 @@ export function OverlayView({
<Button
aria-label={closeLabel}
className="pointer-events-auto absolute right-3.75 top-[calc(0.1875rem+var(--titlebar-height)/2)] h-7 w-7 -translate-y-1/2 rounded-lg text-muted-foreground hover:bg-accent/70 hover:text-foreground [-webkit-app-region:no-drag]"
className="pointer-events-auto absolute right-3 top-[calc(0.1875rem+var(--titlebar-height)/2)] h-7 w-7 -translate-y-1/2 rounded-md text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground [-webkit-app-region:no-drag]"
onClick={closeOverlay}
size="icon"
variant="ghost"
>
<X size={16} />
<Codicon name="close" size="1rem" />
</Button>
</div>

View file

@ -0,0 +1,43 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
import { PageSearchInput } from './overlays/overlay-search-input'
interface PageSearchShellProps extends React.ComponentProps<'section'> {
children: ReactNode
filters?: ReactNode
onSearchChange: (value: string) => void
searchPlaceholder: string
searchTrailingAction?: ReactNode
searchValue: string
}
export function PageSearchShell({
children,
className,
filters,
onSearchChange,
searchPlaceholder,
searchTrailingAction,
searchValue,
...props
}: PageSearchShellProps) {
return (
<section
{...props}
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
onChange={onSearchChange}
placeholder={searchPlaceholder}
trailingAction={searchTrailingAction}
value={searchValue}
/>
{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-(--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,7 @@ import {
renameProfile,
updateProfileSoul
} from '@/hermes'
import { AlertTriangle, Pencil, Plus, RefreshCw, Save, Terminal, Trash2, Users } from '@/lib/icons'
import { AlertTriangle, Pencil, Save, Terminal, Trash2, Users } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@ -88,7 +89,7 @@ export function ProfilesView({
setTitlebarToolGroup('profiles', [
{
disabled: refreshing,
icon: <RefreshCw className={cn(refreshing && 'animate-spin')} />,
icon: <Codicon name="refresh" spinning={refreshing} />,
id: 'refresh-profiles',
label: refreshing ? 'Refreshing profiles' : 'Refresh profiles',
onSelect: () => void refresh()
@ -179,7 +180,7 @@ export function ProfilesView({
<aside className="flex min-h-0 flex-col overflow-hidden border-b border-border/50 lg:border-b-0 lg:border-r">
<div className="border-b border-border/40 p-2">
<Button className="w-full" onClick={() => setCreateOpen(true)} size="sm">
<Plus />
<Codicon name="add" />
New profile
</Button>
</div>
@ -253,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(
@ -363,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>
@ -672,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

@ -1,18 +1,19 @@
import { useCallback, useRef, useState } from 'react'
import { type NodeApi, type NodeRendererProps, Tree, type TreeApi } from 'react-arborist'
import { Codicon } from '@/components/ui/codicon'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { ChevronDown, ChevronRight, FileText, FolderOpen, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { TreeNode } from './use-project-tree'
const ROW_HEIGHT = 28
const INDENT = 14
const ROW_HEIGHT = 22
const INDENT = 10
interface ProjectTreeProps {
data: TreeNode[]
onActivateFile: (path: string) => void
onActivateFolder: (path: string) => void
onLoadChildren: (id: string) => void | Promise<void>
onNodeOpenChange: (id: string, open: boolean) => void
onPreviewFile?: (path: string) => void
@ -22,6 +23,7 @@ interface ProjectTreeProps {
export function ProjectTree({
data,
onActivateFile,
onActivateFolder,
onLoadChildren,
onNodeOpenChange,
onPreviewFile,
@ -97,9 +99,26 @@ export function ProjectTree({
rowHeight={ROW_HEIGHT}
width={size.width}
>
{props => <ProjectTreeRow {...props} onAttachFile={onActivateFile} onPreviewFile={onPreviewFile} />}
{props => (
<ProjectTreeRow
{...props}
onAttachFile={onActivateFile}
onAttachFolder={onActivateFolder}
onPreviewFile={onPreviewFile}
/>
)}
</Tree>
) : null}
) : (
<TreeSizingState />
)}
</div>
)
}
function TreeSizingState() {
return (
<div className="flex h-full min-h-24 items-center justify-center px-3 text-[0.68rem] text-(--ui-text-tertiary)">
Loading files...
</div>
)
}
@ -108,20 +127,24 @@ function ProjectTreeRow({
dragHandle,
node,
onAttachFile,
onAttachFolder,
onPreviewFile,
style
}: NodeRendererProps<TreeNode> & { onAttachFile: (path: string) => void; onPreviewFile?: (path: string) => void }) {
}: NodeRendererProps<TreeNode> & {
onAttachFile: (path: string) => void
onAttachFolder: (path: string) => void
onPreviewFile?: (path: string) => void
}) {
const isFolder = node.data.isDirectory
const isPlaceholder = node.data.id.endsWith('::__loading__')
const Caret = node.isOpen ? ChevronDown : ChevronRight
return (
<div
aria-expanded={isFolder ? node.isOpen : undefined}
aria-selected={node.isSelected}
className={cn(
'group/row flex h-full cursor-pointer select-none items-center gap-0.5 rounded-sm px-0 text-sm font-medium leading-snug text-foreground/90 transition-colors hover:bg-(--chrome-action-hover)',
node.isSelected && 'bg-accent/65 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}
@ -132,14 +155,16 @@ function ProjectTreeRow({
return
}
if (event.shiftKey) {
;(isFolder ? onAttachFolder : onAttachFile)(node.data.id)
return
}
if (isFolder) {
node.toggle()
} else {
node.select()
if (event.shiftKey) {
onAttachFile(node.data.id)
}
}
}}
onDoubleClick={event => {
@ -166,17 +191,22 @@ function ProjectTreeRow({
style={style}
>
{isFolder && !isPlaceholder && (
<span aria-hidden className="flex w-2.5 items-center justify-center">
<Caret className="size-3 text-muted-foreground/70" />
<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"
/>
</span>
)}
<span aria-hidden className="flex w-3 items-center justify-center text-muted-foreground/85">
{!isFolder && <span aria-hidden className="w-3 shrink-0" />}
<span aria-hidden className="flex w-3.5 items-center justify-center text-(--ui-text-tertiary)">
{isPlaceholder ? (
<Loader2 className="size-3 animate-spin" />
<Codicon name="loading" size="0.75rem" spinning />
) : isFolder ? (
<FolderOpen className="size-3.5" />
<Codicon name={node.isOpen ? 'folder-opened' : 'folder'} size="0.875rem" />
) : (
<FileText className="size-3.5" />
<Codicon name="file" size="0.875rem" />
)}
</span>
<span className="min-w-0 flex-1 truncate">{node.data.name}</span>

View file

@ -227,7 +227,7 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
openState: state.cwd === cwd ? state.openState : {},
refreshRoot,
rootError: state.cwd === cwd ? state.rootError : null,
rootLoading: state.cwd === cwd ? state.rootLoading : false,
rootLoading: state.cwd === cwd ? state.rootLoading : Boolean(cwd),
setNodeOpen
}),
[

View file

@ -0,0 +1,292 @@
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Loader } from '@/components/ui/loader'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import { setCurrentSessionPreviewTarget } from '@/store/preview'
import { $currentBranch, $currentCwd } from '@/store/session'
import { SidebarPanelLabel } from '../shell/sidebar-label'
import { ProjectTree } from './files/tree'
import { useProjectTree } from './files/use-project-tree'
import { $rightSidebarTab, type RightSidebarTabId, setRightSidebarTab } from './store'
import { TerminalTab } from './terminal'
interface RightSidebarPaneProps {
onActivateFile: (path: string) => void
onActivateFolder: (path: string) => void
onAddTerminalSelection: (text: string, label?: string) => void
onChangeCwd: (path: string) => Promise<void> | void
}
interface RightSidebarTab {
icon: string
id: RightSidebarTabId
label: string
}
const RIGHT_SIDEBAR_TABS: readonly RightSidebarTab[] = [
{ id: 'files', label: 'File system', icon: 'files' },
{ id: 'terminal', label: 'Terminal', icon: 'terminal' }
]
export function RightSidebarPane({
onActivateFile,
onActivateFolder,
onAddTerminalSelection,
onChangeCwd
}: RightSidebarPaneProps) {
const activeTab = useStore($rightSidebarTab)
const currentBranch = useStore($currentBranch).trim()
const currentCwd = useStore($currentCwd).trim()
const hasCwd = currentCwd.length > 0
const cwdName = hasCwd
? (currentCwd
.split(/[\\/]+/)
.filter(Boolean)
.pop() ?? currentCwd)
: 'No folder selected'
const { data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } = useProjectTree(currentCwd)
const chooseFolder = async () => {
const selected = await window.hermesDesktop?.selectPaths({
defaultPath: hasCwd ? currentCwd : undefined,
directories: true,
multiple: false,
title: 'Change working directory'
})
if (selected?.[0]) {
await onChangeCwd(selected[0])
}
}
const previewFile = async (path: string) => {
try {
const preview = await normalizeOrLocalPreviewTarget(path, currentCwd || undefined)
if (!preview) {
throw new Error(`Could not preview ${path}`)
}
setCurrentSessionPreviewTarget(preview, 'file-browser', path)
} catch (error) {
notifyError(error, 'Preview unavailable')
}
}
return (
<aside
aria-label="Right sidebar"
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} />
{activeTab === 'terminal' ? (
<TerminalTab cwd={currentCwd} onAddSelectionToChat={onAddTerminalSelection} />
) : (
<FilesystemTab
cwd={currentCwd}
cwdName={cwdName}
data={data}
error={rootError}
hasCwd={hasCwd}
loading={rootLoading}
onActivateFile={onActivateFile}
onActivateFolder={onActivateFolder}
onChangeFolder={chooseFolder}
onLoadChildren={loadChildren}
onNodeOpenChange={setNodeOpen}
onPreviewFile={previewFile}
onRefresh={() => void refreshRoot()}
openState={openState}
/>
)}
</aside>
)
}
function RightSidebarChrome({ activeTab, branch }: { activeTab: RightSidebarTabId; branch: string }) {
return (
<header className="shrink-0 bg-transparent text-[0.75rem]">
<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 text-[0.6875rem] text-(--ui-text-tertiary)">
<Codicon className="shrink-0" name="git-branch" size="0.75rem" />
<span className="truncate">{branch}</span>
</span>
)}
</div>
</header>
)
}
interface FilesystemTabProps extends FileTreeBodyProps {
cwdName: string
hasCwd: boolean
onChangeFolder: () => Promise<void> | void
onRefresh: () => void
}
function FilesystemTab({
cwd,
cwdName,
data,
error,
hasCwd,
loading,
onActivateFile,
onActivateFolder,
onChangeFolder,
onLoadChildren,
onNodeOpenChange,
onPreviewFile,
onRefresh,
openState
}: FilesystemTabProps) {
return (
<div className="group/project-header flex min-h-0 flex-1 flex-col">
<RightSidebarSectionHeader>
<button
className="flex min-w-0 flex-1 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
onClick={() => void onChangeFolder()}
title={hasCwd ? cwd : 'No folder selected'}
type="button"
>
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
</button>
<Button
aria-label="Refresh tree"
className="pointer-events-none size-6 shrink-0 rounded-md text-sidebar-foreground/70 opacity-0 transition-opacity hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-sidebar-ring group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100"
disabled={!hasCwd || loading}
onClick={onRefresh}
size="icon"
title="Refresh tree"
variant="ghost"
>
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
</Button>
</RightSidebarSectionHeader>
<FileTreeBody
cwd={cwd}
data={data}
error={error}
loading={loading}
onActivateFile={onActivateFile}
onActivateFolder={onActivateFolder}
onLoadChildren={onLoadChildren}
onNodeOpenChange={onNodeOpenChange}
onPreviewFile={onPreviewFile}
openState={openState}
/>
</div>
)
}
export function RightSidebarSectionHeader({ children }: { children: ReactNode }) {
return <div className="flex h-7 shrink-0 items-center px-2">{children}</div>
}
interface FileTreeBodyProps {
cwd: string
data: ReturnType<typeof useProjectTree>['data']
error: string | null
loading: boolean
onActivateFile: (path: string) => void
onActivateFolder: (path: string) => void
onLoadChildren: (id: string) => void | Promise<void>
onNodeOpenChange: (id: string, open: boolean) => void
onPreviewFile?: (path: string) => void
openState: ReturnType<typeof useProjectTree>['openState']
}
function FileTreeBody({
cwd,
data,
error,
loading,
onActivateFile,
onActivateFolder,
onLoadChildren,
onNodeOpenChange,
onPreviewFile,
openState
}: FileTreeBodyProps) {
if (!cwd) {
return <EmptyState body="Set a working directory from the status bar to browse files." title="No project" />
}
if (error) {
return <EmptyState body={`Could not read this folder (${error}).`} title="Unreadable" />
}
if (loading && data.length === 0) {
return <FileTreeLoadingState />
}
if (data.length === 0) {
return <EmptyState body="This folder is empty." title="Empty" />
}
return (
<ProjectTree
data={data}
onActivateFile={onActivateFile}
onActivateFolder={onActivateFolder}
onLoadChildren={onLoadChildren}
onNodeOpenChange={onNodeOpenChange}
onPreviewFile={onPreviewFile}
openState={openState}
/>
)
}
function FileTreeLoadingState() {
return (
<div aria-label="Loading file tree" className="grid min-h-0 flex-1 place-items-center px-3" role="status">
<Loader
aria-hidden="true"
className="size-8 text-(--ui-text-tertiary)"
pathSteps={180}
role="presentation"
strokeScale={0.68}
type="spiral-search"
/>
</div>
)
}
function EmptyState({ body, title }: { body: string; title: string }) {
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-1 px-4 text-center">
<div className="text-[0.7rem] font-semibold uppercase tracking-[0.07em] text-muted-foreground/75">{title}</div>
<div className="text-[0.68rem] leading-relaxed text-muted-foreground/65">{body}</div>
</div>
)
}

View file

@ -0,0 +1,9 @@
import { atom } from 'nanostores'
export type RightSidebarTabId = 'files' | 'git' | 'terminal' | 'web'
export const $rightSidebarTab = atom<RightSidebarTabId>('files')
export function setRightSidebarTab(tab: RightSidebarTabId) {
$rightSidebarTab.set(tab)
}

View file

@ -0,0 +1,63 @@
import '@xterm/xterm/css/xterm.css'
import { Button } from '@/components/ui/button'
import { Loader } from '@/components/ui/loader'
import { SidebarPanelLabel } from '../../shell/sidebar-label'
import { addSelectionShortcutLabel } from './selection'
import { useTerminalSession } from './use-terminal-session'
interface TerminalTabProps {
cwd: string
onAddSelectionToChat: (text: string, label?: string) => void
}
export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
const { addSelectionToChat, hostRef, selection, selectionStyle, shellName, status } = useTerminalSession({
cwd,
onAddSelectionToChat
})
return (
<div className="relative flex min-h-0 flex-1 flex-col">
<div className="flex h-7 shrink-0 items-center px-3">
<SidebarPanelLabel>{shellName}</SidebarPanelLabel>
</div>
<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"
/>
</div>
)}
{selection.trim() && (
<div className="absolute z-50 flex items-center gap-1" style={selectionStyle ?? { right: 12, top: 8 }}>
<Button
className="h-6 rounded-md px-2 text-[0.68rem] shadow-md backdrop-blur-md"
onClick={event => event.preventDefault()}
onMouseDown={event => {
event.preventDefault()
event.stopPropagation()
addSelectionToChat()
}}
type="button"
variant="secondary"
>
Add to chat
<span className="ml-1 text-[0.6rem] text-(--ui-text-tertiary)">{addSelectionShortcutLabel()}</span>
</Button>
</div>
)}
<div
className="h-full min-h-0 overflow-hidden px-1 py-1 text-(--ui-text-secondary) [&_.xterm]:h-full [&_.xterm-screen]:bg-transparent! [&_.xterm-viewport]:bg-transparent!"
ref={hostRef}
/>
</div>
</div>
)
}

View file

@ -0,0 +1,96 @@
import type { ITheme, Terminal } from '@xterm/xterm'
import type { CSSProperties } from 'react'
// Solarized (Ethan Schoonover) for both modes. The accent palette is shared
// — Schoonover's design is that ANSI 015 are identical between Light and
// Dark; only fg/bg/cursor swap (`base00/01` vs `base0/1`). We skip both
// Solarized backgrounds (`base3` cream / `base03` slate) and keep the glass
// translucent in either mode.
//
// Heads-up: ANSI 7 (`white` = `base2` #eee8d5) is the lightest cream by
// design — near-invisible against light glass. Bump to `base1` (#93a1a1)
// if anything that emits `\x1b[37m` (e.g. tmux status bars) breaks.
//
// Palette source: altercation/solarized (iTerm2 scheme).
const SOLARIZED_ANSI: ITheme = {
black: '#073642',
red: '#dc322f',
green: '#859900',
yellow: '#b58900',
blue: '#268bd2',
magenta: '#d33682',
cyan: '#2aa198',
white: '#eee8d5',
brightBlack: '#002b36',
brightRed: '#cb4b16',
brightGreen: '#586e75',
brightYellow: '#657b83',
brightBlue: '#839496',
brightMagenta: '#6c71c4',
brightCyan: '#93a1a1',
brightWhite: '#fdf6e3'
}
const TRANSPARENT_GLASS: ITheme = {
background: '#00000000',
selectionBackground: '#8c8c8c33'
}
// The only thing Schoonover swaps between modes: the fg + cursor pair.
const MODE_TONES = {
light: { foreground: '#657b83', cursor: '#586e75', cursorAccent: '#fdf6e3' }, // base00 / base01 / base3
dark: { foreground: '#839496', cursor: '#93a1a1', cursorAccent: '#002b36' } // base0 / base1 / base03
}
export const terminalTheme = (mode: 'light' | 'dark'): ITheme => ({
...SOLARIZED_ANSI,
...TRANSPARENT_GLASS,
...MODE_TONES[mode]
})
export const isMacPlatform = () => navigator.platform.toLowerCase().includes('mac')
export const addSelectionShortcutLabel = () => (isMacPlatform() ? '⌘L' : 'Ctrl+L')
export function isAddSelectionShortcut(event: KeyboardEvent) {
return isMacPlatform()
? event.metaKey && !event.shiftKey && event.key.toLowerCase() === 'l'
: event.ctrlKey && !event.shiftKey && event.key.toLowerCase() === 'l'
}
function selectionLineCount(text: string) {
return Math.max(1, text.trim().split(/\r?\n/).length)
}
export function terminalSelectionLabel(term: Terminal, shellName: string, text: string) {
const position = term.getSelectionPosition()
if (position) {
return position.start.y === position.end.y
? `${shellName}:${position.start.y}`
: `${shellName}:${position.start.y}-${position.end.y}`
}
const lines = selectionLineCount(text)
return `${shellName}:${lines} line${lines === 1 ? '' : 's'}`
}
export function terminalSelectionAnchor(host: HTMLDivElement): CSSProperties | null {
const selectionRects = Array.from(host.querySelectorAll<HTMLElement>('.xterm-selection div'))
.map(node => node.getBoundingClientRect())
.filter(rect => rect.width > 0 && rect.height > 0)
const rect = selectionRects.at(-1)
if (!rect) {
return null
}
const hostRect = host.getBoundingClientRect()
const buttonWidth = 128
const left = Math.min(Math.max(rect.left - hostRect.left, 8), Math.max(8, host.clientWidth - buttonWidth - 8))
const top = Math.min(Math.max(rect.bottom - hostRect.top + 4, 8), Math.max(8, host.clientHeight - 34))
return { left, top }
}

View file

@ -0,0 +1,346 @@
import { FitAddon } from '@xterm/addon-fit'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { Terminal } from '@xterm/xterm'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import { triggerHaptic } from '@/lib/haptics'
import { useTheme } from '@/themes/context'
import { isAddSelectionShortcut, terminalSelectionAnchor, terminalSelectionLabel, terminalTheme } from './selection'
type TerminalStatus = 'closed' | 'open' | 'starting'
function readEscapeSequence(data: string, index: number) {
if (data.charCodeAt(index) !== 0x1b || index + 1 >= data.length) {
return null
}
const kind = data[index + 1]
if (kind === '[') {
for (let i = index + 2; i < data.length; i += 1) {
const code = data.charCodeAt(i)
if (code >= 0x40 && code <= 0x7e) {
return data.slice(index, i + 1)
}
}
}
if (kind === ']') {
for (let i = index + 2; i < data.length; i += 1) {
if (data.charCodeAt(i) === 0x07) {
return data.slice(index, i + 1)
}
if (data.charCodeAt(i) === 0x1b && data[i + 1] === '\\') {
return data.slice(index, i + 2)
}
}
}
return data.slice(index, Math.min(index + 2, data.length))
}
function stripEscapeSequences(data: string) {
let index = 0
let text = ''
while (index < data.length) {
const sequence = readEscapeSequence(data, index)
if (sequence) {
index += sequence.length
} else {
text += data[index]
index += 1
}
}
return text
}
function isStartupSpacer(data: string) {
const text = stripEscapeSequences(data).replace(/[\s\r\n]/g, '')
return text === '' || text === '%'
}
function stripInitialPromptGap(data: string) {
let index = 0
let prefix = ''
while (index < data.length) {
const sequence = readEscapeSequence(data, index)
if (sequence) {
prefix += sequence
index += sequence.length
} else if (data[index] === '\r' || data[index] === '\n') {
index += 1
} else {
return prefix + data.slice(index)
}
}
return prefix
}
interface UseTerminalSessionOptions {
cwd: string
onAddSelectionToChat: (text: string, label?: string) => void
}
export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSessionOptions) {
const hostRef = useRef<HTMLDivElement | null>(null)
const termRef = useRef<Terminal | null>(null)
const sessionIdRef = useRef<string | null>(null)
const shellNameRef = useRef('shell')
const selectionLabelRef = useRef('')
const selectionRef = useRef('')
const onAddSelectionToChatRef = useRef(onAddSelectionToChat)
const { resolvedMode } = useTheme()
const resolvedModeRef = useRef(resolvedMode)
const [status, setStatus] = useState<TerminalStatus>('starting')
const [selection, setSelection] = useState('')
const [selectionStyle, setSelectionStyle] = useState<CSSProperties | null>(null)
const [shellName, setShellName] = useState('shell')
useEffect(() => {
onAddSelectionToChatRef.current = onAddSelectionToChat
}, [onAddSelectionToChat])
useEffect(() => {
resolvedModeRef.current = resolvedMode
if (termRef.current) {
termRef.current.options.theme = terminalTheme(resolvedMode)
}
}, [resolvedMode])
const addSelectionToChat = useCallback(() => {
const selectedText = selectionRef.current || termRef.current?.getSelection() || ''
const label =
selectionLabelRef.current ||
(termRef.current ? terminalSelectionLabel(termRef.current, shellNameRef.current, selectedText) : 'selection')
const trimmed = selectedText.trim()
if (!trimmed) {
return
}
onAddSelectionToChatRef.current(trimmed, label)
termRef.current?.clearSelection()
selectionRef.current = ''
selectionLabelRef.current = ''
setSelection('')
setSelectionStyle(null)
triggerHaptic('selection')
}, [])
useEffect(() => {
if (!selection.trim()) {
return
}
const onKeyDown = (event: KeyboardEvent) => {
if (!isAddSelectionShortcut(event)) {
return
}
event.preventDefault()
event.stopPropagation()
addSelectionToChat()
}
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [addSelectionToChat, selection])
useEffect(() => {
const host = hostRef.current
const terminalApi = window.hermesDesktop?.terminal
if (!host || !terminalApi) {
setStatus('closed')
return
}
let disposed = false
const cleanup: Array<() => void> = []
let lastSentSize: { cols: number; rows: number } | null = null
const term = new Terminal({
allowProposedApi: true,
allowTransparency: true,
convertEol: true,
cursorBlink: true,
fontFamily: "'SF Mono', 'Menlo', 'Cascadia Code', 'JetBrains Mono', monospace",
fontSize: 11,
lineHeight: 1.12,
macOptionIsMeta: true,
scrollback: 1000,
theme: terminalTheme(resolvedModeRef.current)
})
const fit = new FitAddon()
termRef.current = term
term.loadAddon(fit)
term.loadAddon(new Unicode11Addon())
term.loadAddon(new WebLinksAddon())
term.unicode.activeVersion = '11'
term.open(host)
const fitAndResize = () => {
if (disposed || !host.isConnected || host.clientWidth <= 0 || host.clientHeight <= 0) {
return
}
try {
fit.fit()
} catch {
return
}
const id = sessionIdRef.current
if (id && (lastSentSize?.cols !== term.cols || lastSentSize.rows !== term.rows)) {
lastSentSize = { cols: term.cols, rows: term.rows }
void terminalApi.resize(id, { cols: term.cols, rows: term.rows })
}
}
const resizeObserver = new ResizeObserver(fitAndResize)
resizeObserver.observe(host)
cleanup.push(() => resizeObserver.disconnect())
const dataDisposable = term.onData(data => {
const id = sessionIdRef.current
if (id) {
void terminalApi.write(id, data)
}
})
cleanup.push(() => dataDisposable.dispose())
const selectionDisposable = term.onSelectionChange(() => {
const next = term.getSelection()
selectionRef.current = next
selectionLabelRef.current = next.trim() ? terminalSelectionLabel(term, shellNameRef.current, next) : ''
setSelection(next)
setSelectionStyle(next.trim() ? terminalSelectionAnchor(host) : null)
})
cleanup.push(() => selectionDisposable.dispose())
term.attachCustomKeyEventHandler(event => {
if (event.type !== 'keydown') {
return true
}
if (isAddSelectionShortcut(event) && term.hasSelection()) {
event.preventDefault()
addSelectionToChat()
return false
}
return true
})
fitAndResize()
void terminalApi
.start({ cols: term.cols, cwd, rows: term.rows })
.then(session => {
if (disposed) {
void terminalApi.dispose(session.id)
return
}
sessionIdRef.current = session.id
lastSentSize = { cols: term.cols, rows: term.rows }
shellNameRef.current = session.shell || 'shell'
setShellName(session.shell || 'shell')
if (term.hasSelection()) {
const currentSelection = term.getSelection()
selectionRef.current = currentSelection
selectionLabelRef.current = terminalSelectionLabel(term, shellNameRef.current, currentSelection)
} else {
selectionRef.current = ''
selectionLabelRef.current = ''
}
setStatus('open')
let wrotePromptContent = false
cleanup.push(
terminalApi.onData(session.id, data => {
if (wrotePromptContent) {
term.write(data)
return
}
if (isStartupSpacer(data)) {
return
}
const next = stripInitialPromptGap(data)
if (next) {
wrotePromptContent = true
term.write(next)
}
}),
terminalApi.onExit(session.id, sessionExit => {
const { code, signal } = sessionExit
setStatus('closed')
term.write(`\r\n[terminal exited${signal ? `: ${signal}` : code !== null ? `: ${code}` : ''}]\r\n`)
})
)
window.requestAnimationFrame(fitAndResize)
})
.catch(error => {
setStatus('closed')
term.write(`Terminal failed to start: ${error instanceof Error ? error.message : String(error)}\r\n`)
})
return () => {
disposed = true
cleanup.forEach(run => run())
const id = sessionIdRef.current
sessionIdRef.current = null
if (id) {
void terminalApi.dispose(id)
}
term.dispose()
termRef.current = null
shellNameRef.current = 'shell'
selectionRef.current = ''
selectionLabelRef.current = ''
}
}, [addSelectionToChat, cwd])
return {
addSelectionToChat,
hostRef,
selection,
selectionStyle,
shellName,
status
}
}

View file

@ -7,11 +7,16 @@ import type { SessionRuntimeInfo } from '@/types/hermes'
interface CwdActionsOptions {
activeSessionId: string | null
activeSessionIdRef: MutableRefObject<string | null>
currentCwd: string
onSessionRuntimeInfo?: (info: Pick<SessionRuntimeInfo, 'branch' | 'cwd'>) => void
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
}
export function useCwdActions({ activeSessionId, activeSessionIdRef, currentCwd, requestGateway }: CwdActionsOptions) {
export function useCwdActions({
activeSessionId,
activeSessionIdRef,
onSessionRuntimeInfo,
requestGateway
}: CwdActionsOptions) {
const refreshProjectBranch = useCallback(
async (cwd: string) => {
const target = cwd.trim()
@ -44,23 +49,15 @@ export function useCwdActions({ activeSessionId, activeSessionIdRef, currentCwd,
return
}
const persistGlobal = async () => {
const info = await requestGateway<{ branch?: string; cwd?: string; value?: string }>('config.set', {
...(activeSessionId && { session_id: activeSessionId }),
key: 'terminal.cwd',
value: trimmed
})
setCurrentCwd(info.cwd || info.value || trimmed)
if (!activeSessionId) {
setCurrentBranch(info.branch || '')
}
}
if (!activeSessionId) {
try {
await persistGlobal()
const info = await requestGateway<{ branch?: string; cwd?: string }>('config.get', {
key: 'project',
cwd: trimmed
})
setCurrentCwd(info.cwd || trimmed)
setCurrentBranch(info.branch || '')
} catch (err) {
notifyError(err, 'Working directory change failed')
}
@ -76,44 +73,27 @@ export function useCwdActions({ activeSessionId, activeSessionIdRef, currentCwd,
setCurrentCwd(info.cwd || trimmed)
setCurrentBranch(info.branch || '')
onSessionRuntimeInfo?.({ branch: info.branch || '', cwd: info.cwd || trimmed })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
// Older gateways without `session.cwd.set` fall back to a global write —
// user has to restart the active session for it to take effect.
if (!message.includes('unknown method')) {
notifyError(err, 'Working directory change failed')
return
}
try {
await persistGlobal()
notify({
kind: 'warning',
title: 'Working directory saved',
message: 'Restart the desktop backend to apply cwd changes to this active session.'
})
} catch (fallbackErr) {
notifyError(fallbackErr, 'Working directory change failed')
}
setCurrentCwd(trimmed)
setCurrentBranch('')
notify({
kind: 'warning',
title: 'Working directory staged',
message: 'Restart the desktop backend to apply cwd changes to this active session.'
})
}
},
[activeSessionId, requestGateway]
[activeSessionId, onSessionRuntimeInfo, requestGateway]
)
const browseSessionCwd = useCallback(async () => {
const paths = await window.hermesDesktop?.selectPaths({
title: 'Change working directory',
defaultPath: currentCwd || undefined,
directories: true,
multiple: false
})
if (paths?.[0]) {
await changeSessionCwd(paths[0])
}
}, [changeSessionCwd, currentCwd])
return { browseSessionCwd, changeSessionCwd, refreshProjectBranch }
return { changeSessionCwd, refreshProjectBranch }
}

View file

@ -61,6 +61,21 @@ interface QueuedStreamDeltas {
const STREAM_DELTA_FLUSH_MS = 16
// Gateway/provider failures sometimes arrive as message.complete text instead
// of an explicit error event. Treat matches as inline assistant errors so they
// persist like real error events and don't get erased by hydrate fallback.
const COMPLETION_ERROR_PATTERNS = [
/^API call failed after \d+ retries:/i,
/^HTTP\s+\d{3}\b/i,
/^(Provider|Gateway)\s+error:/i
]
function completionErrorText(finalText: string): string | null {
const text = finalText.trim()
return text && COMPLETION_ERROR_PATTERNS.some(re => re.test(text)) ? text : null
}
const SUBAGENT_EVENT_TYPES = new Set([
'subagent.spawn_requested',
'subagent.start',
@ -100,7 +115,9 @@ function parseMaybeRecord(value: unknown): Record<string, unknown> {
const firstString = (...candidates: unknown[]): string => {
for (const v of candidates) {
if (typeof v === 'string' && v) return v
if (typeof v === 'string' && v) {
return v
}
}
return ''
@ -111,7 +128,9 @@ function delegateTaskPayloads(
phase: 'running' | 'complete',
sourceEventType?: string
): Record<string, unknown>[] {
if (payload?.name !== 'delegate_task') return []
if (payload?.name !== 'delegate_task') {
return []
}
const args = parseMaybeRecord(payload.args ?? payload.input)
const result = parseMaybeRecord(payload.result)
@ -120,6 +139,7 @@ function delegateTaskPayloads(
const status = phase === 'complete' ? (payload.error ? 'failed' : 'completed') : 'running'
const toolId = payload.tool_id || payload.tool_call_id || payload.id || 'delegate_task'
const progressText = firstString(payload.preview, payload.message, payload.context)
const eventType =
phase === 'complete'
? 'subagent.complete'
@ -372,7 +392,12 @@ export function useMessageStream({
) => {
if (!nativeSubagentSessionsRef.current.has(sessionId)) {
for (const subagentPayload of delegateTaskPayloads(payload, phase, sourceEventType)) {
upsertSubagent(sessionId, subagentPayload, true, phase === 'complete' ? 'delegate.complete' : 'delegate.running')
upsertSubagent(
sessionId,
subagentPayload,
true,
phase === 'complete' ? 'delegate.complete' : 'delegate.running'
)
}
}
@ -401,6 +426,7 @@ export function useMessageStream({
const streamId = state.streamId
const finalText = renderMediaTags(text).trim()
const completionError = completionErrorText(finalText)
const normalize = (value: string) => value.replace(/\s+/g, ' ').trim()
const dedupeReference = normalize(finalText)
@ -422,10 +448,26 @@ export function useMessageStream({
return finalText ? [...kept, assistantTextPart(finalText)] : kept
}
const completeMessage = (message: ChatMessage): ChatMessage => ({
...message,
parts: replaceTextPart(message.parts),
pending: false
const completeMessage = (message: ChatMessage): ChatMessage =>
completionError
? {
...message,
error: completionError,
parts: message.parts.filter(part => part.type !== 'text'),
pending: false
}
: {
...message,
parts: replaceTextPart(message.parts),
pending: false
}
const newAssistantFromCompletion = (): ChatMessage => ({
id: `assistant-${Date.now()}`,
role: 'assistant',
parts: completionError ? [] : [assistantTextPart(finalText)],
branchGroupId: state.pendingBranchGroup ?? undefined,
...(completionError && { error: completionError })
})
const prev = state.messages
@ -448,30 +490,18 @@ export function useMessageStream({
messageIndex === index ? completeMessage(message) : message
)
} else if (finalText) {
nextMessages = [
...prev,
{
id: `assistant-${Date.now()}`,
role: 'assistant',
parts: [assistantTextPart(finalText)],
branchGroupId: state.pendingBranchGroup ?? undefined
}
]
nextMessages = [...prev, newAssistantFromCompletion()]
}
} else if (finalText) {
nextMessages = [
...prev,
{
id: `assistant-${Date.now()}`,
role: 'assistant',
parts: [assistantTextPart(finalText)],
branchGroupId: state.pendingBranchGroup ?? undefined
}
]
nextMessages = [...prev, newAssistantFromCompletion()]
}
}
shouldHydrate = !state.sawAssistantPayload || !finalText
const hasInlineError = nextMessages.some(m => m.role === 'assistant' && m.error && !m.hidden)
const lastVisible = [...nextMessages].reverse().find(m => !m.hidden)
const unresolvedUserTail = lastVisible?.role === 'user'
shouldHydrate =
!completionError && !hasInlineError && !unresolvedUserTail && (!state.sawAssistantPayload || !finalText)
return {
...state,
@ -499,6 +529,50 @@ export function useMessageStream({
[activeSessionIdRef, hydrateFromStoredSession, refreshSessions, updateSessionState]
)
const failAssistantMessage = useCallback(
(sessionId: string, errorMessage: string) => {
updateSessionState(sessionId, state => {
const streamId = state.streamId ?? `assistant-error-${Date.now()}`
const groupId = state.pendingBranchGroup ?? undefined
const prev = state.messages
const error = errorMessage.trim() || 'Hermes reported an error'
const nextMessages = prev.some(m => m.id === streamId)
? prev.map(message =>
message.id === streamId
? {
...message,
error,
pending: false
}
: message
)
: [
...prev,
{
id: streamId,
role: 'assistant' as const,
parts: [],
error,
pending: false,
branchGroupId: groupId
}
]
return {
...state,
messages: nextMessages,
streamId: null,
pendingBranchGroup: null,
sawAssistantPayload: true,
awaitingResponse: false,
busy: false
}
})
},
[updateSessionState]
)
const handleGatewayEvent = useCallback(
(event: RpcEvent) => {
const payload = event.payload as GatewayEventPayload | undefined
@ -517,6 +591,8 @@ export function useMessageStream({
const runningChanged = typeof payload?.running === 'boolean'
if (apply) {
const runtimeInfo: { branch?: string; cwd?: string } = {}
if (modelChanged) {
setCurrentModel(payload!.model || '')
}
@ -527,10 +603,20 @@ export function useMessageStream({
if (typeof payload?.cwd === 'string') {
setCurrentCwd(payload.cwd)
runtimeInfo.cwd = payload.cwd
}
if (typeof payload?.branch === 'string') {
setCurrentBranch(payload.branch)
runtimeInfo.branch = payload.branch
}
if (sessionId && (runtimeInfo.cwd !== undefined || runtimeInfo.branch !== undefined)) {
updateSessionState(sessionId, state => ({
...state,
branch: runtimeInfo.branch ?? state.branch,
cwd: runtimeInfo.cwd ?? state.cwd
}))
}
if (typeof payload?.personality === 'string') {
@ -721,11 +807,7 @@ export function useMessageStream({
if (sessionId) {
flushQueuedDeltas(sessionId)
updateSessionState(sessionId, state => ({
...state,
awaitingResponse: false,
busy: false
}))
failAssistantMessage(sessionId, errorMessage)
}
if (isActiveEvent) {
@ -738,6 +820,7 @@ export function useMessageStream({
appendReasoningDelta,
activeSessionIdRef,
completeAssistantMessage,
failAssistantMessage,
flushQueuedDeltas,
queryClient,
refreshHermesConfig,

View file

@ -23,7 +23,8 @@ import {
$composerAttachments,
addComposerAttachment,
clearComposerAttachments,
type ComposerAttachment
type ComposerAttachment,
terminalContextBlocksFromDraft
} from '@/store/composer'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
@ -53,6 +54,12 @@ function isProviderSetupError(error: unknown) {
return isProviderSetupErrorMessage(message)
}
function inlineErrorMessage(error: unknown, fallback: string): string {
const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : fallback
return (raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw).replace(/^Error:\s*/, '').trim()
}
interface PromptActionsOptions {
activeSessionId: string | null
activeSessionIdRef: MutableRefObject<string | null>
@ -202,15 +209,19 @@ export function usePromptActions({
const visibleText = rawText.trim()
const usingComposerAttachments = !options?.attachments
const attachments = options?.attachments ?? $composerAttachments.get()
const contextRefs = attachments
.map(a => a.refText)
.filter(Boolean)
.join('\n')
const terminalContextBlocks = terminalContextBlocksFromDraft(rawText).join('\n\n')
const hasImage = attachments.some(a => a.kind === 'image')
const attachmentRefs = attachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
const text =
[contextRefs, visibleText].filter(Boolean).join('\n\n') || (hasImage ? 'What do you see in this image?' : '')
[contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') ||
(hasImage ? 'What do you see in this image?' : '')
if (!text || busyRef.current) {
return false
@ -311,12 +322,32 @@ export function usePromptActions({
})
await requestGateway('prompt.submit', { session_id: sessionId, text })
if (usingComposerAttachments) clearComposerAttachments()
if (usingComposerAttachments) {
clearComposerAttachments()
}
return true
} catch (err) {
const message = inlineErrorMessage(err, 'Prompt failed')
releaseBusy()
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
updateSessionState(sessionId, state => ({
...state,
messages: [
...state.messages,
{
id: `assistant-error-${Date.now()}`,
role: 'assistant',
parts: [],
error: message || 'Prompt failed',
branchGroupId: state.pendingBranchGroup ?? undefined
}
],
busy: false,
awaitingResponse: false,
pendingBranchGroup: null,
sawAssistantPayload: true
}))
if (isProviderSetupError(err)) {
requestDesktopOnboarding('Add a provider credential before sending your first message.')
@ -325,6 +356,7 @@ export function usePromptActions({
}
notifyError(err, 'Prompt failed')
return false
}
},
@ -681,10 +713,16 @@ export function usePromptActions({
return
}
const truncate_before_user_ordinal = visibleUserOrdinal(messages, sourceIndex)
// Failed turn: optimistic user msg never reached the gateway, so truncating
// by ordinal would 422. Submit as a plain resend instead.
const nextMessage = messages[sourceIndex + 1]
const isFailedTurn = nextMessage?.role === 'assistant' && Boolean(nextMessage.error)
const editedMessage: ChatMessage = { ...source, parts: [textPart(text)] }
clearNotifications()
busyRef.current = true
setBusy(true)
setAwaitingResponse(true)
updateSessionState(sessionId, state => ({
...state,
busy: true,
@ -695,14 +733,39 @@ export function usePromptActions({
messages: [...state.messages.slice(0, sourceIndex), editedMessage]
}))
const submit = (truncateOrdinal?: number) =>
requestGateway('prompt.submit', {
session_id: sessionId,
text,
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
})
const isStaleTargetError = (err: unknown) =>
/no longer in session history|not in session history/i.test(err instanceof Error ? err.message : String(err))
try {
await requestGateway('prompt.submit', { session_id: sessionId, text, truncate_before_user_ordinal })
await submit(isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex))
} catch (err) {
let surfaced = err
if (!isFailedTurn && isStaleTargetError(err)) {
try {
await submit()
return
} catch (retryErr) {
surfaced = retryErr
}
}
busyRef.current = false
setBusy(false)
setAwaitingResponse(false)
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
notifyError(err, 'Edit failed')
notifyError(surfaced, 'Edit failed')
}
},
[activeSessionId, activeSessionIdRef, requestGateway, updateSessionState]
[activeSessionId, activeSessionIdRef, busyRef, requestGateway, updateSessionState]
)
const handleThreadMessagesChange = useCallback(

View file

@ -3,7 +3,7 @@ import { useCallback, useRef } from 'react'
import type { NavigateFunction } from 'react-router-dom'
import { deleteSession, getSessionMessages } from '@/hermes'
import { type ChatMessage, chatMessageText, toChatMessages } from '@/lib/chat-messages'
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
import { normalizePersonalityValue } from '@/lib/chat-runtime'
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
import { clearComposerAttachments, clearComposerDraft } from '@/store/composer'
@ -12,6 +12,7 @@ import { $pinnedSessionIds } from '@/store/layout'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import {
$currentCwd,
$messages,
$sessions,
setActiveSessionId,
@ -91,6 +92,7 @@ function chatMessagesEquivalent(a: ChatMessage, b: ChatMessage): boolean {
a.id !== b.id ||
a.role !== b.role ||
a.pending !== b.pending ||
a.error !== b.error ||
a.hidden !== b.hidden ||
a.branchGroupId !== b.branchGroupId
) {
@ -166,6 +168,7 @@ function upsertOptimisticSession(
const now = Date.now() / 1000
const session: SessionInfo = {
cwd: created.info?.cwd ?? null,
ended_at: null,
id,
input_tokens: 0,
@ -184,11 +187,23 @@ function upsertOptimisticSession(
setSessions(prev => [session, ...prev.filter(s => s.id !== id)])
}
function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined) {
if (!info) {
function patchSessionWorkspace(sessionId: string, cwd: string | undefined) {
if (!cwd) {
return
}
setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session)))
}
function applyRuntimeInfo(
info: SessionCreateResponse['info'] | undefined
): Partial<Pick<ClientSessionState, 'branch' | 'cwd'>> | null {
if (!info) {
return null
}
const sessionState: Partial<Pick<ClientSessionState, 'branch' | 'cwd'>> = {}
if (info.credential_warning) {
requestDesktopOnboarding(info.credential_warning)
}
@ -203,10 +218,12 @@ function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined) {
if (info.cwd) {
setCurrentCwd(info.cwd)
sessionState.cwd = info.cwd
}
if (info.branch !== undefined) {
setCurrentBranch(info.branch || '')
sessionState.branch = info.branch || ''
}
if (typeof info.personality === 'string') {
@ -228,6 +245,8 @@ function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined) {
if (info.usage) {
setCurrentUsage(current => ({ ...current, ...info.usage }))
}
return sessionState
}
export function useSessionActions({
@ -269,6 +288,8 @@ export function useSessionActions({
})
setSessionStartedAt(null)
setTurnStartedAt(null)
setCurrentCwd('')
setCurrentBranch('')
clearComposerDraft()
clearComposerAttachments()
setFreshDraftReady(true)
@ -284,7 +305,8 @@ export function useSessionActions({
creatingSessionRef.current = true
try {
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96 })
const cwd = $currentCwd.get().trim()
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96, ...(cwd && { cwd }) })
const stored = created.stored_session_id ?? null
if (
@ -310,7 +332,11 @@ export function useSessionActions({
setActiveSessionId(created.session_id)
setSelectedStoredSessionId(stored)
setSessionStartedAt(Date.now())
applyRuntimeInfo(created.info)
const runtimeInfo = applyRuntimeInfo(created.info)
if (runtimeInfo) {
updateSessionState(created.session_id, state => ({ ...state, ...runtimeInfo }), stored)
}
return created.session_id
} finally {
@ -325,7 +351,8 @@ export function useSessionActions({
getRouteToken,
navigate,
requestGateway,
selectedStoredSessionIdRef
selectedStoredSessionIdRef,
updateSessionState
])
const selectSidebarItem = useCallback(
@ -376,6 +403,8 @@ export function useSessionActions({
setActiveSessionId(cachedRuntimeId)
activeSessionIdRef.current = cachedRuntimeId
syncSessionStateToView(cachedRuntimeId, cachedState)
setCurrentCwd(cachedState.cwd)
setCurrentBranch(cachedState.branch)
setSessionStartedAt(Date.now())
clearComposerDraft()
clearComposerAttachments()
@ -428,7 +457,7 @@ export function useSessionActions({
const storedMessages = await getSessionMessages(storedSessionId)
if (isCurrentResume()) {
localSnapshot = toChatMessages(storedMessages.messages)
localSnapshot = preserveLocalAssistantErrors(toChatMessages(storedMessages.messages), $messages.get())
if (!chatMessageArraysEquivalent($messages.get(), localSnapshot)) {
setMessages(localSnapshot)
@ -448,7 +477,11 @@ export function useSessionActions({
}
const currentMessages = $messages.get()
const resumedMessages = reconcileResumeMessages(toChatMessages(resumed.messages), currentMessages)
const resumedMessages = preserveLocalAssistantErrors(
reconcileResumeMessages(toChatMessages(resumed.messages), currentMessages),
currentMessages
)
// Avoid a second visible transcript rebuild on resume/switch.
// `getSessionMessages()` is the stable stored transcript snapshot and
// paints first; `session.resume` can return a slightly different
@ -458,19 +491,26 @@ export function useSessionActions({
// exists; use gateway messages only as a fallback when no local
// snapshot was available.
const messagesForView =
const preferredMessages =
localSnapshot.length > 0
? localSnapshot
: chatMessageArraysEquivalent(currentMessages, resumedMessages)
? currentMessages
: resumedMessages
const messagesForView = preserveLocalAssistantErrors(preferredMessages, currentMessages)
setActiveSessionId(resumed.session_id)
activeSessionIdRef.current = resumed.session_id
const runtimeInfo = applyRuntimeInfo(resumed.info)
patchSessionWorkspace(storedSessionId, runtimeInfo?.cwd)
updateSessionState(
resumed.session_id,
state => ({
...state,
...(runtimeInfo ?? {}),
messages: messagesForView,
busy: false,
awaitingResponse: false
@ -479,7 +519,6 @@ export function useSessionActions({
)
clearComposerDraft()
clearComposerAttachments()
applyRuntimeInfo(resumed.info)
} catch (err) {
if (!isCurrentResume()) {
return
@ -491,7 +530,7 @@ export function useSessionActions({
return
}
setMessages(toChatMessages(fallback.messages))
setMessages(preserveLocalAssistantErrors(toChatMessages(fallback.messages), $messages.get()))
notifyError(err, 'Resume failed')
} finally {
if (isCurrentResume()) {
@ -570,8 +609,11 @@ export function useSessionActions({
clearNotifications()
const cwd = $currentCwd.get().trim()
const branched = await requestGateway<SessionCreateResponse>('session.create', {
cols: 96,
...(cwd && { cwd }),
messages: branchMessages.map(({ content, role }) => ({ content, role })),
title: 'Branch'
})
@ -600,7 +642,13 @@ export function useSessionActions({
clearComposerDraft()
clearComposerAttachments()
applyRuntimeInfo(branched.info)
const runtimeInfo = applyRuntimeInfo(branched.info)
patchSessionWorkspace(routedSessionId, runtimeInfo?.cwd)
if (runtimeInfo) {
updateSessionState(branched.session_id, state => ({ ...state, ...runtimeInfo }), routedSessionId)
}
return true
} catch (err) {

View file

@ -2,8 +2,9 @@ import { useStore } from '@nanostores/react'
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import type { ChatMessage } from '@/lib/chat-messages'
import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $busy, setSessionWorking } from '@/store/session'
import { $busy, $messages, setSessionWorking } from '@/store/session'
import type { ClientSessionState } from '../../types'
@ -78,6 +79,20 @@ export function useSessionStateCache({
return created
}, [])
const flushPendingViewState = useCallback(() => {
const pending = pendingViewStateRef.current
pendingViewStateRef.current = null
if (!pending || pending.sessionId !== activeSessionIdRef.current) {
return
}
setMessages(preserveLocalAssistantErrors(pending.state.messages, $messages.get()))
setBusy(pending.state.busy)
busyRef.current = pending.state.busy
setAwaitingResponse(pending.state.awaitingResponse)
}, [busyRef, setAwaitingResponse, setBusy, setMessages])
const syncSessionStateToView = useCallback(
(sessionId: string, state: ClientSessionState) => {
pendingViewStateRef.current = { sessionId, state }
@ -87,41 +102,17 @@ export function useSessionStateCache({
}
if (typeof window === 'undefined') {
const pending = pendingViewStateRef.current
if (!pending || pending.sessionId !== activeSessionIdRef.current) {
pendingViewStateRef.current = null
return
}
pendingViewStateRef.current = null
setMessages(pending.state.messages)
setBusy(pending.state.busy)
busyRef.current = pending.state.busy
setAwaitingResponse(pending.state.awaitingResponse)
flushPendingViewState()
return
}
viewSyncRafRef.current = window.requestAnimationFrame(() => {
viewSyncRafRef.current = null
const pending = pendingViewStateRef.current
if (!pending || pending.sessionId !== activeSessionIdRef.current) {
pendingViewStateRef.current = null
return
}
pendingViewStateRef.current = null
setMessages(pending.state.messages)
setBusy(pending.state.busy)
busyRef.current = pending.state.busy
setAwaitingResponse(pending.state.awaitingResponse)
flushPendingViewState()
})
},
[busyRef, setAwaitingResponse, setBusy, setMessages]
[flushPendingViewState]
)
useEffect(

View file

@ -58,16 +58,16 @@ export function AppearanceSettings() {
return (
<SettingsContent>
<div className="space-y-7">
<div className="space-y-5">
<div>
<SectionHeading icon={Palette} title="Appearance" />
<p className="max-w-2xl text-sm leading-6 text-muted-foreground">
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
These are desktop-only display preferences. Mode controls brightness; theme controls the accent palette and
chat surface styling.
</p>
</div>
<section className="rounded-2xl border border-border/50 bg-card/55 p-4 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>
@ -84,8 +84,8 @@ export function AppearanceSettings() {
return (
<button
className={cn(
'group rounded-xl border border-border/45 bg-background/55 p-3 text-left transition hover:border-primary/35 hover:bg-accent/45',
active && 'border-primary/65 bg-primary/8 ring-2 ring-primary/25'
'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
key={id}
onClick={() => {
@ -104,15 +104,17 @@ export function AppearanceSettings() {
</span>
)}
</div>
<div className="mt-3 text-sm font-medium">{label}</div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">{description}</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>
</button>
)
})}
</div>
</section>
<section className="rounded-2xl border border-border/50 bg-card/55 p-4 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>
@ -142,8 +144,8 @@ export function AppearanceSettings() {
return (
<button
className={cn(
'group rounded-xl border border-border/45 bg-background/55 p-3 text-left transition hover:border-primary/35 hover:bg-accent/45',
active && 'border-primary/65 bg-primary/8 ring-2 ring-primary/25'
'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
key={option.id}
onClick={() => {
@ -153,21 +155,23 @@ export function AppearanceSettings() {
type="button"
>
<div className="flex items-start justify-between gap-3">
<div className="text-sm font-medium">{option.label}</div>
<div className="text-[length:var(--conversation-text-font-size)] font-medium">{option.label}</div>
{active && (
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">{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-2xl border border-border/50 bg-card/55 p-4 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>
@ -184,8 +188,8 @@ export function AppearanceSettings() {
return (
<button
className={cn(
'rounded-2xl border border-border/45 bg-background/50 p-2.5 text-left transition hover:border-primary/35 hover:bg-accent/35',
active && 'border-primary/65 bg-primary/8 ring-2 ring-primary/25'
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
key={theme.name}
onClick={() => {
@ -197,8 +201,10 @@ 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-sm font-medium">{theme.label}</div>
<div className="mt-0.5 line-clamp-2 text-xs leading-5 text-muted-foreground">
<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>
</div>

View file

@ -1,7 +1,7 @@
import {
Brain,
type IconComponent,
Lock,
type LucideIcon,
MessageCircle,
Mic,
Monitor,
@ -302,7 +302,7 @@ export interface ModeOption {
id: ThemeMode
label: string
description: string
icon: LucideIcon
icon: IconComponent
}
export const MODE_OPTIONS: ModeOption[] = [

View file

@ -45,22 +45,24 @@ function ModeCard({
return (
<button
className={cn(
'rounded-2xl border p-4 text-left transition',
'rounded-xl border p-3 text-left transition',
active
? 'border-primary bg-primary/10 ring-2 ring-primary/15'
: 'border-border bg-background/60 hover:bg-muted/40',
? 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
: 'border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) hover:bg-(--chrome-action-hover)',
disabled && 'cursor-not-allowed opacity-50'
)}
disabled={disabled}
onClick={onSelect}
type="button"
>
<div className="flex items-center gap-2 text-sm font-medium">
<div className="flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium">
<Icon className="size-4 text-muted-foreground" />
<span>{title}</span>
{active ? <Check className="ml-auto size-4 text-primary" /> : null}
</div>
<p className="mt-2 text-xs leading-5 text-muted-foreground">{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>
)
}
@ -191,20 +193,20 @@ export function GatewaySettings() {
return (
<SettingsContent>
<div className="mb-6">
<div className="flex items-center gap-2 text-sm font-medium">
<div className="mb-5">
<div className="flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium">
<Globe className="size-4 text-muted-foreground" />
Gateway Connection
{state.envOverride ? <Pill tone="primary">env override</Pill> : null}
</div>
<p className="mt-2 max-w-2xl text-xs leading-5 text-muted-foreground">
<p className="mt-2 max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
Hermes Desktop starts its own local gateway by default. Use a remote gateway when you want this app to control
an already-running Hermes backend on another machine or behind a trusted proxy.
</p>
</div>
{state.envOverride ? (
<div className="mb-5 flex items-start gap-2 rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-xs text-destructive">
<div className="mb-5 flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-[length:var(--conversation-caption-font-size)] text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<div>
<div className="font-medium">Environment variables are controlling this desktop session.</div>

View file

@ -1,9 +1,10 @@
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 { Check, Eye, EyeOff, Save, Settings2, Trash2, X, Zap } from '@/lib/icons'
import { Check, Eye, EyeOff, Save, Settings2, Trash2, Zap } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { EnvVarInfo } from '@/types/hermes'
@ -169,7 +170,7 @@ function EnvVarRow({
{saving === varKey ? 'Saving' : 'Save'}
</Button>
<Button onClick={() => setEdits(c => withoutKey(c, varKey))} size="sm" variant="outline">
<X />
<Codicon name="close" />
Cancel
</Button>
</div>

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

@ -2,14 +2,14 @@ import type { ReactNode } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import type { LucideIcon } from '@/lib/icons'
import type { IconComponent } from '@/lib/icons'
import { cn } from '@/lib/utils'
export function SettingsContent({ children }: { children: ReactNode }) {
return (
<section className="min-h-0 overflow-hidden">
<div className="h-full min-h-0 overflow-y-auto px-8 py-6 pb-24">
<div className="mx-auto w-full max-w-5xl">{children}</div>
<div className="h-full min-h-0 overflow-y-auto px-5 py-4 pb-20">
<div className="mx-auto w-full max-w-4xl">{children}</div>
</div>
</section>
)
@ -19,7 +19,7 @@ export function Pill({ tone = 'muted', children }: { tone?: 'muted' | 'primary';
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.66rem]',
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.6875rem]',
tone === 'primary' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
)}
>
@ -28,9 +28,9 @@ export function Pill({ tone = 'muted', children }: { tone?: 'muted' | 'primary';
)
}
export function SectionHeading({ icon: Icon, title, meta }: { icon: LucideIcon; title: string; meta?: string }) {
export function SectionHeading({ icon: Icon, title, meta }: { icon: IconComponent; title: string; meta?: string }) {
return (
<div className="mb-3 flex items-center gap-2 pt-3.5 text-sm font-medium">
<div className="mb-2.5 flex items-center gap-2 pt-2 text-[length:var(--conversation-text-font-size)] font-medium">
<Icon className="size-4 text-muted-foreground" />
<span>{title}</span>
{meta && <Pill>{meta}</Pill>}
@ -44,7 +44,7 @@ export function NavLink({
active,
onClick
}: {
icon: LucideIcon
icon: IconComponent
label: string
active: boolean
onClick: () => void
@ -52,8 +52,10 @@ export function NavLink({
return (
<Button
className={cn(
'flex min-h-8 w-full justify-start gap-2 rounded-lg px-2.5 text-left text-sm transition',
active ? 'bg-muted text-foreground' : 'text-foreground/80 hover:bg-muted/70'
'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'
)}
onClick={onClick}
size="sm"
@ -84,13 +86,17 @@ export function ListRow({
return (
<div
className={cn(
'grid gap-4 py-3.5 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm:items-center',
'grid gap-3 py-3 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm:items-center',
wide && 'sm:grid-cols-1 sm:items-start'
)}
>
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">{title}</div>
{description && <div className="mt-1 text-xs leading-5 text-muted-foreground">{description}</div>}
<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>
)}
{hint && <div className="mt-1 block font-mono text-[0.68rem] text-muted-foreground/45">{hint}</div>}
{below}
</div>

View file

@ -1,7 +1,7 @@
import type { Dispatch, SetStateAction } from 'react'
import type { HermesGateway } from '@/hermes'
import type { LucideIcon } from '@/lib/icons'
import type { IconComponent } from '@/lib/icons'
import type { EnvVarInfo } from '@/types/hermes'
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'tools' | `config:${string}`
@ -28,7 +28,7 @@ export interface ProviderGroup {
export interface DesktopConfigSection {
id: string
label: string
icon: LucideIcon
icon: IconComponent
keys: string[]
}

View file

@ -2,7 +2,6 @@ import { useStore } from '@nanostores/react'
import type { CSSProperties, ReactNode } from 'react'
import { useSyncExternalStore } from 'react'
import { Backdrop } from '@/components/Backdrop'
import { PaneShell } from '@/components/pane-shell'
import { SidebarProvider } from '@/components/ui/sidebar'
import {
@ -21,9 +20,11 @@ import { TitlebarControls, type TitlebarTool } from './titlebar-controls'
interface AppShellProps {
children: ReactNode
commandCenterOpen?: boolean
leftStatusbarItems?: readonly StatusbarItem[]
leftTitlebarTools?: readonly TitlebarTool[]
onOpenSettings: () => void
onOpenSearch: () => void
overlays?: ReactNode
statusbarItems?: readonly StatusbarItem[]
titlebarTools?: readonly TitlebarTool[]
@ -46,9 +47,11 @@ const viewportIsFullscreen = () =>
export function AppShell({
children,
commandCenterOpen = false,
leftStatusbarItems,
leftTitlebarTools,
onOpenSettings,
onOpenSearch,
overlays,
statusbarItems,
titlebarTools
@ -70,9 +73,9 @@ export function AppShell({
? 0
: titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2)
// The static system cluster (file-browser, haptics, settings) is hardcoded
// in TitlebarControls. Pane-supplied tools (preview's group) render in a
// separate cluster anchored further left.
// The static system cluster (haptics, profiles, settings, right-sidebar) is
// hardcoded in TitlebarControls. Pane-supplied tools (preview's group) render
// in a separate cluster anchored further left.
//
// Width math has to include the `gap-x-1` (0.25rem) between buttons:
// N buttons + (N - 1) inner gaps, plus one extra 0.25rem of breathing room
@ -105,7 +108,7 @@ export function AppShell({
return (
<SidebarProvider
className="h-screen min-h-0 bg-background"
className="h-screen min-h-0 flex-col bg-background"
onOpenChange={setSidebarOpen}
open={sidebarOpen}
style={
@ -127,10 +130,15 @@ export function AppShell({
} as CSSProperties
}
>
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
<TitlebarControls
commandCenterOpen={commandCenterOpen}
leftTools={leftTitlebarTools}
onOpenSearch={onOpenSearch}
onOpenSettings={onOpenSettings}
tools={titlebarTools}
/>
<Backdrop />
<main className="relative z-3 flex h-screen w-full flex-col overflow-hidden pr-0.75 pt-0.75 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

@ -3,16 +3,14 @@ import { useMemo } from 'react'
import type { CommandCenterSection } from '@/app/command-center'
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
import { Activity, AlertCircle, Clock, Command, Cpu, FolderOpen, GitBranch, Hash, Loader2, Sparkles } from '@/lib/icons'
import { Activity, AlertCircle, Clock, Command, Cpu, Hash, Loader2, Sparkles } from '@/lib/icons'
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { compactPath, contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
import { cn } from '@/lib/utils'
import { $desktopActionTasks } from '@/store/activity'
import { $previewServerRestartStatus } from '@/store/preview'
import {
$busy,
$currentBranch,
$currentCwd,
$currentModel,
$currentProvider,
$currentUsage,
@ -30,7 +28,6 @@ import type { StatusbarItem } from '../statusbar-controls'
interface StatusbarItemsOptions {
agentsOpen: boolean
browseSessionCwd: () => Promise<void>
commandCenterOpen: boolean
extraLeftItems: readonly StatusbarItem[]
extraRightItems: readonly StatusbarItem[]
@ -45,7 +42,6 @@ interface StatusbarItemsOptions {
export function useStatusbarItems({
agentsOpen,
browseSessionCwd,
commandCenterOpen,
extraLeftItems,
extraRightItems,
@ -58,8 +54,6 @@ export function useStatusbarItems({
toggleCommandCenter
}: StatusbarItemsOptions) {
const busy = useStore($busy)
const currentBranch = useStore($currentBranch)
const currentCwd = useStore($currentCwd)
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
const currentUsage = useStore($currentUsage)
@ -79,10 +73,10 @@ export function useStatusbarItems({
const gatewayMenuContent = useMemo(
() => (
<GatewayMenuPanel
logLines={gatewayLogLines}
onOpenSystem={() => openCommandCenterSection('system')}
gatewayState={gatewayState}
inferenceStatus={inferenceStatus}
logLines={gatewayLogLines}
onOpenSystem={() => openCommandCenterSection('system')}
statusSnapshot={statusSnapshot}
/>
),
@ -95,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,
@ -108,6 +106,7 @@ export function useStatusbarItems({
const gatewayConnecting = gatewayState === 'connecting'
const inferenceReady = gatewayOpen && inferenceStatus?.ready === true
const gatewayDegraded = gatewayOpen || gatewayConnecting
const gatewayDetail = gatewayOpen
? inferenceStatus?.ready
? 'ready'
@ -117,6 +116,7 @@ export function useStatusbarItems({
: gatewayConnecting
? 'connecting'
: 'offline'
const gatewayClassName = inferenceReady
? undefined
: gatewayDegraded
@ -277,37 +277,9 @@ export function useStatusbarItems({
title: currentProvider ? `Switch model · ${currentProvider}: ${currentModel || ''}` : 'Open model picker',
variant: 'action'
},
{
icon: <FolderOpen className="size-3" />,
id: 'cwd',
label: currentCwd ? compactPath(currentCwd) : 'No project cwd',
onSelect: () => void browseSessionCwd(),
title: currentCwd ? `Change working directory · ${currentCwd}` : 'Choose working directory',
variant: 'action'
},
{
hidden: !currentBranch,
icon: <GitBranch className="size-3" />,
id: 'branch',
label: currentBranch,
title: currentBranch ? `Current branch: ${currentBranch}` : undefined,
variant: 'text'
},
versionItem
],
[
browseSessionCwd,
busy,
contextBar,
contextUsage,
currentBranch,
currentCwd,
currentModel,
currentProvider,
sessionStartedAt,
turnStartedAt,
versionItem
]
[busy, contextBar, contextUsage, currentModel, currentProvider, sessionStartedAt, turnStartedAt, versionItem]
)
const leftStatusbarItems = useMemo(

View file

@ -10,7 +10,7 @@ export function SidebarPanelLabel({ children, className, dotClassName, ...props
return (
<span
className={cn(
'flex min-w-0 items-center gap-2 text-[0.64rem] font-semibold uppercase tracking-[0.16em] text-sidebar-foreground/72',
'flex min-w-0 items-center gap-2 pl-2 text-[0.64rem] font-semibold uppercase tracking-[0.16em] text-(--theme-primary)',
className
)}
{...props}

View file

@ -49,7 +49,7 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr
return (
<footer
className={cn(
'flex h-7 shrink-0 items-stretch justify-between gap-2 border-t border-border/55 bg-[color-mix(in_srgb,var(--dt-muted)_45%,var(--dt-card))] px-1 py-0 text-muted-foreground/95 [-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}
@ -89,7 +89,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
<DropdownMenuTrigger asChild>
<button
className={cn(
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.68rem] text-muted-foreground/95 transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45',
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.6875rem] text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45',
item.className
)}
disabled={item.disabled}
@ -150,7 +150,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
return (
<div
className={cn(
'inline-flex h-full items-center gap-1 px-1.5 text-[0.68rem] text-muted-foreground/90',
'inline-flex h-full items-center gap-1 px-1.5 text-[0.6875rem] text-(--ui-text-tertiary)',
item.className
)}
>
@ -163,7 +163,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
return (
<a
className={cn(
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.68rem] text-muted-foreground/95 transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45',
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.6875rem] text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45',
item.className
)}
href={item.href}
@ -179,7 +179,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
return (
<button
className={cn(
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.68rem] text-muted-foreground/95 transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45',
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.6875rem] text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45',
item.className
)}
disabled={item.disabled}

View file

@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import type { ComponentProps, ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import { Codicon } from '@/components/ui/codicon'
import {
DropdownMenu,
DropdownMenuContent,
@ -11,7 +12,7 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { triggerHaptic } from '@/lib/haptics'
import { FolderOpen, NotebookTabs, Settings, Users, Volume2, VolumeX } from '@/lib/icons'
import { Volume2, VolumeX } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
import { $fileBrowserOpen, $sidebarOpen, toggleFileBrowserOpen, toggleSidebarOpen } from '@/store/layout'
@ -40,10 +41,18 @@ export type SetTitlebarToolGroup = (id: string, tools: readonly TitlebarTool[],
interface TitlebarControlsProps extends ComponentProps<'div'> {
leftTools?: readonly TitlebarTool[]
tools?: readonly TitlebarTool[]
commandCenterOpen?: boolean
onOpenSettings: () => void
onOpenSearch: () => void
}
export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: TitlebarControlsProps) {
export function TitlebarControls({
leftTools = [],
tools = [],
commandCenterOpen = false,
onOpenSettings,
onOpenSearch
}: TitlebarControlsProps) {
const navigate = useNavigate()
const hapticsMuted = useStore($hapticsMuted)
const fileBrowserOpen = useStore($fileBrowserOpen)
@ -63,7 +72,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
const leftToolbarTools: TitlebarTool[] = [
{
icon: <NotebookTabs />,
icon: <Codicon name="layout-sidebar-left" />,
id: 'sidebar',
label: sidebarOpen ? 'Hide sidebar' : 'Show sidebar',
onSelect: () => {
@ -71,21 +80,33 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
toggleSidebarOpen()
}
},
{
active: commandCenterOpen,
icon: <Codicon name="search" />,
id: 'search',
label: 'Search',
onSelect: () => {
triggerHaptic('open')
onOpenSearch()
},
title: 'Search sessions, views, and actions'
},
...leftTools
]
const rightSidebarTool: TitlebarTool = {
active: fileBrowserOpen,
icon: <Codicon name="layout-sidebar-right" />,
id: 'right-sidebar',
label: fileBrowserOpen ? 'Hide right sidebar' : 'Show right sidebar',
onSelect: () => {
triggerHaptic('tap')
toggleFileBrowserOpen()
}
}
// Static system tools — always pinned to the screen's right edge.
const systemTools: TitlebarTool[] = [
{
active: fileBrowserOpen,
icon: <FolderOpen />,
id: 'file-browser',
label: fileBrowserOpen ? 'Hide file browser' : 'Show file browser',
onSelect: () => {
triggerHaptic('tap')
toggleFileBrowserOpen()
}
},
{
active: hapticsMuted,
icon: hapticsMuted ? <VolumeX /> : <Volume2 />,
@ -94,7 +115,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
onSelect: toggleHaptics
},
{
icon: <Settings />,
icon: <Codicon name="settings-gear" />,
id: 'settings',
label: 'Open settings',
onSelect: () => {
@ -113,7 +134,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
<>
<div
aria-label="Window controls"
className="fixed left-(--titlebar-controls-left) top-(--titlebar-controls-top) z-70 flex translate-y-[2px] flex-row items-center gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
className="fixed left-(--titlebar-controls-left) top-(--titlebar-controls-top) z-70 flex translate-y-0.5 flex-row items-center gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
>
{leftToolbarTools
.filter(tool => !tool.hidden)
@ -150,6 +171,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
))}
<ProfilesMenuButton navigate={navigate} />
{settingsTool && <TitlebarToolButton navigate={navigate} tool={settingsTool} />}
<TitlebarToolButton navigate={navigate} tool={rightSidebarTool} />
</div>
</>
)
@ -161,15 +183,12 @@ 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"
>
<Users />
<Codicon name="account" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64" sideOffset={8}>
@ -186,7 +205,7 @@ function ProfilesMenuButton({ navigate }: { navigate: ReturnType<typeof useNavig
navigate(PROFILES_ROUTE)
}}
>
<Users className="size-4" />
<Codicon name="account" size="1rem" />
<span>Manage profiles</span>
</DropdownMenuItem>
</DropdownMenuContent>
@ -198,6 +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-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 hover:bg-accent 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 bg-background/70 px-[max(0.75rem,var(--titlebar-content-inset,0px))] backdrop-blur-sm'
'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 =
"shadow-header after:pointer-events-none after:absolute after:left-0 after:right-0 after:top-full after:h-10 after:bg-linear-to-b after:from-background after:via-background/80 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,20 +3,18 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Codicon } from '@/components/ui/codicon'
import { Switch } from '@/components/ui/switch'
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
import { getSkills, getToolsets, toggleSkill } from '@/hermes'
import { RefreshCw, Search, X } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { PageSearchShell } from '../page-search-shell'
import { asText, includesQuery, prettyName, toolNames } from '../settings/helpers'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { titlebarHeaderBaseClass } from '../shell/titlebar'
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
const SKILLS_MODES = ['skills', 'toolsets'] as const
type SkillsMode = (typeof SKILLS_MODES)[number]
@ -64,14 +62,9 @@ function filteredToolsets(toolsets: ToolsetInfo[], query: string): ToolsetInfo[]
interface SkillsViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup
setTitlebarToolGroup?: SetTitlebarToolGroup
}
export function SkillsView({
setStatusbarItemGroup: _setStatusbarItemGroup,
setTitlebarToolGroup,
...props
}: SkillsViewProps) {
export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: SkillsViewProps) {
const [mode, setMode] = useRouteEnumParam('tab', SKILLS_MODES, 'skills')
const [query, setQuery] = useState('')
@ -99,24 +92,6 @@ export function SkillsView({
void refreshCapabilities()
}, [refreshCapabilities])
useEffect(() => {
if (!setTitlebarToolGroup) {
return
}
setTitlebarToolGroup('skills', [
{
disabled: refreshing,
icon: <RefreshCw className={cn(refreshing && 'animate-spin')} />,
id: 'refresh-skills',
label: refreshing ? 'Refreshing skills' : 'Refresh skills',
onSelect: () => void refreshCapabilities()
}
])
return () => setTitlebarToolGroup('skills', [])
}, [refreshCapabilities, refreshing, setTitlebarToolGroup])
const categories = useMemo(() => {
if (!skills) {
return []
@ -153,7 +128,6 @@ export function SkillsView({
}, [visibleSkills])
const totalSkills = skills?.length || 0
const enabledSkills = skills?.filter(skill => skill.enabled).length || 0
const enabledToolsets = toolsets?.filter(toolset => toolset.enabled).length || 0
async function handleToggleSkill(skill: SkillInfo, enabled: boolean) {
@ -175,50 +149,20 @@ export function SkillsView({
}
return (
<section {...props} className="flex h-full min-w-0 flex-col overflow-hidden rounded-b-[0.9375rem] bg-background">
<header className={titlebarHeaderBaseClass}>
<h2 className="pointer-events-auto text-base font-semibold leading-none tracking-tight">Skills</h2>
<span className="pointer-events-auto text-xs text-muted-foreground">
{enabledSkills}/{totalSkills} enabled
</span>
</header>
<div className="min-h-0 flex-1 overflow-hidden rounded-b-[1.0625rem] border border-border/50 bg-background/85">
<div className="border-b border-border/50 px-4 py-3">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<PageSearchShell
{...props}
filters={
<>
<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 className="ml-auto w-full max-w-sm min-w-64">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
className="h-8 rounded-lg pl-8 pr-8 text-sm"
onChange={event => setQuery(event.target.value)}
placeholder={mode === 'skills' ? 'Search skills...' : 'Search toolsets...'}
value={query}
/>
{query && (
<Button
aria-label="Clear search"
className="absolute right-1 top-1/2 h-6 w-6 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setQuery('')}
size="icon"
type="button"
variant="ghost"
>
<X className="size-3.5" />
</Button>
)}
</div>
</div>
</div>
{mode === 'skills' && categories.length > 0 && (
<div className="mt-2 flex flex-wrap gap-x-2 gap-y-1">
<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>
@ -233,96 +177,113 @@ export function SkillsView({
))}
</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 className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
{list.map(skill => (
<div
className="grid gap-3 px-3 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>
<Switch
checked={skill.enabled}
disabled={savingSkill === skill.name}
onCheckedChange={checked => void handleToggleSkill(skill, checked)}
/>
</div>
))}
</div>
</>
}
onSearchChange={setQuery}
searchPlaceholder={mode === 'skills' ? 'Search skills...' : 'Search toolsets...'}
searchTrailingAction={
<Button
aria-label={refreshing ? 'Refreshing skills' : 'Refresh skills'}
className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground"
disabled={refreshing}
onClick={() => void refreshCapabilities()}
size="icon-xs"
title={refreshing ? 'Refreshing skills' : 'Refresh skills'}
type="button"
variant="ghost"
>
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
</Button>
}
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>
)}
</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-border/40 rounded-lg border border-border/40 bg-background/70">
{visibleToolsets.map(toolset => {
const tools = toolNames(toolset)
const label = asText(toolset.label || toolset.name)
return (
<div className="px-3 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 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-muted px-1.5 py-0.5 font-mono text-[0.65rem] text-muted-foreground"
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>
</section>
<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>
)
}
@ -331,7 +292,7 @@ function StatusPill({ active, children }: { active: boolean; children: string })
<span
className={cn(
'inline-flex items-center rounded-full px-1.5 py-0.5 text-[0.64rem]',
active ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
active ? 'bg-(--ui-bg-tertiary) text-(--ui-text-secondary)' : 'bg-(--ui-bg-quinary) text-(--ui-text-tertiary)'
)}
>
{children}

View file

@ -1,5 +1,6 @@
import type * as React from 'react'
import type { ChatMessage } from '@/lib/chat-messages'
import type { LucideIcon } from '@/lib/icons'
export interface ContextSuggestion {
text: string
@ -51,18 +52,12 @@ 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
label: string
icon: LucideIcon
icon: React.ComponentType<{ className?: string }>
route?: string
action?: 'new-session'
}
@ -70,6 +65,8 @@ export interface SidebarNavItem {
export interface ClientSessionState {
storedSessionId: string | null
messages: ChatMessage[]
branch: string
cwd: string
busy: boolean
awaitingResponse: boolean
streamId: string | null

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,114 +0,0 @@
/**
* Leva-driven palette fine-tuning, dev-mode only.
*
* Two folders (`Theme / Light` and `Theme / Dark`) expose color pickers
* for the most-tweaked surface tokens of the *active* skin. Edits write
* CSS variables directly they're live-only and do not persist or feed
* back into the theme resolver.
*/
import { button, useControls } from 'leva'
import { useMemo } from 'react'
import { getBaseColors, useTheme } from '@/themes/context'
import type { DesktopThemeColors } from '@/themes/types'
/** Curated subset of tokens that materially change the app's look. */
const FIELDS: Array<[keyof DesktopThemeColors, string]> = [
['background', 'background'],
['foreground', 'foreground'],
['card', 'card'],
['muted', 'muted'],
['mutedForeground', 'muted text'],
['primary', 'primary'],
['primaryForeground', 'primary text'],
['secondary', 'secondary'],
['accent', 'accent'],
['border', 'border'],
['ring', 'ring'],
['midground', 'midground'],
['composerRing', 'composer ring'],
['sidebarBackground', 'sidebar bg'],
['userBubble', 'user bubble']
]
const CSS_VARS: Record<keyof DesktopThemeColors, string> = {
background: '--dt-background',
foreground: '--dt-foreground',
card: '--dt-card',
cardForeground: '--dt-card-foreground',
muted: '--dt-muted',
mutedForeground: '--dt-muted-foreground',
popover: '--dt-popover',
popoverForeground: '--dt-popover-foreground',
primary: '--dt-primary',
primaryForeground: '--dt-primary-foreground',
secondary: '--dt-secondary',
secondaryForeground: '--dt-secondary-foreground',
accent: '--dt-accent',
accentForeground: '--dt-accent-foreground',
border: '--dt-border',
input: '--dt-input',
ring: '--dt-ring',
midground: '--dt-midground',
midgroundForeground: '--dt-midground-foreground',
composerRing: '--dt-composer-ring',
destructive: '--dt-destructive',
destructiveForeground: '--dt-destructive-foreground',
sidebarBackground: '--dt-sidebar-bg',
sidebarBorder: '--dt-sidebar-border',
userBubble: '--dt-user-bubble',
userBubbleBorder: '--dt-user-bubble-border'
}
const HEX_RE = /^#[0-9a-f]{6}$/i
// Leva's color picker only renders concrete `#rrggbb` values; non-hex seeds
// (e.g. color-mix(...)) fall back to a dark grey so the swatch is clickable.
const swatch = (value: string | undefined) =>
typeof value === 'string' && HEX_RE.test(value.trim()) ? value : '#444444'
const setVar = (key: keyof DesktopThemeColors, value: string) =>
document.documentElement.style.setProperty(CSS_VARS[key], value)
function buildSchema(skinName: string, mode: 'light' | 'dark') {
const base = getBaseColors(skinName, mode)
const entries: Record<string, unknown> = {}
for (const [key, label] of FIELDS) {
entries[key] = {
value: swatch(base[key]),
label,
transient: false,
onChange: (value: string, _path: string, ctx: { initial: boolean }) => {
if (!ctx.initial) {
setVar(key, value)
}
}
}
}
entries['reset live edits'] = button(() => {
for (const [key] of FIELDS) {
const v = base[key]
if (typeof v === 'string') {
setVar(key, v)
}
}
})
return entries as Parameters<typeof useControls>[1]
}
/** Renders nothing — Leva's UI is a portal driven by `useControls`. */
export function ThemeControls() {
const { themeName } = useTheme()
const light = useMemo(() => buildSchema(themeName, 'light'), [themeName])
const dark = useMemo(() => buildSchema(themeName, 'dark'), [themeName])
useControls('Theme / Light', light, { collapsed: true }, [themeName])
useControls('Theme / Dark', dark, { collapsed: true }, [themeName])
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

@ -8,7 +8,7 @@ import { Fragment, useEffect, useMemo, useState } from 'react'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { extractEmbeddedImages } from '@/lib/embedded-images'
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool', 'line'] as const
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool', 'line', 'terminal'] as const
type HermesRefType = (typeof HERMES_REF_TYPES)[number]
/** Single source of truth for chip icon glyphs (Tabler outline @ 24×24).
@ -37,7 +37,8 @@ const ICON_PATHS: Record<HermesRefType, string[]> = {
'M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3'
],
tool: ['M7 10h3v-3l-3.5 -3.5a6 6 0 0 1 8 8l6 6a2 2 0 0 1 -3 3l-6 -6a6 6 0 0 1 -8 -8l3.5 3.5'],
line: ['M5 9l14 0', 'M5 15l14 0', 'M11 4l-4 16', 'M17 4l-4 16']
line: ['M5 9l14 0', 'M5 15l14 0', 'M11 4l-4 16', 'M17 4l-4 16'],
terminal: ['M5 7l5 5l-5 5', 'M12 19l7 0']
}
const ICON_FALLBACK = ['M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0', 'M16 12v1.5a2.5 2.5 0 0 0 5 0v-1.5a9 9 0 1 0 -5.5 8.28']
@ -112,7 +113,7 @@ export const DIRECTIVE_CHIP_CLASS =
const CANONICAL_DIRECTIVE_RE = /:([\w-]{1,64})\[([^\]\n]{1,1024})\](?:\{name=([^}\n]{1,1024})\})?/g
const HERMES_DIRECTIVE_RE = new RegExp(
'@(file|folder|url|image|tool|line):(' + '`[^`\\n]+`' + '|"[^"\\n]+"' + "|'[^'\\n]+'" + '|\\S+' + ')',
'@(file|folder|url|image|tool|line|terminal):(' + '`[^`\\n]+`' + '|"[^"\\n]+"' + "|'[^'\\n]+'" + '|\\S+' + ')',
'g'
)
@ -248,6 +249,10 @@ function parseDirectiveText(text: string): Unstable_DirectiveSegment[] {
}
function shortLabel(type: HermesRefType, id: string): string {
if (type === 'terminal') {
return id || 'terminal'
}
if (type === 'url') {
try {
const parsed = new URL(id)

View file

@ -12,10 +12,8 @@ import { type ComponentProps, memo, useEffect, useMemo, useState } from 'react'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { SyntaxHighlighter } from '@/components/chat/shiki-highlighter'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { CopyButton } from '@/components/ui/copy-button'
import { normalizeExternalUrl, openExternalLink, PrettyLink } from '@/lib/external-link'
import { createMemoizedMathPlugin } from '@/lib/katex-memo'
import { isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code'
import { preprocessMarkdown } from '@/lib/markdown-preprocess'
import {
filePathFromMediaPath,
@ -42,28 +40,6 @@ import { cn } from '@/lib/utils'
// LLM convention). The default false-setting only accepts `$$...$$`.
const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true })
function CodeHeader({ language, code }: { language?: string; code?: string }) {
const normalizedCode = (code ?? '').replace(/^\n+/, '').trimEnd()
if (!normalizedCode.trim() || isLikelyProseCodeBlock(language, normalizedCode)) {
return null
}
const cleanLanguage = sanitizeLanguageTag(language || '')
const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : ''
return (
<div className="aui-code-header m-0 flex items-stretch justify-between gap-2 rounded-t-md border border-b-0 border-border bg-muted/60 pr-3 text-xs text-muted-foreground">
<span className="flex items-center gap-2.5 py-1.5 pl-3 font-mono uppercase tracking-[0.16em]">
<span className="text-midground/85">{label || 'code'}</span>
</span>
<CopyButton appearance="inline" iconClassName="size-3" label="Copy code" text={normalizedCode}>
Copy
</CopyButton>
</div>
)
}
async function typedBlobUrl(dataUrl: string, mime: string): Promise<string> {
const blob = await fetch(dataUrl).then(response => response.blob())
@ -87,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"
>
@ -145,7 +121,7 @@ function MediaAttachment({ path }: { path: string }) {
if (kind === 'audio' && src) {
return (
<span className="my-3 block max-w-md rounded-xl border border-border/70 bg-card/70 p-3">
<span className="my-3 block max-w-md rounded-xl border border-border bg-muted/35 p-3">
<span className="mb-2 block truncate text-xs font-medium text-muted-foreground">{name}</span>
<audio className="block w-full" controls onError={() => setFailed(true)} preload="metadata" src={src} />
{failed && <OpenMediaButton kind="audio" path={path} />}
@ -155,7 +131,7 @@ function MediaAttachment({ path }: { path: string }) {
if (kind === 'video' && src) {
return (
<span className="my-3 block max-w-2xl rounded-xl border border-border/70 bg-card/70 p-3">
<span className="my-3 block max-w-2xl rounded-xl border border-border bg-muted/35 p-3">
<span className="mb-2 block truncate text-xs font-medium text-muted-foreground">{name}</span>
<video
className="block max-h-112 w-full rounded-lg bg-black"
@ -170,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()
@ -213,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}
@ -250,6 +226,15 @@ function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>)
)
}
// Headings shrink to chat scale rather than the prose default (h1≈xl). Kept
// table-driven so adding/tweaking levels is one row.
const HEADING_SIZES: Record<'h1' | 'h2' | 'h3' | 'h4', string> = {
h1: 'text-[1rem] tracking-tight',
h2: 'text-[0.9375rem] tracking-tight',
h3: 'text-[0.875rem]',
h4: 'text-[0.8125rem]'
}
const MarkdownTextImpl = () => {
const isStreaming = useAuiState(s => s.message.status?.type === 'running')
@ -257,40 +242,44 @@ const MarkdownTextImpl = () => {
() =>
({
h1: ({ className, ...props }: ComponentProps<'h1'>) => (
<h1 className={cn('text-xl font-semibold tracking-tight', className)} {...props} />
<h1 className={cn('my-1 font-semibold', HEADING_SIZES.h1, className)} {...props} />
),
h2: ({ className, ...props }: ComponentProps<'h2'>) => (
<h2 className={cn('text-lg font-semibold tracking-tight', className)} {...props} />
<h2 className={cn('my-1 font-semibold', HEADING_SIZES.h2, className)} {...props} />
),
h3: ({ className, ...props }: ComponentProps<'h3'>) => (
<h3 className={cn('text-base font-semibold', className)} {...props} />
<h3 className={cn('my-1 font-semibold', HEADING_SIZES.h3, className)} {...props} />
),
h4: ({ className, ...props }: ComponentProps<'h4'>) => (
<h4 className={cn('text-sm font-semibold', className)} {...props} />
<h4 className={cn('my-1 font-semibold', HEADING_SIZES.h4, className)} {...props} />
),
p: ({ className, ...props }: ComponentProps<'p'>) => (
<p className={cn('wrap-anywhere leading-(--dt-line-height)', className)} {...props} />
<p className={cn('my-1 wrap-anywhere leading-(--dt-line-height)', className)} {...props} />
),
a: MarkdownLink,
hr: ({ className, ...props }: ComponentProps<'hr'>) => (
<hr className={cn('border-border/70', className)} {...props} />
<hr className={cn('border-border', className)} {...props} />
),
blockquote: ({ className, ...props }: ComponentProps<'blockquote'>) => (
<blockquote
className={cn('border-l-2 border-midground/40 pl-3 text-muted-foreground italic', className)}
className={cn('border-l-2 border-border pl-3 text-muted-foreground italic', className)}
{...props}
/>
),
ul: ({ className, ...props }: ComponentProps<'ul'>) => <ul className={cn(className)} {...props} />,
ol: ({ className, ...props }: ComponentProps<'ol'>) => <ol className={cn(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} />
),
table: ({ className, ...props }: ComponentProps<'table'>) => (
<div className="aui-md-table my-3 max-w-full overflow-x-auto rounded-md border border-border">
<div className="aui-md-table my-2 max-w-full overflow-x-auto rounded-[0.375rem] border border-border">
<table
className={cn(
'm-0 w-full border-collapse text-sm [&_tr]:border-b [&_tr]:border-border last:[&_tr]:border-0',
'm-0 w-full border-collapse text-[0.8125rem] [&_tr]:border-b [&_tr]:border-border last:[&_tr]:border-0',
className
)}
{...props}
@ -298,23 +287,22 @@ const MarkdownTextImpl = () => {
</div>
),
thead: ({ className, ...props }: ComponentProps<'thead'>) => (
<thead className={cn('m-0 bg-muted/50 text-foreground', className)} {...props} />
<thead className={cn('m-0 bg-muted/35 text-muted-foreground', className)} {...props} />
),
th: ({ className, ...props }: ComponentProps<'th'>) => (
<th
className={cn(
'px-3 py-1.5 text-left align-middle text-xs font-semibold uppercase tracking-[0.16em] text-midground/75',
'px-2.5 py-1.5 text-left align-middle text-[0.75rem] font-medium text-muted-foreground',
className
)}
{...props}
/>
),
td: ({ className, ...props }: ComponentProps<'td'>) => (
<td className={cn('px-3 py-2 align-top text-sm leading-snug', className)} {...props} />
<td className={cn('px-2.5 py-1.5 align-top text-[0.8125rem] leading-snug', className)} {...props} />
),
img: MarkdownImage,
SyntaxHighlighter: (props: SyntaxHighlighterProps) => <SyntaxHighlighter {...props} defer={isStreaming} />,
CodeHeader
SyntaxHighlighter: (props: SyntaxHighlighterProps) => <SyntaxHighlighter {...props} defer={isStreaming} />
}) as StreamdownTextComponents,
[isStreaming]
)
@ -324,13 +312,13 @@ const MarkdownTextImpl = () => {
caret="block"
components={components}
containerClassName={cn(
'aui-md prose w-full max-w-none overflow-hidden text-base leading-(--dt-line-height) text-foreground',
'aui-md prose w-full max-w-none overflow-hidden text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground',
'prose-p:leading-(--dt-line-height) prose-li:leading-(--dt-line-height)',
'prose-headings:text-foreground prose-strong:text-foreground',
'prose-a:break-words prose-p:[overflow-wrap:anywhere]',
'prose-li:marker:text-midground/55',
'prose-code:rounded prose-code:border-0 prose-code:bg-muted/80 prose-code:px-0.5 prose-code:py-px prose-code:font-mono prose-code:text-[0.86em] prose-code:text-muted-foreground prose-code:before:content-none prose-code:after:content-none',
'[&>*:last-child]:mb-0'
'prose-li:marker:text-muted-foreground/70',
'prose-code:rounded-[0.25rem] prose-code:px-[0.1875rem] prose-code:py-px prose-code:font-mono prose-code:text-[0.9em] prose-code:font-normal prose-code:before:content-none prose-code:after:content-none',
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-1'
)}
lineNumbers={false}
mode="streaming"
@ -345,7 +333,6 @@ const MarkdownTextImpl = () => {
parseIncompleteMarkdown
plugins={{ math: mathPlugin, ...(isStreaming ? {} : { code }) }}
preprocess={preprocessMarkdown}
shikiTheme={['github-light-default', 'github-dark-default']}
/>
)
}

View file

@ -51,6 +51,34 @@ vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
Element.prototype.scrollTo = function scrollTo() {}
Element.prototype.animate = function animate() {
return {
cancel: () => {},
finished: Promise.resolve()
} as unknown as Animation
}
// jsdom returns 0 for offset*; the virtualizer reads those to size its
// viewport. Fall through to client* (which tests can override) or a sane
// default so virtualized items render.
function stubOffsetDimension(
prop: 'offsetHeight' | 'offsetWidth',
clientProp: 'clientHeight' | 'clientWidth',
fallback: number
) {
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
Object.defineProperty(HTMLElement.prototype, prop, {
configurable: true,
get() {
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
}
})
}
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
async function wait(ms: number) {
await act(async () => {
await new Promise(resolve => window.setTimeout(resolve, ms))
@ -85,6 +113,23 @@ function assistantMessage(text: string, running = true): ThreadMessage {
} as ThreadMessage
}
function assistantErrorMessage(error: string): ThreadMessage {
return {
id: 'assistant-error-1',
role: 'assistant',
content: [],
status: { type: 'incomplete', reason: 'error', error },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function assistantReasoningMessage(text: string): ThreadMessage {
return {
id: 'assistant-reasoning-1',
@ -232,6 +277,20 @@ function TodoHarness({ message }: { message: ThreadMessage }) {
)
}
function MessageHarness({ message }: { message: ThreadMessage }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [message],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
function ReasoningHarness() {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [assistantReasoningMessage(' The user is asking what this file is.')],
@ -311,6 +370,12 @@ describe('assistant-ui streaming renderer', () => {
expect(container.querySelector('[data-slot="aui_composer-clearance"]')).toBeNull()
})
it('renders assistant provider errors inline', () => {
render(<MessageHarness message={assistantErrorMessage('OpenRouter rejected the request (403).')} />)
expect(screen.getByRole('alert').textContent).toContain('OpenRouter rejected the request (403).')
})
it('does not pull the viewport back down after the user scrolls up during streaming', async () => {
const { container } = render(<StreamingHarness />)

View file

@ -0,0 +1,306 @@
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual'
import { type ComponentProps, type FC, type ReactNode, useCallback, useEffect, useMemo, useRef } from 'react'
import { cn } from '@/lib/utils'
import { setThreadScrolledUp } from '@/store/thread-scroll'
const ESTIMATED_ITEM_HEIGHT = 220
const OVERSCAN = 4
const AT_BOTTOM_THRESHOLD = 4
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
type MessageGroup = { id: string; index: number; kind: 'standalone' } | { id: string; indices: number[]; kind: 'turn' }
interface VirtualizedThreadProps {
clampToComposer: boolean
components: ThreadMessageComponents
emptyPlaceholder?: ReactNode
loadingIndicator?: ReactNode
sessionKey?: string | null
}
function buildGroups(signature: string): MessageGroup[] {
if (!signature) {
return []
}
const messages = signature.split('\n').map(row => {
const [index, id, role] = row.split(':')
return { id, index: Number(index), role }
})
const groups: MessageGroup[] = []
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
if (message.role !== 'user') {
groups.push({ id: message.id, index: message.index, kind: 'standalone' })
continue
}
const indices = [message.index]
while (i + 1 < messages.length && messages[i + 1].role !== 'user') {
indices.push(messages[++i].index)
}
groups.push({ id: message.id, indices, kind: 'turn' })
}
return groups
}
export const VirtualizedThread: FC<VirtualizedThreadProps> = ({
clampToComposer,
components,
emptyPlaceholder,
loadingIndicator,
sessionKey
}) => {
const messageSignature = useAuiState(s =>
s.thread.messages.map((message, index) => `${index}:${message.id}:${message.role}`).join('\n')
)
const groups = useMemo(() => buildGroups(messageSignature), [messageSignature])
const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder)
const scrollerRef = useRef<HTMLDivElement | null>(null)
const virtualizer = useVirtualizer({
count: groups.length,
estimateSize: () => ESTIMATED_ITEM_HEIGHT,
getItemKey: index => groups[index]?.id ?? index,
getScrollElement: () => scrollerRef.current,
// Seed the rect so the initial range mounts something before
// `observeElementRect` reports the real layout (it overrides this).
initialRect: { height: 600, width: 800 },
overscan: OVERSCAN
})
useThreadScrollAnchor({
enabled: !renderEmpty,
groupCount: groups.length,
scrollerRef,
sessionKey: sessionKey ?? null,
virtualizer
})
const virtualItems = virtualizer.getVirtualItems()
const totalSize = virtualizer.getTotalSize()
const paddingTop = virtualItems[0]?.start ?? 0
const paddingBottom = Math.max(0, totalSize - (virtualItems.at(-1)?.end ?? 0))
return (
<div
className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
style={{ height: clampToComposer ? 'var(--thread-viewport-height)' : '100%' }}
>
<div
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
data-slot="aui_thread-viewport"
ref={scrollerRef}
>
{renderEmpty ? (
<div
className="mx-auto grid h-full w-full max-w-(--composer-width) grid-rows-[minmax(0,1fr)_auto] min-w-0 gap-(--conversation-turn-gap) px-6 py-8"
data-slot="aui_thread-content"
>
{emptyPlaceholder}
</div>
) : (
<div
className={cn(
'mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6 pt-[calc(var(--titlebar-height)+1.5rem)]'
)}
data-slot="aui_thread-content"
>
{/* Natural-flow virtualization: mounted items render as normal
flex siblings so `position: sticky` on the human bubble
resolves against the scroller without transform interference.
Padding spacers reserve scroll space for unmounted items. */}
<div style={{ paddingBottom: `${paddingBottom}px`, paddingTop: `${paddingTop}px` }}>
{virtualItems.map(virtualItem => {
const group = groups[virtualItem.index]
if (!group) {
return null
}
return (
<div
className="flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)"
data-index={virtualItem.index}
key={virtualItem.key}
ref={virtualizer.measureElement}
>
{group.kind === 'turn' ? (
<div
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
data-slot="aui_turn-pair"
>
{group.indices.map(index => (
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
))}
</div>
) : (
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
)}
</div>
)
})}
</div>
{loadingIndicator}
{clampToComposer && (
<div
aria-hidden="true"
className="shrink-0"
data-slot="aui_composer-clearance"
style={{ height: 'var(--thread-last-message-clearance)' }}
/>
)}
</div>
)}
</div>
</div>
)
}
interface ScrollAnchorOptions {
enabled: boolean
groupCount: number
scrollerRef: React.RefObject<HTMLDivElement | null>
sessionKey: string | null
virtualizer: Virtualizer<HTMLDivElement, Element>
}
function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, virtualizer }: ScrollAnchorOptions) {
// `armed` = parked at bottom, content growth should follow. Cleared on
// user-driven upward scroll; re-armed when they reach bottom again.
const armedRef = useRef(true)
const lastTopRef = useRef(0)
const prevSessionKeyRef = useRef(sessionKey)
const prevGroupCountRef = useRef(0)
const pinToBottom = useCallback(() => {
const el = scrollerRef.current
if (!el) {
return
}
el.scrollTop = el.scrollHeight
lastTopRef.current = el.scrollTop
}, [scrollerRef])
const jumpToBottom = useCallback(() => {
armedRef.current = true
if (groupCount > 0) {
virtualizer.scrollToIndex(groupCount - 1, { align: 'end', behavior: 'auto' })
}
requestAnimationFrame(() => {
if (armedRef.current) {
pinToBottom()
}
})
}, [groupCount, pinToBottom, virtualizer])
useEffect(() => () => setThreadScrolledUp(false), [])
// Track at-bottom state, dim composer when scrolled up, disarm on user
// scroll/wheel/touch.
useEffect(() => {
const el = scrollerRef.current
if (!el) {
return undefined
}
const disarm = () => {
armedRef.current = false
}
const onScroll = () => {
const top = el.scrollTop
if (top + 1 < lastTopRef.current) {
armedRef.current = false
}
lastTopRef.current = top
const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD
if (atBottom) {
armedRef.current = true
}
setThreadScrolledUp(!atBottom)
}
const onWheel = (event: WheelEvent) => {
if (event.deltaY < 0) {
disarm()
}
}
el.addEventListener('scroll', onScroll, { passive: true })
el.addEventListener('wheel', onWheel, { passive: true })
el.addEventListener('touchmove', disarm, { passive: true })
return () => {
el.removeEventListener('scroll', onScroll)
el.removeEventListener('wheel', onWheel)
el.removeEventListener('touchmove', disarm)
}
}, [scrollerRef])
// Follow content growth (streaming, item measurements, loading indicator)
// while armed.
useEffect(() => {
if (!enabled) {
return undefined
}
const el = scrollerRef.current
if (!el) {
return undefined
}
const observer = new ResizeObserver(() => {
if (armedRef.current) {
pinToBottom()
}
})
observer.observe(el)
if (el.firstElementChild) {
observer.observe(el.firstElementChild)
}
return () => observer.disconnect()
}, [enabled, pinToBottom, scrollerRef])
// Jump to bottom on session change OR when an empty thread first gets
// content. Both share the same intent and the same effect.
useEffect(() => {
const sessionChanged = prevSessionKeyRef.current !== sessionKey
const becameNonEmpty = prevGroupCountRef.current === 0 && groupCount > 0
prevSessionKeyRef.current = sessionKey
prevGroupCountRef.current = groupCount
if (enabled && (sessionChanged || becameNonEmpty)) {
jumpToBottom()
}
}, [enabled, groupCount, jumpToBottom, sessionKey])
useAuiEvent('thread.runStart', jumpToBottom)
}

File diff suppressed because it is too large Load diff

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

@ -1,6 +1,4 @@
import { normalizeExternalUrl } from '@/lib/external-link'
import { Command, FileText, Globe, LinkIcon, Search, Sparkles, Wrench } from '@/lib/icons'
import type { LucideIcon } from '@/lib/icons'
import { extractToolErrorMessage, formatToolResultSummary } from '@/lib/tool-result-summary'
export type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web'
@ -31,7 +29,7 @@ export interface ToolView {
detail: string
detailLabel: string
durationLabel?: string
icon: LucideIcon
icon?: string
imageUrl?: string
inlineDiff: string
previewTarget?: string
@ -46,7 +44,7 @@ export interface ToolView {
interface ToolMeta {
done: string
icon: LucideIcon
icon?: string
pending: string
tone: ToolTone
}
@ -63,39 +61,39 @@ export interface MessageRunningStateSlice {
}
const TOOL_META: Record<string, ToolMeta> = {
browser_click: { done: 'Clicked page element', pending: 'Clicking page element', icon: Globe, tone: 'browser' },
browser_fill: { done: 'Filled form field', pending: 'Filling form field', icon: Globe, tone: 'browser' },
browser_navigate: { done: 'Opened page', pending: 'Opening page', icon: Globe, tone: 'browser' },
browser_click: { done: 'Clicked page element', pending: 'Clicking page element', icon: 'globe', tone: 'browser' },
browser_fill: { done: 'Filled form field', pending: 'Filling form field', icon: 'globe', tone: 'browser' },
browser_navigate: { done: 'Opened page', pending: 'Opening page', icon: 'globe', tone: 'browser' },
browser_snapshot: {
done: 'Captured page snapshot',
pending: 'Capturing page snapshot',
icon: Globe,
icon: 'globe',
tone: 'browser'
},
browser_take_screenshot: {
done: 'Captured screenshot',
pending: 'Capturing screenshot',
icon: Sparkles,
icon: 'file-media',
tone: 'browser'
},
browser_type: { done: 'Typed on page', pending: 'Typing on page', icon: Globe, tone: 'browser' },
edit_file: { done: 'Edited file', pending: 'Editing file', icon: FileText, tone: 'file' },
execute_code: { done: 'Ran code', pending: 'Running code', icon: Command, tone: 'terminal' },
image_generate: { done: 'Generated image', pending: 'Generating image', icon: Sparkles, tone: 'image' },
list_files: { done: 'Listed files', pending: 'Listing files', icon: FileText, tone: 'file' },
read_file: { done: 'Read file', pending: 'Reading file', icon: FileText, tone: 'file' },
search_files: { done: 'Searched files', pending: 'Searching files', icon: FileText, tone: 'file' },
browser_type: { done: 'Typed on page', pending: 'Typing on page', icon: 'globe', tone: 'browser' },
edit_file: { done: 'Edited file', pending: 'Editing file', icon: 'edit', tone: 'file' },
execute_code: { done: 'Ran code', pending: 'Running code', icon: 'terminal', tone: 'terminal' },
image_generate: { done: 'Generated image', pending: 'Generating image', icon: 'file-media', tone: 'image' },
list_files: { done: 'Listed files', pending: 'Listing files', icon: 'files', tone: 'file' },
read_file: { done: 'Read file', pending: 'Reading file', icon: 'file', tone: 'file' },
search_files: { done: 'Searched files', pending: 'Searching files', icon: 'search', tone: 'file' },
session_search_recall: {
done: 'Searched session history',
pending: 'Searching session history',
icon: Search,
icon: 'search',
tone: 'agent'
},
terminal: { done: 'Ran command', pending: 'Running command', icon: Command, tone: 'terminal' },
todo: { done: 'Updated todos', pending: 'Updating todos', icon: Wrench, tone: 'agent' },
web_extract: { done: 'Read webpage', pending: 'Reading webpage', icon: LinkIcon, tone: 'web' },
web_search: { done: 'Searched web', pending: 'Searching web', icon: Search, tone: 'web' },
write_file: { done: 'Edited file', pending: 'Editing file', icon: FileText, tone: 'file' }
terminal: { done: 'Ran command', pending: 'Running command', icon: 'terminal', tone: 'terminal' },
todo: { done: 'Updated todos', pending: 'Updating todos', icon: 'tools', tone: 'agent' },
web_extract: { done: 'Read webpage', pending: 'Reading webpage', icon: 'globe', tone: 'web' },
web_search: { done: 'Searched web', pending: 'Searching web', icon: 'search', tone: 'web' },
write_file: { done: 'Edited file', pending: 'Editing file', icon: 'edit', tone: 'file' }
}
const INLINE_CODE_SPLIT_RE = /(`[^`\n]+`)/g
@ -117,9 +115,9 @@ function titleForTool(name: string): string {
)
}
const PREFIX_META: { icon: LucideIcon; prefix: string; tone: ToolTone; verb: string }[] = [
{ prefix: 'browser_', verb: 'Browser', icon: Globe, tone: 'browser' },
{ prefix: 'web_', verb: 'Web', icon: Search, tone: 'web' }
const PREFIX_META: { icon?: string; prefix: string; tone: ToolTone; verb: string }[] = [
{ prefix: 'browser_', verb: 'Browser', icon: 'globe', tone: 'browser' },
{ prefix: 'web_', verb: 'Web', icon: 'globe', tone: 'web' }
]
function toolMeta(name: string): ToolMeta {
@ -137,7 +135,7 @@ function toolMeta(name: string): ToolMeta {
icon: prefix.icon,
tone: prefix.tone
}
: { done: action, pending: `Running ${action.toLowerCase()}`, icon: Wrench, tone: 'default' }
: { done: action, pending: `Running ${action.toLowerCase()}`, tone: 'default' }
}
function isRecord(value: unknown): value is Record<string, unknown> {
@ -836,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 ''
}
@ -1195,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

@ -8,10 +8,12 @@ import { useShallow } from 'zustand/shallow'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { CompactMarkdown } from '@/components/chat/compact-markdown'
import { DiffLines } from '@/components/chat/diff-lines'
import { DisclosureRow } from '@/components/chat/disclosure-row'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { BrailleSpinner } from '@/components/ui/braille-spinner'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import { FadeText } from '@/components/ui/fade-text'
import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
@ -25,11 +27,9 @@ import {
groupCopyText as buildGroupCopyText,
buildToolView,
cleanVisibleText,
compactPreview,
groupFailedStepCount,
groupPreviewTargets,
groupStatus,
groupTailSubtitle,
groupTitle,
groupTotalDurationLabel,
inlineDiffFromResult,
@ -54,27 +54,48 @@ const SPECIAL_TOOL_NAMES = new Set(['todo', 'image_generate', 'clarify'])
// the group already shows.
const ToolEmbedContext = createContext(false)
const STATUS_DOT_CLASS: Record<ToolStatus, string> = {
error: 'bg-destructive',
running: 'bg-muted-foreground/55 animate-pulse',
success: 'bg-emerald-500',
warning: 'bg-amber-500'
}
// Shared header chrome for tool rows. Both the single-tool DisclosureRow
// and the multi-tool group header pass through these constants so a
// "Patch" row and a "Tool actions · 2 steps" row are visually identical.
const TOOL_HEADER_TITLE_CLASS =
'text-[length:var(--conversation-tool-font-size)] font-medium leading-(--conversation-line-height) text-(--ui-text-secondary)'
const STATUS_LABEL: Record<ToolStatus, string> = {
error: 'Error',
running: 'Running',
success: 'Done',
warning: 'Recovered'
}
const TOOL_HEADER_DURATION_CLASS = 'shrink-0 text-[0.625rem] tabular-nums text-(--ui-text-tertiary)'
function statusDot(status: ToolStatus): ReactNode {
return (
<span
aria-label={STATUS_LABEL[status]}
className={cn('size-1.5 shrink-0 rounded-full', STATUS_DOT_CLASS[status])}
/>
)
const TOOL_HEADER_SUBTITLE_CLASS =
'text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)'
const TOOL_HEADER_GLYPH_WRAP_CLASS = 'grid size-3.5 shrink-0 place-items-center self-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)'
// Inset scroll surface for any detail body. The expanded tool row owns the
// border; the payload itself is just clipped raw text.
const TOOL_SECTION_SURFACE_CLASS =
'max-h-20 max-w-full overflow-auto bg-transparent px-2 py-1.5 text-(--ui-text-secondary)'
const TOOL_SECTION_PRE_CLASS = cn(TOOL_SECTION_SURFACE_CLASS, 'font-mono text-[0.7rem] leading-relaxed')
function rawTechnicalTrace(args: unknown, result: unknown): string {
const parts = [args, result]
.filter(value => value !== undefined && value !== null)
.map(value => {
if (typeof value === 'string') {
return value
}
try {
return JSON.stringify(value)
} catch {
return String(value)
}
})
.filter(Boolean)
return parts.join('\n')
}
function statusGlyph(status: ToolStatus): ReactNode {
@ -82,7 +103,7 @@ function statusGlyph(status: ToolStatus): ReactNode {
return (
<BrailleSpinner
ariaLabel="Running"
className="size-3.5 shrink-0 text-[0.95rem] text-muted-foreground/80"
className="size-3.5 shrink-0 text-[0.95rem] text-(--ui-text-tertiary)"
spinner="breathe"
/>
)
@ -99,6 +120,29 @@ function statusGlyph(status: ToolStatus): ReactNode {
return <CheckCircle2 aria-label="Done" className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85" />
}
// Leading glyph for any tool-row header. Status (running/error/warning)
// takes precedence; otherwise falls back to the tool's codicon. Returns
// null when neither applies so callers can render unconditionally.
function ToolGlyph({ icon, status }: { icon?: string; status?: ToolStatus }) {
const node = status ? (
statusGlyph(status)
) : icon ? (
<Codicon className="text-(--ui-text-tertiary)" name={icon} size="0.875rem" />
) : null
return node ? <span className={TOOL_HEADER_GLYPH_WRAP_CLASS}>{node}</span> : null
}
// 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'
}
return status === 'success' ? undefined : status
}
function SearchResultsList({ hits }: { hits: SearchResultRow[] }) {
return (
<ol className="m-0 grid list-none gap-2.5 p-0">
@ -110,17 +154,15 @@ function SearchResultsList({ hits }: { hits: SearchResultRow[] }) {
<li className="grid min-w-0 gap-0.5" key={key}>
{hit.url ? (
<PrettyLink
className="block max-w-full text-[0.78rem] leading-snug"
className={cn(TOOL_HEADER_TITLE_CLASS, 'block max-w-full')}
fallbackLabel={trimmedTitle || urlSlugTitleLabel(hit.url)}
href={hit.url}
label={trimmedTitle || undefined}
/>
) : (
<span className="text-[0.78rem] font-medium leading-snug text-foreground/85">{trimmedTitle}</span>
)}
{hit.snippet && (
<p className="m-0 line-clamp-3 text-[0.7rem] leading-snug text-muted-foreground/85">{hit.snippet}</p>
<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>}
</li>
)
})}
@ -156,7 +198,6 @@ function ToolEntry({ part }: ToolEntryProps) {
// handles its own enter animation, so embedded children skip it.
const enterRef = useEnterAnimation(messageRunning && !embedded, `tool-entry:${disclosureId}`)
const elapsed = useElapsedSeconds(isPending, `tool:${disclosureId}`)
const preview = compactPreview(part.args) || compactPreview(part.result)
const liveDiffs = useStore($toolInlineDiffs)
const sideDiff = part.toolCallId ? liveDiffs[part.toolCallId] || '' : ''
const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(part.result)
@ -224,97 +265,67 @@ function ToolEntry({ part }: ToolEntryProps) {
toolViewMode === 'technical'
)
const isTerminalLike = part.toolName === 'terminal' || part.toolName === 'execute_code'
const subtitleText = view.subtitle ? (toolViewMode === 'technical' ? preview || view.subtitle : view.subtitle) : ''
const subtitleIsSingleLine = !subtitleText.includes('\n')
const showStatusGlyph = isPending || view.status === 'error' || view.status === 'warning'
const copyAction = useMemo(() => toolCopyPayload(part, view), [part, view])
const trailing =
isPending && !embedded ? (
<ActivityTimerText className="text-[0.625rem] tabular-nums text-muted-foreground/55" seconds={elapsed} />
<ActivityTimerText className={TOOL_HEADER_DURATION_CLASS} seconds={elapsed} />
) : !isPending && copyAction.text ? (
<CopyButton appearance="tool-row" label={copyAction.label} stopPropagation text={copyAction.text} />
) : undefined
return (
<div
className="min-w-0 max-w-full overflow-hidden text-sm text-muted-foreground"
className={cn(
'min-w-0 max-w-full overflow-hidden text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)',
open && 'rounded-[0.625rem] border border-(--ui-stroke-tertiary)'
)}
data-slot="tool-block"
ref={enterRef}
>
<DisclosureRow
onToggle={hasExpandableContent ? () => setToolDisclosureOpen(disclosureId, !open) : undefined}
open={open}
trailing={trailing}
>
<span className="flex min-w-0 items-baseline gap-1.5">
{showStatusGlyph && (
<span className="flex h-[1.1rem] shrink-0 items-center">
{statusGlyph(isPending ? 'running' : view.status)}
</span>
)}
<FadeText
className={cn(
'text-[0.78rem] font-medium leading-[1.1rem] text-foreground/85',
isPending && 'shimmer text-foreground/55',
view.status === 'error' && 'text-destructive',
view.status === 'warning' && 'text-amber-700 dark:text-amber-300'
)}
>
{view.title}
</FadeText>
{!isPending && view.countLabel && (
<span className="shrink-0 text-[0.68rem] tabular-nums text-foreground/70">{view.countLabel}</span>
)}
{!isPending && view.durationLabel && (
<span className="shrink-0 text-[0.625rem] tabular-nums text-midground/60 tracking-[0.04em]">
{view.durationLabel}
</span>
)}
</span>
{subtitleText &&
(subtitleIsSingleLine ? (
<div className={cn(open && 'border-b border-(--ui-stroke-tertiary) px-2 py-1.5')}>
<DisclosureRow
onToggle={hasExpandableContent ? () => setToolDisclosureOpen(disclosureId, !open) : undefined}
open={open}
trailing={trailing}
>
<span className="flex min-w-0 items-center gap-1.5">
<ToolGlyph icon={view.icon} status={leadingStatus(isPending, view.status)} />
<FadeText
className={cn(
'text-[0.7rem] leading-[1.05rem] text-muted-foreground/70',
isTerminalLike && 'font-mono text-[0.68rem]'
TOOL_HEADER_TITLE_CLASS,
isPending && 'shimmer text-(--ui-text-tertiary)',
view.status === 'error' && 'text-destructive',
view.status === 'warning' && 'text-amber-700 dark:text-amber-300'
)}
>
{subtitleText}
{view.title}
</FadeText>
) : (
<span
className={cn(
'line-clamp-2 block whitespace-pre-wrap text-[0.7rem] leading-[1.05rem] text-muted-foreground/70',
isTerminalLike && 'font-mono text-[0.68rem]'
)}
>
{subtitleText}
</span>
))}
</DisclosureRow>
{!isPending && view.countLabel && <span className={TOOL_HEADER_DURATION_CLASS}>{view.countLabel}</span>}
{!isPending && view.durationLabel && (
<span className={TOOL_HEADER_DURATION_CLASS}>{view.durationLabel}</span>
)}
</span>
</DisclosureRow>
</div>
{open && (
<div className={cn('mt-2 grid w-full min-w-0 max-w-full gap-2 overflow-hidden pb-2 pr-2 pl-3')}>
<div className="grid w-full min-w-0 max-w-full gap-1.5 overflow-hidden p-1.5">
{!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && (
<PreviewAttachment source="tool-result" target={view.previewTarget} />
)}
{view.imageUrl && (
<div className="max-w-72 overflow-hidden rounded-lg border border-border/70">
<div className="max-w-72 overflow-hidden rounded-[0.25rem] border border-(--ui-stroke-tertiary)">
<ZoomableImage alt="Tool output" className="h-auto w-full object-cover" src={view.imageUrl} />
</div>
)}
{hasSearchHits && view.searchHits && (
<div className="max-w-full text-xs leading-relaxed text-muted-foreground/90">
{searchResultsLabel && (
<p className="mb-1 text-[0.66rem] font-medium uppercase tracking-[0.06em] text-muted-foreground/65">
{searchResultsLabel}
</p>
)}
<div className="max-w-full text-xs leading-relaxed text-(--ui-text-secondary)">
{searchResultsLabel && <p className={TOOL_SECTION_LABEL_CLASS}>{searchResultsLabel}</p>}
<SearchResultsList hits={view.searchHits} />
</div>
)}
{showDetail &&
toolViewMode !== 'technical' &&
(view.status === 'error' ? (
detailSections.summary || detailSections.body ? (
<div className="max-w-full text-xs leading-relaxed text-destructive">
@ -334,53 +345,31 @@ function ToolEntry({ part }: ToolEntryProps) {
</div>
) : null
) : (
<div className="max-w-full text-xs leading-relaxed text-muted-foreground/90">
{view.detailLabel && (
<p className="mb-1 text-[0.66rem] font-medium uppercase tracking-[0.06em] text-muted-foreground/65">
{view.detailLabel}
</p>
)}
<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="max-h-56 max-w-full overflow-auto whitespace-pre-wrap wrap-anywhere border-l-2 border-border/50 pl-3 font-mono text-[0.7rem] leading-[1.55] text-foreground/85">
{view.detail}
</pre>
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>{view.detail}</pre>
) : (
<CompactMarkdown text={view.detail} />
<CompactMarkdown className={cn(TOOL_SECTION_SURFACE_CLASS, 'wrap-anywhere')} text={view.detail} />
)}
</div>
))}
{showRawSearchDrilldown && (
<details className="max-w-full rounded-md border border-border/60 bg-background/55 px-2 py-1.5">
<summary className="cursor-pointer text-[0.65rem] font-medium uppercase tracking-[0.06em] text-muted-foreground/70">
Raw response
</summary>
<pre className="mt-2 max-h-56 max-w-full overflow-auto whitespace-pre-wrap wrap-anywhere font-mono text-[0.66rem] leading-normal text-muted-foreground/90">
<details className="max-w-full">
<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>
</details>
)}
{toolViewMode === 'technical' && (
<div className="grid gap-2">
<JsonSection label="Input" value={view.rawArgs} />
{part.result !== undefined && <JsonSection label="Output" value={view.rawResult} />}
</div>
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
{rawTechnicalTrace(part.args, part.result)}
</pre>
)}
</div>
)}
{view.inlineDiff && <InlineDiff text={view.inlineDiff} />}
</div>
)
}
function JsonSection({ label, value }: { label: string; value: string }) {
return (
<div>
<div className="mb-1 text-[0.65rem] font-medium tracking-[0.08em] text-muted-foreground/75 uppercase">
{label}
</div>
<pre className="max-h-56 max-w-full overflow-auto rounded-md border border-border/70 bg-background/65 p-2 font-mono text-[0.65rem] leading-relaxed text-muted-foreground/90">
{value}
</pre>
{view.inlineDiff && <DiffLines text={view.inlineDiff} />}
</div>
)
}
@ -422,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)
@ -454,10 +444,8 @@ export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex:
? '1 step failed'
: `${failedStepCount} steps failed`
const tailSummary = useMemo(() => groupTailSubtitle(visibleParts), [visibleParts])
const groupCopyText = useMemo(() => buildGroupCopyText(visibleParts), [visibleParts])
const previewTargets = useMemo(() => groupPreviewTargets(visibleParts), [visibleParts])
const showGroupStatusGlyph = displayStatus !== 'success'
return (
<ToolEmbedContext.Provider value={isGroup}>
@ -473,34 +461,23 @@ export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex:
) : undefined
}
>
<span className="flex min-w-0 items-baseline gap-1.5">
{showGroupStatusGlyph && (
<span className="flex h-[1.1rem] shrink-0 items-center">{statusGlyph(displayStatus)}</span>
)}
<span className="flex min-w-0 items-center gap-1.5">
<ToolGlyph status={displayStatus === 'success' ? undefined : displayStatus} />
<FadeText
className={cn(
'text-[0.78rem] font-medium leading-[1.1rem] text-foreground/85',
TOOL_HEADER_TITLE_CLASS,
displayStatus === 'error' && 'text-destructive',
displayStatus === 'warning' && 'text-amber-700 dark:text-amber-300'
)}
>
{groupTitle(visibleParts)}
</FadeText>
{totalDurationLabel && (
<span className="shrink-0 text-[0.625rem] tabular-nums text-muted-foreground/55">
{totalDurationLabel}
</span>
)}
{totalDurationLabel && <span className={TOOL_HEADER_DURATION_CLASS}>{totalDurationLabel}</span>}
</span>
{tailSummary && (
<FadeText className="text-[0.7rem] leading-[1.05rem] text-muted-foreground/70">
{tailSummary.replace(/\n+/g, ' · ')}
</FadeText>
)}
{statusSummary && (
<FadeText
className={cn(
'text-[0.68rem] leading-[1.05rem]',
TOOL_HEADER_SUBTITLE_CLASS,
displayStatus === 'warning' ? 'text-amber-700/80 dark:text-amber-300/85' : 'text-destructive/85'
)}
>
@ -538,31 +515,3 @@ export const ToolFallback = ({ toolCallId, toolName, args, isError, result }: To
return <ToolEntry part={part} />
}
function InlineDiff({ text }: { text: string }) {
return (
<pre className="mt-2 max-h-96 max-w-full min-w-0 overflow-auto rounded-lg border border-border/60 bg-background/70 px-3 py-2 font-mono text-[0.6875rem] leading-relaxed">
{text.split('\n').map((line, index) => {
const added = line.startsWith('+') && !line.startsWith('+++')
const removed = line.startsWith('-') && !line.startsWith('---')
const hunk = line.startsWith('@@')
const fileHeader = line.startsWith('---') || line.startsWith('+++') || / → /.test(line.slice(0, 60))
return (
<span
className={cn(
'block min-w-max whitespace-pre',
added && 'text-emerald-700 dark:text-emerald-300',
removed && 'text-rose-700 dark:text-rose-300',
hunk && 'text-sky-700 dark:text-sky-300',
!added && !removed && !hunk && fileHeader && 'text-muted-foreground/80'
)}
key={`${index}-${line}`}
>
{line || ' '}
</span>
)
})}
</pre>
)
}

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

@ -0,0 +1,78 @@
import * as React from 'react'
import { Codicon, type CodiconProps } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
/**
* Rounded-card shell for fenced code (and any equivalent: diffs, raw payloads,
* etc.) sized for the conversation column. Mirrors the expanded tool-row
* pattern so code blocks read as the same family of artifact.
*/
function CodeCard({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn(
'min-w-0 max-w-full overflow-hidden rounded-[0.625rem] border border-border text-[length:var(--conversation-tool-font-size)] text-muted-foreground',
className
)}
data-slot="code-card"
{...props}
/>
)
}
function CodeCardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('flex items-center justify-between gap-2 border-b border-border px-2 py-1.5', className)}
data-slot="code-card-header"
{...props}
/>
)
}
function CodeCardTitle({ className, children, ...props }: React.ComponentProps<'span'>) {
return (
<span
className={cn(
'flex min-w-0 items-center gap-1.5 truncate text-[length:var(--conversation-tool-font-size)] font-medium leading-(--conversation-line-height) text-foreground/80',
className
)}
data-slot="code-card-title"
{...props}
>
{children}
</span>
)
}
function CodeCardIcon({ className, ...props }: CodiconProps) {
return (
<Codicon
className={cn('shrink-0 text-[0.875rem] leading-none text-muted-foreground', className)}
data-slot="code-card-icon"
{...props}
/>
)
}
function CodeCardSubtitle({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span className={cn('font-normal text-muted-foreground', className)} data-slot="code-card-subtitle" {...props} />
)
}
function CodeCardBody({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn(
'p-1.5 font-mono text-[0.7rem] leading-relaxed text-foreground/90 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-2 [&_pre]:py-1.5 [&_pre]:font-mono [&_pre]:leading-relaxed',
className
)}
data-slot="code-card-body"
{...props}
/>
)
}
export { CodeCard, CodeCardBody, CodeCardHeader, CodeCardIcon, CodeCardSubtitle, CodeCardTitle }

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

@ -0,0 +1,54 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
/**
* Per-line classed renderer for unified diffs. Lives outside `CodeCard` so
* tool-result panels (already nested inside a tool card) don't double-shell;
* for markdown ` ```diff ` fences the standard `CodeCard` + Shiki path runs
* instead and gives equivalent coloring.
*/
interface DiffLineKind {
className?: string
match: (line: string) => boolean
}
const DIFF_LINE_KINDS: DiffLineKind[] = [
{
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('@@') },
{
className: 'text-muted-foreground/70',
match: line => line.startsWith('---') || line.startsWith('+++') || / → /.test(line.slice(0, 60))
}
]
function classifyLine(line: string): string | undefined {
return DIFF_LINE_KINDS.find(kind => kind.match(line))?.className
}
interface DiffLinesProps extends Omit<React.ComponentProps<'pre'>, 'children'> {
text: string
}
export function DiffLines({ className, text, ...props }: DiffLinesProps) {
return (
<pre
className={cn(
'mt-2 max-h-96 max-w-full min-w-0 overflow-auto rounded-md border border-border/60 bg-muted/35 px-2.5 py-1.5 font-mono text-[0.7rem] leading-relaxed text-muted-foreground',
className
)}
data-slot="diff-lines"
{...props}
>
{text.split('\n').map((line, index) => (
<span className={cn('block min-w-max whitespace-pre', classifyLine(line))} key={`${index}-${line}`}>
{line || ' '}
</span>
))}
</pre>
)
}

View file

@ -1,13 +1,13 @@
import { ChevronRight } from 'lucide-react'
import type { ReactNode } from 'react'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { cn } from '@/lib/utils'
// Shared header row for any collapsible block (thinking, tool group, single
// tool). Each parent supplies its own outer wrapper (with the data-slot CSS
// uses to escape the message padding) and its own expanded body.
//
// Cursor-style affordance:
// Affordance:
// - No leading chevron; a caret appears to the RIGHT of the text on hover
// (and stays visible when the row is open).
// - The hover background is a tight content-shaped pill — sized to the
@ -26,13 +26,13 @@ export function DisclosureRow({
trailing?: ReactNode
}) {
return (
<div className="group/disclosure-row relative flex w-full max-w-full min-w-0 text-muted-foreground">
<div className="group/disclosure-row relative flex w-full max-w-full min-w-0 text-(--ui-text-tertiary)">
<button
aria-expanded={onToggle ? open : undefined}
className={cn(
// max-w-fit so the click target hugs the title text width — no
// background fill, just the cursor + the affordance caret.
'flex min-w-0 max-w-fit items-start gap-2 text-left transition-colors',
'flex min-w-0 max-w-fit items-start gap-1.5 text-left transition-colors',
onToggle
? 'cursor-pointer hover:text-foreground focus-visible:text-foreground focus-visible:outline-none'
: 'cursor-default'
@ -41,31 +41,25 @@ export function DisclosureRow({
onClick={onToggle}
type="button"
>
<span className="flex min-w-0 flex-col">{children}</span>
<span className="flex min-w-0 flex-col gap-0.5">{children}</span>
{onToggle && (
// Wrapper height matches the title row's line-height so the caret
// is vertically centred with the title (not with the full stack
// when a subtitle wraps below).
// Wrapper height matches the title row's actual line-height so the
// caret centres with the title, not the whole subtitle stack.
<span
className={cn(
'flex h-[1.1rem] shrink-0 items-center justify-center transition-opacity duration-150',
'flex h-(--conversation-line-height) shrink-0 items-center justify-center transition-opacity duration-150',
open
? 'opacity-80'
: 'opacity-0 group-hover/disclosure-row:opacity-80 group-focus-within/disclosure-row:opacity-80'
)}
>
<ChevronRight
aria-hidden
className={cn('size-3.5 transition-transform duration-150', open && 'rotate-90')}
// currentColor + a chunkier stroke so the caret reads as a
// confident hover affordance instead of a hairline.
color="currentColor"
strokeWidth={2.75}
/>
<DisclosureCaret open={open} />
</span>
)}
</button>
{trailing && <span className="absolute right-1 top-0.5 flex h-[1.1rem] items-center">{trailing}</span>}
{trailing && (
<span className="absolute right-1 top-0 flex h-(--conversation-line-height) items-center">{trailing}</span>
)}
</div>
)
}

View file

@ -1,4 +1,4 @@
import { useState } from 'react'
import { type CSSProperties, useState } from 'react'
import introCopyJsonl from './intro-copy.jsonl?raw'
@ -161,9 +161,17 @@ export function Intro({ personality, seed }: IntroProps) {
className="pointer-events-none flex w-full min-w-0 flex-col items-center justify-center px-3 py-6 text-center text-muted-foreground sm:px-6 lg:px-8"
data-slot="aui_intro"
>
<div className="w-full min-w-0 max-w-xl">
<p className="mb-3 font-['Collapse'] text-[clamp(3.25rem,4.6dvw,4.875rem)] font-bold uppercase leading-[0.95] tracking-wider text-midground mix-blend-plus-lighter dark:text-foreground/90">
Hermes Agent
<div className="w-full min-w-0">
<p
className="fit-text mx-auto mb-3 w-4/5 font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
style={
{ '--fit-text-line-height': '0.9', '--fit-text-max': '8rem', '--fit-text-min': '2.75rem' } as CSSProperties
}
>
<span>
<span>HERMES AGENT</span>
</span>
<span aria-hidden="true">HERMES AGENT</span>
</p>
<p className="m-0 text-center leading-normal tracking-tight">{copy.body}</p>

View file

@ -4,72 +4,89 @@ import type { SyntaxHighlighterProps } from '@assistant-ui/react-streamdown'
import type { FC } from 'react'
import ShikiHighlighter from 'react-shiki'
import { isLikelyProseCodeBlock } from '@/lib/markdown-code'
import {
CodeCard,
CodeCardBody,
CodeCardHeader,
CodeCardIcon,
CodeCardSubtitle,
CodeCardTitle
} from '@/components/chat/code-card'
import { CopyButton } from '@/components/ui/copy-button'
import { codiconForLanguage, isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code'
/**
* assistant-ui's recommended `SyntaxHighlighter` slot.
* Streamdown's code adapter renders header + body as inline siblings, so we
* own the wrapping `<CodeCard>` here and neutralize the upstream
* `data-streamdown="code-block"` chrome from styles.css. Anything that wants
* a card-shaped code surface should compose `CodeCard*` directly.
*
* Uses the full `react-shiki` bundle so all `bundledLanguages` work
* (rust, go, swift, kotlin, sql, etc.) the `/web` subpath only ships
* common web languages and silently falls back to plain text otherwise.
*
* Theme switching is automatic via the CSS `color-scheme` on `:root`
* (set from the desktop theme provider).
*
* `showLanguage` is disabled because we render our own `CodeHeader`;
* leaving it on causes the language to appear twice.
* `react-shiki` full bundle so all `bundledLanguages` work; theme switches
* follow the document `color-scheme` via `defaultColor="light-dark()"`.
*/
interface HermesSyntaxHighlighterProps extends SyntaxHighlighterProps {
defer?: boolean
}
const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
components: { Pre, Code: _UnusedCode },
components: { Pre },
language,
code,
defer = false
}) => {
const preClassName =
'aui-shiki m-0 overflow-hidden rounded-b-md border border-t-0 border-border bg-card font-mono text-sm leading-relaxed [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-4 [&_pre]:py-3 [&_pre]:font-mono [&_pre]:leading-relaxed'
// Streamdown may hand us fence contents with edge newlines. Strip blank
// fence padding without touching indentation on the first real line.
const trimmed = (code ?? '').replace(/^\n+/, '').trimEnd()
// Avoid rendering an empty code card while Streamdown is still deciding
// whether a transient/incomplete fence is real markdown.
// Streaming may hand us empty/incomplete fences — render nothing rather
// than a transient empty card.
if (!trimmed.trim()) {
return null
}
if (isLikelyProseCodeBlock(language, trimmed)) {
return <div className="whitespace-pre-wrap wrap-anywhere text-foreground">{trimmed}</div>
return <div className="aui-prose-fence whitespace-pre-wrap wrap-anywhere text-foreground">{trimmed}</div>
}
if (defer) {
return (
<Pre className={preClassName}>
<code className="block whitespace-pre">{trimmed}</code>
</Pre>
)
}
const cleanLanguage = sanitizeLanguageTag(language || '')
const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : ''
return (
<Pre className={preClassName}>
<ShikiHighlighter
addDefaultStyles={false}
as="div"
defaultColor="light-dark()"
delay={120}
language={language || 'text'}
showLanguage={false}
theme={{
light: 'github-light-default',
dark: 'github-dark-default'
}}
>
{trimmed}
</ShikiHighlighter>
</Pre>
<CodeCard>
<CodeCardHeader>
<CodeCardTitle>
<CodeCardIcon name={codiconForLanguage(label)} />
Code
{label && <CodeCardSubtitle> · {label}</CodeCardSubtitle>}
</CodeCardTitle>
<CopyButton
appearance="inline"
className="-my-1 -mr-1 h-5 px-1 opacity-55 hover:opacity-100"
iconClassName="size-2.5"
label="Copy code"
showLabel={false}
text={trimmed}
/>
</CodeCardHeader>
<CodeCardBody>
<Pre className="aui-shiki m-0 overflow-hidden bg-transparent p-0">
{defer ? (
<code className="block whitespace-pre">{trimmed}</code>
) : (
<ShikiHighlighter
addDefaultStyles={false}
as="div"
defaultColor="light-dark()"
delay={120}
language={language || 'text'}
showLanguage={false}
theme={SHIKI_THEME}
>
{trimmed}
</ShikiHighlighter>
)}
</Pre>
</CodeCardBody>
</CodeCard>
)
}

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-background/80 p-6 backdrop-blur-xl">
<div className="w-full max-w-2xl overflow-hidden rounded-3xl border border-border bg-card/95 shadow-2xl">
<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,14 +209,14 @@ function Preparing({ boot }: { boot: DesktopBootState }) {
function Header() {
return (
<div className="border-b border-border bg-muted/30 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-11 shrink-0 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<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" />
</div>
<div>
<h2 className="text-lg font-semibold tracking-tight">Welcome to Hermes</h2>
<p className="mt-1 max-w-xl text-sm leading-6 text-muted-foreground">
<h2 className="text-[0.9375rem] font-semibold tracking-tight">Welcome to Hermes</h2>
<p className="mt-1 max-w-xl text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
Connect a model provider to start chatting. Most options take one click.
</p>
</div>
@ -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

@ -2,9 +2,10 @@ import { useStore } from '@nanostores/react'
import { type ReactNode, useEffect, useRef, useState } from 'react'
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 LucideIcon, X } from '@/lib/icons'
import { AlertCircle, AlertTriangle, CheckCircle2, type IconComponent, Info } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$notifications,
@ -16,7 +17,7 @@ import {
type ToneVariant = 'default' | 'destructive' | 'warning' | 'success'
const tone: Record<NotificationKind, { icon: LucideIcon; iconClass: string; variant: ToneVariant }> = {
const tone: Record<NotificationKind, { icon: IconComponent; iconClass: string; variant: ToneVariant }> = {
error: { icon: AlertCircle, iconClass: 'text-destructive', variant: 'destructive' },
warning: { icon: AlertTriangle, iconClass: 'text-primary', variant: 'warning' },
info: { icon: Info, iconClass: 'text-muted-foreground', variant: 'default' },
@ -122,7 +123,7 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
onClick={() => dismissNotification(notification.id)}
type="button"
>
<X className="size-3.5" />
<Codicon name="close" size="0.875rem" />
</button>
</Alert>
)

View file

@ -54,6 +54,7 @@ interface CollectedPane {
defaultOpen: boolean
disabled: boolean
id: string
resizable: boolean
side: PaneSide
width: string
}
@ -110,6 +111,7 @@ function collectPanes(children: ReactNode) {
defaultOpen: props.defaultOpen ?? true,
disabled: props.disabled ?? false,
id: props.id,
resizable: props.resizable ?? false,
side: props.side,
width: widthToCss(props.width, DEFAULT_WIDTH)
}
@ -128,7 +130,7 @@ function trackForPane(pane: CollectedPane, states: Record<string, { open: boolea
return { open: false, track: '0px' }
}
const override = states[pane.id]?.widthOverride
const override = pane.resizable ? states[pane.id]?.widthOverride : undefined
return { open: true, track: override !== undefined ? `${override}px` : pane.width }
}
@ -286,14 +288,14 @@ export function Pane({
aria-label={`Resize ${id}`}
aria-orientation="vertical"
className={cn(
'group absolute bottom-0 top-0 z-10 w-3 cursor-col-resize [-webkit-app-region:no-drag]',
'group absolute bottom-0 top-0 z-20 w-1 cursor-col-resize [-webkit-app-region:no-drag]',
slot.side === 'left' ? 'right-0 translate-x-1/2' : 'left-0 -translate-x-1/2'
)}
onPointerDown={startResize}
role="separator"
tabIndex={0}
>
<span className="absolute left-1/2 top-1/2 h-18 w-0.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground/80 opacity-0 transition-opacity duration-100 group-hover:opacity-[0.65] group-focus-visible:opacity-[0.65]" />
<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

@ -1,7 +1,7 @@
import { Checkbox as CheckboxPrimitive } from 'radix-ui'
import * as React from 'react'
import { CheckIcon } from '@/lib/icons'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
@ -18,7 +18,7 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
className="flex items-center justify-center text-current"
data-slot="checkbox-indicator"
>
<CheckIcon className="size-3.5" />
<Codicon name="check" size="0.875rem" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)

View file

@ -0,0 +1,20 @@
import type * as React from 'react'
import { cn } from '@/lib/utils'
export interface CodiconProps extends React.HTMLAttributes<HTMLElement> {
name: string
size?: number | string
spinning?: boolean
}
export function Codicon({ className, name, size, spinning, style, ...props }: CodiconProps) {
return (
<i
aria-hidden="true"
className={cn('codicon', `codicon-${name}`, spinning && 'codicon-modifier-spin', className)}
style={{ fontSize: size, ...style }}
{...props}
/>
)
}

View file

@ -0,0 +1,141 @@
import { ContextMenu as ContextMenuPrimitive } from 'radix-ui'
import * as React from 'react'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
}
function ContextMenuTrigger({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
}
function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
}
function ContextMenuContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
className={cn(
'z-50 max-h-(--radix-context-menu-content-available-height) min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 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="context-menu-content"
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
}) {
return (
<ContextMenuPrimitive.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-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}
data-slot="context-menu-item"
data-variant={variant}
{...props}
/>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
className={cn('px-2 py-1 text-xs font-medium text-(--ui-text-tertiary) data-[inset]:pl-7', className)}
data-inset={inset}
data-slot="context-menu-label"
{...props}
/>
)
}
function ContextMenuSeparator({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
className={cn('-mx-1 my-1 h-px bg-(--ui-stroke-tertiary)', className)}
data-slot="context-menu-separator"
{...props}
/>
)
}
function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.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-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}
data-slot="context-menu-sub-trigger"
{...props}
>
{children}
<Codicon className="ml-auto text-(--ui-text-tertiary)" name="chevron-right" size="1rem" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
className={cn(
'z-50 min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 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="context-menu-sub-content"
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuLabel,
ContextMenuPortal,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger
}

View file

@ -1,7 +1,7 @@
import { Dialog as DialogPrimitive } from 'radix-ui'
import * as React from 'react'
import { XIcon } from '@/lib/icons'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
@ -24,7 +24,7 @@ function DialogOverlay({ className, ...props }: React.ComponentProps<typeof Dial
return (
<DialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-[120] pointer-events-auto bg-black/50 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
'fixed inset-0 z-[120] pointer-events-auto bg-black/22 backdrop-blur-[0.125rem] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
className
)}
data-slot="dialog-overlay"
@ -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-4 rounded-lg border border-border bg-card p-6 shadow-lg 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"
@ -55,10 +55,10 @@ function DialogContent({
{children}
{showCloseButton && (
<DialogPrimitive.Close
className="absolute right-3 top-3 rounded-md p-1.5 text-muted-foreground opacity-70 transition-opacity hover:bg-accent hover:text-foreground hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none"
className="absolute right-2.5 top-2.5 rounded-md p-1 text-(--ui-text-tertiary) opacity-70 transition-opacity hover:bg-(--chrome-action-hover) hover:text-foreground hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none"
data-slot="dialog-close-button"
>
<XIcon className="size-4" />
<Codicon name="close" size="1rem" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
@ -70,7 +70,7 @@ function DialogContent({
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('flex flex-col gap-1.5 text-center sm:text-left', className)}
className={cn('flex flex-col gap-1 text-center sm:text-left', className)}
data-slot="dialog-header"
{...props}
/>
@ -90,7 +90,7 @@ function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
className={cn('text-base font-semibold tracking-tight text-foreground', className)}
className={cn('text-[0.9375rem] font-semibold tracking-tight text-foreground', className)}
data-slot="dialog-title"
{...props}
/>
@ -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-sm text-muted-foreground', 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}
/>

Some files were not shown because too many files have changed in this diff Show more