feat: file preview and folder tree etc

This commit is contained in:
Brooklyn Nicholson 2026-05-04 21:47:15 -05:00
parent 74127e0c48
commit 42db075e10
96 changed files with 7952 additions and 3277 deletions

View file

@ -94,6 +94,85 @@ const MEDIA_MIME_TYPES = {
const PREVIEW_HTML_EXTENSIONS = new Set(['.html', '.htm'])
const PREVIEW_WATCH_DEBOUNCE_MS = 120
const LOCAL_PREVIEW_HOSTS = new Set(['0.0.0.0', '127.0.0.1', '::1', '[::1]', 'localhost'])
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024
const PREVIEW_LANGUAGE_BY_EXT = {
'.c': 'c',
'.conf': 'ini',
'.cpp': 'cpp',
'.css': 'css',
'.csv': 'csv',
'.go': 'go',
'.graphql': 'graphql',
'.h': 'c',
'.hpp': 'cpp',
'.html': 'html',
'.java': 'java',
'.js': 'javascript',
'.json': 'json',
'.jsx': 'jsx',
'.kt': 'kotlin',
'.lua': 'lua',
'.md': 'markdown',
'.mjs': 'javascript',
'.py': 'python',
'.rb': 'ruby',
'.rs': 'rust',
'.sh': 'shell',
'.sql': 'sql',
'.svg': 'xml',
'.toml': 'toml',
'.ts': 'typescript',
'.tsx': 'tsx',
'.txt': 'text',
'.xml': 'xml',
'.yaml': 'yaml',
'.yml': 'yaml',
'.zsh': 'shell'
}
function looksBinary(buffer) {
if (!buffer.length) return false
let suspicious = 0
for (const byte of buffer) {
if (byte === 0) return true
// Allow common whitespace controls: tab, LF, CR.
if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) suspicious += 1
}
return suspicious / buffer.length > 0.12
}
function previewFileMetadata(filePath, mimeType) {
let byteSize = 0
let binary = false
try {
const stat = fs.statSync(filePath)
byteSize = stat.size
if (!mimeType.startsWith('image/')) {
const fd = fs.openSync(filePath, 'r')
try {
const sample = Buffer.alloc(Math.min(byteSize, 4096))
const bytesRead = fs.readSync(fd, sample, 0, sample.length, 0)
binary = looksBinary(sample.subarray(0, bytesRead))
} finally {
fs.closeSync(fd)
}
}
} catch {
// Metadata is best-effort; the read handlers surface hard errors later.
}
return {
binary,
byteSize,
large: byteSize > TEXT_PREVIEW_MAX_BYTES
}
}
app.setName(APP_NAME)
app.setAboutPanelOptions({
@ -106,6 +185,7 @@ let hermesProcess = null
let connectionPromise = null
const hermesLog = []
const previewWatchers = new Map()
let previewShortcutActive = false
function rememberLog(chunk) {
const text = String(chunk || '').trim()
@ -617,13 +697,26 @@ function previewFileTarget(rawTarget, baseDir) {
}
const ext = path.extname(resolved).toLowerCase()
if (!PREVIEW_HTML_EXTENSIONS.has(ext) || !fileExists(resolved)) {
if (!fileExists(resolved)) {
return null
}
const mimeType = mimeTypeForPath(resolved)
const metadata = previewFileMetadata(resolved, mimeType)
const isHtml = PREVIEW_HTML_EXTENSIONS.has(ext)
const isImage = mimeType.startsWith('image/')
const previewKind = isHtml ? 'html' : isImage ? 'image' : metadata.binary ? 'binary' : 'text'
return {
binary: metadata.binary,
byteSize: metadata.byteSize,
kind: 'file',
large: metadata.large,
label: path.basename(resolved),
language: PREVIEW_LANGUAGE_BY_EXT[ext] || 'text',
mimeType,
path: resolved,
previewKind,
source: raw,
url: pathToFileURL(resolved).toString()
}
@ -671,12 +764,11 @@ function normalizePreviewTarget(rawTarget, baseDir) {
}
}
function previewFilePathFromUrl(rawUrl) {
function filePathFromPreviewUrl(rawUrl) {
const filePath = fileURLToPath(String(rawUrl || ''))
const ext = path.extname(filePath).toLowerCase()
if (!PREVIEW_HTML_EXTENSIONS.has(ext) || !fileExists(filePath)) {
throw new Error('Preview file is not a readable HTML file')
if (!fileExists(filePath)) {
throw new Error('Preview file is not readable')
}
return filePath
@ -690,7 +782,7 @@ function sendPreviewFileChanged(payload) {
}
function watchPreviewFile(rawUrl) {
const filePath = previewFilePathFromUrl(rawUrl)
const filePath = filePathFromPreviewUrl(rawUrl)
const watchDir = path.dirname(filePath)
const targetName = path.basename(filePath)
const id = crypto.randomBytes(12).toString('base64url')
@ -768,6 +860,13 @@ function sendBackendExit(payload) {
webContents.send('hermes:backend-exit', payload)
}
function sendClosePreviewRequested() {
if (!mainWindow || mainWindow.isDestroyed()) return
const { webContents } = mainWindow
if (!webContents || webContents.isDestroyed()) return
webContents.send('hermes:close-preview-requested')
}
function getAppIconPath() {
return APP_ICON_PATHS.find(fileExists)
}
@ -793,7 +892,21 @@ function buildApplicationMenu() {
template.push({
label: 'File',
submenu: [IS_MAC ? { role: 'close' } : { role: 'quit' }]
submenu: [
IS_MAC
? {
accelerator: 'CommandOrControl+W',
click: () => {
if (previewShortcutActive) {
sendClosePreviewRequested()
} else {
mainWindow?.close()
}
},
label: 'Close'
}
: { role: 'quit' }
]
})
template.push({
label: 'Edit',
@ -856,6 +969,22 @@ function installDevToolsShortcut(window) {
})
}
function installPreviewShortcut(window) {
window.webContents.on('before-input-event', (event, input) => {
const key = String(input.key || '').toLowerCase()
const isPreviewCloseShortcut =
key === 'w' &&
(IS_MAC ? input.meta : input.control) &&
!input.alt &&
!input.shift
if (!isPreviewCloseShortcut || !previewShortcutActive) return
event.preventDefault()
sendClosePreviewRequested()
})
}
function installContextMenu(window) {
window.webContents.on('context-menu', (_event, params) => {
const template = []
@ -1043,6 +1172,7 @@ function createWindow() {
}
}
installPreviewShortcut(mainWindow)
installDevToolsShortcut(mainWindow)
installContextMenu(mainWindow)
@ -1055,6 +1185,10 @@ function createWindow() {
ipcMain.handle('hermes:connection', async () => startHermes())
ipcMain.on('hermes:previewShortcutActive', (_event, active) => {
previewShortcutActive = Boolean(active)
})
ipcMain.handle('hermes:requestMicrophoneAccess', async () => {
if (!IS_MAC || typeof systemPreferences.askForMediaAccess !== 'function') {
return true
@ -1082,11 +1216,38 @@ ipcMain.handle('hermes:notify', (_event, payload) => {
})
ipcMain.handle('hermes:readFileDataUrl', async (_event, filePath) => {
const resolved = path.resolve(String(filePath || ''))
const input = String(filePath || '')
const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input)
const data = await fs.promises.readFile(resolved)
return `data:${mimeTypeForPath(resolved)};base64,${data.toString('base64')}`
})
ipcMain.handle('hermes:readFileText', async (_event, filePath) => {
const input = String(filePath || '')
const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input)
const ext = path.extname(resolved).toLowerCase()
const stat = await fs.promises.stat(resolved)
const handle = await fs.promises.open(resolved, 'r')
const bytesToRead = Math.min(stat.size, TEXT_PREVIEW_MAX_BYTES)
try {
const buffer = Buffer.alloc(bytesToRead)
const { bytesRead } = await handle.read(buffer, 0, bytesToRead, 0)
return {
binary: looksBinary(buffer.subarray(0, Math.min(bytesRead, 4096))),
byteSize: stat.size,
language: PREVIEW_LANGUAGE_BY_EXT[ext] || 'text',
mimeType: mimeTypeForPath(resolved),
path: resolved,
text: buffer.subarray(0, bytesRead).toString('utf8'),
truncated: stat.size > TEXT_PREVIEW_MAX_BYTES
}
} finally {
await handle.close()
}
})
ipcMain.handle('hermes:selectPaths', async (_event, options = {}) => {
const properties = ['openFile']
if (options?.directories) properties.push('openDirectory')
@ -1137,6 +1298,151 @@ ipcMain.handle('hermes:stopPreviewFileWatch', (_event, id) => stopPreviewFileWat
ipcMain.handle('hermes:openExternal', (_event, url) => shell.openExternal(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 ignore = require('ignore')
// Cache one Ignore instance per .gitignore path keyed by mtime so edits
// invalidate automatically without us having to wire a watcher.
const gitignoreCache = new Map() // gitignorePath → { mtime: number, ig: Ignore, base: string }
function findGitRoot(start) {
let dir = start
for (let i = 0; i < 50; i += 1) {
try {
if (fs.existsSync(path.join(dir, '.git'))) {return dir}
} catch {
return null
}
const parent = path.dirname(dir)
if (parent === dir) {return null}
dir = parent
}
return null
}
function getGitignoreFile(giPath) {
let stat = null
try {
stat = fs.statSync(giPath)
} catch {
return null
}
if (!stat.isFile()) {return null}
const cached = gitignoreCache.get(giPath)
if (cached && cached.mtime === stat.mtimeMs) {return cached}
try {
const entry = {
base: path.dirname(giPath),
ig: ignore().add(fs.readFileSync(giPath, 'utf8')),
mtime: stat.mtimeMs
}
gitignoreCache.set(giPath, entry)
return entry
} catch {
return null
}
}
function gitignoreRulesFor(root, dir) {
const rules = []
const rel = path.relative(root, dir)
const dirs = [root]
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
const parts = rel.split(path.sep).filter(Boolean)
let current = root
for (const part of parts) {
current = path.join(current, part)
dirs.push(current)
}
}
for (const ruleDir of dirs) {
const rule = getGitignoreFile(path.join(ruleDir, '.gitignore'))
if (rule) {rules.push(rule)}
}
return rules
}
function ignoredByRules(rules, abs, isDirectory) {
for (const rule of rules) {
const rel = path.relative(rule.base, abs)
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {continue}
const probe = `${rel.split(path.sep).join('/')}${isDirectory ? '/' : ''}`
if (rule.ig.ignores(probe)) {return true}
}
return false
}
ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => {
const resolved = path.resolve(String(dirPath || ''))
if (!resolved) {
return { entries: [], error: 'invalid-path' }
}
try {
const dirents = await fs.promises.readdir(resolved, { withFileTypes: true })
const root = findGitRoot(resolved)
const gitignoreRules = root ? gitignoreRulesFor(root, resolved) : []
const entries = dirents
.filter(d => {
if (FS_READDIR_HIDDEN.has(d.name)) {return false}
if (gitignoreRules.length > 0) {
const abs = path.join(resolved, d.name)
if (ignoredByRules(gitignoreRules, abs, d.isDirectory())) {return false}
}
return true
})
.map(d => ({ name: d.name, path: path.join(resolved, d.name), isDirectory: d.isDirectory() }))
.sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.name.localeCompare(b.name))
return { entries }
} catch (error) {
return { entries: [], error: error?.code || 'read-error' }
}
})
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => {
const input = String(startPath || '')
const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input)
try {
const stat = await fs.promises.stat(resolved)
const start = stat.isDirectory() ? resolved : path.dirname(resolved)
return findGitRoot(start)
} catch {
return findGitRoot(resolved)
}
})
app.whenReady().then(() => {
Menu.setApplicationMenu(buildApplicationMenu())
installMediaPermissions()

View file

@ -6,6 +6,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
notify: payload => ipcRenderer.invoke('hermes:notify', payload),
requestMicrophoneAccess: () => ipcRenderer.invoke('hermes:requestMicrophoneAccess'),
readFileDataUrl: filePath => ipcRenderer.invoke('hermes:readFileDataUrl', filePath),
readFileText: filePath => ipcRenderer.invoke('hermes:readFileText', filePath),
selectPaths: options => ipcRenderer.invoke('hermes:selectPaths', options),
writeClipboard: text => ipcRenderer.invoke('hermes:writeClipboard', text),
saveImageFromUrl: url => ipcRenderer.invoke('hermes:saveImageFromUrl', url),
@ -21,7 +22,15 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
normalizePreviewTarget: (target, baseDir) => ipcRenderer.invoke('hermes:normalizePreviewTarget', target, baseDir),
watchPreviewFile: url => ipcRenderer.invoke('hermes:watchPreviewFile', url),
stopPreviewFileWatch: id => ipcRenderer.invoke('hermes:stopPreviewFileWatch', id),
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
onClosePreviewRequested: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:close-preview-requested', listener)
return () => ipcRenderer.removeListener('hermes:close-preview-requested', listener)
},
onPreviewFileChanged: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:preview-file-changed', listener)

View file

@ -50,15 +50,18 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"ignore": "^7.0.5",
"liquid-glass-react": "^1.1.1",
"lucide-react": "^0.577.0",
"nanostores": "^1.3.0",
"radix-ui": "^1.4.3",
"react": "^19.2.5",
"react-arborist": "^3.5.0",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2",
"react-shiki": "^0.9.3",
"shiki": "^4.0.2",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",
"tw-shimmer": "^0.4.11",

View file

@ -0,0 +1,65 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Preview Demo</title>
<style>
:root { color-scheme: dark; }
html, body { height: 100%; margin: 0; }
body {
font-family: ui-sans-serif, system-ui, -apple-system, "SF Pro Text", sans-serif;
background: radial-gradient(1200px 600px at 20% 10%, #3a2a1a 0%, #1a1410 40%, #0c0a08 100%);
color: #f5e9d7;
display: grid;
place-items: center;
padding: 2rem;
}
.card {
max-width: 520px;
padding: 2rem 2.25rem;
border: 1px solid rgba(245,233,215,0.15);
border-radius: 14px;
background: rgba(20,16,12,0.6);
backdrop-filter: blur(6px);
box-shadow: 0 10px 40px rgba(0,0,0,0.4);
}
h1 {
margin: 0 0 0.5rem;
font-size: 1.5rem;
letter-spacing: 0.01em;
}
p { margin: 0.35rem 0; opacity: 0.85; line-height: 1.5; }
.dot {
display: inline-block; width: 10px; height: 10px; border-radius: 50%;
background: #f4a93c; margin-right: 0.5rem;
box-shadow: 0 0 12px #f4a93c;
animation: pulse 1.6s ease-in-out infinite;
}
@keyframes pulse {
0%,100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.4); opacity: 0.6; }
}
code {
background: rgba(245,233,215,0.08);
padding: 0.1rem 0.35rem;
border-radius: 4px;
font-size: 0.9em;
}
.time { font-variant-numeric: tabular-nums; opacity: 0.7; font-size: 0.85rem; margin-top: 1rem; }
</style>
</head>
<body>
<div class="card">
<h1><span class="dot"></span>preview-demo.html</h1>
<p>Tiny standalone HTML artifact — no server, no build step.</p>
<p>Open directly in a browser via <code>file://</code>.</p>
<p class="time" id="t"></p>
</div>
<script>
const el = document.getElementById('t');
const tick = () => { el.textContent = new Date().toLocaleString(); };
tick(); setInterval(tick, 1000);
</script>
</body>
</html>

View file

@ -0,0 +1,134 @@
import { useStore } from '@nanostores/react'
import { useMemo, useState } from 'react'
import { Activity, AlertCircle, Layers3, Loader2, type LucideIcon, RefreshCw, Sparkles } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $desktopActionTasks, buildRailTasks, type RailTask, type RailTaskStatus } from '@/store/activity'
import { $previewServerRestart } from '@/store/preview'
import { $sessions, $workingSessionIds } from '@/store/session'
import { OverlayCard } from '../overlays/overlay-chrome'
import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
import { OverlayView } from '../overlays/overlay-view'
type AgentsSection = 'tree' | 'activity' | 'history'
interface SectionDef {
description: string
icon: LucideIcon
id: AgentsSection
label: string
}
const SECTIONS: readonly SectionDef[] = [
{ description: 'Live subagent spawn tree for the current turn', icon: Layers3, id: 'tree', label: 'Spawn tree' },
{ description: 'Background work across sessions and the desktop', icon: Activity, id: 'activity', label: 'Activity' },
{ description: 'Past spawn snapshots, replay, and diff', icon: RefreshCw, id: 'history', label: 'History' }
]
const STATUS_TONE: Record<RailTaskStatus, string> = {
error: 'text-destructive',
running: 'text-foreground',
success: 'text-emerald-500'
}
const STATUS_ICON: Record<RailTaskStatus, LucideIcon> = {
error: AlertCircle,
running: Loader2,
success: Sparkles
}
interface AgentsViewProps {
initialSection?: AgentsSection
onClose: () => void
}
export function AgentsView({ initialSection = 'tree', onClose }: AgentsViewProps) {
const [section, setSection] = useState<AgentsSection>(initialSection)
const sessions = useStore($sessions)
const workingSessionIds = useStore($workingSessionIds)
const previewRestart = useStore($previewServerRestart)
const desktopActionTasks = useStore($desktopActionTasks)
const activityTasks = useMemo(
() => buildRailTasks(workingSessionIds, sessions, previewRestart, desktopActionTasks),
[desktopActionTasks, previewRestart, sessions, workingSessionIds]
)
const active = SECTIONS.find(s => s.id === section) ?? SECTIONS[0]!
return (
<OverlayView closeLabel="Close agents" onClose={onClose}>
<OverlaySplitLayout>
<OverlaySidebar>
{SECTIONS.map(s => (
<OverlayNavItem
active={s.id === section}
icon={s.icon}
key={s.id}
label={s.label}
onClick={() => setSection(s.id)}
/>
))}
</OverlaySidebar>
<OverlayMain>
<header className="mb-4">
<h2 className="text-sm font-semibold text-foreground">{active.label}</h2>
<p className="text-xs text-muted-foreground">{active.description}</p>
</header>
{section === 'activity' ? <ActivityList tasks={activityTasks} /> : <SectionStub label={active.label} />}
</OverlayMain>
</OverlaySplitLayout>
</OverlayView>
)
}
function ActivityList({ tasks }: { tasks: readonly RailTask[] }) {
if (tasks.length === 0) {
return (
<OverlayCard className="px-3 py-4 text-sm text-muted-foreground">
No background activity. Long-running tools, preview restarts, and parallel sessions surface here.
</OverlayCard>
)
}
return (
<div className="grid min-h-0 gap-1.5 overflow-y-auto pr-1">
{tasks.map(task => {
const Icon = STATUS_ICON[task.status]
return (
<OverlayCard className="flex items-start gap-2.5 px-3 py-2" key={task.id}>
<Icon
className={cn('mt-0.5 size-3.5 shrink-0', STATUS_TONE[task.status], task.status === 'running' && 'animate-spin')}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">{task.label}</div>
{task.detail && <div className="truncate text-xs text-muted-foreground">{task.detail}</div>}
</div>
</OverlayCard>
)
})}
</div>
)
}
function SectionStub({ label }: { label: string }) {
return (
<OverlayCard className="grid place-items-center gap-3 px-6 py-12 text-center">
<Sparkles className="size-6 text-muted-foreground/70" />
<div className="grid gap-1">
<p className="text-sm font-medium text-foreground">{label} coming soon</p>
<p className="max-w-md text-xs leading-relaxed text-muted-foreground">
Subagent stores aren&apos;t wired into the desktop yet. Once gateway events for{' '}
<code className="rounded bg-muted/60 px-1 py-0.5 font-mono text-[0.65rem]">subagent.spawn / progress / complete</code>{' '}
land here, this view shows the live spawn tree, replay history, and pause/kill controls modelled on the TUI&apos;s{' '}
<code className="rounded bg-muted/60 px-1 py-0.5 font-mono text-[0.65rem]">/agents</code> overlay.
</p>
</div>
</OverlayCard>
)
}

View file

@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom'
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { CopyButton } from '@/components/ui/copy-button'
import { Input } from '@/components/ui/input'
import {
Pagination,
@ -17,9 +18,9 @@ import {
} from '@/components/ui/pagination'
import { getSessionMessages, listSessions } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { Copy, ExternalLink, FileImage, FileText, FolderOpen, Layers3, Link2, RefreshCw, Search, X } from '@/lib/icons'
import { ExternalLink, FileImage, FileText, FolderOpen, Layers3, Link2, RefreshCw, Search, X } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { notifyError } from '@/store/notifications'
import type { SessionInfo, SessionMessage } from '@/types/hermes'
import { sessionRoute } from '../routes'
@ -346,7 +347,11 @@ interface ArtifactsViewProps extends React.ComponentProps<'section'> {
setTitlebarToolGroup?: SetTitlebarToolGroup
}
export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, setTitlebarToolGroup, ...props }: ArtifactsViewProps) {
export function ArtifactsView({
setStatusbarItemGroup: _setStatusbarItemGroup,
setTitlebarToolGroup,
...props
}: ArtifactsViewProps) {
const navigate = useNavigate()
const [artifacts, setArtifacts] = useState<ArtifactRecord[] | null>(null)
const [query, setQuery] = useState('')
@ -469,24 +474,6 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, s
}
}, [artifacts])
const copyArtifact = useCallback(async (value: string) => {
try {
if (window.hermesDesktop?.writeClipboard) {
await window.hermesDesktop.writeClipboard(value)
} else if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value)
}
notify({
kind: 'success',
title: 'Copied',
message: value
})
} catch (err) {
notifyError(err, 'Copy failed')
}
}, [])
const openArtifact = useCallback(async (href: string) => {
try {
if (window.hermesDesktop?.openExternal) {
@ -510,10 +497,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, s
}, [])
return (
<section
{...props}
className="flex h-full min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background"
>
<section {...props} className="flex h-full min-w-0 flex-col overflow-hidden rounded-[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>
@ -645,7 +629,6 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, s
<ArtifactListRow
artifact={artifact}
key={artifact.id}
onCopy={copyArtifact}
onOpen={openArtifact}
onOpenChat={sessionId => navigate(sessionRoute(sessionId))}
/>
@ -804,12 +787,11 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
interface ArtifactListRowProps {
artifact: ArtifactRecord
onCopy: (value: string) => void | Promise<void>
onOpen: (href: string) => void | Promise<void>
onOpenChat: (sessionId: string) => void
}
function ArtifactListRow({ artifact, onCopy, onOpen, onOpenChat }: ArtifactListRowProps) {
function ArtifactListRow({ artifact, onOpen, onOpenChat }: ArtifactListRowProps) {
const Icon = artifact.kind === 'file' ? FileText : Link2
return (
@ -852,16 +834,14 @@ function ArtifactListRow({ artifact, onCopy, onOpen, onOpenChat }: ArtifactListR
>
<ExternalLink className="size-3.5" />
</Button>
<Button
<CopyButton
appearance="button"
buttonSize="icon-xs"
className="text-muted-foreground hover:text-foreground"
onClick={() => void onCopy(artifact.value)}
size="icon-xs"
title="Copy"
type="button"
variant="ghost"
>
<Copy className="size-3.5" />
</Button>
iconClassName="size-3.5"
label="Copy"
text={artifact.value}
/>
<Button
className="text-muted-foreground hover:text-foreground"
onClick={() => onOpenChat(artifact.sessionId)}

View file

@ -1,5 +1,11 @@
import { useStore } from '@nanostores/react'
import { FileText, FolderOpen, ImageIcon, Link, X } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import type { ComposerAttachment } from '@/store/composer'
import { notifyError } from '@/store/notifications'
import { setCurrentSessionPreviewTarget } from '@/store/preview'
import { $currentCwd } from '@/store/session'
export function AttachmentList({
attachments,
@ -9,7 +15,7 @@ export function AttachmentList({
onRemove?: (id: string) => void
}) {
return (
<div className="flex flex-wrap gap-1 px-1 pt-1">
<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} />
))}
@ -19,21 +25,62 @@ export function AttachmentList({
function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachment; onRemove?: (id: string) => void }) {
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText }[attachment.kind]
const cwd = useStore($currentCwd)
const canPreview = attachment.kind !== 'folder'
const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined
async function openPreview() {
if (!canPreview) {
return
}
const rawTarget = attachment.path || attachment.detail || attachment.refText?.replace(/^@(file|image|url):/, '') || attachment.label || ''
const target = rawTarget.replace(/^`|`$/g, '')
if (!target) {
return
}
try {
const preview = await normalizeOrLocalPreviewTarget(target, cwd || undefined)
if (!preview) {
throw new Error(`Could not preview ${attachment.label}`)
}
setCurrentSessionPreviewTarget(preview, 'manual', target)
} catch (error) {
notifyError(error, 'Preview unavailable')
}
}
return (
<div className="group/attachment relative shrink-0" title={attachment.label}>
{attachment.previewUrl && attachment.kind === 'image' ? (
<img
alt={attachment.label}
className="size-7 rounded-md border border-border/70 object-cover"
draggable={false}
src={attachment.previewUrl}
/>
) : (
<span className="grid size-7 place-items-center rounded-md border border-border/70 bg-muted/30 text-muted-foreground">
<Icon className="size-3.5" />
<div className="group/attachment relative min-w-0 shrink-0" title={attachment.path || attachment.detail || attachment.label}>
<button
aria-label={canPreview ? `Preview ${attachment.label}` : attachment.label}
className="flex max-w-56 items-center gap-2 border border-border/60 bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] transition-colors hover:border-primary/35 hover:bg-accent/45 disabled:cursor-default"
disabled={!canPreview}
onClick={() => void openPreview()}
title={canPreview ? `Preview ${attachment.label}` : attachment.label}
type="button"
>
{attachment.previewUrl && attachment.kind === 'image' ? (
<img
alt={attachment.label}
className="size-8 shrink-0 border border-border/70 object-cover"
draggable={false}
src={attachment.previewUrl}
/>
) : (
<span className="grid size-8 shrink-0 place-items-center border border-border/55 bg-muted/35 text-muted-foreground">
<Icon className="size-3.5" />
</span>
)}
<span className="min-w-0">
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">{attachment.label}</span>
{detail && <span className="block truncate font-mono text-[0.6rem] leading-3 text-muted-foreground/65">{detail}</span>}
</span>
)}
</button>
{onRemove && (
<button
aria-label={`Remove ${attachment.label}`}

View file

@ -1,48 +0,0 @@
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import { ComposerPrimitive, type Unstable_MentionDirective } from '@assistant-ui/react'
import { COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty, ComposerCompletionDrawer } from './completion-drawer'
export function DirectivePopover({
adapter,
directive,
loading = false
}: {
adapter: Unstable_TriggerAdapter
directive: Unstable_MentionDirective
loading?: boolean
}) {
return (
<ComposerCompletionDrawer adapter={adapter} ariaLabel="Reference suggestions" char="@">
<ComposerPrimitive.Unstable_TriggerPopover.Directive {...directive} />
<ComposerPrimitive.Unstable_TriggerPopoverItems>
{items => (
<div className="grid gap-0.5 pt-0.5">
{items.length === 0 ? (
<CompletionDrawerEmpty title={loading ? 'Looking up...' : 'No matches.'}>
Try <span className="font-mono text-foreground/80">@</span> for shortcuts, or paths like{' '}
<span className="font-mono text-foreground/80">@~/Desktop</span> /{' '}
<span className="font-mono text-foreground/80">@./src</span>.
</CompletionDrawerEmpty>
) : (
items.map((item, index) => <DirectiveRow index={index} item={item} key={item.id} />)
)}
</div>
)}
</ComposerPrimitive.Unstable_TriggerPopoverItems>
</ComposerCompletionDrawer>
)
}
function DirectiveRow({ index, item }: { index: number; item: Unstable_TriggerItem }) {
const metadata = item.metadata as { display?: string; meta?: string } | undefined
const display = metadata?.display || item.label
const description = metadata?.meta || item.description
return (
<ComposerPrimitive.Unstable_TriggerPopoverItem className={COMPLETION_DRAWER_ROW_CLASS} index={index} item={item}>
<span className="shrink-0 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>}
</ComposerPrimitive.Unstable_TriggerPopoverItem>
)
}

View file

@ -7,6 +7,28 @@ import type { CompletionEntry, CompletionPayload } from './use-live-completion-a
import { useLiveCompletionAdapter } from './use-live-completion-adapter'
const KIND_RE = /^@(file|folder|url|image|tool|git):(.*)$/
const REF_STARTERS = new Set(['file', 'folder', 'url', 'image', 'tool', 'git'])
const STARTER_META: Record<string, string> = {
file: 'Attach a file reference',
folder: 'Attach a folder reference',
url: 'Attach a URL reference',
image: 'Attach an image reference',
tool: 'Attach a tool reference',
git: 'Attach git context'
}
function starterEntries(query: string): CompletionEntry[] {
const q = query.trim().toLowerCase()
const kinds = Array.from(REF_STARTERS)
const filtered = q ? kinds.filter(kind => kind.startsWith(q)) : kinds
return filtered.map(kind => ({
text: `@${kind}:`,
display: `@${kind}:`,
meta: STARTER_META[kind] || ''
}))
}
interface AtItemMetadata extends Record<string, string> {
icon: string
@ -61,11 +83,13 @@ export function useAtCompletions(options: {
const fetcher = useCallback(
async (query: string): Promise<CompletionPayload> => {
const starters = starterEntries(query)
if (!gateway) {
return { items: [], query }
return { items: starters, query }
}
const word = `@${query}`
const word = REF_STARTERS.has(query) ? `@${query}:` : `@${query}`
const params: Record<string, unknown> = { word }
if (sessionId) {
@ -78,10 +102,11 @@ export function useAtCompletions(options: {
try {
const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.path', params)
const items = result.items ?? []
return { items: result.items ?? [], query }
return { items: items.length > 0 ? items : starters, query }
} catch {
return { items: [], query }
return { items: starters, query }
}
},
[gateway, sessionId, cwd]

View file

@ -1,20 +1,25 @@
import './liquid-glass-overrides.css'
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import LiquidGlass from 'liquid-glass-react'
import {
type ClipboardEvent,
type CSSProperties,
type FormEvent,
type KeyboardEvent,
type DragEvent as ReactDragEvent,
useCallback,
useEffect,
useRef,
useState
} from 'react'
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { formatRefValue, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { useMediaQuery } from '@/hooks/use-media-query'
import { chatMessageText } from '@/lib/chat-messages'
import { contextPath } from '@/lib/chat-runtime'
import { DATA_IMAGE_URL_RE, dataUrlToBlob } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
@ -22,20 +27,27 @@ import { $composerAttachments, $composerDraft } from '@/store/composer'
import { $messages } from '@/store/session'
import { $threadScrolledUp } from '@/store/thread-scroll'
import { extractDroppedFiles } from '../hooks/use-composer-actions'
import { type DroppedFile, extractDroppedFiles, HERMES_PATHS_MIME } from '../hooks/use-composer-actions'
import { AttachmentList } from './attachments'
import { ContextMenu } from './context-menu'
import { ComposerControls } from './controls'
import { DirectivePopover } from './directive-popover'
import { HelpHint } from './help-hint'
import { useAtCompletions } from './hooks/use-at-completions'
import { useComposerGlassTweaks } from './hooks/use-composer-glass-tweaks'
import { useSlashCompletions } from './hooks/use-slash-completions'
import { useVoiceConversation } from './hooks/use-voice-conversation'
import { useVoiceRecorder } from './hooks/use-voice-recorder'
import {
composerHtml,
composerPlainText,
escapeHtml,
placeCaretEnd,
refChipHtml,
RICH_INPUT_SLOT
} from './rich-editor'
import { SkinSlashPopover } from './skin-slash-popover'
import { SlashPopover } from './slash-popover'
import { ComposerTriggerPopover } from './trigger-popover'
import type { ChatBarProps } from './types'
import { UrlDialog } from './url-dialog'
import { VoiceActivity, VoicePlaybackActivity } from './voice-activity'
@ -117,6 +129,36 @@ const COMPOSER_FROST_CLASS = cn(
'group-focus-within/composer:[-webkit-backdrop-filter:none]'
)
interface TriggerState {
kind: '@' | '/'
query: string
tokenLength: number
}
const TRIGGER_RE = /(?:^|[\s])([@/])([^\s@/]*)$/
/** Caret-anchored text before the cursor, or null if the selection isn't a collapsed caret inside `editor`. */
function textBeforeCaret(editor: HTMLDivElement): string | null {
const sel = window.getSelection()
const range = sel?.rangeCount ? sel.getRangeAt(0) : null
if (!range?.collapsed || !editor.contains(range.commonAncestorContainer)) {return null}
const before = range.cloneRange()
before.selectNodeContents(editor)
before.setEnd(range.startContainer, range.startOffset)
return before.toString()
}
function detectTrigger(textBefore: string): TriggerState | null {
const match = TRIGGER_RE.exec(textBefore)
if (!match) {return null}
return { kind: match[1] as '@' | '/', query: match[2], tokenLength: 1 + match[2].length }
}
export function ChatBar({
busy,
cwd,
@ -144,9 +186,9 @@ export function ChatBar({
const scrolledUp = useStore($threadScrolledUp)
const composerRef = useRef<HTMLFormElement | null>(null)
const editorRef = useRef<HTMLDivElement | null>(null)
const glassShellRef = useRef<HTMLDivElement | null>(null)
const draftRef = useRef(draft)
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
const urlInputRef = useRef<HTMLInputElement | null>(null)
const [urlOpen, setUrlOpen] = useState(false)
@ -192,7 +234,7 @@ export function ChatBar({
const glassTweaks = useComposerGlassTweaks()
const focusInput = () => window.requestAnimationFrame(() => textareaRef.current?.focus({ preventScroll: true }))
const focusInput = () => window.requestAnimationFrame(() => editorRef.current?.focus({ preventScroll: true }))
useEffect(() => {
if (!disabled) {
@ -203,6 +245,12 @@ export function ChatBar({
useEffect(() => {
draftRef.current = draft
$composerDraft.set(draft)
const editor = editorRef.current
if (editor && document.activeElement !== editor && composerPlainText(editor) !== draft) {
editor.innerHTML = composerHtml(draft)
}
}, [draft])
useEffect(
@ -232,7 +280,7 @@ export function ChatBar({
return
}
const wraps = (textareaRef.current?.scrollHeight ?? 0) > 42
const wraps = (editorRef.current?.scrollHeight ?? 0) > 42
if (draft.includes('\n') || wraps) {
setExpanded(true)
@ -265,13 +313,108 @@ export function ChatBar({
focusInput()
}
const insertInlineRefs = (refs: string[]) => {
const editor = editorRef.current
if (!refs.length || !editor) {
return false
}
const inline = refs.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)
range.deleteContents()
const fragment = document.createDocumentFragment()
if (needsBeforeSpace) {
fragment.appendChild(document.createTextNode(' '))
}
refs.forEach((ref, index) => {
const match = ref.match(/^@([^:]+):(.+)$/)
const holder = document.createElement('span')
holder.innerHTML = match ? refChipHtml(match[1], match[2]) : escapeHtml(ref)
fragment.appendChild(holder.firstChild || 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)
editor.innerHTML = composerHtml(`${current}${current && !/\s$/.test(current) ? ' ' : ''}${inline} `)
placeCaretEnd(editor)
}
const nextDraft = composerPlainText(editor)
draftRef.current = nextDraft
aui.composer().setText(nextDraft)
return true
}
const droppedFileInlineRef = (candidate: DroppedFile) => {
if (!candidate.path) {
return null
}
const kind = candidate.isDirectory ? 'folder' : 'file'
const rel = contextPath(candidate.path, cwd || '')
return `@${kind}:${formatRefValue(rel)}`
}
const selectSkinSlashCommand = (command: string) => {
draftRef.current = command
aui.composer().setText(command)
focusInput()
}
const handlePaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
if (imageBlobs.length > 0) {
@ -304,32 +447,205 @@ export function ChatBar({
return
}
const trimmedText = pastedText.replace(/^[\t ]*(?:\r\n|\r|\n)+|(?:\r\n|\r|\n)+[\t ]*$/g, '')
event.preventDefault()
document.execCommand('insertText', false, pastedText)
const nextDraft = composerPlainText(event.currentTarget)
draftRef.current = nextDraft
aui.composer().setText(nextDraft)
}
const [trigger, setTrigger] = useState<TriggerState | null>(null)
const [triggerActive, setTriggerActive] = useState(0)
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
// Try caret-anchored detection first; fall back to whole-draft so blur/select-all
// edge cases still surface the popover instead of silently closing it.
const refreshTrigger = useCallback(() => {
const editor = editorRef.current
if (!editor) {return}
const before = textBeforeCaret(editor)
const detected = detectTrigger(before ?? composerPlainText(editor))
setTrigger(detected)
setTriggerActive(0)
}, [])
const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {
const editor = event.currentTarget
// Strip Chrome's stray <br> when the editor is otherwise empty so :empty
// pseudo-class works for the placeholder.
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
editor.replaceChildren()
}
const nextDraft = composerPlainText(editor)
if (nextDraft !== draftRef.current) {
draftRef.current = nextDraft
aui.composer().setText(nextDraft)
}
window.setTimeout(refreshTrigger, 0)
}
const triggerAdapter: Unstable_TriggerAdapter | null = trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
useEffect(() => {
if (!trigger || !triggerAdapter?.search) {
setTriggerItems([])
if (trimmedText === pastedText) {
return
}
event.preventDefault()
const textarea = event.currentTarget
const start = textarea.selectionStart
const end = textarea.selectionEnd
setTriggerItems(triggerAdapter.search(trigger.query))
}, [trigger, triggerAdapter])
const nextDraft = textarea.value.slice(0, start) + trimmedText + textarea.value.slice(end)
const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false
const cursor = start + trimmedText.length
const closeTrigger = () => {
setTrigger(null)
setTriggerItems([])
setTriggerActive(0)
}
useEffect(() => {
if (!triggerItems.length) {
setTriggerActive(0)
return
}
if (triggerActive >= triggerItems.length) {
setTriggerActive(triggerItems.length - 1)
}
}, [triggerActive, triggerItems.length])
const replaceTriggerWithChip = (item: Unstable_TriggerItem) => {
const editor = editorRef.current
const sel = window.getSelection()
if (!editor || !trigger) {
return
}
const serialized = hermesDirectiveFormatter.serialize(item)
const replaceDraftFallback = () => {
const current = composerPlainText(editor)
const nextDraft = `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${serialized}${
serialized.endsWith(' ') ? '' : ' '
}`
editor.innerHTML = composerHtml(nextDraft)
placeCaretEnd(editor)
draftRef.current = nextDraft
aui.composer().setText(nextDraft)
closeTrigger()
}
if (!sel?.rangeCount) {
replaceDraftFallback()
return
}
const range = sel.getRangeAt(0)
const startNode = range.startContainer
const startOffset = range.startOffset
if (startNode.nodeType !== Node.TEXT_NODE || startOffset < trigger.tokenLength) {
replaceDraftFallback()
return
}
const replaceRange = document.createRange()
replaceRange.setStart(startNode, startOffset - trigger.tokenLength)
replaceRange.setEnd(startNode, startOffset)
const fragment = document.createDocumentFragment()
const directiveMatch = serialized.match(/^@([^:]+):(.+)$/)
if (directiveMatch) {
const holder = document.createElement('span')
holder.innerHTML = refChipHtml(directiveMatch[1], directiveMatch[2])
const chipNode = holder.firstChild
if (chipNode) {
fragment.appendChild(chipNode)
const space = document.createTextNode(' ')
fragment.appendChild(space)
replaceRange.deleteContents()
replaceRange.insertNode(fragment)
const after = document.createRange()
after.setStart(space, 1)
after.collapse(true)
sel.removeAllRanges()
sel.addRange(after)
} else {
replaceRange.deleteContents()
document.execCommand('insertText', false, `${serialized} `)
}
} else {
replaceRange.deleteContents()
document.execCommand('insertText', false, serialized.endsWith(' ') ? serialized : `${serialized} `)
}
const nextDraft = composerPlainText(editor)
draftRef.current = nextDraft
aui.composer().setText(nextDraft)
window.requestAnimationFrame(() => {
const current = textareaRef.current
closeTrigger()
}
const handleEditorKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (trigger && triggerItems.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault()
setTriggerActive(idx => (idx + 1) % triggerItems.length)
if (!current) {
return
}
current.focus({ preventScroll: true })
current.setSelectionRange(cursor, cursor)
})
if (event.key === 'ArrowUp') {
event.preventDefault()
setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length)
return
}
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault()
const item = triggerItems[triggerActive]
if (item) {
replaceTriggerWithChip(item)
}
return
}
if (event.key === 'Escape') {
event.preventDefault()
closeTrigger()
return
}
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
submitDraft()
}
}
const handleEditorKeyUp = () => {
window.setTimeout(refreshTrigger, 0)
}
const dragHasAttachments = (transfer: DataTransfer | null) => {
@ -337,6 +653,10 @@ export function ChatBar({
return false
}
if (Array.from(transfer.types || []).includes(HERMES_PATHS_MIME)) {
return true
}
if (Array.from(transfer.types || []).includes('Files')) {
return true
}
@ -398,6 +718,16 @@ export function ChatBar({
return
}
if (Array.from(event.dataTransfer.types || []).includes(HERMES_PATHS_MIME)) {
const refs = candidates.map(droppedFileInlineRef).filter((ref): ref is string => Boolean(ref))
if (insertInlineRefs(refs)) {
triggerHaptic('selection')
}
return
}
void Promise.resolve(onAttachDroppedItems(candidates)).then(attached => {
if (attached) {
triggerHaptic('selection')
@ -406,9 +736,44 @@ export function ChatBar({
})
}
const handleInputDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
if (!dragHasAttachments(event.dataTransfer)) {
return
}
event.preventDefault()
event.stopPropagation()
event.dataTransfer.dropEffect = 'copy'
}
const handleInputDrop = (event: ReactDragEvent<HTMLDivElement>) => {
if (!dragHasAttachments(event.dataTransfer)) {
return
}
const candidates = extractDroppedFiles(event.dataTransfer)
const refs = candidates.map(droppedFileInlineRef).filter((ref): ref is string => Boolean(ref))
if (!refs.length) {
return
}
event.preventDefault()
event.stopPropagation()
resetDragState()
if (insertInlineRefs(refs)) {
triggerHaptic('selection')
}
}
const clearDraft = () => {
aui.composer().setText('')
draftRef.current = ''
if (editorRef.current) {
editorRef.current.innerHTML = ''
}
}
const submitDraft = () => {
@ -541,19 +906,42 @@ export function ChatBar({
)
const input = (
<ComposerPrimitive.Input
className={cn(
'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) resize-none overflow-y-auto bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none placeholder:text-muted-foreground/80 disabled:cursor-not-allowed',
stacked && 'pl-3',
stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1'
<div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}>
{!draft && (
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 pb-1 pr-1 pt-1 leading-normal text-muted-foreground/80',
stacked && 'pl-3'
)}
>
{placeholder}
</div>
)}
disabled={disabled}
onPaste={handlePaste}
placeholder={placeholder}
ref={textareaRef}
rows={1}
unstable_focusOnScrollToBottom={false}
/>
<div
aria-label="Message"
className={cn(
'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) overflow-y-auto bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none empty:before:content-[attr(data-placeholder)] disabled:cursor-not-allowed **:data-ref-text:cursor-default',
stacked && 'pl-3',
stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1'
)}
contentEditable={!disabled}
data-placeholder={placeholder}
data-slot={RICH_INPUT_SLOT}
onBlur={() => window.setTimeout(closeTrigger, 80)}
onDragOver={handleInputDragOver}
onDrop={handleInputDrop}
onInput={handleEditorInput}
onKeyDown={handleEditorKeyDown}
onKeyUp={handleEditorKeyUp}
onMouseUp={refreshTrigger}
onPaste={handlePaste}
ref={editorRef}
role="textbox"
suppressContentEditableWarning
/>
<ComposerPrimitive.Input className="sr-only" tabIndex={-1} unstable_focusOnScrollToBottom={false} />
</div>
)
return (
@ -581,12 +969,16 @@ export function ChatBar({
}
>
{showHelpHint && <HelpHint />}
<DirectivePopover
adapter={at.adapter}
directive={{ formatter: hermesDirectiveFormatter }}
loading={at.loading}
/>
<SlashPopover adapter={slash.adapter} loading={slash.loading} />
{trigger && (
<ComposerTriggerPopover
activeIndex={triggerActive}
items={triggerItems}
kind={trigger.kind}
loading={triggerLoading}
onHover={setTriggerActive}
onPick={replaceTriggerWithChip}
/>
)}
<SkinSlashPopover draft={draft} onSelect={selectSkinSlashCommand} />
<div className="pointer-events-none absolute inset-0" style={{ background: glassTweaks.fadeBackground }} />
<div className="relative w-full">

View file

@ -0,0 +1,94 @@
/**
* Helpers for the contenteditable composer surface: serialize refs to chip
* HTML, walk the DOM back to plain `@kind:value` text, and place the caret.
*
* Chip values are always wrapped in backticks/quotes so REF_RE stops at the
* fence without that, typing after a chip would get re-absorbed on the next
* plain-text round-trip.
*/
import { formatRefValue } from '@/components/assistant-ui/directive-text'
export const RICH_INPUT_SLOT = 'composer-rich-input'
export const REF_RE = /@(file|folder|url|image|tool):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
const ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }
export function escapeHtml(value: string) {
return value.replace(/[&<>"']/g, ch => ESC[ch] || ch)
}
export function unquoteRef(raw: string) {
const head = raw[0]
const tail = raw[raw.length - 1]
const quoted = (head === '`' && tail === '`') || (head === '"' && tail === '"') || (head === "'" && tail === "'")
return quoted ? raw.slice(1, -1) : raw.replace(/[,.;!?]+$/, '')
}
export function refLabel(id: string) {
return id.split(/[\\/]/).filter(Boolean).pop() || id
}
/** Always-quote variant of formatRefValue — chips need a fence even for safe values. */
export function quoteRefValue(value: string) {
if (!value.includes('`')) {return `\`${value}\``}
if (!value.includes('"')) {return `"${value}"`}
if (!value.includes("'")) {return `'${value}'`}
return formatRefValue(value)
}
export function refChipHtml(kind: string, rawValue: string) {
const id = unquoteRef(rawValue)
const text = `@${kind}:${quoteRefValue(id)}`
return `<span contenteditable="false" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${kind}" class="mx-0.5 inline-flex max-w-56 items-center gap-1 border border-primary/20 bg-primary/8 px-1.5 py-0.5 align-[0.02em] text-[0.86em] font-medium leading-none text-primary"><span class="truncate">${escapeHtml(refLabel(id))}</span></span>`
}
/** Serialize a draft string into chip-HTML for the contenteditable surface. */
export function composerHtml(text: string) {
let cursor = 0
let html = ''
REF_RE.lastIndex = 0
for (const match of text.matchAll(REF_RE)) {
const index = match.index ?? 0
html += escapeHtml(text.slice(cursor, index)).replace(/\n/g, '<br>')
html += refChipHtml(match[1] || 'file', match[2] || '')
cursor = index + match[0].length
}
return html + escapeHtml(text.slice(cursor)).replace(/\n/g, '<br>')
}
/** Walk a DOM subtree back to the plain `@kind:value` text it represents. */
export function composerPlainText(node: Node): string {
if (node.nodeType === Node.TEXT_NODE) {return node.textContent || ''}
if (node.nodeType !== Node.ELEMENT_NODE) {return ''}
const el = node as HTMLElement
if (el.dataset.refText) {return el.dataset.refText}
if (el.tagName === 'BR') {return '\n'}
const text = Array.from(node.childNodes).map(composerPlainText).join('')
const block = el.tagName === 'DIV' || el.tagName === 'P'
return block && text && el.dataset.slot !== RICH_INPUT_SLOT ? `${text}\n` : text
}
export function placeCaretEnd(element: HTMLElement) {
const range = document.createRange()
const selection = window.getSelection()
range.selectNodeContents(element)
range.collapse(false)
selection?.removeAllRanges()
selection?.addRange(range)
}

View file

@ -1,59 +0,0 @@
import type { Unstable_DirectiveFormatter, Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import { ComposerPrimitive } from '@assistant-ui/react'
import { COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty, ComposerCompletionDrawer } from './completion-drawer'
const slashFormatter: Unstable_DirectiveFormatter = {
serialize(item: Unstable_TriggerItem): string {
const metadata = item.metadata as { command?: unknown; display?: unknown } | undefined
const command = typeof metadata?.command === 'string' ? metadata.command : null
if (command) {
return command
}
return `/${item.label}`
},
parse() {
return []
}
}
export function SlashPopover({ adapter, loading }: { adapter: Unstable_TriggerAdapter; loading: boolean }) {
return (
<ComposerCompletionDrawer adapter={adapter} ariaLabel="Slash command suggestions" char="/">
<ComposerPrimitive.Unstable_TriggerPopover.Directive formatter={slashFormatter} />
<ComposerPrimitive.Unstable_TriggerPopoverItems>
{items => (
<div className="grid gap-0.5 pt-0.5">
{items.length === 0 ? (
<CompletionDrawerEmpty title={loading ? 'Looking up...' : 'No matching commands.'}>
Try <span className="font-mono text-foreground/80">/help</span> for the desktop command list.
</CompletionDrawerEmpty>
) : (
items.map((item, index) => {
const meta = item.metadata as { command?: string; display?: string; meta?: string } | undefined
const display = meta?.display ?? meta?.command ?? `/${item.label}`
const description = meta?.meta || item.description
return (
<ComposerPrimitive.Unstable_TriggerPopoverItem
className={COMPLETION_DRAWER_ROW_CLASS}
index={index}
item={item}
key={item.id}
>
<span className="shrink-0 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>
)}
</ComposerPrimitive.Unstable_TriggerPopoverItem>
)
})
)}
</div>
)}
</ComposerPrimitive.Unstable_TriggerPopoverItems>
</ComposerCompletionDrawer>
)
}

View file

@ -0,0 +1,64 @@
import type { Unstable_TriggerItem } from '@assistant-ui/core'
import { cn } from '@/lib/utils'
import { COMPLETION_DRAWER_CLASS, COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty } from './completion-drawer'
interface ComposerTriggerPopoverProps {
activeIndex: number
items: readonly Unstable_TriggerItem[]
kind: '@' | '/'
loading: boolean
onHover: (index: number) => void
onPick: (item: Unstable_TriggerItem) => void
}
export function ComposerTriggerPopover({ activeIndex, items, kind, loading, onHover, onPick }: ComposerTriggerPopoverProps) {
return (
<div
className={COMPLETION_DRAWER_CLASS}
data-slot="composer-completion-drawer"
data-state="open"
onMouseDown={event => event.preventDefault()}
role="listbox"
>
{items.length === 0 ? (
<CompletionDrawerEmpty title={loading ? 'Looking up…' : 'No matches.'}>
{kind === '@' ? (
<>
Try <span className="font-mono text-foreground/80">@file:</span> or{' '}
<span className="font-mono text-foreground/80">@folder:</span>.
</>
) : (
<>
Try <span className="font-mono text-foreground/80">/help</span>.
</>
)}
</CompletionDrawerEmpty>
) : (
items.map((item, index) => {
const meta = item.metadata as { display?: string; meta?: string } | undefined
const display = meta?.display ?? (kind === '/' ? `/${item.label}` : item.label)
const description = meta?.meta || item.description
return (
<button
className={cn(
COMPLETION_DRAWER_ROW_CLASS,
index === activeIndex && 'bg-[color-mix(in_srgb,var(--dt-accent)_70%,transparent)]'
)}
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>
{description && <span className="min-w-0 truncate leading-5 text-muted-foreground/80">{description}</span>}
</button>
)
})
)}
</div>
)
}

View file

@ -31,12 +31,22 @@ function isImagePath(filePath: string): boolean {
}
export interface DroppedFile {
file: File
/** Browser-native File handle. Absent for in-app drags (e.g. project tree). */
file?: File
/** Absolute filesystem path. Empty when an OS drop didn't carry one. */
path: string
/** True if the entry is a directory. Currently only set by in-app drags. */
isDirectory?: boolean
}
/** MIME emitted by in-app drag sources (project tree, etc.). Payload is JSON
* `{ path: string; isDirectory?: boolean }[]`. */
export const HERMES_PATHS_MIME = 'application/x-hermes-paths'
/**
* Eagerly resolve files from a drop event into [File, path] pairs.
* Eagerly resolve files from a drop event into [File?, path, isDirectory?]
* triples. Internal Hermes sources (e.g. the project tree) ride on a custom
* MIME and produce path-only entries; OS drops produce File-bearing entries.
*
* Must be called synchronously from inside the drop handler `DataTransfer`
* items are detached as soon as the handler returns, and `webUtils.getPathForFile`
@ -44,19 +54,42 @@ export interface DroppedFile {
*/
export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
const result: DroppedFile[] = []
const seen = new Set<File>()
const seenPaths = new Set<string>()
const seenFiles = new Set<File>()
const getPath = window.hermesDesktop?.getPathForFile
// In-app drags first — they carry richer metadata (isDirectory) than the
// File-based fallback can provide, and produce no overlapping native files.
try {
const internalRaw = transfer.getData(HERMES_PATHS_MIME)
if (internalRaw) {
const parsed = JSON.parse(internalRaw) as { path?: unknown; isDirectory?: unknown }[]
for (const entry of parsed) {
if (!entry || typeof entry.path !== 'string' || !entry.path || seenPaths.has(entry.path)) {
continue
}
seenPaths.add(entry.path)
result.push({ isDirectory: entry.isDirectory === true, path: entry.path })
}
}
} catch {
// Malformed payload — fall through to native files.
}
const fileList = transfer.files
if (fileList) {
for (let i = 0; i < fileList.length; i += 1) {
const file = fileList.item(i)
if (!file || seen.has(file)) {
if (!file || seenFiles.has(file)) {
continue
}
seen.add(file)
seenFiles.add(file)
let path = ''
if (getPath) {
@ -67,6 +100,14 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
}
}
if (path && seenPaths.has(path)) {
continue
}
if (path) {
seenPaths.add(path)
}
result.push({ file, path })
}
}
@ -80,12 +121,14 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
if (!item || item.kind !== 'file') {
continue
}
const file = item.getAsFile()
if (!file || seen.has(file)) {
if (!file || seenFiles.has(file)) {
continue
}
seen.add(file)
seenFiles.add(file)
let path = ''
if (getPath) {
@ -96,6 +139,14 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
}
}
if (path && seenPaths.has(path)) {
continue
}
if (path) {
seenPaths.add(path)
}
result.push({ file, path })
}
}
@ -282,6 +333,26 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
}
}, [attachImagePath])
const attachContextFolderPath = useCallback(
(folderPath: string) => {
if (!folderPath) {return false}
const rel = contextPath(folderPath, currentCwd)
addComposerAttachment({
id: attachmentId('folder', rel),
kind: 'folder',
label: pathLabel(folderPath),
detail: rel,
refText: `@folder:${formatRefValue(rel)}`,
path: folderPath
})
return true
},
[currentCwd]
)
const attachDroppedItems = useCallback(
async (candidates: DroppedFile[]) => {
if (candidates.length === 0) {
@ -291,9 +362,49 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
let attached = false
let lastFailure: string | null = null
for (const { file, path: knownPath } of candidates) {
for (const candidate of candidates) {
const { file, isDirectory, path: knownPath } = candidate
// Path-only entry (in-app drag from the file browser tree, etc.).
if (!file) {
if (isDirectory) {
if (knownPath && attachContextFolderPath(knownPath)) {
attached = true
continue
}
lastFailure = `Could not attach folder ${knownPath || ''}`
continue
}
if (knownPath && isImagePath(knownPath)) {
if (await attachImagePath(knownPath)) {
attached = true
continue
}
lastFailure = `Could not attach ${knownPath}`
continue
}
if (knownPath && attachContextFilePath(knownPath)) {
attached = true
continue
}
lastFailure = `Could not attach ${knownPath || 'file'}`
continue
}
const fallbackPath =
!knownPath && window.hermesDesktop?.getPathForFile ? window.hermesDesktop.getPathForFile(file) : ''
const filePath = knownPath || fallbackPath || ''
const isImage = file.type.startsWith('image/') || isImagePath(file.name) || (filePath && isImagePath(filePath))
@ -324,7 +435,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
return attached
},
[attachContextFilePath, attachImageBlob, attachImagePath]
[attachContextFilePath, attachContextFolderPath, attachImageBlob, attachImagePath]
)
const removeAttachment = useCallback(
@ -349,6 +460,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
return {
addContextRefAttachment,
attachContextFilePath,
attachDroppedItems,
attachImageBlob,
attachImagePath,

View file

@ -39,14 +39,11 @@ import {
import type { ModelOptionsResponse } from '@/types/hermes'
import { routeSessionId } from '../routes'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/titlebar'
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
import { ChatBar, ChatBarFallback } from './composer'
import type { ChatBarState } from './composer/types'
import type { DroppedFile } from './hooks/use-composer-actions'
import { ChatPreviewRail, ChatRightRail } from './right-rail'
import { SessionActionsMenu } from './sidebar/session-actions-menu'
interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
@ -66,21 +63,10 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
onPickImages: () => void
onRemoveAttachment: (id: string) => void
onSubmit: (text: string) => Promise<void> | void
onChangeCwd: (cwd: string) => void
onBrowseCwd: () => void
onOpenModelPicker: () => void
onRestartPreviewServer?: (url: string, context?: string) => Promise<string>
onSetFastMode: (enabled: boolean) => void
onSetReasoningEffort: (effort: string) => void
onSelectPersonality: (name: string) => void
onOpenCommandCenterSystem: () => void
onOpenSkills: () => void
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
onEdit: (message: AppendMessage) => Promise<void>
onReload: (parentId: string | null) => Promise<void>
onTranscribeAudio?: (audio: Blob) => Promise<string>
setStatusbarItemGroup?: SetStatusbarItemGroup
setTitlebarToolGroup?: SetTitlebarToolGroup
}
function threadLoadingState(
@ -125,21 +111,10 @@ export function ChatView({
onPickImages,
onRemoveAttachment,
onSubmit,
onChangeCwd: _onChangeCwd,
onBrowseCwd: _onBrowseCwd,
onOpenModelPicker: _onOpenModelPicker,
onRestartPreviewServer,
onSetFastMode: _onSetFastMode,
onSetReasoningEffort: _onSetReasoningEffort,
onSelectPersonality: _onSelectPersonality,
onOpenCommandCenterSystem,
onOpenSkills,
onThreadMessagesChange,
onEdit,
onReload,
onTranscribeAudio,
setStatusbarItemGroup: _setStatusbarItemGroup,
setTitlebarToolGroup
onTranscribeAudio
}: ChatViewProps) {
const location = useLocation()
const activeSessionId = useStore($activeSessionId)
@ -270,85 +245,75 @@ export function ChatView({
})
return (
<>
<div
className={cn(
'relative col-start-2 col-end-3 row-start-1 flex h-full min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-transparent',
className
)}
>
<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}
<div
className={cn(
'relative flex h-full min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-transparent',
className
)}
>
<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}
>
<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"
>
<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>
)}
</div>
</header>
<NotificationStack />
<div className="relative min-h-0 max-w-full flex-1 overflow-hidden rounded-[1.0625rem] bg-transparent contain-[layout_paint]">
<AssistantRuntimeProvider runtime={runtime}>
<Thread
intro={showIntro ? { personality: introPersonality, seed: introSeed } : undefined}
loading={threadLoading}
onBranchInNewChat={onBranchInNewChat}
sessionKey={threadKey}
/>
{showChatBar && (
<Suspense fallback={<ChatBarFallback />}>
<ChatBar
busy={busy}
cwd={currentCwd}
disabled={!gatewayOpen}
focusKey={activeSessionId}
gateway={gateway}
maxRecordingSeconds={maxVoiceRecordingSeconds}
onAddContextRef={onAddContextRef}
onAddUrl={onAddUrl}
onAttachDroppedItems={onAttachDroppedItems}
onAttachImageBlob={onAttachImageBlob}
onCancel={onCancel}
onPasteClipboardImage={onPasteClipboardImage}
onPickFiles={onPickFiles}
onPickFolders={onPickFolders}
onPickImages={onPickImages}
onRemoveAttachment={onRemoveAttachment}
onSubmit={onSubmit}
onTranscribeAudio={onTranscribeAudio}
sessionId={activeSessionId}
state={chatBarState}
/>
</Suspense>
)}
</AssistantRuntimeProvider>
<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>
)}
</div>
</div>
</header>
<ChatPreviewRail onRestartServer={onRestartPreviewServer} setTitlebarToolGroup={setTitlebarToolGroup} />
<ChatRightRail
onOpenCommandCenterSystem={onOpenCommandCenterSystem}
onOpenSkills={onOpenSkills}
/>
</>
<NotificationStack />
<div className="relative min-h-0 max-w-full flex-1 overflow-hidden rounded-[1.0625rem] bg-transparent contain-[layout_paint]">
<AssistantRuntimeProvider runtime={runtime}>
<Thread
intro={showIntro ? { personality: introPersonality, seed: introSeed } : undefined}
loading={threadLoading}
onBranchInNewChat={onBranchInNewChat}
sessionKey={threadKey}
/>
{showChatBar && (
<Suspense fallback={<ChatBarFallback />}>
<ChatBar
busy={busy}
cwd={currentCwd}
disabled={!gatewayOpen}
focusKey={activeSessionId}
gateway={gateway}
maxRecordingSeconds={maxVoiceRecordingSeconds}
onAddContextRef={onAddContextRef}
onAddUrl={onAddUrl}
onAttachDroppedItems={onAttachDroppedItems}
onAttachImageBlob={onAttachImageBlob}
onCancel={onCancel}
onPasteClipboardImage={onPasteClipboardImage}
onPickFiles={onPickFiles}
onPickFolders={onPickFolders}
onPickImages={onPickImages}
onRemoveAttachment={onRemoveAttachment}
onSubmit={onSubmit}
onTranscribeAudio={onTranscribeAudio}
sessionId={activeSessionId}
state={chatBarState}
/>
</Suspense>
)}
</AssistantRuntimeProvider>
</div>
</div>
)
}
export { PREVIEW_RAIL_WIDTH, SESSION_INSPECTOR_WIDTH } from './right-rail'

View file

@ -1,108 +0,0 @@
'use client'
import { useMemo } from 'react'
import { RailActionRow } from './rail-action-row'
import { RailSection } from './rail-section'
import { type RailSelectOption, RailSelectRow } from './rail-select-row'
import { RailToggleRow } from './rail-toggle-row'
const REASONING_OPTIONS: RailSelectOption[] = [
{ value: 'none', label: 'Off' },
{ value: 'minimal', label: 'Minimal' },
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'xhigh', label: 'XHigh' }
]
interface AgentSectionProps {
modelLabel: string
providerName?: string
reasoningEffort: string
serviceTier: string
fastMode: boolean
personality: string
personalities: string[]
onOpenModelPicker?: () => void
onSetReasoningEffort?: (effort: string) => void
onSetFastMode?: (enabled: boolean) => void
onSelectPersonality?: (name: string) => void
}
export function AgentSection({
modelLabel,
providerName,
reasoningEffort,
serviceTier,
fastMode,
personality,
personalities,
onOpenModelPicker,
onSetReasoningEffort,
onSetFastMode,
onSelectPersonality
}: AgentSectionProps) {
const activeReasoning = normalizeReasoningEffort(reasoningEffort)
const fastEnabled = fastMode || ['fast', 'priority'].includes(serviceTier.trim().toLowerCase())
const activePersonality = personalityOptionKey(personality)
const personalityOptions = useMemo<RailSelectOption[]>(
() =>
[...new Set(['none', ...personalities, personality].map(personalityOptionKey).filter(Boolean))].map(name => ({
value: name,
label: name === 'none' ? 'None' : titleize(name)
})),
[personalities, personality]
)
return (
<RailSection title="Agent">
<RailActionRow
ariaLabel="Change model"
onClick={onOpenModelPicker}
primary={modelLabel || 'Hermes'}
secondary={providerName}
/>
<RailSelectRow
ariaLabel="Change reasoning effort"
label="Reasoning"
menuLabel="Reasoning"
onChange={onSetReasoningEffort}
options={REASONING_OPTIONS}
value={activeReasoning}
/>
<RailToggleRow checked={fastEnabled} label="Fast mode" onChange={onSetFastMode} />
<RailSelectRow
ariaLabel="Change personality"
label="Personality"
menuLabel="Personality"
menuWidthClass="w-52"
onChange={onSelectPersonality}
options={personalityOptions}
value={activePersonality}
/>
</RailSection>
)
}
function personalityOptionKey(value?: string): string {
const key = value?.trim().toLowerCase() || 'none'
return key === 'default' ? 'none' : key
}
function normalizeReasoningEffort(value: string): string {
const normalized = value.trim().toLowerCase()
return REASONING_OPTIONS.some(option => option.value === normalized) ? normalized : 'medium'
}
function titleize(value: string): string {
return value
.replace(/[-_]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/(^|\s)\S/g, m => m.toUpperCase())
}

View file

@ -0,0 +1 @@
export { ChatPreviewRail, PREVIEW_RAIL_PANE_WIDTH } from './preview'

View file

@ -1,137 +0,0 @@
import { useStore } from '@nanostores/react'
import { type ReactNode, useMemo } from 'react'
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
import { Button } from '@/components/ui/button'
import { AlertCircle, Loader2, Sparkles } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $desktopActionTasks, buildRailTasks, type RailTask, type RailTaskStatus } from '@/store/activity'
import { $inspectorOpen } from '@/store/layout'
import { $previewReloadRequest, $previewServerRestart, $previewTarget } from '@/store/preview'
import { $sessions, $workingSessionIds } from '@/store/session'
import { PreviewPane } from './preview-pane'
export const SESSION_INSPECTOR_WIDTH = 'clamp(13.5rem, 21vw, 20rem)'
export const PREVIEW_RAIL_WIDTH = 'clamp(18rem, 36vw, 38rem)'
const RAIL_TASK_LIMIT = 6
const TASK_ICONS: Record<RailTaskStatus, ReactNode> = {
error: <AlertCircle className="size-3 text-destructive" />,
running: <Loader2 className="size-3 animate-spin text-muted-foreground" />,
success: <Sparkles className="size-3 text-emerald-500" />
}
interface ChatRightRailProps {
onOpenCommandCenterSystem: () => void
onOpenSkills: () => void
}
export function ChatRightRail({ onOpenCommandCenterSystem, onOpenSkills }: ChatRightRailProps) {
const inspectorOpen = useStore($inspectorOpen)
const sessions = useStore($sessions)
const workingSessionIds = useStore($workingSessionIds)
const previewRestart = useStore($previewServerRestart)
const desktopActionTasks = useStore($desktopActionTasks)
const tasks = useMemo(
() => buildRailTasks(workingSessionIds, sessions, previewRestart, desktopActionTasks),
[desktopActionTasks, previewRestart, sessions, workingSessionIds]
)
return (
<div
className={cn(
'col-start-4 col-end-5 row-start-1 min-w-0 overflow-hidden',
inspectorOpen && 'border-l border-border/60'
)}
>
<aside
aria-hidden={!inspectorOpen}
className={cn(
'relative flex h-full w-full min-w-0 flex-col overflow-hidden bg-transparent pb-2 pl-2 pr-3 pt-[calc(var(--titlebar-height)+0.25rem)] text-muted-foreground transition-none',
inspectorOpen ? 'opacity-100' : 'pointer-events-none opacity-0'
)}
>
<RailHeader onOpenAll={onOpenCommandCenterSystem} />
<div className="flex min-h-0 flex-1 flex-col gap-1.5 overflow-y-auto px-1.5">
{tasks.length === 0 ? <EmptyRail /> : tasks.slice(0, RAIL_TASK_LIMIT).map(task => <RailRow key={task.id} task={task} />)}
</div>
<RailFooter onOpenSkills={onOpenSkills} onOpenSystem={onOpenCommandCenterSystem} />
</aside>
</div>
)
}
export function ChatPreviewRail({
onRestartServer,
setTitlebarToolGroup
}: {
onRestartServer?: (url: string, context?: string) => Promise<string>
setTitlebarToolGroup?: SetTitlebarToolGroup
}) {
const previewReloadRequest = useStore($previewReloadRequest)
const previewTarget = useStore($previewTarget)
if (!previewTarget) {
return <aside aria-hidden="true" className="col-start-3 col-end-4 row-start-1 min-w-0 overflow-hidden" />
}
return (
<div className="pointer-events-none col-start-3 col-end-4 row-start-1 min-w-0 overflow-hidden">
<PreviewPane
onRestartServer={onRestartServer}
reloadRequest={previewReloadRequest}
setTitlebarToolGroup={setTitlebarToolGroup}
target={previewTarget}
/>
</div>
)
}
function RailHeader({ onOpenAll }: { onOpenAll: () => void }) {
return (
<div className="mb-2 flex items-center justify-between gap-1 px-1.5">
<span className="text-[0.68rem] font-semibold uppercase tracking-[0.07em] text-muted-foreground/85">Background</span>
<Button className="h-6 px-2 text-[0.68rem]" onClick={onOpenAll} size="sm" variant="ghost">
View all
</Button>
</div>
)
}
function RailFooter({ onOpenSkills, onOpenSystem }: { onOpenSkills: () => void; onOpenSystem: () => void }) {
return (
<div className="mt-2 flex items-center gap-1 px-1.5">
<Button className="h-6 flex-1 justify-start px-2 text-[0.68rem]" onClick={onOpenSkills} size="sm" variant="ghost">
Agents
</Button>
<Button className="h-6 flex-1 justify-start px-2 text-[0.68rem]" onClick={onOpenSystem} size="sm" variant="ghost">
System
</Button>
</div>
)
}
function EmptyRail() {
return (
<div className="rounded-md border border-border/45 bg-background/55 px-2.5 py-2 text-[0.68rem] text-muted-foreground/80">
No background activity.
</div>
)
}
function RailRow({ task }: { task: RailTask }) {
return (
<div className="rounded-md border border-border/45 bg-background/58 px-2 py-1.5">
<div className="flex items-center gap-1.5">
{TASK_ICONS[task.status]}
<span className="truncate text-[0.72rem] font-medium text-foreground/90">{task.label}</span>
</div>
<div className="mt-0.5 truncate pl-4.5 text-[0.66rem] text-muted-foreground/80">{task.detail}</div>
</div>
)
}

View file

@ -0,0 +1,80 @@
import { atom, computed } from 'nanostores'
type Updater<T> = T | ((current: T) => T)
interface WritableStore<T> {
get: () => T
set: (value: T) => void
}
const DEFAULT_CONSOLE_HEIGHT = 240
export interface ConsoleEntry {
id: number
level: number
line?: number
message: string
source?: string
}
export interface ConsoleEntryInput {
level: number
line?: number
message: string
source?: string
}
function updateAtom<T>(store: WritableStore<T>, next: Updater<T>) {
store.set(typeof next === 'function' ? (next as (current: T) => T)(store.get()) : next)
}
export function createPreviewConsoleState() {
const $height = atom(DEFAULT_CONSOLE_HEIGHT)
const $logs = atom<ConsoleEntry[]>([])
const $logCount = computed($logs, logs => logs.length)
const $open = atom(false)
const $selectedLogIds = atom<ReadonlySet<number>>(new Set())
let nextLogId = 0
return {
$height,
$logCount,
$logs,
$open,
$selectedLogIds,
append(entry: ConsoleEntryInput) {
$logs.set([...$logs.get().slice(-199), { ...entry, id: ++nextLogId }])
},
clear() {
$logs.set([])
$selectedLogIds.set(new Set())
},
clearSelection() {
if ($selectedLogIds.get().size === 0) {return}
$selectedLogIds.set(new Set())
},
reset() {
nextLogId = 0
$logs.set([])
$selectedLogIds.set(new Set())
},
setHeight(next: Updater<number>) {
updateAtom($height, next)
},
setOpen(next: Updater<boolean>) {
updateAtom($open, next)
},
toggleSelection(id: number) {
const next = new Set($selectedLogIds.get())
if (!next.delete(id)) {
next.add(id)
}
$selectedLogIds.set(next)
}
}
}
export type PreviewConsoleState = ReturnType<typeof createPreviewConsoleState>

View file

@ -0,0 +1,44 @@
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PreviewPane } from './preview-pane'
describe('PreviewPane console state', () => {
afterEach(() => {
cleanup()
})
it('does not rebuild the pane titlebar group for streamed console logs', () => {
const setTitlebarToolGroup = vi.fn()
const rendered = render(
<PreviewPane
onClose={vi.fn()}
setTitlebarToolGroup={setTitlebarToolGroup}
target={{
kind: 'url',
label: 'Preview',
source: 'http://localhost:5174',
url: 'http://localhost:5174'
}}
/>
)
const initialCalls = setTitlebarToolGroup.mock.calls.length
const webview = rendered.container.querySelector('webview')
expect(webview).toBeInstanceOf(HTMLElement)
act(() => {
webview?.dispatchEvent(
Object.assign(new Event('console-message'), {
level: 0,
message: 'streamed log line',
sourceId: 'http://localhost:5174/src/main.tsx'
})
)
})
expect(setTitlebarToolGroup).toHaveBeenCalledTimes(initialCalls)
})
})

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,44 @@
import { useStore } from '@nanostores/react'
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
import {
$filePreviewTarget,
$previewReloadRequest,
$previewTarget,
dismissFilePreviewTarget,
dismissPreviewTarget
} from '@/store/preview'
import { PreviewPane } from './preview-pane'
const INTRINSIC = 'clamp(18rem, 36vw, 38rem)'
// Track for <Pane id="preview">. Folds the intrinsic clamp with a min-floor
// 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))))`
interface ChatPreviewRailProps {
onRestartServer?: (url: string, context?: string) => Promise<string>
setTitlebarToolGroup?: SetTitlebarToolGroup
}
export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatPreviewRailProps) {
const previewReloadRequest = useStore($previewReloadRequest)
const filePreviewTarget = useStore($filePreviewTarget)
const previewTarget = useStore($previewTarget)
const target = filePreviewTarget ?? previewTarget
if (!target) {return null}
return (
<PreviewPane
onClose={filePreviewTarget ? dismissFilePreviewTarget : dismissPreviewTarget}
onRestartServer={filePreviewTarget ? undefined : onRestartServer}
reloadRequest={previewReloadRequest}
setTitlebarToolGroup={setTitlebarToolGroup}
target={target}
/>
)
}

View file

@ -1,123 +0,0 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Input } from '@/components/ui/input'
import { FolderOpen, GitBranch, Pencil } from '@/lib/icons'
import { RailSection } from './rail-section'
interface ProjectSectionProps {
cwd: string
branch: string
busy: boolean
onChangeCwd?: (cwd: string) => void
onBrowseCwd?: () => void
}
export function ProjectSection({ cwd, branch, busy, onChangeCwd, onBrowseCwd }: ProjectSectionProps) {
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(cwd)
const inputRef = useRef<HTMLInputElement | null>(null)
const canChange = Boolean(onChangeCwd) && !busy
const beginEdit = () => canChange && setEditing(true)
useEffect(() => {
if (!editing) {
setDraft(cwd)
}
}, [cwd, editing])
useEffect(() => {
if (editing) {
inputRef.current?.focus()
}
}, [editing])
const apply = () => {
const next = draft.trim()
if (next && next !== cwd) {
onChangeCwd?.(next)
}
setEditing(false)
}
const branchLabel = branch.trim()
return (
<RailSection title="Project">
{editing ? (
<Input
className="-ml-1.5 w-[calc(100%_+_0.375rem)] h-7 bg-background px-1.5 font-mono text-[0.6875rem]"
onBlur={apply}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault()
apply()
} else if (e.key === 'Escape') {
e.preventDefault()
setEditing(false)
}
}}
placeholder="/path/to/project"
ref={inputRef}
value={draft}
/>
) : (
<div className="-ml-1.5 w-[calc(100%_+_0.375rem)] group grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1 rounded-md border border-transparent bg-transparent px-1.5 py-1 font-mono text-[0.6875rem] text-foreground/75 transition-[background-color,border-color,color,box-shadow] hover:border-input hover:bg-background hover:text-foreground focus-visible:border-ring focus-visible:bg-background focus-visible:outline-none focus-visible:ring-[0.1875rem] focus-visible:ring-ring/30">
<button
aria-label="Browse workspace folder"
className="grid size-4 shrink-0 place-items-center rounded text-muted-foreground/60 hover:text-foreground focus-visible:outline-none disabled:cursor-default disabled:hover:text-muted-foreground/60"
disabled={!canChange || !onBrowseCwd}
onClick={onBrowseCwd}
type="button"
>
<FolderOpen className="size-3" />
</button>
<button
aria-label="Edit working directory"
className="min-w-0 truncate text-right focus-visible:outline-none disabled:cursor-default"
dir="rtl"
disabled={!canChange}
onClick={beginEdit}
type="button"
>
<span dir="ltr">{compactPath(cwd) || '—'}</span>
</button>
{canChange && (
<button
aria-hidden="true"
className="grid size-4 shrink-0 place-items-center rounded text-muted-foreground/60 opacity-60 transition-opacity hover:text-foreground group-hover:opacity-100 focus-visible:outline-none"
onClick={beginEdit}
tabIndex={-1}
type="button"
>
<Pencil className="size-3" />
</button>
)}
</div>
)}
{branchLabel && (
<div className="-ml-1.5 w-[calc(100%_+_0.375rem)] flex min-w-0 items-center gap-1 rounded-md border border-transparent bg-transparent px-1.5 py-1 text-[0.6875rem] transition-[background-color,border-color,color,box-shadow] hover:border-input hover:bg-background hover:text-foreground">
<GitBranch className="size-3 shrink-0 text-muted-foreground/60" />
<span className="min-w-0 truncate font-mono text-foreground/75">{branchLabel}</span>
</div>
)}
</RailSection>
)
}
function compactPath(path: string): string {
if (!path) {
return ''
}
const normalized = path.replace(/\\/g, '/').replace(/\/+$/, '')
const parts = normalized.split('/').filter(Boolean)
return parts.length <= 4 ? normalized || path : `.../${parts.slice(-3).join('/')}`
}

View file

@ -1,33 +0,0 @@
'use client'
import { ChevronDown } from '@/lib/icons'
import { cn } from '@/lib/utils'
interface RailActionRowProps {
primary: string
secondary?: string
ariaLabel?: string
onClick?: () => void
}
export function RailActionRow({ primary, secondary, ariaLabel, onClick }: RailActionRowProps) {
return (
<button
aria-label={ariaLabel}
className={cn(
'-ml-1.5 w-[calc(100%_+_0.375rem)] group grid gap-px rounded-md border border-transparent bg-transparent px-1.5 py-1 text-left transition-[background-color,border-color,color,box-shadow] hover:border-input hover:bg-background hover:text-foreground focus-visible:border-ring focus-visible:bg-background focus-visible:outline-none focus-visible:ring-[0.1875rem] focus-visible:ring-ring/30 disabled:cursor-default disabled:hover:border-transparent disabled:hover:bg-transparent'
)}
disabled={!onClick}
onClick={onClick}
type="button"
>
<span className="flex items-center gap-1.5">
<span className="min-w-0 flex-1 truncate font-mono text-[0.6875rem] text-foreground/85">{primary}</span>
{onClick && (
<ChevronDown className="size-3 shrink-0 text-muted-foreground/60 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100" />
)}
</span>
{secondary && <span className="truncate text-[0.625rem] text-muted-foreground/70">{secondary}</span>}
</button>
)
}

View file

@ -1,19 +0,0 @@
import type { ReactNode } from 'react'
export function RailSectionLabel({ children }: { children: ReactNode }) {
return <div className="text-xs font-medium text-muted-foreground/90">{children}</div>
}
interface RailSectionProps {
title: string
children: ReactNode
}
export function RailSection({ title, children }: RailSectionProps) {
return (
<section className="grid gap-1.5 py-1.5">
<RailSectionLabel>{title}</RailSectionLabel>
{children}
</section>
)
}

View file

@ -1,88 +0,0 @@
'use client'
import { type ReactNode, useState } from 'react'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { ChevronDown } from '@/lib/icons'
import { cn } from '@/lib/utils'
export interface RailSelectOption {
value: string
label: string
}
interface RailSelectRowProps {
label: string
menuLabel?: string
value: string
options: RailSelectOption[]
valueLabel?: ReactNode
ariaLabel?: string
menuWidthClass?: string
onChange?: (value: string) => void
}
export function RailSelectRow({
label,
menuLabel,
value,
options,
valueLabel,
ariaLabel,
menuWidthClass = 'w-44',
onChange
}: RailSelectRowProps) {
const [open, setOpen] = useState(false)
const activeOption = options.find(option => option.value === value)
const displayLabel = valueLabel ?? activeOption?.label ?? value
return (
<DropdownMenu onOpenChange={setOpen} open={open}>
<DropdownMenuTrigger asChild disabled={!onChange}>
<button
aria-label={ariaLabel ?? `Change ${label.toLowerCase()}`}
className="-ml-1.5 w-[calc(100%_+_0.375rem)] group flex items-center gap-1.5 rounded-md border border-transparent bg-transparent px-1.5 py-1 text-left transition-[background-color,border-color,color,box-shadow] hover:border-input hover:bg-background hover:text-foreground focus-visible:border-ring focus-visible:bg-background focus-visible:outline-none focus-visible:ring-[0.1875rem] focus-visible:ring-ring/30 disabled:cursor-default disabled:hover:border-transparent disabled:hover:bg-transparent"
type="button"
>
<span className="min-w-0 flex-1 truncate text-[0.6875rem] text-muted-foreground group-hover:text-foreground group-focus-within:text-foreground">
{label}
</span>
<span className="truncate text-[0.6875rem] text-foreground/75">{displayLabel}</span>
{onChange && (
<ChevronDown className="size-3 shrink-0 text-muted-foreground/60 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100" />
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className={cn(menuWidthClass, 'border-border/70 bg-popover/95 shadow-md')}
side="bottom"
sideOffset={6}
>
<DropdownMenuLabel className="text-xs text-muted-foreground">{menuLabel ?? label}</DropdownMenuLabel>
<DropdownMenuSeparator />
{options.map(option => (
<DropdownMenuCheckboxItem
checked={value === option.value}
className="text-xs text-muted-foreground focus:text-foreground"
key={option.value}
onSelect={e => {
e.preventDefault()
onChange?.(option.value)
setOpen(false)
}}
>
{option.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}

View file

@ -1,36 +0,0 @@
'use client'
import { useId } from 'react'
import { Switch } from '@/components/ui/switch'
interface RailToggleRowProps {
label: string
checked: boolean
valueLabel?: string
onChange?: (enabled: boolean) => void
}
export function RailToggleRow({ label, checked, valueLabel, onChange }: RailToggleRowProps) {
const id = useId()
const disabled = !onChange
return (
<label
className={`-ml-1.5 w-[calc(100%_+_0.375rem)] group flex items-center gap-1.5 rounded-md border border-transparent bg-transparent px-1.5 py-1 text-left transition-[background-color,border-color,color,box-shadow] ${disabled ? 'cursor-default' : 'cursor-pointer hover:border-input hover:bg-background hover:text-foreground focus-within:border-ring focus-within:bg-background focus-within:ring-[0.1875rem] focus-within:ring-ring/30'}`}
htmlFor={id}
>
<span className="min-w-0 flex-1 truncate text-[0.6875rem] text-muted-foreground group-hover:text-foreground group-focus-within:text-foreground">
{label}
</span>
<span className="truncate text-[0.6875rem] text-foreground/75">{valueLabel ?? (checked ? 'On' : 'Off')}</span>
<Switch
checked={checked}
className="h-4 w-7 [&_[data-slot=switch-thumb]]:size-3 [&_[data-slot=switch-thumb]]:data-[state=checked]:translate-x-3"
disabled={disabled}
id={id}
onCheckedChange={next => onChange?.(next)}
/>
</label>
)
}

View file

@ -15,7 +15,7 @@ import {
} from '@/components/ui/sidebar'
import { Skeleton } from '@/components/ui/skeleton'
import type { SessionInfo } from '@/hermes'
import { Brain, ChevronDown, Layers3, Pin, Plus, RefreshCw } from '@/lib/icons'
import { Brain, ChevronDown, Command, Layers3, Pin, Plus, RefreshCw, Settings } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$pinnedSessionIds,
@ -29,7 +29,7 @@ import {
} from '@/store/layout'
import { $selectedStoredSessionId, $sessions, $sessionsLoading, $workingSessionIds } from '@/store/session'
import { type AppView, ARTIFACTS_ROUTE, SKILLS_ROUTE } from '../../routes'
import { type AppView, ARTIFACTS_ROUTE, COMMAND_CENTER_ROUTE, SETTINGS_ROUTE, SKILLS_ROUTE } from '../../routes'
import type { SidebarNavItem } from '../../types'
import { SidebarSessionRow } from './session-row'
@ -37,16 +37,21 @@ import { SidebarSessionRow } from './session-row'
const SIDEBAR_NAV: SidebarNavItem[] = [
{
id: 'new-session',
label: 'New session',
label: 'New chat',
icon: Plus,
action: 'new-session'
},
{ id: 'command-center', label: 'Command Center', icon: Command, route: COMMAND_CENTER_ROUTE },
{ id: 'skills', label: 'Skills', icon: Brain, route: SKILLS_ROUTE },
{ id: 'artifacts', label: 'Artifacts', icon: Layers3, route: ARTIFACTS_ROUTE }
{ id: 'artifacts', label: 'Artifacts', icon: Layers3, route: ARTIFACTS_ROUTE },
{ id: 'settings', label: 'Settings', icon: Settings, route: SETTINGS_ROUTE }
]
const sidebarNavItemClass =
'flex h-7 w-full justify-start gap-2 rounded-md px-2 text-left text-sm font-medium text-muted-foreground transition-colors duration-300 ease-out hover:bg-accent hover:text-foreground hover:transition-none'
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-sm font-medium text-muted-foreground transition-colors duration-300 ease-out 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 hover:transition-none'
const sidebarNavItemActiveClass =
'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)]'
interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
currentView: AppView
@ -109,12 +114,17 @@ export function ChatSidebar({
>
<SidebarContent className="gap-0 overflow-hidden bg-transparent">
<SidebarGroup className="shrink-0 pl-4 pr-2 pb-2 pt-[calc(var(--titlebar-height)+0.25rem)]">
<SidebarGroupLabel className="h-auto px-2 pb-1 pt-1 text-[0.64rem] font-semibold uppercase tracking-[0.07em] text-muted-foreground/70">
Workspace
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className="gap-px">
{SIDEBAR_NAV.map(item => {
const isInteractive = Boolean(item.action) || Boolean(item.route)
const active =
(item.id === 'command-center' && currentView === 'command-center') ||
(item.id === 'settings' && currentView === 'settings') ||
(item.id === 'skills' && currentView === 'skills') ||
(item.id === 'artifacts' && currentView === 'artifacts')
@ -124,8 +134,9 @@ export function ChatSidebar({
aria-disabled={!isInteractive}
className={cn(
sidebarNavItemClass,
active && 'bg-accent text-foreground',
!isInteractive && 'cursor-default hover:bg-transparent hover:text-muted-foreground'
active && sidebarNavItemActiveClass,
!isInteractive &&
'cursor-default hover:border-transparent hover:bg-transparent hover:text-muted-foreground'
)}
onClick={() => onNavigate(item)}
tooltip={item.label}
@ -147,9 +158,9 @@ export function ChatSidebar({
{pinsOpen && (
<SidebarGroupContent className="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1">
{pinnedSessions.length === 0 && (
<div className="flex min-h-8 items-center gap-2 rounded-lg px-2 text-xs text-muted-foreground opacity-50">
<div className="flex min-h-8 items-center gap-2 rounded-lg px-2 text-xs text-muted-foreground/80">
<Pin size={14} />
<span>Shift+click to pin</span>
<span>Pin important chats from the menu</span>
</div>
)}
{pinnedSessions.map(session => (
@ -188,7 +199,7 @@ export function ChatSidebar({
<RefreshCw className={cn(sessionsLoading && 'animate-spin')} />
</Button>
}
label="Sessions"
label="Recent chats"
onToggle={() => setSidebarRecentsOpen(!recentsOpen)}
open={recentsOpen}
/>
@ -270,7 +281,7 @@ function SidebarSessionSkeletons() {
function SidebarEmptySessionState() {
return (
<div className="grid min-h-35 place-items-center rounded-lg px-3 text-center text-xs text-muted-foreground">
Recent chats will appear here.
Start a chat to build your history.
</div>
)
}
@ -278,7 +289,7 @@ function SidebarEmptySessionState() {
function SidebarAllPinnedState() {
return (
<div className="grid min-h-24 place-items-center rounded-lg px-3 text-center text-xs text-muted-foreground">
Pinned sessions stay above.
Everything here is pinned. Unpin a chat to show it in recents.
</div>
)
}

View file

@ -1,15 +1,18 @@
import {
IconArchive,
IconBookmark,
IconBookmarkFilled,
IconCircleX,
IconCopy,
IconFileDownload,
IconPencil
} from '@tabler/icons-react'
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 { Button } from '@/components/ui/button'
import { CopyButton } from '@/components/ui/copy-button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
@ -17,10 +20,13 @@ import {
DropdownMenuSeparator,
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>,
@ -45,70 +51,154 @@ export function SessionActionsMenu({
sideOffset = 6
}: SessionActionsMenuProps) {
const itemClass = 'gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4'
const [renameOpen, setRenameOpen] = useState(false)
const copyId = async () => {
triggerHaptic('selection')
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent align={align} aria-label={`Actions for ${title}`} className="w-44" sideOffset={sideOffset}>
<DropdownMenuItem
className={itemClass}
disabled={!onPin}
onSelect={() => {
triggerHaptic('selection')
onPin?.()
}}
>
{pinned ? <IconBookmarkFilled /> : <IconBookmark />}
<span>{pinned ? 'Unpin' : 'Pin'}</span>
</DropdownMenuItem>
<CopyButton
appearance="menu-item"
className={itemClass}
disabled={!sessionId}
errorMessage="Could not copy session ID"
label="Copy ID"
text={sessionId}
/>
<DropdownMenuItem
className={itemClass}
disabled={!sessionId}
onSelect={() => {
triggerHaptic('selection')
void exportSession(sessionId, { title })
}}
>
<IconFileDownload />
<span>Export</span>
</DropdownMenuItem>
<DropdownMenuItem
className={itemClass}
disabled={!sessionId}
onSelect={() => {
triggerHaptic('selection')
setRenameOpen(true)
}}
>
<IconPencil />
<span>Rename</span>
</DropdownMenuItem>
<DropdownMenuSeparator className="my-3" />
<DropdownMenuItem
className={cn(itemClass, 'text-destructive focus:text-destructive')}
disabled={!onDelete}
onSelect={() => {
triggerHaptic('warning')
onDelete?.()
}}
variant="destructive"
>
<IconCircleX />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<RenameSessionDialog currentTitle={title} onOpenChange={setRenameOpen} open={renameOpen} sessionId={sessionId} />
</>
)
}
interface RenameSessionDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
sessionId: string
currentTitle: string
}
function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle }: RenameSessionDialogProps) {
const [value, setValue] = useState(currentTitle)
const [submitting, setSubmitting] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (open) {
setValue(currentTitle)
window.setTimeout(() => inputRef.current?.select(), 0)
}
}, [currentTitle, open])
const submit = async () => {
const next = value.trim()
if (!sessionId || submitting) {
return
}
if (next === currentTitle.trim()) {
onOpenChange(false)
return
}
setSubmitting(true)
try {
await navigator.clipboard.writeText(sessionId)
notify({ kind: 'success', message: 'Session ID copied', durationMs: 2_000 })
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 })
onOpenChange(false)
} catch (err) {
notifyError(err, 'Could not copy session ID')
notifyError(err, 'Rename failed')
} finally {
setSubmitting(false)
}
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent align={align} aria-label={`Actions for ${title}`} className="w-44" sideOffset={sideOffset}>
<DropdownMenuItem
className={itemClass}
disabled={!onPin}
onSelect={() => {
triggerHaptic('selection')
onPin?.()
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Rename session</DialogTitle>
<DialogDescription>Give this chat a memorable title. Leave empty to clear.</DialogDescription>
</DialogHeader>
<Input
autoFocus
disabled={submitting}
onChange={event => setValue(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
void submit()
} else if (event.key === 'Escape') {
onOpenChange(false)
}
}}
>
{pinned ? <IconBookmarkFilled /> : <IconBookmark />}
<span>{pinned ? 'Unpin' : 'Pin'}</span>
</DropdownMenuItem>
<DropdownMenuItem className={itemClass} onSelect={() => void copyId()}>
<IconCopy />
<span>Copy ID</span>
</DropdownMenuItem>
<DropdownMenuItem
className={itemClass}
disabled={!sessionId}
onSelect={() => {
triggerHaptic('selection')
void exportSession(sessionId, { title })
}}
>
<IconFileDownload />
<span>Export</span>
</DropdownMenuItem>
<DropdownMenuItem className={itemClass}>
<IconPencil />
<span>Rename</span>
</DropdownMenuItem>
<DropdownMenuItem className={itemClass}>
<IconArchive />
<span>Add to project</span>
</DropdownMenuItem>
<DropdownMenuSeparator className="my-3" />
<DropdownMenuItem
className={cn(itemClass, 'text-destructive focus:text-destructive')}
disabled={!onDelete}
onSelect={() => {
triggerHaptic('warning')
onDelete?.()
}}
variant="destructive"
>
<IconCircleX />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
placeholder="Untitled session"
ref={inputRef}
value={value}
/>
<DialogFooter>
<Button disabled={submitting} onClick={() => onOpenChange(false)} type="button" variant="ghost">
Cancel
</Button>
<Button disabled={submitting} onClick={() => void submit()} type="button">
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,167 @@
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 { ProjectTree } from './tree'
import { useProjectTree } from './use-project-tree'
const HEADER_ACTION_CLASS =
'pointer-events-none size-6 shrink-0 opacity-0 text-muted-foreground/75 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'
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)] 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 pl-4 pr-2 pb-1 pt-0">
<div className="flex items-center gap-1.5">
<FadeText
className="flex-1 px-2 pb-1 pt-1 text-[0.64rem] font-semibold uppercase tracking-[0.07em] text-muted-foreground/70"
title={hasCwd ? currentCwd : 'No folder selected'}
>
{cwdName}
</FadeText>
<Button
aria-label="Change working directory"
className={HEADER_ACTION_CLASS}
onClick={() => void chooseFolder()}
size="icon"
title="Change working directory"
variant="ghost"
>
<FolderOpen className="size-3.5" />
</Button>
<Button
aria-label="Refresh tree"
className={HEADER_ACTION_CLASS}
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

@ -0,0 +1,143 @@
import ignore from 'ignore'
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
export type ProjectTreeEntry = HermesReadDirEntry
interface GitignoreRule {
base: string
ig: ReturnType<typeof ignore>
}
const gitRootCache = new Map<string, Promise<string | null>>()
const gitignoreCache = new Map<string, Promise<GitignoreRule | null>>()
function decodeDataUrl(dataUrl: string) {
const match = dataUrl.match(/^data:[^,]*,(.*)$/)
const data = match?.[1] || ''
const isBase64 = dataUrl.slice(0, dataUrl.indexOf(',')).includes(';base64')
if (!isBase64) {return decodeURIComponent(data)}
const bytes = Uint8Array.from(atob(data), ch => ch.charCodeAt(0))
return new TextDecoder().decode(bytes)
}
function clean(path: string) {
return path.replace(/\/+$/, '') || '/'
}
/** Strict POSIX-style relative path; null if `child` is not inside `root`. */
function relativeTo(root: string, child: string) {
const r = clean(root)
const c = clean(child)
if (c === r) {return ''}
return c.startsWith(`${r}/`) ? c.slice(r.length + 1) : null
}
/** Repo-root → repo-root/a → repo-root/a/b → … for every dir between root and `dir`. */
function ancestorDirs(root: string, dir: string) {
const r = clean(root)
const rel = relativeTo(r, dir)
if (rel === null || rel === '') {return [r]}
const dirs = [r]
let current = r
for (const part of rel.split('/').filter(Boolean)) {
current = `${current}/${part}`
dirs.push(current)
}
return dirs
}
async function gitRootFor(start: string) {
if (!window.hermesDesktop?.gitRoot) {return null}
const key = clean(start)
let cached = gitRootCache.get(key)
if (!cached) {
cached = window.hermesDesktop.gitRoot(key)
gitRootCache.set(key, cached)
}
return cached
}
/** Read .gitignore at `dir` if it actually exists — never probe missing files. */
async function readGitignore(dir: string): Promise<GitignoreRule | null> {
if (!window.hermesDesktop?.readDir || !window.hermesDesktop.readFileDataUrl) {return null}
try {
const listing = await window.hermesDesktop.readDir(dir)
if (!listing.entries.some(e => e.name === '.gitignore' && !e.isDirectory)) {return null}
const text = decodeDataUrl(await window.hermesDesktop.readFileDataUrl(`${dir}/.gitignore`))
return { base: dir, ig: ignore().add(text) }
} catch {
return null
}
}
async function gitignoreFor(dir: string) {
const key = clean(dir)
let cached = gitignoreCache.get(key)
if (!cached) {
cached = readGitignore(key)
gitignoreCache.set(key, cached)
}
return cached
}
function ignoredBy(rules: GitignoreRule[], entry: HermesReadDirEntry) {
return rules.some(rule => {
const rel = relativeTo(rule.base, entry.path)
if (rel === null || rel === '') {return false}
return rule.ig.ignores(entry.isDirectory ? `${rel}/` : rel)
})
}
async function filterIgnored(entries: HermesReadDirEntry[], rootPath: string, dirPath: string) {
const root = await gitRootFor(rootPath)
if (!root) {return entries}
const rules = (await Promise.all(ancestorDirs(root, dirPath).map(gitignoreFor))).filter(
(r): r is GitignoreRule => Boolean(r)
)
return rules.length > 0 ? entries.filter(entry => !ignoredBy(rules, entry)) : entries
}
export async function readProjectDir(dirPath: string, rootPath = dirPath): Promise<HermesReadDirResult> {
if (!window.hermesDesktop) {return { entries: [], error: 'no-bridge' }}
const result = await window.hermesDesktop.readDir(dirPath)
return { ...result, entries: await filterIgnored(result.entries, rootPath, dirPath) }
}
export function clearProjectDirCache(rootPath?: string) {
if (!rootPath) {
gitRootCache.clear()
gitignoreCache.clear()
return
}
const key = clean(rootPath)
gitRootCache.delete(key)
gitignoreCache.delete(key)
}

View file

@ -0,0 +1,177 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { type NodeApi, type NodeRendererProps, Tree, type TreeApi } from 'react-arborist'
import { ChevronDown, ChevronRight, FileText, FolderOpen, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { TreeNode } from './use-project-tree'
const ROW_HEIGHT = 24
const INDENT = 14
interface ProjectTreeProps {
data: TreeNode[]
onActivateFile: (path: string) => void
onLoadChildren: (id: string) => void | Promise<void>
onNodeOpenChange: (id: string, open: boolean) => void
onPreviewFile?: (path: string) => void
openState: Record<string, boolean>
}
export function ProjectTree({
data,
onActivateFile,
onLoadChildren,
onNodeOpenChange,
onPreviewFile,
openState
}: ProjectTreeProps) {
const containerRef = useRef<HTMLDivElement | null>(null)
const treeRef = useRef<TreeApi<TreeNode> | null>(null)
const [size, setSize] = useState({ height: 0, width: 0 })
useEffect(() => {
const el = containerRef.current
if (!el || typeof ResizeObserver === 'undefined') {return}
const observer = new ResizeObserver(([entry]) => {
const { height, width } = entry.contentRect
setSize({ height, width })
})
observer.observe(el)
return () => observer.disconnect()
}, [])
const handleToggle = useCallback(
(id: string) => {
const node = treeRef.current?.get(id)
if (!node) {return}
onNodeOpenChange(id, node.isOpen)
// Lazy fetch on first expand: children===undefined means "not yet loaded".
if (node.isOpen && node.data.children === undefined) {
void onLoadChildren(id)
}
},
[onLoadChildren, onNodeOpenChange]
)
const handleActivate = useCallback(
(node: NodeApi<TreeNode>) => {
if (!node.data.isDirectory) {
onPreviewFile?.(node.data.id)
}
},
[onPreviewFile]
)
return (
<div className="min-h-0 flex-1 overflow-hidden px-2" ref={containerRef}>
{size.height > 0 && size.width > 0 ? (
<Tree<TreeNode>
childrenAccessor={node => (node.isDirectory ? (node.children ?? []) : null)}
data={data}
disableDrag
disableDrop
disableEdit
height={size.height}
indent={INDENT}
initialOpenState={openState}
onActivate={handleActivate}
onToggle={handleToggle}
openByDefault={false}
padding={2}
ref={treeRef}
rowHeight={ROW_HEIGHT}
width={size.width}
>
{props => <ProjectTreeRow {...props} onAttachFile={onActivateFile} onPreviewFile={onPreviewFile} />}
</Tree>
) : null}
</div>
)
}
function ProjectTreeRow({
dragHandle,
node,
onAttachFile,
onPreviewFile,
style
}: NodeRendererProps<TreeNode> & { onAttachFile: (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-1 rounded-sm px-1.5 text-[0.72rem] leading-none text-foreground/85 transition-colors hover:bg-accent/55',
node.isSelected && 'bg-accent/65 text-foreground',
isPlaceholder && 'pointer-events-none italic text-muted-foreground/70'
)}
draggable={!isPlaceholder}
onClick={event => {
event.stopPropagation()
if (isPlaceholder) {return}
if (isFolder) {
node.toggle()
} else {
node.select()
if (event.shiftKey) {
onAttachFile(node.data.id)
}
}
}}
onDoubleClick={event => {
event.stopPropagation()
if (!isFolder && !isPlaceholder) {
onPreviewFile?.(node.data.id)
}
}}
onDragStart={event => {
if (isPlaceholder) {
event.preventDefault()
return
}
// Custom MIME the composer's drop handler unpacks. text/plain is set
// as a fallback so dragging into other apps gets a sensible payload
// (the absolute path).
const payload = JSON.stringify([{ isDirectory: isFolder, path: node.data.id }])
event.dataTransfer.effectAllowed = 'copy'
event.dataTransfer.setData('application/x-hermes-paths', payload)
event.dataTransfer.setData('text/plain', node.data.id)
}}
ref={dragHandle}
style={style}
>
<span aria-hidden className={cn('flex w-3.5 items-center justify-center', !isFolder && 'opacity-0')}>
{isFolder && !isPlaceholder ? <Caret className="size-3 text-muted-foreground/70" /> : null}
</span>
<span aria-hidden className="flex w-3.5 items-center justify-center text-muted-foreground/85">
{isPlaceholder ? (
<Loader2 className="size-3 animate-spin" />
) : isFolder ? (
<FolderOpen className="size-3.5" />
) : (
<FileText className="size-3.5" />
)}
</span>
<span className="min-w-0 flex-1 truncate">{node.data.name}</span>
</div>
)
}

View file

@ -0,0 +1,190 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { HermesReadDirResult } from '@/global'
import { resetProjectTreeState, useProjectTree } from './use-project-tree'
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
beforeEach(() => {
resetProjectTreeState()
readDir.mockReset()
;(window as unknown as { hermesDesktop: { readDir: typeof readDir } }).hermesDesktop = { readDir }
})
afterEach(() => {
resetProjectTreeState()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
function ok(entries: { name: string; path: string; isDirectory: boolean }[]): HermesReadDirResult {
return { entries }
}
describe('useProjectTree', () => {
it('starts empty when cwd is blank and skips IPC', async () => {
const { result } = renderHook(() => useProjectTree(''))
await waitFor(() => expect(result.current.rootLoading).toBe(false))
expect(result.current.data).toEqual([])
expect(result.current.rootError).toBeNull()
expect(readDir).not.toHaveBeenCalled()
})
it('loads root entries on mount and sorts folders before files', async () => {
readDir.mockResolvedValueOnce(
ok([
{ name: 'README.md', path: '/p/README.md', isDirectory: false },
{ name: 'src', path: '/p/src', isDirectory: true }
])
)
const { result } = renderHook(() => useProjectTree('/p'))
await waitFor(() => expect(result.current.data.length).toBe(2))
expect(readDir).toHaveBeenCalledWith('/p')
// Hook trusts main-process sort order; folders/files preserved as supplied.
expect(result.current.data.map(n => n.name)).toEqual(['README.md', 'src'])
// Folder children start undefined (lazy load on first expand).
expect(result.current.data.find(n => n.name === 'src')?.children).toBeUndefined()
expect(result.current.data.find(n => n.name === 'src')?.isDirectory).toBe(true)
expect(result.current.data.find(n => n.name === 'README.md')?.isDirectory).toBe(false)
})
it('records rootError when readDir returns an error', async () => {
readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' })
const { result } = renderHook(() => useProjectTree('/locked'))
await waitFor(() => expect(result.current.rootError).toBe('EACCES'))
expect(result.current.data).toEqual([])
})
it('lazy-loads children on loadChildren and replaces the placeholder', async () => {
readDir.mockResolvedValueOnce(ok([{ name: 'src', path: '/p/src', isDirectory: true }]))
readDir.mockResolvedValueOnce(
ok([
{ name: 'index.ts', path: '/p/src/index.ts', isDirectory: false },
{ name: 'lib', path: '/p/src/lib', isDirectory: true }
])
)
const { result } = renderHook(() => useProjectTree('/p'))
await waitFor(() => expect(result.current.data.length).toBe(1))
await act(async () => {
await result.current.loadChildren('/p/src')
})
const src = result.current.data[0]
expect(src.children?.map(n => n.name)).toEqual(['index.ts', 'lib'])
expect(src.loading).toBe(false)
expect(src.error).toBeUndefined()
})
it('keeps loaded tree state across remounts for the same cwd', async () => {
readDir.mockResolvedValueOnce(ok([{ name: 'src', path: '/p/src', isDirectory: true }]))
const { result, unmount } = renderHook(() => useProjectTree('/p'))
await waitFor(() => expect(result.current.data.length).toBe(1))
act(() => {
result.current.setNodeOpen('/p/src', true)
})
unmount()
const remounted = renderHook(() => useProjectTree('/p'))
expect(remounted.result.current.data.map(n => n.name)).toEqual(['src'])
expect(remounted.result.current.openState).toEqual({ '/p/src': true })
expect(readDir).toHaveBeenCalledTimes(1)
})
it('captures per-folder error code and leaves the folder expandable but empty', async () => {
readDir.mockResolvedValueOnce(ok([{ name: 'priv', path: '/p/priv', isDirectory: true }]))
readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' })
const { result } = renderHook(() => useProjectTree('/p'))
await waitFor(() => expect(result.current.data.length).toBe(1))
await act(async () => {
await result.current.loadChildren('/p/priv')
})
expect(result.current.data[0].error).toBe('EACCES')
expect(result.current.data[0].children).toEqual([])
})
it('dedupes concurrent loadChildren calls for the same id', async () => {
readDir.mockResolvedValueOnce(ok([{ name: 'src', path: '/p/src', isDirectory: true }]))
let resolveChildren: ((value: HermesReadDirResult) => void) | undefined
readDir.mockImplementationOnce(
() =>
new Promise<HermesReadDirResult>(resolve => {
resolveChildren = resolve
})
)
const { result } = renderHook(() => useProjectTree('/p'))
await waitFor(() => expect(result.current.data.length).toBe(1))
await act(async () => {
// First call enters inflight, second short-circuits, third also short-circuits.
void result.current.loadChildren('/p/src')
void result.current.loadChildren('/p/src')
void result.current.loadChildren('/p/src')
resolveChildren?.(ok([{ name: 'a.ts', path: '/p/src/a.ts', isDirectory: false }]))
})
// Mount load + a single folder fetch — duplicates were dropped.
expect(readDir).toHaveBeenCalledTimes(2)
})
it('refreshRoot reloads the root and clears prior error', async () => {
readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' })
readDir.mockResolvedValueOnce(ok([{ name: 'README.md', path: '/p/README.md', isDirectory: false }]))
const { result } = renderHook(() => useProjectTree('/p'))
await waitFor(() => expect(result.current.rootError).toBe('EACCES'))
await act(async () => {
await result.current.refreshRoot()
})
expect(result.current.rootError).toBeNull()
expect(result.current.data.map(n => n.name)).toEqual(['README.md'])
})
it('reloads when cwd changes', async () => {
readDir.mockResolvedValueOnce(ok([{ name: 'one', path: '/a/one', isDirectory: false }]))
readDir.mockResolvedValueOnce(ok([{ name: 'two', path: '/b/two', isDirectory: false }]))
const { rerender, result } = renderHook(({ cwd }) => useProjectTree(cwd), { initialProps: { cwd: '/a' } })
await waitFor(() => expect(result.current.data[0]?.name).toBe('one'))
rerender({ cwd: '/b' })
await waitFor(() => expect(result.current.data[0]?.name).toBe('two'))
expect(readDir).toHaveBeenLastCalledWith('/b')
})
it('returns no-bridge gracefully when window.hermesDesktop is missing', async () => {
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
const { result } = renderHook(() => useProjectTree('/p'))
await waitFor(() => expect(result.current.rootError).toBe('no-bridge'))
expect(result.current.data).toEqual([])
})
})

View file

@ -0,0 +1,221 @@
import { useStore } from '@nanostores/react'
import { atom } from 'nanostores'
import { useCallback, useEffect, useMemo } from 'react'
import { clearProjectDirCache, readProjectDir } from './ipc'
export interface TreeNode {
/** Absolute filesystem path. Doubles as react-arborist node id. */
id: string
name: string
/** Drives arborist's leaf-vs-expandable decision via childrenAccessor. */
isDirectory: boolean
/** `undefined` = directory, children not yet loaded. `[]` = loaded empty. */
children?: TreeNode[]
/** True while a readDir for this folder is in flight. */
loading?: boolean
/** Last error code from readDir (e.g. EACCES). Cleared on next successful load. */
error?: string
}
const PLACEHOLDER_ID = '__loading__'
function makeNode(path: string, name: string, isDirectory: boolean): TreeNode {
return { id: path, isDirectory, name }
}
function patchNode(nodes: TreeNode[] | undefined | null, id: string, patch: (n: TreeNode) => TreeNode): TreeNode[] {
if (!nodes) {return []}
return nodes.map(n => {
if (n.id === id) {return patch(n)}
if (n.children && n.children.length > 0) {
return { ...n, children: patchNode(n.children, id, patch) }
}
return n
})
}
function placeholderChild(parentId: string): TreeNode {
return { id: `${parentId}::${PLACEHOLDER_ID}`, isDirectory: false, name: 'Loading…' }
}
export interface UseProjectTreeResult {
data: TreeNode[]
openState: Record<string, boolean>
rootError: string | null
rootLoading: boolean
loadChildren: (id: string) => Promise<void>
refreshRoot: () => Promise<void>
setNodeOpen: (id: string, open: boolean) => void
}
interface ProjectTreeState {
cwd: string
data: TreeNode[]
loaded: boolean
openState: Record<string, boolean>
requestId: number
rootError: string | null
rootLoading: boolean
}
const initialState: ProjectTreeState = {
cwd: '',
data: [],
loaded: false,
openState: {},
requestId: 0,
rootError: null,
rootLoading: false
}
const inflight = new Set<string>()
const $projectTree = atom<ProjectTreeState>(initialState)
let nextRootRequestId = 0
function setProjectTree(updater: (current: ProjectTreeState) => ProjectTreeState) {
$projectTree.set(updater($projectTree.get()))
}
function clearProjectTree() {
nextRootRequestId += 1
inflight.clear()
$projectTree.set({ ...initialState, requestId: nextRootRequestId })
}
async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}) {
if (!cwd) {
clearProjectTree()
return
}
const current = $projectTree.get()
if (!force && current.cwd === cwd && (current.loaded || current.rootLoading)) {
return
}
const requestId = nextRootRequestId + 1
nextRootRequestId = requestId
inflight.clear()
if (force || current.cwd !== cwd) {
clearProjectDirCache(cwd)
}
$projectTree.set({
cwd,
data: [],
loaded: false,
openState: current.cwd === cwd ? current.openState : {},
requestId,
rootError: null,
rootLoading: true
})
const { entries, error } = await readProjectDir(cwd, cwd)
setProjectTree(latest => {
if (latest.cwd !== cwd || latest.requestId !== requestId) {
return latest
}
return {
...latest,
data: error ? [] : entries.map(e => makeNode(e.path, e.name, e.isDirectory)),
loaded: true,
rootError: error || null,
rootLoading: false
}
})
}
export function resetProjectTreeState() {
clearProjectTree()
clearProjectDirCache()
}
/**
* Lazy-loads a directory tree rooted at `cwd`. Children are fetched on first
* expand and cached in this feature-owned atom so unrelated chat rerenders or
* remounts cannot reset the browser. A placeholder leaf renders so the
* disclosure caret shows for unloaded folders. `refreshRoot` invalidates the
* whole tree (used after cwd change or manual refresh).
*/
export function useProjectTree(cwd: string): UseProjectTreeResult {
const state = useStore($projectTree)
const refreshRoot = useCallback(() => loadRoot(cwd, { force: true }), [cwd])
const setNodeOpen = useCallback(
(id: string, open: boolean) => {
setProjectTree(current => {
if (current.cwd !== cwd || current.openState[id] === open) {
return current
}
return {
...current,
openState: {
...current.openState,
[id]: open
}
}
})
},
[cwd]
)
const loadChildren = useCallback(async (id: string) => {
if (!cwd || inflight.has(id)) {return}
inflight.add(id)
setProjectTree(current => {
if (current.cwd !== cwd) {return current}
return {
...current,
data: patchNode(current.data, id, n => ({ ...n, loading: true, children: [placeholderChild(n.id)] }))
}
})
const { entries, error } = await readProjectDir(id, cwd)
inflight.delete(id)
setProjectTree(current => {
if (current.cwd !== cwd) {return current}
return {
...current,
data: patchNode(current.data, id, n => ({
...n,
loading: false,
error: error || undefined,
children: error ? [] : entries.map(e => makeNode(e.path, e.name, e.isDirectory))
}))
}
})
}, [cwd])
useEffect(() => {
void loadRoot(cwd)
}, [cwd])
return useMemo(
() => ({
data: state.cwd === cwd ? state.data : [],
loadChildren,
openState: state.cwd === cwd ? state.openState : {},
refreshRoot,
rootError: state.cwd === cwd ? state.rootError : null,
rootLoading: state.cwd === cwd ? state.rootLoading : false,
setNodeOpen
}),
[cwd, loadChildren, refreshRoot, setNodeOpen, state.cwd, state.data, state.openState, state.rootError, state.rootLoading]
)
}

View file

@ -30,10 +30,10 @@ export function OverlaySearchInput({
return (
<div className={cn('relative', containerClassName)}>
<Search className="pointer-events-none absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground/80" />
<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(
'h-8.5 rounded-full border border-[color-mix(in_srgb,var(--dt-border)_60%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)] py-2 pl-8 pr-12 text-sm shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_38%,transparent)] focus-visible:border-[color-mix(in_srgb,var(--dt-ring)_70%,transparent)] focus-visible:bg-background',
'relative z-0 h-8.5 rounded-full border border-[color-mix(in_srgb,var(--dt-border)_60%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)] py-2 pl-8 pr-12 text-sm shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_38%,transparent)] focus-visible:border-[color-mix(in_srgb,var(--dt-ring)_70%,transparent)] focus-visible:bg-background dark:border-[color-mix(in_srgb,var(--dt-border)_48%,transparent)] dark:bg-[color-mix(in_srgb,var(--dt-card)_96%,var(--dt-background))] dark:shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_10%,transparent)]',
inputClassName
)}
onChange={event => onChange(event.target.value)}
@ -42,11 +42,11 @@ export function OverlaySearchInput({
value={value}
/>
{loading ? (
<Loader2 className="pointer-events-none absolute right-3 top-1/2 size-3.5 -translate-y-1/2 animate-spin text-muted-foreground/70" />
<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 -translate-y-1/2 text-muted-foreground/85 hover:bg-accent/60 hover:text-foreground"
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"

View file

@ -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',
'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)]',
className
)}
>

View file

@ -4,10 +4,11 @@ export const SETTINGS_ROUTE = '/settings'
export const COMMAND_CENTER_ROUTE = '/command-center'
export const SKILLS_ROUTE = '/skills'
export const ARTIFACTS_ROUTE = '/artifacts'
export const AGENTS_ROUTE = '/agents'
export type AppView = 'chat' | 'settings' | 'command-center' | 'skills' | 'artifacts'
export type AppView = 'chat' | 'settings' | 'command-center' | 'skills' | 'artifacts' | 'agents'
export type AppRouteId = 'new' | 'settings' | 'command-center' | 'skills' | 'artifacts'
export type AppRouteId = 'new' | 'settings' | 'command-center' | 'skills' | 'artifacts' | 'agents'
export interface AppRoute {
id: AppRouteId
@ -20,7 +21,8 @@ export const APP_ROUTES = [
{ id: 'settings', path: SETTINGS_ROUTE, view: 'settings' },
{ id: 'command-center', path: COMMAND_CENTER_ROUTE, view: 'command-center' },
{ id: 'skills', path: SKILLS_ROUTE, view: 'skills' },
{ id: 'artifacts', path: ARTIFACTS_ROUTE, view: 'artifacts' }
{ id: 'artifacts', path: ARTIFACTS_ROUTE, view: 'artifacts' },
{ id: 'agents', path: AGENTS_ROUTE, view: 'agents' }
] as const satisfies readonly AppRoute[]
const APP_VIEW_BY_PATH = new Map<string, AppView>(APP_ROUTES.map(route => [route.path, route.view]))

View file

@ -0,0 +1,52 @@
import { type MutableRefObject, useCallback, useEffect } from 'react'
import { $currentCwd, setContextSuggestions } from '@/store/session'
import type { ContextSuggestion } from '../../types'
interface ContextSuggestionsOptions {
activeSessionId: string | null
activeSessionIdRef: MutableRefObject<string | null>
currentCwd: string
gatewayState: string | undefined
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
}
export function useContextSuggestions({
activeSessionId,
activeSessionIdRef,
currentCwd,
gatewayState,
requestGateway
}: ContextSuggestionsOptions) {
const refresh = useCallback(async () => {
if (!activeSessionId) {
setContextSuggestions([])
return
}
const sessionId = activeSessionId
const cwd = currentCwd || ''
// Race guard: only commit if the session+cwd we sent for still match
// by the time the gateway responds.
const stillCurrent = () => activeSessionIdRef.current === sessionId && $currentCwd.get() === cwd
try {
const result = await requestGateway<{ items?: ContextSuggestion[] }>('complete.path', {
session_id: sessionId,
word: '@file:',
cwd: cwd || undefined
})
if (stillCurrent()) {setContextSuggestions((result.items || []).filter(i => i.text))}
} catch {
if (stillCurrent()) {setContextSuggestions([])}
}
}, [activeSessionId, activeSessionIdRef, currentCwd, requestGateway])
useEffect(() => {
if (gatewayState === 'open' && activeSessionId) {void refresh()}
}, [activeSessionId, gatewayState, refresh])
}

View file

@ -0,0 +1,108 @@
import { type MutableRefObject, useCallback } from 'react'
import { notify, notifyError } from '@/store/notifications'
import { $currentCwd, setCurrentBranch, setCurrentCwd } from '@/store/session'
import type { SessionRuntimeInfo } from '@/types/hermes'
interface CwdActionsOptions {
activeSessionId: string | null
activeSessionIdRef: MutableRefObject<string | null>
currentCwd: string
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
}
export function useCwdActions({ activeSessionId, activeSessionIdRef, currentCwd, requestGateway }: CwdActionsOptions) {
const refreshProjectBranch = useCallback(
async (cwd: string) => {
const target = cwd.trim()
if (!target || activeSessionIdRef.current) {return}
try {
const info = await requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target })
if (!activeSessionIdRef.current && ($currentCwd.get() || target) === (info.cwd || target)) {
setCurrentBranch(info.branch || '')
}
} catch {
setCurrentBranch('')
}
},
[activeSessionIdRef, requestGateway]
)
const changeSessionCwd = useCallback(
async (cwd: string) => {
const trimmed = cwd.trim()
if (!trimmed) {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()
} catch (err) {
notifyError(err, 'Working directory change failed')
}
return
}
try {
const info = await requestGateway<SessionRuntimeInfo>('session.cwd.set', {
session_id: activeSessionId,
cwd: trimmed
})
setCurrentCwd(info.cwd || trimmed)
setCurrentBranch(info.branch || '')
} 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')
}
}
},
[activeSessionId, 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 }
}

View file

@ -0,0 +1,74 @@
import { type MutableRefObject, useCallback, useState } from 'react'
import { getHermesConfig, getHermesConfigDefaults } from '@/hermes'
import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromConfig } from '@/lib/chat-runtime'
import {
$currentCwd,
setAvailablePersonalities,
setCurrentCwd,
setCurrentFastMode,
setCurrentPersonality,
setCurrentReasoningEffort,
setCurrentServiceTier,
setIntroPersonality
} from '@/store/session'
const DEFAULT_VOICE_SECONDS = 120
const FAST_TIERS = new Set(['fast', 'priority', 'on'])
function recordingLimit(value: unknown) {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : DEFAULT_VOICE_SECONDS
}
interface HermesConfigOptions {
activeSessionIdRef: MutableRefObject<string | null>
refreshProjectBranch: (cwd: string) => Promise<void>
}
export function useHermesConfig({ activeSessionIdRef, refreshProjectBranch }: HermesConfigOptions) {
const [voiceMaxRecordingSeconds, setVoiceMaxRecordingSeconds] = useState(DEFAULT_VOICE_SECONDS)
const [sttEnabled, setSttEnabled] = useState(true)
const refreshHermesConfig = useCallback(async () => {
try {
const [config, defaults] = await Promise.all([getHermesConfig(), getHermesConfigDefaults().catch(() => ({}))])
const personality = normalizePersonalityValue(
typeof config.display?.personality === 'string' ? config.display.personality : ''
)
setIntroPersonality(personality)
// Active sessions keep their per-session value; standalone falls back to config.
setCurrentPersonality(prev => (activeSessionIdRef.current ? prev || personality : personality))
setAvailablePersonalities([
...new Set([
'none',
...BUILTIN_PERSONALITIES,
...personalityNamesFromConfig(defaults),
...personalityNamesFromConfig(config)
])
])
const cwd = (config.terminal?.cwd ?? '').trim()
if (cwd && cwd !== '.') {
setCurrentCwd(prev => prev || cwd)
void refreshProjectBranch($currentCwd.get() || cwd)
}
const reasoning = (config.agent?.reasoning_effort ?? '').trim()
const tier = (config.agent?.service_tier ?? '').trim()
setCurrentReasoningEffort(prev => (activeSessionIdRef.current ? prev : reasoning))
setCurrentServiceTier(prev => (activeSessionIdRef.current ? prev : tier))
setCurrentFastMode(prev => (activeSessionIdRef.current ? prev : FAST_TIERS.has(tier.toLowerCase())))
setVoiceMaxRecordingSeconds(recordingLimit(config.voice?.max_recording_seconds))
setSttEnabled(config.stt?.enabled !== false)
} catch {
// Config is nice-to-have; chat still works without it.
}
}, [activeSessionIdRef, refreshProjectBranch])
return { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds }
}

View file

@ -0,0 +1,79 @@
import { type QueryClient } from '@tanstack/react-query'
import { useCallback } from 'react'
import { getGlobalModelInfo, setGlobalModel } from '@/hermes'
import { notifyError } from '@/store/notifications'
import { setCurrentModel, setCurrentProvider } from '@/store/session'
import type { ModelOptionsResponse } from '@/types/hermes'
interface ModelSelection {
model: string
persistGlobal: boolean
provider: string
}
interface ModelControlsOptions {
activeSessionId: string | null
queryClient: QueryClient
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
}
export function useModelControls({ activeSessionId, queryClient, requestGateway }: ModelControlsOptions) {
const updateModelOptionsCache = useCallback(
(provider: string, model: string, includeGlobal: boolean) => {
const patch = (prev: ModelOptionsResponse | undefined) => ({ ...(prev ?? {}), provider, model })
queryClient.setQueryData<ModelOptionsResponse>(['model-options', activeSessionId || 'global'], patch)
if (includeGlobal) {queryClient.setQueryData<ModelOptionsResponse>(['model-options', 'global'], patch)}
},
[activeSessionId, queryClient]
)
const refreshCurrentModel = useCallback(async () => {
try {
const result = await getGlobalModelInfo()
if (typeof result.model === 'string') {setCurrentModel(result.model)}
if (typeof result.provider === 'string') {setCurrentProvider(result.provider)}
} catch {
// The delayed session.info event still updates this once the agent is ready.
}
}, [])
const selectModel = useCallback(
(selection: ModelSelection) => {
setCurrentModel(selection.model)
setCurrentProvider(selection.provider)
updateModelOptionsCache(selection.provider, selection.model, selection.persistGlobal || !activeSessionId)
void (async () => {
try {
if (activeSessionId) {
await requestGateway('slash.exec', {
session_id: activeSessionId,
command: `/model ${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
})
if (selection.persistGlobal) {void refreshCurrentModel()}
void queryClient.invalidateQueries({
queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId]
})
return
}
await setGlobalModel(selection.provider, selection.model)
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
} catch (err) {
notifyError(err, 'Model switch failed')
}
})()
},
[activeSessionId, queryClient, refreshCurrentModel, requestGateway, updateModelOptionsCache]
)
return { refreshCurrentModel, selectModel, updateModelOptionsCache }
}

View file

@ -0,0 +1,144 @@
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { assistantTextPart, type ChatMessage } from '@/lib/chat-messages'
import {
$previewTarget,
clearSessionPreviewRegistry,
type PreviewTarget,
registerSessionPreview
} from '@/store/preview'
import { $currentCwd, $messages } from '@/store/session'
import type { RpcEvent } from '@/types/hermes'
import { usePreviewRouting } from './use-preview-routing'
function assistantMessage(id: string, text: string): ChatMessage {
return {
id,
parts: [assistantTextPart(text)],
role: 'assistant'
}
}
function previewTarget(source: string): PreviewTarget {
const isUrl = /^https?:\/\//i.test(source)
return {
kind: isUrl ? 'url' : 'file',
label: source,
path: isUrl ? undefined : source,
previewKind: isUrl ? undefined : 'html',
source,
url: isUrl ? source : `file://${source}`
}
}
let handleEvent: (event: RpcEvent) => void = () => undefined
function PreviewRoutingHarness({ onEvent }: { onEvent: (handler: (event: RpcEvent) => void) => void }) {
const activeSessionIdRef = useRef<string | null>('session-1')
const routing = usePreviewRouting({
activeSessionIdRef,
baseHandleGatewayEvent: vi.fn(),
currentCwd: '/work',
currentView: 'chat',
requestGateway: vi.fn(),
routedSessionId: 'session-1',
selectedStoredSessionId: null
})
useEffect(() => {
onEvent(routing.handleDesktopGatewayEvent)
}, [onEvent, routing.handleDesktopGatewayEvent])
return null
}
describe('usePreviewRouting', () => {
beforeEach(() => {
$currentCwd.set('/work')
$messages.set([])
$previewTarget.set(null)
window.localStorage.clear()
clearSessionPreviewRegistry()
handleEvent = () => undefined
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: {
normalizePreviewTarget: vi.fn(async (target: string) => previewTarget(target))
}
})
})
afterEach(() => {
cleanup()
$messages.set([])
$previewTarget.set(null)
window.localStorage.clear()
clearSessionPreviewRegistry()
vi.restoreAllMocks()
})
it('opens the active session preview from the registry', async () => {
const target = previewTarget('/work/demo.html')
registerSessionPreview('session-1', target, 'tool-result')
render(<PreviewRoutingHarness onEvent={handler => { handleEvent = handler }} />)
await waitFor(() => {
expect($previewTarget.get()).toEqual({ ...target, renderMode: 'preview' })
})
})
it('does not infer previews from assistant prose', async () => {
render(<PreviewRoutingHarness onEvent={handler => { handleEvent = handler }} />)
act(() => {
$messages.set([
assistantMessage('a1', 'Preview: http://localhost:5173/'),
assistantMessage('a2', 'Open /work/demo.html')
])
})
expect($previewTarget.get()).toBeNull()
expect(window.hermesDesktop.normalizePreviewTarget).not.toHaveBeenCalled()
})
it('registers structured tool-result preview targets', async () => {
render(<PreviewRoutingHarness onEvent={handler => { handleEvent = handler }} />)
act(() =>
handleEvent({
payload: { path: './dist/index.html' },
session_id: 'session-1',
type: 'tool.complete'
})
)
await waitFor(() => {
expect($previewTarget.get()?.source).toBe('./dist/index.html')
})
expect(window.localStorage.getItem('hermes.desktop.sessionPreviews.v1')).toContain('./dist/index.html')
})
it('registers html previews from edit inline diffs', async () => {
render(<PreviewRoutingHarness onEvent={handler => { handleEvent = handler }} />)
act(() =>
handleEvent({
payload: { inline_diff: '\u001b[38;2;218;165;32ma/preview-demo.html -> b/preview-demo.html\u001b[0m\n' },
session_id: 'session-1',
type: 'tool.complete'
})
)
await waitFor(() => {
expect($previewTarget.get()?.source).toBe('preview-demo.html')
})
})
})

View file

@ -0,0 +1,195 @@
import { useStore } from '@nanostores/react'
import { type MutableRefObject, useCallback, useEffect } from 'react'
import { gatewayEventCompletedFileDiff } from '@/lib/gateway-events'
import {
$previewTarget,
$sessionPreviewRegistry,
beginPreviewServerRestart,
completePreviewServerRestart,
getSessionPreviewRecord,
progressPreviewServerRestart,
requestPreviewReload,
setPreviewTarget,
setSessionPreviewTarget
} from '@/store/preview'
import { $currentCwd } from '@/store/session'
import type { RpcEvent } from '@/types/hermes'
type EventHandler = (event: RpcEvent) => void
interface PreviewRoutingOptions {
activeSessionIdRef: MutableRefObject<string | null>
baseHandleGatewayEvent: EventHandler
currentCwd: string
currentView: string
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
routedSessionId: string | null
selectedStoredSessionId: string | null
}
function asRecord(payload: unknown): Record<string, unknown> {
return payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
}
function activePreviewSessionId(
activeSessionIdRef: MutableRefObject<string | null>,
routedSessionId: string | null,
selectedStoredSessionId: string | null
): string {
return selectedStoredSessionId || routedSessionId || activeSessionIdRef.current || ''
}
function looksLikePreviewTarget(value: string): boolean {
return /^https?:\/\//i.test(value) || /^file:\/\//i.test(value) || /^(?:\/|\.{1,2}\/|~\/).+/.test(value)
}
function stripAnsi(value: string): string {
return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '')
}
function htmlPathFromInlineDiff(value: string): string {
const cleaned = stripAnsi(value).replace(/^\s*┊\s*review diff\s*\n/i, '')
for (const match of cleaned.matchAll(/(?:^|\s)(?:[ab]\/)?([^\s]+\.html?)(?=\s|$)/gi)) {
const candidate = match[1]?.trim()
if (candidate) {
return candidate
}
}
return ''
}
function structuredPreviewCandidate(payload: unknown): string {
const record = asRecord(payload)
const fields = ['url', 'target', 'path', 'file', 'filepath', 'preview']
for (const field of fields) {
const value = record[field]
if (typeof value === 'string') {
const target = value.trim()
if (target && looksLikePreviewTarget(target)) {
return target
}
}
}
const inlineDiff = record.inline_diff
if (typeof inlineDiff === 'string') {
return htmlPathFromInlineDiff(inlineDiff)
}
return ''
}
export function usePreviewRouting({
activeSessionIdRef,
baseHandleGatewayEvent,
currentCwd,
currentView,
requestGateway,
routedSessionId,
selectedStoredSessionId
}: PreviewRoutingOptions) {
const previewRegistry = useStore($sessionPreviewRegistry)
const previewSessionId = activePreviewSessionId(activeSessionIdRef, routedSessionId, selectedStoredSessionId)
useEffect(() => {
if (currentView !== 'chat' || !previewSessionId) {
setPreviewTarget(null)
return
}
const record = getSessionPreviewRecord(previewSessionId)
setPreviewTarget(record?.normalized ?? null)
}, [currentView, previewRegistry, previewSessionId])
const registerStructuredPreview = useCallback(
async (event: RpcEvent) => {
if (event.session_id && event.session_id !== activeSessionIdRef.current && event.session_id !== previewSessionId) {return}
if (!event.type.startsWith('tool.')) {return}
if (!previewSessionId) {return}
const candidate = structuredPreviewCandidate(event.payload)
if (!candidate) {return}
const desktop = window.hermesDesktop
if (!desktop?.normalizePreviewTarget) {return}
const sessionId = previewSessionId
const cwd = currentCwd || ''
const target = await desktop.normalizePreviewTarget(candidate, cwd || undefined).catch(() => null)
if (
!target ||
sessionId !== activePreviewSessionId(activeSessionIdRef, routedSessionId, selectedStoredSessionId) ||
$currentCwd.get() !== cwd
) {
return
}
setSessionPreviewTarget(sessionId, target, 'tool-result', candidate)
},
[activeSessionIdRef, currentCwd, previewSessionId, routedSessionId, selectedStoredSessionId]
)
const restartPreviewServer = useCallback(
async (url: string, context?: string) => {
const sessionId = activeSessionIdRef.current
if (!sessionId) {throw new Error('No active session for background restart')}
const cwd = $currentCwd.get() || currentCwd || ''
const result = await requestGateway<{ task_id?: string }>('preview.restart', {
context: context || undefined,
cwd: cwd || undefined,
session_id: sessionId,
url
})
const taskId = result.task_id || ''
if (!taskId) {throw new Error('Background restart did not return a task id')}
beginPreviewServerRestart(taskId, url)
return taskId
},
[activeSessionIdRef, currentCwd, requestGateway]
)
const handleDesktopGatewayEvent = useCallback<EventHandler>(
event => {
baseHandleGatewayEvent(event)
if (event.type === 'preview.restart.complete') {
const { task_id, text } = asRecord(event.payload)
if (typeof task_id === 'string' && task_id) {completePreviewServerRestart(task_id, typeof text === 'string' ? text : '')}
} else if (event.type === 'preview.restart.progress') {
const { task_id, text } = asRecord(event.payload)
if (typeof task_id === 'string' && task_id) {progressPreviewServerRestart(task_id, typeof text === 'string' ? text : '')}
}
if (event.session_id && event.session_id !== activeSessionIdRef.current) {return}
void registerStructuredPreview(event)
if ($previewTarget.get()?.kind === 'url' && gatewayEventCompletedFileDiff(event)) {requestPreviewReload()}
},
[activeSessionIdRef, baseHandleGatewayEvent, registerStructuredPreview]
)
return { handleDesktopGatewayEvent, restartPreviewServer }
}

View file

@ -188,7 +188,7 @@ export function usePromptActions({
.join('\n')
const hasImageAttachment = attachments.some(attachment => attachment.kind === 'image')
const displayRefs = attachments.map(attachmentDisplayText).filter(Boolean).join('\n')
const attachmentRefs = attachments.map(attachmentDisplayText).filter((ref): ref is string => Boolean(ref))
const text =
[contextRefs, visibleText].filter(Boolean).join('\n\n') ||
@ -201,12 +201,8 @@ export function usePromptActions({
const userMessage: ChatMessage = {
id: `user-${Date.now()}`,
role: 'user',
parts: [
textPart(
[displayRefs, visibleText].filter(Boolean).join('\n\n') ||
attachments.map(attachment => attachment.label).join(', ')
)
]
parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))],
attachmentRefs
}
const releaseBusy = () => {

View file

@ -0,0 +1,87 @@
import { type MutableRefObject, useEffect } from 'react'
import { isNewChatRoute } from '@/app/routes'
interface RouteResumeOptions {
activeSessionId: string | null
activeSessionIdRef: MutableRefObject<string | null>
creatingSessionRef: MutableRefObject<boolean>
currentView: string
freshDraftReady: boolean
gatewayState: string | undefined
locationPathname: string
resumeSession: (sessionId: string, focus: boolean) => Promise<unknown>
routedSessionId: string | null
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
selectedStoredSessionId: string | null
selectedStoredSessionIdRef: MutableRefObject<string | null>
startFreshSessionDraft: (focus: boolean) => unknown
}
// HashRouter boot edge case: pathname briefly reads `/` before the hash is
// parsed. If the hash references a real session, defer; resume picks it up
// next tick. Without this, ctrl+R on `#/:sessionId` flashes 5 loading states.
function rawHashLooksLikeSession(): boolean {
if (typeof window === 'undefined') {return false}
const hash = window.location.hash.replace(/^#/, '')
if (!hash || hash === '/') {return false}
return !hash.startsWith('/settings') && !hash.startsWith('/skills') && !hash.startsWith('/artifacts')
}
export function useRouteResume({
activeSessionId,
activeSessionIdRef,
creatingSessionRef,
currentView,
freshDraftReady,
gatewayState,
locationPathname,
resumeSession,
routedSessionId,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
selectedStoredSessionIdRef,
startFreshSessionDraft
}: RouteResumeOptions) {
useEffect(() => {
if (currentView !== 'chat' || gatewayState !== 'open') {return}
if (routedSessionId) {
const cachedRuntime = runtimeIdByStoredSessionIdRef.current.get(routedSessionId)
const alreadyActive =
routedSessionId === selectedStoredSessionIdRef.current &&
Boolean(cachedRuntime) &&
cachedRuntime === activeSessionIdRef.current
if (!alreadyActive) {void resumeSession(routedSessionId, true)}
return
}
if (
isNewChatRoute(locationPathname) &&
!creatingSessionRef.current &&
(selectedStoredSessionId || activeSessionId || !freshDraftReady) &&
!rawHashLooksLikeSession()
) {
startFreshSessionDraft(true)
}
}, [
activeSessionId,
activeSessionIdRef,
creatingSessionRef,
currentView,
freshDraftReady,
gatewayState,
locationPathname,
resumeSession,
routedSessionId,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
selectedStoredSessionIdRef,
startFreshSessionDraft
])
}

View file

@ -28,8 +28,8 @@ import {
setIntroSeed,
setMessages,
setSelectedStoredSessionId,
setSessionStartedAt,
setSessions,
setSessionStartedAt,
setTurnStartedAt
} from '@/store/session'
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, UsageStats } from '@/types/hermes'

View file

@ -2,17 +2,18 @@ import { useStore } from '@nanostores/react'
import type { CSSProperties, ReactNode, PointerEvent as ReactPointerEvent } from 'react'
import { useCallback } from 'react'
import { PaneShell } from '@/components/pane-shell'
import { SidebarProvider } from '@/components/ui/sidebar'
import { triggerHaptic } from '@/lib/haptics'
import {
$inspectorOpen,
$fileBrowserOpen,
$sidebarOpen,
$sidebarWidth,
FILE_BROWSER_DEFAULT_WIDTH,
setSidebarOpen,
setSidebarResizing,
setSidebarWidth
} from '@/store/layout'
import { $previewTarget } from '@/store/preview'
import { $connection } from '@/store/session'
import { StatusbarControls, type StatusbarItem } from './statusbar-controls'
@ -21,41 +22,27 @@ import { TitlebarControls, type TitlebarTool } from './titlebar-controls'
interface AppShellProps {
children: ReactNode
inspectorWidth: string
leftTitlebarTools?: readonly TitlebarTool[]
leftStatusbarItems?: readonly StatusbarItem[]
previewWidth: string
rightRailOpen: boolean
sidebar: ReactNode
statusbarItems?: readonly StatusbarItem[]
titlebarTools?: readonly TitlebarTool[]
leftTitlebarTools?: readonly TitlebarTool[]
onOpenSettings: () => void
overlays?: ReactNode
statusbarItems?: readonly StatusbarItem[]
titlebarTools?: readonly TitlebarTool[]
}
export function AppShell({
children,
inspectorWidth,
leftTitlebarTools,
leftStatusbarItems,
previewWidth,
rightRailOpen,
sidebar,
statusbarItems,
titlebarTools,
leftTitlebarTools,
onOpenSettings,
overlays
overlays,
statusbarItems,
titlebarTools
}: AppShellProps) {
const sidebarWidth = useStore($sidebarWidth)
const connection = useStore($connection)
const sidebarOpen = useStore($sidebarOpen)
const inspectorOpen = useStore($inspectorOpen)
const previewTarget = useStore($previewTarget)
// The shell grid should describe visible app chrome only. Titlebar buttons
// and draggable hit-zones are fixed overlays, so keeping an invisible grid
// column for a closed sidebar pushes/clips the actual chat surface.
const displayedSidebarWidth = sidebarOpen ? sidebarWidth : 0
const fileBrowserOpen = useStore($fileBrowserOpen)
const connection = useStore($connection)
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition)
@ -63,18 +50,28 @@ export function AppShell({
? 0
: titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2)
const showPreviewRail = rightRailOpen && Boolean(previewTarget)
const showInspectorRail = rightRailOpen && inspectorOpen
// 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.
const SYSTEM_TOOL_COUNT = 3
const paneToolCount = titlebarTools?.filter(tool => !tool.hidden).length ?? 0
const systemToolsWidth = `calc(${SYSTEM_TOOL_COUNT} * var(--titlebar-control-size))`
const inspectorColumn = showInspectorRail ? 'var(--inspector-width)' : '0px'
// Where the pane-tool cluster's right edge sits, measured from the inner
// titlebar padding (--titlebar-tools-right). Two anchors:
// - file-browser closed → flush against static cluster's left edge
// - file-browser open → flush against the file-browser pane's left edge
// (= preview pane's right edge)
const previewToolbarGap = fileBrowserOpen ? FILE_BROWSER_DEFAULT_WIDTH : systemToolsWidth
const previewColumn = showPreviewRail
? `min(var(--preview-width), max(0px, calc(100vw - var(--sidebar-width) - ${showInspectorRail ? 'var(--inspector-width)' : '0px'} - var(--chat-min-width))))`
: '0px'
const titlebarToolCount = (titlebarTools?.filter(tool => !tool.hidden).length ?? 0) + (rightRailOpen ? 1 : 0) + 2
const shellGridColumns = 'var(--sidebar-width) minmax(0,1fr) var(--preview-col) var(--inspector-col)'
// Used by the drag region to know where the rightmost interactive element
// ends. When pane tools are present, that's `gap + paneCount * controlSize`
// (the leftmost button is at `tools-right + gap + paneCount * size`).
// Otherwise the static cluster's footprint is enough.
const titlebarToolsWidth =
paneToolCount > 0
? `calc(${previewToolbarGap} + ${paneToolCount} * var(--titlebar-control-size))`
: systemToolsWidth
const startSidebarResize = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
@ -115,69 +112,51 @@ export function AppShell({
open={sidebarOpen}
style={
{
'--inspector-width': inspectorWidth,
'--preview-width': previewWidth,
'--sidebar-width': `${displayedSidebarWidth}px`,
'--chat-center-offset': '0px',
'--shell-left-sidebar-width': `${displayedSidebarWidth}px`,
'--shell-preview-pane-width': previewColumn,
'--shell-right-sidebar-width': inspectorColumn,
'--shell-right-region-width': 'calc(var(--shell-preview-pane-width) + var(--shell-right-sidebar-width))',
'--shell-preview-toolbar-gap': showPreviewRail
? 'max(0px, calc(var(--shell-right-sidebar-width) - (3 * var(--titlebar-control-size)) + 0.2rem))'
: '0px',
// Alias for shadcn <Sidebar> descendants. Resolves to the chat-sidebar
// pane track via PaneShell's emitted --pane-chat-sidebar-width.
'--sidebar-width': 'var(--pane-chat-sidebar-width)',
'--titlebar-height': `${TITLEBAR_HEIGHT}px`,
'--titlebar-content-inset': `${titlebarContentInset}px`,
'--titlebar-controls-left': `${titlebarControls.left}px`,
'--titlebar-controls-top': `${titlebarControls.top}px`,
'--titlebar-tools-right': '0.75rem',
'--titlebar-tools-width': `calc(${titlebarToolCount} * var(--titlebar-control-size) + var(--shell-preview-toolbar-gap))`
'--titlebar-tools-width': titlebarToolsWidth,
// Anchor for the pane-tool cluster's right edge in TitlebarControls.
// Sourced from the layout store rather than the PaneShell-emitted
// --pane-*-width vars because the titlebar is a sibling of PaneShell
// and CSS variables resolve at the consumer's scope.
'--shell-preview-toolbar-gap': previewToolbarGap
} as CSSProperties
}
>
<TitlebarControls
leftTools={leftTitlebarTools}
onOpenSettings={onOpenSettings}
showInspectorToggle={rightRailOpen}
tools={titlebarTools}
/>
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
<main
className="relative grid h-screen w-full overflow-hidden bg-background pr-0.75 pb-0.75 pt-0.75 transition-none"
style={
{
'--inspector-col': inspectorColumn,
'--preview-col': previewColumn,
gridTemplateColumns: shellGridColumns,
gridTemplateRows: 'minmax(0,1fr) auto'
} as CSSProperties
}
>
<div
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 z-1 h-(--titlebar-height) w-(--titlebar-controls-left) [-webkit-app-region:drag]"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute top-0 z-1 h-(--titlebar-height) left-[calc(var(--titlebar-controls-left)+(var(--titlebar-control-size)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right)+var(--titlebar-tools-width)+0.75rem)] [-webkit-app-region:drag]"
/>
<div className="col-start-1 col-end-2 row-start-1 min-h-0 overflow-hidden">{sidebar}</div>
{sidebarOpen && (
<main className="relative flex h-screen w-full flex-col overflow-hidden bg-background pr-0.75 pb-0.75 pt-0.75 transition-none">
<PaneShell className="min-h-0 flex-1">
<div
aria-label="Resize sidebar"
aria-orientation="vertical"
className="group absolute bottom-0 top-0 left-[calc(var(--sidebar-width)-0.5rem)] z-5 w-4 cursor-col-resize [-webkit-app-region:no-drag]"
onPointerDown={startSidebarResize}
role="separator"
tabIndex={0}
>
<span className="absolute left-1/2 top-1/2 h-23 w-0.75 -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]" />
</div>
)}
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 z-1 h-(--titlebar-height) w-(--titlebar-controls-left) [-webkit-app-region:drag]"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute top-0 z-1 h-(--titlebar-height) left-[calc(var(--titlebar-controls-left)+(var(--titlebar-control-size)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right)+var(--titlebar-tools-width)+0.75rem)] [-webkit-app-region:drag]"
/>
{children}
{children}
{sidebarOpen && (
<div
aria-label="Resize sidebar"
aria-orientation="vertical"
className="group absolute bottom-0 top-0 left-[calc(var(--pane-chat-sidebar-width)-0.5rem)] z-5 w-4 cursor-col-resize [-webkit-app-region:no-drag]"
onPointerDown={startSidebarResize}
role="separator"
tabIndex={0}
>
<span className="absolute left-1/2 top-1/2 h-23 w-0.75 -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]" />
</div>
)}
</PaneShell>
<StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />
</main>

View file

@ -0,0 +1,68 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { type CommandCenterSection } from '@/app/command-center'
import { AGENTS_ROUTE, appViewForPath, COMMAND_CENTER_ROUTE, NEW_CHAT_ROUTE } from '@/app/routes'
const SECTIONS = ['models', 'sessions', 'system'] as const
const OVERLAY_VIEWS = new Set(['settings', 'command-center', 'agents'])
export function useOverlayRouting() {
const location = useLocation()
const navigate = useNavigate()
const currentView = appViewForPath(location.pathname)
const settingsOpen = currentView === 'settings'
const commandCenterOpen = currentView === 'command-center'
const agentsOpen = currentView === 'agents'
const chatOpen = currentView === 'chat'
const overlayOpen = OVERLAY_VIEWS.has(currentView)
// Overlay routes (settings/command-center/agents) stash the underlying path
// so closing them returns there instead of bouncing to /.
const returnPathRef = useRef(NEW_CHAT_ROUTE)
useEffect(() => {
if (!overlayOpen) {
returnPathRef.current = `${location.pathname}${location.search}${location.hash}`
}
}, [location.hash, location.pathname, location.search, overlayOpen])
const commandCenterInitialSection = useMemo<CommandCenterSection | undefined>(
() => SECTIONS.find(value => value === new URLSearchParams(location.search).get('section')),
[location.search]
)
const openCommandCenterSection = useCallback(
(section: CommandCenterSection) => navigate(`${COMMAND_CENTER_ROUTE}?section=${section}`),
[navigate]
)
const closeOverlayToPreviousRoute = useCallback(
() => navigate(returnPathRef.current || NEW_CHAT_ROUTE, { replace: true }),
[navigate]
)
const toggleCommandCenter = useCallback(() => {
if (commandCenterOpen) {
closeOverlayToPreviousRoute()
} else {
navigate(COMMAND_CENTER_ROUTE)
}
}, [closeOverlayToPreviousRoute, commandCenterOpen, navigate])
const openAgents = useCallback(() => navigate(AGENTS_ROUTE), [navigate])
return {
agentsOpen,
chatOpen,
closeOverlayToPreviousRoute,
commandCenterInitialSection,
commandCenterOpen,
currentView,
openAgents,
openCommandCenterSection,
settingsOpen,
toggleCommandCenter
}
}

View file

@ -0,0 +1,42 @@
import { useEffect, useState } from 'react'
import { getLogs, getStatus } from '@/hermes'
import type { StatusResponse } from '@/types/hermes'
const REFRESH_MS = 15_000
const LOG_TAIL = 12
export function useStatusSnapshot(gatewayState: string | undefined) {
const [statusSnapshot, setStatusSnapshot] = useState<StatusResponse | null>(null)
const [gatewayLogLines, setGatewayLogLines] = useState<string[]>([])
useEffect(() => {
let cancelled = false
const refresh = async () => {
try {
const [next, logs] = await Promise.all([
getStatus(),
getLogs({ file: 'gateway', lines: LOG_TAIL }).catch(() => ({ lines: [] }))
])
if (cancelled) {return}
setStatusSnapshot(next)
setGatewayLogLines(logs.lines.map(line => line.trim()).filter(Boolean))
} catch {
// Keep last snapshot through transient gateway flaps.
}
}
void refresh()
const timer = window.setInterval(() => void refresh(), REFRESH_MS)
return () => {
cancelled = true
window.clearInterval(timer)
}
}, [gatewayState])
return { gatewayLogLines, statusSnapshot }
}

View file

@ -0,0 +1,218 @@
import { useStore } from '@nanostores/react'
import { useMemo } from 'react'
import type { CommandCenterSection } from '@/app/command-center'
import { buildGatewayLogItems } from '@/lib/gateway-events'
import { Activity, AlertCircle, Command, Cpu, FolderOpen, GitBranch, Loader2, Sparkles } from '@/lib/icons'
import { compactPath, 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,
$sessionStartedAt,
$turnStartedAt,
$workingSessionIds,
setModelPickerOpen
} from '@/store/session'
import type { StatusResponse } from '@/types/hermes'
import type { StatusbarItem, StatusbarMenuItem } from '../statusbar-controls'
interface StatusbarItemsOptions {
agentsOpen: boolean
browseSessionCwd: () => Promise<void>
commandCenterOpen: boolean
extraLeftItems: readonly StatusbarItem[]
extraRightItems: readonly StatusbarItem[]
gatewayLogLines: readonly string[]
openAgents: () => void
openCommandCenterSection: (section: CommandCenterSection) => void
statusSnapshot: StatusResponse | null
toggleCommandCenter: () => void
}
export function useStatusbarItems({
agentsOpen,
browseSessionCwd,
commandCenterOpen,
extraLeftItems,
extraRightItems,
gatewayLogLines,
openAgents,
openCommandCenterSection,
statusSnapshot,
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)
const desktopActionTasks = useStore($desktopActionTasks)
const previewServerRestartStatus = useStore($previewServerRestartStatus)
const sessionStartedAt = useStore($sessionStartedAt)
const turnStartedAt = useStore($turnStartedAt)
const workingSessionIds = useStore($workingSessionIds)
const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage])
const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage])
const platformMenuItems = useMemo<readonly StatusbarMenuItem[]>(
() =>
Object.entries(statusSnapshot?.gateway_platforms || {})
.sort(([l], [r]) => l.localeCompare(r))
.map(([name, platform]) => ({ disabled: true, id: `platform:${name}`, label: `${name} · ${platform.state}` })),
[statusSnapshot?.gateway_platforms]
)
const gatewayMenuItems = useMemo<readonly StatusbarMenuItem[]>(
() => [
{ id: 'gateway:open-system', label: 'Open system panel', onSelect: () => openCommandCenterSection('system') },
...buildGatewayLogItems(gatewayLogLines),
...platformMenuItems
],
[gatewayLogLines, openCommandCenterSection, platformMenuItems]
)
const { bgFailed, bgRunning } = useMemo(() => {
const actions = Object.values(desktopActionTasks)
const running = actions.filter(t => t.status.running).length
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
return { bgFailed: failed + previewFailed, bgRunning: workingSessionIds.length + running + previewRunning }
}, [desktopActionTasks, previewServerRestartStatus, workingSessionIds])
const gatewayUp = Boolean(statusSnapshot?.gateway_running)
const coreLeftStatusbarItems = useMemo<readonly StatusbarItem[]>(
() => [
{
className: `h-6 w-6 justify-center px-0${commandCenterOpen ? ' bg-accent/55 text-foreground' : ''}`,
icon: <Command className="size-3.5" />,
id: 'command-center',
onSelect: toggleCommandCenter,
title: commandCenterOpen ? 'Close Command Center' : 'Open Command Center',
variant: 'action'
},
{
className: gatewayUp ? undefined : 'text-destructive hover:text-destructive',
detail: gatewayUp ? statusSnapshot?.gateway_state || 'online' : 'offline',
icon: gatewayUp ? <Activity className="size-3" /> : <AlertCircle className="size-3" />,
id: 'gateway-health',
label: 'Gateway',
menuClassName: 'w-96',
menuItems: gatewayMenuItems,
title: 'Gateway and platform health',
variant: 'menu'
},
{
className: cn(
agentsOpen && 'bg-accent/55 text-foreground',
bgFailed > 0 && 'text-destructive hover:text-destructive'
),
detail: bgFailed > 0 ? `${bgFailed} failed` : bgRunning > 0 ? `${bgRunning} running` : undefined,
icon:
bgFailed > 0 ? (
<AlertCircle className="size-3" />
) : bgRunning > 0 ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Sparkles className="size-3" />
),
id: 'agents',
label: 'Agents',
onSelect: openAgents,
title: agentsOpen ? 'Close agents' : 'Open agents',
variant: 'action'
}
],
[
agentsOpen,
bgFailed,
bgRunning,
commandCenterOpen,
gatewayMenuItems,
gatewayUp,
openAgents,
statusSnapshot?.gateway_state,
toggleCommandCenter
]
)
const coreRightStatusbarItems = useMemo<readonly StatusbarItem[]>(
() => [
{
detail: <LiveDuration since={turnStartedAt} />,
hidden: !busy || !turnStartedAt,
icon: <Loader2 className="size-3 animate-spin" />,
id: 'running-timer',
label: 'Running',
title: 'Current turn elapsed',
variant: 'text'
},
{
detail: contextBar || undefined,
hidden: !contextUsage,
id: 'context-usage',
label: contextUsage,
title: 'Context usage',
variant: 'text'
},
{
detail: <LiveDuration since={sessionStartedAt} />,
hidden: !sessionStartedAt,
id: 'session-timer',
label: 'Session',
title: 'Runtime session elapsed',
variant: 'text'
},
{
detail: currentProvider || '',
icon: <Cpu className="size-3" />,
id: 'model-summary',
label: currentModel || 'No model selected',
onSelect: () => setModelPickerOpen(true),
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'
}
],
[browseSessionCwd, busy, contextBar, contextUsage, currentBranch, currentCwd, currentModel, currentProvider, sessionStartedAt, turnStartedAt]
)
const leftStatusbarItems = useMemo(
() => [...coreLeftStatusbarItems, ...extraLeftItems],
[coreLeftStatusbarItems, extraLeftItems]
)
const statusbarItems = useMemo(
() => [...extraRightItems, ...coreRightStatusbarItems],
[coreRightStatusbarItems, extraRightItems]
)
return { leftStatusbarItems, statusbarItems }
}

View file

@ -48,7 +48,7 @@ interface StatusbarControlsProps extends ComponentProps<'footer'> {
}
const statusbarItemClass =
'inline-flex h-6 items-center gap-1.5 rounded-md px-2 text-[0.69rem] text-muted-foreground/95 transition-colors hover:bg-accent/55 hover:text-foreground disabled:cursor-default disabled:opacity-45'
'inline-flex h-5 items-center gap-1 rounded px-1 text-[0.68rem] text-muted-foreground/95 transition-colors hover:bg-accent/55 hover:text-foreground disabled:cursor-default disabled:opacity-45'
export function StatusbarControls({ className, leftItems = [], items = [], ...props }: StatusbarControlsProps) {
const navigate = useNavigate()
@ -56,17 +56,17 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr
return (
<footer
className={cn(
'col-span-4 row-start-2 row-end-3 flex h-7 items-center justify-between gap-2 border-t border-border/55 bg-[color-mix(in_srgb,var(--dt-muted)_45%,var(--dt-card))] px-2.5 py-1 text-muted-foreground/95 [-webkit-app-region:no-drag]',
'flex h-7 shrink-0 items-center justify-between gap-2 border-t border-border/55 bg-[color-mix(in_srgb,var(--dt-muted)_45%,var(--dt-card))] px-2.5 py-1 text-muted-foreground/95 [-webkit-app-region:no-drag]',
className
)}
{...props}
>
<div className="flex min-w-0 items-center gap-1 overflow-x-auto">
<div className="flex min-w-0 items-center gap-0.5 overflow-x-auto">
{leftItems.filter(item => !item.hidden).map(item => (
<StatusbarItemView item={item} key={`left:${item.id}`} navigate={navigate} />
))}
</div>
<div className="flex min-w-0 items-center gap-1 overflow-x-auto">
<div className="flex min-w-0 items-center gap-0.5 overflow-x-auto">
{items.filter(item => !item.hidden).map(item => (
<StatusbarItemView item={item} key={`right:${item.id}`} navigate={navigate} />
))}
@ -147,7 +147,7 @@ function StatusbarItemView({
if (item.variant === 'text' && !item.onSelect && !item.to && !item.href) {
return (
<div className={cn('inline-flex items-center gap-1.5 px-1 text-[0.69rem] text-muted-foreground/90', item.className)}>
<div className={cn('inline-flex h-5 items-center gap-1 px-0.5 text-[0.68rem] text-muted-foreground/90', item.className)}>
{content}
</div>
)

View file

@ -3,10 +3,10 @@ import type { ComponentProps, ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import { triggerHaptic } from '@/lib/haptics'
import { NotebookTabs, Settings, SlidersHorizontal, Volume2, VolumeX } from '@/lib/icons'
import { FolderOpen, NotebookTabs, Settings, Volume2, VolumeX } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
import { $inspectorOpen, $sidebarOpen, toggleInspectorOpen, toggleSidebarOpen } from '@/store/layout'
import { $fileBrowserOpen, $sidebarOpen, toggleFileBrowserOpen, toggleSidebarOpen } from '@/store/layout'
import { titlebarButtonClass } from './titlebar'
@ -29,21 +29,15 @@ export type SetTitlebarToolGroup = (id: string, tools: readonly TitlebarTool[],
interface TitlebarControlsProps extends ComponentProps<'div'> {
leftTools?: readonly TitlebarTool[]
showInspectorToggle: boolean
tools?: readonly TitlebarTool[]
onOpenSettings: () => void
}
export function TitlebarControls({
leftTools = [],
showInspectorToggle,
tools = [],
onOpenSettings
}: TitlebarControlsProps) {
export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: TitlebarControlsProps) {
const navigate = useNavigate()
const hapticsMuted = useStore($hapticsMuted)
const fileBrowserOpen = useStore($fileBrowserOpen)
const sidebarOpen = useStore($sidebarOpen)
const inspectorOpen = useStore($inspectorOpen)
const toggleHaptics = () => {
if (!hapticsMuted) {
@ -70,17 +64,16 @@ export function TitlebarControls({
...leftTools
]
const rightToolbarTools: TitlebarTool[] = [
...tools,
// Static system tools — always pinned to the screen's right edge.
const systemTools: TitlebarTool[] = [
{
active: inspectorOpen,
hidden: !showInspectorToggle,
icon: <SlidersHorizontal />,
id: 'session-details',
label: inspectorOpen ? 'Hide session details' : 'Show session details',
active: fileBrowserOpen,
icon: <FolderOpen />,
id: 'file-browser',
label: fileBrowserOpen ? 'Hide file browser' : 'Show file browser',
onSelect: () => {
triggerHaptic('tap')
toggleInspectorOpen()
toggleFileBrowserOpen()
}
},
{
@ -101,6 +94,9 @@ export function TitlebarControls({
}
]
const visibleSystemTools = systemTools.filter(tool => !tool.hidden)
const visiblePaneTools = tools.filter(tool => !tool.hidden)
return (
<>
<div
@ -114,15 +110,32 @@ export function TitlebarControls({
))}
</div>
{/*
Pane-scoped tools (preview's monitor / devtools / refresh / X) render
as their own fixed cluster. AppShell sets --shell-preview-toolbar-gap
to either the static cluster's width (file-browser closed cluster
sits flush against system tools) or the file-browser pane's width
(file-browser open cluster sits flush against the file-browser pane,
i.e. at the preview pane's right edge). No margin hacks needed.
*/}
{visiblePaneTools.length > 0 && (
<div
aria-label="Pane controls"
className="fixed top-(--titlebar-controls-top) right-[calc(var(--titlebar-tools-right)+var(--shell-preview-toolbar-gap,0px))] z-70 flex flex-row items-center gap-px pointer-events-auto select-none [-webkit-app-region:no-drag]"
>
{visiblePaneTools.map(tool => (
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
))}
</div>
)}
<div
aria-label="App controls"
className="fixed right-(--titlebar-tools-right) top-(--titlebar-controls-top) z-70 flex flex-row items-center justify-end gap-px pointer-events-auto select-none [-webkit-app-region:no-drag]"
>
{rightToolbarTools
.filter(tool => !tool.hidden)
.map(tool => (
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
))}
{visibleSystemTools.map(tool => (
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
))}
</div>
</>
)

View file

@ -51,7 +51,7 @@ export type CommandDispatchResponse =
| SkillCommandDispatchResponse
| SendCommandDispatchResponse
export type SidebarNavId = 'new-session' | 'skills' | 'artifacts'
export type SidebarNavId = 'new-session' | 'command-center' | 'settings' | 'skills' | 'artifacts'
export interface SidebarNavItem {
id: SidebarNavId

View file

@ -186,10 +186,10 @@ function shortLabel(type: HermesRefType, id: string): string {
}
/**
* Renders a text message part with our directive segments as inline chips.
* Unknown directive types fall through as plain text.
* Renders text containing Hermes directives (`@file:...`, `@image:...`) as
* inline chips. Embedded MEDIA images render below as a thumbnail row.
*/
export const DirectiveText: TextMessagePartComponent = ({ text }: TextMessagePartProps) => {
export function DirectiveContent({ text }: { text: string }) {
const { cleanedText, images } = useMemo(() => extractEmbeddedImages(text ?? ''), [text])
const segments = useMemo(() => hermesDirectiveFormatter.parse(cleanedText), [cleanedText])
@ -220,6 +220,11 @@ export const DirectiveText: TextMessagePartComponent = ({ text }: TextMessagePar
)
}
/** assistant-ui adapter: same renderer, exposed as a TextMessagePartComponent. */
export const DirectiveText: TextMessagePartComponent = ({ text }: TextMessagePartProps) => (
<DirectiveContent text={text ?? ''} />
)
const DirectiveChip: FC<{
type: string
label: string
@ -230,14 +235,14 @@ const DirectiveChip: FC<{
return (
<span
className={cn(
'mx-0.5 inline-flex max-w-64 items-center gap-1 rounded-full bg-[color-mix(in_srgb,var(--dt-primary)_16%,transparent)] px-2 py-0.5 align-[0.02em] text-[0.92em] font-semibold leading-tight text-primary ring-1 ring-inset ring-primary/10'
'mx-0.5 inline-flex max-w-56 items-center gap-1 border border-primary/20 bg-primary/8 px-1.5 py-0.5 align-[0.02em] text-[0.86em] font-medium leading-none text-primary'
)}
data-directive-id={id}
data-directive-type={type}
data-slot="aui_directive-chip"
title={id}
>
{Icon && <Icon className="size-3.5 shrink-0 text-primary" />}
{Icon && <Icon className="size-3 shrink-0 text-primary" />}
<span className="truncate">{label}</span>
</span>
)

View file

@ -175,10 +175,10 @@ export const Intro: FC<IntroProps> = ({ personality, seed }) => {
}, [])
return (
<div className="pointer-events-none flex min-h-[calc(100vh-var(--titlebar-height)-var(--thread-composer-clearance)-var(--composer-shell-pad-block-end))] flex-col items-center justify-center px-[calc(var(--vsq)*50)] text-center text-muted-foreground">
<div className="pointer-events-none flex min-h-[calc(100vh-var(--titlebar-height)-var(--thread-composer-clearance)-var(--composer-shell-pad-block-end))] w-full min-w-0 flex-col items-center justify-center px-3 py-8 text-center text-muted-foreground sm:px-6 lg:px-8">
<button
aria-label="Change Hermes pose"
className="pointer-events-auto mb-5 h-56 w-64 cursor-default border-0 bg-transparent p-0"
className="pointer-events-auto mb-5 aspect-8/7 w-full max-w-64 cursor-default border-0 bg-transparent p-0"
onClick={advanceFrame}
type="button"
>
@ -191,9 +191,11 @@ export const Intro: FC<IntroProps> = ({ personality, seed }) => {
style={{ transform: `translateX(${spriteOffsetPx}px) scale(1.1)` }}
/>
</button>
<p className="mb-3 text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground/75">Hermes Agent</p>
<h1 className="mb-2.5 text-xl font-semibold tracking-tight text-foreground">{copy.headline}</h1>
<p className="m-0 max-w-120 leading-normal">{copy.body}</p>
<div className="w-full min-w-0 max-w-xl">
<p className="mb-3 text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground/75">Hermes Agent</p>
<h1 className="mb-2.5 text-xl font-semibold tracking-tight text-foreground">{copy.headline}</h1>
<p className="m-0 leading-normal">{copy.body}</p>
</div>
</div>
)
}

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { preprocessMarkdown } from './markdown-text'
import { preprocessMarkdown } from '@/lib/markdown-preprocess'
describe('preprocessMarkdown', () => {
it('strips inline accidental triple-backtick starts', () => {

View file

@ -7,9 +7,9 @@ import { type ComponentProps, memo, useEffect, useMemo, useState } from 'react'
import { PreviewAttachment } from '@/components/assistant-ui/preview-attachment'
import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter'
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Copy } from '@/lib/icons'
import { isLikelyProseCodeBlock, isLikelyProseFence, sanitizeLanguageTag } from '@/lib/markdown-code'
import { CopyButton } from '@/components/ui/copy-button'
import { isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code'
import { preprocessMarkdown } from '@/lib/markdown-preprocess'
import {
filePathFromMediaPath,
mediaExternalUrl,
@ -18,172 +18,10 @@ import {
mediaName,
mediaPathFromMarkdownHref
} from '@/lib/media'
import { isLikelyPreviewCandidate, previewTargetFromMarkdownHref, stripPreviewTargets } from '@/lib/preview-targets'
import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
import { cn } from '@/lib/utils'
/**
* Strip provider/model "thinking" blocks before markdown render.
*
* Some Hermes providers stream raw `<think>…</think>` and similar into
* assistant text. Proper reasoning UI uses dedicated `reasoning.*` parts.
*/
const REASONING_BLOCK_RE = /<(think|thinking|reasoning|scratchpad|analysis)>[\s\S]*?<\/\1>\s*/gi
const PREVIEW_MARKER_RE = /\[Preview:[^\]]+\]\(#preview[:/][^)]+\)/gi
const FENCE_LINE_RE = /^([ \t]*)(`{3,}|~{3,})([^\n]*)$/
const MIDLINE_FENCE_RE = /([^\n])(`{3,}|~{3,})(?=\s|$)/g
const EMPTY_FENCE_BLOCK_RE = /(^|\n)[ \t]*(?:`{3,}|~{3,})[^\n]*\n[ \t]*(?:`{3,}|~{3,})[ \t]*(?=\n|$)/g
function stripMidlineFenceStarts(text: string): string {
// Providers often stream inline fence noise like `200.``` http://...`.
// A real fenced block must start at the beginning of a line; anything
// mid-line should be treated as literal/prose and never allowed to create
// an empty Streamdown code-card shell.
return text.replace(MIDLINE_FENCE_RE, '$1')
}
function stripEmptyFenceBlocks(text: string): string {
// Remove already-balanced but empty fences before Streamdown sees them.
// Returning null from our CodeHeader/SyntaxHighlighter is not enough: the
// code plugin still renders its outer code-block wrapper, producing the
// blank bordered element seen during streaming.
return text.replace(EMPTY_FENCE_BLOCK_RE, '$1')
}
function pushProseFence(out: string[], indent: string, info: string, lines: string[]) {
if (info) {
out.push(`${indent}${info}`.trimEnd())
}
out.push(...lines)
}
function findClosingFence(lines: string[], start: number, marker: string): number {
for (let cursor = start + 1; cursor < lines.length; cursor += 1) {
const closeMatch = (lines[cursor] || '').match(FENCE_LINE_RE)
if (!closeMatch) {
continue
}
const closeMarker = closeMatch[2] || ''
const closeInfo = (closeMatch[3] || '').trim()
if (!closeInfo && closeMarker[0] === marker[0] && closeMarker.length >= marker.length) {
return cursor
}
}
return -1
}
function isPreviewOnlyFence(body: string): boolean {
const lines = body
.split('\n')
.map(line => line.trim())
.filter(Boolean)
return lines.length === 1 && isLikelyPreviewCandidate(lines[0])
}
function normalizeFenceBlocks(text: string): string {
const sourceLines = text.split('\n')
const out: string[] = []
let index = 0
while (index < sourceLines.length) {
const line = sourceLines[index] || ''
const match = line.match(FENCE_LINE_RE)
if (!match) {
out.push(line)
index += 1
continue
}
const indent = match[1] || ''
const marker = match[2] || '```'
const infoRaw = (match[3] || '').trim()
const languageToken = infoRaw.split(/\s+/, 1)[0] || ''
const language = sanitizeLanguageTag(languageToken)
const openerValid = !infoRaw || Boolean(language)
if (!openerValid) {
out.push(`${indent}${infoRaw}`.trimEnd())
index += 1
continue
}
const closeIndex = findClosingFence(sourceLines, index, marker)
const bodyLines = sourceLines.slice(index + 1, closeIndex === -1 ? sourceLines.length : closeIndex)
const body = bodyLines.join('\n')
if (closeIndex !== -1 && !body.trim()) {
// Empty fenced block: drop both delimiters. This prevents Streamdown's
// code plugin from rendering an empty shell/card.
index = closeIndex + 1
continue
}
if (closeIndex !== -1 && isPreviewOnlyFence(body)) {
// Agents often fence a lone preview URL to make it copyable. The chat UI
// already renders a preview card for that URL, so don't show code fences.
out.push(...bodyLines)
index = closeIndex + 1
continue
}
if (closeIndex === -1) {
if (!body.trim()) {
index += 1
continue
}
if (isLikelyProseFence(infoRaw, body)) {
pushProseFence(out, indent, infoRaw, bodyLines)
} else {
out.push(`${indent}${marker}${language}`)
out.push(...bodyLines)
}
break
}
if (isLikelyProseFence(infoRaw, body)) {
pushProseFence(out, indent, infoRaw, bodyLines)
index = closeIndex + 1
continue
}
out.push(`${indent}${marker}${language}`)
out.push(...bodyLines)
out.push(`${indent}${marker}`)
index = closeIndex + 1
}
return out.join('\n')
}
export function preprocessMarkdown(text: string): string {
const cleaned = text.replace(REASONING_BLOCK_RE, '').replace(PREVIEW_MARKER_RE, '')
const normalizedFences = normalizeFenceBlocks(stripMidlineFenceStarts(cleaned))
const strippedEmptyFences = stripEmptyFenceBlocks(normalizedFences)
return strippedEmptyFences
.split(/((?:```|~~~)[\s\S]*?(?:```|~~~))/g)
.map(part => (/^(?:```|~~~)/.test(part) ? part : stripPreviewTargets(part)))
.join('')
.replace(/[ \t]+\n/g, '\n')
}
function CodeHeader({ language, code }: { language?: string; code?: string }) {
const [copied, setCopied] = useState(false)
const normalizedCode = (code ?? '').replace(/^\n+/, '').trimEnd()
// Streamdown can transiently parse stray backticks / incomplete fences as
@ -194,41 +32,15 @@ function CodeHeader({ language, code }: { language?: string; code?: string }) {
return null
}
async function handleCopy() {
if (!normalizedCode) {
return
}
try {
if (window.hermesDesktop?.writeClipboard) {
await window.hermesDesktop.writeClipboard(normalizedCode)
} else if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(normalizedCode)
}
triggerHaptic('selection')
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
// Best-effort copy; silent failure is OK for a chat surface.
}
}
const cleanLanguage = sanitizeLanguageTag(language || '')
const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : ''
return (
<div className="m-0 flex items-center justify-between gap-2 rounded-t-md border border-b-0 border-border bg-muted/60 px-3 py-1.5 text-xs text-muted-foreground">
<span className="font-mono uppercase tracking-wide">{label || 'code'}</span>
<button
aria-label={copied ? 'Copied' : 'Copy code'}
className="inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-[0.75rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={handleCopy}
type="button"
>
{copied ? <Check size={12} /> : <Copy size={12} />}
{copied ? 'Copied' : 'Copy'}
</button>
<CopyButton appearance="inline" iconClassName="size-3" label="Copy code" text={normalizedCode}>
Copy
</CopyButton>
</div>
)
}
@ -360,7 +172,7 @@ function MarkdownLink({ className, href, ...props }: ComponentProps<'a'>) {
}
if (previewTarget) {
return <PreviewAttachment target={previewTarget} />
return <PreviewAttachment source="explicit-link" target={previewTarget} />
}
return (

View file

@ -2,12 +2,13 @@ import { useStore } from '@nanostores/react'
import { useEffect, useRef, useState } from 'react'
import { MonitorPlay } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { previewName } from '@/lib/preview-targets'
import { notifyError } from '@/store/notifications'
import { $previewTarget, setPreviewTarget } from '@/store/preview'
import { $previewTarget, dismissPreviewTarget, type PreviewRecordSource, setCurrentSessionPreviewTarget } from '@/store/preview'
import { $currentCwd } from '@/store/session'
export function PreviewAttachment({ target }: { target: string }) {
export function PreviewAttachment({ source = 'manual', target }: { source?: PreviewRecordSource; target: string }) {
const cwd = useStore($currentCwd)
const activePreview = useStore($previewTarget)
const [opening, setOpening] = useState(false)
@ -37,37 +38,13 @@ export function PreviewAttachment({ target }: { target: string }) {
setOpening(false)
}, [cwd, target])
function localFallbackPreview(value: string) {
if (/^https?:\/\//i.test(value)) {
return { kind: 'url' as const, label: previewName(value), source: value, url: value }
}
if (/^file:\/\//i.test(value)) {
return { kind: 'file' as const, label: previewName(value), source: value, url: value }
}
if (/^(?:\/|\.{1,2}\/|~\/).+\.html?$/i.test(value)) {
const path = value.startsWith('file://') ? value : `file://${encodeURI(value)}`
return { kind: 'file' as const, label: previewName(value), source: value, url: path }
}
return null
}
function isMissingPreviewIpc(error: unknown): boolean {
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
return message.includes("No handler registered for 'hermes:normalizePreviewTarget'")
}
async function togglePreview() {
if (opening) {
return
}
if (isActive) {
setPreviewTarget(null)
dismissPreviewTarget()
return
}
@ -79,15 +56,7 @@ export function PreviewAttachment({ target }: { target: string }) {
setOpening(true)
try {
const preview = await window.hermesDesktop
?.normalizePreviewTarget(requestTarget, requestCwd || undefined)
.catch(error => {
if (isMissingPreviewIpc(error)) {
return localFallbackPreview(requestTarget)
}
throw error
})
const preview = await normalizeOrLocalPreviewTarget(requestTarget, requestCwd || undefined)
if (
!mountedRef.current ||
@ -108,7 +77,7 @@ export function PreviewAttachment({ target }: { target: string }) {
return
}
setPreviewTarget(preview)
setCurrentSessionPreviewTarget(preview, source, requestTarget)
} catch (error) {
if (
!mountedRef.current ||
@ -128,21 +97,21 @@ export function PreviewAttachment({ target }: { target: string }) {
}
return (
<div className="inline-flex max-w-[min(100%,32rem)] items-center gap-3 rounded-xl border border-border/70 bg-card/70 p-3 text-sm">
<div className="grid size-9 shrink-0 place-items-center rounded-lg bg-accent text-muted-foreground">
<MonitorPlay className="size-4" />
</div>
<div className="min-w-0 max-w-64">
<div className="truncate font-medium text-foreground">{name}</div>
<div className="truncate font-mono text-xs text-muted-foreground">{target}</div>
<div className="flex w-full max-w-160 flex-wrap items-center gap-2.5 rounded-lg border border-border/55 bg-card/55 px-2.5 py-1.5 text-sm">
<span className="grid size-7 shrink-0 place-items-center rounded-md bg-muted/55 text-muted-foreground/85">
<MonitorPlay className="size-3.5" />
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-[0.78rem] font-medium leading-[1.15rem] text-foreground/90">{name}</div>
<div className="truncate font-mono text-[0.66rem] leading-4 text-muted-foreground/70">{target}</div>
</div>
<button
className="shrink-0 rounded-lg border border-border/70 px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50"
className="ml-auto shrink-0 rounded-md border border-border/55 bg-background/40 px-2 py-1 text-[0.7rem] font-medium text-muted-foreground transition-colors hover:bg-accent/55 hover:text-foreground disabled:opacity-50 max-[28rem]:ml-9 max-[28rem]:w-[calc(100%-2.25rem)]"
disabled={opening}
onClick={() => void togglePreview()}
type="button"
>
{opening ? 'Opening...' : isActive ? 'Hide Preview' : 'Toggle Preview'}
{opening ? 'Opening…' : isActive ? 'Hide' : 'Open preview'}
</button>
</div>
)

View file

@ -85,6 +85,23 @@ function assistantMessage(text: string, running = true): ThreadMessage {
} as ThreadMessage
}
function assistantReasoningMessage(text: string): ThreadMessage {
return {
id: 'assistant-reasoning-1',
role: 'assistant',
content: [{ type: 'reasoning', text }],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function StreamingHarness() {
const [messages, setMessages] = useState<ThreadMessage[]>([userMessage()])
const [isRunning, setIsRunning] = useState(true)
@ -123,6 +140,20 @@ function StreamingHarness() {
)
}
function ReasoningHarness() {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [assistantReasoningMessage(' The user is asking what this file is.')],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
describe('assistant-ui streaming renderer', () => {
beforeEach(() => {
resizeObservers.clear()
@ -157,7 +188,8 @@ describe('assistant-ui streaming renderer', () => {
it('does not pull the viewport back down after the user scrolls up during streaming', async () => {
const { container } = render(<StreamingHarness />)
const viewport = container.querySelector('[data-slot="aui_thread-viewport"]') as HTMLDivElement
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
const viewport = content.parentElement as HTMLDivElement
let scrollHeight = 1_000
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
@ -191,4 +223,14 @@ describe('assistant-ui streaming renderer', () => {
expect(viewport.scrollTop).toBe(420)
})
it('renders reasoning text without a leading token space', () => {
const { container } = render(<ReasoningHarness />)
fireEvent.click(screen.getByRole('button', { name: /thinking/i }))
expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toBe(
'The user is asking what this file is.'
)
})
})

View file

@ -28,7 +28,7 @@ import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
import { DirectiveText } from '@/components/assistant-ui/directive-text'
import { DirectiveContent, DirectiveText } from '@/components/assistant-ui/directive-text'
import { GeneratedImageProvider, useGeneratedImageContext } from '@/components/assistant-ui/generated-image-context'
import { ImageGenerationPlaceholder } from '@/components/assistant-ui/image-generation-placeholder'
import { Intro, type IntroProps } from '@/components/assistant-ui/intro'
@ -36,6 +36,7 @@ import { MarkdownText } from '@/components/assistant-ui/markdown-text'
import { PreviewAttachment } from '@/components/assistant-ui/preview-attachment'
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
import { CopyButton } from '@/components/ui/copy-button'
import {
DropdownMenu,
DropdownMenuContent,
@ -49,7 +50,6 @@ import {
CheckIcon,
ChevronLeftIcon,
ChevronRightIcon,
CopyIcon,
GitBranchIcon,
Loader2Icon,
MoreHorizontalIcon,
@ -137,7 +137,7 @@ export const Thread: FC<{
>
<ThreadScrollSync sessionKey={sessionKey} />
<StickToBottom.Content
className="mx-auto flex w-full max-w-[48rem] min-w-0 flex-col gap-3 px-4 pt-[calc(var(--vsq)*19)] sm:px-6 lg:px-8"
className="mx-auto flex w-full max-w-3xl min-w-0 flex-col gap-3 px-4 pt-[calc(var(--vsq)*19)] sm:px-6 lg:px-8"
data-slot="aui_thread-content"
scrollClassName="overflow-x-hidden overflow-y-auto overscroll-contain"
>
@ -371,6 +371,10 @@ const ComposerClearance: FC = () => {
})
useEffect(() => {
if (typeof document === 'undefined') {
return
}
let composerObserver: ResizeObserver | null = null
let observedComposer: HTMLElement | null = null
@ -385,6 +389,10 @@ const ComposerClearance: FC = () => {
}
const bindComposer = () => {
if (typeof document === 'undefined') {
return false
}
const composer = document.querySelector<HTMLElement>('[data-slot="composer-root"]')
if (!composer || composer === observedComposer) {
@ -471,7 +479,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
{previewTargets.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{previewTargets.map(target => (
<PreviewAttachment key={target} target={target} />
<PreviewAttachment key={target} source="explicit-link" target={target} />
))}
</div>
)}
@ -563,42 +571,59 @@ const ThinkingDisclosure: FC<{
const elapsed = useElapsedSeconds(pending)
return (
<div className="mb-2 text-sm text-muted-foreground">
<div className="text-sm text-muted-foreground" data-slot="tool-block">
<button
aria-expanded={open}
className="inline-flex max-w-full items-center gap-1 rounded-md py-0.5 pr-1 text-left text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
className="group/thinking-row flex w-full max-w-full min-w-0 items-start gap-2 rounded-md px-2 py-0.5 text-left text-muted-foreground transition-colors hover:bg-accent/35 hover:text-foreground"
onClick={() => setOpen(value => !value)}
type="button"
>
<ChevronRightIcon
className={cn('size-3 shrink-0 text-muted-foreground/80 transition-transform', open && 'rotate-90')}
/>
<span
className={cn('shrink-0 text-xs font-medium text-foreground/70', pending && 'shimmer text-foreground/55')}
>
Thinking
<span className="flex h-[1.1rem] shrink-0 items-center">
<ChevronRightIcon
className={cn(
'size-3 text-muted-foreground/55 transition-transform group-hover/thinking-row:text-muted-foreground/85',
open && 'rotate-90'
)}
/>
</span>
<span className="flex min-w-0 flex-1 items-baseline gap-1.5">
<span
className={cn(
'text-[0.78rem] font-medium leading-[1.1rem] text-foreground/75',
pending && 'shimmer text-foreground/55'
)}
>
Thinking
</span>
{pending && (
<ActivityTimerText className="text-[0.625rem] tabular-nums text-muted-foreground/55" seconds={elapsed} />
)}
</span>
{pending && <ActivityTimerText seconds={elapsed} />}
</button>
{open && <div className="ml-4 mt-1 max-w-full wrap-anywhere border-l border-border pl-3">{children}</div>}
{open && (
<div className="mt-2 w-full min-w-0 max-w-full overflow-hidden pl-6 pr-2 wrap-anywhere pb-1">{children}</div>
)}
</div>
)
}
const ReasoningPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => (
<div className="mb-1 mt-1">
const ReasoningPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => {
const displayText = text.trimStart()
return (
<ThinkingDisclosure pending={status?.type === 'running'}>
<div
className={cn(
'whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground/85',
status?.type === 'running' && 'shimmer text-muted-foreground/55'
)}
data-slot="aui_reasoning-text"
>
{text}
{displayText}
</div>
</ThinkingDisclosure>
</div>
)
)
}
const TIME_FMT = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' })
@ -679,27 +704,15 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, on
}
const CopyMessageButton: FC<{ text: string }> = ({ text }) => {
const [copied, setCopied] = useState(false)
const copy = useCallback(async () => {
if (!text) {
return
}
try {
await navigator.clipboard.writeText(text)
triggerHaptic('selection')
setCopied(true)
window.setTimeout(() => setCopied(false), 2000)
} catch (error) {
notifyError(error, 'Copy failed')
}
}, [text])
return (
<TooltipIconButton disabled={!text} onClick={() => void copy()} tooltip={copied ? 'Copied' : 'Copy'}>
{copied ? <CheckIcon /> : <CopyIcon />}
</TooltipIconButton>
<CopyButton
appearance="icon"
buttonSize="icon"
className="aui-button-icon size-6 p-1"
disabled={!text}
label="Copy"
text={text}
/>
)
}
@ -774,18 +787,45 @@ const AssistantFooter: FC<MessageActionProps> = props => (
const branchButtonClass =
'grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-35'
const EMPTY_ATTACHMENT_REFS: string[] = []
function messageAttachmentRefs(value: unknown): string[] {
if (!Array.isArray(value)) {
return EMPTY_ATTACHMENT_REFS
}
return value.every(ref => typeof ref === 'string') ? value : EMPTY_ATTACHMENT_REFS
}
const UserMessage: FC = () => {
const content = useAuiState(s => s.message.content)
const messageText = messageContentText(content)
const attachmentRefs = useAuiState(s => {
const custom = (s.message.metadata?.custom ?? {}) as { attachmentRefs?: unknown }
return messageAttachmentRefs(custom.attachmentRefs)
})
const hasBody = messageText.trim().length > 0
return (
<MessagePrimitive.Root
className="group flex min-w-0 max-w-[min(72%,34rem)] flex-col items-end gap-2 self-end overflow-hidden"
data-role="user"
data-slot="aui_user-message-root"
>
<div className="wrap-anywhere max-w-full overflow-hidden whitespace-pre-line rounded-2xl border border-[color-mix(in_srgb,var(--dt-user-bubble-border)_78%,transparent)] bg-[color-mix(in_srgb,var(--dt-user-bubble)_94%,transparent)] px-3 py-2 leading-[1.48] text-foreground/95">
<MessagePrimitive.Parts components={{ Text: DirectiveText }} />
<div className="flex min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-2xl border border-[color-mix(in_srgb,var(--dt-user-bubble-border)_78%,transparent)] bg-[color-mix(in_srgb,var(--dt-user-bubble)_94%,transparent)] px-3 py-2 leading-[1.48] text-foreground/95">
{attachmentRefs.length > 0 && (
<div className="-mx-1 flex flex-wrap gap-1 border-b border-border/45 pb-1.5">
<DirectiveContent text={attachmentRefs.join(' ')} />
</div>
)}
{hasBody && (
<div className="wrap-anywhere whitespace-pre-line">
<MessagePrimitive.Parts components={{ Text: DirectiveText }} />
</div>
)}
</div>
<div className="min-h-6">
<UserActionBar messageText={messageText} />

File diff suppressed because it is too large Load diff

View file

@ -102,13 +102,13 @@ export function ZoomableImage({ className, containerClassName, src, alt, slot, .
const lightbox = src ? (
<Dialog onOpenChange={setLightboxOpen} open={lightboxOpen}>
<DialogContent
className="grid max-h-[calc(100vh-2rem)] w-auto max-w-[calc(100vw-2rem)] place-items-center overflow-visible border-0 bg-transparent p-0 shadow-none"
className="block w-auto max-h-[calc(100vh-12rem)] max-w-[calc(100vw-12rem)] overflow-visible border-0 bg-transparent p-0 shadow-none"
showCloseButton={false}
>
<div className="group/lightbox relative max-h-[calc(100vh-2rem)] max-w-[calc(100vw-2rem)] overflow-auto">
<div className="group/lightbox relative inline-block">
<img
alt={alt ?? ''}
className="block max-h-[calc(100vh-2rem)] max-w-full cursor-zoom-out select-auto rounded-lg object-contain shadow-2xl"
className="block max-h-[calc(100vh-12rem)] max-w-[calc(100vw-12rem)] cursor-zoom-out select-auto rounded-lg object-contain shadow-2xl"
onClick={() => setLightboxOpen(false)}
src={src}
/>

View file

@ -2,8 +2,9 @@ import { useStore } from '@nanostores/react'
import { type ReactNode, useEffect, useRef, useState } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { CopyButton } from '@/components/ui/copy-button'
import { triggerHaptic } from '@/lib/haptics'
import { AlertCircle, AlertTriangle, CheckCircle2, Copy, Info, type LucideIcon, X } from '@/lib/icons'
import { AlertCircle, AlertTriangle, CheckCircle2, Info, type LucideIcon, X } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$notifications,
@ -116,18 +117,6 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
}
function NotificationDetail({ detail }: { detail: string }) {
const [copied, setCopied] = useState(false)
async function copyDetail() {
try {
await navigator.clipboard.writeText(detail)
setCopied(true)
window.setTimeout(() => setCopied(false), 1200)
} catch {
// Best effort; details remain visible even if clipboard access fails.
}
}
return (
<details className="mt-2 text-xs text-muted-foreground">
<summary className="cursor-pointer select-none font-medium text-muted-foreground hover:text-foreground">
@ -137,14 +126,16 @@ function NotificationDetail({ detail }: { detail: string }) {
<pre className="max-h-32 whitespace-pre-wrap wrap-break-word font-mono text-[0.6875rem] leading-relaxed">
{detail}
</pre>
<button
<CopyButton
appearance="inline"
className="mt-1 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[0.6875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={copyDetail}
type="button"
errorMessage="Could not copy notification detail"
iconClassName="size-3"
label="Copy detail"
text={detail}
>
<Copy className="size-3" />
{copied ? 'Copied' : 'Copy detail'}
</button>
Copy detail
</CopyButton>
</div>
</details>
)

View file

@ -0,0 +1,14 @@
import { createContext } from 'react'
export interface PaneSlot {
column: number
side: 'left' | 'right'
open: boolean
}
export interface PaneShellContextValue {
paneById: Map<string, PaneSlot>
mainColumn: number
}
export const PaneShellContext = createContext<PaneShellContextValue | null>(null)

View file

@ -0,0 +1,4 @@
export type { PaneShellContextValue, PaneSlot } from './context'
export { PaneShellContext } from './context'
export { Pane, PaneMain, PaneShell } from './pane-shell'
export type { PaneMainProps, PaneProps, PaneShellProps } from './pane-shell'

View file

@ -0,0 +1,247 @@
import { cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { $paneStates, setPaneOpen, setPaneWidthOverride } from '@/store/panes'
import { Pane, PaneMain, PaneShell } from './pane-shell'
function gridContainer(rendered: ReturnType<typeof render>): HTMLElement {
const root = rendered.container.firstElementChild
if (!(root instanceof HTMLElement)) {
throw new Error('PaneShell did not render a root element')
}
return root
}
function getColumnTemplate(container: HTMLElement): string[] {
return (container.style.gridTemplateColumns ?? '').split(/\s+/).filter(Boolean)
}
describe('PaneShell composition', () => {
beforeEach(() => {
$paneStates.set({})
window.localStorage.clear()
})
afterEach(() => {
cleanup()
$paneStates.set({})
window.localStorage.clear()
})
it('builds a 2-column grid for one left pane + main', () => {
const rendered = render(
<PaneShell>
<Pane id="files" side="left" width="240px">
files
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
const tracks = getColumnTemplate(gridContainer(rendered))
expect(tracks).toEqual(['240px', 'minmax(0,1fr)'])
})
it('orders panes left-to-right by side, preserving source order within a side', () => {
const rendered = render(
<PaneShell>
<Pane id="files" side="left" width="240px">
files
</Pane>
<Pane id="sessions" side="left" width="200px">
sessions
</Pane>
<PaneMain>main</PaneMain>
<Pane id="preview" side="right" width="320px">
preview
</Pane>
<Pane id="inspector" side="right" width="280px">
inspector
</Pane>
</PaneShell>
)
const tracks = getColumnTemplate(gridContainer(rendered))
expect(tracks).toEqual(['240px', '200px', 'minmax(0,1fr)', '320px', '280px'])
})
it('collapses a closed pane to 0px', () => {
const rendered = render(
<PaneShell>
<Pane defaultOpen={false} id="files" side="left" width="240px">
files
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
const tracks = getColumnTemplate(gridContainer(rendered))
expect(tracks).toEqual(['0px', 'minmax(0,1fr)'])
})
it('reads open state from the panes store', () => {
setPaneOpen('files', false)
const rendered = render(
<PaneShell>
<Pane id="files" side="left" width="240px">
files
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
expect(getColumnTemplate(gridContainer(rendered))).toEqual(['0px', 'minmax(0,1fr)'])
})
it('disabled forces the track to 0px even when the store says open', () => {
setPaneOpen('files', true)
const rendered = render(
<PaneShell>
<Pane disabled={true} id="files" side="left" width="240px">
files
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
expect(getColumnTemplate(gridContainer(rendered))).toEqual(['0px', 'minmax(0,1fr)'])
})
it('disabled does NOT mutate the store-persisted open state', () => {
setPaneOpen('files', true)
render(
<PaneShell>
<Pane disabled={true} id="files" side="left" width="240px">
files
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
expect($paneStates.get().files?.open).toBe(true)
})
it('uses widthOverride from the store when set', () => {
setPaneOpen('files', true)
setPaneWidthOverride('files', 320)
const rendered = render(
<PaneShell>
<Pane id="files" side="left" width="240px">
files
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
expect(getColumnTemplate(gridContainer(rendered))).toEqual(['320px', 'minmax(0,1fr)'])
})
it('preserves CSS-string widths verbatim (clamp, var, etc.)', () => {
const rendered = render(
<PaneShell>
<Pane id="inspector" side="right" width="clamp(13.5rem,21vw,20rem)">
inspector
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
const template = gridContainer(rendered).style.gridTemplateColumns
expect(template).toContain('clamp(13.5rem,21vw,20rem)')
})
it('coerces numeric widths to px', () => {
const rendered = render(
<PaneShell>
<Pane id="files" side="left" width={224}>
files
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
expect(getColumnTemplate(gridContainer(rendered))).toEqual(['224px', 'minmax(0,1fr)'])
})
it('emits per-pane width as a CSS variable', () => {
const rendered = render(
<PaneShell>
<Pane id="files" side="left" width="240px">
files
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
const root = gridContainer(rendered)
expect(root.style.getPropertyValue('--pane-files-width').trim()).toBe('240px')
})
it('places a Pane in the correct grid column via inline style', () => {
const rendered = render(
<PaneShell>
<Pane id="files" side="left" width="240px">
<span data-testid="files-content">files</span>
</Pane>
<PaneMain>
<span data-testid="main-content">main</span>
</PaneMain>
<Pane id="preview" side="right" width="320px">
<span data-testid="preview-content">preview</span>
</Pane>
</PaneShell>
)
const filesCell = rendered.getByTestId('files-content').parentElement!
const mainCell = rendered.getByTestId('main-content').parentElement!
const previewCell = rendered.getByTestId('preview-content').parentElement!
expect(filesCell.style.gridColumn).toBe('1 / 2')
expect(mainCell.style.gridColumn).toBe('2 / 3')
expect(previewCell.style.gridColumn).toBe('3 / 4')
})
it('marks closed panes aria-hidden', () => {
const rendered = render(
<PaneShell>
<Pane defaultOpen={false} id="files" side="left" width="240px">
<span data-testid="files-content">files</span>
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
const cell = rendered.getByTestId('files-content').parentElement!
expect(cell.getAttribute('aria-hidden')).toBe('true')
expect(cell.getAttribute('data-pane-open')).toBe('false')
})
it('passes through arbitrary non-Pane children for self-placement', () => {
const rendered = render(
<PaneShell>
<Pane id="files" side="left" width="240px">
files
</Pane>
<PaneMain>main</PaneMain>
<div data-testid="floating-overlay" style={{ position: 'absolute' }}>
overlay
</div>
</PaneShell>
)
expect(rendered.getByTestId('floating-overlay')).toBeDefined()
})
})

View file

@ -0,0 +1,220 @@
import { useStore } from '@nanostores/react'
import {
Children,
type CSSProperties,
isValidElement,
type ReactElement,
type ReactNode,
useContext,
useEffect,
useMemo,
useRef
} from 'react'
import { cn } from '@/lib/utils'
import { $paneStates, ensurePaneRegistered } from '@/store/panes'
import { PaneShellContext, type PaneShellContextValue, type PaneSlot } from './context'
type PaneSide = 'left' | 'right'
type WidthValue = string | number
interface PaneRoleMarker {
__paneShellRole?: 'pane' | 'main'
}
export interface PaneProps {
children?: ReactNode
className?: string
defaultOpen?: boolean
/** Forces the pane closed (track→0, aria-hidden) without writing to the store — for transient route gates. */
disabled?: boolean
id: string
maxWidth?: WidthValue
minWidth?: WidthValue
resizable?: boolean
side: PaneSide
width?: WidthValue
}
export interface PaneMainProps {
children?: ReactNode
className?: string
}
export interface PaneShellProps {
children?: ReactNode
className?: string
style?: CSSProperties
}
interface CollectedPane {
defaultOpen: boolean
disabled: boolean
id: string
side: PaneSide
width: string
}
const DEFAULT_WIDTH = '16rem'
const widthToCss = (value: WidthValue | undefined, fallback: string) =>
value === undefined ? fallback : typeof value === 'number' ? `${value}px` : value
function isRole(child: unknown, role: 'pane' | 'main'): child is ReactElement {
return isValidElement(child) && (child.type as PaneRoleMarker)?.__paneShellRole === role
}
function collectPanes(children: ReactNode) {
const left: CollectedPane[] = []
const right: CollectedPane[] = []
let mainCount = 0
Children.forEach(children, child => {
if (isRole(child, 'main')) {
mainCount++
return
}
if (!isRole(child, 'pane')) {return}
const props = child.props as PaneProps
const entry: CollectedPane = {
defaultOpen: props.defaultOpen ?? true,
disabled: props.disabled ?? false,
id: props.id,
side: props.side,
width: widthToCss(props.width, DEFAULT_WIDTH)
}
;(props.side === 'left' ? left : right).push(entry)
})
return { left, mainCount, right }
}
function trackForPane(pane: CollectedPane, states: Record<string, { open: boolean; widthOverride?: number }>) {
const stateOpen = states[pane.id]?.open ?? pane.defaultOpen
const open = !pane.disabled && stateOpen
if (!open) {return { open: false, track: '0px' }}
const override = states[pane.id]?.widthOverride
return { open: true, track: override !== undefined ? `${override}px` : pane.width }
}
export function PaneShell({ children, className, style }: PaneShellProps) {
const paneStates = useStore($paneStates)
const { left, mainCount, right } = useMemo(() => collectPanes(children), [children])
if (import.meta.env.DEV && mainCount > 1) {
console.warn('[PaneShell] expected at most one <PaneMain>, got', mainCount)
}
const ctxValue = useMemo(() => {
const paneById = new Map<string, PaneSlot>()
const tracks: string[] = []
const cssVars: Record<string, string> = {}
let column = 1
for (const pane of left) {
const { open, track } = trackForPane(pane, paneStates)
tracks.push(track)
paneById.set(pane.id, { column, open, side: 'left' })
cssVars[`--pane-${pane.id}-width`] = track
column++
}
tracks.push('minmax(0,1fr)')
const mainColumn = column++
for (const pane of right) {
const { open, track } = trackForPane(pane, paneStates)
tracks.push(track)
paneById.set(pane.id, { column, open, side: 'right' })
cssVars[`--pane-${pane.id}-width`] = track
column++
}
return { cssVars, gridTemplate: tracks.join(' '), mainColumn, paneById } satisfies PaneShellContextValue & {
cssVars: Record<string, string>
gridTemplate: string
}
}, [left, paneStates, right])
const composedStyle = useMemo<CSSProperties>(
() => ({ ...ctxValue.cssVars, ...style, gridTemplateColumns: ctxValue.gridTemplate }),
[ctxValue.cssVars, ctxValue.gridTemplate, style]
)
return (
<PaneShellContext.Provider value={{ mainColumn: ctxValue.mainColumn, paneById: ctxValue.paneById }}>
<div className={cn('relative grid h-full min-h-0', className)} style={composedStyle}>
{children}
</div>
</PaneShellContext.Provider>
)
}
export function Pane({ children, className, defaultOpen = true, disabled = false, id }: PaneProps) {
const ctx = useContext(PaneShellContext)
const registered = useRef(false)
useEffect(() => {
if (registered.current) {return}
registered.current = true
ensurePaneRegistered(id, { open: defaultOpen })
}, [defaultOpen, id])
if (!ctx) {
if (import.meta.env.DEV) {console.warn(`[Pane:${id}] must be rendered inside <PaneShell>`)}
return null
}
const slot = ctx.paneById.get(id)
if (!slot) {return null}
const open = slot.open && !disabled
return (
<div
aria-hidden={!open}
className={cn('row-start-1 min-w-0 overflow-hidden', !open && 'pointer-events-none', className)}
data-pane-id={id}
data-pane-open={open ? 'true' : 'false'}
data-pane-side={slot.side}
style={{ gridColumn: `${slot.column} / ${slot.column + 1}` }}
>
{children}
</div>
)
}
;(Pane as unknown as PaneRoleMarker).__paneShellRole = 'pane'
export function PaneMain({ children, className }: PaneMainProps) {
const ctx = useContext(PaneShellContext)
if (!ctx) {
if (import.meta.env.DEV) {console.warn('[PaneMain] must be rendered inside <PaneShell>')}
return null
}
return (
<div
className={cn('row-start-1 flex min-h-0 min-w-0 flex-col overflow-hidden', className)}
data-pane-main="true"
style={{ gridColumn: `${ctx.mainColumn} / ${ctx.mainColumn + 1}` }}
>
{children}
</div>
)
}
;(PaneMain as unknown as PaneRoleMarker).__paneShellRole = 'main'

View file

@ -1,76 +0,0 @@
'use client'
import type { FC } from 'react'
import { AgentSection } from '@/app/chat/right-rail/agent-section'
import { ProjectSection } from '@/app/chat/right-rail/project-section'
import { cn } from '@/lib/utils'
export interface SessionInspectorProps {
open: boolean
cwd: string
branch: string
busy: boolean
modelLabel: string
modelTitle?: string
providerName?: string
reasoningEffort: string
serviceTier: string
fastMode: boolean
personality: string
personalities: string[]
onChangeCwd?: (cwd: string) => void
onBrowseCwd?: () => void
onOpenModelPicker?: () => void
onSetReasoningEffort?: (effort: string) => void
onSetFastMode?: (enabled: boolean) => void
onSelectPersonality?: (name: string) => void
}
export const SESSION_INSPECTOR_WIDTH = '14rem'
export const SessionInspector: FC<SessionInspectorProps> = ({
open,
cwd,
branch,
busy,
modelLabel,
providerName,
reasoningEffort,
serviceTier,
fastMode,
personality,
personalities,
onChangeCwd,
onBrowseCwd,
onOpenModelPicker,
onSetFastMode,
onSetReasoningEffort,
onSelectPersonality
}) => (
<aside
aria-hidden={!open}
className={cn(
'relative flex h-full w-full min-w-0 flex-col overflow-hidden bg-transparent pb-2 pl-2 pr-3 pt-[calc(var(--titlebar-height)+0.25rem)] text-muted-foreground transition-none',
open ? 'opacity-100' : 'pointer-events-none opacity-0'
)}
data-open={open}
>
<div className="flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto overscroll-contain pl-1.5 pr-1 text-xs">
<ProjectSection branch={branch} busy={busy} cwd={cwd} onBrowseCwd={onBrowseCwd} onChangeCwd={onChangeCwd} />
<AgentSection
fastMode={fastMode}
modelLabel={modelLabel}
onOpenModelPicker={onOpenModelPicker}
onSelectPersonality={onSelectPersonality}
onSetFastMode={onSetFastMode}
onSetReasoningEffort={onSetReasoningEffort}
personalities={personalities}
personality={personality}
providerName={providerName}
reasoningEffort={reasoningEffort}
serviceTier={serviceTier}
/>
</div>
</aside>
)

View file

@ -0,0 +1,220 @@
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Copy, X } from '@/lib/icons'
import { cn } from '@/lib/utils'
type CopyPayload = string | (() => Promise<string> | string)
type CopyButtonAppearance = 'button' | 'icon' | 'inline' | 'menu-item' | 'tool-row'
type CopyStatus = 'copied' | 'error' | 'idle'
const COPIED_RESET_MS = 1_500
export async function writeClipboardText(text: string) {
if (!text) {
return
}
if (window.hermesDesktop?.writeClipboard) {
await window.hermesDesktop.writeClipboard(text)
return
}
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
return
}
throw new Error('Clipboard API is unavailable')
}
export interface CopyButtonProps {
appearance?: CopyButtonAppearance
buttonSize?: React.ComponentProps<typeof Button>['size']
buttonVariant?: React.ComponentProps<typeof Button>['variant']
children?: React.ReactNode
className?: string
disabled?: boolean
errorMessage?: string
haptic?: boolean
iconClassName?: string
label?: string
onCopied?: () => void
onCopyError?: (error: unknown) => void
preventDefault?: boolean
showLabel?: boolean
stopPropagation?: boolean
text: CopyPayload
title?: string
}
export function CopyButton({
appearance = 'button',
buttonSize,
buttonVariant = 'ghost',
children,
className,
disabled = false,
errorMessage = 'Copy failed',
haptic = true,
iconClassName,
label = 'Copy',
onCopied,
onCopyError,
preventDefault = false,
showLabel,
stopPropagation = false,
text,
title
}: CopyButtonProps) {
const [status, setStatus] = React.useState<CopyStatus>('idle')
const resetRef = React.useRef<number | null>(null)
React.useEffect(() => {
return () => {
if (resetRef.current !== null) {
window.clearTimeout(resetRef.current)
}
}
}, [])
const copy = React.useCallback(
async (event?: Event | React.MouseEvent<HTMLElement>) => {
if (preventDefault) {
event?.preventDefault()
}
if (stopPropagation) {
event?.stopPropagation()
}
try {
const value = typeof text === 'function' ? await text() : text
if (!value) {
return
}
await writeClipboardText(value)
if (haptic) {
triggerHaptic('selection')
}
if (resetRef.current !== null) {
window.clearTimeout(resetRef.current)
}
setStatus('copied')
resetRef.current = window.setTimeout(() => {
setStatus('idle')
resetRef.current = null
}, COPIED_RESET_MS)
onCopied?.()
} catch (error) {
onCopyError?.(error)
if (resetRef.current !== null) {
window.clearTimeout(resetRef.current)
}
setStatus('error')
resetRef.current = window.setTimeout(() => {
setStatus('idle')
resetRef.current = null
}, COPIED_RESET_MS)
}
},
[haptic, onCopied, onCopyError, preventDefault, stopPropagation, text]
)
const Icon = status === 'copied' ? Check : status === 'error' ? X : Copy
const icon = <Icon className={cn('size-3.5', iconClassName)} />
const visibleChildren =
(showLabel ?? (appearance !== 'icon' && appearance !== 'tool-row'))
? status === 'copied'
? 'Copied'
: status === 'error'
? 'Failed'
: (children ?? label)
: null
const content = (
<>
{icon}
{visibleChildren}
</>
)
const feedbackLabel = status === 'copied' ? 'Copied' : status === 'error' ? errorMessage : (title ?? label)
const ariaLabel = status === 'idle' ? label : feedbackLabel
if (appearance === 'menu-item') {
return (
<DropdownMenuItem
className={className}
disabled={disabled}
onSelect={event => {
event.preventDefault()
void copy(event)
}}
>
{content}
</DropdownMenuItem>
)
}
if (appearance === 'inline') {
return (
<button
aria-label={ariaLabel}
className={cn(
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-[0.75rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40',
className
)}
disabled={disabled}
onClick={event => void copy(event)}
title={feedbackLabel}
type="button"
>
{content}
</button>
)
}
if (appearance === 'tool-row') {
return (
<button
aria-label={ariaLabel}
className={cn(
'grid size-6 place-items-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity hover:bg-accent/55 hover:text-foreground focus-visible:opacity-100 group-hover/tool-row:opacity-100 disabled:opacity-40',
className
)}
disabled={disabled}
onClick={event => void copy(event)}
title={feedbackLabel}
type="button"
>
{icon}
</button>
)
}
return (
<Button
aria-label={ariaLabel}
className={className}
disabled={disabled}
onClick={event => void copy(event)}
size={buttonSize ?? (appearance === 'icon' ? 'icon' : 'default')}
title={feedbackLabel}
type="button"
variant={buttonVariant}
>
{content}
</Button>
)
}

View file

@ -0,0 +1,65 @@
import type { ComponentProps, CSSProperties } from 'react'
import { useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
interface FadeTextProps extends Omit<ComponentProps<'span'>, 'children'> {
children: React.ReactNode
/**
* Width of the fade region on the trailing edge. Accepts any CSS length.
* Defaults to 3rem so long strings clearly trail off short enough to
* preserve readable content, long enough to feel like a deliberate fade
* rather than a clipped ellipsis.
*/
fadeWidth?: string
}
/**
* Single-line text that fades out instead of truncating with an ellipsis.
*
* Uses an inline mask-image so the fade resolves against whatever the parent
* background is no need to know the surface color, no after-pseudo overlap.
* The mask is only applied when the text is actually overflowing, so short
* strings render as plain text without an unnecessary gradient on their tail.
*/
export function FadeText({ children, className, fadeWidth = '3rem', style, ...rest }: FadeTextProps) {
const ref = useRef<HTMLSpanElement>(null)
const [overflowing, setOverflowing] = useState(false)
useEffect(() => {
const el = ref.current
if (!el) {
return
}
const measure = () => {
setOverflowing(el.scrollWidth - el.clientWidth > 1)
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(el)
return () => observer.disconnect()
}, [children])
const maskStyle: CSSProperties = overflowing
? {
maskImage: `linear-gradient(to right, black calc(100% - ${fadeWidth}), transparent)`,
WebkitMaskImage: `linear-gradient(to right, black calc(100% - ${fadeWidth}), transparent)`,
...style
}
: style ?? {}
return (
<span
{...rest}
className={cn('block min-w-0 max-w-full overflow-hidden whitespace-nowrap', className)}
ref={ref}
style={maskStyle}
>
{children}
</span>
)
}

View file

@ -8,6 +8,7 @@ declare global {
notify: (payload: HermesNotification) => Promise<boolean>
requestMicrophoneAccess: () => Promise<boolean>
readFileDataUrl: (filePath: string) => Promise<string>
readFileText: (filePath: string) => Promise<HermesReadFileTextResult>
selectPaths: (options?: HermesSelectPathsOptions) => Promise<string[]>
writeClipboard: (text: string) => Promise<boolean>
saveImageFromUrl: (url: string) => Promise<boolean>
@ -17,7 +18,11 @@ declare global {
normalizePreviewTarget: (target: string, baseDir?: string) => Promise<HermesPreviewTarget | null>
watchPreviewFile: (url: string) => Promise<HermesPreviewWatch>
stopPreviewFileWatch: (id: string) => Promise<boolean>
setPreviewShortcutActive?: (active: boolean) => void
openExternal: (url: string) => Promise<void>
readDir: (path: string) => Promise<HermesReadDirResult>
gitRoot?: (path: string) => Promise<string | null>
onClosePreviewRequested?: (callback: () => void) => () => void
onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void
onBackendExit: (callback: (payload: BackendExit) => void) => () => void
}
@ -45,17 +50,46 @@ export interface HermesNotification {
}
export interface HermesPreviewTarget {
binary?: boolean
byteSize?: number
kind: 'file' | 'url'
label: string
large?: boolean
language?: string
mimeType?: string
path?: string
previewKind?: 'binary' | 'html' | 'image' | 'text'
renderMode?: 'preview' | 'source'
source: string
url: string
}
export interface HermesReadFileTextResult {
binary?: boolean
byteSize?: number
language?: string
mimeType?: string
path: string
text: string
truncated?: boolean
}
export interface HermesPreviewWatch {
id: string
path: string
}
export interface HermesReadDirEntry {
name: string
path: string
isDirectory: boolean
}
export interface HermesReadDirResult {
entries: HermesReadDirEntry[]
error?: string
}
export interface HermesPreviewFileChanged {
id: string
path: string

View file

@ -102,6 +102,14 @@ export function deleteSession(id: string): Promise<{ ok: boolean }> {
})
}
export function renameSession(id: string, title: string): Promise<{ ok: boolean; title: string }> {
return window.hermesDesktop.api<{ ok: boolean; title: string }>({
path: `/api/sessions/${encodeURIComponent(id)}`,
method: 'PATCH',
body: { title }
})
}
export function getGlobalModelInfo(): Promise<ModelInfoResponse> {
return window.hermesDesktop.api<ModelInfoResponse>({
path: '/api/model/info'

View file

@ -13,6 +13,8 @@ export type ChatMessage = {
pending?: boolean
branchGroupId?: string
hidden?: boolean
/** Composer attachment ref strings (`@file:...`, `@image:...`) sent with this user message. */
attachmentRefs?: string[]
}
export type GatewayEventPayload = {

View file

@ -106,10 +106,20 @@ export function coerceGatewayText(value: unknown): string {
return String(value)
}
/**
* Normalize a reasoning/thinking text payload from the gateway.
*
* Only the leading status prefix (e.g. "Hermes is thinking...") and the
* obvious placeholder echoes are stripped. We deliberately do NOT trim
* the delta reasoning streams as small chunks (often individual tokens
* with leading or trailing spaces), and trimming each chunk before
* concatenation collapses adjacent words together. Whitespace between
* tokens belongs to the data, not chrome.
*/
export function coerceThinkingText(value: unknown): string {
const text = coerceGatewayText(value).replace(THINKING_STATUS_PREFIX_RE, '').trim()
const raw = coerceGatewayText(value).replace(THINKING_STATUS_PREFIX_RE, '')
return EMPTY_THINKING_PLACEHOLDER_RE.test(text) ? '' : text
return EMPTY_THINKING_PLACEHOLDER_RE.test(raw) ? '' : raw
}
export function isImageGenerationTool(name?: string): boolean {
@ -281,7 +291,7 @@ export function toRuntimeMessage(message: ChatMessage): ThreadMessage {
content: message.parts.filter((part): part is Extract<ChatMessagePart, { type: 'text' }> => part.type === 'text'),
attachments: [],
createdAt,
metadata: { custom: {} }
metadata: { custom: { attachmentRefs: message.attachmentRefs ?? [] } }
} as ThreadMessage
}

View file

@ -0,0 +1,32 @@
import type { StatusbarMenuItem } from '@/app/shell/statusbar-controls'
const LOG_TAIL = 5
interface RpcEventLike {
payload?: unknown
type?: string
}
function asRecord(payload: unknown): Record<string, unknown> {
return payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
}
export function gatewayEventCompletedFileDiff(event: RpcEventLike): boolean {
if (event.type !== 'tool.complete') {return false}
const diff = asRecord(event.payload).inline_diff
return typeof diff === 'string' && diff.trim().length > 0
}
export function buildGatewayLogItems(lines: readonly string[]): readonly StatusbarMenuItem[] {
if (lines.length === 0) {
return [{ className: 'text-muted-foreground', disabled: true, id: 'gateway-log-empty', label: 'No recent gateway log lines' }]
}
return lines.slice(-LOG_TAIL).map((line, index) => ({
className: 'font-mono text-[0.68rem] text-muted-foreground',
disabled: true,
id: `gateway-log:${index}`,
label: line.trim().slice(0, 120) || '(blank log line)'
}))
}

View file

@ -0,0 +1,116 @@
import type { PreviewTarget } from '@/store/preview'
const HTML_EXTENSIONS = new Set(['.htm', '.html'])
const IMAGE_EXTENSIONS = new Set(['.bmp', '.gif', '.jpeg', '.jpg', '.png', '.svg', '.webp'])
const LANGUAGE_BY_EXT: Record<string, string> = {
'.c': 'c',
'.conf': 'ini',
'.cpp': 'cpp',
'.css': 'css',
'.csv': 'csv',
'.go': 'go',
'.graphql': 'graphql',
'.h': 'c',
'.hpp': 'cpp',
'.html': 'html',
'.java': 'java',
'.js': 'javascript',
'.json': 'json',
'.jsx': 'jsx',
'.log': 'text',
'.lua': 'lua',
'.md': 'markdown',
'.mjs': 'javascript',
'.py': 'python',
'.rb': 'ruby',
'.rs': 'rust',
'.sh': 'shell',
'.sql': 'sql',
'.svg': 'xml',
'.toml': 'toml',
'.ts': 'typescript',
'.tsx': 'tsx',
'.txt': 'text',
'.xml': 'xml',
'.yaml': 'yaml',
'.yml': 'yaml',
'.zsh': 'shell'
}
function basename(value: string) {
return value.split(/[\\/]/).filter(Boolean).pop() || value
}
function extension(value: string) {
const clean = value.split(/[?#]/, 1)[0] || value
const idx = clean.lastIndexOf('.')
return idx >= 0 ? clean.slice(idx).toLowerCase() : ''
}
function joinPath(base: string, rel: string) {
if (!base) {return rel}
return `${base.replace(/\/+$/, '')}/${rel.replace(/^\.?\//, '')}`
}
function pathToFileUrl(path: string) {
const encoded = path.split('/').map(part => encodeURIComponent(part)).join('/')
return `file://${encoded.startsWith('/') ? encoded : `/${encoded}`}`
}
export function localPreviewTarget(rawTarget: string, cwd?: string | null): PreviewTarget | null {
const raw = rawTarget.trim().replace(/^`|`$/g, '')
if (!raw) {return null}
if (/^https?:\/\//i.test(raw)) {
return { kind: 'url', label: basename(raw), source: raw, url: raw }
}
let path = raw
if (/^file:\/\//i.test(raw)) {
try {
path = decodeURIComponent(new URL(raw).pathname)
} catch {
path = raw.replace(/^file:\/\//i, '')
}
} else if (!raw.startsWith('/') && cwd) {
path = joinPath(cwd, raw)
}
const ext = extension(path)
const isHtml = HTML_EXTENSIONS.has(ext)
const isImage = IMAGE_EXTENSIONS.has(ext)
return {
kind: 'file',
label: basename(path),
language: LANGUAGE_BY_EXT[ext] || 'text',
path,
// Renderer fallback can't stat/sniff without reading; assume text unless
// image/html extension says otherwise. LocalFilePreview still guards
// binary/large files when readFileText/readFileDataUrl returns metadata.
previewKind: isHtml ? 'html' : isImage ? 'image' : 'text',
source: raw,
url: pathToFileUrl(path)
}
}
export async function normalizeOrLocalPreviewTarget(rawTarget: string, cwd?: string | null): Promise<PreviewTarget | null> {
try {
const normalized = await window.hermesDesktop?.normalizePreviewTarget?.(rawTarget, cwd || undefined)
if (normalized) {
return normalized
}
} catch {
// Running Electron may still have the old HTML-only preview IPC. Fall
// through to renderer-side local classification so text/images still open.
}
return localPreviewTarget(rawTarget, cwd)
}

View file

@ -0,0 +1,182 @@
import { isLikelyProseFence, sanitizeLanguageTag } from '@/lib/markdown-code'
import { stripPreviewTargets } from '@/lib/preview-targets'
/**
* Strip provider/model "thinking" blocks before markdown render.
*
* Some Hermes providers stream raw `<think>…</think>` and similar into
* assistant text. Proper reasoning UI uses dedicated `reasoning.*` parts.
*/
const REASONING_BLOCK_RE = /<(think|thinking|reasoning|scratchpad|analysis)>[\s\S]*?<\/\1>\s*/gi
const PREVIEW_MARKER_RE = /\[Preview:[^\]]+\]\(#preview[:/][^)]+\)/gi
const FENCE_LINE_RE = /^([ \t]*)(`{3,}|~{3,})([^\n]*)$/
const EMPTY_FENCE_BLOCK_RE = /(^|\n)[ \t]*(?:`{3,}|~{3,})[^\n]*\n[ \t]*(?:`{3,}|~{3,})[ \t]*(?=\n|$)/g
/**
* Forcefully scrub backtick noise the model streams that doesn't form a
* valid markdown construct.
*
* Strategy: find every well-formed `\`\`\`\`\`\`` (or `~~~`) block whose
* opener and closer are each on their own line, snapshot those ranges as
* "protected", then nuke all other triple-backtick runs in the text.
* This handles every failure mode in one shot:
*
* - mid-line `\`\`\`` (`there:\`\`\` cd /Users/...`)
* - orphan opener with no closer
* - orphan closer with no opener
* - chunk-boundary backticks where the preceding non-newline char isn't
* in the same regex window
* - empty `` `` `` `` and orphan double backticks
*
* Real, well-formed fenced blocks survive untouched.
*/
function scrubBacktickNoise(text: string): string {
const balancedFenceRe = /(^|\n)([ \t]*)(`{3,}|~{3,})([^\n]*)\n([\s\S]*?)\n[ \t]*\3[ \t]*(?=\n|$)/g
const protectedRanges: { end: number; start: number }[] = []
let match: RegExpExecArray | null
while ((match = balancedFenceRe.exec(text)) !== null) {
const start = match.index + match[1].length
protectedRanges.push({ end: balancedFenceRe.lastIndex, start })
}
const fenceNoiseRe = /`{3,}/g
let out = ''
let cursor = 0
for (const range of protectedRanges) {
out += text.slice(cursor, range.start).replace(fenceNoiseRe, '')
out += text.slice(range.start, range.end)
cursor = range.end
}
out += text.slice(cursor).replace(fenceNoiseRe, '')
// Empty inline code spans (`` `` `` `` with nothing meaningful inside)
// render as literal backticks. Two passes catch chains.
for (let pass = 0; pass < 2; pass += 1) {
out = out.replace(/``\s*``/g, '')
out = out.replace(/(^|[^`])``(?=\s|[.,;:!?)\]'"\u2014\u2013-]|$)/g, '$1')
}
return out
}
function stripEmptyFenceBlocks(text: string): string {
return text.replace(EMPTY_FENCE_BLOCK_RE, '$1')
}
function pushProseFence(out: string[], indent: string, info: string, lines: string[]) {
if (info) {
out.push(`${indent}${info}`.trimEnd())
}
out.push(...lines)
}
function findClosingFence(lines: string[], start: number, marker: string): number {
for (let cursor = start + 1; cursor < lines.length; cursor += 1) {
const closeMatch = (lines[cursor] || '').match(FENCE_LINE_RE)
if (!closeMatch) {
continue
}
const closeMarker = closeMatch[2] || ''
const closeInfo = (closeMatch[3] || '').trim()
if (!closeInfo && closeMarker[0] === marker[0] && closeMarker.length >= marker.length) {
return cursor
}
}
return -1
}
function normalizeFenceBlocks(text: string): string {
const sourceLines = text.split('\n')
const out: string[] = []
let index = 0
while (index < sourceLines.length) {
const line = sourceLines[index] || ''
const match = line.match(FENCE_LINE_RE)
if (!match) {
out.push(line)
index += 1
continue
}
const indent = match[1] || ''
const marker = match[2] || '```'
const infoRaw = (match[3] || '').trim()
const languageToken = infoRaw.split(/\s+/, 1)[0] || ''
const language = sanitizeLanguageTag(languageToken)
const openerValid = !infoRaw || Boolean(language)
if (!openerValid) {
out.push(`${indent}${infoRaw}`.trimEnd())
index += 1
continue
}
const closeIndex = findClosingFence(sourceLines, index, marker)
const bodyLines = sourceLines.slice(index + 1, closeIndex === -1 ? sourceLines.length : closeIndex)
const body = bodyLines.join('\n')
if (closeIndex !== -1 && !body.trim()) {
index = closeIndex + 1
continue
}
if (closeIndex === -1) {
if (!body.trim()) {
index += 1
continue
}
if (isLikelyProseFence(infoRaw, body)) {
pushProseFence(out, indent, infoRaw, bodyLines)
} else {
out.push(`${indent}${marker}${language}`)
out.push(...bodyLines)
}
break
}
if (isLikelyProseFence(infoRaw, body)) {
pushProseFence(out, indent, infoRaw, bodyLines)
index = closeIndex + 1
continue
}
out.push(`${indent}${marker}${language}`)
out.push(...bodyLines)
out.push(`${indent}${marker}`)
index = closeIndex + 1
}
return out.join('\n')
}
export function preprocessMarkdown(text: string): string {
const cleaned = text.replace(REASONING_BLOCK_RE, '').replace(PREVIEW_MARKER_RE, '')
const scrubbed = scrubBacktickNoise(cleaned)
const normalizedFences = normalizeFenceBlocks(scrubbed)
const strippedEmptyFences = stripEmptyFenceBlocks(normalizedFences)
return strippedEmptyFences
.split(/((?:```|~~~)[\s\S]*?(?:```|~~~))/g)
.map(part => (/^(?:```|~~~)/.test(part) ? part : stripPreviewTargets(part)))
.join('')
.replace(/[ \t]+\n/g, '\n')
}

View file

@ -1,54 +1,15 @@
import { describe, expect, it } from 'vitest'
import {
extractPreviewCandidates,
extractPreviewTargets,
isLikelyPreviewCandidate,
previewTargetFromMarkdownHref,
renderPreviewTargets,
stripPreviewTargets
} from './preview-targets'
describe('preview target detection', () => {
it('extracts local server URLs and html files', () => {
expect(
extractPreviewCandidates(
'Open http://localhost:5173/ and /tmp/mycelium-bunnies/index.html, not https://example.com/app.'
)
).toEqual(['http://localhost:5173/', '/tmp/mycelium-bunnies/index.html'])
})
it('accepts relative html files and file URLs', () => {
expect(extractPreviewCandidates('Wrote ./dist/index.html and file:///tmp/demo.html.')).toEqual([
'./dist/index.html',
'file:///tmp/demo.html'
])
})
it('accepts bare html files and common preview directories', () => {
expect(extractPreviewCandidates('Open index.html, nested/demo.html, ./dist, and /tmp/site/.')).toEqual([
'index.html',
'nested/demo.html',
'./dist',
'/tmp/site/'
])
})
it('rejects non-html file URLs and obvious local API or asset URLs', () => {
expect(isLikelyPreviewCandidate('file:///tmp/demo.png')).toBe(false)
expect(isLikelyPreviewCandidate('http://localhost:3000/api/users')).toBe(false)
expect(isLikelyPreviewCandidate('http://localhost:3000/src/main.tsx')).toBe(false)
})
it('ignores remote web URLs', () => {
expect(isLikelyPreviewCandidate('https://example.com/demo')).toBe(false)
expect(isLikelyPreviewCandidate('http://127.0.0.1:3000')).toBe(true)
})
it('renders previewable paths as markdown links', () => {
expect(renderPreviewTargets('ready\n/tmp/mycelium-bunnies.html\nopen it')).toBe(
'ready\n[Preview: mycelium-bunnies.html](#preview/%2Ftmp%2Fmycelium-bunnies.html)\nopen it'
)
it('does not infer preview targets from raw paths or URLs', () => {
expect(extractPreviewTargets('Preview: http://localhost:5173/')).toEqual([])
expect(extractPreviewTargets('Open index.html\n/tmp/demo.html\nhttp://localhost:5173/')).toEqual([])
})
it('decodes preview markdown hrefs', () => {
@ -62,7 +23,9 @@ describe('preview target detection', () => {
})
it('strips preview targets from visible assistant text', () => {
expect(stripPreviewTargets('ready\n/tmp/mycelium-bunnies.html\nopen it')).toBe('ready\nopen it')
expect(stripPreviewTargets('ready\n/tmp/mycelium-bunnies.html\nopen it')).toBe(
'ready\n/tmp/mycelium-bunnies.html\nopen it'
)
expect(stripPreviewTargets('[Preview: demo.html](#preview:%2Ftmp%2Fdemo.html)\nopen it')).toBe('open it')
})
})

View file

@ -1,217 +1,7 @@
const LOCAL_HOSTS = new Set(['0.0.0.0', '127.0.0.1', '::1', '[::1]', 'localhost'])
const PREVIEW_DIRECTORY_NAMES = new Set(['build', 'dist', 'out', 'public', 'site', 'web', 'www'])
const HTML_EXT_RE = /\.html?(?:[?#].*)?$/i
const ASSET_EXT_RE =
/\.(?:cjs|css|csv|gif|ico|jpe?g|js|json|jsx|map|mjs|otf|png|svg|ts|tsx|ttf|txt|wasm|webp|woff2?|xml)$/i
const URL_RE = /\bhttps?:\/\/[^\s<>"'`)\]]+/gi
const FILE_URL_RE = /\bfile:\/\/[^\s<>"'`)\]]+/gi
const POSIX_HTML_PATH_RE =
/(?:^|[\s("'`])(?<path>\/[^\s<>"'`]*?\.html?(?:[?#][^\s<>"'`)\]]*)?)(?:[),.;:!?]*)(?=$|[\s)"'`])/gi
const RELATIVE_HTML_PATH_RE =
/(?:^|[\s("'`])(?<path>\.{1,2}\/[^\s<>"'`]*?\.html?(?:[?#][^\s<>"'`)\]]*)?)(?:[),.;:!?]*)(?=$|[\s)"'`])/gi
const BARE_HTML_PATH_RE =
/(?:^|[\s("'`])(?<path>(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+\.html?(?:[?#][^\s<>"'`)\]]*)?)(?:[),.;:!?]*)(?=$|[\s)"'`])/gi
const POSIX_PATH_RE = /(?:^|[\s("'`])(?<path>\/[^\s<>"'`]+)(?:[),.;:!?]*)(?=$|[\s)"'`])/gi
const RELATIVE_PATH_RE = /(?:^|[\s("'`])(?<path>(?:\.{1,2}|~)\/[^\s<>"'`]+)(?:[),.;:!?]*)(?=$|[\s)"'`])/gi
const PREVIEW_MARKDOWN_RE = /\[Preview:[^\]]+\]\((?<href>#preview[:/][^)]+)\)/gi
interface PreviewCandidateMatch {
end: number
index: number
value: string
}
function stripTrailingPunctuation(value: string): string {
return value.replace(/[),.;:!?]+$/, '')
}
function pathWithoutQuery(value: string): string {
return value.split(/[?#]/, 1)[0]
}
function pathBasename(value: string): string {
return pathWithoutQuery(value).replace(/\/+$/, '').split(/[\\/]/).filter(Boolean).pop()?.toLowerCase() || ''
}
function isHtmlFileUrl(value: string): boolean {
try {
const url = new URL(value)
return url.protocol === 'file:' && HTML_EXT_RE.test(url.pathname)
} catch {
return false
}
}
function isPreviewDirectoryCandidate(value: string): boolean {
const path = pathWithoutQuery(value)
if (!/^(?:\/|\.{1,2}\/|~\/)/.test(path) || HTML_EXT_RE.test(value)) {
return false
}
const name = pathBasename(path)
if (!name || /\.[a-z0-9]{1,8}$/i.test(name)) {
return false
}
return path.endsWith('/') || PREVIEW_DIRECTORY_NAMES.has(name)
}
function isLocalPreviewUrl(value: string): boolean {
try {
const url = new URL(value)
if (!['http:', 'https:'].includes(url.protocol)) {
return false
}
if (!LOCAL_HOSTS.has(url.hostname.toLowerCase())) {
return false
}
const pathname = url.pathname.toLowerCase()
if (/^\/(?:api|graphql|health|metrics|rpc)(?:\/|$)/.test(pathname)) {
return false
}
return !ASSET_EXT_RE.test(pathname)
} catch {
return false
}
}
export function isLikelyPreviewCandidate(value: string): boolean {
const trimmed = stripTrailingPunctuation(value.trim())
return (
isHtmlFileUrl(trimmed) ||
HTML_EXT_RE.test(trimmed) ||
isPreviewDirectoryCandidate(trimmed) ||
isLocalPreviewUrl(trimmed)
)
}
function collectPreviewMatches(text: string): PreviewCandidateMatch[] {
const matches: PreviewCandidateMatch[] = []
const collect = (index: number | undefined, raw: string, value = raw) => {
if (index === undefined) {
return
}
const candidate = stripTrailingPunctuation(value.trim())
if (!candidate || !isLikelyPreviewCandidate(candidate)) {
return
}
const offset = raw.indexOf(value)
const start = index + Math.max(0, offset)
matches.push({
end: start + candidate.length,
index: start,
value: candidate
})
}
for (const match of text.matchAll(URL_RE)) {
collect(match.index, match[0])
}
for (const match of text.matchAll(FILE_URL_RE)) {
collect(match.index, match[0])
}
for (const match of text.matchAll(POSIX_HTML_PATH_RE)) {
collect(match.index, match[0], match.groups?.path || '')
}
for (const match of text.matchAll(RELATIVE_HTML_PATH_RE)) {
collect(match.index, match[0], match.groups?.path || '')
}
for (const match of text.matchAll(BARE_HTML_PATH_RE)) {
collect(match.index, match[0], match.groups?.path || '')
}
for (const match of text.matchAll(POSIX_PATH_RE)) {
collect(match.index, match[0], match.groups?.path || '')
}
for (const match of text.matchAll(RELATIVE_PATH_RE)) {
collect(match.index, match[0], match.groups?.path || '')
}
return matches.sort((a, b) => a.index - b.index)
}
export function extractPreviewCandidates(text: string): string[] {
const candidates: string[] = []
const seen = new Set<string>()
const push = (value: string) => {
const candidate = stripTrailingPunctuation(value.trim())
if (!candidate || seen.has(candidate) || !isLikelyPreviewCandidate(candidate)) {
return
}
seen.add(candidate)
candidates.push(candidate)
}
for (const match of collectPreviewMatches(text)) {
push(match.value)
}
return candidates
}
export function stripPreviewTargets(text: string): string {
const matches = collectPreviewMatches(text)
let cursor = 0
let stripped = ''
for (const match of matches) {
if (match.index < cursor) {
continue
}
const lineStart = text.lastIndexOf('\n', Math.max(0, match.index - 1)) + 1
const nextLineBreak = text.indexOf('\n', match.end)
const lineEnd = nextLineBreak === -1 ? text.length : nextLineBreak + 1
const beforeOnLine = text.slice(lineStart, match.index)
const afterOnLine = text.slice(match.end, nextLineBreak === -1 ? text.length : nextLineBreak)
if (lineStart >= cursor && !beforeOnLine.trim() && !afterOnLine.trim()) {
stripped += text.slice(cursor, lineStart)
cursor = lineEnd
continue
}
stripped += text.slice(cursor, match.index)
cursor = match.end
}
stripped += text.slice(cursor)
return stripped
return text
.replace(PREVIEW_MARKDOWN_RE, '')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
@ -219,8 +9,8 @@ export function stripPreviewTargets(text: string): string {
}
export function extractPreviewTargets(text: string): string[] {
const targets = extractPreviewCandidates(text)
const seen = new Set(targets)
const targets: string[] = []
const seen = new Set<string>()
for (const match of text.matchAll(PREVIEW_MARKDOWN_RE)) {
const target = previewTargetFromMarkdownHref(match.groups?.href)
@ -272,26 +62,3 @@ export function previewDisplayLabel(target: string): string {
return `Preview: ${escaped}`
}
function previewLink(value: string): string {
return `[${previewDisplayLabel(value)}](${previewMarkdownHref(value)})`
}
export function renderPreviewTargets(text: string): string {
const matches = collectPreviewMatches(text)
let cursor = 0
let rendered = ''
const seen = new Set<string>()
for (const match of matches) {
if (match.index < cursor || seen.has(match.value)) {
continue
}
rendered += text.slice(cursor, match.index)
rendered += previewLink(match.value)
cursor = match.end
seen.add(match.value)
}
return rendered + text.slice(cursor)
}

View file

@ -1,4 +1,5 @@
import './styles.css'
import 'streamdown/styles.css'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { StrictMode } from 'react'

View file

@ -1,47 +1,67 @@
import { atom } from 'nanostores'
import { atom, computed, type ReadableAtom } from 'nanostores'
import { arraysEqual, insertUniqueId, persistStringArray, storedStringArray } from '@/lib/storage'
import {
arraysEqual,
insertUniqueId,
persistBoolean,
persistStringArray,
storedBoolean,
storedStringArray
} from '@/lib/storage'
$paneStates,
ensurePaneRegistered,
setPaneOpen,
setPaneWidthOverride,
togglePane
} from './panes'
export const SIDEBAR_DEFAULT_WIDTH = 224
export const SIDEBAR_MAX_WIDTH = 320
const SIDEBAR_OPEN_STORAGE_KEY = 'hermes.desktop.sidebarOpen'
const SIDEBAR_PINNED_STORAGE_KEY = 'hermes.desktop.pinnedSessions'
const INSPECTOR_OPEN_STORAGE_KEY = 'hermes.desktop.inspectorOpen'
export const FILE_BROWSER_DEFAULT_WIDTH = '17rem'
const SIDEBAR_PINNED_STORAGE_KEY = 'hermes.desktop.pinnedSessions'
export const CHAT_SIDEBAR_PANE_ID = 'chat-sidebar'
export const FILE_BROWSER_PANE_ID = 'file-browser'
// Pre-register app chrome panes so legacy callers see the correct initial
// values whether or not the user has any persisted state.
ensurePaneRegistered(CHAT_SIDEBAR_PANE_ID, { open: true })
ensurePaneRegistered(FILE_BROWSER_PANE_ID, { open: false })
export const $sidebarOpen: ReadableAtom<boolean> = computed(
$paneStates,
states => states[CHAT_SIDEBAR_PANE_ID]?.open ?? true
)
export const $fileBrowserOpen: ReadableAtom<boolean> = computed(
$paneStates,
states => states[FILE_BROWSER_PANE_ID]?.open ?? false
)
export const $sidebarWidth: ReadableAtom<number> = computed($paneStates, states => {
const override = states[CHAT_SIDEBAR_PANE_ID]?.widthOverride
return typeof override === 'number' ? override : SIDEBAR_DEFAULT_WIDTH
})
export const $sidebarWidth = atom(SIDEBAR_DEFAULT_WIDTH)
export const $sidebarOpen = atom(storedBoolean(SIDEBAR_OPEN_STORAGE_KEY, true))
export const $inspectorOpen = atom(storedBoolean(INSPECTOR_OPEN_STORAGE_KEY, true))
export const $pinnedSessionIds = atom(storedStringArray(SIDEBAR_PINNED_STORAGE_KEY))
export const $sidebarPinsOpen = atom(true)
export const $sidebarRecentsOpen = atom(true)
export const $isSidebarResizing = atom(false)
$sidebarOpen.subscribe(open => persistBoolean(SIDEBAR_OPEN_STORAGE_KEY, open))
$inspectorOpen.subscribe(open => persistBoolean(INSPECTOR_OPEN_STORAGE_KEY, open))
$pinnedSessionIds.subscribe(ids => persistStringArray(SIDEBAR_PINNED_STORAGE_KEY, [...ids]))
export function setSidebarWidth(width: number) {
const bounded = Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_DEFAULT_WIDTH, width))
$sidebarWidth.set(bounded)
setPaneWidthOverride(CHAT_SIDEBAR_PANE_ID, bounded)
}
export function setSidebarOpen(open: boolean) {
$sidebarOpen.set(open)
setPaneOpen(CHAT_SIDEBAR_PANE_ID, open)
}
export function toggleSidebarOpen() {
$sidebarOpen.set(!$sidebarOpen.get())
togglePane(CHAT_SIDEBAR_PANE_ID)
}
export function toggleInspectorOpen() {
$inspectorOpen.set(!$inspectorOpen.get())
export function toggleFileBrowserOpen() {
togglePane(FILE_BROWSER_PANE_ID)
}
export function setSidebarPinsOpen(open: boolean) {

View file

@ -0,0 +1,146 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
$paneOpen,
$paneStates,
$paneWidthOverride,
clearPaneWidthOverride,
ensurePaneRegistered,
getPaneStateSnapshot,
setPaneOpen,
setPaneWidthOverride,
togglePane
} from './panes'
const STORAGE_KEY = 'hermes.desktop.paneStates.v1'
describe('panes store', () => {
beforeEach(() => {
$paneStates.set({})
window.localStorage.clear()
})
afterEach(() => {
$paneStates.set({})
window.localStorage.clear()
})
describe('ensurePaneRegistered', () => {
it('adds a pane with defaults when missing', () => {
ensurePaneRegistered('files', { open: true })
expect(getPaneStateSnapshot('files')).toEqual({ open: true, widthOverride: undefined })
})
it('is a no-op when the pane already exists', () => {
ensurePaneRegistered('files', { open: false })
ensurePaneRegistered('files', { open: true })
expect(getPaneStateSnapshot('files')?.open).toBe(false)
})
it('preserves an existing widthOverride when re-registering', () => {
ensurePaneRegistered('files', { open: true })
setPaneWidthOverride('files', 360)
ensurePaneRegistered('files', { open: false })
expect(getPaneStateSnapshot('files')?.widthOverride).toBe(360)
})
})
describe('setPaneOpen / togglePane', () => {
it('updates the pane open flag', () => {
ensurePaneRegistered('files', { open: false })
setPaneOpen('files', true)
expect(getPaneStateSnapshot('files')?.open).toBe(true)
})
it('togglePane flips the current value', () => {
ensurePaneRegistered('files', { open: false })
togglePane('files')
togglePane('files')
togglePane('files')
expect(getPaneStateSnapshot('files')?.open).toBe(true)
})
it('togglePane on an unregistered id starts from false', () => {
togglePane('ephemeral')
expect(getPaneStateSnapshot('ephemeral')?.open).toBe(true)
})
it('preserves widthOverride across open/close changes', () => {
ensurePaneRegistered('files', { open: true })
setPaneWidthOverride('files', 280)
setPaneOpen('files', false)
setPaneOpen('files', true)
expect(getPaneStateSnapshot('files')?.widthOverride).toBe(280)
})
})
describe('width overrides', () => {
it('setPaneWidthOverride stores the px value', () => {
ensurePaneRegistered('files', { open: true })
setPaneWidthOverride('files', 300)
expect(getPaneStateSnapshot('files')?.widthOverride).toBe(300)
})
it('clearPaneWidthOverride removes the override', () => {
ensurePaneRegistered('files', { open: true })
setPaneWidthOverride('files', 300)
clearPaneWidthOverride('files')
expect(getPaneStateSnapshot('files')?.widthOverride).toBeUndefined()
})
it('width override is in-memory only — not persisted across reloads', () => {
ensurePaneRegistered('files', { open: true })
setPaneWidthOverride('files', 300)
const persisted = window.localStorage.getItem(STORAGE_KEY)
expect(persisted).not.toBeNull()
expect(JSON.parse(persisted ?? '{}')).toEqual({ files: { open: true } })
})
it('open flag is persisted across changes', () => {
ensurePaneRegistered('files', { open: false })
setPaneOpen('files', true)
const persisted = window.localStorage.getItem(STORAGE_KEY)
expect(persisted).not.toBeNull()
expect(JSON.parse(persisted ?? '{}')).toEqual({ files: { open: true } })
})
})
describe('derived atoms', () => {
it('$paneOpen reflects the pane state', () => {
const open$ = $paneOpen('files')
expect(open$.get()).toBe(false)
ensurePaneRegistered('files', { open: true })
expect(open$.get()).toBe(true)
setPaneOpen('files', false)
expect(open$.get()).toBe(false)
})
it('$paneWidthOverride reflects the width', () => {
const width$ = $paneWidthOverride('files')
expect(width$.get()).toBeUndefined()
ensurePaneRegistered('files', { open: true })
setPaneWidthOverride('files', 240)
expect(width$.get()).toBe(240)
})
it('$paneOpen returns the same atom instance for repeated calls', () => {
expect($paneOpen('files')).toBe($paneOpen('files'))
})
})
})

View file

@ -0,0 +1,118 @@
import { atom, computed, type ReadableAtom } from 'nanostores'
export interface PaneStateSnapshot {
open: boolean
widthOverride?: number
}
export interface PaneRegisterDefaults {
open: boolean
widthOverride?: number
}
const STORAGE_KEY = 'hermes.desktop.paneStates.v1'
function isSnapshot(value: unknown): value is PaneStateSnapshot {
if (!value || typeof value !== 'object') {return false}
const r = value as Record<string, unknown>
if (typeof r.open !== 'boolean') {return false}
return r.widthOverride === undefined || (typeof r.widthOverride === 'number' && Number.isFinite(r.widthOverride))
}
function load(): Record<string, PaneStateSnapshot> {
if (typeof window === 'undefined') {return {}}
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw) as unknown
if (parsed && typeof parsed === 'object') {
const out: Record<string, PaneStateSnapshot> = {}
for (const [id, value] of Object.entries(parsed as Record<string, unknown>)) {
if (isSnapshot(value)) {out[id] = { open: value.open, widthOverride: value.widthOverride }}
}
return out
}
}
} catch {
// Treat unparseable persisted state as missing.
}
return {}
}
// widthOverride is in-memory only — phase 2 can add per-pane persistWidth opt-in.
function persist(states: Record<string, PaneStateSnapshot>) {
if (typeof window === 'undefined') {return}
const minimal: Record<string, { open: boolean }> = {}
for (const [id, s] of Object.entries(states)) {minimal[id] = { open: s.open }}
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(minimal))
} catch {
// Storage failures are nonfatal.
}
}
export const $paneStates = atom<Record<string, PaneStateSnapshot>>(load())
$paneStates.subscribe(persist)
// Cached per-pane derived atoms keep useStore subscriptions referentially stable.
function memoized<T>(cache: Map<string, ReadableAtom<T>>, id: string, selector: (s: PaneStateSnapshot | undefined) => T) {
let cached = cache.get(id)
if (!cached) {
cached = computed($paneStates, states => selector(states[id]))
cache.set(id, cached)
}
return cached
}
const openCache = new Map<string, ReadableAtom<boolean>>()
const stateCache = new Map<string, ReadableAtom<PaneStateSnapshot | undefined>>()
const widthCache = new Map<string, ReadableAtom<number | undefined>>()
export const $paneOpen = (id: string) => memoized(openCache, id, s => s?.open ?? false)
export const $paneState = (id: string) => memoized(stateCache, id, s => s)
export const $paneWidthOverride = (id: string) => memoized(widthCache, id, s => s?.widthOverride)
export function ensurePaneRegistered(id: string, defaults: PaneRegisterDefaults) {
const current = $paneStates.get()
if (current[id] !== undefined) {return}
$paneStates.set({ ...current, [id]: { open: defaults.open, widthOverride: defaults.widthOverride } })
}
export function setPaneOpen(id: string, open: boolean) {
const current = $paneStates.get()
const existing = current[id]
if (existing?.open === open) {return}
$paneStates.set({ ...current, [id]: { open, widthOverride: existing?.widthOverride } })
}
export function togglePane(id: string) {
const current = $paneStates.get()
const existing = current[id]
$paneStates.set({ ...current, [id]: { open: !(existing?.open ?? false), widthOverride: existing?.widthOverride } })
}
export function setPaneWidthOverride(id: string, width: number | undefined) {
const current = $paneStates.get()
const existing = current[id] ?? { open: false }
if (existing.widthOverride === width) {return}
$paneStates.set({ ...current, [id]: { open: existing.open, widthOverride: width } })
}
export const clearPaneWidthOverride = (id: string) => setPaneWidthOverride(id, undefined)
export const getPaneStateSnapshot = (id: string) => $paneStates.get()[id]

View file

@ -0,0 +1,131 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
$filePreviewTarget,
$previewServerRestart,
$previewServerRestartStatus,
$previewTarget,
$sessionPreviewRegistry,
beginPreviewServerRestart,
clearSessionPreviewRegistry,
dismissFilePreviewTarget,
dismissPreviewTarget,
getSessionPreviewRecord,
type PreviewTarget,
progressPreviewServerRestart,
setCurrentSessionPreviewTarget
} from './preview'
import { $activeSessionId, $selectedStoredSessionId } from './session'
function previewTarget(source: string): PreviewTarget {
return {
kind: 'file',
label: source,
path: source,
previewKind: 'html',
source,
url: `file://${source}`
}
}
function withRenderMode(target: PreviewTarget, renderMode: PreviewTarget['renderMode']): PreviewTarget {
return { ...target, renderMode }
}
describe('preview store', () => {
beforeEach(() => {
$previewServerRestart.set(null)
$activeSessionId.set('session-1')
$selectedStoredSessionId.set(null)
window.localStorage.clear()
clearSessionPreviewRegistry()
})
afterEach(() => {
$previewServerRestart.set(null)
$activeSessionId.set(null)
$selectedStoredSessionId.set(null)
window.localStorage.clear()
clearSessionPreviewRegistry()
})
it('does not notify status subscribers for restart progress text', () => {
const statuses: string[] = []
const unsubscribe = $previewServerRestartStatus.subscribe(status => statuses.push(status))
beginPreviewServerRestart('task-1', 'http://localhost:5174')
progressPreviewServerRestart('task-1', 'first line')
progressPreviewServerRestart('task-1', 'second line')
unsubscribe()
expect(statuses).toEqual(['idle', 'running'])
})
it('persists registered previews and dismissal per session', () => {
const target = previewTarget('/work/demo.html')
setCurrentSessionPreviewTarget(target, 'tool-result')
expect($previewTarget.get()).toEqual(withRenderMode(target, 'preview'))
expect(getSessionPreviewRecord('session-1')?.normalized).toEqual(withRenderMode(target, 'preview'))
expect(window.localStorage.getItem('hermes.desktop.sessionPreviews.v1')).toContain('/work/demo.html')
dismissPreviewTarget()
expect($previewTarget.get()).toBeNull()
expect(getSessionPreviewRecord('session-1')).toBeNull()
expect($sessionPreviewRegistry.get()['session-1']?.[0]?.dismissedAt).toEqual(expect.any(Number))
setCurrentSessionPreviewTarget(target, 'tool-result')
expect(getSessionPreviewRecord('session-1')?.dismissedAt).toBeUndefined()
})
it('replaces the session preview instead of keeping a back stack', () => {
const first = previewTarget('/work/first.html')
const second = previewTarget('/work/second.html')
setCurrentSessionPreviewTarget(first, 'tool-result')
setCurrentSessionPreviewTarget(second, 'tool-result')
expect($sessionPreviewRegistry.get()['session-1']).toHaveLength(1)
expect(getSessionPreviewRecord('session-1')?.normalized).toEqual(withRenderMode(second, 'preview'))
dismissPreviewTarget()
expect($previewTarget.get()).toBeNull()
expect(getSessionPreviewRecord('session-1')).toBeNull()
expect($sessionPreviewRegistry.get()['session-1']?.map(record => record.normalized.url)).toEqual([
'file:///work/second.html'
])
})
it('keeps file inspection separate from live preview', () => {
const target = previewTarget('/work/demo.html')
const preview = previewTarget('/work/live.html')
setCurrentSessionPreviewTarget(preview, 'tool-result')
setCurrentSessionPreviewTarget(target, 'manual')
expect($filePreviewTarget.get()).toEqual(withRenderMode(target, 'source'))
expect($previewTarget.get()).toEqual(withRenderMode(preview, 'preview'))
expect(getSessionPreviewRecord('session-1')?.normalized).toEqual(withRenderMode(preview, 'preview'))
dismissFilePreviewTarget()
expect($filePreviewTarget.get()).toBeNull()
expect($previewTarget.get()).toEqual(withRenderMode(preview, 'preview'))
})
it('clears file inspection when a live preview opens', () => {
const file = previewTarget('/work/file.html')
const live = previewTarget('/work/live.html')
setCurrentSessionPreviewTarget(file, 'manual')
setCurrentSessionPreviewTarget(live, 'tool-result')
expect($filePreviewTarget.get()).toBeNull()
expect($previewTarget.get()).toEqual(withRenderMode(live, 'preview'))
})
})

View file

@ -1,8 +1,18 @@
import { atom } from 'nanostores'
import { atom, computed } from 'nanostores'
import { $activeSessionId, $selectedStoredSessionId } from './session'
export interface PreviewTarget {
binary?: boolean
byteSize?: number
kind: 'file' | 'url'
label: string
large?: boolean
language?: string
mimeType?: string
path?: string
previewKind?: 'binary' | 'html' | 'image' | 'text'
renderMode?: 'preview' | 'source'
source: string
url: string
}
@ -14,9 +24,33 @@ export interface PreviewServerRestart {
url: string
}
export type PreviewRecordSource = 'explicit-link' | 'file-browser' | 'manual' | 'tool-result'
export interface SessionPreviewRecord {
autoOpen?: boolean
createdAt: number
dismissedAt?: number
id: string
normalized: PreviewTarget
sessionId: string
source: PreviewRecordSource
target: string
}
type SessionPreviewRegistry = Record<string, SessionPreviewRecord[]>
const REGISTRY_STORAGE_KEY = 'hermes.desktop.sessionPreviews.v1'
const MAX_RECORDS_PER_SESSION = 1
const MAX_SESSIONS = 120
export const $previewTarget = atom<PreviewTarget | null>(null)
export const $filePreviewTarget = atom<PreviewTarget | null>(null)
export const $previewReloadRequest = atom(0)
export const $previewServerRestart = atom<PreviewServerRestart | null>(null)
export const $previewServerRestartStatus = computed($previewServerRestart, restart => restart?.status ?? 'idle')
export const $sessionPreviewRegistry = atom<SessionPreviewRegistry>(loadSessionPreviewRegistry())
$sessionPreviewRegistry.subscribe(persistSessionPreviewRegistry)
function isSamePreviewTarget(a: PreviewTarget | null, b: PreviewTarget | null): boolean {
if (a === b) {
@ -27,7 +61,7 @@ function isSamePreviewTarget(a: PreviewTarget | null, b: PreviewTarget | null):
return false
}
return a.kind === b.kind && a.label === b.label && a.source === b.source && a.url === b.url
return a.kind === b.kind && a.label === b.label && a.renderMode === b.renderMode && a.source === b.source && a.url === b.url
}
export function setPreviewTarget(target: PreviewTarget | null) {
@ -38,6 +72,252 @@ export function setPreviewTarget(target: PreviewTarget | null) {
$previewTarget.set(target)
}
export function setFilePreviewTarget(target: PreviewTarget | null) {
if (isSamePreviewTarget($filePreviewTarget.get(), target)) {
return
}
$filePreviewTarget.set(target)
}
function isPreviewTarget(value: unknown): value is PreviewTarget {
if (!value || typeof value !== 'object') {return false}
const r = value as Record<string, unknown>
return (
(r.kind === 'file' || r.kind === 'url') &&
typeof r.label === 'string' &&
typeof r.source === 'string' &&
typeof r.url === 'string'
)
}
function isPreviewRecord(value: unknown): value is SessionPreviewRecord {
if (!value || typeof value !== 'object') {return false}
const r = value as Record<string, unknown>
return (
typeof r.createdAt === 'number' &&
typeof r.id === 'string' &&
isPreviewTarget(r.normalized) &&
typeof r.sessionId === 'string' &&
['explicit-link', 'file-browser', 'manual', 'tool-result'].includes(String(r.source)) &&
typeof r.target === 'string' &&
(r.dismissedAt === undefined || typeof r.dismissedAt === 'number')
)
}
function loadSessionPreviewRegistry(): SessionPreviewRegistry {
if (typeof window === 'undefined') {return {}}
try {
const raw = window.localStorage.getItem(REGISTRY_STORAGE_KEY)
if (!raw) {return {}}
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object') {return {}}
const out: SessionPreviewRegistry = {}
for (const [sessionId, records] of Object.entries(parsed as Record<string, unknown>)) {
if (!Array.isArray(records)) {continue}
const valid = records.filter(isPreviewRecord).slice(0, MAX_RECORDS_PER_SESSION)
if (valid.length > 0) {out[sessionId] = valid}
}
return pruneRegistry(out)
} catch {
return {}
}
}
function persistSessionPreviewRegistry(registry: SessionPreviewRegistry) {
if (typeof window === 'undefined') {return}
try {
window.localStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(pruneRegistry(registry)))
} catch {
// Session previews are a desktop convenience; storage failures are nonfatal.
}
}
function pruneRegistry(registry: SessionPreviewRegistry): SessionPreviewRegistry {
const entries = Object.entries(registry)
.map(([sessionId, records]) => [
sessionId,
[...records].sort((a, b) => b.createdAt - a.createdAt).slice(0, MAX_RECORDS_PER_SESSION)
] as const)
.filter(([, records]) => records.length > 0)
.sort(([, a], [, b]) => (b[0]?.createdAt ?? 0) - (a[0]?.createdAt ?? 0))
.slice(0, MAX_SESSIONS)
return Object.fromEntries(entries)
}
function currentPreviewSessionId(): string {
return $selectedStoredSessionId.get() || $activeSessionId.get() || ''
}
function recordId(sessionId: string, target: PreviewTarget): string {
return `${sessionId}:${target.url}`
}
export function registerSessionPreview(
sessionId: string | null | undefined,
target: PreviewTarget,
source: PreviewRecordSource,
rawTarget = target.source
): SessionPreviewRecord | null {
const id = sessionId?.trim()
if (!id) {return null}
const current = $sessionPreviewRegistry.get()
const now = Date.now()
const records = current[id] ?? []
const existing = records.find(record => record.normalized.url === target.url)
const normalized = previewTargetForSource(target, source)
const nextRecord: SessionPreviewRecord = {
autoOpen: true,
createdAt: now,
id: existing?.id || recordId(id, target),
normalized,
sessionId: id,
source,
target: rawTarget || target.source
}
$sessionPreviewRegistry.set(
pruneRegistry({
...current,
[id]: [nextRecord]
})
)
return nextRecord
}
function previewTargetForSource(target: PreviewTarget, source: PreviewRecordSource): PreviewTarget {
if (target.kind !== 'file' || target.previewKind !== 'html') {
return target
}
return {
...target,
renderMode: source === 'file-browser' || source === 'manual' ? 'source' : 'preview'
}
}
function shouldOpenAsFilePreview(target: PreviewTarget, source: PreviewRecordSource): boolean {
return target.kind === 'file' && (source === 'file-browser' || source === 'manual')
}
export function registerCurrentSessionPreview(
target: PreviewTarget,
source: PreviewRecordSource,
rawTarget = target.source
): SessionPreviewRecord | null {
return registerSessionPreview(currentPreviewSessionId(), target, source, rawTarget)
}
export function setSessionPreviewTarget(
sessionId: string | null | undefined,
target: PreviewTarget,
source: PreviewRecordSource,
rawTarget = target.source
): SessionPreviewRecord | null {
if (shouldOpenAsFilePreview(target, source)) {
setFilePreviewTarget(previewTargetForSource(target, source))
return null
}
const record = registerSessionPreview(sessionId, target, source, rawTarget)
setFilePreviewTarget(null)
setPreviewTarget(record?.normalized ?? previewTargetForSource(target, source))
return record
}
export function setCurrentSessionPreviewTarget(
target: PreviewTarget,
source: PreviewRecordSource,
rawTarget = target.source
): SessionPreviewRecord | null {
if (shouldOpenAsFilePreview(target, source)) {
setFilePreviewTarget(previewTargetForSource(target, source))
return null
}
const record = registerCurrentSessionPreview(target, source, rawTarget)
setFilePreviewTarget(null)
setPreviewTarget(record?.normalized ?? previewTargetForSource(target, source))
return record
}
export function getSessionPreviewRecord(sessionId: string | null | undefined): SessionPreviewRecord | null {
const id = sessionId?.trim()
if (!id) {return null}
return $sessionPreviewRegistry.get()[id]?.find(record => !record.dismissedAt && record.autoOpen !== false) ?? null
}
export function dismissSessionPreview(sessionId: string | null | undefined, url?: string) {
const id = sessionId?.trim()
if (!id) {return}
const current = $sessionPreviewRegistry.get()
const records = current[id]
if (!records?.length) {return}
const now = Date.now()
const targetUrl = url || records.find(record => !record.dismissedAt)?.normalized.url
if (!targetUrl) {return}
// The preview rail is a single active file, not a back stack. Dismissing the
// current preview should leave the rail closed instead of revealing an older
// record for the same session.
const dismissedRecords = records.map(record => ({
...record,
autoOpen: false,
dismissedAt: now
}))
$sessionPreviewRegistry.set({
...current,
[id]: dismissedRecords
})
}
/** User clicked the close X — clear the target and persist dismissal for the current session. */
export function dismissPreviewTarget() {
const current = $previewTarget.get()
if (current?.url) {
dismissSessionPreview(currentPreviewSessionId(), current.url)
}
$previewTarget.set(null)
}
export function dismissFilePreviewTarget() {
setFilePreviewTarget(null)
}
export function clearSessionPreviewRegistry() {
$sessionPreviewRegistry.set({})
setPreviewTarget(null)
setFilePreviewTarget(null)
}
export function requestPreviewReload() {
$previewReloadRequest.set($previewReloadRequest.get() + 1)
}

View file

@ -4,12 +4,60 @@ import { persistBoolean, storedBoolean } from '@/lib/storage'
export type ToolViewMode = 'product' | 'technical'
type ToolDisclosureStates = Record<string, boolean>
const TOOL_VIEW_TECHNICAL_STORAGE_KEY = 'hermes.desktop.toolView.technical'
const TOOL_DISCLOSURE_STORAGE_KEY = 'hermes.desktop.toolDisclosure.v1'
const MAX_DISCLOSURE_STATES = 240
export const $toolViewMode = atom<ToolViewMode>(storedBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, false) ? 'technical' : 'product')
export const $toolDisclosureStates = atom<ToolDisclosureStates>(loadToolDisclosureStates())
$toolViewMode.subscribe(mode => persistBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, mode === 'technical'))
$toolDisclosureStates.subscribe(persistToolDisclosureStates)
export function setToolViewMode(mode: ToolViewMode) {
$toolViewMode.set(mode)
}
function loadToolDisclosureStates(): ToolDisclosureStates {
if (typeof window === 'undefined') {return {}}
try {
const raw = window.localStorage.getItem(TOOL_DISCLOSURE_STORAGE_KEY)
if (!raw) {return {}}
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {return {}}
return Object.fromEntries(
Object.entries(parsed as Record<string, unknown>)
.filter((entry): entry is [string, boolean] => typeof entry[0] === 'string' && typeof entry[1] === 'boolean')
.slice(-MAX_DISCLOSURE_STATES)
)
} catch {
return {}
}
}
function persistToolDisclosureStates(states: ToolDisclosureStates) {
if (typeof window === 'undefined') {return}
try {
const entries = Object.entries(states).slice(-MAX_DISCLOSURE_STATES)
window.localStorage.setItem(TOOL_DISCLOSURE_STORAGE_KEY, JSON.stringify(Object.fromEntries(entries)))
} catch {
// Tool disclosure is a local UI preference; ignore storage failures.
}
}
export function setToolDisclosureOpen(id: string, open: boolean) {
if (!id) {return}
const current = $toolDisclosureStates.get()
if (current[id] === open) {return}
$toolDisclosureStates.set({ ...current, [id]: open })
}

View file

@ -83,7 +83,7 @@
:root {
color-scheme: dark;
/* Fallback values (Claude Light theme) — ThemeProvider overwrites on mount. */
/* Default visual tokens. ThemeProvider only overrides colors and font families. */
--dt-background: #f7f7f7;
--dt-foreground: #242424;
--dt-card: #ffffff;
@ -110,7 +110,7 @@
--dt-font-sans: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
--dt-font-mono: 'SF Mono', ui-monospace, 'Cascadia Code', Menlo, Consolas, monospace;
--dt-base-size: 0.9375rem;
--dt-base-size: 0.875rem;
--dt-line-height: 1.55;
--dt-letter-spacing: 0;
--dt-spacing-mul: 1;
@ -175,7 +175,7 @@
}
html {
font-size: var(--dt-base-size, 0.9375rem);
font-size: var(--dt-base-size, 0.875rem);
}
body {
@ -282,7 +282,7 @@ canvas {
* react-virtuoso) with stable item sizing not a CSS heuristic.
*/
.aui-md img {
[data-slot='aui_assistant-message-content'] .aui-md img {
display: block;
width: auto;
height: auto;
@ -296,11 +296,11 @@ canvas {
0 0.625rem 1.5rem color-mix(in srgb, #000 5%, transparent);
}
.aui-md [data-slot='aui_markdown-image'] {
[data-slot='aui_assistant-message-content'] .aui-md [data-slot='aui_markdown-image'] {
max-width: min(100%, var(--image-preview-max-width));
}
.aui-md {
[data-slot='aui_assistant-message-content'] .aui-md {
overflow-wrap: anywhere;
}
@ -324,17 +324,62 @@ canvas {
scroll-behavior: auto;
}
.aui-md a,
.aui-md code {
[data-slot='aui_assistant-message-content'] .aui-md a {
overflow-wrap: anywhere;
word-break: break-word;
}
.aui-md p:has(> img:only-child) {
/**
* Inline `code`: monospace pill with a subtle background. Scoped to plain
* inline code only fenced blocks are handled by the SyntaxHighlighter
* component and live inside `[data-streamdown='code-block']`, so we explicitly
* unset there to keep the highlighter rendering its own chrome.
*/
[data-slot='aui_assistant-message-content'] .aui-md code {
/* Inline (not inside a fenced code-block). Use plain inline rendering so
* the surrounding `<p>` (wrap-anywhere) can break long paths/URLs at any
* character. inline-block + white-space: nowrap was leaking long paths
* past the chat column under any sibling pane width. */
display: inline;
max-width: 100%;
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace;
font-size: 0.86em;
padding: 0.01rem 0.16rem;
border-radius: 0.2rem;
background: #f4f4f5;
color: #be185d;
border: 0;
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
}
:root.dark [data-slot='aui_assistant-message-content'] .aui-md code {
background: #27272a;
color: #f9a8d4;
border-color: #831843;
}
[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'] code {
max-width: none;
font-family: inherit;
font-size: inherit;
padding: 0;
border-radius: 0;
background: transparent;
color: inherit;
overflow-x: visible;
overflow-wrap: inherit;
vertical-align: baseline;
word-break: inherit;
white-space: inherit;
}
[data-slot='aui_assistant-message-content'] .aui-md p:has(> img:only-child) {
margin-block: 0.75rem;
}
.aui-md p:has(> [data-slot='aui_markdown-image']:only-child) {
[data-slot='aui_assistant-message-content'] .aui-md p:has(> [data-slot='aui_markdown-image']:only-child) {
margin-block: 0.75rem;
}
@ -346,14 +391,14 @@ canvas {
* section breaks. Margin collapse picks the larger neighbor producing
* "more above headings, less below" without per-pair rules.
*/
.aui-md p,
.aui-md ul,
.aui-md ol,
.aui-md blockquote,
.aui-md pre,
.aui-md table,
.aui-md [data-streamdown='code-block'],
.aui-md div:has(> table) {
[data-slot='aui_assistant-message-content'] .aui-md p,
[data-slot='aui_assistant-message-content'] .aui-md ul,
[data-slot='aui_assistant-message-content'] .aui-md ol,
[data-slot='aui_assistant-message-content'] .aui-md blockquote,
[data-slot='aui_assistant-message-content'] .aui-md pre,
[data-slot='aui_assistant-message-content'] .aui-md table,
[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'],
[data-slot='aui_assistant-message-content'] .aui-md div:has(> table) {
margin: 0 0 1rem;
}
@ -361,8 +406,7 @@ canvas {
* with `flex flex-col gap-2 p-2 border bg-sidebar rounded-xl my-4`. Our own
* CodeHeader + SyntaxHighlighter already supply the chrome, so undo the
* library's wrapper to keep the header flush with the code body. */
.aui-md [data-streamdown='code-block'],
[data-streamdown='code-block'] {
[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'] {
padding: 0 !important;
gap: 0 !important;
border: 0 !important;
@ -371,67 +415,106 @@ canvas {
margin: 1rem 0 !important;
}
.aui-md [data-streamdown='code-block'] > *,
[data-streamdown='code-block'] > * {
[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'] > * {
margin: 0 !important;
}
.aui-md h1 {
[data-slot='aui_assistant-message-content'] .aui-md h1 {
margin: 1.6rem 0 0.55rem;
}
.aui-md h2 {
[data-slot='aui_assistant-message-content'] .aui-md h2 {
margin: 1.4rem 0 0.5rem;
}
.aui-md h3 {
[data-slot='aui_assistant-message-content'] .aui-md h3 {
margin: 1.15rem 0 0.45rem;
}
.aui-md h4 {
[data-slot='aui_assistant-message-content'] .aui-md h4 {
margin: 0.95rem 0 0.4rem;
}
.aui-md hr {
[data-slot='aui_assistant-message-content'] .aui-md hr {
margin: 1.5rem 0;
}
/* `padding-left` keeps outside-position list markers in the gutter. */
.aui-md ul,
.aui-md ol {
[data-slot='aui_assistant-message-content'] .aui-md ul,
[data-slot='aui_assistant-message-content'] .aui-md ol {
padding-left: 1.75rem;
}
/* Tight inter-bullet gap; loose items override below. */
.aui-md li + li {
[data-slot='aui_assistant-message-content'] .aui-md li + li {
margin-top: 0.375rem;
}
/* Inside a bullet, hug nested blocks to the lead text. */
.aui-md li > p {
[data-slot='aui_assistant-message-content'] .aui-md li > p {
margin-bottom: 0.4rem;
}
.aui-md li > ul,
.aui-md li > ol {
[data-slot='aui_assistant-message-content'] .aui-md li > ul,
[data-slot='aui_assistant-message-content'] .aui-md li > ol {
margin-top: 0.4rem;
}
/* Loose list items (CommonMark wraps each in <p> when any sibling has a
block child) need visible separation the tight rhythm collapses
against a trailing heavy block like a code fence. */
.aui-md li:has(> p) {
[data-slot='aui_assistant-message-content'] .aui-md li:has(> p) {
margin-bottom: 0.85rem;
}
.aui-md li:has(> p):last-child {
[data-slot='aui_assistant-message-content'] .aui-md li:has(> p):last-child {
margin-bottom: 0;
}
/* Trim edge margins at the container, list items, and blockquotes. */
.aui-md > :first-child,
.aui-md > * > :first-child,
.aui-md li > :first-child,
.aui-md blockquote > :first-child {
[data-slot='aui_assistant-message-content'] .aui-md > :first-child,
[data-slot='aui_assistant-message-content'] .aui-md > * > :first-child,
[data-slot='aui_assistant-message-content'] .aui-md li > :first-child,
[data-slot='aui_assistant-message-content'] .aui-md blockquote > :first-child {
margin-top: 0;
}
.aui-md > :last-child,
.aui-md > * > :last-child,
.aui-md li > :last-child,
.aui-md blockquote > :last-child {
[data-slot='aui_assistant-message-content'] .aui-md > :last-child,
[data-slot='aui_assistant-message-content'] .aui-md > * > :last-child,
[data-slot='aui_assistant-message-content'] .aui-md li > :last-child,
[data-slot='aui_assistant-message-content'] .aui-md blockquote > :last-child {
margin-bottom: 0;
}
/**
* Intelligent spacing around inline tool/thinking blocks inside an
* assistant message.
*
* Two cases to balance:
* 1. Two tool/thinking rows next to each other keep them tight so a
* multi-step turn reads as one continuous activity column.
* 2. A tool/thinking row next to prose (markdown text) give it real
* breathing room so the activity feels like a separate panel and
* doesn't crowd the paragraph above or below.
*
* Markdown text is wrapped in `.aui-md`; tool/thinking rows expose
* `data-slot="tool-block"`. Sibling combinators handle the rest.
*/
[data-slot='tool-block'] + [data-slot='tool-block'] {
margin-top: 0.25rem;
}
/**
* When the previous tool/thinking block is expanded (showing its content),
* give the next block visible breathing room so they don't read as one
* continuous chunk. The :has() guard keeps the tight rhythm between two
* collapsed rows.
*/
[data-slot='tool-block']:has(> :nth-child(2)) + [data-slot='tool-block'] {
margin-top: 0.625rem;
}
[data-slot='aui_assistant-message-content'] [data-slot='tool-block'] + .aui-md,
[data-slot='aui_assistant-message-content'] .aui-md + [data-slot='tool-block'] {
margin-top: 0.875rem;
}
/* When the assistant message starts with a tool block, don't pile padding
* above it the message header already provides spacing. */
[data-slot='aui_assistant-message-content'] > [data-slot='tool-block']:first-child {
margin-top: 0;
}

View file

@ -2,8 +2,8 @@
* Desktop theme context.
*
* Applies the active theme as CSS custom properties on :root, making every
* Tailwind utility that references a `--color-*` / `--radius` / `--font-*`
* variable pick up the change automatically.
* Tailwind utility that references a color or font-family token pick up the
* change automatically.
*
* Persists mode (light/dark/system) and skin separately. Mode controls
* brightness; skin controls accent family.
@ -16,13 +16,12 @@ import { matchesQuery, useMediaQuery } from '@/hooks/use-media-query'
import {
BUILTIN_THEME_LIST,
BUILTIN_THEMES,
DEFAULT_LAYOUT,
DEFAULT_TYPOGRAPHY,
defaultTheme,
nousLightTheme,
nousTheme
} from './presets'
import type { DesktopTheme, DesktopThemeColors, ThemeDensity } from './types'
import type { DesktopTheme, DesktopThemeColors } from './types'
const STORAGE_KEY = 'hermes-desktop-theme-v2' // Stores skin name.
const MODE_KEY = 'hermes-desktop-mode-v1'
@ -30,12 +29,6 @@ const DEFAULT_SKIN = 'default'
export type ThemeMode = 'light' | 'dark' | 'system'
const DENSITY_MULTIPLIERS: Record<ThemeDensity, string> = {
compact: '0.85',
comfortable: '1',
spacious: '1.2'
}
const INJECTED_FONT_URLS = new Set<string>()
const SKIN_THEME_LIST = BUILTIN_THEME_LIST.filter(t => t.name !== 'nous-light')
@ -180,8 +173,7 @@ function deriveTheme(skinName: string, mode: 'light' | 'dark'): DesktopTheme {
label: `${isDefault ? 'Hermes' : seed.label} ${mode === 'light' ? 'Light' : 'Dark'}`,
description: `${seed.label} ${mode} palette`,
colors: mode === 'light' ? lightColors(seed, skinName) : darkColors(seed, skinName),
typography: fontOnly(seed),
layout: undefined
typography: fontOnly(seed)
}
}
@ -220,7 +212,6 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
const root = document.documentElement
const typo = { ...DEFAULT_TYPOGRAPHY, ...NOUS_FONT_FAMILY_FALLBACK, ...theme.typography }
const layout = { ...DEFAULT_LAYOUT, ...theme.layout }
const c = theme.colors
const rendered = renderedModeFor(theme.colors, mode)
@ -253,21 +244,14 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
'--dt-sidebar-border': c.sidebarBorder ?? c.border,
'--dt-user-bubble': c.userBubble ?? c.muted,
'--dt-user-bubble-border': c.userBubbleBorder ?? c.border,
'--radius': layout.radius,
'--dt-spacing-mul': DENSITY_MULTIPLIERS[layout.density] ?? '1',
'--dt-font-sans': typo.fontSans,
'--dt-font-mono': typo.fontMono,
'--dt-base-size': typo.baseSize,
'--dt-line-height': typo.lineHeight,
'--dt-letter-spacing': typo.letterSpacing
'--dt-font-mono': typo.fontMono
}
for (const [k, v] of Object.entries(vars)) {
root.style.setProperty(k, v)
}
root.style.setProperty('font-size', 'var(--dt-base-size)')
if (typo.fontUrl && !INJECTED_FONT_URLS.has(typo.fontUrl)) {
const link = document.createElement('link')
link.rel = 'stylesheet'

View file

@ -1,3 +1,3 @@
export { ThemeProvider, useSyncThemeFromBackend, useTheme } from './context'
export { BUILTIN_THEME_LIST, BUILTIN_THEMES } from './presets'
export type { DesktopTheme, DesktopThemeColors, DesktopThemeLayout, DesktopThemeTypography } from './types'
export type { DesktopTheme, DesktopThemeColors, DesktopThemeTypography } from './types'

View file

@ -7,7 +7,7 @@
* Add new themes here no code changes needed elsewhere.
*/
import type { DesktopTheme, DesktopThemeLayout, DesktopThemeTypography } from './types'
import type { DesktopTheme, DesktopThemeTypography } from './types'
// ---------------------------------------------------------------------------
// Shared defaults
@ -21,15 +21,7 @@ const SYSTEM_MONO =
export const DEFAULT_TYPOGRAPHY: DesktopThemeTypography = {
fontSans: SYSTEM_SANS,
fontMono: SYSTEM_MONO,
baseSize: '0.9375rem',
lineHeight: '1.55',
letterSpacing: '0'
}
export const DEFAULT_LAYOUT: DesktopThemeLayout = {
radius: '0.75rem',
density: 'comfortable'
fontMono: SYSTEM_MONO
}
// ---------------------------------------------------------------------------
@ -122,9 +114,6 @@ export const nousTheme: DesktopTheme = {
fontSans: SYSTEM_SANS,
fontMono: `"Courier Prime", ${SYSTEM_MONO}`,
fontUrl: 'https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&display=swap'
},
layout: {
radius: '0.25rem'
}
}
@ -192,11 +181,7 @@ export const midnightTheme: DesktopTheme = {
},
typography: {
fontMono: `"JetBrains Mono", ${SYSTEM_MONO}`,
fontUrl: 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap',
letterSpacing: '-0.005em'
},
layout: {
radius: '0.875rem'
fontUrl: 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap'
}
}
@ -233,9 +218,6 @@ export const emberTheme: DesktopTheme = {
typography: {
fontMono: `"IBM Plex Mono", ${SYSTEM_MONO}`,
fontUrl: 'https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;700&display=swap'
},
layout: {
radius: '0.375rem'
}
}
@ -268,9 +250,6 @@ export const monoTheme: DesktopTheme = {
sidebarBorder: '#202020',
userBubble: '#1a1a1a',
userBubbleBorder: '#363636'
},
layout: {
radius: '0.375rem'
}
}
@ -306,11 +285,7 @@ export const cyberpunkTheme: DesktopTheme = {
},
typography: {
fontMono: `"Courier New", Courier, monospace`,
fontSans: `"Courier New", Courier, monospace`,
letterSpacing: '0.02em'
},
layout: {
radius: '0'
fontSans: `"Courier New", Courier, monospace`
}
}

View file

@ -1,10 +1,12 @@
/**
* Desktop app theme model.
*
* Three orthogonal layers:
* 1. `colors` all Tailwind token values written directly to CSS vars.
* 2. `typography` font families, base size, line-height, letter-spacing.
* 3. `layout` corner radius, spacing density.
* Two theme layers:
* 1. `colors` Tailwind color token values written directly to CSS vars.
* 2. `typography` font families and optional font stylesheet URL.
*
* Layout, sizing, spacing, radius, line-height, and letter-spacing live in
* `styles.css` so CSS remains the source of truth for app geometry.
*
* Every field except `name`, `label`, and `description` is optional
* missing values fall back to the `default` theme.
@ -66,21 +68,6 @@ export interface DesktopThemeTypography {
fontMono: string
/** Optional Google/Bunny/self-hosted font stylesheet URL. */
fontUrl?: string
/** Root font size: `"0.875rem"`, `"0.9375rem"`, `"1rem"`. */
baseSize: string
/** Default line height: `"1.5"`, `"1.6"`. */
lineHeight: string
/** Default letter spacing: `"0"`, `"-0.01em"`. */
letterSpacing: string
}
export type ThemeDensity = 'compact' | 'comfortable' | 'spacious'
export interface DesktopThemeLayout {
/** Corner-radius token: `"0"`, `"0.5rem"`, `"1rem"`. */
radius: string
/** Spacing multiplier. */
density: ThemeDensity
}
export interface DesktopTheme {
@ -89,5 +76,4 @@ export interface DesktopTheme {
description: string
colors: DesktopThemeColors
typography?: Partial<DesktopThemeTypography>
layout?: Partial<DesktopThemeLayout>
}

View file

@ -0,0 +1,46 @@
import { useCallback } from 'react'
import { useTheme } from './context'
const ALIASES: Record<string, string> = {
ares: 'ember',
hermes: 'default'
}
export function useSkinCommand() {
const { availableThemes, setTheme, themeName } = useTheme()
return useCallback(
(rawArg: string) => {
const arg = rawArg.trim()
if (!availableThemes.length) {return 'No desktop themes are available.'}
const activeIndex = Math.max(0, availableThemes.findIndex(t => t.name === themeName))
if (!arg || arg === 'next') {
const next = availableThemes[(activeIndex + 1) % availableThemes.length]
setTheme(next.name)
return `Desktop theme switched to ${next.label}.`
}
if (arg === 'list' || arg === 'ls' || arg === 'status') {
const rows = availableThemes.map(t => `${t.name === themeName ? '*' : ' '} ${t.name.padEnd(10)} ${t.label}`)
return ['Desktop themes:', ...rows, '', 'Use /skin <name>, or /skin to cycle.'].join('\n')
}
const normalized = arg.toLowerCase()
const targetName = ALIASES[normalized] || normalized
const target = availableThemes.find(t => t.name.toLowerCase() === targetName || t.label.toLowerCase() === normalized)
if (!target) {return `Unknown desktop theme: ${arg}\nAvailable: ${availableThemes.map(t => t.name).join(', ')}`}
setTheme(target.name)
return `Desktop theme switched to ${target.label}.`
},
[availableThemes, setTheme, themeName]
)
}

View file

@ -2445,6 +2445,28 @@ async def delete_session_endpoint(session_id: str):
db.close()
class SessionRename(BaseModel):
title: str
@app.patch("/api/sessions/{session_id}")
async def rename_session_endpoint(session_id: str, body: SessionRename):
from hermes_state import SessionDB
db = SessionDB()
try:
sid = db.resolve_session_id(session_id) or session_id
try:
ok = db.set_session_title(sid, body.title)
except ValueError as exc:
# Title collision or validation failure (e.g. duplicate title).
raise HTTPException(status_code=409, detail=str(exc))
if not ok:
raise HTTPException(status_code=404, detail="Session not found")
return {"ok": True, "title": db.get_session_title(sid) or ""}
finally:
db.close()
# ---------------------------------------------------------------------------
# Log viewer endpoint
# ---------------------------------------------------------------------------

132
package-lock.json generated
View file

@ -79,15 +79,18 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"ignore": "^7.0.5",
"liquid-glass-react": "^1.1.1",
"lucide-react": "^0.577.0",
"nanostores": "^1.3.0",
"radix-ui": "^1.4.3",
"react": "^19.2.5",
"react-arborist": "^3.5.0",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2",
"react-shiki": "^0.9.3",
"shiki": "^4.0.2",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",
"tw-shimmer": "^0.4.11",
@ -6451,6 +6454,21 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@react-dnd/asap": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz",
"integrity": "sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg=="
},
"node_modules/@react-dnd/invariant": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-2.0.0.tgz",
"integrity": "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw=="
},
"node_modules/@react-dnd/shallowequal": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz",
"integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg=="
},
"node_modules/@react-three/fiber": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.1.tgz",
@ -11352,6 +11370,24 @@
"node": ">=8"
}
},
"node_modules/dnd-core": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.1.tgz",
"integrity": "sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==",
"dependencies": {
"@react-dnd/asap": "^4.0.0",
"@react-dnd/invariant": "^2.0.0",
"redux": "^4.1.1"
}
},
"node_modules/dnd-core/node_modules/redux": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
"dependencies": {
"@babel/runtime": "^7.9.2"
}
},
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@ -12642,7 +12678,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-json-stable-stringify": {
@ -13594,6 +13629,19 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"dependencies": {
"react-is": "^16.7.0"
}
},
"node_modules/hoist-non-react-statics/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/hosted-git-info": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
@ -13864,8 +13912,6 @@
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
@ -15794,6 +15840,11 @@
"node": ">= 0.6"
}
},
"node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
},
"node_modules/merge-deep": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/merge-deep/-/merge-deep-3.0.3.tgz",
@ -18197,6 +18248,22 @@
"node": ">=0.10.0"
}
},
"node_modules/react-arborist": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/react-arborist/-/react-arborist-3.5.0.tgz",
"integrity": "sha512-FdXOICSt7P2h+Pxin1ULN02b4qrXJznNcshgwwWVtuYMLWSJcD245PQ4HOSj/Lr2T1uEegmnEm5Lbns2hUUsqg==",
"dependencies": {
"react-dnd": "^14.0.3",
"react-dnd-html5-backend": "^14.0.3",
"react-window": "^1.8.11",
"redux": "^5.0.0",
"use-sync-external-store": "^1.2.0"
},
"peerDependencies": {
"react": ">= 16.14",
"react-dom": ">= 16.14"
}
},
"node_modules/react-colorful": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz",
@ -18207,6 +18274,43 @@
"react-dom": ">=16.8.0"
}
},
"node_modules/react-dnd": {
"version": "14.0.5",
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz",
"integrity": "sha512-9i1jSgbyVw0ELlEVt/NkCUkxy1hmhJOkePoCH713u75vzHGyXhPDm28oLfc2NMSBjZRM1Y+wRjHXJT3sPrTy+A==",
"dependencies": {
"@react-dnd/invariant": "^2.0.0",
"@react-dnd/shallowequal": "^2.0.0",
"dnd-core": "14.0.1",
"fast-deep-equal": "^3.1.3",
"hoist-non-react-statics": "^3.3.2"
},
"peerDependencies": {
"@types/hoist-non-react-statics": ">= 3.3.1",
"@types/node": ">= 12",
"@types/react": ">= 16",
"react": ">= 16.14"
},
"peerDependenciesMeta": {
"@types/hoist-non-react-statics": {
"optional": true
},
"@types/node": {
"optional": true
},
"@types/react": {
"optional": true
}
}
},
"node_modules/react-dnd-html5-backend": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.1.0.tgz",
"integrity": "sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw==",
"dependencies": {
"dnd-core": "14.0.1"
}
},
"node_modules/react-dom": {
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
@ -18433,6 +18537,22 @@
}
}
},
"node_modules/react-window": {
"version": "1.8.11",
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz",
"integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==",
"dependencies": {
"@babel/runtime": "^7.0.0",
"memoize-one": ">=3.1.1 <6"
},
"engines": {
"node": ">8.0.0"
},
"peerDependencies": {
"react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/read-binary-file-arch": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
@ -18485,6 +18605,11 @@
"node": ">= 6"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@ -19690,7 +19815,6 @@
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/streamdown/-/streamdown-2.5.0.tgz",
"integrity": "sha512-/tTnURfIOxZK/pqJAxsfCvETG/XCJHoWnk3jq9xLcuz6CSpnjjuxSRBTTL4PKGhxiZQf0lqPxGhImdpwcZ2XwA==",
"license": "Apache-2.0",
"dependencies": {
"clsx": "^2.1.1",
"hast-util-to-jsx-runtime": "^2.3.6",

View file

@ -1850,6 +1850,65 @@ def _ephemeral_preview_agent_kwargs(agent, task_id: str) -> dict:
return kwargs
def _preview_restart_history(session: dict, max_messages: int = 24, max_tool_chars: int = 1200) -> list[dict]:
"""Distill the parent session's recent history into a context the
ephemeral preview-restart agent can actually use.
The restart agent has no idea what app the user was building, what
server they ran, what cwd was active, or which port belongs to which
project. Without this, it would take the bare URL + console logs and
guess usually starting the wrong thing.
We keep the last ``max_messages`` messages from the parent session so
the restart agent sees recent user prompts, assistant replies, and
most importantly any terminal/tool calls. Tool result payloads are
truncated so we don't blow the context window with file dumps.
"""
try:
with session["history_lock"]:
history = list(session.get("history", []) or [])
except Exception:
history = list(session.get("history", []) or [])
if not history:
return []
# Anchor on the last user turn so we always include at least the most
# recent request and the assistant/tool work that followed it. Then
# extend backwards up to max_messages so we capture the prior context.
last_user_idx = None
for idx in range(len(history) - 1, -1, -1):
if history[idx].get("role") == "user":
last_user_idx = idx
break
start = max(0, len(history) - max_messages)
if last_user_idx is not None:
start = min(start, last_user_idx)
trimmed: list[dict] = []
for msg in history[start:]:
if not isinstance(msg, dict):
continue
role = msg.get("role")
if role not in ("user", "assistant", "tool", "system"):
continue
copy = {k: v for k, v in msg.items() if k != "reasoning"}
# Truncate heavy tool outputs so a single 50KB file read doesn't
# crowd out the rest of the context.
if role == "tool":
content = copy.get("content")
if isinstance(content, str) and len(content) > max_tool_chars:
copy["content"] = (
content[:max_tool_chars]
+ f"\n... (truncated, original {len(content)} chars)"
)
trimmed.append(copy)
return trimmed
def _preview_tool_result_preview(name: str, result: str) -> str:
try:
data = json.loads(result)
@ -3670,6 +3729,8 @@ def _(rid, params: dict) -> dict:
task_id = f"preview_{uuid.uuid4().hex[:6]}"
parent = params.get("session_id", "")
parent_history = _preview_restart_history(session)
has_history = bool(parent_history)
prompt = "\n".join(
line
for line in [
@ -3680,8 +3741,14 @@ def _(rid, params: dict) -> dict:
"",
f"Preview console:\n{context}" if context else "",
"" if context else "",
(
"The conversation history above is from the user's main session — including the commands you (the assistant) previously ran to start servers, edit files, or check ports. Use it to figure out exactly which server should be running at this Preview URL. The user did not start a brand new task; recover what they had working."
if has_history
else None
),
"Restart exactly the app intended for the Preview URL, not Hermes Desktop itself.",
"The Preview URL and port are the target. Preserve that target unless you conclude it is impossible.",
"If the prior conversation shows a specific command that bound this URL/port, prefer re-running THAT exact command (in the same cwd) over guessing a new one.",
"First inspect what process, if any, owns the Preview URL port. If a stale server exists, inspect its cwd and prefer that cwd over the Hermes/Desktop process cwd.",
"The Current working directory is only a hint. Do not assume it is the preview app root when the port owner or files indicate another root.",
"If the console shows a module-script MIME error for src/main.tsx or similar, a static server is serving source files. Do not restart python -m http.server or any dumb static server for that app.",
@ -3706,11 +3773,24 @@ def _(rid, params: dict) -> dict:
if cwd and os.path.isdir(os.path.abspath(os.path.expanduser(cwd))):
register_task_env_overrides(task_id, {"cwd": os.path.abspath(os.path.expanduser(cwd))})
_emit("preview.restart.progress", parent, {"task_id": task_id, "text": "Starting hidden restart agent"})
history_note = (
f" (with {len(parent_history)} parent-session messages of context)"
if parent_history
else ""
)
_emit(
"preview.restart.progress",
parent,
{"task_id": task_id, "text": f"Starting hidden restart agent{history_note}"},
)
result = AIAgent(
**_ephemeral_preview_agent_kwargs(session["agent"], task_id),
**_preview_restart_callbacks(parent, task_id),
).run_conversation(user_message=prompt, task_id=task_id)
).run_conversation(
user_message=prompt,
task_id=task_id,
conversation_history=parent_history or None,
)
text = (
result.get("final_response", str(result))
if isinstance(result, dict)