feat: better pane management and toolbar api

This commit is contained in:
Brooklyn Nicholson 2026-05-02 15:22:18 -05:00
parent a66303eaef
commit 6dcf5bcbc0
17 changed files with 772 additions and 348 deletions

View file

@ -2,6 +2,8 @@
Instructions for AI coding assistants and developers working on the hermes-agent codebase.
**Never give up on the right solution.**
## Development Environment
```bash

View file

@ -1,5 +1,5 @@
import { Copy, ExternalLink, FileImage, FileText, FolderOpen, Layers3, Link2, RefreshCw, Search, X } from 'lucide-react'
import type { ReactNode } from 'react'
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
@ -23,7 +23,8 @@ import { notify, notifyError } from '@/store/notifications'
import type { SessionInfo, SessionMessage } from '@/types/hermes'
import { sessionRoute } from '../routes'
import { TITLEBAR_ICON_SIZE, titlebarButtonClass, titlebarHeaderBaseClass } from '../shell/titlebar'
import { titlebarHeaderBaseClass } from '../shell/titlebar'
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
type ArtifactKind = 'image' | 'file' | 'link'
@ -340,10 +341,10 @@ function paginationItems(page: number, pageCount: number): Array<number | 'ellip
}
interface ArtifactsViewProps extends React.ComponentProps<'section'> {
setTitlebarActions?: (actions: ReactNode | null) => void
setTitlebarToolGroup?: SetTitlebarToolGroup
}
export function ArtifactsView({ setTitlebarActions, ...props }: ArtifactsViewProps) {
export function ArtifactsView({ setTitlebarToolGroup, ...props }: ArtifactsViewProps) {
const navigate = useNavigate()
const [artifacts, setArtifacts] = useState<ArtifactRecord[] | null>(null)
const [query, setQuery] = useState('')
@ -384,24 +385,22 @@ export function ArtifactsView({ setTitlebarActions, ...props }: ArtifactsViewPro
}, [refreshArtifacts])
useEffect(() => {
if (!setTitlebarActions) {
if (!setTitlebarToolGroup) {
return
}
setTitlebarActions(
<button
aria-label={refreshing ? 'Refreshing artifacts' : 'Refresh artifacts'}
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent')}
disabled={refreshing}
onClick={() => void refreshArtifacts()}
type="button"
>
<RefreshCw className={cn(refreshing && 'animate-spin')} size={TITLEBAR_ICON_SIZE} />
</button>
)
setTitlebarToolGroup('artifacts', [
{
disabled: refreshing,
icon: <RefreshCw className={cn(refreshing && 'animate-spin')} />,
id: 'refresh-artifacts',
label: refreshing ? 'Refreshing artifacts' : 'Refresh artifacts',
onSelect: () => void refreshArtifacts()
}
])
return () => setTitlebarActions(null)
}, [refreshArtifacts, refreshing, setTitlebarActions])
return () => setTitlebarToolGroup('artifacts', [])
}, [refreshArtifacts, refreshing, setTitlebarToolGroup])
useEffect(() => {
setImagePage(1)
@ -514,8 +513,8 @@ export function ArtifactsView({ setTitlebarActions, ...props }: ArtifactsViewPro
className="flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background"
>
<header className={titlebarHeaderBaseClass}>
<h2 className="text-base font-semibold leading-none tracking-tight">Artifacts</h2>
<span className="text-xs text-muted-foreground">{counts.all} found</span>
<h2 className="pointer-events-auto text-base font-semibold leading-none tracking-tight">Artifacts</h2>
<span className="pointer-events-auto text-xs text-muted-foreground">{counts.all} found</span>
</header>
<div className="min-h-0 flex-1 overflow-hidden rounded-[1.0625rem] border border-border/50 bg-background/85">

View file

@ -40,11 +40,12 @@ import type { ModelOptionsResponse } from '@/types/hermes'
import { routeSessionId } from '../routes'
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 { ChatRightRail } from './right-rail'
import { ChatPreviewRail, ChatRightRail } from './right-rail'
import { SessionActionsMenu } from './sidebar/session-actions-menu'
interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
@ -72,6 +73,7 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
onEdit: (message: AppendMessage) => Promise<void>
onReload: (parentId: string | null) => Promise<void>
onTranscribeAudio?: (audio: Blob) => Promise<string>
setTitlebarToolGroup?: SetTitlebarToolGroup
}
function threadLoadingState(
@ -123,7 +125,8 @@ export function ChatView({
onThreadMessagesChange,
onEdit,
onReload,
onTranscribeAudio
onTranscribeAudio,
setTitlebarToolGroup
}: ChatViewProps) {
const location = useLocation()
const activeSessionId = useStore($activeSessionId)
@ -255,7 +258,7 @@ export function ChatView({
return (
<>
<div className={cn('relative flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-transparent', className)}>
<div className={cn('relative col-start-2 col-end-3 row-start-1 flex h-[calc(100vh-0.375rem)] 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 && (
@ -269,7 +272,7 @@ export function ChatView({
title={title}
>
<Button
className="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]"
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"
>
@ -283,7 +286,7 @@ export function ChatView({
<NotificationStack />
<div className="relative min-h-0 flex-1 overflow-hidden rounded-[1.0625rem] bg-transparent">
<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}
@ -321,6 +324,7 @@ export function ChatView({
</div>
</div>
<ChatPreviewRail setTitlebarToolGroup={setTitlebarToolGroup} />
<ChatRightRail
onBrowseCwd={onBrowseCwd}
onChangeCwd={onChangeCwd}

View file

@ -2,8 +2,10 @@ import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { SESSION_INSPECTOR_WIDTH, SessionInspector } from '@/components/session-inspector'
import { cn } from '@/lib/utils'
import { $inspectorOpen } from '@/store/layout'
import { $previewTarget } from '@/store/preview'
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
import {
$availablePersonalities,
$busy,
@ -32,7 +34,6 @@ export function ChatRightRail({
onSelectPersonality
}: ChatRightRailProps) {
const inspectorOpen = useStore($inspectorOpen)
const previewTarget = useStore($previewTarget)
const gatewayOpen = useStore($gatewayState) === 'open'
const busy = useStore($busy)
const cwd = useStore($currentCwd)
@ -42,26 +43,44 @@ export function ChatRightRail({
const personality = useStore($currentPersonality)
const personalities = useStore($availablePersonalities)
if (previewTarget) {
return <PreviewPane target={previewTarget} />
return (
<div className="col-start-4 col-end-5 row-start-1 min-w-0 overflow-hidden">
<SessionInspector
branch={branch}
busy={busy}
cwd={cwd}
modelLabel={model ? model.split('/').pop() || model : ''}
modelTitle={provider ? `${provider}: ${model || ''}` : model}
onBrowseCwd={onBrowseCwd}
onChangeCwd={onChangeCwd}
onOpenModelPicker={gatewayOpen ? onOpenModelPicker : undefined}
onSelectPersonality={gatewayOpen ? onSelectPersonality : undefined}
open={inspectorOpen}
personalities={personalities}
personality={personality}
providerName={provider}
/>
</div>
)
}
export function ChatPreviewRail({ setTitlebarToolGroup }: { setTitlebarToolGroup?: SetTitlebarToolGroup }) {
const inspectorOpen = useStore($inspectorOpen)
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 (
<SessionInspector
branch={branch}
busy={busy}
cwd={cwd}
modelLabel={model ? model.split('/').pop() || model : ''}
modelTitle={provider ? `${provider}: ${model || ''}` : model}
onBrowseCwd={onBrowseCwd}
onChangeCwd={onChangeCwd}
onOpenModelPicker={gatewayOpen ? onOpenModelPicker : undefined}
onSelectPersonality={gatewayOpen ? onSelectPersonality : undefined}
open={inspectorOpen}
personalities={personalities}
personality={personality}
providerName={provider}
/>
<div
className={cn(
'pointer-events-none col-start-3 col-end-4 row-start-1 min-w-0 overflow-hidden',
inspectorOpen && 'border-r border-border/60'
)}
>
<PreviewPane setTitlebarToolGroup={setTitlebarToolGroup} target={previewTarget} />
</div>
)
}

View file

@ -1,7 +1,8 @@
import { Bug, Check, Copy, ExternalLink, PanelBottom, RefreshCw, Send, Trash2, X } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Bug, Check, Copy, PanelBottom, RefreshCw, Send, Trash2, X } from 'lucide-react'
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
import { cn } from '@/lib/utils'
import { $composerDraft, setComposerDraft } from '@/store/composer'
import { notify, notifyError } from '@/store/notifications'
@ -23,6 +24,12 @@ interface ConsoleEntry {
source?: string
}
interface PreviewLoadErrorState {
code?: number
description: string
url: string
}
const consoleLevelLabel: Record<number, string> = {
0: 'log',
1: 'info',
@ -38,6 +45,8 @@ const consoleLevelClass: Record<number, string> = {
}
const CONSOLE_BOTTOM_THRESHOLD = 24
const CONSOLE_DEFAULT_HEIGHT = 240
const CONSOLE_HEADER_HEIGHT = 32
const FILE_RELOAD_DEBOUNCE_MS = 200
function compactUrl(value: string): string {
@ -69,6 +78,20 @@ function isNearConsoleBottom(element: HTMLDivElement | null): boolean {
return element.scrollHeight - element.scrollTop - element.clientHeight <= CONSOLE_BOTTOM_THRESHOLD
}
function clampConsoleHeight(value: number): number {
return Math.max(value, CONSOLE_HEADER_HEIGHT)
}
function loadErrorTitle(error: PreviewLoadErrorState): string {
const description = error.description.toLowerCase()
if (description.includes('connection') || description.includes('refused') || description.includes('not found')) {
return 'Server not found'
}
return 'Preview failed to load'
}
interface ConsoleRowProps {
log: ConsoleEntry
onCopy: () => void | Promise<void>
@ -129,6 +152,67 @@ function ConsoleRow({ log, onCopy, onSend, onToggleSelect, selected }: ConsoleRo
)
}
function PreviewLoadError({
consoleHeight = 0,
error,
onRetry
}: {
consoleHeight?: number
error: PreviewLoadErrorState
onRetry: () => void
}) {
return (
<div
className="absolute inset-x-0 top-0 z-10 grid place-items-center bg-background px-6 text-center bottom-(--preview-error-bottom)"
style={{ '--preview-error-bottom': `${consoleHeight}px` } as CSSProperties}
>
<div className="grid max-w-72 justify-items-center gap-4">
<svg aria-hidden="true" className="size-16 text-muted-foreground/35" viewBox="0 0 64 64">
<path
d="M32 5 56 18.5v27L32 59 8 45.5v-27L32 5Z"
fill="none"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.25"
/>
<path
d="M8 18.5 32 32l24-13.5M32 32v27"
fill="none"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.25"
/>
<path d="M20 11.75 44 25.25" fill="none" stroke="currentColor" strokeWidth="0.9" opacity="0.45" />
</svg>
<div className="grid gap-1.5">
<div className="text-sm font-medium text-foreground">{loadErrorTitle(error)}</div>
<div className="text-xs leading-5 text-muted-foreground">
<a
className="pointer-events-auto cursor-pointer font-mono text-muted-foreground/90 underline decoration-muted-foreground/30 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/70"
href={error.url}
onClick={event => {
event.preventDefault()
void window.hermesDesktop?.openExternal(error.url)
}}
>
{compactUrl(error.url)}
</a>
{error.code ? ` (${error.code})` : ''}
</div>
<div className="text-[0.6875rem] leading-5 text-muted-foreground/70">{error.description}</div>
</div>
<button
className="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground shadow-xs transition-colors hover:bg-accent"
onClick={onRetry}
type="button"
>
Try again
</button>
</div>
</div>
)
}
async function writeClipboardText(text: string) {
if (!text) {
return
@ -145,12 +229,20 @@ async function writeClipboardText(text: string) {
}
}
export function PreviewPane({ target }: { target: PreviewTarget }) {
export function PreviewPane({
setTitlebarToolGroup,
target
}: {
setTitlebarToolGroup?: SetTitlebarToolGroup
target: PreviewTarget
}) {
const consoleBodyRef = useRef<HTMLDivElement | null>(null)
const consoleShouldStickRef = useRef(true)
const hostRef = useRef<HTMLDivElement | null>(null)
const logIdRef = useRef(0)
const previewContentRef = useRef<HTMLDivElement | null>(null)
const webviewRef = useRef<PreviewWebview | null>(null)
const [consoleHeight, setConsoleHeight] = useState(CONSOLE_DEFAULT_HEIGHT)
const [consoleOpen, setConsoleOpen] = useState(false)
const [currentUrl, setCurrentUrl] = useState(target.url)
const [devtoolsOpen, setDevtoolsOpen] = useState(false)
@ -158,8 +250,72 @@ export function PreviewPane({ target }: { target: PreviewTarget }) {
const [selectedLogIds, setSelectedLogIds] = useState<Set<number>>(() => new Set())
const [copiedAll, setCopiedAll] = useState(false)
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<PreviewLoadErrorState | null>(null)
const visibleSelection = useMemo(() => logs.filter(log => selectedLogIds.has(log.id)), [logs, selectedLogIds])
const sendableLogs = visibleSelection.length > 0 ? visibleSelection : logs
const currentLabel = compactUrl(currentUrl)
const previewLabel =
target.label && target.label.replace(/\/$/, '') !== currentLabel.replace(/\/$/, '') ? target.label : currentLabel
const startConsoleResize = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault()
const handle = event.currentTarget
const pointerId = event.pointerId
const startY = event.clientY
const startHeight = consoleHeight
const previousCursor = document.body.style.cursor
const previousUserSelect = document.body.style.userSelect
let active = true
handle.setPointerCapture?.(pointerId)
document.body.style.cursor = 'row-resize'
document.body.style.userSelect = 'none'
const handleMove = (moveEvent: PointerEvent) => {
if (!active) {
return
}
setConsoleHeight(clampConsoleHeight(startHeight + startY - moveEvent.clientY))
}
const cleanup = () => {
if (!active) {
return
}
active = false
document.body.style.cursor = previousCursor
document.body.style.userSelect = previousUserSelect
handle.releasePointerCapture?.(pointerId)
window.removeEventListener('pointermove', handleMove, true)
window.removeEventListener('pointerup', cleanup, true)
window.removeEventListener('pointercancel', cleanup, true)
window.removeEventListener('blur', cleanup)
handle.removeEventListener('lostpointercapture', cleanup)
}
window.addEventListener('pointermove', handleMove, true)
window.addEventListener('pointerup', cleanup, true)
window.addEventListener('pointercancel', cleanup, true)
window.addEventListener('blur', cleanup)
handle.addEventListener('lostpointercapture', cleanup)
},
[consoleHeight]
)
const reloadPreview = useCallback(() => {
setLoadError(null)
if (webviewRef.current?.reloadIgnoringCache) {
webviewRef.current.reloadIgnoringCache()
} else {
webviewRef.current?.reload?.()
}
}, [])
function toggleLogSelection(id: number) {
setSelectedLogIds(prev => {
@ -204,7 +360,7 @@ export function PreviewPane({ target }: { target: PreviewTarget }) {
})
}
function toggleDevTools() {
const toggleDevTools = useCallback(() => {
const webview = webviewRef.current
if (!webview?.openDevTools) {
@ -220,7 +376,52 @@ export function PreviewPane({ target }: { target: PreviewTarget }) {
webview.openDevTools()
setDevtoolsOpen(true)
}
}, [])
useEffect(() => {
if (!setTitlebarToolGroup) {
return
}
const tools: TitlebarTool[] = [
{
active: consoleOpen,
icon: (
<>
<PanelBottom />
{logs.length > 0 && <span className="sr-only">{logs.length} console messages</span>}
</>
),
id: 'preview-console',
label: consoleOpen ? 'Hide preview console' : 'Show preview console',
onSelect: () => setConsoleOpen(open => !open)
},
{
active: devtoolsOpen,
icon: <Bug />,
id: 'preview-devtools',
label: devtoolsOpen ? 'Hide preview DevTools' : 'Open preview DevTools',
onSelect: toggleDevTools
},
{
icon: <RefreshCw className={cn(loading && 'animate-spin')} />,
id: 'preview-reload',
label: 'Reload preview',
onSelect: reloadPreview
},
{
className: 'mr-(--shell-preview-toolbar-gap)',
icon: <X />,
id: 'preview-close',
label: 'Close preview',
onSelect: () => setPreviewTarget(null)
}
]
setTitlebarToolGroup('preview', tools)
return () => setTitlebarToolGroup('preview', [])
}, [consoleOpen, currentUrl, devtoolsOpen, loading, logs.length, reloadPreview, setTitlebarToolGroup, toggleDevTools])
useEffect(() => {
if (consoleOpen && consoleShouldStickRef.current) {
@ -228,7 +429,7 @@ export function PreviewPane({ target }: { target: PreviewTarget }) {
consoleBody?.scrollTo({ top: consoleBody.scrollHeight })
}
}, [consoleOpen, logs])
}, [consoleHeight, consoleOpen, logs])
useEffect(() => {
if (consoleOpen) {
@ -275,11 +476,7 @@ export function PreviewPane({ target }: { target: PreviewTarget }) {
}
])
if (webviewRef.current?.reloadIgnoringCache) {
webviewRef.current.reloadIgnoringCache()
} else {
webviewRef.current?.reload?.()
}
reloadPreview()
}
const unsubscribe = window.hermesDesktop.onPreviewFileChanged(payload => {
@ -334,7 +531,7 @@ export function PreviewPane({ target }: { target: PreviewTarget }) {
void window.hermesDesktop?.stopPreviewFileWatch?.(watchId)
}
}
}, [target.kind, target.url])
}, [reloadPreview, target.kind, target.url])
useEffect(() => {
const host = hostRef.current
@ -347,6 +544,7 @@ export function PreviewPane({ target }: { target: PreviewTarget }) {
webviewRef.current = null
setCurrentUrl(target.url)
setDevtoolsOpen(false)
setLoadError(null)
setLogs([])
setLoading(true)
@ -381,6 +579,7 @@ export function PreviewPane({ target }: { target: PreviewTarget }) {
const detail = event as Event & { url?: string }
if (detail.url) {
setLoadError(null)
setCurrentUrl(detail.url)
}
}
@ -391,13 +590,23 @@ export function PreviewPane({ target }: { target: PreviewTarget }) {
errorDescription?: string
validatedURL?: string
}
const errorCode = detail.errorCode
if (errorCode === -3) {
return
}
appendLog({
level: 3,
message: `Load failed${detail.errorCode ? ` (${detail.errorCode})` : ''}: ${
message: `Load failed${errorCode ? ` (${errorCode})` : ''}: ${
detail.errorDescription || detail.validatedURL || 'unknown error'
}`
})
setLoadError({
code: errorCode,
description: detail.errorDescription || 'The preview page could not be reached.',
url: detail.validatedURL || currentUrl || target.url
})
setLoading(false)
}
@ -425,157 +634,129 @@ export function PreviewPane({ target }: { target: PreviewTarget }) {
}, [target.url])
return (
<aside className="relative flex h-screen 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">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-border/60 bg-card/70 shadow-sm">
<div className="flex items-center gap-1.5 border-b border-border/60 px-2 py-2">
<aside className="relative flex h-screen w-full min-w-0 flex-col overflow-hidden border-l border-border/60 bg-background text-muted-foreground">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="pointer-events-none flex min-h-(--titlebar-height) items-center gap-1.5 border-b border-border/60 bg-background px-2 py-1">
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium text-foreground">{target.label || 'Preview'}</div>
<div className="truncate font-mono text-[0.625rem] text-muted-foreground">{compactUrl(currentUrl)}</div>
<a
className="pointer-events-auto inline max-w-full cursor-pointer truncate text-left text-xs font-medium text-foreground underline-offset-4 transition-colors hover:text-primary hover:underline"
href={currentUrl}
rel="noreferrer"
target="_blank"
title={`Open ${currentUrl}`}
>
{previewLabel || 'Preview'}
</a>
</div>
<Button
aria-label={consoleOpen ? 'Hide preview console' : 'Show preview console'}
className="h-7 shrink-0 rounded-lg px-2 text-[0.6875rem]"
onClick={() => setConsoleOpen(open => !open)}
size="xs"
title={consoleOpen ? 'Hide Console' : 'Show Console'}
type="button"
variant="ghost"
>
<PanelBottom className="size-3.5" />
Console
{logs.length > 0 && (
<span className="ml-0.5 rounded-full bg-muted px-1.5 py-px text-[0.5625rem] text-muted-foreground">
{logs.length}
</span>
)}
</Button>
<Button
aria-label={devtoolsOpen ? 'Hide preview DevTools' : 'Open preview DevTools'}
className="h-7 shrink-0 rounded-lg px-2 text-[0.6875rem]"
onClick={toggleDevTools}
size="xs"
title={devtoolsOpen ? 'Hide DevTools' : 'Open DevTools'}
type="button"
variant="ghost"
>
<Bug className="size-3.5" />
{devtoolsOpen ? 'Hide DevTools' : 'DevTools'}
</Button>
<Button
aria-label="Reload preview"
className="size-7 shrink-0 rounded-lg"
onClick={() => webviewRef.current?.reload?.()}
size="icon"
type="button"
variant="ghost"
>
<RefreshCw className={cn('size-3.5', loading && 'animate-spin')} />
</Button>
<Button
aria-label="Open preview externally"
className="size-7 shrink-0 rounded-lg"
onClick={() => void window.hermesDesktop?.openExternal(currentUrl)}
size="icon"
type="button"
variant="ghost"
>
<ExternalLink className="size-3.5" />
</Button>
<Button
aria-label="Close preview"
className="size-7 shrink-0 rounded-lg"
onClick={() => setPreviewTarget(null)}
size="icon"
type="button"
variant="ghost"
>
<X className="size-3.5" />
</Button>
</div>
<div className="min-h-0 flex-1 bg-background" ref={hostRef} />
<div className="pointer-events-auto relative min-h-0 flex-1 overflow-hidden bg-background" ref={previewContentRef}>
<div
className={cn('absolute inset-0 flex bg-background', loadError && 'pointer-events-none opacity-0')}
ref={hostRef}
/>
{loadError && (
<PreviewLoadError
consoleHeight={consoleOpen ? consoleHeight : 0}
error={loadError}
onRetry={reloadPreview}
/>
)}
{consoleOpen && (
<div className="min-h-44 border-t border-border/60 bg-background/95">
<div className="flex h-8 items-center justify-between border-b border-border/50 px-2">
<div className="flex items-center gap-2 text-[0.6875rem] font-medium text-muted-foreground">
<PanelBottom className="size-3.5" />
Preview Console
{selectedLogIds.size > 0 && (
<span className="rounded-full bg-muted px-1.5 py-px text-[0.5625rem] text-muted-foreground">
{selectedLogIds.size} selected
</span>
{consoleOpen && (
<div
className="pointer-events-auto absolute inset-x-0 bottom-0 z-20 flex h-(--preview-console-height) min-h-8 flex-col overflow-hidden border-t border-border/60 bg-background"
style={{ '--preview-console-height': `${consoleHeight}px` } as CSSProperties}
>
<div
aria-label="Resize preview console"
className="group absolute inset-x-0 -top-1 z-1 h-2 cursor-row-resize"
onDoubleClick={() => setConsoleHeight(CONSOLE_HEADER_HEIGHT)}
onPointerDown={startConsoleResize}
role="separator"
>
<span className="absolute left-1/2 top-1/2 h-0.75 w-23 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground/80 opacity-0 transition-opacity duration-100 group-hover:opacity-[0.5]" />
</div>
<div className="flex h-8 shrink-0 items-center justify-between border-b border-border/50 px-2">
<div className="flex items-center gap-2 text-[0.6875rem] font-medium text-muted-foreground">
<PanelBottom className="size-3.5" />
Preview Console
{selectedLogIds.size > 0 && (
<span className="rounded-full bg-muted px-1.5 py-px text-[0.5625rem] text-muted-foreground">
{selectedLogIds.size} selected
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
disabled={sendableLogs.length === 0}
onClick={() => sendLogsToComposer(sendableLogs)}
title={
visibleSelection.length > 0
? `Send ${visibleSelection.length} selected to chat`
: 'Send all log entries to chat'
}
type="button"
>
<Send className="size-3" />
Send to chat
</button>
<button
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
disabled={sendableLogs.length === 0}
onClick={async () => {
await copyConsoleText(
sendableLogs,
visibleSelection.length > 0 ? `${visibleSelection.length} selected entries` : 'All console entries'
)
setCopiedAll(true)
setTimeout(() => setCopiedAll(false), 1500)
}}
title={visibleSelection.length > 0 ? 'Copy selected to clipboard' : 'Copy all to clipboard'}
type="button"
>
{copiedAll ? <Check className="size-3" /> : <Copy className="size-3" />}
Copy
</button>
<button
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
disabled={logs.length === 0}
onClick={() => {
setLogs([])
setSelectedLogIds(new Set())
}}
title="Clear console"
type="button"
>
<Trash2 className="size-3" />
Clear
</button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-1.5 font-mono text-[0.6875rem] leading-relaxed" ref={consoleBodyRef}>
{logs.length > 0 ? (
logs.map(log => {
const selected = selectedLogIds.has(log.id)
return (
<ConsoleRow
key={log.id}
log={log}
onCopy={() => copyConsoleText([log], 'Log entry copied')}
onSend={() => sendLogsToComposer([log])}
onToggleSelect={() => toggleLogSelection(log.id)}
selected={selected}
/>
)
})
) : (
<div className="py-2 text-muted-foreground/70">No console messages yet.</div>
)}
</div>
<div className="flex items-center gap-1">
<button
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
disabled={sendableLogs.length === 0}
onClick={() => sendLogsToComposer(sendableLogs)}
title={
visibleSelection.length > 0
? `Send ${visibleSelection.length} selected to chat`
: 'Send all log entries to chat'
}
type="button"
>
<Send className="size-3" />
Send to chat
</button>
<button
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
disabled={sendableLogs.length === 0}
onClick={async () => {
await copyConsoleText(
sendableLogs,
visibleSelection.length > 0 ? `${visibleSelection.length} selected entries` : 'All console entries'
)
setCopiedAll(true)
setTimeout(() => setCopiedAll(false), 1500)
}}
title={visibleSelection.length > 0 ? 'Copy selected to clipboard' : 'Copy all to clipboard'}
type="button"
>
{copiedAll ? <Check className="size-3" /> : <Copy className="size-3" />}
Copy
</button>
<button
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
disabled={logs.length === 0}
onClick={() => {
setLogs([])
setSelectedLogIds(new Set())
}}
title="Clear console"
type="button"
>
<Trash2 className="size-3" />
Clear
</button>
</div>
</div>
<div className="h-40 overflow-y-auto px-2 py-1.5 font-mono text-[0.6875rem] leading-relaxed" ref={consoleBodyRef}>
{logs.length > 0 ? (
logs.map(log => {
const selected = selectedLogIds.has(log.id)
return (
<ConsoleRow
key={log.id}
log={log}
onCopy={() => copyConsoleText([log], 'Log entry copied')}
onSend={() => sendLogsToComposer([log])}
onToggleSelect={() => toggleLogSelection(log.id)}
selected={selected}
/>
)
})
) : (
<div className="py-2 text-muted-foreground/70">No console messages yet.</div>
)}
</div>
</div>
)}
)}
</div>
</div>
</aside>
)

View file

@ -1,6 +1,6 @@
import { useStore } from '@nanostores/react'
import { useQueryClient } from '@tanstack/react-query'
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom'
import type { ModelOptionsResponse, SessionRuntimeInfo } from '@/types/hermes'
@ -20,7 +20,7 @@ import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromC
import { extractPreviewCandidates } from '../lib/preview-targets'
import { $pinnedSessionIds, pinSession, unpinSession } from '../store/layout'
import { notify, notifyError } from '../store/notifications'
import { $previewTarget, setPreviewTarget } from '../store/preview'
import { setPreviewTarget } from '../store/preview'
import {
$activeSessionId,
$currentCwd,
@ -59,6 +59,7 @@ import { useSessionActions } from './session/hooks/use-session-actions'
import { useSessionStateCache } from './session/hooks/use-session-state-cache'
import { SettingsView } from './settings'
import { AppShell } from './shell/app-shell'
import type { SetTitlebarToolGroup, TitlebarTool, TitlebarToolSide } from './shell/titlebar-controls'
import { SkillsView } from './skills'
import type { ContextSuggestion } from './types'
@ -87,7 +88,6 @@ export function DesktopController() {
const gatewayState = useStore($gatewayState)
const { availableThemes, setTheme, themeName } = useTheme()
const activeSessionId = useStore($activeSessionId)
const previewTarget = useStore($previewTarget)
const messages = useStore($messages)
const selectedStoredSessionId = useStore($selectedStoredSessionId)
const currentCwd = useStore($currentCwd)
@ -102,7 +102,9 @@ export function DesktopController() {
const chatOpen = currentView === 'chat'
const settingsReturnPathRef = useRef(NEW_CHAT_ROUTE)
const refreshSessionsRequestRef = useRef(0)
const [titlebarActions, setTitlebarActions] = useState<ReactNode>(null)
const [titlebarToolGroups, setTitlebarToolGroups] = useState<
Record<TitlebarToolSide, Record<string, readonly TitlebarTool[]>>
>({ left: {}, right: {} })
const [voiceMaxRecordingSeconds, setVoiceMaxRecordingSeconds] = useState(DEFAULT_VOICE_RECORDING_SECONDS)
const [sttEnabled, setSttEnabled] = useState(true)
@ -123,6 +125,30 @@ export function DesktopController() {
setMessages
})
const setTitlebarToolGroup = useCallback<SetTitlebarToolGroup>((id, tools, side = 'right') => {
setTitlebarToolGroups(current => {
const next = { ...current, [side]: { ...current[side] } }
if (tools.length === 0) {
delete next[side][id]
} else {
next[side][id] = tools
}
return next
})
}, [])
const leftTitlebarTools = useMemo(
() => Object.values(titlebarToolGroups.left).flat(),
[titlebarToolGroups.left]
)
const titlebarTools = useMemo(
() => Object.values(titlebarToolGroups.right).flat(),
[titlebarToolGroups.right]
)
const toggleSelectedPin = useCallback(() => {
const sessionId = $selectedStoredSessionId.get()
@ -786,6 +812,7 @@ export function DesktopController() {
onSelectPersonality={name => void selectPersonality(name)}
onSubmit={submitText}
onThreadMessagesChange={handleThreadMessagesChange}
setTitlebarToolGroup={setTitlebarToolGroup}
onToggleSelectedPin={toggleSelectedPin}
onTranscribeAudio={transcribeVoiceAudio}
/>
@ -793,19 +820,21 @@ export function DesktopController() {
return (
<AppShell
inspectorWidth={previewTarget ? PREVIEW_RAIL_WIDTH : SESSION_INSPECTOR_WIDTH}
inspectorWidth={SESSION_INSPECTOR_WIDTH}
leftTitlebarTools={leftTitlebarTools}
onOpenSettings={openSettings}
overlays={overlays}
previewWidth={PREVIEW_RAIL_WIDTH}
rightRailOpen={chatOpen}
settingsOpen={settingsOpen}
sidebar={sidebar}
titlebarActions={titlebarActions}
titlebarTools={titlebarTools}
>
<Routes>
<Route element={chatView} index />
<Route element={chatView} path=":sessionId" />
<Route element={<SkillsView setTitlebarActions={setTitlebarActions} />} path="skills" />
<Route element={<ArtifactsView setTitlebarActions={setTitlebarActions} />} path="artifacts" />
<Route element={<SkillsView setTitlebarToolGroup={setTitlebarToolGroup} />} path="skills" />
<Route element={<ArtifactsView setTitlebarToolGroup={setTitlebarToolGroup} />} path="artifacts" />
<Route element={null} path="settings" />
<Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="new" />
<Route element={<LegacySessionRedirect />} path="sessions/:sessionId" />

View file

@ -17,15 +17,17 @@ import { $previewTarget } from '@/store/preview'
import { $connection } from '@/store/session'
import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar'
import { TitlebarControls } from './titlebar-controls'
import { TitlebarControls, type TitlebarTool } from './titlebar-controls'
interface AppShellProps {
children: ReactNode
inspectorWidth: string
leftTitlebarTools?: readonly TitlebarTool[]
previewWidth: string
rightRailOpen: boolean
settingsOpen: boolean
sidebar: ReactNode
titlebarActions?: ReactNode
titlebarTools?: readonly TitlebarTool[]
onOpenSettings: () => void
overlays?: ReactNode
}
@ -33,10 +35,12 @@ interface AppShellProps {
export function AppShell({
children,
inspectorWidth,
leftTitlebarTools,
previewWidth,
rightRailOpen,
settingsOpen,
sidebar,
titlebarActions,
titlebarTools,
onOpenSettings,
overlays
}: AppShellProps) {
@ -50,22 +54,36 @@ export function AppShell({
// 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 titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition)
const titlebarContentInset = sidebarOpen
? 0
: titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2)
const showRightRail = rightRailOpen && (inspectorOpen || Boolean(previewTarget))
// Right rail yields to chat min-width before the chat column starts crushing the composer.
const inspectorColumn = showRightRail
? 'min(var(--inspector-width), max(0px, calc(100vw - var(--sidebar-width) - var(--chat-min-width) - 2 * var(--shell-gap))))'
const showPreviewRail = rightRailOpen && Boolean(previewTarget)
const showInspectorRail = rightRailOpen && inspectorOpen
const inspectorColumn = showInspectorRail ? 'var(--inspector-width)' : '0px'
// Preview yields first because it is the widest rail; keep chat usable before
// letting the webview consume horizontal space.
const previewColumn = showPreviewRail
? `min(var(--preview-width), max(0px, calc(100vw - var(--sidebar-width) - ${showInspectorRail ? 'var(--inspector-width)' : '0px'} - var(--chat-min-width) - 3 * var(--shell-gap))))`
: '0px'
// Always keep the shell as 3 columns because the sidebar and chat are
// always rendered as grid children. Collapsing to a single grid column
// makes the hidden sidebar occupy row 1 and pushes chat into row 2, which
// looks like a blank/white screen when closing the preview with sidebars
// hidden. Centering is handled by setting closed side columns to 0px.
const shellGridColumns = 'var(--sidebar-width) minmax(0,1fr) var(--inspector-col)'
const titlebarToolCount = (titlebarTools?.filter(tool => !tool.hidden).length ?? 0) + (rightRailOpen ? 1 : 0) + 2
const previewToolbarGap = showPreviewRail
? 'max(0px, calc(var(--shell-right-sidebar-width) - (3 * var(--titlebar-control-size)) + 0.2rem))'
: '0px'
// Always keep the shell as fixed columns because sidebar/chat/preview/inspector
// are always rendered as grid children. Hidden rails collapse to 0px so they
// don't float over the chat surface or reorder into a new row.
const shellGridColumns = 'var(--sidebar-width) minmax(0,1fr) var(--preview-col) var(--inspector-col)'
const hasSideGaps = sidebarOpen || showPreviewRail || showInspectorRail
const startSidebarResize = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
@ -106,42 +124,52 @@ 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': previewToolbarGap,
'--titlebar-height': `${TITLEBAR_HEIGHT}px`,
'--titlebar-content-inset': `${titlebarContentInset}px`,
'--titlebar-controls-left': `${titlebarControls.left}px`,
'--titlebar-controls-top': `${titlebarControls.top}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))`
} as CSSProperties
}
>
<TitlebarControls
leadingActions={titlebarActions}
leftTools={leftTitlebarTools}
onOpenSettings={onOpenSettings}
settingsOpen={settingsOpen}
showInspectorToggle={rightRailOpen}
tools={titlebarTools}
/>
<main
className={cn(
'relative grid h-screen w-full overflow-hidden bg-background pr-0.75 pb-0.75 pt-0.75 transition-none',
sidebarOpen || showRightRail ? 'gap-(--shell-gap)' : 'gap-0'
hasSideGaps ? 'gap-(--shell-gap)' : 'gap-0'
)}
style={
{
'--inspector-width': inspectorWidth,
'--inspector-col': inspectorColumn,
'--preview-col': previewColumn,
gridTemplateColumns: shellGridColumns
} as CSSProperties
}
>
<div
aria-hidden="true"
className="absolute left-0 top-0 z-1 h-(--titlebar-height) w-(--titlebar-controls-left) [-webkit-app-region:drag]"
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="absolute right-20 top-0 z-1 h-(--titlebar-height) left-[calc(var(--titlebar-controls-left)+(var(--titlebar-control-size)*2)+0.75rem)] [-webkit-app-region:drag]"
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]"
/>
{sidebar}

View file

@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import { NotebookTabs, Search, Settings, SlidersHorizontal, Volume2, VolumeX } from 'lucide-react'
import type { ReactNode } from 'react'
import type * as React from 'react'
import { useNavigate } from 'react-router-dom'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
@ -10,19 +11,39 @@ import { $inspectorOpen, $sidebarOpen, toggleInspectorOpen, toggleSidebarOpen }
import { TITLEBAR_ICON_SIZE, titlebarButtonClass } from './titlebar'
export interface TitlebarTool {
id: string
label: string
active?: boolean
className?: string
disabled?: boolean
hidden?: boolean
href?: string
icon: ReactNode
onSelect?: () => void
title?: string
to?: string
}
export type TitlebarToolSide = 'left' | 'right'
export type SetTitlebarToolGroup = (id: string, tools: readonly TitlebarTool[], side?: TitlebarToolSide) => void
interface TitlebarControlsProps extends React.ComponentProps<'div'> {
leftTools?: readonly TitlebarTool[]
settingsOpen: boolean
showInspectorToggle: boolean
leadingActions?: ReactNode
tools?: readonly TitlebarTool[]
onOpenSettings: () => void
}
export function TitlebarControls({
leftTools = [],
settingsOpen,
showInspectorToggle,
leadingActions,
tools = [],
onOpenSettings
}: TitlebarControlsProps) {
const navigate = useNavigate()
const hapticsMuted = useStore($hapticsMuted)
const sidebarOpen = useStore($sidebarOpen)
const inspectorOpen = useStore($inspectorOpen)
@ -39,86 +60,136 @@ export function TitlebarControls({
}
}
const leftToolbarTools: TitlebarTool[] = [
{
icon: <NotebookTabs />,
id: 'sidebar',
label: sidebarOpen ? 'Hide sidebar' : 'Show sidebar',
onSelect: () => {
triggerHaptic('tap')
toggleSidebarOpen()
}
},
{
icon: <Search size={TITLEBAR_ICON_SIZE} />,
id: 'search',
label: 'Search'
},
...leftTools
]
const rightToolbarTools: TitlebarTool[] = [
...tools,
{
active: inspectorOpen,
hidden: !showInspectorToggle,
icon: <SlidersHorizontal />,
id: 'session-details',
label: inspectorOpen ? 'Hide session details' : 'Show session details',
onSelect: () => {
triggerHaptic('tap')
toggleInspectorOpen()
}
},
{
active: hapticsMuted,
icon: hapticsMuted ? <VolumeX /> : <Volume2 />,
id: 'haptics',
label: hapticsMuted ? 'Unmute haptics' : 'Mute haptics',
onSelect: toggleHaptics
},
{
icon: <Settings />,
id: 'settings',
label: 'Open settings',
onSelect: () => {
triggerHaptic('open')
onOpenSettings()
}
}
]
return (
<>
<div
aria-label="Window controls"
className="fixed left-(--titlebar-controls-left) top-(--titlebar-controls-top) z-50 grid translate-y-[2px] grid-flow-col auto-cols-(--titlebar-control-size) items-center pointer-events-auto [-webkit-app-region:no-drag]"
className="fixed left-(--titlebar-controls-left) top-(--titlebar-controls-top) z-2147483647 flex translate-y-[2px] flex-row items-center gap-px pointer-events-auto [-webkit-app-region:no-drag]"
>
<button
aria-label={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent [&_svg]:size-3.5')}
onClick={() => {
triggerHaptic('tap')
toggleSidebarOpen()
}}
onPointerDown={event => event.stopPropagation()}
type="button"
>
<NotebookTabs />
</button>
<button
aria-label="Search"
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent')}
onPointerDown={event => event.stopPropagation()}
type="button"
>
<Search size={TITLEBAR_ICON_SIZE} />
</button>
{leftToolbarTools
.filter(tool => !tool.hidden)
.map(tool => (
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
))}
</div>
{!settingsOpen && (
<div
aria-label="App controls"
className="fixed right-3 top-(--titlebar-controls-top) z-1100 grid grid-flow-col auto-cols-(--titlebar-control-size) items-center pointer-events-auto [-webkit-app-region:no-drag]"
className="fixed right-(--titlebar-tools-right) top-(--titlebar-controls-top) z-2147483647 flex flex-row items-center justify-end gap-px pointer-events-auto [-webkit-app-region:no-drag]"
>
{leadingActions}
{showInspectorToggle && (
<button
aria-label={inspectorOpen ? 'Hide session details' : 'Show session details'}
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent [&_svg]:size-3.5')}
onClick={() => {
triggerHaptic('tap')
toggleInspectorOpen()
}}
onPointerDown={event => event.stopPropagation()}
title={inspectorOpen ? 'Hide session details' : 'Show session details'}
type="button"
>
<SlidersHorizontal />
</button>
)}
<button
aria-label={hapticsMuted ? 'Unmute haptics' : 'Mute haptics'}
aria-pressed={hapticsMuted}
className={cn(
titlebarButtonClass,
'grid place-items-center bg-transparent [&_svg]:size-3.5',
hapticsMuted && 'bg-muted text-muted-foreground'
)}
onClick={toggleHaptics}
onPointerDown={event => event.stopPropagation()}
title={hapticsMuted ? 'Unmute haptics' : 'Mute haptics'}
type="button"
>
{hapticsMuted ? <VolumeX /> : <Volume2 />}
</button>
<button
aria-label="Open settings"
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent [&_svg]:size-3.5')}
onClick={() => {
triggerHaptic('open')
onOpenSettings()
}}
onPointerDown={event => event.stopPropagation()}
title="Settings"
type="button"
>
<Settings />
</button>
{rightToolbarTools
.filter(tool => !tool.hidden)
.map(tool => (
<TitlebarToolButton
key={tool.id}
navigate={navigate}
tool={tool}
/>
))}
</div>
)}
</>
)
}
function TitlebarToolButton({
navigate,
tool
}: {
navigate: ReturnType<typeof useNavigate>
tool: TitlebarTool
}) {
const className = cn(
titlebarButtonClass,
'grid place-items-center bg-transparent [&_svg]:size-3.5',
tool.active && 'bg-muted text-muted-foreground',
tool.className
)
if (tool.href) {
return (
<a
aria-label={tool.label}
className={className}
href={tool.href}
onPointerDown={event => event.stopPropagation()}
rel="noreferrer"
target="_blank"
title={tool.title ?? tool.label}
>
{tool.icon}
</a>
)
}
return (
<button
aria-label={tool.label}
aria-pressed={tool.active}
className={className}
disabled={tool.disabled}
onClick={() => {
if (tool.to) {
navigate(tool.to)
}
tool.onSelect?.()
}}
onPointerDown={event => event.stopPropagation()}
title={tool.title ?? tool.label}
type="button"
>
{tool.icon}
</button>
)
}

View file

@ -16,7 +16,7 @@ export const titlebarButtonClass =
'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] rounded-md text-muted-foreground hover:bg-accent hover:text-foreground'
export const titlebarHeaderBaseClass =
'relative z-3 flex h-(--titlebar-height) shrink-0 items-center justify-start gap-3 bg-background/70 px-[max(0.75rem,var(--titlebar-content-inset,0px))] backdrop-blur-sm'
'pointer-events-none relative z-3 flex h-(--titlebar-height) shrink-0 items-center justify-start gap-3 bg-background/70 px-[max(0.75rem,var(--titlebar-content-inset,0px))] backdrop-blur-sm'
export const titlebarHeaderShadowClass =
"shadow-header after:pointer-events-none after:absolute after:left-0 after:right-0 after:top-full after:h-10 after:bg-linear-to-b after:from-background after:via-background/80 after:to-transparent after:content-['']"

View file

@ -1,6 +1,6 @@
import { Brain, RefreshCw, Search, Wrench, X } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type { ReactNode } from 'react'
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
@ -13,7 +13,8 @@ import { notify, notifyError } from '@/store/notifications'
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
import { asText, includesQuery, prettyName, toolNames } from '../settings/helpers'
import { TITLEBAR_ICON_SIZE, titlebarButtonClass, titlebarHeaderBaseClass } from '../shell/titlebar'
import { titlebarHeaderBaseClass } from '../shell/titlebar'
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
type SkillsMode = 'skills' | 'toolsets'
@ -59,10 +60,10 @@ function filteredToolsets(toolsets: ToolsetInfo[], query: string): ToolsetInfo[]
}
interface SkillsViewProps extends React.ComponentProps<'section'> {
setTitlebarActions?: (actions: ReactNode | null) => void
setTitlebarToolGroup?: SetTitlebarToolGroup
}
export function SkillsView({ setTitlebarActions, ...props }: SkillsViewProps) {
export function SkillsView({ setTitlebarToolGroup, ...props }: SkillsViewProps) {
const [mode, setMode] = useState<SkillsMode>('skills')
const [query, setQuery] = useState('')
const [skills, setSkills] = useState<SkillInfo[] | null>(null)
@ -90,24 +91,22 @@ export function SkillsView({ setTitlebarActions, ...props }: SkillsViewProps) {
}, [refreshCapabilities])
useEffect(() => {
if (!setTitlebarActions) {
if (!setTitlebarToolGroup) {
return
}
setTitlebarActions(
<button
aria-label={refreshing ? 'Refreshing skills' : 'Refresh skills'}
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent')}
disabled={refreshing}
onClick={() => void refreshCapabilities()}
type="button"
>
<RefreshCw className={cn(refreshing && 'animate-spin')} size={TITLEBAR_ICON_SIZE} />
</button>
)
setTitlebarToolGroup('skills', [
{
disabled: refreshing,
icon: <RefreshCw className={cn(refreshing && 'animate-spin')} />,
id: 'refresh-skills',
label: refreshing ? 'Refreshing skills' : 'Refresh skills',
onSelect: () => void refreshCapabilities()
}
])
return () => setTitlebarActions(null)
}, [refreshCapabilities, refreshing, setTitlebarActions])
return () => setTitlebarToolGroup('skills', [])
}, [refreshCapabilities, refreshing, setTitlebarToolGroup])
const categories = useMemo(() => {
if (!skills) {
@ -172,8 +171,8 @@ export function SkillsView({ setTitlebarActions, ...props }: SkillsViewProps) {
className="flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background"
>
<header className={titlebarHeaderBaseClass}>
<h2 className="text-base font-semibold leading-none tracking-tight">Skills</h2>
<span className="text-xs text-muted-foreground">
<h2 className="pointer-events-auto text-base font-semibold leading-none tracking-tight">Skills</h2>
<span className="pointer-events-auto text-xs text-muted-foreground">
{enabledSkills}/{totalSkills} enabled
</span>
</header>

View file

@ -177,7 +177,7 @@ export const Intro: FC<IntroProps> = ({ personality, seed }) => {
}, [advanceFrame, frameOffset])
return (
<div className="pointer-events-none absolute inset-0 z-1 flex 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))] flex-col items-center justify-center px-[calc(var(--vsq)*50)] text-center text-muted-foreground">
<button
aria-label="Change Hermes pose"
className="pointer-events-auto mb-5 h-56 w-64 cursor-default border-0 bg-transparent p-0"

View file

@ -36,6 +36,17 @@ describe('preprocessMarkdown', () => {
expect(output).toContain('- **Scroll wheel** - zoom')
})
it('drops fences around a preview-only URL block', () => {
const fence = '```'
const input = ['Server is back.', '', fence, 'http://localhost:8812/', fence].join('\n')
const output = preprocessMarkdown(input)
expect(output).toContain('Server is back.')
expect(output).not.toContain('```')
expect(output).not.toContain('http://localhost:8812/')
})
it('demotes prose sentence masquerading as fence info', () => {
const input = ['```Heads up - a bunny got added', '- Pure white (`#ffffff`)', '- Ambient dropped to 0.18'].join('\n')
const output = preprocessMarkdown(input)

View file

@ -18,7 +18,7 @@ import {
mediaPathFromMarkdownHref
} from '@/lib/media'
import { isLikelyProseCodeBlock, isLikelyProseFence, sanitizeLanguageTag } from '@/lib/markdown-code'
import { previewTargetFromMarkdownHref, stripPreviewTargets } from '@/lib/preview-targets'
import { isLikelyPreviewCandidate, previewTargetFromMarkdownHref, stripPreviewTargets } from '@/lib/preview-targets'
import { cn } from '@/lib/utils'
/**
@ -77,6 +77,15 @@ function findClosingFence(lines: string[], start: number, marker: string): numbe
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[] = []
@ -116,6 +125,14 @@ function normalizeFenceBlocks(text: string): string {
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
@ -406,7 +423,7 @@ const MarkdownTextImpl = () => {
<li className={cn('leading-relaxed', className)} {...props} />
),
table: ({ className, ...props }: ComponentProps<'table'>) => (
<div className="w-full overflow-x-auto rounded-md border border-border">
<div className="max-w-full overflow-x-auto rounded-md border border-border">
<table
className={cn(
'w-full border-collapse text-sm [&_tr]:border-b [&_tr]:border-border last:[&_tr]:border-0',
@ -442,7 +459,7 @@ const MarkdownTextImpl = () => {
<StreamdownTextPrimitive
caret="block"
components={components}
containerClassName="aui-md text-foreground"
containerClassName="aui-md max-w-full overflow-hidden text-foreground"
lineNumbers={false}
mode="streaming"
parseIncompleteMarkdown

View file

@ -33,6 +33,7 @@ import {
useRef,
useState
} from 'react'
import spinners from 'unicode-animations'
// Scroll behavior: delegated to `use-stick-to-bottom` (StackBlitz), the
// reference implementation that powers bolt.new and several other streaming
// chat UIs. It handles everything we care about — spring-animated catch-up,
@ -44,7 +45,6 @@ import {
// keeping `$threadScrolledUp` in sync with `isAtBottom` for the composer's
// dim-when-scrolled-away treatment.
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
import spinners from 'unicode-animations'
import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
@ -115,9 +115,7 @@ export const Thread: FC<{
}> = ({ intro, loading, onBranchInNewChat, sessionKey }) => {
return (
<GeneratedImageProvider>
<ThreadPrimitive.Root className="relative grid h-full min-h-0 grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent">
<AuiIf condition={s => Boolean(intro) && s.thread.isEmpty}>{intro && <Intro {...intro} />}</AuiIf>
<ThreadPrimitive.Root className="relative grid h-full min-h-0 max-w-full grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent contain-[layout_paint]">
<ThreadPrimitive.ViewportProvider>
{/*
* <StickToBottom> renders a wrapper <div>; <StickToBottom.Content>
@ -140,16 +138,17 @@ export const Thread: FC<{
* content above the composer, not hidden behind it.
*/}
<StickToBottom
className="relative h-full min-h-0"
className="relative h-full min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
initial="instant"
resize="instant"
>
<ThreadScrollSync sessionKey={sessionKey} />
<StickToBottom.Content
className="mx-auto flex w-full max-w-[48rem] 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-[48rem] 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-y-auto overscroll-contain"
scrollClassName="overflow-x-hidden overflow-y-auto overscroll-contain"
>
<AuiIf condition={s => Boolean(intro) && s.thread.isEmpty}>{intro && <Intro {...intro} />}</AuiIf>
<ThreadPrimitive.Messages
components={{
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} />,
@ -357,25 +356,26 @@ const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) =>
* textarea grows (multi-line input), attachments expand, or the composer
* enters a focused/expanded state.
*/
const COMPOSER_BREATHING_ROOM_PX = 20
const COMPOSER_BREATHING_ROOM_PX = 36
const DEFAULT_COMPOSER_CLEARANCE_PX = 192
const ComposerClearance: FC = () => {
const [height, setHeight] = useState<number>(() => {
// Sensible default until the observer wires up (~ 8rem).
if (typeof document === 'undefined') return 128
// Keep enough space even while the floating composer is still mounting.
if (typeof document === 'undefined') {
return DEFAULT_COMPOSER_CLEARANCE_PX
}
const composer = document.querySelector<HTMLElement>('[data-slot="composer-root"]')
return composer ? composer.getBoundingClientRect().height + COMPOSER_BREATHING_ROOM_PX : 128
return composer ? composer.getBoundingClientRect().height + COMPOSER_BREATHING_ROOM_PX : DEFAULT_COMPOSER_CLEARANCE_PX
})
useEffect(() => {
const composer = document.querySelector<HTMLElement>('[data-slot="composer-root"]')
let composerObserver: ResizeObserver | null = null
let observedComposer: HTMLElement | null = null
if (!composer) {
return
}
const apply = () => {
const apply = (composer: HTMLElement) => {
const h = composer.getBoundingClientRect().height
setHeight(prev => {
@ -385,11 +385,30 @@ const ComposerClearance: FC = () => {
})
}
apply()
const observer = new ResizeObserver(apply)
observer.observe(composer)
const bindComposer = () => {
const composer = document.querySelector<HTMLElement>('[data-slot="composer-root"]')
return () => observer.disconnect()
if (!composer || composer === observedComposer) {
return false
}
observedComposer = composer
apply(composer)
composerObserver?.disconnect()
composerObserver = new ResizeObserver(() => apply(composer))
composerObserver.observe(composer)
return true
}
bindComposer()
const mutationObserver = new MutationObserver(() => void bindComposer())
mutationObserver.observe(document.body, { childList: true, subtree: true })
return () => {
composerObserver?.disconnect()
mutationObserver.disconnect()
}
}, [])
return <div aria-hidden="true" className="shrink-0" style={{ height: `${height}px` }} />
@ -435,11 +454,11 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
return (
<MessagePrimitive.Root
className="group flex w-full flex-col gap-2 self-start"
className="group flex w-full min-w-0 max-w-full flex-col gap-2 self-start overflow-hidden"
data-role="assistant"
data-slot="aui_assistant-message-root"
>
<div className="wrap-anywhere text-pretty text-foreground" data-slot="aui_assistant-message-content">
<div className="wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-foreground" data-slot="aui_assistant-message-content">
<MessagePrimitive.Parts
components={{
Text: MarkdownText,
@ -540,7 +559,7 @@ const ThinkingDisclosure: FC<{
const elapsed = useElapsedSeconds(pending)
return (
<div className="mb-3 text-sm text-muted-foreground">
<div className="mb-2 text-sm text-muted-foreground">
<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"
@ -757,11 +776,11 @@ const UserMessage: FC = () => {
return (
<MessagePrimitive.Root
className="group flex max-w-[min(72%,34rem)] flex-col items-end gap-2 self-end"
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 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">
<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>
<div className="min-h-6">

View file

@ -165,7 +165,7 @@ export const ToolFallback = ({ toolCallId, toolName, args, result }: ToolCallMes
}, [isPending])
return (
<div className="mb-3 mt-1 text-sm text-muted-foreground">
<div className="mb-2 mt-2 text-sm text-muted-foreground">
<button
className="inline-grid max-w-full grid-cols-[0.75rem_minmax(0,auto)_minmax(0,1fr)_auto_auto] items-center gap-1 rounded-md py-0.5 pr-1 text-left text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={() => setOpen(v => !v)}

View file

@ -3,6 +3,35 @@ import { describe, expect, it } from 'vitest'
import { appendAssistantTextPart, chatMessageText, renderMediaTags, toChatMessages, upsertToolPart } from './chat-messages'
describe('toChatMessages', () => {
it('merges queued tool-only assistant rows into the previous assistant bubble', () => {
const messages = toChatMessages([
{ role: 'assistant', content: 'Planning.', timestamp: 1 },
{
role: 'assistant',
content: '',
timestamp: 2,
tool_calls: [{ id: 'tc-terminal', function: { name: 'terminal', arguments: '{"command":"ls"}' } }]
},
{ role: 'assistant', content: 'Done.', timestamp: 3 }
])
const assistants = messages.filter(m => m.role === 'assistant')
expect(assistants).toHaveLength(1)
expect(chatMessageText(assistants[0])).toContain('Planning')
expect(chatMessageText(assistants[0])).toContain('Done')
expect(assistants[0].parts.some(p => p.type === 'tool-call' && p.toolName === 'terminal')).toBe(true)
const ordered = assistants[0].parts.map(p => p.type)
expect(ordered.filter(t => t === 'text')).toHaveLength(2)
const toolIdx = assistants[0].parts.findIndex(p => p.type === 'tool-call')
expect(ordered.slice(0, toolIdx)).toContain('text')
expect(
assistants[0].parts.findIndex((p, i) => p.type === 'text' && i > toolIdx)
).toBeGreaterThanOrEqual(0)
})
it('hides attached context payloads from user message display', () => {
const [message] = toChatMessages([
{

View file

@ -514,6 +514,22 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
}
if (message.role === 'assistant' && pendingToolParts.length) {
const last = result.at(-1)
// Session logs interleave tool-only assistant rows with later prose; appending onto
// the previous bubble keeps one turn contiguous (vs unshift splitting the UI).
if (last?.role === 'assistant') {
last.parts = [...last.parts, ...pendingToolParts, ...parts]
pendingToolParts = []
pendingToolTimestamp = undefined
if (message.timestamp !== undefined) {
last.timestamp = message.timestamp
}
return
}
parts.unshift(...pendingToolParts)
pendingToolParts = []
pendingToolTimestamp = undefined