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 7951 additions and 3276 deletions
+134
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>
)
}
+17 -37
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)}
@@ -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}`}
@@ -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>
)
}
@@ -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]
+432 -40
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">
@@ -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)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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,
+68 -103
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'
@@ -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())
}
@@ -0,0 +1 @@
export { ChatPreviewRail, PREVIEW_RAIL_PANE_WIDTH } from './preview'
@@ -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>
)
}
@@ -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>
@@ -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
@@ -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}
/>
)
}
@@ -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('/')}`
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
+23 -12
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>
)
}
@@ -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
+167
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>
)
}
+143
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)
}
+177
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>
)
}
@@ -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([])
})
})
@@ -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]
)
}
@@ -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"
@@ -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
)}
>
+5 -3
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]))
@@ -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])
}
@@ -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 }
}
@@ -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 }
}
@@ -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 }
}
@@ -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')
})
})
})
@@ -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 }
}
@@ -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 = () => {
@@ -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
])
}
@@ -28,8 +28,8 @@ import {
setIntroSeed,
setMessages,
setSelectedStoredSessionId,
setSessionStartedAt,
setSessions,
setSessionStartedAt,
setTurnStartedAt
} from '@/store/session'
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, UsageStats } from '@/types/hermes'
+66 -87
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>
@@ -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
}
}
@@ -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 }
}
@@ -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 }
}
@@ -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>
)
@@ -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>
</>
)
+1 -1
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
@@ -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>
)
@@ -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>
)
}
@@ -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', () => {
@@ -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 (
@@ -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>
)
@@ -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.'
)
})
})
@@ -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
@@ -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}
/>
+10 -19
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>
)
@@ -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)
@@ -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'
@@ -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()
})
})
@@ -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'
@@ -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>
)
@@ -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>
)
}
@@ -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>
)
}
+34
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
+8
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'
+2
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 = {
+13 -3
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
}
+32
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)'
}))
}
+116
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)
}
+182
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')
}
+6 -43
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')
})
})
+3 -236
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)
}
+1
View File
@@ -1,4 +1,5 @@
import './styles.css'
import 'streamdown/styles.css'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { StrictMode } from 'react'
+40 -20
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'
export const FILE_BROWSER_DEFAULT_WIDTH = '17rem'
const SIDEBAR_PINNED_STORAGE_KEY = 'hermes.desktop.pinnedSessions'
const INSPECTOR_OPEN_STORAGE_KEY = 'hermes.desktop.inspectorOpen'
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) {
+146
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'))
})
})
})
+118
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]
+131
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'))
})
})
+282 -2
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)
}
+48
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 })
}
+126 -43
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;
}
+5 -21
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'
+1 -1
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'
+4 -29
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`
}
}
+6 -20
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>
}
@@ -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]
)
}