chore: uptick

This commit is contained in:
Brooklyn Nicholson
2026-05-02 03:19:39 -05:00
parent 420f68e4e2
commit db884f4646
240 changed files with 25206 additions and 3155 deletions
+367 -284
View File
@@ -1,24 +1,21 @@
import {
Copy,
Download,
ExternalLink,
FileImage,
FileText,
FolderOpen,
Layers3,
Link2,
RefreshCw,
Search,
X
} from 'lucide-react'
import { Copy, ExternalLink, FileImage, FileText, FolderOpen, Layers3, Link2, RefreshCw, Search, X } from 'lucide-react'
import type { ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { PageLoader } from '@/components/page-loader'
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import {
Pagination,
PaginationButton,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationNext,
PaginationPrevious
} from '@/components/ui/pagination'
import { getSessionMessages, listSessions } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { cn } from '@/lib/utils'
@@ -49,9 +46,6 @@ const IMAGE_EXT_RE = /\.(?:png|jpe?g|gif|webp|svg|bmp)(?:\?.*)?$/i
const FILE_EXT_RE = /\.(?:png|jpe?g|gif|webp|svg|bmp|pdf|txt|json|md|csv|zip|tar|gz|mp3|wav|mp4|mov)(?:\?.*)?$/i
const KEY_HINT_RE = /(path|file|url|image|artifact|output|download|result|target)/i
const imageActionButtonClass =
'absolute right-2 top-2 grid size-8 place-items-center rounded-full border border-border/70 bg-background/80 text-muted-foreground opacity-0 shadow-sm backdrop-blur transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 disabled:opacity-50'
const ARTIFACT_TIME_FMT = new Intl.DateTimeFormat(undefined, {
day: 'numeric',
hour: 'numeric',
@@ -308,6 +302,43 @@ function formatArtifactTime(timestamp: number): string {
return ARTIFACT_TIME_FMT.format(new Date(timestamp))
}
function pageRangeLabel(total: number, page: number, pageSize: number): string {
if (total === 0) {
return '0'
}
const start = (page - 1) * pageSize + 1
const end = Math.min(total, page * pageSize)
return `${start}-${end} of ${total}`
}
function paginationItems(page: number, pageCount: number): Array<number | 'ellipsis'> {
if (pageCount <= 7) {
return Array.from({ length: pageCount }, (_, index) => index + 1)
}
const pages: Array<number | 'ellipsis'> = [1]
const start = Math.max(2, page - 1)
const end = Math.min(pageCount - 1, page + 1)
if (start > 2) {
pages.push('ellipsis')
}
for (let nextPage = start; nextPage <= end; nextPage += 1) {
pages.push(nextPage)
}
if (end < pageCount - 1) {
pages.push('ellipsis')
}
pages.push(pageCount)
return pages
}
interface ArtifactsViewProps extends React.ComponentProps<'section'> {
setTitlebarActions?: (actions: ReactNode | null) => void
}
@@ -318,15 +349,15 @@ export function ArtifactsView({ setTitlebarActions, ...props }: ArtifactsViewPro
const [query, setQuery] = useState('')
const [kindFilter, setKindFilter] = useState<'all' | ArtifactKind>('all')
const [refreshing, setRefreshing] = useState(false)
const [savingArtifactId, setSavingArtifactId] = useState<string | null>(null)
const [failedImageIds, setFailedImageIds] = useState<Set<string>>(() => new Set())
const [lightboxArtifact, setLightboxArtifact] = useState<ArtifactRecord | null>(null)
const [imagePage, setImagePage] = useState(1)
const [filePage, setFilePage] = useState(1)
const refreshArtifacts = useCallback(async () => {
setRefreshing(true)
try {
const sessions = (await listSessions(30)).sessions
const sessions = (await listSessions(30, 1)).sessions
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id)))
const nextArtifacts: ArtifactRecord[] = []
@@ -372,6 +403,11 @@ export function ArtifactsView({ setTitlebarActions, ...props }: ArtifactsViewPro
return () => setTitlebarActions(null)
}, [refreshArtifacts, refreshing, setTitlebarActions])
useEffect(() => {
setImagePage(1)
setFilePage(1)
}, [artifacts, kindFilter, query])
const visibleArtifacts = useMemo(() => {
if (!artifacts) {
return []
@@ -396,6 +432,31 @@ export function ArtifactsView({ setTitlebarActions, ...props }: ArtifactsViewPro
})
}, [artifacts, kindFilter, query])
const visibleImageArtifacts = useMemo(
() => visibleArtifacts.filter(artifact => artifact.kind === 'image'),
[visibleArtifacts]
)
const visibleFileArtifacts = useMemo(
() => visibleArtifacts.filter(artifact => artifact.kind !== 'image'),
[visibleArtifacts]
)
const imagePageCount = Math.max(1, Math.ceil(visibleImageArtifacts.length / 24))
const filePageCount = Math.max(1, Math.ceil(visibleFileArtifacts.length / 100))
const currentImagePage = Math.min(imagePage, imagePageCount)
const currentFilePage = Math.min(filePage, filePageCount)
const pagedImageArtifacts = useMemo(
() => visibleImageArtifacts.slice((currentImagePage - 1) * 24, currentImagePage * 24),
[currentImagePage, visibleImageArtifacts]
)
const pagedFileArtifacts = useMemo(
() => visibleFileArtifacts.slice((currentFilePage - 1) * 100, currentFilePage * 100),
[currentFilePage, visibleFileArtifacts]
)
const counts = useMemo(() => {
const all = artifacts || []
@@ -437,34 +498,6 @@ export function ArtifactsView({ setTitlebarActions, ...props }: ArtifactsViewPro
}
}, [])
const saveImageArtifact = useCallback(async (artifact: ArtifactRecord) => {
if (artifact.kind !== 'image') {
return
}
setSavingArtifactId(artifact.id)
try {
if (!window.hermesDesktop?.saveImageFromUrl) {
throw new Error('Image saving is unavailable in this build.')
}
const saved = await window.hermesDesktop.saveImageFromUrl(artifact.href)
if (saved) {
notify({
kind: 'success',
title: 'Image saved',
message: artifact.label
})
}
} catch (err) {
notifyError(err, 'Save failed')
} finally {
setSavingArtifactId(null)
}
}, [])
const markImageFailed = useCallback((id: string) => {
setFailedImageIds(current => {
if (current.has(id)) {
@@ -475,136 +508,208 @@ export function ArtifactsView({ setTitlebarActions, ...props }: ArtifactsViewPro
})
}, [])
const imageLightbox = lightboxArtifact ? (
<Dialog onOpenChange={open => !open && setLightboxArtifact(null)} open>
<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"
showCloseButton={false}
>
<div className="group/lightbox relative max-h-[calc(100vh-2rem)] max-w-[calc(100vw-2rem)] overflow-auto">
<img
alt={lightboxArtifact.label}
className="block max-h-[calc(100vh-2rem)] max-w-full cursor-zoom-out select-auto rounded-lg object-contain shadow-2xl"
onClick={() => setLightboxArtifact(null)}
src={lightboxArtifact.href}
/>
<button
aria-label={savingArtifactId === lightboxArtifact.id ? 'Saving image' : 'Download image'}
className={cn(imageActionButtonClass, 'group-hover/lightbox:opacity-100')}
disabled={savingArtifactId === lightboxArtifact.id}
onClick={event => {
event.stopPropagation()
void saveImageArtifact(lightboxArtifact)
}}
title={savingArtifactId === lightboxArtifact.id ? 'Saving image' : 'Download image'}
type="button"
>
<Download className={cn('size-4', savingArtifactId === lightboxArtifact.id && 'animate-pulse')} />
</button>
</div>
</DialogContent>
</Dialog>
) : null
return (
<>
<section
{...props}
className="flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background"
>
<header className={titlebarHeaderBaseClass}>
<h2 className="text-base font-semibold leading-none tracking-tight">Artifacts</h2>
<span className="text-xs text-muted-foreground">{counts.all} found</span>
</header>
<section
{...props}
className="flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background"
>
<header className={titlebarHeaderBaseClass}>
<h2 className="text-base font-semibold leading-none tracking-tight">Artifacts</h2>
<span className="text-xs text-muted-foreground">{counts.all} found</span>
</header>
<div className="min-h-0 flex-1 overflow-hidden rounded-[1.0625rem] border border-border/50 bg-background/85">
<div className="border-b border-border/50 px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<FilterButton
active={kindFilter === 'all'}
icon={Layers3}
label={`All (${counts.all})`}
onClick={() => setKindFilter('all')}
/>
<FilterButton
active={kindFilter === 'image'}
icon={FileImage}
label={`Images (${counts.image})`}
onClick={() => setKindFilter('image')}
/>
<FilterButton
active={kindFilter === 'file'}
icon={FileText}
label={`Files (${counts.file})`}
onClick={() => setKindFilter('file')}
/>
<FilterButton
active={kindFilter === 'link'}
icon={Link2}
label={`Links (${counts.link})`}
onClick={() => setKindFilter('link')}
/>
<div className="ml-auto w-full max-w-sm min-w-64">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
className="h-8 rounded-lg pl-8 pr-8 text-sm"
onChange={event => setQuery(event.target.value)}
placeholder="Search artifacts..."
value={query}
/>
{query && (
<Button
aria-label="Clear search"
className="absolute right-1 top-1/2 h-6 w-6 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setQuery('')}
size="icon"
type="button"
variant="ghost"
>
<X className="size-3.5" />
</Button>
)}
</div>
<div className="min-h-0 flex-1 overflow-hidden rounded-[1.0625rem] border border-border/50 bg-background/85">
<div className="border-b border-border/50 px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<FilterButton
active={kindFilter === 'all'}
icon={Layers3}
label={`All (${counts.all})`}
onClick={() => setKindFilter('all')}
/>
<FilterButton
active={kindFilter === 'image'}
icon={FileImage}
label={`Images (${counts.image})`}
onClick={() => setKindFilter('image')}
/>
<FilterButton
active={kindFilter === 'file'}
icon={FileText}
label={`Files (${counts.file})`}
onClick={() => setKindFilter('file')}
/>
<FilterButton
active={kindFilter === 'link'}
icon={Link2}
label={`Links (${counts.link})`}
onClick={() => setKindFilter('link')}
/>
<div className="ml-auto w-full max-w-sm min-w-64">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
className="h-8 rounded-lg pl-8 pr-8 text-sm"
onChange={event => setQuery(event.target.value)}
placeholder="Search artifacts..."
value={query}
/>
{query && (
<Button
aria-label="Clear search"
className="absolute right-1 top-1/2 h-6 w-6 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setQuery('')}
size="icon"
type="button"
variant="ghost"
>
<X className="size-3.5" />
</Button>
)}
</div>
</div>
</div>
{!artifacts ? (
<PageLoader label="Indexing recent session artifacts" />
) : visibleArtifacts.length === 0 ? (
<div className="grid h-full place-items-center px-6 text-center">
<div>
<div className="text-sm font-medium">No artifacts found</div>
<div className="mt-1 text-xs text-muted-foreground">
Generated images and file outputs will appear here as sessions produce them.
</div>
</div>
</div>
) : (
<div className="h-full overflow-y-auto p-3">
<div className="grid grid-cols-[repeat(auto-fill,minmax(13rem,1fr))] items-start gap-3">
{visibleArtifacts.map(artifact => (
<ArtifactCard
artifact={artifact}
failedImage={failedImageIds.has(artifact.id)}
key={artifact.id}
onCopy={copyArtifact}
onImageError={markImageFailed}
onOpen={openArtifact}
onOpenChat={sessionId => navigate(sessionRoute(sessionId))}
onSaveImage={saveImageArtifact}
onZoom={setLightboxArtifact}
saving={savingArtifactId === artifact.id}
/>
))}
</div>
</div>
)}
</div>
</section>
{imageLightbox}
</>
{!artifacts ? (
<PageLoader label="Indexing recent session artifacts" />
) : visibleArtifacts.length === 0 ? (
<div className="grid h-full place-items-center px-6 text-center">
<div>
<div className="text-sm font-medium">No artifacts found</div>
<div className="mt-1 text-xs text-muted-foreground">
Generated images and file outputs will appear here as sessions produce them.
</div>
</div>
</div>
) : (
<div className="h-full overflow-y-auto">
<div className="flex flex-col gap-4 px-2 pb-2">
{visibleImageArtifacts.length > 0 && (
<section aria-labelledby="artifacts-images-heading" className="flex flex-col">
<div className="sticky top-0 z-10 -mx-2 flex h-7 items-center justify-between gap-3 overflow-x-auto bg-background px-3">
<h3 className="shrink-0 text-xs font-semibold" id="artifacts-images-heading">
Images
</h3>
<ArtifactsPagination
className="justify-end px-0"
itemLabel="images"
onPageChange={setImagePage}
page={currentImagePage}
pageSize={24}
total={visibleImageArtifacts.length}
/>
</div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(12rem,1fr))] items-start gap-2 pt-1.5">
{pagedImageArtifacts.map(artifact => (
<ArtifactImageCard
artifact={artifact}
failedImage={failedImageIds.has(artifact.id)}
key={artifact.id}
onImageError={markImageFailed}
onOpenChat={sessionId => navigate(sessionRoute(sessionId))}
/>
))}
</div>
</section>
)}
{visibleFileArtifacts.length > 0 && (
<section aria-labelledby="artifacts-files-heading" className="flex flex-col">
<div className="sticky top-0 z-10 -mx-2 flex h-7 items-center justify-between gap-3 overflow-x-auto bg-background px-3">
<h3 className="shrink-0 text-xs font-semibold" id="artifacts-files-heading">
{kindFilter === 'link' ? 'Links' : kindFilter === 'file' ? 'Files' : 'Files and links'}
</h3>
<ArtifactsPagination
className="justify-end px-0"
itemLabel="files"
onPageChange={setFilePage}
page={currentFilePage}
pageSize={100}
total={visibleFileArtifacts.length}
/>
</div>
<div className="overflow-x-auto rounded-lg border border-border/50 bg-background/70 shadow-[0_0.125rem_0.5rem_color-mix(in_srgb,black_3%,transparent)]">
<table className="w-full min-w-176 table-fixed text-left text-xs">
<thead className="border-b border-border/50 bg-muted/35 text-[0.62rem] uppercase tracking-[0.08em] text-muted-foreground">
<tr>
<th className="w-[31%] px-2.5 py-1.5 font-medium">Name</th>
<th className="w-[35%] px-2.5 py-1.5 font-medium">Location</th>
<th className="w-[22%] px-2.5 py-1.5 font-medium">Session</th>
<th className="w-[12%] px-2.5 py-1.5 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border/45">
{pagedFileArtifacts.map(artifact => (
<ArtifactListRow
artifact={artifact}
key={artifact.id}
onCopy={copyArtifact}
onOpen={openArtifact}
onOpenChat={sessionId => navigate(sessionRoute(sessionId))}
/>
))}
</tbody>
</table>
</div>
</section>
)}
</div>
</div>
)}
</div>
</section>
)
}
interface ArtifactsPaginationProps {
className?: string
itemLabel: string
onPageChange: (page: number) => void
page: number
pageSize: number
total: number
}
function ArtifactsPagination({ className, itemLabel, onPageChange, page, pageSize, total }: ArtifactsPaginationProps) {
const pageCount = Math.max(1, Math.ceil(total / pageSize))
return (
<div className={cn('flex h-6 items-center justify-between gap-2 px-1', className)}>
<div className="shrink-0 text-[0.62rem] text-muted-foreground">
{pageRangeLabel(total, page, pageSize)} {itemLabel}
</div>
{pageCount > 1 && (
<Pagination className="mx-0 w-auto min-w-0 justify-end">
<PaginationContent className="gap-0.5">
<PaginationItem>
<PaginationPrevious disabled={page <= 1} onClick={() => onPageChange(Math.max(1, page - 1))} />
</PaginationItem>
{paginationItems(page, pageCount).map((item, index) => (
<PaginationItem key={`${item}-${index}`}>
{item === 'ellipsis' ? (
<PaginationEllipsis />
) : (
<PaginationButton
aria-label={`Go to ${itemLabel} page ${item}`}
isActive={page === item}
onClick={() => onPageChange(item)}
>
{item}
</PaginationButton>
)}
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
disabled={page >= pageCount}
onClick={() => onPageChange(Math.min(pageCount, page + 1))}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
)
}
@@ -636,52 +741,106 @@ function FilterButton({
)
}
interface ArtifactCardProps {
interface ArtifactImageCardProps {
artifact: ArtifactRecord
failedImage: boolean
onCopy: (value: string) => void | Promise<void>
onImageError: (id: string) => void
onOpen: (href: string) => void | Promise<void>
onOpenChat: (sessionId: string) => void
onSaveImage: (artifact: ArtifactRecord) => void | Promise<void>
onZoom: (artifact: ArtifactRecord) => void
saving: boolean
}
function ArtifactCard({
artifact,
failedImage,
onCopy,
onImageError,
onOpen,
onOpenChat,
onSaveImage,
onZoom,
saving
}: ArtifactCardProps) {
const image = artifact.kind === 'image'
if (!image) {
const Icon = artifact.kind === 'file' ? FileText : Link2
return (
<article className="group/artifact grid grid-cols-[2rem_minmax(0,1fr)_auto] items-start gap-2 rounded-xl border border-border/50 bg-background/70 p-3 shadow-[0_0.1875rem_0.75rem_color-mix(in_srgb,black_3%,transparent)]">
<div className="mt-0.5 grid size-8 place-items-center rounded-lg bg-muted text-muted-foreground">
<Icon className="size-4" />
</div>
function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }: ArtifactImageCardProps) {
return (
<article
className={cn(
'group/artifact overflow-hidden rounded-lg border border-border/50 bg-background/70 shadow-[0_0.125rem_0.5rem_color-mix(in_srgb,black_3%,transparent)]',
'bg-muted/20'
)}
>
<div
className={cn(
'relative flex h-44 w-full items-center justify-center overflow-hidden border-b border-border/50 bg-[color-mix(in_srgb,var(--dt-muted)_58%,var(--dt-background))] p-1.5',
failedImage && 'cursor-default'
)}
>
{!failedImage && (
<ZoomableImage
alt={artifact.label}
className="max-h-40 max-w-full rounded-md object-contain shadow-sm"
containerClassName="max-h-full"
decoding="async"
loading="lazy"
onError={() => onImageError(artifact.id)}
slot="artifact-media"
src={artifact.href}
/>
)}
</div>
<div className="space-y-1.5 p-2">
<div className="min-w-0">
<div className="mb-1 flex items-center gap-1.5 text-[0.68rem] uppercase tracking-[0.08em] text-muted-foreground">
<div className="mb-0.5 flex items-center gap-1 text-[0.62rem] uppercase tracking-[0.08em] text-muted-foreground">
<FileImage className="size-3" />
{artifact.kind}
</div>
<div className="truncate text-sm font-medium">{artifact.label}</div>
<div className="mt-0.5 truncate font-mono text-[0.68rem] text-muted-foreground/80">{artifact.value}</div>
<div className="mt-2 truncate text-[0.68rem] text-muted-foreground">
{artifact.sessionTitle} · {formatArtifactTime(artifact.timestamp)}
</div>
<div className="truncate text-xs font-medium">{artifact.label}</div>
<div className="mt-0.5 truncate text-[0.62rem] text-muted-foreground">{artifact.value}</div>
</div>
<div className="flex items-center gap-0.5 opacity-70 transition-opacity group-hover/artifact:opacity-100">
<div className="truncate text-[0.62rem] text-muted-foreground">
{artifact.sessionTitle} · {formatArtifactTime(artifact.timestamp)}
</div>
<div className="flex flex-wrap gap-1.5">
<Button onClick={() => onOpenChat(artifact.sessionId)} size="xs" type="button" variant="outline">
<FolderOpen className="size-3" />
Chat
</Button>
</div>
</div>
</article>
)
}
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) {
const Icon = artifact.kind === 'file' ? FileText : Link2
return (
<tr className="group/artifact transition-colors hover:bg-muted/30">
<td className="px-2.5 py-1.5 align-middle">
<div className="flex min-w-0 items-center gap-2">
<div className="grid size-7 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground">
<Icon className="size-3.5" />
</div>
<div className="min-w-0">
<div className="truncate font-medium" title={artifact.label}>
{artifact.label}
</div>
<div className="text-[0.6rem] uppercase tracking-[0.08em] text-muted-foreground">{artifact.kind}</div>
</div>
</div>
</td>
<td className="px-2.5 py-1.5 align-middle">
<div className="truncate font-mono text-[0.68rem] text-muted-foreground/85" title={artifact.value}>
{artifact.value}
</div>
</td>
<td className="px-2.5 py-1.5 align-middle">
<div className="min-w-0">
<div className="truncate text-[0.68rem] text-muted-foreground" title={artifact.sessionTitle}>
{artifact.sessionTitle}
</div>
<div className="text-[0.6rem] text-muted-foreground/75">{formatArtifactTime(artifact.timestamp)}</div>
</div>
</td>
<td className="px-2.5 py-1.5 align-middle">
<div className="flex justify-end gap-0.5 opacity-70 transition-opacity group-hover/artifact:opacity-100">
<Button
className="text-muted-foreground hover:text-foreground"
onClick={() => void onOpen(artifact.href)}
@@ -713,83 +872,7 @@ function ArtifactCard({
<FolderOpen className="size-3.5" />
</Button>
</div>
</article>
)
}
return (
<article
className={cn(
'group/artifact overflow-hidden rounded-xl border border-border/50 bg-background/70 shadow-[0_0.1875rem_0.75rem_color-mix(in_srgb,black_3%,transparent)]',
image && 'bg-muted/20'
)}
>
{image && (
<button
aria-label={failedImage ? undefined : `Open ${artifact.label}`}
className={cn(
'relative flex h-56 w-full items-center justify-center overflow-hidden border-b border-border/50 bg-[color-mix(in_srgb,var(--dt-muted)_58%,var(--dt-background))] p-2',
failedImage ? 'cursor-default' : 'cursor-zoom-in'
)}
disabled={failedImage}
onClick={() => onZoom(artifact)}
title={failedImage ? undefined : 'Open image'}
type="button"
>
{!failedImage && (
<>
<img
alt=""
className="max-h-full max-w-full rounded-md object-contain shadow-sm"
data-slot="artifact-media"
decoding="async"
loading="lazy"
onError={() => onImageError(artifact.id)}
src={artifact.href}
/>
<span
aria-label={saving ? 'Saving image' : 'Download image'}
className={cn(imageActionButtonClass, 'group-hover/artifact:opacity-100')}
onClick={event => {
event.stopPropagation()
void onSaveImage(artifact)
}}
title={saving ? 'Saving image' : 'Download image'}
>
<Download className={cn('size-4', saving && 'animate-pulse')} />
</span>
</>
)}
</button>
)}
<div className="space-y-2 p-3">
<div className="min-w-0">
<div className="mb-1 flex items-center gap-1.5 text-[0.68rem] uppercase tracking-[0.08em] text-muted-foreground">
{image ? (
<FileImage className="size-3.5" />
) : artifact.kind === 'file' ? (
<FileText className="size-3.5" />
) : (
<Link2 className="size-3.5" />
)}
{artifact.kind}
</div>
<div className="truncate text-sm font-medium">{artifact.label}</div>
<div className="mt-0.5 truncate text-[0.68rem] text-muted-foreground">{artifact.value}</div>
</div>
<div className="truncate text-[0.68rem] text-muted-foreground">
{artifact.sessionTitle} · {formatArtifactTime(artifact.timestamp)}
</div>
<div className="flex flex-wrap gap-1.5">
<Button onClick={() => onOpenChat(artifact.sessionId)} size="sm" type="button" variant="outline">
<FolderOpen className="size-3.5" />
Chat
</Button>
</div>
</div>
</article>
</td>
</tr>
)
}
@@ -10,7 +10,7 @@ export function AttachmentList({
onRemove?: (id: string) => void
}) {
return (
<div className="flex flex-wrap gap-1.5 px-1 pt-1">
<div className="flex flex-wrap gap-1 px-1 pt-1">
{attachments.map(a => (
<AttachmentPill attachment={a} key={a.id} onRemove={onRemove} />
))}
@@ -22,28 +22,30 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText }[attachment.kind]
return (
<div className="group/attachment flex max-w-full items-center gap-2 rounded-2xl border border-border/70 bg-muted/35 py-1 pl-1 pr-1.5 text-xs text-foreground/90">
{attachment.previewUrl ? (
<img alt="" className="size-9 rounded-xl object-cover" draggable={false} src={attachment.previewUrl} />
<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-9 shrink-0 place-items-center rounded-xl bg-background/70 text-muted-foreground">
<Icon className="size-4" />
<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" />
</span>
)}
<span className="grid min-w-0 gap-0.5">
<span className="truncate font-medium">{attachment.label}</span>
{attachment.detail && (
<span className="truncate text-[0.6875rem] text-muted-foreground">{attachment.detail}</span>
)}
</span>
{onRemove && (
<button
aria-label={`Remove ${attachment.label}`}
className="grid size-5 shrink-0 place-items-center rounded-full text-muted-foreground opacity-70 transition hover:bg-accent hover:text-foreground group-hover/attachment:opacity-100"
className="absolute -right-1 -top-1 grid size-3.5 place-items-center rounded-full border border-border/70 bg-background text-muted-foreground opacity-0 shadow-xs transition hover:bg-accent hover:text-foreground group-hover/attachment:opacity-100 focus-visible:opacity-100"
onClick={() => onRemove(attachment.id)}
type="button"
>
<X className="size-3.5" />
<X className="size-2.5" />
</button>
)}
</div>
@@ -7,7 +7,7 @@ import { cn } from '@/lib/utils'
import type { ConversationStatus } from './hooks/use-voice-conversation'
import type { ChatBarState, VoiceStatus } from './types'
export const ICON_BTN = 'h-8 w-8 shrink-0 rounded-full'
export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-full'
export const GHOST_ICON_BTN = cn(ICON_BTN, 'text-muted-foreground hover:bg-accent hover:text-foreground')
interface ConversationProps {
@@ -47,7 +47,7 @@ export function ComposerControls({
const showVoicePrimary = !busy && !hasComposerPayload
return (
<div className="ml-auto flex shrink-0 items-center gap-1.5">
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
{showVoicePrimary ? (
<Button
@@ -102,7 +102,7 @@ function ConversationPill({
: 'Listening'
return (
<div className="ml-auto flex shrink-0 items-center gap-1">
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<Button
aria-label={muted ? 'Unmute microphone' : 'Mute microphone'}
aria-pressed={muted}
@@ -122,7 +122,7 @@ function ConversationPill({
{listening && (
<Button
aria-label="Stop listening and send"
className="h-8 shrink-0 gap-1.5 rounded-full px-2.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
className="h-(--composer-control-size) shrink-0 gap-1.5 rounded-full px-2.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
disabled={disabled}
onClick={() => {
triggerHaptic('submit')
@@ -138,7 +138,7 @@ function ConversationPill({
)}
<Button
aria-label="End voice conversation"
className="h-8 gap-1.5 rounded-full bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
className="h-(--composer-control-size) gap-1.5 rounded-full bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
disabled={disabled}
onClick={() => {
triggerHaptic('close')
@@ -2,22 +2,22 @@ import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-u
import { useCallback } from 'react'
import type { HermesGateway } from '@/hermes'
import {
desktopSlashDescription,
filterDesktopCommandsCatalog,
isDesktopSlashSuggestion,
type CommandsCatalogLike
} from '@/lib/desktop-slash-commands'
import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter'
import { useLiveCompletionAdapter } from './use-live-completion-adapter'
const PICKER_OWNED = new Set(['/model', '/provider', 'model', 'provider'])
interface SlashItemMetadata extends Record<string, string> {
command: string
display: string
meta: string
}
interface CommandsCatalogResponse {
pairs?: [string, string][]
}
function textValue(value: unknown, fallback = ''): string {
if (typeof value === 'string') {
return value
@@ -53,14 +53,9 @@ export function useSlashCompletions(options: { gateway: HermesGateway | null }):
const text = `/${query}`
// Model/provider have a dedicated picker; suppress slash completions for them once typed.
if (text.startsWith('/model') || text.startsWith('/provider')) {
return { items: [], query }
}
try {
if (!query) {
const catalog = await gateway.request<CommandsCatalogResponse>('commands.catalog')
const catalog = filterDesktopCommandsCatalog(await gateway.request<CommandsCatalogLike>('commands.catalog'))
const items = (catalog.pairs ?? [])
.map(([command, meta]) => ({
@@ -68,13 +63,17 @@ export function useSlashCompletions(options: { gateway: HermesGateway | null }):
display: command,
meta
}))
.filter(item => !PICKER_OWNED.has(item.text))
return { items, query }
}
const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.slash', { text })
const items = (result.items ?? []).filter(item => !PICKER_OWNED.has(item.text))
const items = (result.items ?? [])
.filter(item => isDesktopSlashSuggestion(item.text))
.map(item => ({
...item,
meta: desktopSlashDescription(item.text, textValue(item.meta))
}))
return { items, query }
} catch {
+239 -20
View File
@@ -3,17 +3,27 @@ import './liquid-glass-overrides.css'
import { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import LiquidGlass from 'liquid-glass-react'
import { type ClipboardEvent, type CSSProperties, useEffect, useRef, useState } from 'react'
import {
type ClipboardEvent,
type CSSProperties,
type DragEvent as ReactDragEvent,
useEffect,
useRef,
useState
} from 'react'
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { useMediaQuery } from '@/hooks/use-media-query'
import { chatMessageText } from '@/lib/chat-messages'
import { DATA_IMAGE_URL_RE, dataUrlToBlob } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $composerAttachments } from '@/store/composer'
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 { AttachmentList } from './attachments'
import { ContextMenu } from './context-menu'
import { ComposerControls } from './controls'
@@ -24,13 +34,73 @@ 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 { SkinSlashPopover } from './skin-slash-popover'
import { SlashPopover } from './slash-popover'
import type { ChatBarProps } from './types'
import { UrlDialog } from './url-dialog'
import { VoiceActivity, VoicePlaybackActivity } from './voice-activity'
const COMPOSER_SHELL_CLASS =
'group/composer absolute bottom-0 left-1/2 z-30 w-[min(calc(100%-1rem),clamp(26rem,61.8%,56rem))] max-w-full -translate-x-1/2 pt-2 pb-[var(--composer-shell-pad-block-end)]'
'group/composer absolute bottom-0 left-1/2 z-30 max-w-full -translate-x-1/2 pt-2 pb-[var(--composer-shell-pad-block-end)]'
function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] {
const blobs: Blob[] = []
const seen = new Set<Blob>()
const push = (blob: Blob | null) => {
if (!blob || blob.size === 0 || seen.has(blob)) {
return
}
seen.add(blob)
blobs.push(blob)
}
if (clipboard.items?.length) {
for (const item of clipboard.items) {
if (item.kind === 'file' && item.type.startsWith('image/')) {
push(item.getAsFile())
}
}
}
if (clipboard.files?.length) {
for (let i = 0; i < clipboard.files.length; i += 1) {
const file = clipboard.files.item(i)
if (file && file.type.startsWith('image/')) {
push(file)
}
}
}
if (blobs.length > 0) {
return blobs
}
const text = clipboard.getData('text/plain').trim()
if (DATA_IMAGE_URL_RE.test(text)) {
push(dataUrlToBlob(text))
}
if (blobs.length === 0) {
const html = clipboard.getData('text/html')
if (html) {
const matches = html.matchAll(/<img\b[^>]*?\bsrc\s*=\s*["'](data:image\/[^"']+)["']/gi)
for (const match of matches) {
push(dataUrlToBlob(match[1]))
}
}
}
return blobs
}
// Below this composer width the input gets cramped — drop controls onto a second row.
const COMPOSER_STACK_BREAKPOINT_PX = 380
const COMPOSER_SCROLLED_DIM_CLASS =
'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
@@ -58,6 +128,8 @@ export function ChatBar({
state,
onCancel,
onAddUrl,
onAttachDroppedItems,
onAttachImageBlob,
onPasteClipboardImage,
onPickFiles,
onPickFolders,
@@ -82,9 +154,11 @@ export function ChatBar({
const [expanded, setExpanded] = useState(false)
const [voiceConversationActive, setVoiceConversationActive] = useState(false)
const [tight, setTight] = useState(false)
const [dragActive, setDragActive] = useState(false)
const dragDepthRef = useRef(0)
const lastSpokenIdRef = useRef<string | null>(null)
const narrow = useMediaQuery('(max-width: 680px)')
const narrow = useMediaQuery('(max-width: 480px)')
const [askPlaceholder] = useState(() => {
const lines = [
@@ -108,9 +182,17 @@ export function ChatBar({
const canSubmit = busy || hasComposerPayload
const showHelpHint = draft === '?'
const placeholder = disabled
? stacked
? 'Starting...'
: 'Starting Hermes...'
: stacked
? 'Ask anything'
: askPlaceholder
const glassTweaks = useComposerGlassTweaks()
const focusInput = () => window.requestAnimationFrame(() => textareaRef.current?.focus())
const focusInput = () => window.requestAnimationFrame(() => textareaRef.current?.focus({ preventScroll: true }))
useEffect(() => {
if (!disabled) {
@@ -120,11 +202,22 @@ export function ChatBar({
useEffect(() => {
draftRef.current = draft
$composerDraft.set(draft)
}, [draft])
useEffect(
() =>
$composerDraft.subscribe(value => {
if (value !== draftRef.current) {
aui.composer().setText(value)
}
}),
[aui]
)
useEffect(() => {
if (urlOpen) {
window.requestAnimationFrame(() => urlInputRef.current?.focus())
window.requestAnimationFrame(() => urlInputRef.current?.focus({ preventScroll: true }))
}
}, [urlOpen])
@@ -153,7 +246,7 @@ export function ChatBar({
return
}
const update = () => setTight(el.getBoundingClientRect().width < 500)
const update = () => setTight(el.getBoundingClientRect().width < COMPOSER_STACK_BREAKPOINT_PX)
update()
const ro = new ResizeObserver(update)
@@ -172,13 +265,45 @@ export function ChatBar({
focusInput()
}
const selectSkinSlashCommand = (command: string) => {
draftRef.current = command
aui.composer().setText(command)
focusInput()
}
const handlePaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
if (imageBlobs.length > 0) {
event.preventDefault()
if (onAttachImageBlob) {
triggerHaptic('selection')
for (const blob of imageBlobs) {
void onAttachImageBlob(blob)
}
}
return
}
const pastedText = event.clipboardData.getData('text')
if (!pastedText) {
return
}
// Some clipboard sources deliver an image as a giant `data:image/...;base64,...`
// text/plain payload. Without this guard the whole base64 string would be
// inserted into the textarea (and persisted as the user message). Drop it
// outright — image pastes belong on the image-blob path above.
if (DATA_IMAGE_URL_RE.test(pastedText.trim())) {
event.preventDefault()
return
}
const trimmedText = pastedText.replace(/^[\t ]*(?:\r\n|\r|\n)+|(?:\r\n|\r|\n)+[\t ]*$/g, '')
if (trimmedText === pastedText) {
@@ -202,19 +327,99 @@ export function ChatBar({
return
}
current.focus()
current.focus({ preventScroll: true })
current.setSelectionRange(cursor, cursor)
})
}
const dragHasAttachments = (transfer: DataTransfer | null) => {
if (!transfer) {
return false
}
if (Array.from(transfer.types || []).includes('Files')) {
return true
}
return Array.from(transfer.items || []).some(item => item.kind === 'file')
}
const resetDragState = () => {
dragDepthRef.current = 0
setDragActive(false)
}
const handleDragEnter = (event: ReactDragEvent<HTMLFormElement>) => {
if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer)) {
return
}
event.preventDefault()
dragDepthRef.current += 1
if (!dragActive) {
setDragActive(true)
}
}
const handleDragOver = (event: ReactDragEvent<HTMLFormElement>) => {
if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer)) {
return
}
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
}
const handleDragLeave = (event: ReactDragEvent<HTMLFormElement>) => {
if (!onAttachDroppedItems) {
return
}
event.preventDefault()
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0) {
setDragActive(false)
}
}
const handleDrop = (event: ReactDragEvent<HTMLFormElement>) => {
if (!onAttachDroppedItems) {
return
}
event.preventDefault()
resetDragState()
const candidates = extractDroppedFiles(event.dataTransfer)
if (candidates.length === 0) {
return
}
void Promise.resolve(onAttachDroppedItems(candidates)).then(attached => {
if (attached) {
triggerHaptic('selection')
focusInput()
}
})
}
const clearDraft = () => {
aui.composer().setText('')
draftRef.current = ''
}
const submitDraft = () => {
if (busy) {
triggerHaptic('cancel')
onCancel()
} else if (draft.trim() || attachments.length > 0) {
const submitted = draft
triggerHaptic('submit')
void onSubmit(draft)
aui.composer().setText('')
clearDraft()
void onSubmit(submitted)
}
focusInput()
@@ -281,9 +486,8 @@ export function ChatBar({
}
triggerHaptic('submit')
clearDraft()
await onSubmit(text)
aui.composer().setText('')
draftRef.current = ''
}
const conversation = useVoiceConversation({
@@ -339,13 +543,13 @@ export function ChatBar({
const input = (
<ComposerPrimitive.Input
className={cn(
'min-h-8 max-h-37.5 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',
'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-48 flex-1'
stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1'
)}
disabled={disabled}
onPaste={handlePaste}
placeholder={disabled ? 'Starting Hermes...' : askPlaceholder}
placeholder={placeholder}
ref={textareaRef}
rows={1}
unstable_focusOnScrollToBottom={false}
@@ -357,8 +561,13 @@ export function ChatBar({
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
<ComposerPrimitive.Root
className={COMPOSER_SHELL_CLASS}
data-drag-active={dragActive ? '' : undefined}
data-slot="composer-root"
data-thread-scrolled-up={scrolledUp ? '' : undefined}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onSubmit={e => {
e.preventDefault()
submitDraft()
@@ -378,6 +587,7 @@ export function ChatBar({
loading={at.loading}
/>
<SlashPopover adapter={slash.adapter} loading={slash.loading} />
<SkinSlashPopover draft={draft} onSelect={selectSkinSlashCommand} />
<div className="pointer-events-none absolute inset-0" style={{ background: glassTweaks.fadeBackground }} />
<div className="relative w-full">
<div
@@ -413,14 +623,23 @@ export function ChatBar({
'relative z-4 isolate overflow-hidden rounded-(--composer-active-radius) border border-input/70 shadow-composer transition-[border-color,box-shadow] duration-200 ease-out',
'group-focus-within/composer:border-ring/35 group-focus-within/composer:shadow-composer-focus',
'group-has-data-[state=open]/composer:rounded-t-none group-has-data-[state=open]/composer:border-t-transparent',
'group-has-data-[state=open]/composer:shadow-[0_0.0625rem_0_0.0625rem_color-mix(in_srgb,var(--dt-ring)_35%,transparent),0_0.5rem_1.5rem_color-mix(in_srgb,var(--shadow-ink)_6%,transparent)]'
'group-has-data-[state=open]/composer:shadow-[0_0.0625rem_0_0.0625rem_color-mix(in_srgb,var(--dt-ring)_35%,transparent),0_0.5rem_1.5rem_color-mix(in_srgb,var(--shadow-ink)_6%,transparent)]',
dragActive && 'border-primary/70 shadow-composer-focus ring-2 ring-primary/40'
)}
data-slot="composer-surface"
>
<div aria-hidden className={COMPOSER_FROST_CLASS} />
{dragActive && (
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-3 flex items-center justify-center rounded-(--composer-active-radius) bg-primary/10 text-sm font-medium text-primary backdrop-blur-[1px]"
>
Drop files to attach
</div>
)}
<div
className={cn(
'relative z-1 flex min-h-0 w-full flex-col gap-1.5 px-2 py-1.5 transition-opacity duration-200 ease-out',
'relative z-1 flex min-h-0 w-full flex-col gap-(--composer-row-gap) px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out',
scrolledUp ? COMPOSER_SCROLLED_DIM_CLASS : 'opacity-100'
)}
data-slot="composer-fade"
@@ -431,13 +650,13 @@ export function ChatBar({
{stacked ? (
<>
{input}
<div className="flex w-full items-center gap-1.5">
<div className="flex w-full items-center gap-(--composer-control-gap)">
{contextMenu}
{controls}
</div>
</>
) : (
<div className="flex w-full items-end gap-1.5">
<div className="flex w-full items-end gap-(--composer-control-gap)">
{contextMenu}
{input}
{controls}
@@ -468,7 +687,7 @@ export function ChatBarFallback() {
data-slot="composer-root"
style={{ '--composer-active-radius': '1.25rem' } as CSSProperties}
>
<div className="relative isolate h-11 w-full overflow-hidden rounded-(--composer-active-radius) border border-input/70 shadow-composer">
<div className="relative isolate h-(--composer-fallback-height) w-full overflow-hidden rounded-(--composer-active-radius) border border-input/70 shadow-composer">
<div aria-hidden className={COMPOSER_FROST_CLASS} />
</div>
</div>
@@ -0,0 +1,56 @@
import { desktopSkinSlashCompletions } from '@/lib/desktop-slash-commands'
import { triggerHaptic } from '@/lib/haptics'
import { useTheme } from '@/themes/context'
import { COMPLETION_DRAWER_CLASS, COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty } from './completion-drawer'
interface SkinSlashPopoverProps {
draft: string
onSelect: (command: string) => void
}
export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
const { availableThemes, themeName } = useTheme()
const match = draft.match(/^\/skin\s+(\S*)$/i)
if (!match) {
return null
}
const items = desktopSkinSlashCompletions(availableThemes, themeName, match[1] ?? '')
return (
<div
aria-label="Desktop theme suggestions"
className={COMPLETION_DRAWER_CLASS}
data-slot="composer-skin-completion-drawer"
data-state="open"
role="listbox"
>
<div className="grid gap-0.5 pt-0.5">
{items.length === 0 ? (
<CompletionDrawerEmpty title="No matching themes.">
Try <span className="font-mono text-foreground/80">/skin list</span>.
</CompletionDrawerEmpty>
) : (
items.map(item => (
<button
className={COMPLETION_DRAWER_ROW_CLASS}
key={item.text}
onClick={() => {
triggerHaptic('selection')
onSelect(item.text)
}}
onMouseDown={event => event.preventDefault()}
role="option"
type="button"
>
<span className="shrink-0 font-mono font-medium leading-5 text-foreground">{item.display}</span>
<span className="min-w-0 truncate leading-5 text-muted-foreground/80">{item.meta}</span>
</button>
))
)}
</div>
</div>
)
}
@@ -28,7 +28,7 @@ export function SlashPopover({ adapter, loading }: { adapter: Unstable_TriggerAd
<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 full list.
Try <span className="font-mono text-foreground/80">/help</span> for the desktop command list.
</CompletionDrawerEmpty>
) : (
items.map((item, index) => {
@@ -1,5 +1,7 @@
import type { HermesGateway } from '@/hermes'
import type { DroppedFile } from '../hooks/use-composer-actions'
export interface ContextSuggestion {
text: string
display: string
@@ -36,6 +38,8 @@ export interface ChatBarProps {
onCancel: () => void
onAddContextRef?: (refText: string, label?: string, detail?: string) => void
onAddUrl?: (url: string) => void
onAttachImageBlob?: (blob: Blob) => Promise<boolean | void> | boolean | void
onAttachDroppedItems?: (candidates: DroppedFile[]) => Promise<boolean | void> | boolean | void
onPasteClipboardImage?: () => void
onPickFiles?: () => void
onPickFolders?: () => void
@@ -5,7 +5,88 @@ import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
import { addComposerAttachment, type ComposerAttachment, removeComposerAttachment } from '@/store/composer'
import { notify, notifyError } from '@/store/notifications'
import type { ImageAttachResponse, ImageDetachResponse } from '../../types'
import type { ImageDetachResponse } from '../../types'
const IMAGE_EXTENSION_PATTERN = /\.(png|jpe?g|gif|webp|bmp|tiff?|svg|ico)$/i
const BLOB_MIME_EXTENSION: Record<string, string> = {
'image/bmp': '.bmp',
'image/gif': '.gif',
'image/jpeg': '.jpg',
'image/png': '.png',
'image/svg+xml': '.svg',
'image/tiff': '.tiff',
'image/webp': '.webp',
'image/x-icon': '.ico'
}
function blobExtension(blob: Blob): string {
const mime = blob.type.split(';')[0]?.trim().toLowerCase()
return (mime && BLOB_MIME_EXTENSION[mime]) || '.png'
}
function isImagePath(filePath: string): boolean {
return IMAGE_EXTENSION_PATTERN.test(filePath)
}
export interface DroppedFile {
file: File
path: string
}
/**
* Eagerly resolve files from a drop event into [File, path] pairs.
*
* Must be called synchronously from inside the drop handler — `DataTransfer`
* items are detached as soon as the handler returns, and `webUtils.getPathForFile`
* also requires the original (non-cloned) File reference.
*/
export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
const result: DroppedFile[] = []
const seen = new Set<File>()
const getPath = window.hermesDesktop?.getPathForFile
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)) continue
seen.add(file)
let path = ''
if (getPath) {
try {
path = getPath(file) || ''
} catch {
path = ''
}
}
result.push({ file, path })
}
}
const items = transfer.items
if (items) {
for (let i = 0; i < items.length; i += 1) {
const item = items[i]
if (!item || item.kind !== 'file') continue
const file = item.getAsFile()
if (!file || seen.has(file)) continue
seen.add(file)
let path = ''
if (getPath) {
try {
path = getPath(file) || ''
} catch {
path = ''
}
}
result.push({ file, path })
}
}
return result
}
interface ComposerActionsOptions {
activeSessionId: string | null
@@ -13,7 +94,11 @@ interface ComposerActionsOptions {
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}
export function useComposerActions({ activeSessionId, currentCwd, requestGateway }: ComposerActionsOptions) {
export function useComposerActions({
activeSessionId,
currentCwd,
requestGateway
}: ComposerActionsOptions) {
const addContextRefAttachment = useCallback((refText: string, label?: string, detail?: string) => {
let kind: ComposerAttachment['kind'] = 'file'
@@ -62,11 +147,93 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
[currentCwd]
)
const pickImages = useCallback(async () => {
if (!activeSessionId) {
return
}
const attachContextFilePath = useCallback(
(filePath: string) => {
if (!filePath) {
return false
}
const rel = contextPath(filePath, currentCwd)
addComposerAttachment({
id: attachmentId('file', rel),
kind: 'file',
label: pathLabel(filePath),
detail: rel,
refText: `@file:${formatRefValue(rel)}`,
path: filePath
})
return true
},
[currentCwd]
)
const attachImagePath = useCallback(
async (filePath: string) => {
if (!filePath) {
return false
}
const baseAttachment: ComposerAttachment = {
id: attachmentId('image', filePath),
kind: 'image',
label: pathLabel(filePath),
detail: filePath,
path: filePath
}
addComposerAttachment(baseAttachment)
try {
const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
if (previewUrl) {
addComposerAttachment({ ...baseAttachment, previewUrl })
}
return true
} catch (err) {
notifyError(err, 'Image preview failed')
return true
}
},
[]
)
const attachImageBlob = useCallback(
async (blob: Blob) => {
if (blob.size === 0) {
return false
}
if (blob.type && !blob.type.startsWith('image/')) {
return false
}
try {
const buffer = await blob.arrayBuffer()
const data = new Uint8Array(buffer)
const savedPath = await window.hermesDesktop?.saveImageBuffer(data, blobExtension(blob))
if (!savedPath) {
notify({ kind: 'error', title: 'Image attach', message: 'Failed to write image to disk.' })
return false
}
return attachImagePath(savedPath)
} catch (err) {
notifyError(err, 'Image attach failed')
return false
}
},
[attachImagePath]
)
const pickImages = useCallback(async () => {
const paths = await window.hermesDesktop?.selectPaths({
title: 'Attach images',
defaultPath: currentCwd || undefined,
@@ -83,73 +250,82 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
}
for (const path of paths) {
try {
const result = await requestGateway<ImageAttachResponse>('image.attach', {
session_id: activeSessionId,
path
})
const attachedPath = result.path || path
if (result.attached) {
const previewUrl = await window.hermesDesktop?.readFileDataUrl(attachedPath)
addComposerAttachment({
id: attachmentId('image', attachedPath),
kind: 'image',
label: pathLabel(attachedPath),
detail: attachedPath,
previewUrl,
path: attachedPath
})
}
} catch (err) {
notifyError(err, 'Image attach failed')
}
await attachImagePath(path)
}
}, [activeSessionId, currentCwd, requestGateway])
}, [attachImagePath, currentCwd])
const pasteClipboardImage = useCallback(async () => {
if (!activeSessionId) {
return
}
try {
const result = await requestGateway<ImageAttachResponse>('clipboard.paste', {
session_id: activeSessionId
})
const path = await window.hermesDesktop?.saveClipboardImage()
if (!result.attached) {
if (!path) {
notify({
kind: 'warning',
title: 'Clipboard',
message: result.message || 'No image found in clipboard'
message: 'No image found in clipboard'
})
return
}
const attachedPath = result.path || 'clipboard'
const previewUrl = result.path && (await window.hermesDesktop?.readFileDataUrl(result.path))
addComposerAttachment({
id: attachmentId('image', attachedPath),
kind: 'image',
label: pathLabel(attachedPath),
detail: attachedPath,
previewUrl: previewUrl || undefined,
path: result.path
})
await attachImagePath(path)
} catch (err) {
notifyError(err, 'Clipboard paste failed')
}
}, [activeSessionId, requestGateway])
}, [attachImagePath])
const attachDroppedItems = useCallback(
async (candidates: DroppedFile[]) => {
if (candidates.length === 0) {
return false
}
let attached = false
let lastFailure: string | null = null
for (const { file, path: knownPath } of candidates) {
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))
if (isImage) {
if ((filePath && (await attachImagePath(filePath))) || (await attachImageBlob(file))) {
attached = true
continue
}
lastFailure = `Could not attach ${file.name || 'image'}`
continue
}
if (filePath && attachContextFilePath(filePath)) {
attached = true
continue
}
lastFailure = `Could not attach ${file.name || 'file'}`
}
if (!attached && lastFailure) {
notify({ kind: 'warning', title: 'Drop files', message: lastFailure })
}
return attached
},
[attachContextFilePath, attachImageBlob, attachImagePath]
)
const removeAttachment = useCallback(
async (id: string) => {
const removed = removeComposerAttachment(id)
if (removed?.kind === 'image' && removed.path && activeSessionId) {
if (
removed?.kind === 'image' &&
removed.path &&
activeSessionId &&
removed.attachedSessionId &&
removed.attachedSessionId === activeSessionId
) {
await requestGateway<ImageDetachResponse>('image.detach', {
session_id: activeSessionId,
path: removed.path
@@ -161,6 +337,9 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
return {
addContextRefAttachment,
attachDroppedItems,
attachImageBlob,
attachImagePath,
pasteClipboardImage,
pickContextPaths,
pickImages,
+37 -7
View File
@@ -1,4 +1,5 @@
import {
type AppendMessage,
AssistantRuntimeProvider,
ExportedMessageRepository,
type ThreadMessage,
@@ -42,6 +43,7 @@ import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/tit
import { ChatBar, ChatBarFallback } from './composer'
import type { ChatBarState } from './composer/types'
import type { DroppedFile } from './hooks/use-composer-actions'
import { ChatRightRail } from './right-rail'
import { SessionActionsMenu } from './sidebar/session-actions-menu'
@@ -54,6 +56,8 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
onAddUrl: (url: string) => void
onBranchInNewChat: (messageId: string) => void
maxVoiceRecordingSeconds?: number
onAttachImageBlob: (blob: Blob) => Promise<boolean | void> | boolean | void
onAttachDroppedItems: (candidates: DroppedFile[]) => Promise<boolean | void> | boolean | void
onPasteClipboardImage: () => void
onPickFiles: () => void
onPickFolders: () => void
@@ -65,20 +69,33 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
onOpenModelPicker: () => void
onSelectPersonality: (name: string) => void
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
onEdit: (message: AppendMessage) => Promise<void>
onReload: (parentId: string | null) => Promise<void>
onTranscribeAudio?: (audio: Blob) => Promise<string>
}
function threadLoadingState(loadingSession: boolean, busy: boolean, awaitingResponse: boolean) {
function threadLoadingState(
loadingSession: boolean,
busy: boolean,
awaitingResponse: boolean,
lastMessageIsUser: boolean
) {
if (loadingSession) {
return 'session'
}
if (!busy) {
return undefined
// Only show the response spinner when we're actually waiting for an
// assistant reply to a user message. Previously any `busy && awaiting`
// window showed the spinner — including the brief gateway-hydration blip
// right after a session resume, which produced a visible flicker chain:
// session spinner → response spinner → content.
// Gating on `lastMessageIsUser` means the spinner only appears when the
// user actually just sent something and there's no assistant reply yet.
if (busy && awaitingResponse && lastMessageIsUser) {
return 'response'
}
return awaitingResponse ? 'response' : 'working'
return undefined
}
export function ChatView({
@@ -88,6 +105,8 @@ export function ChatView({
onCancel,
onAddContextRef,
onAddUrl,
onAttachImageBlob,
onAttachDroppedItems,
onBranchInNewChat,
maxVoiceRecordingSeconds,
onPasteClipboardImage,
@@ -101,6 +120,7 @@ export function ChatView({
onOpenModelPicker,
onSelectPersonality,
onThreadMessagesChange,
onEdit,
onReload,
onTranscribeAudio
}: ChatViewProps) {
@@ -129,8 +149,14 @@ export function ChatView({
const showIntro =
freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messages.length === 0
const loadingSession = isRoutedSessionView && messages.length === 0
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse)
// Session is still loading if the route references a session we haven't
// resumed yet. Once `activeSessionId` is set (runtime has resumed), the
// session exists — even if it has zero messages (a brand-new routed
// session). The flicker where `busy` flips true briefly during hydrate
// is handled by `threadLoadingState`'s `lastMessageIsUser` gate.
const loadingSession = isRoutedSessionView && messages.length === 0 && !activeSessionId
const lastMessageIsUser = messages.at(-1)?.role === 'user'
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastMessageIsUser)
const showChatBar = !loadingSession
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
const title = activeStoredSession ? sessionTitle(activeStoredSession) : ''
@@ -221,6 +247,7 @@ export function ChatView({
// Submission is handled explicitly by ChatBar.
// Keeping this no-op avoids duplicate prompt.submit calls.
},
onEdit,
onCancel: async () => onCancel(),
onReload
})
@@ -236,6 +263,7 @@ export function ChatView({
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
onPin={selectedSessionId ? onToggleSelectedPin : undefined}
pinned={selectedIsPinned}
sessionId={selectedSessionId || activeSessionId || ''}
sideOffset={8}
title={title}
>
@@ -273,6 +301,8 @@ export function ChatView({
maxRecordingSeconds={maxVoiceRecordingSeconds}
onAddContextRef={onAddContextRef}
onAddUrl={onAddUrl}
onAttachDroppedItems={onAttachDroppedItems}
onAttachImageBlob={onAttachImageBlob}
onCancel={onCancel}
onPasteClipboardImage={onPasteClipboardImage}
onPickFiles={onPickFiles}
@@ -300,4 +330,4 @@ export function ChatView({
)
}
export { SESSION_INSPECTOR_WIDTH } from './right-rail'
export { PREVIEW_RAIL_WIDTH, SESSION_INSPECTOR_WIDTH } from './right-rail'
@@ -3,6 +3,7 @@ import type * as React from 'react'
import { SESSION_INSPECTOR_WIDTH, SessionInspector } from '@/components/session-inspector'
import { $inspectorOpen } from '@/store/layout'
import { $previewTarget } from '@/store/preview'
import {
$availablePersonalities,
$busy,
@@ -14,6 +15,8 @@ import {
$gatewayState
} from '@/store/session'
import { PreviewPane } from './preview-pane'
interface ChatRightRailProps extends Pick<
React.ComponentProps<typeof SessionInspector>,
'onBrowseCwd' | 'onChangeCwd'
@@ -29,6 +32,7 @@ export function ChatRightRail({
onSelectPersonality
}: ChatRightRailProps) {
const inspectorOpen = useStore($inspectorOpen)
const previewTarget = useStore($previewTarget)
const gatewayOpen = useStore($gatewayState) === 'open'
const busy = useStore($busy)
const cwd = useStore($currentCwd)
@@ -38,6 +42,10 @@ export function ChatRightRail({
const personality = useStore($currentPersonality)
const personalities = useStore($availablePersonalities)
if (previewTarget) {
return <PreviewPane target={previewTarget} />
}
return (
<SessionInspector
branch={branch}
@@ -58,3 +66,4 @@ export function ChatRightRail({
}
export { SESSION_INSPECTOR_WIDTH }
export const PREVIEW_RAIL_WIDTH = 'clamp(18rem, 36vw, 38rem)'
@@ -0,0 +1,522 @@
import { Bug, Check, Copy, ExternalLink, PanelBottom, RefreshCw, Send, Trash2, X } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { $composerDraft, setComposerDraft } from '@/store/composer'
import { notify, notifyError } from '@/store/notifications'
import { type PreviewTarget, setPreviewTarget } from '@/store/preview'
type PreviewWebview = HTMLElement & {
closeDevTools?: () => void
isDevToolsOpened?: () => boolean
openDevTools?: () => void
reload?: () => void
reloadIgnoringCache?: () => void
}
interface ConsoleEntry {
id: number
level: number
line?: number
message: string
source?: string
}
const consoleLevelLabel: Record<number, string> = {
0: 'log',
1: 'info',
2: 'warn',
3: 'error'
}
const consoleLevelClass: Record<number, string> = {
0: 'text-foreground',
1: 'text-sky-700 dark:text-sky-300',
2: 'text-amber-700 dark:text-amber-300',
3: 'text-destructive'
}
function compactUrl(value: string): string {
try {
const url = new URL(value)
if (url.protocol === 'file:') {
return decodeURIComponent(url.pathname)
}
return `${url.host}${url.pathname}${url.search}`
} catch {
return value
}
}
function formatLogLine(log: ConsoleEntry): string {
const head = `[${consoleLevelLabel[log.level] || 'log'}]`
const tail = log.source ? ` (${compactUrl(log.source)}${log.line ? `:${log.line}` : ''})` : ''
return `${head} ${log.message}${tail}`.trim()
}
interface ConsoleRowProps {
log: ConsoleEntry
onCopy: () => void | Promise<void>
onSend: () => void
onToggleSelect: () => void
selected: boolean
}
function ConsoleRow({ log, onCopy, onSend, onToggleSelect, selected }: ConsoleRowProps) {
return (
<div
className={cn(
'group/row grid grid-cols-[3.25rem_minmax(0,1fr)_auto] items-start gap-2 rounded-md border border-transparent px-1 py-1 transition-colors hover:bg-accent/40',
selected && 'border-border/60 bg-accent/40'
)}
>
<button
className={cn(
'mt-0.5 cursor-pointer text-left uppercase opacity-70 transition-colors hover:opacity-100',
consoleLevelClass[log.level] ?? consoleLevelClass[0]
)}
onClick={onToggleSelect}
title={selected ? 'Deselect entry' : 'Select entry'}
type="button"
>
{consoleLevelLabel[log.level] || 'log'}
</button>
<div className="min-w-0" data-selectable-text="true">
<span className={cn('block wrap-break-word', consoleLevelClass[log.level] ?? consoleLevelClass[0])}>
{log.message}
</span>
{log.source && (
<span className="block truncate text-muted-foreground/60">
{compactUrl(log.source)}
{log.line ? `:${log.line}` : ''}
</span>
)}
</div>
<span className="opacity-0 transition-opacity group-hover/row:opacity-100">
<button
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={() => void onCopy()}
title="Copy this entry"
type="button"
>
<Copy className="size-3" />
</button>
<button
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onSend}
title="Send this entry to chat"
type="button"
>
<Send className="size-3" />
</button>
</span>
</div>
)
}
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)
}
}
export function PreviewPane({ target }: { target: PreviewTarget }) {
const consoleBodyRef = useRef<HTMLDivElement | null>(null)
const hostRef = useRef<HTMLDivElement | null>(null)
const logIdRef = useRef(0)
const webviewRef = useRef<PreviewWebview | null>(null)
const [consoleOpen, setConsoleOpen] = useState(true)
const [currentUrl, setCurrentUrl] = useState(target.url)
const [devtoolsOpen, setDevtoolsOpen] = useState(false)
const [logs, setLogs] = useState<ConsoleEntry[]>([])
const [selectedLogIds, setSelectedLogIds] = useState<Set<number>>(() => new Set())
const [copiedAll, setCopiedAll] = useState(false)
const [loading, setLoading] = useState(true)
const visibleSelection = useMemo(() => logs.filter(log => selectedLogIds.has(log.id)), [logs, selectedLogIds])
const sendableLogs = visibleSelection.length > 0 ? visibleSelection : logs
function toggleLogSelection(id: number) {
setSelectedLogIds(prev => {
const next = new Set(prev)
if (!next.delete(id)) {
next.add(id)
}
return next
})
}
async function copyConsoleText(entries: ConsoleEntry[], successMessage: string) {
if (!entries.length) {
return
}
try {
await writeClipboardText(entries.map(formatLogLine).join('\n'))
notify({ kind: 'success', title: 'Console copied', message: successMessage })
} catch (error) {
notifyError(error, 'Could not copy console output')
}
}
function sendLogsToComposer(entries: ConsoleEntry[]) {
if (!entries.length) {
return
}
const block = ['Preview console:', '```', ...entries.map(formatLogLine), '```'].join('\n')
const draft = $composerDraft.get()
const next = draft && !draft.endsWith('\n') ? `${draft}\n\n${block}` : `${draft}${block}`
setComposerDraft(next)
setSelectedLogIds(new Set())
notify({
kind: 'success',
title: 'Sent to chat',
message: `${entries.length} log entr${entries.length === 1 ? 'y' : 'ies'} added to composer`
})
}
function toggleDevTools() {
const webview = webviewRef.current
if (!webview?.openDevTools) {
return
}
if (webview.isDevToolsOpened?.()) {
webview.closeDevTools?.()
setDevtoolsOpen(false)
return
}
webview.openDevTools()
setDevtoolsOpen(true)
}
useEffect(() => {
if (consoleOpen) {
consoleBodyRef.current?.scrollTo({ top: consoleBodyRef.current.scrollHeight })
}
}, [consoleOpen, logs])
useEffect(() => {
if (target.kind !== 'file' || !window.hermesDesktop?.watchPreviewFile || !window.hermesDesktop?.onPreviewFileChanged) {
return
}
let active = true
let watchId = ''
const unsubscribe = window.hermesDesktop.onPreviewFileChanged(payload => {
if (!active || payload.id !== watchId) {
return
}
setLogs(prev => [
...prev.slice(-199),
{
id: ++logIdRef.current,
level: 1,
message: `File changed, reloading preview: ${compactUrl(payload.url)}`
}
])
if (webviewRef.current?.reloadIgnoringCache) {
webviewRef.current.reloadIgnoringCache()
} else {
webviewRef.current?.reload?.()
}
})
void window.hermesDesktop
.watchPreviewFile(target.url)
.then(watch => {
if (!active) {
void window.hermesDesktop?.stopPreviewFileWatch?.(watch.id)
return
}
watchId = watch.id
})
.catch(error => {
setLogs(prev => [
...prev.slice(-199),
{
id: ++logIdRef.current,
level: 2,
message: `Could not watch preview file: ${error instanceof Error ? error.message : String(error)}`
}
])
})
return () => {
active = false
unsubscribe()
if (watchId) {
void window.hermesDesktop?.stopPreviewFileWatch?.(watchId)
}
}
}, [target.kind, target.url])
useEffect(() => {
const host = hostRef.current
if (!host) {
return
}
host.replaceChildren()
webviewRef.current = null
setCurrentUrl(target.url)
setDevtoolsOpen(false)
setLogs([])
setLoading(true)
const webview = document.createElement('webview') as PreviewWebview
webview.className = 'hermes-preview-webview h-full w-full flex-1 bg-background'
webview.setAttribute('partition', 'persist:hermes-preview')
webview.setAttribute('src', target.url)
webview.setAttribute('webpreferences', 'contextIsolation=yes,nodeIntegration=no,sandbox=yes')
const appendLog = (entry: Omit<ConsoleEntry, 'id'>) => {
setLogs(prev => [...prev.slice(-199), { ...entry, id: ++logIdRef.current }])
}
const onConsole = (event: Event) => {
const detail = event as Event & {
level?: number
line?: number
message?: string
sourceId?: string
}
appendLog({
level: detail.level ?? 0,
line: detail.line,
message: detail.message || '',
source: detail.sourceId
})
}
const onNavigate = (event: Event) => {
const detail = event as Event & { url?: string }
if (detail.url) {
setCurrentUrl(detail.url)
}
}
const onFail = (event: Event) => {
const detail = event as Event & {
errorCode?: number
errorDescription?: string
validatedURL?: string
}
appendLog({
level: 3,
message: `Load failed${detail.errorCode ? ` (${detail.errorCode})` : ''}: ${
detail.errorDescription || detail.validatedURL || 'unknown error'
}`
})
setLoading(false)
}
const onStart = () => setLoading(true)
const onStop = () => setLoading(false)
webview.addEventListener('console-message', onConsole)
webview.addEventListener('did-fail-load', onFail)
webview.addEventListener('did-navigate', onNavigate)
webview.addEventListener('did-navigate-in-page', onNavigate)
webview.addEventListener('did-start-loading', onStart)
webview.addEventListener('did-stop-loading', onStop)
host.appendChild(webview)
webviewRef.current = webview
return () => {
webview.removeEventListener('console-message', onConsole)
webview.removeEventListener('did-fail-load', onFail)
webview.removeEventListener('did-navigate', onNavigate)
webview.removeEventListener('did-navigate-in-page', onNavigate)
webview.removeEventListener('did-start-loading', onStart)
webview.removeEventListener('did-stop-loading', onStop)
webview.remove()
}
}, [target.url])
return (
<aside className="relative flex h-screen min-w-0 flex-col overflow-hidden bg-transparent pb-2 pl-2 pr-3 pt-[calc(var(--titlebar-height)+0.25rem)] text-muted-foreground">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-border/60 bg-card/70 shadow-sm">
<div className="flex items-center gap-1.5 border-b border-border/60 px-2 py-2">
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium text-foreground">{target.label || 'Preview'}</div>
<div className="truncate font-mono text-[0.625rem] text-muted-foreground">{compactUrl(currentUrl)}</div>
</div>
<Button
aria-label={consoleOpen ? 'Hide preview console' : 'Show preview console'}
className="h-7 shrink-0 rounded-lg px-2 text-[0.6875rem]"
onClick={() => setConsoleOpen(open => !open)}
size="xs"
title={consoleOpen ? 'Hide Console' : 'Show Console'}
type="button"
variant="ghost"
>
<PanelBottom className="size-3.5" />
Console
{logs.length > 0 && (
<span className="ml-0.5 rounded-full bg-muted px-1.5 py-px text-[0.5625rem] text-muted-foreground">
{logs.length}
</span>
)}
</Button>
<Button
aria-label={devtoolsOpen ? 'Hide preview DevTools' : 'Open preview DevTools'}
className="h-7 shrink-0 rounded-lg px-2 text-[0.6875rem]"
onClick={toggleDevTools}
size="xs"
title={devtoolsOpen ? 'Hide DevTools' : 'Open DevTools'}
type="button"
variant="ghost"
>
<Bug className="size-3.5" />
{devtoolsOpen ? 'Hide DevTools' : 'DevTools'}
</Button>
<Button
aria-label="Reload preview"
className="size-7 shrink-0 rounded-lg"
onClick={() => webviewRef.current?.reload?.()}
size="icon"
type="button"
variant="ghost"
>
<RefreshCw className={cn('size-3.5', loading && 'animate-spin')} />
</Button>
<Button
aria-label="Open preview externally"
className="size-7 shrink-0 rounded-lg"
onClick={() => void window.hermesDesktop?.openExternal(currentUrl)}
size="icon"
type="button"
variant="ghost"
>
<ExternalLink className="size-3.5" />
</Button>
<Button
aria-label="Close preview"
className="size-7 shrink-0 rounded-lg"
onClick={() => setPreviewTarget(null)}
size="icon"
type="button"
variant="ghost"
>
<X className="size-3.5" />
</Button>
</div>
<div className="min-h-0 flex-1 bg-background" ref={hostRef} />
{consoleOpen && (
<div className="min-h-44 border-t border-border/60 bg-background/95">
<div className="flex h-8 items-center justify-between border-b border-border/50 px-2">
<div className="flex items-center gap-2 text-[0.6875rem] font-medium text-muted-foreground">
<PanelBottom className="size-3.5" />
Preview Console
{selectedLogIds.size > 0 && (
<span className="rounded-full bg-muted px-1.5 py-px text-[0.5625rem] text-muted-foreground">
{selectedLogIds.size} selected
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
disabled={sendableLogs.length === 0}
onClick={() => sendLogsToComposer(sendableLogs)}
title={
visibleSelection.length > 0
? `Send ${visibleSelection.length} selected to chat`
: 'Send all log entries to chat'
}
type="button"
>
<Send className="size-3" />
Send to chat
</button>
<button
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
disabled={sendableLogs.length === 0}
onClick={async () => {
await copyConsoleText(
sendableLogs,
visibleSelection.length > 0 ? `${visibleSelection.length} selected entries` : 'All console entries'
)
setCopiedAll(true)
setTimeout(() => setCopiedAll(false), 1500)
}}
title={visibleSelection.length > 0 ? 'Copy selected to clipboard' : 'Copy all to clipboard'}
type="button"
>
{copiedAll ? <Check className="size-3" /> : <Copy className="size-3" />}
Copy
</button>
<button
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
disabled={logs.length === 0}
onClick={() => {
setLogs([])
setSelectedLogIds(new Set())
}}
title="Clear console"
type="button"
>
<Trash2 className="size-3" />
Clear
</button>
</div>
</div>
<div className="h-40 overflow-y-auto px-2 py-1.5 font-mono text-[0.6875rem] leading-relaxed" ref={consoleBodyRef}>
{logs.length > 0 ? (
logs.map(log => {
const selected = selectedLogIds.has(log.id)
return (
<ConsoleRow
key={log.id}
log={log}
onCopy={() => copyConsoleText([log], 'Log entry copied')}
onSend={() => sendLogsToComposer([log])}
onToggleSelect={() => toggleLogSelection(log.id)}
selected={selected}
/>
)
})
) : (
<div className="py-2 text-muted-foreground/70">No console messages yet.</div>
)}
</div>
</div>
)}
</div>
</aside>
)
}
+6 -10
View File
@@ -18,7 +18,6 @@ import { Skeleton } from '@/components/ui/skeleton'
import type { SessionInfo } from '@/hermes'
import { cn } from '@/lib/utils'
import {
$isSidebarResizing,
$pinnedSessionIds,
$sidebarOpen,
$sidebarPinsOpen,
@@ -66,10 +65,10 @@ export function ChatSidebar({
}: ChatSidebarProps) {
const sidebarOpen = useStore($sidebarOpen)
const pinnedSessionIds = useStore($pinnedSessionIds)
const isSidebarResizing = useStore($isSidebarResizing)
const pinsOpen = useStore($sidebarPinsOpen)
const recentsOpen = useStore($sidebarRecentsOpen)
const selectedSessionId = useStore($selectedStoredSessionId)
const activeSidebarSessionId = currentView === 'chat' ? selectedSessionId : null
const sessions = useStore($sessions)
const sessionsLoading = useStore($sessionsLoading)
const workingSessionIds = useStore($workingSessionIds)
@@ -101,13 +100,10 @@ export function ChatSidebar({
return (
<Sidebar
className={cn(
'relative h-screen min-w-0 overflow-hidden border-r border-t-0 border-b-0 border-l-0 text-foreground [backdrop-filter:blur(1.5rem)_saturate(1.08)]',
isSidebarResizing
? 'transition-none'
: 'transition-[opacity,transform,border-color,background-color] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
'relative h-screen min-w-0 overflow-hidden border-r border-t-0 border-b-0 border-l-0 text-foreground transition-none [backdrop-filter:blur(1.5rem)_saturate(1.08)]',
sidebarOpen
? 'translate-x-0 border-(--sidebar-edge-border) bg-[color-mix(in_srgb,var(--dt-sidebar-bg)_97%,transparent)] opacity-100'
: 'pointer-events-none -translate-x-2 border-transparent bg-transparent opacity-0'
? 'border-(--sidebar-edge-border) bg-[color-mix(in_srgb,var(--dt-sidebar-bg)_97%,transparent)] opacity-100'
: 'pointer-events-none border-transparent bg-transparent opacity-0'
)}
collapsible="none"
>
@@ -159,7 +155,7 @@ export function ChatSidebar({
{pinnedSessions.map(session => (
<SidebarSessionRow
isPinned
isSelected={session.id === selectedSessionId}
isSelected={session.id === activeSidebarSessionId}
isWorking={workingSessionIdSet.has(session.id)}
key={session.id}
onDelete={() => onDeleteSession(session.id)}
@@ -207,7 +203,7 @@ export function ChatSidebar({
{recentSessions.map(session => (
<SidebarSessionRow
isPinned={false}
isSelected={session.id === selectedSessionId}
isSelected={session.id === activeSidebarSessionId}
isWorking={workingSessionIdSet.has(session.id)}
key={session.id}
onDelete={() => onDeleteSession(session.id)}
@@ -1,4 +1,4 @@
import { Archive, Pencil, Pin, Trash2 } from 'lucide-react'
import { Archive, Copy, Pencil, Pin, Trash2 } from 'lucide-react'
import type * as React from 'react'
import type { ReactNode } from 'react'
@@ -11,6 +11,7 @@ import {
} from '@/components/ui/dropdown-menu'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
interface SessionActionsMenuProps extends Pick<
React.ComponentProps<typeof DropdownMenuContent>,
@@ -18,6 +19,7 @@ interface SessionActionsMenuProps extends Pick<
> {
children: ReactNode
title: string
sessionId: string
pinned?: boolean
onPin?: () => void
onDelete?: () => void
@@ -26,6 +28,7 @@ interface SessionActionsMenuProps extends Pick<
export function SessionActionsMenu({
children,
title,
sessionId,
pinned = false,
onPin,
onDelete,
@@ -34,6 +37,17 @@ export function SessionActionsMenu({
}: SessionActionsMenuProps) {
const itemClass = 'gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4'
const copyId = async () => {
triggerHaptic('selection')
try {
await navigator.clipboard.writeText(sessionId)
notify({ kind: 'success', message: 'Session ID copied', durationMs: 2_000 })
} catch (err) {
notifyError(err, 'Could not copy session ID')
}
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
@@ -49,6 +63,10 @@ export function SessionActionsMenu({
<Pin />
<span>{pinned ? 'Unpin' : 'Pin'}</span>
</DropdownMenuItem>
<DropdownMenuItem className={itemClass} onSelect={() => void copyId()}>
<Copy />
<span>Copy ID</span>
</DropdownMenuItem>
<DropdownMenuItem className={itemClass}>
<Pencil />
<span>Rename</span>
@@ -72,7 +72,7 @@ export function SidebarSessionRow({
<span className="truncate text-sm font-medium text-foreground/90">{title}</span>
</button>
<div className="relative z-2 grid w-6 place-items-center">
<SessionActionsMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} title={title}>
<SessionActionsMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}>
<Button
aria-label={`Actions for ${title}`}
className="size-6 rounded-md bg-transparent text-transparent transition-colors duration-150 hover:bg-accent hover:text-foreground data-[state=open]:bg-accent data-[state=open]:text-foreground group-hover:text-muted-foreground"
+221 -21
View File
@@ -15,15 +15,18 @@ import {
listSessions,
setGlobalModel
} from '../hermes'
import { toChatMessages } from '../lib/chat-messages'
import { chatMessageText, toChatMessages } from '../lib/chat-messages'
import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromConfig } from '../lib/chat-runtime'
import { extractPreviewCandidates } from '../lib/preview-targets'
import { $pinnedSessionIds, pinSession, unpinSession } from '../store/layout'
import { notify, notifyError } from '../store/notifications'
import { $previewTarget, setPreviewTarget } from '../store/preview'
import {
$activeSessionId,
$currentCwd,
$freshDraftReady,
$gatewayState,
$messages,
$selectedStoredSessionId,
setAvailablePersonalities,
setAwaitingResponse,
@@ -40,9 +43,10 @@ import {
setSessions,
setSessionsLoading
} from '../store/session'
import { useTheme } from '../themes/context'
import { ArtifactsView } from './artifacts'
import { ChatView, SESSION_INSPECTOR_WIDTH } from './chat'
import { ChatView, PREVIEW_RAIL_WIDTH, SESSION_INSPECTOR_WIDTH } from './chat'
import { useComposerActions } from './chat/hooks/use-composer-actions'
import { ChatSidebar } from './chat/sidebar'
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
@@ -64,21 +68,40 @@ function normalizeRecordingLimit(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : DEFAULT_VOICE_RECORDING_SECONDS
}
function gatewayEventPreviewText(event: { payload?: unknown }): string {
const payload = event.payload && typeof event.payload === 'object' ? (event.payload as Record<string, unknown>) : {}
const fields = ['text', 'rendered', 'preview', 'context', 'summary', 'message']
return fields
.map(key => payload[key])
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.join('\n')
}
export function DesktopController() {
const queryClient = useQueryClient()
const location = useLocation()
const navigate = useNavigate()
const busyRef = useRef(false)
const creatingSessionRef = useRef(false)
const gatewayState = useStore($gatewayState)
const { availableThemes, setTheme, themeName } = useTheme()
const activeSessionId = useStore($activeSessionId)
const previewTarget = useStore($previewTarget)
const messages = useStore($messages)
const selectedStoredSessionId = useStore($selectedStoredSessionId)
const currentCwd = useStore($currentCwd)
const freshDraftReady = useStore($freshDraftReady)
const routedSessionId = routeSessionId(location.pathname)
const currentView = appViewForPath(location.pathname)
const routeToken = `${currentView}:${routedSessionId || ''}:${location.pathname}:${location.search}:${location.hash}`
const routeTokenRef = useRef(routeToken)
routeTokenRef.current = routeToken
const getRouteToken = useCallback(() => routeTokenRef.current, [])
const settingsOpen = currentView === 'settings'
const chatOpen = currentView === 'chat'
const settingsReturnPathRef = useRef(NEW_CHAT_ROUTE)
const refreshSessionsRequestRef = useRef(0)
const [titlebarActions, setTitlebarActions] = useState<ReactNode>(null)
const [voiceMaxRecordingSeconds, setVoiceMaxRecordingSeconds] = useState(DEFAULT_VOICE_RECORDING_SECONDS)
const [sttEnabled, setSttEnabled] = useState(true)
@@ -115,13 +138,20 @@ export function DesktopController() {
}, [])
const refreshSessions = useCallback(async () => {
const requestId = refreshSessionsRequestRef.current + 1
refreshSessionsRequestRef.current = requestId
setSessionsLoading(true)
try {
const result = await listSessions(50)
setSessions(result.sessions)
if (refreshSessionsRequestRef.current === requestId) {
setSessions(result.sessions)
}
} finally {
setSessionsLoading(false)
if (refreshSessionsRequestRef.current === requestId) {
setSessionsLoading(false)
}
}
}, [])
@@ -165,18 +195,25 @@ export function DesktopController() {
return
}
const sessionId = activeSessionId
const cwd = currentCwd || ''
try {
const result = await requestGateway<{ items?: ContextSuggestion[] }>('complete.path', {
session_id: activeSessionId,
session_id: sessionId,
word: '@file:',
cwd: currentCwd || undefined
cwd: cwd || undefined
})
setContextSuggestions((result.items || []).filter(item => item.text))
if (activeSessionIdRef.current === sessionId && $currentCwd.get() === cwd) {
setContextSuggestions((result.items || []).filter(item => item.text))
}
} catch {
setContextSuggestions([])
if (activeSessionIdRef.current === sessionId && $currentCwd.get() === cwd) {
setContextSuggestions([])
}
}
}, [activeSessionId, currentCwd, requestGateway])
}, [activeSessionId, activeSessionIdRef, currentCwd, requestGateway])
const refreshCurrentModel = useCallback(async () => {
try {
@@ -372,13 +409,6 @@ export function DesktopController() {
[activeSessionId, refreshHermesConfig, requestGateway]
)
const { addContextRefAttachment, pasteClipboardImage, pickContextPaths, pickImages, removeAttachment } =
useComposerActions({
activeSessionId,
currentCwd,
requestGateway
})
const hydrateFromStoredSession = useCallback(
async (
attempts = 1,
@@ -423,6 +453,65 @@ export function DesktopController() {
updateSessionState
})
const lastPreviewUrlRef = useRef<string>('')
const openDetectedPreview = useCallback(
async (text: string) => {
const desktop = window.hermesDesktop
const routeKey = lastPreviewRouteRef.current
const sessionId = activeSessionIdRef.current
const cwd = currentCwd || ''
if (!desktop?.normalizePreviewTarget) {
return
}
for (const candidate of extractPreviewCandidates(text)) {
const target = await desktop.normalizePreviewTarget(candidate, cwd || undefined).catch(() => null)
if (lastPreviewRouteRef.current !== routeKey || activeSessionIdRef.current !== sessionId || $currentCwd.get() !== cwd) {
return
}
if (!target || target.url === lastPreviewUrlRef.current) {
continue
}
lastPreviewUrlRef.current = target.url
setPreviewTarget(target)
return
}
},
[activeSessionIdRef, currentCwd]
)
const handleDesktopGatewayEvent = useCallback(
(event: Parameters<typeof handleGatewayEvent>[0]) => {
handleGatewayEvent(event)
if (event.session_id && event.session_id !== activeSessionIdRef.current) {
return
}
const previewText = gatewayEventPreviewText(event)
if (previewText) {
void openDetectedPreview(previewText)
}
},
[activeSessionIdRef, handleGatewayEvent, openDetectedPreview]
)
useEffect(() => {
const latestAssistant = [...messages].reverse().find(message => message.role === 'assistant' && !message.pending)
const text = latestAssistant ? chatMessageText(latestAssistant) : ''
if (text) {
void openDetectedPreview(text)
}
}, [messages, openDetectedPreview])
const {
branchCurrentSession,
createBackendSessionForSend,
@@ -435,7 +524,9 @@ export function DesktopController() {
activeSessionId,
activeSessionIdRef,
busyRef,
creatingSessionRef,
ensureSessionState,
getRouteToken,
navigate,
requestGateway,
runtimeIdByStoredSessionIdRef,
@@ -446,41 +537,127 @@ export function DesktopController() {
updateSessionState
})
const {
addContextRefAttachment,
attachDroppedItems,
attachImageBlob,
pasteClipboardImage,
pickContextPaths,
pickImages,
removeAttachment
} = useComposerActions({
activeSessionId,
currentCwd,
requestGateway
})
useEffect(() => {
if (currentView !== 'settings') {
settingsReturnPathRef.current = `${location.pathname}${location.search}${location.hash}`
}
}, [currentView, location.hash, location.pathname, location.search])
const previewRouteKey = `${currentView}:${routedSessionId || ''}:${selectedStoredSessionId || ''}`
const lastPreviewRouteRef = useRef(previewRouteKey)
useEffect(() => {
if (lastPreviewRouteRef.current !== previewRouteKey) {
lastPreviewRouteRef.current = previewRouteKey
lastPreviewUrlRef.current = ''
setPreviewTarget(null)
}
}, [previewRouteKey])
const closeSettingsToPreviousRoute = useCallback(() => {
navigate(settingsReturnPathRef.current || NEW_CHAT_ROUTE, { replace: true })
}, [navigate])
const branchInNewChat = useCallback(
async (messageId: string) => {
async (messageId?: string) => {
const branched = await branchCurrentSession(messageId)
if (branched) {
await refreshSessions().catch(() => undefined)
}
return branched
},
[branchCurrentSession, refreshSessions]
)
const { cancelRun, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio } =
const handleSkinCommand = useCallback(
(rawArg: string) => {
const arg = rawArg.trim()
const names = availableThemes.map(theme => theme.name)
if (!availableThemes.length) {
return 'No desktop themes are available.'
}
const activeIndex = Math.max(
0,
availableThemes.findIndex(theme => theme.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(theme => {
const marker = theme.name === themeName ? '*' : ' '
return `${marker} ${theme.name.padEnd(10)} ${theme.label}`
})
return [`Desktop themes:`, ...rows, '', 'Use /skin <name>, or /skin to cycle.'].join('\n')
}
const normalized = arg.toLowerCase()
const aliases: Record<string, string> = {
ares: 'ember',
hermes: 'default'
}
const targetName = aliases[normalized] || normalized
const target = availableThemes.find(
theme => theme.name.toLowerCase() === targetName || theme.label.toLowerCase() === normalized
)
if (!target) {
return `Unknown desktop theme: ${arg}\nAvailable: ${names.join(', ')}`
}
setTheme(target.name)
return `Desktop theme switched to ${target.label}.`
},
[availableThemes, setTheme, themeName]
)
const { cancelRun, editMessage, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio } =
usePromptActions({
activeSessionId,
activeSessionIdRef,
branchCurrentSession: branchInNewChat,
busyRef,
createBackendSessionForSend,
handleSkinCommand,
requestGateway,
selectedStoredSessionIdRef,
startFreshSessionDraft,
sttEnabled,
updateSessionState
})
useGatewayBoot({
handleGatewayEvent,
handleGatewayEvent: handleDesktopGatewayEvent,
onConnectionReady: setBootConnection,
onGatewayReady: setBootGateway,
refreshHermesConfig,
@@ -516,7 +693,27 @@ export function DesktopController() {
if (!alreadyActive) {
void resumeSession(routedSessionId, true)
}
} else if (isNewChatRoute(location.pathname) && (selectedStoredSessionId || activeSessionId || !freshDraftReady)) {
} else if (
isNewChatRoute(location.pathname) &&
!creatingSessionRef.current &&
(selectedStoredSessionId || activeSessionId || !freshDraftReady)
) {
// Guard: during HashRouter boot the `location.pathname` can read `/`
// briefly before the hash-portion (which holds the real route) is
// parsed. If the window hash clearly references a session, defer —
// `routedSessionId` will update in a tick and the routedSessionId
// branch above will handle resume. Without this guard, a ctrl+R on
// `#/:sessionId` calls startFreshSessionDraft → navigates to `/` →
// wipes messages → races the real resume, producing the visible
// "5 loading states" flash chain.
if (typeof window !== 'undefined') {
const rawHash = window.location.hash.replace(/^#/, '')
if (rawHash && rawHash !== '/' && !rawHash.startsWith('/settings') && !rawHash.startsWith('/skills') && !rawHash.startsWith('/artifacts')) {
return
}
}
startFreshSessionDraft(true)
}
}, [
@@ -567,6 +764,8 @@ export function DesktopController() {
maxVoiceRecordingSeconds={voiceMaxRecordingSeconds}
onAddContextRef={addContextRefAttachment}
onAddUrl={url => addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
onAttachDroppedItems={attachDroppedItems}
onAttachImageBlob={attachImageBlob}
onBranchInNewChat={messageId => void branchInNewChat(messageId)}
onBrowseCwd={() => void browseSessionCwd()}
onCancel={() => void cancelRun()}
@@ -576,6 +775,7 @@ export function DesktopController() {
void removeSession(selectedStoredSessionId)
}
}}
onEdit={editMessage}
onOpenModelPicker={() => setModelPickerOpen(true)}
onPasteClipboardImage={() => void pasteClipboardImage()}
onPickFiles={() => void pickContextPaths('file')}
@@ -593,7 +793,7 @@ export function DesktopController() {
return (
<AppShell
inspectorWidth={SESSION_INSPECTOR_WIDTH}
inspectorWidth={previewTarget ? PREVIEW_RAIL_WIDTH : SESSION_INSPECTOR_WIDTH}
onOpenSettings={openSettings}
overlays={overlays}
rightRailOpen={chatOpen}
@@ -1,6 +1,7 @@
import { useEffect } from 'react'
import { useEffect, useRef } from 'react'
import { HermesGateway } from '@/hermes'
import { setGateway } from '@/store/gateway'
import { notify, notifyError } from '@/store/notifications'
import { setConnection, setGatewayState, setSessionsLoading } from '@/store/session'
import type { RpcEvent } from '@/types/hermes'
@@ -22,6 +23,22 @@ export function useGatewayBoot({
refreshHermesConfig,
refreshSessions
}: GatewayBootOptions) {
const callbacksRef = useRef({
handleGatewayEvent,
onConnectionReady,
onGatewayReady,
refreshHermesConfig,
refreshSessions
})
callbacksRef.current = {
handleGatewayEvent,
onConnectionReady,
onGatewayReady,
refreshHermesConfig,
refreshSessions
}
useEffect(() => {
let cancelled = false
const desktop = window.hermesDesktop
@@ -33,10 +50,11 @@ export function useGatewayBoot({
}
const gateway = new HermesGateway()
onGatewayReady(gateway)
callbacksRef.current.onGatewayReady(gateway)
setGateway(gateway)
const offState = gateway.onState(st => void setGatewayState(st))
const offEvent = gateway.onEvent(handleGatewayEvent)
const offEvent = gateway.onEvent(event => callbacksRef.current.handleGatewayEvent(event))
const offExit = desktop.onBackendExit(() => {
notify({
@@ -55,7 +73,7 @@ export function useGatewayBoot({
return
}
onConnectionReady(conn)
callbacksRef.current.onConnectionReady(conn)
setConnection(conn)
await gateway.connect(conn.wsUrl)
@@ -63,13 +81,13 @@ export function useGatewayBoot({
return
}
await refreshHermesConfig()
await callbacksRef.current.refreshHermesConfig()
if (cancelled) {
return
}
await refreshSessions()
await callbacksRef.current.refreshSessions()
} catch (err) {
if (!cancelled) {
notifyError(err, 'Desktop boot failed')
@@ -86,8 +104,9 @@ export function useGatewayBoot({
offEvent()
offExit()
gateway.close()
onConnectionReady(null)
onGatewayReady(null)
callbacksRef.current.onConnectionReady(null)
callbacksRef.current.onGatewayReady(null)
setGateway(null)
}
}, [handleGatewayEvent, onConnectionReady, onGatewayReady, refreshHermesConfig, refreshSessions])
}, [])
}
@@ -41,15 +41,17 @@ export function useGatewayRequest() {
return null
}
const conn = connectionRef.current || (await desktop.getConnection())
connectionRef.current = conn
setConnection(conn)
try {
const conn = await desktop.getConnection()
connectionRef.current = conn
setConnection(conn)
await existing.connect(conn.wsUrl)
return existing
} catch {
connectionRef.current = null
setConnection(null)
return null
} finally {
reconnectingRef.current = null
@@ -2,18 +2,20 @@ import type { QueryClient } from '@tanstack/react-query'
import { type MutableRefObject, useCallback } from 'react'
import {
appendAssistantTextPart,
appendReasoningPart,
appendTextPart,
assistantTextPart,
type ChatMessage,
type ChatMessagePart,
chatMessageText,
type GatewayEventPayload,
reasoningPart,
textPart,
renderMediaTags,
upsertToolPart
} from '@/lib/chat-messages'
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { setClarifyRequest } from '@/store/clarify'
import { notify } from '@/store/notifications'
import {
setCurrentBranch,
@@ -22,6 +24,7 @@ import {
setCurrentPersonality,
setCurrentProvider
} from '@/store/session'
import { recordToolDiff } from '@/store/tool-diffs'
import type { RpcEvent } from '@/types/hermes'
import type { ClientSessionState } from '../../types'
@@ -123,8 +126,8 @@ export function useMessageStream({
mutateStream(
sessionId,
parts => appendTextPart(parts, delta),
() => [textPart(delta)]
parts => appendAssistantTextPart(parts, delta),
() => [assistantTextPart(delta)]
)
},
[mutateStream]
@@ -181,7 +184,7 @@ export function useMessageStream({
}
const streamId = state.streamId
const finalText = text.trim()
const finalText = renderMediaTags(text).trim()
const normalize = (value: string) => value.replace(/\s+/g, ' ').trim()
const dedupeReference = normalize(finalText)
@@ -200,7 +203,7 @@ export function useMessageStream({
return !(r && (dedupeReference.startsWith(r) || r.startsWith(dedupeReference)))
})
return text ? [...kept, textPart(text)] : kept
return finalText ? [...kept, assistantTextPart(finalText)] : kept
}
const completeMessage = (message: ChatMessage): ChatMessage => ({
@@ -228,24 +231,24 @@ export function useMessageStream({
nextMessages = prev.map((message, messageIndex) =>
messageIndex === index ? completeMessage(message) : message
)
} else if (text) {
} else if (finalText) {
nextMessages = [
...prev,
{
id: `assistant-${Date.now()}`,
role: 'assistant',
parts: [textPart(text)],
parts: [assistantTextPart(finalText)],
branchGroupId: state.pendingBranchGroup ?? undefined
}
]
}
} else if (text) {
} else if (finalText) {
nextMessages = [
...prev,
{
id: `assistant-${Date.now()}`,
role: 'assistant',
parts: [textPart(text)],
parts: [assistantTextPart(finalText)],
branchGroupId: state.pendingBranchGroup ?? undefined
}
]
@@ -408,6 +411,29 @@ export function useMessageStream({
if (sessionId) {
upsertToolCall(sessionId, payload, 'complete')
}
if (typeof payload?.inline_diff === 'string' && payload.inline_diff.trim()) {
recordToolDiff(payload.tool_id || payload.name || '', payload.inline_diff)
}
} else if (event.type === 'clarify.request') {
if (!isActiveEvent) {
return
}
// Surface the clarify tool's overlay. The Python side is blocked on
// `clarify.respond`, so without this handler the agent would hang
// forever (see tools/clarify_tool.py + tui_gateway/server.py:_block).
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
const question = typeof payload?.question === 'string' ? payload.question : ''
if (requestId && question) {
setClarifyRequest({
requestId,
question,
choices: Array.isArray(payload?.choices) ? payload!.choices!.filter(c => typeof c === 'string') : null,
sessionId: sessionId ?? null
})
}
} else if (event.type === 'error') {
if (isActiveEvent) {
notify({
@@ -1,4 +1,4 @@
import type { ThreadMessage } from '@assistant-ui/react'
import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
import { type MutableRefObject, useCallback } from 'react'
import { transcribeAudio } from '@/hermes'
@@ -8,14 +8,21 @@ import {
INTERRUPTED_MARKER,
parseCommandDispatch,
parseSlashCommand,
pathLabel,
SLASH_COMMAND_RE
} from '@/lib/chat-runtime'
import {
type CommandsCatalogLike,
desktopSlashUnavailableMessage,
filterDesktopCommandsCatalog,
isDesktopSlashCommand
} from '@/lib/desktop-slash-commands'
import { triggerHaptic } from '@/lib/haptics'
import { $composerAttachments, clearComposerAttachments } from '@/store/composer'
import { $composerAttachments, addComposerAttachment, clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { $busy, $messages, setAwaitingResponse, setBusy, setMessages } from '@/store/session'
import type { ClientSessionState, SlashExecResponse } from '../../types'
import type { ClientSessionState, ImageAttachResponse, SlashExecResponse } from '../../types'
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
@@ -37,9 +44,12 @@ interface PromptActionsOptions {
activeSessionId: string | null
activeSessionIdRef: MutableRefObject<string | null>
busyRef: MutableRefObject<boolean>
branchCurrentSession: () => Promise<boolean>
createBackendSessionForSend: () => Promise<string | null>
handleSkinCommand: (arg: string) => string
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
selectedStoredSessionIdRef: MutableRefObject<string | null>
startFreshSessionDraft: () => void
sttEnabled: boolean
updateSessionState: (
sessionId: string,
@@ -48,15 +58,12 @@ interface PromptActionsOptions {
) => ClientSessionState
}
interface CommandsCatalogResponse {
categories?: Array<{ name: string; pairs: [string, string][] }>
pairs?: [string, string][]
skill_count?: number
warning?: string
}
function renderCommandsCatalog(catalog: CommandsCatalogLike): string {
const desktopCatalog = filterDesktopCommandsCatalog(catalog)
function renderCommandsCatalog(catalog: CommandsCatalogResponse): string {
const sections = catalog.categories?.length ? catalog.categories : [{ name: 'Commands', pairs: catalog.pairs ?? [] }]
const sections = desktopCatalog.categories?.length
? desktopCatalog.categories
: [{ name: 'Desktop commands', pairs: desktopCatalog.pairs ?? [] }]
const body = sections
.filter(section => section.pairs.length > 0)
@@ -68,22 +75,40 @@ function renderCommandsCatalog(catalog: CommandsCatalogResponse): string {
.join('\n\n')
const tail = [
catalog.skill_count ? `${catalog.skill_count} skill commands available.` : '',
catalog.warning ? `warning: ${catalog.warning}` : ''
desktopCatalog.skill_count ? `${desktopCatalog.skill_count} skill commands available.` : '',
desktopCatalog.warning ? `warning: ${desktopCatalog.warning}` : ''
]
.filter(Boolean)
.join('\n')
return [body || 'No commands available.', tail].filter(Boolean).join('\n\n')
return [body || 'No desktop commands available.', tail].filter(Boolean).join('\n\n')
}
function slashStatusText(command: string, output: string): string {
return [`slash:${command}`, output.trim()].filter(Boolean).join('\n')
}
function appendText(message: AppendMessage): string {
return message.content
.map(part => ('text' in part ? part.text : ''))
.join('')
.trim()
}
function visibleUserOrdinal(messages: readonly ChatMessage[], end: number): number {
return messages.slice(0, end).filter(m => m.role === 'user' && !m.hidden).length
}
export function usePromptActions({
activeSessionId,
activeSessionIdRef,
busyRef,
branchCurrentSession,
createBackendSessionForSend,
handleSkinCommand,
requestGateway,
selectedStoredSessionIdRef,
startFreshSessionDraft,
sttEnabled,
updateSessionState
}: PromptActionsOptions) {
@@ -114,6 +139,39 @@ export function usePromptActions({
[selectedStoredSessionIdRef, updateSessionState]
)
const syncImageAttachmentsForSubmit = useCallback(
async (sessionId: string, attachments: ComposerAttachment[]) => {
const images = attachments.filter(attachment => attachment.kind === 'image' && attachment.path)
for (const attachment of images) {
if (attachment.attachedSessionId === sessionId) {
continue
}
const result = await requestGateway<ImageAttachResponse>('image.attach', {
session_id: sessionId,
path: attachment.path
})
if (!result.attached) {
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
throw new Error(result.message || `Could not attach ${label}`)
}
const attachedPath = result.path || attachment.path
addComposerAttachment({
...attachment,
id: attachment.id,
label: attachedPath ? pathLabel(attachedPath) : attachment.label,
path: attachedPath,
attachedSessionId: sessionId
})
}
},
[requestGateway]
)
const submitPromptText = useCallback(
async (rawText: string) => {
const visibleText = rawText.trim()
@@ -146,21 +204,33 @@ export function usePromptActions({
]
}
const releaseBusy = () => {
busyRef.current = false
setBusy(false)
setAwaitingResponse(false)
}
busyRef.current = true
setBusy(true)
setAwaitingResponse(true)
clearNotifications()
const sessionId = activeSessionId ? activeSessionId : await createBackendSessionForSend()
let sessionId = activeSessionId
if (!sessionId) {
busyRef.current = false
setBusy(false)
setAwaitingResponse(false)
notify({
kind: 'error',
title: 'Session unavailable',
message: 'Could not create a new session'
})
try {
sessionId = await createBackendSessionForSend()
} catch (err) {
releaseBusy()
notifyError(err, 'Session unavailable')
return
}
}
if (!sessionId) {
releaseBusy()
notify({ kind: 'error', title: 'Session unavailable', message: 'Could not create a new session' })
return
}
@@ -180,20 +250,24 @@ export function usePromptActions({
)
try {
await syncImageAttachmentsForSubmit(sessionId, attachments)
await requestGateway('prompt.submit', { session_id: sessionId, text })
clearComposerAttachments()
} catch (err) {
busyRef.current = false
updateSessionState(sessionId, state => ({
...state,
messages: state.messages.filter(message => message.id !== userMessage.id),
busy: false,
awaitingResponse: false
}))
releaseBusy()
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
notifyError(err, 'Prompt failed')
}
},
[activeSessionId, createBackendSessionForSend, requestGateway, selectedStoredSessionIdRef, updateSessionState]
[
activeSessionId,
busyRef,
createBackendSessionForSend,
requestGateway,
selectedStoredSessionIdRef,
syncImageAttachmentsForSubmit,
updateSessionState
]
)
const executeSlashCommand = useCallback(
@@ -201,6 +275,36 @@ export function usePromptActions({
const runSlash = async (commandText: string, sessionHint?: string, recordInput = true): Promise<void> => {
const command = commandText.trim()
const { name, arg } = parseSlashCommand(command)
const normalizedName = name.toLowerCase()
if (!name) {
const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
if (sessionId) {
appendSessionTextMessage(sessionId, 'system', 'empty slash command')
}
return
}
if (normalizedName === 'new' || normalizedName === 'reset') {
startFreshSessionDraft()
return
}
if (normalizedName === 'branch' || normalizedName === 'fork') {
await branchCurrentSession()
return
}
if (normalizedName === 'skin' && !sessionHint && !activeSessionIdRef.current) {
notify({ kind: 'success', message: handleSkinCommand(arg) })
return
}
const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
if (!sessionId) {
@@ -213,21 +317,18 @@ export function usePromptActions({
return
}
const renderSlashOutput = (text: string) => appendSessionTextMessage(sessionId, 'system', text)
const renderSlashOutput = (text: string) =>
appendSessionTextMessage(sessionId, 'system', recordInput ? slashStatusText(command, text) : text)
if (recordInput) {
appendSessionTextMessage(sessionId, 'user', command)
}
if (!name) {
renderSlashOutput('empty slash command')
if (normalizedName === 'skin') {
renderSlashOutput(handleSkinCommand(arg))
return
}
if (name === 'help' || name === 'commands') {
try {
const catalog = await requestGateway<CommandsCatalogResponse>('commands.catalog', { session_id: sessionId })
const catalog = await requestGateway<CommandsCatalogLike>('commands.catalog', { session_id: sessionId })
renderSlashOutput(renderCommandsCatalog(catalog))
} catch (err) {
@@ -237,6 +338,12 @@ export function usePromptActions({
return
}
if (!isDesktopSlashCommand(name)) {
renderSlashOutput(desktopSlashUnavailableMessage(name) || `/${name} is not available in the desktop app.`)
return
}
try {
const result = await requestGateway<SlashExecResponse>('slash.exec', {
session_id: sessionId,
@@ -306,7 +413,17 @@ export function usePromptActions({
await runSlash(rawCommand, options?.sessionId, options?.recordInput ?? true)
},
[activeSessionIdRef, appendSessionTextMessage, createBackendSessionForSend, requestGateway, submitPromptText]
[
activeSessionIdRef,
appendSessionTextMessage,
branchCurrentSession,
busyRef,
createBackendSessionForSend,
handleSkinCommand,
requestGateway,
startFreshSessionDraft,
submitPromptText
]
)
const submitText = useCallback(
@@ -433,6 +550,7 @@ export function usePromptActions({
: messages.slice(absoluteUserIndex + 1).find(message => message.role === 'assistant')
const branchGroupId = targetAssistant?.branchGroupId ?? branchGroupForUser(userMessage)
const truncateBeforeUserOrdinal = visibleUserOrdinal(messages, absoluteUserIndex)
clearNotifications()
updateSessionState(activeSessionId, state => {
@@ -459,7 +577,11 @@ export function usePromptActions({
})
try {
await requestGateway('prompt.submit', { session_id: activeSessionId, text: userText })
await requestGateway('prompt.submit', {
session_id: activeSessionId,
text: userText,
truncate_before_user_ordinal: truncateBeforeUserOrdinal
})
} catch (err) {
updateSessionState(activeSessionId, state => ({
...state,
@@ -472,26 +594,80 @@ export function usePromptActions({
[activeSessionId, requestGateway, updateSessionState]
)
const editMessage = useCallback(
async (edited: AppendMessage) => {
const sessionId = activeSessionId || activeSessionIdRef.current
const sourceId = edited.sourceId || edited.parentId
const text = appendText(edited)
if (!sessionId || !sourceId || !text || edited.role !== 'user' || $busy.get()) {
return
}
const messages = $messages.get()
const sourceIndex = messages.findIndex(m => m.id === sourceId)
const source = messages[sourceIndex]
if (!source || source.role !== 'user' || chatMessageText(source).trim() === text) {
return
}
const truncate_before_user_ordinal = visibleUserOrdinal(messages, sourceIndex)
const editedMessage: ChatMessage = { ...source, parts: [textPart(text)] }
clearNotifications()
updateSessionState(sessionId, state => ({
...state,
busy: true,
awaitingResponse: true,
pendingBranchGroup: null,
sawAssistantPayload: false,
interrupted: false,
messages: [...state.messages.slice(0, sourceIndex), editedMessage]
}))
try {
await requestGateway('prompt.submit', { session_id: sessionId, text, truncate_before_user_ordinal })
} catch (err) {
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
notifyError(err, 'Edit failed')
}
},
[activeSessionId, activeSessionIdRef, requestGateway, updateSessionState]
)
const handleThreadMessagesChange = useCallback(
(nextMessages: readonly ThreadMessage[]) => {
const visibleIds = new Set(nextMessages.map(message => message.id))
const visibleIds = new Set(nextMessages.map(m => m.id))
const sessionId = activeSessionIdRef.current
if (!sessionId) {
return
}
updateSessionState(sessionId, state => ({
...state,
messages: state.messages.map(message =>
message.role === 'assistant' && message.branchGroupId
? { ...message, hidden: !visibleIds.has(message.id) }
: message
)
}))
updateSessionState(sessionId, state => {
let changed = false
const messages = state.messages.map(message => {
if (message.role !== 'assistant' || !message.branchGroupId) {
return message
}
const hidden = !visibleIds.has(message.id)
if (message.hidden === hidden) {
return message
}
changed = true
return { ...message, hidden }
})
return changed ? { ...state, messages } : state
})
},
[activeSessionIdRef, updateSessionState]
)
return { cancelRun, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio }
return { cancelRun, editMessage, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio }
}
@@ -3,8 +3,9 @@ import { useCallback, useRef } from 'react'
import type { NavigateFunction } from 'react-router-dom'
import { deleteSession, getSessionMessages } from '@/hermes'
import { chatMessageText, toChatMessages } from '@/lib/chat-messages'
import { type ChatMessage, chatMessageText, toChatMessages } from '@/lib/chat-messages'
import { normalizePersonalityValue } from '@/lib/chat-runtime'
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
import { clearComposerAttachments, clearComposerDraft } from '@/store/composer'
import { $pinnedSessionIds } from '@/store/layout'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
@@ -25,7 +26,7 @@ import {
setSelectedStoredSessionId,
setSessions
} from '@/store/session'
import type { SessionCreateResponse, SessionResumeResponse } from '@/types/hermes'
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse } from '@/types/hermes'
import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../routes'
import type { ClientSessionState, SidebarNavItem } from '../../types'
@@ -34,7 +35,9 @@ interface SessionActionsOptions {
activeSessionId: string | null
activeSessionIdRef: MutableRefObject<string | null>
busyRef: MutableRefObject<boolean>
creatingSessionRef: MutableRefObject<boolean>
ensureSessionState: (sessionId: string, storedSessionId?: string | null) => ClientSessionState
getRouteToken: () => string
navigate: NavigateFunction
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
@@ -49,11 +52,156 @@ interface SessionActionsOptions {
) => ClientSessionState
}
function withAppendedText(message: ChatMessage, suffix: string): ChatMessage {
let appended = false
const parts = message.parts.map(part => {
if (part.type !== 'text' || appended) {
return part
}
appended = true
return { ...part, text: `${part.text}${suffix}` }
})
return appended ? { ...message, parts } : message
}
function preserveReasoningParts(message: ChatMessage, previous: ChatMessage): ChatMessage {
if (message.parts.some(part => part.type === 'reasoning')) {
return message
}
const reasoningParts = previous.parts.filter(part => part.type === 'reasoning')
return reasoningParts.length ? { ...message, parts: [...reasoningParts, ...message.parts] } : message
}
function chatMessagesEquivalent(a: ChatMessage, b: ChatMessage): boolean {
if (a.id !== b.id || a.role !== b.role || a.pending !== b.pending || a.hidden !== b.hidden || a.branchGroupId !== b.branchGroupId) {
return false
}
if (a.parts.length !== b.parts.length) {
return false
}
return a.parts.every((part, index) => JSON.stringify(part) === JSON.stringify(b.parts[index]))
}
function chatMessageArraysEquivalent(a: ChatMessage[], b: ChatMessage[]): boolean {
return a.length === b.length && a.every((message, index) => chatMessagesEquivalent(message, b[index]))
}
function reconcileResumeMessages(nextMessages: ChatMessage[], previousMessages: ChatMessage[]): ChatMessage[] {
if (!previousMessages.length) {
return nextMessages
}
const previousByRoleOrdinal = new Map<string, ChatMessage>()
const previousRoleCounts = new Map<string, number>()
for (const message of previousMessages) {
const ordinal = previousRoleCounts.get(message.role) ?? 0
previousRoleCounts.set(message.role, ordinal + 1)
previousByRoleOrdinal.set(`${message.role}:${ordinal}`, message)
}
const nextRoleCounts = new Map<string, number>()
return nextMessages.map(message => {
const ordinal = nextRoleCounts.get(message.role) ?? 0
nextRoleCounts.set(message.role, ordinal + 1)
const previous = previousByRoleOrdinal.get(`${message.role}:${ordinal}`)
if (!previous) {
return message
}
const nextText = chatMessageText(message).trim()
const previousText = chatMessageText(previous)
const previousVisibleText = textWithoutEmbeddedImages(previousText)
let preserved = message
if (nextText === previousVisibleText || nextText === previousText.trim()) {
preserved = preserveReasoningParts(preserved, previous)
}
const previousImages = embeddedImageUrls(previousText)
if (!previousImages.length || embeddedImageUrls(chatMessageText(preserved)).length) {
return preserved
}
if (nextText !== previousVisibleText) {
return preserved
}
return withAppendedText(preserved, previousImages.map(url => `\n${url}`).join(''))
})
}
function upsertOptimisticSession(
created: SessionCreateResponse,
id: string,
title: string | null = null,
preview: string | null = null
) {
const now = Date.now() / 1000
const session: SessionInfo = {
ended_at: null,
id,
input_tokens: 0,
is_active: true,
last_active: now,
message_count: created.message_count ?? created.messages?.length ?? 0,
model: created.info?.model ?? null,
output_tokens: 0,
preview,
source: 'tui',
started_at: now,
title,
tool_call_count: 0
}
setSessions(prev => [session, ...prev.filter(s => s.id !== id)])
}
function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined) {
if (!info) {
return
}
if (info.model) {
setCurrentModel(info.model)
}
if (info.provider) {
setCurrentProvider(info.provider)
}
if (info.cwd) {
setCurrentCwd(info.cwd)
}
if (info.branch !== undefined) {
setCurrentBranch(info.branch || '')
}
if (typeof info.personality === 'string') {
setCurrentPersonality(normalizePersonalityValue(info.personality))
}
}
export function useSessionActions({
activeSessionId,
activeSessionIdRef,
busyRef,
creatingSessionRef,
ensureSessionState,
getRouteToken,
navigate,
requestGateway,
runtimeIdByStoredSessionIdRef,
@@ -86,43 +234,47 @@ export function useSessionActions({
)
const createBackendSessionForSend = useCallback(async (): Promise<string | null> => {
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96 })
const startingActiveSessionId = activeSessionIdRef.current
const startingStoredSessionId = selectedStoredSessionIdRef.current
const startingRouteToken = getRouteToken()
if (created.stored_session_id) {
navigate(sessionRoute(created.stored_session_id), { replace: true })
creatingSessionRef.current = true
try {
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96 })
const stored = created.stored_session_id ?? null
if (
activeSessionIdRef.current !== startingActiveSessionId ||
selectedStoredSessionIdRef.current !== startingStoredSessionId ||
getRouteToken() !== startingRouteToken
) {
await requestGateway('session.close', { session_id: created.session_id }).catch(() => undefined)
return null
}
activeSessionIdRef.current = created.session_id
selectedStoredSessionIdRef.current = stored
ensureSessionState(created.session_id, stored)
if (stored) {
upsertOptimisticSession(created, stored)
navigate(sessionRoute(stored), { replace: true })
}
setFreshDraftReady(false)
setActiveSessionId(created.session_id)
setSelectedStoredSessionId(stored)
applyRuntimeInfo(created.info)
return created.session_id
} finally {
window.setTimeout(() => {
creatingSessionRef.current = false
}, 0)
}
setActiveSessionId(created.session_id)
activeSessionIdRef.current = created.session_id
ensureSessionState(created.session_id, created.stored_session_id ?? null)
if (created.stored_session_id) {
setSelectedStoredSessionId(created.stored_session_id)
selectedStoredSessionIdRef.current = created.stored_session_id
}
if (created.info?.model) {
setCurrentModel(created.info.model)
}
if (created.info?.provider) {
setCurrentProvider(created.info.provider)
}
if (created.info?.cwd) {
setCurrentCwd(created.info.cwd)
}
if (created.info?.branch) {
setCurrentBranch(created.info.branch)
}
if (typeof created.info?.personality === 'string') {
setCurrentPersonality(normalizePersonalityValue(created.info.personality))
}
return created.session_id
}, [activeSessionIdRef, ensureSessionState, navigate, requestGateway, selectedStoredSessionIdRef])
}, [activeSessionIdRef, creatingSessionRef, ensureSessionState, getRouteToken, navigate, requestGateway, selectedStoredSessionIdRef])
const selectSidebarItem = useCallback(
(item: SidebarNavItem) => {
@@ -187,41 +339,65 @@ export function useSessionActions({
clearNotifications()
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
setMessages([])
try {
let resumeApplied = false
// Load the local snapshot first, then ask the gateway to resume.
// Previously these raced:
// 1. clear messages to []
// 2. local getSessionMessages -> 45 msgs
// 3. a second resume path cleared [] again
// 4. gateway resume -> 43 msgs
// That is the ctrl+R flash chain. Avoid showing an empty thread
// while we already have a route-scoped session id, and don't race the
// local snapshot against gateway resume.
let localSnapshot = $messages.get()
const storedMessagesPromise = getSessionMessages(storedSessionId)
.then(storedMessages => {
if (!resumeApplied && isCurrentResume()) {
setMessages(toChatMessages(storedMessages.messages))
try {
const storedMessages = await getSessionMessages(storedSessionId)
if (isCurrentResume()) {
localSnapshot = toChatMessages(storedMessages.messages)
if (!chatMessageArraysEquivalent($messages.get(), localSnapshot)) {
setMessages(localSnapshot)
}
})
.catch(() => undefined)
}
} catch {
// Non-fatal: gateway resume below can still hydrate the session.
}
const resumePromise = requestGateway<SessionResumeResponse>('session.resume', {
const resumed = await requestGateway<SessionResumeResponse>('session.resume', {
session_id: storedSessionId,
cols: 96
})
void storedMessagesPromise
const resumed = await resumePromise
resumeApplied = true
if (!isCurrentResume()) {
return
}
const currentMessages = $messages.get()
const resumedMessages = reconcileResumeMessages(toChatMessages(resumed.messages), currentMessages)
// Avoid a second visible transcript rebuild on resume/switch.
// `getSessionMessages()` is the stable stored transcript snapshot and
// paints first; `session.resume` can return a slightly different
// runtime-shaped projection (e.g. tool/system coalescing), which was
// causing a second full message-list replacement a second later.
// Keep the already-painted local snapshot for the view/cache when it
// exists; use gateway messages only as a fallback when no local
// snapshot was available.
const messagesForView = localSnapshot.length > 0
? localSnapshot
: chatMessageArraysEquivalent(currentMessages, resumedMessages)
? currentMessages
: resumedMessages
setActiveSessionId(resumed.session_id)
activeSessionIdRef.current = resumed.session_id
updateSessionState(
resumed.session_id,
state => ({
...state,
messages: toChatMessages(resumed.messages),
messages: messagesForView,
busy: false,
awaitingResponse: false
}),
@@ -229,24 +405,7 @@ export function useSessionActions({
)
clearComposerDraft()
clearComposerAttachments()
if (resumed.info?.model) {
setCurrentModel(resumed.info.model)
}
if (resumed.info?.provider) {
setCurrentProvider(resumed.info.provider)
}
if (resumed.info?.cwd) {
setCurrentCwd(resumed.info.cwd)
}
setCurrentBranch(resumed.info?.branch || '')
if (typeof resumed.info?.personality === 'string') {
setCurrentPersonality(normalizePersonalityValue(resumed.info.personality))
}
applyRuntimeInfo(resumed.info)
} catch (err) {
if (!isCurrentResume()) {
return
@@ -304,9 +463,15 @@ export function useSessionActions({
return false
}
creatingSessionRef.current = true
try {
const currentMessages = $messages.get()
const targetIndex = messageId ? currentMessages.findIndex(message => message.id === messageId) : -1
const targetIndex = messageId
? currentMessages.findIndex(message => message.id === messageId)
: currentMessages.findLastIndex(message => message.role === 'assistant' || message.role === 'user')
const branchStart = targetIndex >= 0 ? targetIndex : Math.max(currentMessages.length - 1, 0)
const branchEnd = targetIndex >= 0 ? targetIndex + 1 : currentMessages.length
@@ -317,7 +482,7 @@ export function useSessionActions({
source: message,
role: message.role
}))
.filter(message => message.content.trim() && ['assistant', 'system', 'user'].includes(message.role))
.filter(message => message.content.trim() && ['assistant', 'user'].includes(message.role))
if (!branchMessages.length) {
notify({
@@ -338,8 +503,10 @@ export function useSessionActions({
})
const routedSessionId = branched.stored_session_id ?? branched.session_id
const preview = branchMessages.map(({ content }) => content).find(Boolean) ?? null
setFreshDraftReady(false)
upsertOptimisticSession(branched, routedSessionId, 'Branch', preview)
ensureSessionState(branched.session_id, routedSessionId)
setActiveSessionId(branched.session_id)
activeSessionIdRef.current = branched.session_id
@@ -359,35 +526,23 @@ export function useSessionActions({
clearComposerDraft()
clearComposerAttachments()
if (branched.info?.model) {
setCurrentModel(branched.info.model)
}
if (branched.info?.provider) {
setCurrentProvider(branched.info.provider)
}
if (branched.info?.cwd) {
setCurrentCwd(branched.info.cwd)
}
setCurrentBranch(branched.info?.branch || '')
if (typeof branched.info?.personality === 'string') {
setCurrentPersonality(normalizePersonalityValue(branched.info.personality))
}
applyRuntimeInfo(branched.info)
return true
} catch (err) {
notifyError(err, 'Branch failed')
return false
} finally {
window.setTimeout(() => {
creatingSessionRef.current = false
}, 0)
}
},
[
activeSessionIdRef,
busyRef,
creatingSessionRef,
ensureSessionState,
navigate,
requestGateway,
@@ -399,49 +554,60 @@ export function useSessionActions({
const removeSession = useCallback(
async (storedSessionId: string) => {
clearNotifications()
const removed = $sessions.get().find(s => s.id === storedSessionId)
const wasSelected = selectedStoredSessionId === storedSessionId
const closingRuntimeId = wasSelected ? activeSessionId : null
const previousMessages = $messages.get()
const previousPinnedSessionIds = $pinnedSessionIds.get()
const previousPinned = $pinnedSessionIds.get()
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
$pinnedSessionIds.set(previousPinnedSessionIds.filter(id => id !== storedSessionId))
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId))
// Tear down before awaiting so the route effect can't resume the
// doomed session via the stale /<sid> URL.
if (wasSelected) {
setSelectedStoredSessionId(null)
selectedStoredSessionIdRef.current = null
setMessages([])
startFreshSessionDraft(true)
}
try {
if (wasSelected && activeSessionId) {
await requestGateway('session.close', {
session_id: activeSessionId
}).catch(() => undefined)
if (closingRuntimeId) {
await requestGateway('session.close', { session_id: closingRuntimeId }).catch(() => undefined)
}
await deleteSession(storedSessionId)
if (wasSelected) {
startFreshSessionDraft()
}
} catch (err) {
if (removed) {
setSessions(prev => [removed, ...prev])
}
$pinnedSessionIds.set(previousPinnedSessionIds)
$pinnedSessionIds.set(previousPinned)
if (wasSelected) {
setFreshDraftReady(false)
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
setMessages(previousMessages)
navigate(sessionRoute(storedSessionId), { replace: true })
if (closingRuntimeId) {
setActiveSessionId(closingRuntimeId)
activeSessionIdRef.current = closingRuntimeId
}
}
notifyError(err, 'Delete failed')
}
},
[activeSessionId, selectedStoredSessionId, selectedStoredSessionIdRef, startFreshSessionDraft, requestGateway]
[
activeSessionId,
activeSessionIdRef,
navigate,
requestGateway,
selectedStoredSessionId,
selectedStoredSessionIdRef,
startFreshSessionDraft
]
)
return {
+11 -9
View File
@@ -7,13 +7,13 @@ import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import {
$inspectorOpen,
$isSidebarResizing,
$sidebarOpen,
$sidebarWidth,
setSidebarOpen,
setSidebarResizing,
setSidebarWidth
} from '@/store/layout'
import { $previewTarget } from '@/store/preview'
import { $connection } from '@/store/session'
import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar'
@@ -44,11 +44,16 @@ export function AppShell({
const connection = useStore($connection)
const sidebarOpen = useStore($sidebarOpen)
const inspectorOpen = useStore($inspectorOpen)
const isSidebarResizing = useStore($isSidebarResizing)
const previewTarget = useStore($previewTarget)
const displayedSidebarWidth = sidebarOpen ? sidebarWidth : Math.round(sidebarWidth * 0.8)
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition)
const showRightRail = rightRailOpen && inspectorOpen
const showRightRail = rightRailOpen && (inspectorOpen || Boolean(previewTarget))
// Right rail yields to chat min-width before the chat column starts crushing the composer.
const inspectorColumn = showRightRail
? 'min(var(--inspector-width), max(0px, calc(100vw - var(--sidebar-width) - var(--chat-min-width) - 2 * var(--shell-gap))))'
: '0px'
const startSidebarResize = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
@@ -105,16 +110,13 @@ export function AppShell({
<main
className={cn(
'relative grid h-screen w-full grid-cols-[var(--sidebar-width)_minmax(0,1fr)_var(--inspector-col)] overflow-hidden bg-background pr-0.75 pb-0.75 pt-0.75',
isSidebarResizing
? 'transition-none'
: 'transition-[grid-template-columns,gap] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
sidebarOpen || showRightRail ? 'gap-2.5' : 'gap-0'
'relative grid h-screen w-full grid-cols-[var(--sidebar-width)_minmax(0,1fr)_var(--inspector-col)] overflow-hidden bg-background pr-0.75 pb-0.75 pt-0.75 transition-none',
sidebarOpen || showRightRail ? 'gap-(--shell-gap)' : 'gap-0'
)}
style={
{
'--inspector-width': inspectorWidth,
'--inspector-col': showRightRail ? inspectorWidth : '0px'
'--inspector-col': inspectorColumn
} as CSSProperties
}
>
@@ -0,0 +1,284 @@
'use client'
import { type ToolCallMessagePartProps } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { HelpCircle, Loader2, PencilLine } from 'lucide-react'
import { type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useState } from 'react'
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $clarifyRequest, clearClarifyRequest } from '@/store/clarify'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
interface ClarifyArgs {
question?: string
choices?: string[] | null
}
function readClarifyArgs(args: unknown): ClarifyArgs {
if (!args || typeof args !== 'object') {
return {}
}
const row = args as Record<string, unknown>
const choices = Array.isArray(row.choices) ? row.choices.filter((c): c is string => typeof c === 'string') : null
return {
question: typeof row.question === 'string' ? row.question : undefined,
choices: choices && choices.length > 0 ? choices : null
}
}
export const ClarifyTool = (props: ToolCallMessagePartProps) => {
const isPending = props.result === undefined
// Once Hermes records an answer, fall back to the standard tool block so
// the past Q/A renders consistently with every other tool in the thread.
if (!isPending) {
return <ToolFallback {...props} />
}
return <ClarifyToolPending {...props} />
}
function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
const request = useStore($clarifyRequest)
const gateway = useStore($gateway)
const fromArgs = useMemo(() => readClarifyArgs(args), [args])
const matchingRequest = useMemo(() => {
if (!request) {
return null
}
if (fromArgs.question && request.question && fromArgs.question !== request.question) {
return null
}
return request
}, [fromArgs.question, request])
const question = fromArgs.question || matchingRequest?.question || ''
const choices = useMemo(
() => fromArgs.choices ?? matchingRequest?.choices ?? [],
[fromArgs.choices, matchingRequest?.choices]
)
const hasChoices = choices.length > 0
const [typing, setTyping] = useState(false)
const [draft, setDraft] = useState('')
const [submitting, setSubmitting] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
// Race: tool.start fires a tick before clarify.request, so request_id
// arrives slightly after the tool block mounts. Show the question (from
// args) but disable submit until we have the request id from the gateway.
const ready = Boolean(matchingRequest?.requestId)
const respond = useCallback(
async (answer: string) => {
if (!ready || !matchingRequest) {
notifyError(new Error('Clarify request is not ready yet'), 'Could not send clarify response')
return
}
if (!gateway) {
notifyError(new Error('Hermes gateway is not connected'), 'Could not send clarify response')
return
}
setSubmitting(true)
try {
await gateway.request<{ ok?: boolean }>('clarify.respond', {
request_id: matchingRequest.requestId,
answer
})
triggerHaptic('submit')
clearClarifyRequest(matchingRequest.requestId)
// The matching tool.complete will land shortly after, swapping this
// panel for the ToolFallback view above.
} catch (error) {
notifyError(error, 'Could not send clarify response')
setSubmitting(false)
}
},
[gateway, matchingRequest, ready]
)
const handleTextareaKey = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
const trimmed = draft.trim()
if (trimmed) {
void respond(trimmed)
}
}
},
[draft, respond]
)
const handleSubmitFreeform = useCallback(
(event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
const trimmed = draft.trim()
if (trimmed) {
void respond(trimmed)
}
},
[draft, respond]
)
const handleChoiceKey = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
if (typing || submitting) {
return
}
const numeric = Number.parseInt(event.key, 10)
if (Number.isFinite(numeric) && numeric >= 1 && numeric <= choices.length) {
event.preventDefault()
void respond(choices[numeric - 1]!)
}
},
[choices, respond, submitting, typing]
)
return (
<div
className={cn(
'mb-3 mt-2 grid gap-3 rounded-xl border border-border/70 bg-card/40 px-4 py-3.5 text-sm',
'shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_3%,transparent)]'
)}
data-slot="clarify-inline"
>
<div className="flex items-start gap-2.5">
<span
aria-hidden
className="mt-0.5 grid size-6 shrink-0 place-items-center rounded-md bg-[color-mix(in_srgb,var(--dt-primary)_14%,transparent)] text-primary ring-1 ring-inset ring-primary/15"
>
<HelpCircle className="size-3.5" />
</span>
<div className="grid flex-1 gap-0.5">
<span className="text-[0.6875rem] font-medium uppercase tracking-wide text-muted-foreground/85">
Hermes is asking
</span>
<span className="whitespace-pre-wrap leading-snug text-foreground">
{question || <em className="text-muted-foreground/70">Loading question</em>}
</span>
</div>
</div>
{!typing && hasChoices && (
<div className="grid gap-1.5" onKeyDown={handleChoiceKey} role="group">
{choices.map((choice, index) => (
<button
className={cn(
'group/choice flex w-full items-center gap-3 rounded-lg border border-border/70 bg-background/60 px-3 py-2 text-left text-sm text-foreground/95',
'transition-colors hover:border-border hover:bg-accent/60 disabled:cursor-not-allowed disabled:opacity-55'
)}
data-choice
disabled={!ready || submitting}
key={`${index}-${choice}`}
onClick={() => void respond(choice)}
type="button"
>
<span className="grid size-5 shrink-0 place-items-center rounded-md bg-muted text-[0.6875rem] font-mono tabular-nums text-muted-foreground group-hover/choice:bg-background">
{index + 1}
</span>
<span className="flex-1 wrap-anywhere">{choice}</span>
</button>
))}
<button
className={cn(
'flex w-full items-center gap-3 rounded-lg border border-dashed border-border/60 bg-transparent px-3 py-2 text-left text-sm text-muted-foreground',
'transition-colors hover:border-border hover:bg-accent/40 hover:text-foreground'
)}
disabled={submitting}
onClick={() => {
setTyping(true)
window.setTimeout(() => textareaRef.current?.focus({ preventScroll: true }), 0)
}}
type="button"
>
<span aria-hidden className="grid size-5 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground">
<PencilLine className="size-3" />
</span>
<span className="flex-1">Other (type your answer)</span>
</button>
</div>
)}
{(typing || !hasChoices) && (
<form className="grid gap-2" onSubmit={handleSubmitFreeform}>
<Textarea
className="min-h-20 resize-y rounded-lg border-border/70 bg-background/60 text-sm"
disabled={submitting}
onChange={event => setDraft(event.target.value)}
onKeyDown={handleTextareaKey}
placeholder="Type your answer…"
ref={textareaRef}
value={draft}
/>
<div className="flex items-center justify-between gap-2">
<span className="text-[0.6875rem] text-muted-foreground/85">/Ctrl + Enter to send</span>
<div className="flex items-center gap-1.5">
{hasChoices && (
<Button
disabled={submitting}
onClick={() => {
setTyping(false)
setDraft('')
}}
size="sm"
type="button"
variant="ghost"
>
Back
</Button>
)}
<Button
disabled={!ready || submitting}
onClick={() => void respond('')}
size="sm"
type="button"
variant="ghost"
>
Skip
</Button>
<Button disabled={!ready || submitting || !draft.trim()} size="sm" type="submit">
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : 'Send'}
</Button>
</div>
</div>
</form>
)}
{!typing && hasChoices && (
<div className="flex items-center justify-between text-[0.6875rem] text-muted-foreground/85">
<span>1{choices.length} to pick</span>
<button
className="bg-transparent text-muted-foreground/85 underline-offset-2 hover:text-foreground hover:underline disabled:opacity-50"
disabled={!ready || submitting}
onClick={() => void respond('')}
type="button"
>
Skip
</button>
</div>
)}
</div>
)
}
@@ -6,6 +6,8 @@ import { AtSign, FileText, FolderOpen, ImageIcon, Link as LinkIcon, Wrench } fro
import type { ComponentType, FC } from 'react'
import { Fragment, useMemo } from 'react'
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
import { extractEmbeddedImages } from '@/lib/embedded-images'
import { cn } from '@/lib/utils'
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool'] as const
@@ -188,7 +190,8 @@ function shortLabel(type: HermesRefType, id: string): string {
* Unknown directive types fall through as plain text.
*/
export const DirectiveText: TextMessagePartComponent = ({ text }: TextMessagePartProps) => {
const segments = useMemo(() => hermesDirectiveFormatter.parse(text ?? ''), [text])
const { cleanedText, images } = useMemo(() => extractEmbeddedImages(text ?? ''), [text])
const segments = useMemo(() => hermesDirectiveFormatter.parse(cleanedText), [cleanedText])
return (
<span className="whitespace-pre-line" data-slot="aui_directive-text">
@@ -199,6 +202,20 @@ export const DirectiveText: TextMessagePartComponent = ({ text }: TextMessagePar
<DirectiveChip id={segment.id} key={`m-${index}-${segment.id}`} label={segment.label} type={segment.type} />
)
)}
{images.length > 0 && (
<span className="mt-2 flex flex-wrap gap-2" data-slot="aui_embedded-images">
{images.map((src, index) => (
<ZoomableImage
alt=""
className="max-h-48 max-w-full rounded-lg border border-border/60 object-contain"
draggable={false}
key={`img-${index}`}
slot="aui_embedded-image"
src={src}
/>
))}
</span>
)}
</span>
)
}
@@ -177,7 +177,7 @@ export const Intro: FC<IntroProps> = ({ personality, seed }) => {
}, [advanceFrame, frameOffset])
return (
<div className="pointer-events-none absolute inset-0 z-1 grid place-items-center content-center px-[calc(var(--vsq)*50)] pb-32 text-center text-muted-foreground">
<div className="pointer-events-none absolute inset-0 z-1 flex flex-col items-center justify-center px-[calc(var(--vsq)*50)] text-center text-muted-foreground">
<button
aria-label="Change Hermes pose"
className="pointer-events-auto mb-5 h-56 w-64 cursor-default border-0 bg-transparent p-0"
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest'
import { preprocessMarkdown } from './markdown-text'
describe('preprocessMarkdown', () => {
it('strips inline accidental triple-backtick starts', () => {
const input = [
'Working as intended.',
"Here's your scene: ``` http://localhost:8812/",
'',
'- **Multicolored cube**',
'- **Rotates**'
].join('\n')
const output = preprocessMarkdown(input)
expect(output).not.toContain('```')
expect(output).toContain("Here's your scene:")
expect(output).not.toContain('http://localhost:8812/')
expect(output).toContain('- **Multicolored cube**')
})
it('demotes invalid fenced prose blocks with closers', () => {
const fence = '```'
const input = [
`${fence} http://localhost:8812/`,
'- **Scroll wheel** - zoom',
'- **Right-drag/pan** - disabled',
fence
].join('\n')
const output = preprocessMarkdown(input)
expect(output).not.toContain('```')
expect(output).not.toContain('http://localhost:8812/')
expect(output).toContain('- **Scroll wheel** - zoom')
})
it('demotes prose sentence masquerading as fence info', () => {
const input = ['```Heads up - a bunny got added', '- Pure white (`#ffffff`)', '- Ambient dropped to 0.18'].join('\n')
const output = preprocessMarkdown(input)
expect(output).not.toContain('```heads')
expect(output).toContain('Heads up - a bunny got added')
expect(output).toContain('- Pure white (`#ffffff`)')
})
it('keeps valid code fences intact', () => {
const fence = '```'
const input = [`${fence}ts`, 'const value = 1;', fence].join('\n')
const output = preprocessMarkdown(input)
expect(output).toContain('```ts')
expect(output).toContain('const value = 1;')
})
it('keeps dangling real code fences during streaming', () => {
const input = ['```ts', 'const value = 1;'].join('\n')
const output = preprocessMarkdown(input)
expect(output.startsWith('```ts')).toBe(true)
expect(output).toContain('const value = 1;')
})
it('demotes dangling prose fences', () => {
const input = ['```', '- Pure white (`#ffffff`)', '- Ambient dropped to 0.18'].join('\n')
const output = preprocessMarkdown(input)
expect(output).not.toContain('```')
expect(output).toContain('- Pure white (`#ffffff`)')
})
})
@@ -2,14 +2,24 @@
import { type StreamdownTextComponents, StreamdownTextPrimitive } from '@assistant-ui/react-streamdown'
import { code } from '@streamdown/code'
import { Check, Copy, Download } from 'lucide-react'
import { type ComponentProps, memo, useMemo, useState } from 'react'
import { Check, Copy } from 'lucide-react'
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 { Dialog, DialogContent } from '@/components/ui/dialog'
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
import { triggerHaptic } from '@/lib/haptics'
import {
filePathFromMediaPath,
mediaExternalUrl,
mediaKind,
mediaMime,
mediaName,
mediaPathFromMarkdownHref
} from '@/lib/media'
import { isLikelyProseCodeBlock, isLikelyProseFence, sanitizeLanguageTag } from '@/lib/markdown-code'
import { previewTargetFromMarkdownHref, stripPreviewTargets } from '@/lib/preview-targets'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
/**
* Strip provider/model "thinking" blocks before markdown render.
@@ -18,14 +28,123 @@ import { notify, notifyError } from '@/store/notifications'
* 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
function stripReasoning(text: string): string {
return text.replace(REASONING_BLOCK_RE, '')
const FENCE_LINE_RE = /^([ \t]*)(`{3,}|~{3,})([^\n]*)$/
const MIDLINE_FENCE_RE = /([^\n])```+(?=\s|$)/g
function stripMidlineFenceStarts(text: string): string {
return text.replace(MIDLINE_FENCE_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) {
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))
return normalizedFences
.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)
if (isLikelyProseCodeBlock(language, code)) {
return null
}
async function handleCopy() {
if (!code) {
return
@@ -46,11 +165,12 @@ function CodeHeader({ language, code }: { language?: string; code?: string }) {
}
}
const label = language && language !== 'unknown' ? language : 'code'
const cleanLanguage = sanitizeLanguageTag(language || '')
const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : ''
return (
<div className="mt-4 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}</span>
<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"
@@ -64,164 +184,160 @@ function CodeHeader({ language, code }: { language?: string; code?: string }) {
)
}
function imageFilename(src?: string): string {
if (!src) {
return 'image'
}
async function typedBlobUrl(dataUrl: string, mime: string): Promise<string> {
const blob = await fetch(dataUrl).then(response => response.blob())
try {
const { pathname } = new URL(src, window.location.href)
return pathname.split('/').filter(Boolean).pop() || 'image'
} catch {
return src.split(/[\\/]/).filter(Boolean).pop() || 'image'
}
return URL.createObjectURL(new Blob([await blob.arrayBuffer()], { type: mime }))
}
function isMissingIpcHandler(error: unknown): boolean {
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
return message.includes("No handler registered for 'hermes:saveImageFromUrl'")
}
async function startBrowserDownload(src: string) {
const response = await fetch(src)
if (!response.ok) {
throw new Error(`Could not fetch image: ${response.status}`)
async function mediaSrc(path: string): Promise<string> {
if (/^(?:https?|data):/i.test(path)) {
return path
}
const blobUrl = URL.createObjectURL(await response.blob())
const link = document.createElement('a')
link.href = blobUrl
link.download = imageFilename(src)
link.rel = 'noopener noreferrer'
document.body.appendChild(link)
link.click()
link.remove()
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
if (!window.hermesDesktop?.readFileDataUrl) {
return mediaExternalUrl(path)
}
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
return ['audio', 'video'].includes(mediaKind(path)) ? typedBlobUrl(dataUrl, mediaMime(path)) : dataUrl
}
const imageActionButtonClass =
'absolute right-2 top-2 grid size-8 place-items-center rounded-full border border-border/70 bg-background/80 text-muted-foreground opacity-0 shadow-sm backdrop-blur transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 disabled:opacity-50'
function OpenMediaButton({ kind, path }: { kind: 'audio' | 'video'; path: string }) {
return (
<button
className="mt-2 bg-transparent text-xs font-medium text-muted-foreground underline underline-offset-4 hover:text-foreground"
onClick={() => void window.hermesDesktop?.openExternal(mediaExternalUrl(path))}
type="button"
>
Open {kind} file
</button>
)
}
function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>) {
const [saving, setSaving] = useState(false)
const [lightboxOpen, setLightboxOpen] = useState(false)
const canOpen = Boolean(src)
function MediaAttachment({ path }: { path: string }) {
const [src, setSrc] = useState('')
const [failed, setFailed] = useState(false)
const kind = mediaKind(path)
const name = mediaName(path)
async function handleDownload() {
if (!src || saving) {
return
}
useEffect(() => {
let cancelled = false
let objectUrl = ''
setSaving(true)
try {
if (window.hermesDesktop?.saveImageFromUrl) {
const saved = await window.hermesDesktop.saveImageFromUrl(src)
if (saved) {
notify({
kind: 'success',
title: 'Image saved',
message: imageFilename(src)
})
setFailed(false)
setSrc('')
void mediaSrc(path)
.then(value => {
if (value.startsWith('blob:')) {
objectUrl = value
}
return
}
await startBrowserDownload(src)
} catch (error) {
if (isMissingIpcHandler(error)) {
try {
await startBrowserDownload(src)
notify({
kind: 'info',
title: 'Download started',
message: 'Restart Hermes Desktop to use Save Image.'
})
} catch (fallbackError) {
notifyError(fallbackError, 'Restart Hermes Desktop to save images')
if (!cancelled) {
setSrc(value)
} else if (objectUrl) {
URL.revokeObjectURL(objectUrl)
}
})
.catch(() => {
if (!cancelled) {
setFailed(true)
}
})
return
return () => {
cancelled = true
if (objectUrl) {
URL.revokeObjectURL(objectUrl)
}
notifyError(error, 'Image download failed')
} finally {
setSaving(false)
}
}, [path])
if (kind === 'image' && src) {
return (
<span className="block">
<MarkdownImage alt={name} src={src} />
</span>
)
}
function openLightbox() {
if (canOpen) {
setLightboxOpen(true)
}
if (kind === 'audio' && src) {
return (
<span className="my-3 block max-w-md rounded-xl border border-border/70 bg-card/70 p-3">
<span className="mb-2 block truncate text-xs font-medium text-muted-foreground">{name}</span>
<audio className="block w-full" controls onError={() => setFailed(true)} preload="metadata" src={src} />
{failed && <OpenMediaButton kind="audio" path={path} />}
</span>
)
}
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"
showCloseButton={false}
>
<div className="group/lightbox relative max-h-[calc(100vh-2rem)] max-w-[calc(100vw-2rem)] overflow-auto">
<img
alt={alt ?? ''}
className="block max-h-[calc(100vh-2rem)] max-w-full cursor-zoom-out select-auto rounded-lg object-contain shadow-2xl"
onClick={() => setLightboxOpen(false)}
src={src}
/>
<button
aria-label={saving ? 'Saving image' : 'Download image'}
className={cn(imageActionButtonClass, 'group-hover/lightbox:opacity-100')}
disabled={saving}
onClick={event => {
event.stopPropagation()
void handleDownload()
}}
title={saving ? 'Saving image' : 'Download image'}
type="button"
>
<Download className={cn('size-4', saving && 'animate-pulse')} />
</button>
</div>
</DialogContent>
</Dialog>
) : null
if (kind === 'video' && src) {
return (
<span className="my-3 block max-w-2xl rounded-xl border border-border/70 bg-card/70 p-3">
<span className="mb-2 block truncate text-xs font-medium text-muted-foreground">{name}</span>
<video
className="block max-h-112 w-full rounded-lg bg-black"
controls
onError={() => setFailed(true)}
src={src}
/>
{failed && <OpenMediaButton kind="video" path={path} />}
</span>
)
}
return (
<>
<span className="group/image relative my-3 inline-block max-w-full align-top" data-slot="aui_markdown-image">
<button
className="block max-w-full cursor-zoom-in bg-transparent p-0 text-left"
disabled={!canOpen}
onClick={openLightbox}
title={canOpen ? 'Open image' : undefined}
type="button"
>
<img alt={alt ?? ''} className={className} src={src} {...props} />
</button>
{src && (
<button
aria-label={saving ? 'Saving image' : 'Download image'}
className={cn(imageActionButtonClass, 'group-hover/image:opacity-100')}
disabled={saving}
onClick={event => {
event.stopPropagation()
void handleDownload()
}}
title={saving ? 'Saving image' : 'Download image'}
type="button"
>
<Download className={cn('size-4', saving && 'animate-pulse')} />
</button>
)}
</span>
{lightbox}
</>
<a
className="font-medium text-foreground underline underline-offset-4 decoration-foreground/30 wrap-anywhere hover:decoration-foreground/70"
href="#"
onClick={event => {
event.preventDefault()
void window.hermesDesktop?.openExternal(mediaExternalUrl(path))
}}
>
{failed ? `Open ${name}` : `Loading ${name}...`}
</a>
)
}
function MarkdownLink({ className, href, ...props }: ComponentProps<'a'>) {
const mediaPath = mediaPathFromMarkdownHref(href)
const previewTarget = previewTargetFromMarkdownHref(href)
if (mediaPath) {
return <MediaAttachment path={mediaPath} />
}
if (previewTarget) {
return <PreviewAttachment target={previewTarget} />
}
return (
<a
className={cn(
'font-medium text-foreground underline underline-offset-4 decoration-foreground/30 wrap-anywhere hover:decoration-foreground/70',
className
)}
href={href}
rel="noopener noreferrer"
target="_blank"
{...props}
/>
)
}
function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>) {
return (
<ZoomableImage
alt={alt}
className={className}
containerClassName="my-3"
slot="aui_markdown-image"
src={src}
{...props}
/>
)
}
@@ -244,17 +360,7 @@ const MarkdownTextImpl = () => {
p: ({ className, ...props }: ComponentProps<'p'>) => (
<p className={cn('wrap-anywhere leading-relaxed', className)} {...props} />
),
a: ({ className, ...props }: ComponentProps<'a'>) => (
<a
className={cn(
'font-medium text-foreground underline underline-offset-4 decoration-foreground/30 wrap-anywhere hover:decoration-foreground/70',
className
)}
rel="noopener noreferrer"
target="_blank"
{...props}
/>
),
a: MarkdownLink,
hr: ({ className, ...props }: ComponentProps<'hr'>) => (
<hr className={cn('border-border/70', className)} {...props} />
),
@@ -315,7 +421,7 @@ const MarkdownTextImpl = () => {
mode="streaming"
parseIncompleteMarkdown
plugins={{ code }}
preprocess={stripReasoning}
preprocess={preprocessMarkdown}
shikiTheme={['github-light-default', 'github-dark-default']}
/>
)
@@ -0,0 +1,94 @@
import { useStore } from '@nanostores/react'
import { MonitorPlay } from 'lucide-react'
import { useState } from 'react'
import { previewName } from '@/lib/preview-targets'
import { notifyError } from '@/store/notifications'
import { $previewTarget, setPreviewTarget } from '@/store/preview'
import { $currentCwd } from '@/store/session'
export function PreviewAttachment({ target }: { target: string }) {
const cwd = useStore($currentCwd)
const activePreview = useStore($previewTarget)
const [opening, setOpening] = useState(false)
const name = previewName(target)
const isActive = activePreview?.source === target
function localFallbackPreview() {
if (/^https?:\/\//i.test(target)) {
return { kind: 'url' as const, label: previewName(target), source: target, url: target }
}
if (/^file:\/\//i.test(target)) {
return { kind: 'file' as const, label: previewName(target), source: target, url: target }
}
if (/^(?:\/|\.{1,2}\/|~\/).+\.html?$/i.test(target)) {
const path = target.startsWith('file://') ? target : `file://${encodeURI(target)}`
return { kind: 'file' as const, label: previewName(target), source: target, 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)
return
}
setOpening(true)
try {
const preview = await window.hermesDesktop?.normalizePreviewTarget(target, cwd || undefined).catch(error => {
if (isMissingPreviewIpc(error)) {
return localFallbackPreview()
}
throw error
})
if (!preview) {
throw new Error(`Could not open preview target: ${target}`)
}
setPreviewTarget(preview)
} catch (error) {
notifyError(error, 'Preview unavailable')
} finally {
setOpening(false)
}
}
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>
<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"
disabled={opening}
onClick={() => void togglePreview()}
type="button"
>
{opening ? 'Opening...' : isActive ? 'Hide Preview' : 'Toggle Preview'}
</button>
</div>
)
}
@@ -4,6 +4,8 @@ import type { SyntaxHighlighterProps } from '@assistant-ui/react-streamdown'
import type { FC } from 'react'
import ShikiHighlighter from 'react-shiki'
import { isLikelyProseCodeBlock } from '@/lib/markdown-code'
/**
* assistant-ui's recommended `SyntaxHighlighter` slot.
*
@@ -22,10 +24,13 @@ export const SyntaxHighlighter: FC<SyntaxHighlighterProps> = ({
language,
code
}) => {
// Markdown fences include the pre-closing newline in `code`, which
// Shiki tokenizes into a blank final line. Trim so the box ends on
// real code.
const trimmed = (code ?? '').trimEnd()
// Streamdown may hand us fence contents with edge newlines. Strip blank
// fence padding without touching indentation on the first real line.
const trimmed = (code ?? '').replace(/^\n+/, '').trimEnd()
if (isLikelyProseCodeBlock(language, trimmed)) {
return <div className="whitespace-pre-wrap wrap-anywhere text-foreground">{trimmed}</div>
}
return (
<Pre className="aui-shiki m-0 overflow-hidden rounded-b-md border border-t-0 border-border bg-card font-mono text-sm leading-relaxed [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-4 [&_pre]:py-3 [&_pre]:font-mono [&_pre]:leading-relaxed">
@@ -2,10 +2,12 @@ import {
ActionBarPrimitive,
AuiIf,
BranchPickerPrimitive,
ComposerPrimitive,
ErrorPrimitive,
MessagePrimitive,
ThreadPrimitive,
type ToolCallMessagePartProps,
useAuiEvent,
useAuiState
} from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
@@ -17,19 +19,42 @@ import {
GitBranchIcon,
Loader2Icon,
MoreHorizontalIcon,
PencilIcon,
RefreshCwIcon,
Volume2Icon,
VolumeXIcon
VolumeXIcon,
XIcon
} from 'lucide-react'
import { type FC, type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import {
type FC,
type ReactNode,
useCallback,
useEffect,
useRef,
useState
} from 'react'
// Scroll behavior: delegated to `use-stick-to-bottom` (StackBlitz), the
// reference implementation that powers bolt.new and several other streaming
// chat UIs. It handles everything we care about — spring-animated catch-up,
// resize-vs-user-scroll disambiguation, wheel/touch escape, text-selection
// pause, subpixel overshoot, programmatic-scroll event suppression — via 665
// lines of well-tested edge-case handling that we should NOT hand-roll.
//
// We only own the thin glue: jump-to-bottom on session switch / send, and
// keeping `$threadScrolledUp` in sync with `isAtBottom` for the composer's
// dim-when-scrolled-away treatment.
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
import spinners from 'unicode-animations'
import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
import { 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'
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 {
@@ -41,49 +66,16 @@ import {
} from '@/components/ui/dropdown-menu'
import { Loader } from '@/components/ui/loader'
import { triggerHaptic } from '@/lib/haptics'
import { extractPreviewTargets } from '@/lib/preview-targets'
import { cn } from '@/lib/utils'
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
import { notifyError } from '@/store/notifications'
import { setThreadScrolledUp } from '@/store/thread-scroll'
import { $voicePlayback } from '@/store/voice-playback'
const THINKING_FACES = [
'(。•́︿•̀。)',
'(◔_◔)',
'(¬‿¬)',
'( •_•)>⌐■-■',
'(⌐■_■)',
'(´・_・`)',
'◉_◉',
'(°ロ°)',
'( ˘⌣˘)♡',
'ヽ(>∀<☆)☆',
'٩(๑❛ᴗ❛๑)۶',
'(⊙_⊙)',
'(¬_¬)',
'( ͡° ͜ʖ ͡°)',
'ಠ_ಠ'
]
const RESPONSE_SPINNER = spinners.braille
const THINKING_VERBS = [
'pondering',
'contemplating',
'musing',
'cogitating',
'ruminating',
'deliberating',
'mulling',
'reflecting',
'processing',
'reasoning',
'analyzing',
'computing',
'synthesizing',
'formulating',
'brainstorming'
]
type ThreadLoadingState = 'response' | 'session' | 'working'
type ThreadLoadingState = 'response' | 'session'
interface MessageActionProps {
messageId: string
@@ -91,13 +83,8 @@ interface MessageActionProps {
onBranchInNewChat?: (messageId: string) => void
}
const BOTTOM_DISTANCE_PX = 24
let readAloudAudio: HTMLAudioElement | null = null
function isNearBottom(el: HTMLElement): boolean {
return el.scrollHeight - (el.scrollTop + el.clientHeight) <= BOTTOM_DISTANCE_PX
}
function partText(part: unknown): string {
if (typeof part === 'string') {
return part
@@ -126,142 +113,298 @@ export const Thread: FC<{
onBranchInNewChat?: (messageId: string) => void
sessionKey?: string | null
}> = ({ intro, loading, onBranchInNewChat, sessionKey }) => {
const viewportRef = useRef<HTMLDivElement | null>(null)
const contentRef = useRef<HTMLDivElement | null>(null)
const messageCount = useAuiState(s => s.thread.messages.length)
const isRunning = useAuiState(s => s.thread.isRunning)
const lastMessageId = useAuiState(s => s.thread.messages.at(-1)?.id ?? '')
const shouldStickToBottomRef = useRef(true)
const scrollFrameRef = useRef<number | null>(null)
const sessionKeyRef = useRef<string | null>(sessionKey ?? null)
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
const nearBottom = isNearBottom(event.currentTarget)
shouldStickToBottomRef.current = nearBottom
setThreadScrolledUp(!nearBottom)
}, [])
const handleWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
if (event.deltaY < 0) {
shouldStickToBottomRef.current = false
setThreadScrolledUp(true)
}
}, [])
const scrollToBottom = useCallback(() => {
const viewport = viewportRef.current
if (!viewport) {
return
}
viewport.scrollTop = viewport.scrollHeight
shouldStickToBottomRef.current = true
setThreadScrolledUp(false)
}, [])
const scheduleScrollToBottom = useCallback(() => {
if (scrollFrameRef.current !== null) {
window.cancelAnimationFrame(scrollFrameRef.current)
}
scrollFrameRef.current = window.requestAnimationFrame(() => {
scrollFrameRef.current = null
scrollToBottom()
})
}, [scrollToBottom])
useEffect(() => {
return () => {
if (scrollFrameRef.current !== null) {
window.cancelAnimationFrame(scrollFrameRef.current)
}
setThreadScrolledUp(false)
}
}, [])
useLayoutEffect(() => {
const viewport = viewportRef.current
if (!viewport) {
return
}
const nextSessionKey = sessionKey ?? null
const sessionChanged = sessionKeyRef.current !== nextSessionKey
sessionKeyRef.current = nextSessionKey
const force = loading === 'session' || sessionChanged
if (!force && !shouldStickToBottomRef.current) {
return
}
scheduleScrollToBottom()
}, [isRunning, lastMessageId, loading, messageCount, scheduleScrollToBottom, sessionKey])
useLayoutEffect(() => {
const content = contentRef.current
const viewport = viewportRef.current
if (!content || !viewport) {
return
}
let previousHeight = content.getBoundingClientRect().height
const observer = new ResizeObserver(entries => {
const height = entries[0]?.contentRect.height ?? content.getBoundingClientRect().height
if (height === previousHeight) {
return
}
previousHeight = height
if (!shouldStickToBottomRef.current && !isNearBottom(viewport)) {
return
}
scheduleScrollToBottom()
})
observer.observe(content)
return () => observer.disconnect()
}, [scheduleScrollToBottom])
return (
<GeneratedImageProvider>
<ThreadPrimitive.Root className="relative grid h-full min-h-0 grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent">
<AuiIf condition={s => Boolean(intro) && s.thread.isEmpty}>{intro && <Intro {...intro} />}</AuiIf>
<ThreadPrimitive.Viewport
autoScroll={false}
className="h-full min-h-0 overflow-y-auto overscroll-contain px-[clamp(1rem,10%,12rem)] pt-[calc(var(--vsq)*19)]"
data-slot="aui_thread-viewport"
onScroll={handleScroll}
onWheel={handleWheel}
ref={viewportRef}
scrollToBottomOnInitialize
scrollToBottomOnRunStart
scrollToBottomOnThreadSwitch
>
<div className="flex w-full flex-col gap-3" ref={contentRef}>
<ThreadPrimitive.Messages>
{() => <ThreadMessage onBranchInNewChat={onBranchInNewChat} />}
</ThreadPrimitive.Messages>
{loading === 'response' && <ResponseLoadingIndicator />}
{loading === 'working' && <WorkingIndicator />}
</div>
<ThreadPrimitive.ViewportFooter className="h-(--thread-composer-clearance) shrink-0" />
</ThreadPrimitive.Viewport>
<ThreadPrimitive.ViewportProvider>
{/*
* <StickToBottom> renders a wrapper <div>; <StickToBottom.Content>
* renders an inner scroll container (inline height/width 100%) plus
* an inner content div. So:
* - `className` on <StickToBottom> = outer wrapper sizing
* - `scrollClassName` on <.Content> = scroll container
* - `className` on <.Content> = content (flex column)
*
* `initial: 'instant'`: no animation on first mount.
* `resize: 'instant'`: during streaming, snap to bottom each token.
* Spring animation ('smooth') visibly lags behind fast token
* streams; users read that as jank. 'instant' matches ChatGPT.
*
* The composer is rendered OUTSIDE the scroller as `position:
* absolute; bottom: 0` (floating glass treatment) and overlays the
* bottom of the scroll surface. We compensate by putting a tall
* bottom spacer (>= composer height + margin) inside the scroll
* content so "scroll to bottom" naturally parks the last line of
* content above the composer, not hidden behind it.
*/}
<StickToBottom
className="relative h-full min-h-0"
initial="instant"
resize="instant"
>
<ThreadScrollSync sessionKey={sessionKey} />
<StickToBottom.Content
className="flex w-full flex-col gap-3 px-[clamp(1rem,10%,12rem)] pt-[calc(var(--vsq)*19)]"
data-slot="aui_thread-content"
scrollClassName="overflow-y-auto overscroll-contain"
>
<ThreadPrimitive.Messages
components={{
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} />,
SystemMessage,
UserEditComposer,
UserMessage
}}
/>
{loading === 'response' && <ResponseLoadingIndicator />}
<ComposerClearance />
</StickToBottom.Content>
</StickToBottom>
</ThreadPrimitive.ViewportProvider>
{loading === 'session' && <CenteredThreadSpinner />}
</ThreadPrimitive.Root>
</GeneratedImageProvider>
)
}
/**
* Scroll glue for the chat thread. Replaces hand-rolled follow logic with
* the exact pattern that assistant-ui's own `useThreadViewportAutoScroll`
* uses internally: **raw DOM scroll + an armed behavior ref + a
* ResizeObserver loop that re-pins to bottom until we actually reach it.**
*
* Why not use the library's `scrollToBottom` for sends?
* - It wraps its work in `new Promise(requestAnimationFrame)` so even
* `animation: 'instant'` is 1+ frame async.
* - It does NOT clear `escapedFromLock` on call — if the user had
* scrolled up before sending, the library's resize handler keeps
* un-setting `isAtBottom` between our scroll and the next resize.
* - `ignoreEscapes` only blocks NEW escapes during the animation; it
* doesn't unstick an already-escaped state.
*
* The armed-ref pattern handles all of that:
* 1. `thread.runStart` fires after the runtime has committed the user
* message to state (so scrollHeight already reflects it).
* 2. We arm a ref ('instant') and write `scrollTop = scrollHeight`
* synchronously.
* 3. A ResizeObserver on the content keeps re-pinning each time the
* DOM grows (user message paints, assistant placeholder mounts,
* assistant streams) until scrollTop is actually at bottom — then
* we disarm.
* 4. Any wheel-up or touch-scroll-up disarms immediately so the user
* can always escape.
*
* This mirrors:
* - assistant-ui's `useThreadViewportAutoScroll` (scrollToBottomBehaviorRef
* + useOnResizeContent loop)
* - Vercel ai-chatbot's `useScrollToBottom` (MutationObserver + RO on
* container and children + isAtBottom/isUserScrolling flags)
*
* Must be rendered INSIDE a <StickToBottom> because useStickToBottomContext
* reads from that component's context.
*/
const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) => {
const { scrollRef, isAtBottom, state } = useStickToBottomContext()
const sessionKeyRef = useRef<string | null>(sessionKey ?? null)
// "Armed" behavior ref. Non-null = "keep chasing bottom across resize
// ticks until we get there." Null = "user owns the viewport."
const armedRef = useRef<ScrollBehavior | null>(null)
const messageCount = useAuiState(s => s.thread.messages.length)
const prevMessageCountRef = useRef(messageCount)
useEffect(() => {
setThreadScrolledUp(!isAtBottom)
}, [isAtBottom])
useEffect(() => {
return () => {
setThreadScrolledUp(false)
}
}, [])
// Slam to bottom + arm the ref. Also forces library state flags off
// so its internal resize handler doesn't fight our re-pins.
const armAndPin = useCallback((behavior: ScrollBehavior) => {
const el = scrollRef.current
if (!el) {
return
}
armedRef.current = behavior
// Clear the library's escape/at-bottom flags directly on the mutable
// state object so its resize handler sees a clean follow state.
state.escapedFromLock = false
state.isAtBottom = true
el.scrollTop = el.scrollHeight
}, [scrollRef, state])
// ResizeObserver loop — re-pins to bottom while armed, disarms when
// actually at bottom. This is the assistant-ui pattern.
useEffect(() => {
const el = scrollRef.current
if (!el) {
return
}
const observer = new ResizeObserver(() => {
const behavior = armedRef.current
if (!behavior) {
return
}
const distance = el.scrollHeight - (el.scrollTop + el.clientHeight)
if (distance < 2) {
armedRef.current = null
return
}
el.scrollTop = el.scrollHeight
})
observer.observe(el)
const content = el.firstElementChild
if (content) {
observer.observe(content)
}
return () => observer.disconnect()
}, [scrollRef])
// User-intent detection — any upward gesture disarms the chase.
useEffect(() => {
const el = scrollRef.current
if (!el) {
return
}
const onWheel = (e: WheelEvent) => {
if (e.deltaY < 0) {
armedRef.current = null
}
}
const onTouch = () => {
armedRef.current = null
}
el.addEventListener('wheel', onWheel, { passive: true })
el.addEventListener('touchmove', onTouch, { passive: true })
return () => {
el.removeEventListener('wheel', onWheel)
el.removeEventListener('touchmove', onTouch)
}
}, [scrollRef])
// (1) Session switch — strong intent to see the bottom of the new thread.
useEffect(() => {
const next = sessionKey ?? null
if (sessionKeyRef.current === next) {
return
}
sessionKeyRef.current = next
prevMessageCountRef.current = 0
armAndPin('auto')
}, [armAndPin, sessionKey])
// (2) Bulk message load (session history arriving from storage) — pin
// to bottom and stay armed while the thread's markdown/code/images
// settle over the next several frames.
useEffect(() => {
const prev = prevMessageCountRef.current
prevMessageCountRef.current = messageCount
if (prev === 0 && messageCount > 0) {
armAndPin('auto')
}
}, [armAndPin, messageCount])
// (3) User send — the runtime event `thread.runStart` fires after the
// user message has been committed to state (scrollHeight already reflects
// it). This is the canonical signal per assistant-ui's own code. We
// arm-and-pin synchronously in the callback, then the RO loop above
// keeps us at bottom as the assistant message placeholder + reply stream.
useAuiEvent('thread.runStart', () => {
armAndPin('instant')
})
return null
}
/**
* Invisible bottom spacer whose height matches the currently-measured
* composer height (plus a small gap). Because the composer is rendered
* OUTSIDE the scroll container as `position: absolute; bottom: 0`, "scroll
* to bottom" would otherwise park the last content line behind it. By
* extending the scroll content down with real (blank) space equal to the
* composer's footprint, the library's scroll-to-scrollHeight naturally
* leaves the last message line sitting above the composer.
*
* A ResizeObserver on the composer keeps the spacer in sync when the
* textarea grows (multi-line input), attachments expand, or the composer
* enters a focused/expanded state.
*/
const COMPOSER_BREATHING_ROOM_PX = 20
const ComposerClearance: FC = () => {
const [height, setHeight] = useState<number>(() => {
// Sensible default until the observer wires up (~ 8rem).
if (typeof document === 'undefined') return 128
const composer = document.querySelector<HTMLElement>('[data-slot="composer-root"]')
return composer ? composer.getBoundingClientRect().height + COMPOSER_BREATHING_ROOM_PX : 128
})
useEffect(() => {
const composer = document.querySelector<HTMLElement>('[data-slot="composer-root"]')
if (!composer) {
return
}
const apply = () => {
const h = composer.getBoundingClientRect().height
setHeight(prev => {
const next = Math.round(h + COMPOSER_BREATHING_ROOM_PX)
return Math.abs(prev - next) < 1 ? prev : next
})
}
apply()
const observer = new ResizeObserver(apply)
observer.observe(composer)
return () => observer.disconnect()
}, [])
return <div aria-hidden="true" className="shrink-0" style={{ height: `${height}px` }} />
}
function pickPrimaryPreviewTarget(targets: string[]): string[] {
if (targets.length <= 1) {
return targets
}
const localUrl = targets.find(value => /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/i.test(value))
return [localUrl || targets[targets.length - 1]]
}
const CenteredThreadSpinner: FC = () => (
<div
aria-label="Loading session"
@@ -279,38 +422,16 @@ const CenteredThreadSpinner: FC = () => (
</div>
)
const ThreadMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
const role = useAuiState(s => s.message.role)
const isEditing = useAuiState(s => s.message.composer.isEditing)
// The runtime synthesizes an empty assistant placeholder while isRunning is true
// (last message is user). Rendering the full `MessagePrimitive.Root` for it adds
// ~36px of invisible chrome (gap-2 + min-h-7 footer) which can push the
// loading affordance too far below the user message. Skip it —
// `ResponseLoadingIndicator` in the viewport handles the loading affordance directly.
const isPlaceholder = useAuiState(
s => s.message.role === 'assistant' && s.message.status?.type === 'running' && s.message.content.length === 0
)
if (isEditing) {
return <EditComposer />
}
if (role === 'user') {
return <UserMessage />
}
if (isPlaceholder) {
return null
}
return <AssistantMessage onBranchInNewChat={onBranchInNewChat} />
}
const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
const messageId = useAuiState(s => s.message.id)
const content = useAuiState(s => s.message.content)
const messageText = messageContentText(content)
const previewTargets = pickPrimaryPreviewTarget(extractPreviewTargets(messageText))
const isPlaceholder = useAuiState(s => s.message.status?.type === 'running' && s.message.content.length === 0)
if (isPlaceholder) {
return null
}
return (
<MessagePrimitive.Root
@@ -326,6 +447,13 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
tools: { Fallback: ChainToolFallback }
}}
/>
{previewTargets.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{previewTargets.map(target => (
<PreviewAttachment key={target} target={target} />
))}
</div>
)}
<MessagePrimitive.Error>
<ErrorPrimitive.Root
className="mt-2 rounded-md border border-destructive/20 bg-destructive/5 px-3 py-2 text-sm text-destructive"
@@ -351,45 +479,28 @@ const StatusRow: FC<{ children: ReactNode; label: string }> = ({ children, label
)
const ResponseLoadingIndicator: FC = () => {
const [tick, setTick] = useState(0)
const [frame, setFrame] = useState(0)
const elapsed = useElapsedSeconds()
useEffect(() => {
const id = window.setInterval(() => setTick(t => t + 1), 900)
const id = window.setInterval(
() => setFrame(current => (current + 1) % RESPONSE_SPINNER.frames.length),
RESPONSE_SPINNER.interval
)
return () => window.clearInterval(id)
}, [])
const face = THINKING_FACES[tick % THINKING_FACES.length]
const verb = THINKING_VERBS[tick % THINKING_VERBS.length]
return (
<StatusRow label="Hermes is loading a response">
<span className="shimmer shimmer-repeat-delay-0 min-w-0 truncate text-muted-foreground/55">
{face} {verb}
<span aria-hidden="true" className="font-mono text-base leading-none text-muted-foreground/60">
{RESPONSE_SPINNER.frames[frame]}
</span>
<ActivityTimerText seconds={elapsed} />
</StatusRow>
)
}
const WorkingIndicator: FC = () => {
const elapsed = useElapsedSeconds()
return (
<StatusRow label="Hermes is still working">
<Loader
className="size-4 text-muted-foreground/60"
label="Still working"
strokeScale={0.65}
type="spiral-search"
/>
<span className="shimmer min-w-0 truncate text-muted-foreground/60">Still working</span>
<ActivityTimerText seconds={elapsed} />
</StatusRow>
)
}
const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ result }) => {
const generatedImage = useGeneratedImageContext()
const running = result === undefined
@@ -414,6 +525,10 @@ const ChainToolFallback: FC<ToolCallMessagePartProps> = props => {
return <ImageGenerateTool {...props} />
}
if (props.toolName === 'clarify') {
return <ClarifyTool {...props} />
}
return <ToolFallback {...props} />
}
@@ -637,21 +752,97 @@ 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 UserMessage: FC = () => {
const content = useAuiState(s => s.message.content)
const messageText = messageContentText(content)
return (
<MessagePrimitive.Root
className="group flex max-w-[min(72%,34rem)] flex-col gap-2 self-end 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"
className="group flex max-w-[min(72%,34rem)] flex-col items-end gap-2 self-end"
data-role="user"
data-slot="aui_user-message-root"
>
<div className="wrap-anywhere whitespace-pre-line leading-[1.48] text-foreground/95">
<div className="wrap-anywhere whitespace-pre-line rounded-2xl border border-[color-mix(in_srgb,var(--dt-user-bubble-border)_78%,transparent)] bg-[color-mix(in_srgb,var(--dt-user-bubble)_94%,transparent)] px-3 py-2 leading-[1.48] text-foreground/95">
<MessagePrimitive.Parts components={{ Text: DirectiveText }} />
</div>
<div className="min-h-6">
<UserActionBar messageText={messageText} />
</div>
</MessagePrimitive.Root>
)
}
const EditComposer: FC = () => {
// Editing requires a real onEdit implementation against Hermes history.
// Hide the edit composer until that contract is implemented.
return null
const UserActionBar: FC<{ messageText: string }> = ({ messageText }) => (
<div className="relative h-6 w-14 shrink-0">
<ActionBarPrimitive.Root className={ACTION_BAR_CLASS} hideWhenRunning>
<CopyMessageButton text={messageText} />
<ActionBarPrimitive.Edit asChild>
<TooltipIconButton onClick={() => triggerHaptic('selection')} tooltip="Edit">
<PencilIcon />
</TooltipIconButton>
</ActionBarPrimitive.Edit>
</ActionBarPrimitive.Root>
</div>
)
const SLASH_STATUS_RE = /^slash:(?<command>\/[^\n]+)\n(?<output>[\s\S]*)$/
const SystemMessage: FC = () => {
const text = useAuiState(s => messageContentText(s.message.content))
if (!text) {
return null
}
const slashStatus = text.match(SLASH_STATUS_RE)
if (slashStatus?.groups) {
return (
<MessagePrimitive.Root
className="max-w-[min(86%,44rem)] self-center px-2 py-0.5 text-center text-[0.6875rem] leading-5 text-muted-foreground/60"
data-role="system"
data-slot="aui_system-message-root"
>
<span className="font-mono text-muted-foreground/55">{slashStatus.groups.command}</span>
<span className="mx-1.5 text-muted-foreground/35">·</span>
<span className="whitespace-pre-wrap">{slashStatus.groups.output.trim()}</span>
</MessagePrimitive.Root>
)
}
return (
<MessagePrimitive.Root
className="max-w-[min(86%,44rem)] self-center px-2 py-0.5 text-center text-[0.6875rem] leading-5 text-muted-foreground/55"
data-role="system"
data-slot="aui_system-message-root"
>
<span className="whitespace-pre-wrap">{text}</span>
</MessagePrimitive.Root>
)
}
const UserEditComposer: FC = () => (
<ComposerPrimitive.Root
className="flex min-w-[min(18rem,72vw)] max-w-[min(72%,34rem)] flex-col gap-1.5 self-end rounded-2xl border border-[color-mix(in_srgb,var(--dt-user-bubble-border)_88%,transparent)] bg-[color-mix(in_srgb,var(--dt-user-bubble)_98%,transparent)] px-3 py-2 shadow-sm"
data-slot="aui_edit-composer-root"
>
<ComposerPrimitive.Input
autoFocus
className="min-h-8 w-full resize-none bg-transparent leading-[1.48] text-foreground/95 outline-none"
rows={1}
submitMode="enter"
unstable_focusOnScrollToBottom={false}
/>
<div className="flex justify-end gap-1">
<ComposerPrimitive.Cancel asChild>
<TooltipIconButton tooltip="Cancel edit">
<XIcon />
</TooltipIconButton>
</ComposerPrimitive.Cancel>
<ComposerPrimitive.Send asChild>
<TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip="Send edit">
<CheckIcon />
</TooltipIconButton>
</ComposerPrimitive.Send>
</div>
</ComposerPrimitive.Root>
)
@@ -1,12 +1,14 @@
'use client'
import { type ToolCallMessagePartProps } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { ChevronRight } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
import { cn } from '@/lib/utils'
import { $toolInlineDiffs } from '@/store/tool-diffs'
const TOOL_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
@@ -78,6 +80,24 @@ function prettyJson(value: unknown): string {
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
}
function recordValue(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
}
function stripAnsi(value: string): string {
return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '')
}
function stripInlineDiffChrome(value: string): string {
return value ? stripAnsi(value).replace(/^\s*┊\s*review diff\s*\n/i, '').trim() : ''
}
function inlineDiffFromResult(result: unknown): string {
const value = recordValue(result).inline_diff
return typeof value === 'string' ? stripInlineDiffChrome(value) : ''
}
function detailLabel(toolName: string): string {
if (toolName === 'image_generate') {
return 'Prompt'
@@ -121,7 +141,7 @@ function detailText(args: unknown, result: unknown): string {
return prettyJson(args)
}
export const ToolFallback = ({ toolName, args, result }: ToolCallMessagePartProps) => {
export const ToolFallback = ({ toolCallId, toolName, args, result }: ToolCallMessagePartProps) => {
const [open, setOpen] = useState(false)
const isPending = result === undefined
const [tick, setTick] = useState(0)
@@ -129,6 +149,9 @@ export const ToolFallback = ({ toolName, args, result }: ToolCallMessagePartProp
const preview = compactPreview(args) || compactPreview(result)
const label = toolLabel(toolName, isPending)
const detail = detailText(args, result)
const liveDiffs = useStore($toolInlineDiffs)
const sideDiff = toolCallId ? liveDiffs[toolCallId] || '' : ''
const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(result)
const spinnerFrame = TOOL_SPINNER_FRAMES[tick % TOOL_SPINNER_FRAMES.length]
useEffect(() => {
@@ -173,6 +196,35 @@ export const ToolFallback = ({ toolName, args, result }: ToolCallMessagePartProp
{detail}
</div>
)}
{inlineDiff && <InlineDiff text={inlineDiff} />}
</div>
)
}
function InlineDiff({ text }: { text: string }) {
return (
<pre className="ml-4 mt-2 max-h-96 max-w-full overflow-auto rounded-lg border border-border/60 bg-background/70 px-3 py-2 font-mono text-[0.6875rem] leading-relaxed">
{text.split('\n').map((line, index) => {
const added = line.startsWith('+') && !line.startsWith('+++')
const removed = line.startsWith('-') && !line.startsWith('---')
const hunk = line.startsWith('@@')
const fileHeader = line.startsWith('---') || line.startsWith('+++') || / → /.test(line.slice(0, 60))
return (
<span
className={cn(
'block min-w-max whitespace-pre',
added && 'text-emerald-700 dark:text-emerald-300',
removed && 'text-rose-700 dark:text-rose-300',
hunk && 'text-sky-700 dark:text-sky-300',
!added && !removed && !hunk && fileHeader && 'text-muted-foreground/80'
)}
key={`${index}-${line}`}
>
{line || ' '}
</span>
)
})}
</pre>
)
}
@@ -0,0 +1,170 @@
'use client'
import { Download } from 'lucide-react'
import { type ComponentProps, useState } from 'react'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
function imageFilename(src?: string): string {
if (!src) {
return 'image'
}
try {
const { pathname } = new URL(src, window.location.href)
return pathname.split('/').filter(Boolean).pop() || 'image'
} catch {
return src.split(/[\\/]/).filter(Boolean).pop() || 'image'
}
}
function isMissingIpcHandler(error: unknown): boolean {
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
return message.includes("No handler registered for 'hermes:saveImageFromUrl'")
}
async function startBrowserDownload(src: string) {
const response = await fetch(src)
if (!response.ok) {
throw new Error(`Could not fetch image: ${response.status}`)
}
const blobUrl = URL.createObjectURL(await response.blob())
const link = document.createElement('a')
link.href = blobUrl
link.download = imageFilename(src)
link.rel = 'noopener noreferrer'
document.body.appendChild(link)
link.click()
link.remove()
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
}
const imageActionButtonClass =
'absolute right-2 top-2 grid size-8 place-items-center rounded-full border border-border/70 bg-background/80 text-muted-foreground opacity-0 shadow-sm backdrop-blur transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 disabled:opacity-50'
export interface ZoomableImageProps extends ComponentProps<'img'> {
containerClassName?: string
slot?: string
}
export function ZoomableImage({ className, containerClassName, src, alt, slot, ...props }: ZoomableImageProps) {
const [saving, setSaving] = useState(false)
const [lightboxOpen, setLightboxOpen] = useState(false)
const canOpen = Boolean(src)
async function handleDownload() {
if (!src || saving) {
return
}
setSaving(true)
try {
if (window.hermesDesktop?.saveImageFromUrl) {
const saved = await window.hermesDesktop.saveImageFromUrl(src)
if (saved) {
notify({ kind: 'success', title: 'Image saved', message: imageFilename(src) })
}
return
}
await startBrowserDownload(src)
} catch (error) {
if (isMissingIpcHandler(error)) {
try {
await startBrowserDownload(src)
notify({
kind: 'info',
title: 'Download started',
message: 'Restart Hermes Desktop to use Save Image.'
})
} catch (fallbackError) {
notifyError(fallbackError, 'Restart Hermes Desktop to save images')
}
return
}
notifyError(error, 'Image download failed')
} finally {
setSaving(false)
}
}
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"
showCloseButton={false}
>
<div className="group/lightbox relative max-h-[calc(100vh-2rem)] max-w-[calc(100vw-2rem)] overflow-auto">
<img
alt={alt ?? ''}
className="block max-h-[calc(100vh-2rem)] max-w-full cursor-zoom-out select-auto rounded-lg object-contain shadow-2xl"
onClick={() => setLightboxOpen(false)}
src={src}
/>
<ImageActionButton onClick={handleDownload} saving={saving} variant="lightbox" />
</div>
</DialogContent>
</Dialog>
) : null
return (
<>
<span
className={cn('group/image relative inline-block max-w-full align-top', containerClassName)}
data-slot={slot ?? 'aui_zoomable-image'}
>
<button
className="block max-w-full cursor-zoom-in bg-transparent p-0 text-left"
disabled={!canOpen}
onClick={() => canOpen && setLightboxOpen(true)}
title={canOpen ? 'Open image' : undefined}
type="button"
>
<img alt={alt ?? ''} className={className} src={src} {...props} />
</button>
{src && <ImageActionButton onClick={handleDownload} saving={saving} variant="inline" />}
</span>
{lightbox}
</>
)
}
function ImageActionButton({
onClick,
saving,
variant
}: {
onClick: () => void
saving: boolean
variant: 'inline' | 'lightbox'
}) {
return (
<button
aria-label={saving ? 'Saving image' : 'Download image'}
className={cn(
imageActionButtonClass,
variant === 'inline' ? 'group-hover/image:opacity-100' : 'group-hover/lightbox:opacity-100'
)}
disabled={saving}
onClick={event => {
event.stopPropagation()
void onClick()
}}
title={saving ? 'Saving image' : 'Download image'}
type="button"
>
<Download className={cn('size-4', saving && 'animate-pulse')} />
</button>
)
}
@@ -60,8 +60,8 @@ export const SessionInspector: FC<SessionInspectorProps> = ({
<aside
aria-hidden={!open}
className={cn(
'relative flex h-screen 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-[opacity,transform] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
open ? 'translate-x-0 opacity-100' : 'pointer-events-none translate-x-2 opacity-0'
'relative flex h-screen 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}
>
@@ -132,7 +132,7 @@ function WorkspaceSection({
<SectionLabel>cwd</SectionLabel>
{editing ? (
<Input
className="h-7 bg-background px-2 font-mono text-[0.6875rem]"
className={cn(bleed, 'h-7 bg-background px-1.5 font-mono text-[0.6875rem]')}
onBlur={apply}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
@@ -152,7 +152,8 @@ function WorkspaceSection({
<div
className={cn(
quietControl,
'group grid w-full min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1 px-1.5 py-1 font-mono text-[0.6875rem] text-foreground/75'
bleed,
'group grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1 px-1.5 py-1 font-mono text-[0.6875rem] text-foreground/75'
)}
>
<button
@@ -0,0 +1,105 @@
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react'
import * as React from 'react'
import { cn } from '@/lib/utils'
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
return (
<nav
aria-label="pagination"
className={cn('mx-auto flex w-full justify-center', className)}
data-slot="pagination"
{...props}
/>
)
}
function PaginationContent({ className, ...props }: React.ComponentProps<'ul'>) {
return <ul className={cn('flex h-5 flex-row items-center gap-0.5', className)} data-slot="pagination-content" {...props} />
}
function PaginationItem({ className, ...props }: React.ComponentProps<'li'>) {
return <li className={cn('flex h-5 items-center', className)} data-slot="pagination-item" {...props} />
}
interface PaginationButtonProps extends React.ComponentProps<'button'> {
isActive?: boolean
}
function PaginationButton({ className, isActive, ...props }: PaginationButtonProps) {
return (
<button
aria-current={isActive ? 'page' : undefined}
className={cn(
'inline-flex h-5 min-w-5 items-center justify-center rounded border border-transparent px-1 text-[0.6875rem] leading-none tabular-nums transition-colors disabled:pointer-events-none disabled:opacity-45',
isActive
? 'border-border bg-background text-foreground shadow-xs'
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
className
)}
data-active={isActive}
data-slot="pagination-button"
type="button"
{...props}
/>
)
}
function PaginationPrevious({ className, ...props }: React.ComponentProps<'button'>) {
return (
<button
aria-label="Go to previous page"
className={cn(
'inline-flex h-5 items-center justify-center gap-0.5 rounded border border-transparent px-1 text-[0.6875rem] leading-none text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-45',
className
)}
data-slot="pagination-previous"
type="button"
{...props}
>
<ChevronLeft className="size-3" />
<span>Prev</span>
</button>
)
}
function PaginationNext({ className, ...props }: React.ComponentProps<'button'>) {
return (
<button
aria-label="Go to next page"
className={cn(
'inline-flex h-5 items-center justify-center gap-0.5 rounded border border-transparent px-1 text-[0.6875rem] leading-none text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-45',
className
)}
data-slot="pagination-next"
type="button"
{...props}
>
<span>Next</span>
<ChevronRight className="size-3" />
</button>
)
}
function PaginationEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
aria-hidden
className={cn('flex size-5 items-center justify-center', className)}
data-slot="pagination-ellipsis"
{...props}
>
<MoreHorizontal className="size-3" />
</span>
)
}
export {
Pagination,
PaginationButton,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationNext,
PaginationPrevious
}
+25
View File
@@ -11,7 +11,14 @@ declare global {
selectPaths: (options?: HermesSelectPathsOptions) => Promise<string[]>
writeClipboard: (text: string) => Promise<boolean>
saveImageFromUrl: (url: string) => Promise<boolean>
saveImageBuffer: (data: ArrayBuffer | Uint8Array, ext: string) => Promise<string>
saveClipboardImage: () => Promise<string>
getPathForFile: (file: File) => string
normalizePreviewTarget: (target: string, baseDir?: string) => Promise<HermesPreviewTarget | null>
watchPreviewFile: (url: string) => Promise<HermesPreviewWatch>
stopPreviewFileWatch: (id: string) => Promise<boolean>
openExternal: (url: string) => Promise<void>
onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void
onBackendExit: (callback: (payload: BackendExit) => void) => () => void
}
}
@@ -37,6 +44,24 @@ export interface HermesNotification {
silent?: boolean
}
export interface HermesPreviewTarget {
kind: 'file' | 'url'
label: string
source: string
url: string
}
export interface HermesPreviewWatch {
id: string
path: string
}
export interface HermesPreviewFileChanged {
id: string
path: string
url: string
}
export interface HermesSelectPathsOptions {
title?: string
defaultPath?: string
+2 -2
View File
@@ -180,9 +180,9 @@ export class HermesGateway {
}
}
export async function listSessions(limit = 40): Promise<PaginatedSessions> {
export async function listSessions(limit = 40, minMessages = 0): Promise<PaginatedSessions> {
const result = await window.hermesDesktop.api<PaginatedSessions>({
path: `/api/sessions?limit=${limit}&offset=0&min_messages=1`
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}`
})
return {
+84 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { chatMessageText, toChatMessages } from './chat-messages'
import { appendAssistantTextPart, chatMessageText, renderMediaTags, toChatMessages, upsertToolPart } from './chat-messages'
describe('toChatMessages', () => {
it('hides attached context payloads from user message display', () => {
@@ -15,4 +15,87 @@ describe('toChatMessages', () => {
expect(chatMessageText(message)).toBe('@file:tsconfig.tsbuildinfo\n\nwhat is this file')
})
it('renders MEDIA tags as assistant attachment links', () => {
const [message] = toChatMessages([
{
role: 'assistant',
content: "MEDIA:/Users/brooklyn/.hermes/cache/audio/tts_20260501_222725.mp3\n\nhow's that sound?",
timestamp: 1
}
])
expect(chatMessageText(message)).toBe(
"[Audio: tts_20260501_222725.mp3](#media:%2FUsers%2Fbrooklyn%2F.hermes%2Fcache%2Faudio%2Ftts_20260501_222725.mp3)\n\nhow's that sound?"
)
})
it('coerces non-string message content without throwing', () => {
const [message] = toChatMessages([
{
content: {
text: 'hello from object content'
},
role: 'assistant',
timestamp: 1
}
])
expect(chatMessageText(message)).toBe('hello from object content')
})
it('applies attached-context filtering when user content is object-shaped', () => {
const [message] = toChatMessages([
{
content: {
text:
'look\n\n--- Attached Context ---\n\n📄 @file:foo.ts (10 tokens)\n```ts\nconst x = 1\n```'
},
role: 'user',
timestamp: 1
}
])
expect(chatMessageText(message)).toBe('@file:foo.ts\n\nlook')
})
})
describe('renderMediaTags', () => {
it('renders standalone and inline MEDIA tags as links', () => {
expect(renderMediaTags('here\nMEDIA:/tmp/voice.mp3\nthere')).toBe(
'here\n[Audio: voice.mp3](#media:%2Ftmp%2Fvoice.mp3)\nthere'
)
expect(renderMediaTags('audio: MEDIA:/tmp/voice.mp3 done')).toBe(
'audio: [Audio: voice.mp3](#media:%2Ftmp%2Fvoice.mp3) done'
)
expect(renderMediaTags('MEDIA:/tmp/demo.mp4')).toBe('[Video: demo.mp4](#media:%2Ftmp%2Fdemo.mp4)')
})
it('renders streamed assistant media once the tag is complete', () => {
const parts = appendAssistantTextPart(appendAssistantTextPart([], 'ok\nMEDIA:'), '/tmp/voice.mp3')
const text = chatMessageText({ id: 'a', role: 'assistant', parts })
expect(text).toBe('ok\n[Audio: voice.mp3](#media:%2Ftmp%2Fvoice.mp3)')
})
})
describe('upsertToolPart', () => {
it('preserves inline diffs from tool completion events', () => {
const parts = upsertToolPart(
[],
{
inline_diff: '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new',
name: 'patch',
tool_id: 'tool-1'
},
'complete'
)
const [part] = parts
expect(part?.type).toBe('tool-call')
expect(part && 'result' in part ? part.result : undefined).toMatchObject({
inline_diff: '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
})
})
})
+109 -16
View File
@@ -1,5 +1,6 @@
import type { ThreadMessageLike } from '@assistant-ui/react'
import { mediaDisplayLabel, mediaMarkdownHref } from '@/lib/media'
import type { SessionMessage } from '@/types/hermes'
export type ChatMessagePart = Exclude<ThreadMessageLike['content'], string>[number]
@@ -25,6 +26,7 @@ export type GatewayEventPayload = {
preview?: string
summary?: string
error?: string | boolean
inline_diff?: string
duration_s?: number
todos?: unknown
model?: string
@@ -33,6 +35,10 @@ export type GatewayEventPayload = {
cwd?: string
branch?: string
personality?: string
// clarify.request
request_id?: string
question?: string
choices?: string[] | null
}
export function textPart(text: string): ChatMessagePart {
@@ -43,6 +49,37 @@ export function reasoningPart(text: string): ChatMessagePart {
return { type: 'reasoning', text }
}
const MEDIA_LINE_RE =
/(^|\n)[\t ]*[`"']?MEDIA:\s*(?<line>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)[`"']?[\t ]*(?:\n|$)/g
const MEDIA_TAG_RE = /[`"']?MEDIA:\s*(?<inline>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)[`"']?/g
function unquoteMediaPath(value: string): string {
const trimmed = value.trim()
const quote = trimmed[0]
return quote && quote === trimmed.at(-1) && ['"', "'", '`'].includes(quote) ? trimmed.slice(1, -1) : trimmed
}
function mediaLink(value: string): string {
const path = unquoteMediaPath(value)
return `[${mediaDisplayLabel(path)}](${mediaMarkdownHref(path)})`
}
export function renderMediaTags(text: string): string {
return text
.replace(MEDIA_LINE_RE, (_match, lead: string, value: string) => `${lead}${mediaLink(value)}\n`)
.replace(MEDIA_TAG_RE, (_match, value: string) => mediaLink(value))
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
export function assistantTextPart(text: string): ChatMessagePart {
return textPart(renderMediaTags(text))
}
export function chatMessageText(message: ChatMessage): string {
return message.parts
.filter((part): part is Extract<ChatMessagePart, { type: 'text' }> => part.type === 'text')
@@ -54,19 +91,57 @@ const ATTACHED_CONTEXT_MARKER_RE = /(?:^|\n)--- Attached Context ---\s*\n/
const CONTEXT_WARNINGS_MARKER_RE = /(?:^|\n)--- Context Warnings ---[\s\S]*$/
const CONTEXT_REF_RE = /@(file|folder|url|image|tool):(?:"[^"\n]+"|'[^'\n]+'|`[^`\n]+`|\S+)/g
function displayContentForMessage(role: SessionMessage['role'], content: string): string {
if (role !== 'user') {
return content
function textFromUnknown(value: unknown, depth = 0): string {
if (typeof value === 'string') {
return value
}
const marker = content.match(ATTACHED_CONTEXT_MARKER_RE)
if (value === null || value === undefined) {
return ''
}
if (depth > 2) {
return ''
}
if (Array.isArray(value)) {
return value.map(item => textFromUnknown(item, depth + 1)).join('')
}
if (typeof value === 'object') {
const row = value as Record<string, unknown>
const textValue = row.text ?? row.output_text ?? row.content ?? row.message
const nestedText = textFromUnknown(textValue, depth + 1)
if (nestedText) {
return nestedText
}
try {
return JSON.stringify(value)
} catch {
return ''
}
}
return String(value)
}
function displayContentForMessage(role: SessionMessage['role'], content: unknown): string {
const textContent = textFromUnknown(content)
if (role !== 'user') {
return textContent
}
const marker = textContent.match(ATTACHED_CONTEXT_MARKER_RE)
if (!marker || marker.index === undefined) {
return content.replace(CONTEXT_WARNINGS_MARKER_RE, '').trim()
return textContent.replace(CONTEXT_WARNINGS_MARKER_RE, '').trim()
}
const visibleText = content.slice(0, marker.index).replace(CONTEXT_WARNINGS_MARKER_RE, '').trim()
const attachedContext = content.slice(marker.index + marker[0].length)
const visibleText = textContent.slice(0, marker.index).replace(CONTEXT_WARNINGS_MARKER_RE, '').trim()
const attachedContext = textContent.slice(marker.index + marker[0].length)
const refs = [...new Set(Array.from(attachedContext.matchAll(CONTEXT_REF_RE)).map(match => match[0]))]
return [refs.join('\n'), visibleText].filter(Boolean).join('\n\n') || visibleText
@@ -87,6 +162,17 @@ export function appendTextPart(parts: ChatMessagePart[], delta: string): ChatMes
return next
}
export function appendAssistantTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
const next = appendTextPart(parts, delta)
const last = next.at(-1)
if (last?.type === 'text') {
next[next.length - 1] = { ...last, text: renderMediaTags(last.text) }
}
return next
}
export function appendReasoningPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
const next = [...parts]
const last = next.at(-1)
@@ -119,6 +205,7 @@ function toolArgs(payload: GatewayEventPayload | undefined): Record<string, unkn
function toolResult(payload: GatewayEventPayload | undefined): Record<string, unknown> {
return {
...(payload?.inline_diff ? { inline_diff: payload.inline_diff } : {}),
...(payload?.summary ? { summary: payload.summary } : {}),
...(payload?.message ? { message: payload.message } : {}),
...(payload?.preview ? { preview: payload.preview } : {}),
@@ -198,15 +285,21 @@ function firstNonEmptyObject(...values: unknown[]): Record<string, unknown> {
return {}
}
function parseStoredToolResult(content: string): unknown {
if (!content.trim()) {
function parseStoredToolResult(content: unknown): unknown {
if (content && typeof content === 'object') {
return content
}
const textContent = textFromUnknown(content)
if (!textContent.trim()) {
return ''
}
try {
return JSON.parse(content)
return JSON.parse(textContent)
} catch {
return content
return textContent
}
}
@@ -233,7 +326,7 @@ function toolPartFromStoredCall(call: unknown, fallbackIndex: number): ChatMessa
function applyStoredToolResult(messages: ChatMessage[], toolMessage: SessionMessage): boolean {
const toolCallId = toolMessage.tool_call_id || undefined
const toolName = toolMessage.tool_name || toolMessage.name || 'tool'
const content = toolMessage.content || toolMessage.text || toolMessage.context || toolMessage.name || ''
const content = toolMessage.content || toolMessage.text || toolMessage.context || toolMessage.name
for (let i = messages.length - 1; i >= 0; i -= 1) {
const message = messages[i]
@@ -270,7 +363,7 @@ function applyStoredToolResult(messages: ChatMessage[], toolMessage: SessionMess
function applyStoredToolResultToParts(parts: ChatMessagePart[], toolMessage: SessionMessage): ChatMessagePart[] | null {
const toolCallId = toolMessage.tool_call_id || undefined
const toolName = toolMessage.tool_name || toolMessage.name || 'tool'
const content = toolMessage.content || toolMessage.text || toolMessage.context || toolMessage.name || ''
const content = toolMessage.content || toolMessage.text || toolMessage.context || toolMessage.name
const partIndex = parts.findIndex(
part =>
@@ -295,7 +388,7 @@ function applyStoredToolResultToParts(parts: ChatMessagePart[], toolMessage: Ses
function storedToolMessagePart(toolMessage: SessionMessage, fallbackIndex: number): ChatMessagePart {
const name = toolMessage.tool_name || toolMessage.name || 'tool'
const context = toolMessage.context || toolMessage.text || toolMessage.content || ''
const context = textFromUnknown(toolMessage.context || toolMessage.text || toolMessage.content || '')
const args = context ? { context } : {}
return {
@@ -385,7 +478,7 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
return
}
const content = message.content || message.text || message.context || message.name || ''
const content = message.content || message.text || message.context || message.name
const displayContent = displayContentForMessage(message.role, content)
const parts: ChatMessagePart[] = []
@@ -399,7 +492,7 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
}
if (displayContent) {
parts.push(textPart(displayContent))
parts.push(message.role === 'assistant' ? assistantTextPart(displayContent) : textPart(displayContent))
}
if (message.role === 'assistant' && Array.isArray(message.tool_calls)) {
@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest'
import {
desktopSlashDescription,
desktopSlashUnavailableMessage,
desktopSkinSlashCompletions,
filterDesktopCommandsCatalog,
isDesktopSlashCommand,
isDesktopSlashSuggestion
} from './desktop-slash-commands'
describe('desktop slash command curation', () => {
it('keeps core desktop chat commands in suggestions', () => {
expect(isDesktopSlashSuggestion('/new')).toBe(true)
expect(isDesktopSlashSuggestion('/branch')).toBe(true)
expect(isDesktopSlashSuggestion('/skin')).toBe(true)
expect(isDesktopSlashSuggestion('/usage')).toBe(true)
})
it('lets explicitly typed extension commands run without suggesting them', () => {
expect(isDesktopSlashSuggestion('/my-skill')).toBe(false)
expect(isDesktopSlashCommand('/my-skill')).toBe(true)
})
it('hides terminal, messaging, and dedicated-UI commands from suggestions', () => {
expect(isDesktopSlashSuggestion('/clear')).toBe(false)
expect(isDesktopSlashSuggestion('/compact')).toBe(false)
expect(isDesktopSlashSuggestion('/redraw')).toBe(false)
expect(isDesktopSlashSuggestion('/approve')).toBe(false)
expect(isDesktopSlashSuggestion('/model')).toBe(false)
expect(isDesktopSlashSuggestion('/skills')).toBe(false)
expect(isDesktopSlashSuggestion('/voice')).toBe(false)
expect(isDesktopSlashSuggestion('/curator')).toBe(false)
})
it('allows aliases to execute without cluttering the popover', () => {
expect(isDesktopSlashSuggestion('/reset')).toBe(false)
expect(isDesktopSlashCommand('/reset')).toBe(true)
})
it('filters command catalogs down to core desktop commands', () => {
const filtered = filterDesktopCommandsCatalog({
categories: [
{
name: 'Session',
pairs: [
['/new', 'Start a new session'],
['/clear', 'Clear terminal screen']
]
},
{
name: 'User commands',
pairs: [['/ship-it', 'Run release checklist']]
}
],
pairs: [
['/new', 'Start a new session'],
['/model', 'Switch model'],
['/ship-it', 'Run release checklist']
],
skill_count: 2
})
expect(filtered.categories).toEqual([{ name: 'Session', pairs: [['/new', 'Start a new desktop chat']] }])
expect(filtered.pairs).toEqual([['/new', 'Start a new desktop chat']])
expect(filtered.skill_count).toBe(2)
})
it('uses desktop-specific labels for commands with different UI behavior', () => {
expect(desktopSlashDescription('/branch', 'Branch the current session')).toBe(
'Branch the latest message into a new chat'
)
expect(desktopSlashDescription('/skin', 'Show or change the display skin/theme')).toBe(
'Switch desktop theme or cycle to the next one'
)
})
it('builds /skin completions from desktop themes', () => {
const completions = desktopSkinSlashCompletions(
[
{ name: 'mono', label: 'Mono', description: 'Clean grayscale' },
{ name: 'midnight', label: 'Midnight', description: 'Deep blue' },
{ name: 'slate', label: 'Slate', description: 'Cool slate blue' }
],
'mono',
'm'
)
expect(completions).toEqual([
{
text: '/skin mono',
display: '/skin mono',
meta: 'Mono (current) - Clean grayscale'
},
{
text: '/skin midnight',
display: '/skin midnight',
meta: 'Midnight - Deep blue'
}
])
})
it('explains known commands that desktop owns elsewhere', () => {
expect(desktopSlashUnavailableMessage('/model sonnet')).toContain('model picker')
expect(desktopSlashUnavailableMessage('/skills')).toContain('desktop sidebar')
expect(desktopSlashUnavailableMessage('/clear')).toContain('terminal interface')
})
})
@@ -0,0 +1,251 @@
export interface CommandsCatalogSection {
name: string
pairs: [string, string][]
}
export interface CommandsCatalogLike {
categories?: CommandsCatalogSection[]
pairs?: [string, string][]
skill_count?: number
warning?: string
}
export interface DesktopSlashCompletion {
display: string
meta: string
text: string
}
export interface DesktopThemeCommandOption {
description: string
label: string
name: string
}
const DESKTOP_COMMAND_META = [
['/agents', 'Show active desktop sessions and running tasks'],
['/background', 'Run a prompt in the background'],
['/branch', 'Branch the latest message into a new chat'],
['/compress', 'Compress this conversation context'],
['/debug', 'Create a debug report'],
['/goal', 'Manage the standing goal for this session'],
['/help', 'Show desktop slash commands'],
['/new', 'Start a new desktop chat'],
['/queue', 'Queue a prompt for the next turn'],
['/resume', 'Resume a saved session'],
['/retry', 'Retry the last user message'],
['/rollback', 'List or restore filesystem checkpoints'],
['/skin', 'Switch desktop theme or cycle to the next one'],
['/status', 'Show current session status'],
['/steer', 'Steer the current run after the next tool call'],
['/stop', 'Stop running background processes'],
['/title', 'Rename the current session'],
['/undo', 'Remove the last user/assistant exchange'],
['/usage', 'Show token usage for this session']
] as const
const DESKTOP_COMMANDS: ReadonlySet<string> = new Set(DESKTOP_COMMAND_META.map(([command]) => command))
const DESKTOP_ALIASES = new Map([
['/bg', '/background'],
['/btw', '/background'],
['/fork', '/branch'],
['/q', '/queue'],
['/reload_mcp', '/reload-mcp'],
['/reload_skills', '/reload-skills'],
['/reset', '/new'],
['/tasks', '/agents']
])
const DESKTOP_COMMAND_DESCRIPTIONS: ReadonlyMap<string, string> = new Map(DESKTOP_COMMAND_META)
const PICKER_OWNED_COMMANDS = new Set(['/model', '/provider'])
const TERMINAL_ONLY_COMMANDS = new Set([
'/browser',
'/busy',
'/clear',
'/commands',
'/compact',
'/config',
'/copy',
'/cron',
'/details',
'/exit',
'/footer',
'/gateway',
'/gquota',
'/history',
'/image',
'/indicator',
'/logs',
'/mouse',
'/paste',
'/platforms',
'/plugins',
'/quit',
'/redraw',
'/reload',
'/restart',
'/save',
'/sb',
'/set-home',
'/sethome',
'/snap',
'/snapshot',
'/statusbar',
'/toolsets',
'/tools',
'/update',
'/verbose'
])
const MESSAGING_ONLY_COMMANDS = new Set(['/approve', '/deny'])
const SETTINGS_OWNED_COMMANDS = new Set(['/skills'])
const ADVANCED_COMMANDS = new Set([
'/curator',
'/fast',
'/insights',
'/kanban',
'/personality',
'/profile',
'/reasoning',
'/reload-mcp',
'/reload-skills',
'/voice',
'/yolo'
])
const BLOCKED_COMMANDS = new Set([
...PICKER_OWNED_COMMANDS,
...TERMINAL_ONLY_COMMANDS,
...MESSAGING_ONLY_COMMANDS,
...SETTINGS_OWNED_COMMANDS,
...ADVANCED_COMMANDS
])
function normalizeCommand(command: string): string {
const trimmed = command.trim()
const base = (trimmed.startsWith('/') ? trimmed : `/${trimmed}`).split(/\s+/, 1)[0]?.toLowerCase() || ''
return base
}
export function canonicalDesktopSlashCommand(command: string): string {
const normalized = normalizeCommand(command)
return DESKTOP_ALIASES.get(normalized) || normalized
}
export function isDesktopSlashCommand(command: string): boolean {
const normalized = normalizeCommand(command)
const canonical = canonicalDesktopSlashCommand(normalized)
if (BLOCKED_COMMANDS.has(normalized) || BLOCKED_COMMANDS.has(canonical)) {
return false
}
return DESKTOP_COMMANDS.has(canonical) || !isKnownHermesSlashCommand(normalized)
}
export function isDesktopSlashSuggestion(command: string): boolean {
const normalized = normalizeCommand(command)
const canonical = canonicalDesktopSlashCommand(normalized)
return DESKTOP_COMMANDS.has(canonical) && !DESKTOP_ALIASES.has(normalized)
}
export function desktopSlashUnavailableMessage(command: string): string | null {
const normalized = normalizeCommand(command)
const canonical = canonicalDesktopSlashCommand(normalized)
if (PICKER_OWNED_COMMANDS.has(canonical)) {
return `/${canonical.slice(1)} uses the desktop model picker instead of a slash command.`
}
if (SETTINGS_OWNED_COMMANDS.has(canonical)) {
return `/${canonical.slice(1)} is managed from the desktop sidebar.`
}
if (MESSAGING_ONLY_COMMANDS.has(canonical)) {
return `/${canonical.slice(1)} is only used from messaging platforms.`
}
if (ADVANCED_COMMANDS.has(canonical)) {
return `/${canonical.slice(1)} is not shown in the desktop slash palette. Use the relevant desktop control or terminal interface instead.`
}
if (TERMINAL_ONLY_COMMANDS.has(normalized) || TERMINAL_ONLY_COMMANDS.has(canonical)) {
return `/${canonical.slice(1)} is only available in the terminal interface.`
}
return null
}
export function desktopSlashDescription(command: string, fallback = ''): string {
const canonical = canonicalDesktopSlashCommand(command)
return DESKTOP_COMMAND_DESCRIPTIONS.get(canonical) || fallback
}
export function desktopSkinSlashCompletions(
themes: DesktopThemeCommandOption[],
activeThemeName: string,
argPrefix: string
): DesktopSlashCompletion[] {
const prefix = argPrefix.trim().toLowerCase()
const commands: DesktopSlashCompletion[] = [
{
text: '/skin list',
display: '/skin list',
meta: 'Show available desktop themes'
},
{
text: '/skin next',
display: '/skin next',
meta: 'Cycle to the next desktop theme'
},
...themes.map(theme => ({
text: `/skin ${theme.name}`,
display: `/skin ${theme.name}`,
meta: `${theme.label}${theme.name === activeThemeName ? ' (current)' : ''} - ${theme.description}`
}))
]
if (!prefix) {
return commands
}
return commands.filter(item => item.text.slice('/skin '.length).toLowerCase().startsWith(prefix))
}
export function filterDesktopCommandsCatalog(catalog: CommandsCatalogLike): CommandsCatalogLike {
const categories = catalog.categories
?.map(section => ({
...section,
pairs: section.pairs
.filter(([command]) => isDesktopSlashSuggestion(command))
.map(([command, description]) => [command, desktopSlashDescription(command, description)] as [string, string])
}))
.filter(section => section.pairs.length > 0)
const pairs = catalog.pairs
?.filter(([command]) => isDesktopSlashSuggestion(command))
.map(([command, description]) => [command, desktopSlashDescription(command, description)] as [string, string])
return {
...catalog,
...(categories ? { categories } : {}),
...(pairs ? { pairs } : {})
}
}
function isKnownHermesSlashCommand(command: string): boolean {
return (
DESKTOP_COMMANDS.has(command) ||
DESKTOP_ALIASES.has(command) ||
BLOCKED_COMMANDS.has(command)
)
}
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { extractEmbeddedImages } from './embedded-images'
const SAMPLE_PNG_DATA_URL = 'data:image/png;base64,' + 'A'.repeat(120)
describe('extractEmbeddedImages', () => {
it('returns text untouched when no data URL is present', () => {
expect(extractEmbeddedImages('describe this')).toEqual({ cleanedText: 'describe this', images: [] })
})
it('lifts a bare data:image URL out of prose', () => {
const result = extractEmbeddedImages(`describe this ${SAMPLE_PNG_DATA_URL}`)
expect(result.cleanedText).toBe('describe this')
expect(result.images).toEqual([SAMPLE_PNG_DATA_URL])
})
it('lifts a JSON-wrapped image_url envelope out of prose', () => {
const result = extractEmbeddedImages(
`describe this{"type":"image_url","image_url":{"url":"${SAMPLE_PNG_DATA_URL}"}}`
)
expect(result.cleanedText).toBe('describe this')
expect(result.images).toEqual([SAMPLE_PNG_DATA_URL])
})
it('extracts multiple embedded images', () => {
const second = 'data:image/jpeg;base64,' + 'B'.repeat(96)
const result = extractEmbeddedImages(`first ${SAMPLE_PNG_DATA_URL} mid ${second} tail`)
expect(result.cleanedText).toBe('first mid tail')
expect(result.images).toEqual([SAMPLE_PNG_DATA_URL, second])
})
})
+59
View File
@@ -0,0 +1,59 @@
const EMBEDDED_IMAGE_RE =
/(\{\s*"type"\s*:\s*"image_url"\s*,\s*"image_url"\s*:\s*\{\s*"url"\s*:\s*")?(data:image\/[\w.+-]+;base64,[A-Za-z0-9+/=]{64,})("\s*\}\s*\})?/g
const DATA_URL_RE = /^data:([\w./+-]+);base64,(.*)$/i
export const DATA_IMAGE_URL_RE = /^data:image\/[\w.+-]+;base64,/i
export interface EmbeddedImageExtraction {
cleanedText: string
images: string[]
}
export function dataUrlToBlob(dataUrl: string): Blob | null {
const match = DATA_URL_RE.exec(dataUrl.trim())
if (!match) {
return null
}
try {
const bytes = atob(match[2])
const buffer = new Uint8Array(bytes.length)
for (let i = 0; i < bytes.length; i += 1) {
buffer[i] = bytes.charCodeAt(i)
}
return new Blob([buffer], { type: match[1] })
} catch {
return null
}
}
export function extractEmbeddedImages(text: string): EmbeddedImageExtraction {
if (!text || !text.includes('data:image/')) {
return { cleanedText: text, images: [] }
}
const images: string[] = []
const cleanedText = text
.replace(EMBEDDED_IMAGE_RE, (_match, _open, dataUrl: string) => {
images.push(dataUrl)
return ''
})
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
return { cleanedText, images }
}
export function embeddedImageUrls(text: string): string[] {
return extractEmbeddedImages(text).images
}
export function textWithoutEmbeddedImages(text: string): string {
return extractEmbeddedImages(text).cleanedText
}
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { isLikelyProseCodeBlock } from './markdown-code'
describe('isLikelyProseCodeBlock', () => {
it('detects prose that Streamdown mislabels as an unknown language', () => {
expect(
isLikelyProseCodeBlock(
'heads',
[
'- Pure white (`#ffffff`), roughness 0.55, no emissive',
'- Black wireframe edges at 35% opacity',
'',
'Want the bunny gone, or want me to keep riffing on it?'
].join('\n')
)
).toBe(true)
})
it('keeps real code blocks', () => {
expect(isLikelyProseCodeBlock('ts', 'const value = { bunny: true };\nreturn value')).toBe(false)
})
})
+132
View File
@@ -0,0 +1,132 @@
const VALID_LANGUAGE_RE = /^[a-z0-9][a-z0-9+#-]*$/i
const NON_CODE_FENCE_LANGUAGES = new Set(['', 'text', 'plain', 'plaintext', 'md', 'markdown'])
const COMMON_CODE_LANGUAGES = new Set([
'bash',
'c',
'cpp',
'css',
'diff',
'go',
'html',
'java',
'javascript',
'js',
'json',
'jsx',
'markdown',
'md',
'php',
'python',
'py',
'ruby',
'rust',
'rs',
'sh',
'sql',
'swift',
'tsx',
'ts',
'typescript',
'xml',
'yaml',
'yml'
])
interface CodeSignals {
bulletLines: number
codeSignals: number
hasMarkdown: boolean
proseLines: number
trimmed: string
}
export function sanitizeLanguageTag(tag: string): string {
const trimmed = tag.trim()
const first = trimmed.split(/\s/, 1)[0] || ''
return VALID_LANGUAGE_RE.test(first) && first.length <= 16 ? first.toLowerCase() : ''
}
function proseLineCount(body: string): number {
return body
.split('\n')
.filter(line => {
const trimmed = line.trim()
return Boolean(trimmed) && /^[A-Za-z0-9"'`*-]/.test(trimmed)
})
.length
}
const CODE_SIGNAL_RE = [
/(^|\s)(const|let|var|function|class|import|export|return|if|for|while|switch)\b/gim,
/=>|==|===|!=|!==|\{|\}|;|<\/?[a-z][^>]*>/gi,
/^\s*(#include|SELECT|INSERT|UPDATE|DELETE|CREATE|DROP)\b/gim
]
function codeSignalCount(body: string): number {
return CODE_SIGNAL_RE.reduce((total, pattern) => total + (body.match(pattern)?.length ?? 0), 0)
}
function codeSignals(body: string): CodeSignals {
const trimmed = body.trim()
const markdownSignals = (trimmed.match(/\*\*[^*]+\*\*/g) || []).length + (trimmed.match(/`[^`\n]+`/g) || []).length
return {
bulletLines: (trimmed.match(/^\s*[-*]\s+\S+/gm) || []).length,
codeSignals: codeSignalCount(trimmed),
hasMarkdown: markdownSignals > 0,
proseLines: proseLineCount(trimmed),
trimmed
}
}
export function isLikelyProseFence(info: string, body: string): boolean {
const trimmedInfo = info.trim()
const rawInfo = trimmedInfo.toLowerCase()
const language = sanitizeLanguageTag(info)
const infoToken = trimmedInfo.split(/\s+/, 1)[0] || ''
const hasInfoTail = Boolean(trimmedInfo) && trimmedInfo !== infoToken
if (/^[-*+]\s/.test(rawInfo) || /^https?:\/\//.test(rawInfo)) {
return true
}
const signals = codeSignals(body)
if (!signals.trimmed) {
return false
}
if (hasInfoTail && signals.codeSignals <= 2 && (signals.proseLines >= 2 || signals.bulletLines >= 1)) {
return true
}
if (!NON_CODE_FENCE_LANGUAGES.has(language)) {
return false
}
return (
(signals.bulletLines >= 2 && signals.hasMarkdown && signals.codeSignals <= 2) ||
(signals.proseLines >= 3 && signals.codeSignals === 0)
)
}
export function isLikelyProseCodeBlock(language: string | undefined, code: string | undefined): boolean {
const cleanLanguage = sanitizeLanguageTag(language || '')
const signals = codeSignals(code || '')
if (!signals.trimmed || signals.codeSignals >= 3) {
return false
}
if (signals.bulletLines >= 1 && (signals.hasMarkdown || signals.proseLines >= 2)) {
return true
}
if (NON_CODE_FENCE_LANGUAGES.has(cleanLanguage)) {
return signals.proseLines >= 3 && signals.codeSignals === 0
}
return !COMMON_CODE_LANGUAGES.has(cleanLanguage) && signals.proseLines >= 2 && signals.codeSignals <= 1
}
+90
View File
@@ -0,0 +1,90 @@
export type MediaKind = 'audio' | 'image' | 'video' | 'file'
interface MediaInfo {
kind: MediaKind
mime: string
}
const MEDIA_BY_EXT: Record<string, MediaInfo> = {
avi: { kind: 'video', mime: 'video/x-msvideo' },
bmp: { kind: 'image', mime: 'image/bmp' },
flac: { kind: 'audio', mime: 'audio/flac' },
gif: { kind: 'image', mime: 'image/gif' },
jpeg: { kind: 'image', mime: 'image/jpeg' },
jpg: { kind: 'image', mime: 'image/jpeg' },
m4a: { kind: 'audio', mime: 'audio/mp4' },
mkv: { kind: 'video', mime: 'video/x-matroska' },
mov: { kind: 'video', mime: 'video/quicktime' },
mp3: { kind: 'audio', mime: 'audio/mpeg' },
mp4: { kind: 'video', mime: 'video/mp4' },
ogg: { kind: 'audio', mime: 'audio/ogg' },
opus: { kind: 'audio', mime: 'audio/ogg; codecs=opus' },
png: { kind: 'image', mime: 'image/png' },
svg: { kind: 'image', mime: 'image/svg+xml' },
wav: { kind: 'audio', mime: 'audio/wav' },
webm: { kind: 'video', mime: 'video/webm' },
webp: { kind: 'image', mime: 'image/webp' }
}
function mediaInfo(path: string): MediaInfo | undefined {
const ext = path.split(/[?#]/, 1)[0]?.split('.').pop()?.toLowerCase()
return ext ? MEDIA_BY_EXT[ext] : undefined
}
export function mediaKind(path: string): MediaKind {
return mediaInfo(path)?.kind ?? 'file'
}
export function mediaMime(path: string): string {
return mediaInfo(path)?.mime ?? 'application/octet-stream'
}
export function mediaName(path: string): string {
try {
const url = new URL(path)
return url.pathname.split('/').filter(Boolean).pop() || path
} catch {
return path.split(/[\\/]/).filter(Boolean).pop() || path
}
}
export function mediaMarkdownHref(path: string): string {
return `#media:${encodeURIComponent(path)}`
}
export function mediaExternalUrl(path: string): string {
return /^(?:https?|file):/i.test(path) ? path : `file://${path}`
}
export function mediaPathFromMarkdownHref(href?: string): string | null {
if (!href?.startsWith('#media:')) {
return null
}
try {
return decodeURIComponent(href.slice('#media:'.length))
} catch {
return null
}
}
export function filePathFromMediaPath(path: string): string {
if (!path.startsWith('file:')) {
return path
}
try {
return decodeURIComponent(new URL(path).pathname)
} catch {
return path.replace(/^file:\/\//, '')
}
}
export function mediaDisplayLabel(path: string): string {
const escaped = mediaName(path).replace(/[[\]\\]/g, '\\$&')
const kind = mediaKind(path)
return `${kind[0].toUpperCase()}${kind.slice(1)}: ${escaped}`
}
@@ -0,0 +1,53 @@
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('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('decodes preview markdown hrefs', () => {
expect(previewTargetFromMarkdownHref('#preview/%2Ftmp%2Fdemo.html')).toBe('/tmp/demo.html')
expect(previewTargetFromMarkdownHref('#preview:%2Ftmp%2Fdemo.html')).toBe('/tmp/demo.html')
expect(previewTargetFromMarkdownHref('#media:%2Ftmp%2Fdemo.mp4')).toBeNull()
})
it('extracts preview targets from already-rendered preview markers', () => {
expect(extractPreviewTargets('[Preview: demo.html](#preview:%2Ftmp%2Fdemo.html)')).toEqual(['/tmp/demo.html'])
})
it('strips preview targets from visible assistant text', () => {
expect(stripPreviewTargets('ready\n/tmp/mycelium-bunnies.html\nopen it')).toBe('ready\nopen it')
expect(stripPreviewTargets('[Preview: demo.html](#preview:%2Ftmp%2Fdemo.html)\nopen it')).toBe('open it')
})
})
+216
View File
@@ -0,0 +1,216 @@
const LOCAL_HOSTS = new Set(['0.0.0.0', '127.0.0.1', '::1', '[::1]', 'localhost'])
const HTML_EXT_RE = /\.html?(?:[?#].*)?$/i
const URL_RE = /\bhttps?:\/\/[^\s<>"'`)\]]+/gi
const FILE_URL_RE = /\bfile:\/\/[^\s<>"'`)\]]+/gi
const POSIX_HTML_PATH_RE = /(?:^|[\s("'`])(?<path>\/[^\s<>"'`]*?\.html?)(?:[),.;:!?]*)(?=$|[\s)"'`])/gi
const RELATIVE_HTML_PATH_RE = /(?:^|[\s("'`])(?<path>\.{1,2}\/[^\s<>"'`]*?\.html?)(?:[),.;:!?]*)(?=$|[\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 isLocalPreviewUrl(value: string): boolean {
try {
const url = new URL(value)
if (!['http:', 'https:'].includes(url.protocol)) {
return false
}
return LOCAL_HOSTS.has(url.hostname.toLowerCase())
} catch {
return false
}
}
export function isLikelyPreviewCandidate(value: string): boolean {
const trimmed = stripTrailingPunctuation(value.trim())
return trimmed.startsWith('file://') || HTML_EXT_RE.test(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 || '')
}
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
.replace(PREVIEW_MARKDOWN_RE, '')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
export function extractPreviewTargets(text: string): string[] {
const targets = extractPreviewCandidates(text)
const seen = new Set(targets)
for (const match of text.matchAll(PREVIEW_MARKDOWN_RE)) {
const target = previewTargetFromMarkdownHref(match.groups?.href)
if (target && !seen.has(target)) {
seen.add(target)
targets.push(target)
}
}
return targets
}
export function previewMarkdownHref(target: string): string {
return `#preview/${encodeURIComponent(target)}`
}
export function previewTargetFromMarkdownHref(href?: string): string | null {
if (!href?.startsWith('#preview:') && !href?.startsWith('#preview/')) {
return null
}
try {
return decodeURIComponent(href.slice('#preview'.length + 1))
} catch {
return null
}
}
export function previewName(target: string): string {
try {
const url = new URL(target)
if (url.protocol === 'file:') {
return decodeURIComponent(url.pathname).split(/[\\/]/).filter(Boolean).pop() || target
}
const file = url.pathname.split('/').filter(Boolean).pop()
return file || url.host
} catch {
return target.split(/[\\/]/).filter(Boolean).pop() || target
}
}
export function previewDisplayLabel(target: string): string {
const escaped = previewName(target).replace(/[[\]\\]/g, '\\$&')
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)
}
+32
View File
@@ -0,0 +1,32 @@
import { atom } from 'nanostores'
export interface ClarifyRequest {
requestId: string
question: string
choices: string[] | null
sessionId: string | null
}
// Holds the request_id (and metadata) for the most recent in-flight
// clarify call. The inline ClarifyTool component (rendered inside the
// assistant message stream) reads this to know which request_id to send
// back over `clarify.respond`.
export const $clarifyRequest = atom<ClarifyRequest | null>(null)
export function setClarifyRequest(request: ClarifyRequest): void {
$clarifyRequest.set(request)
}
export function clearClarifyRequest(requestId?: string): void {
const current = $clarifyRequest.get()
if (!current) {
return
}
if (requestId && current.requestId !== requestId) {
return
}
$clarifyRequest.set(null)
}
+1
View File
@@ -10,6 +10,7 @@ export interface ComposerAttachment {
refText?: string
previewUrl?: string
path?: string
attachedSessionId?: string
}
export const $composerDraft = atom('')
+16
View File
@@ -0,0 +1,16 @@
import { atom } from 'nanostores'
import type { HermesGateway } from '@/hermes'
// The active gateway instance, exposed for inline message-stream components
// (e.g. inline ClarifyTool) that need to call gateway methods without having
// the instance threaded down through props from `ChatView`.
export const $gateway = atom<HermesGateway | null>(null)
export function setGateway(gateway: HermesGateway | null): void {
if ($gateway.get() === gateway) {
return
}
$gateway.set(gateway)
}
+14
View File
@@ -0,0 +1,14 @@
import { atom } from 'nanostores'
export interface PreviewTarget {
kind: 'file' | 'url'
label: string
source: string
url: string
}
export const $previewTarget = atom<PreviewTarget | null>(null)
export function setPreviewTarget(target: PreviewTarget | null) {
$previewTarget.set(target)
}
+23
View File
@@ -0,0 +1,23 @@
import { atom } from 'nanostores'
const $toolDiffs = atom<Record<string, string>>({})
export function recordToolDiff(toolCallId: string, diff: string) {
if (!toolCallId || !diff) {
return
}
const current = $toolDiffs.get()
if (current[toolCallId] === diff) {
return
}
$toolDiffs.set({ ...current, [toolCallId]: diff })
}
export function getToolDiff(toolCallId: string): string {
return toolCallId ? $toolDiffs.get()[toolCallId] || '' : ''
}
export const $toolInlineDiffs = $toolDiffs
+75 -14
View File
@@ -118,14 +118,29 @@
--radius: 0.75rem;
/* Thread ViewportFooter — gap from last msg → composer (scroll only) */
--thread-composer-clearance: 8rem;
/* Composer shell — gap under bar to chat pane bottom */
/* Composer geometry — single source of truth for shell + controls. */
--composer-shell-pad-block-end: 2.5rem;
--composer-inline-clearance: clamp(1rem, 5vw, 4rem);
--composer-min-width: 34rem;
--composer-target-width: 68%;
--composer-max-width: 56rem;
--composer-control-size: 2rem;
--composer-control-gap: 0.375rem;
--composer-row-gap: 0.375rem;
--composer-surface-pad-x: 0.5rem;
--composer-surface-pad-y: 0.375rem;
--composer-input-min-height: 2rem;
--composer-input-max-height: 9.375rem;
--composer-input-inline-min-width: 8rem;
--composer-fallback-height: 2.75rem;
--vsq: min(0.5vh, 0.5vw);
--image-preview-max-width: 34rem;
--image-preview-height: clamp(16.25rem, calc(var(--vsq) * 100), 26.25rem);
/* Sidebar layout */
/* Shell layout */
--sidebar-width: 14rem;
--chat-min-width: 24rem;
--shell-gap: 0.625rem;
--titlebar-control-size: 1.25rem;
--titlebar-control-height: 1.375rem;
@@ -190,7 +205,9 @@ input,
textarea,
[contenteditable]:not([contenteditable='false']),
[data-slot='aui_user-message-root'],
[data-slot='aui_assistant-message-content'] {
[data-slot='aui_assistant-message-content'],
[data-selectable-text='true'],
[data-selectable-text='true'] * {
-webkit-user-select: text;
user-select: text;
}
@@ -247,17 +264,23 @@ canvas {
display: none;
}
@supports (content-visibility: auto) {
[data-slot='aui_user-message-root'],
[data-slot='aui_assistant-message-root'] {
content-visibility: auto;
contain-intrinsic-size: auto 10rem;
}
[data-slot='aui_user-message-root'] {
contain-intrinsic-size: auto 4rem;
}
}
/*
* Previously applied `content-visibility: auto` + `contain-intrinsic-size` to
* message roots for virtualization-lite perf. REMOVED because it interacts
* badly with a stick-to-bottom scroller:
*
* 1. Session loads, messages render at their real heights.
* 2. Scroller pins to `scrollHeight - clientHeight`.
* 3. A few seconds later the browser's content-visibility heuristic kicks
* in for off-screen messages and collapses them to the 10rem intrinsic
* placeholder — shrinking total scrollHeight by a large margin.
* 4. The browser clamps scrollTop to the new (smaller) scrollHeight, and
* the user's viewport "scrolls up by a weird %" a few seconds after
* the session loads. Feels like a scroll bug; actually CSS.
*
* If we want perf here again, the correct path is a real virtualizer (e.g.
* react-virtuoso) with stable item sizing — not a CSS heuristic.
*/
.aui-md img {
display: block;
@@ -281,6 +304,25 @@ canvas {
overflow-wrap: anywhere;
}
.hermes-preview-webview {
display: flex;
}
[data-slot='composer-root'] {
width: clamp(var(--composer-min-width), var(--composer-target-width), var(--composer-max-width));
max-width: calc(100% - var(--composer-inline-clearance));
}
/* Thread scroll container (from use-stick-to-bottom).
* `scroll-behavior: auto` is critical: use-stick-to-bottom writes scrollTop
* directly and temporarily forces this to 'auto' during its programmatic
* scrolls, but we default it to 'auto' anyway so no smooth-scroll fight can
* ever happen. We leave overflow-anchor at the browser default ('auto'); the
* library handles follow-mode imperatively. */
[data-slot='aui_thread-content'] {
scroll-behavior: auto;
}
.aui-md a,
.aui-md code {
overflow-wrap: anywhere;
@@ -314,6 +356,25 @@ canvas {
margin: 0 0 1rem;
}
/* Streamdown wraps every fenced block in <div data-streamdown="code-block">
* 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'] {
padding: 0 !important;
gap: 0 !important;
border: 0 !important;
background: transparent !important;
border-radius: 0 !important;
margin: 1rem 0 !important;
}
.aui-md [data-streamdown='code-block'] > *,
[data-streamdown='code-block'] > * {
margin: 0 !important;
}
.aui-md h1 {
margin: 1.6rem 0 0.55rem;
}
+21 -3
View File
@@ -19,7 +19,8 @@ import {
DEFAULT_LAYOUT,
DEFAULT_TYPOGRAPHY,
defaultTheme,
nousLightTheme
nousLightTheme,
nousTheme
} from './presets'
import type { DesktopTheme, DesktopThemeColors, ThemeDensity } from './types'
@@ -37,6 +38,10 @@ const DENSITY_MULTIPLIERS: Record<ThemeDensity, string> = {
const INJECTED_FONT_URLS = new Set<string>()
const SKIN_THEME_LIST = BUILTIN_THEME_LIST.filter(t => t.name !== 'nous-light')
const NOUS_FONT_FAMILY_FALLBACK = {
fontSans: nousTheme.typography?.fontSans ?? DEFAULT_TYPOGRAPHY.fontSans,
fontMono: nousTheme.typography?.fontMono ?? DEFAULT_TYPOGRAPHY.fontMono
}
function effectiveMode(mode: ThemeMode, systemDark = matchesQuery('(prefers-color-scheme: dark)')): 'light' | 'dark' {
return mode === 'system' ? (systemDark ? 'dark' : 'light') : mode
@@ -101,8 +106,21 @@ function fontOnly(theme: DesktopTheme): DesktopTheme['typography'] {
}
const { fontSans, fontMono, fontUrl } = theme.typography
const typography: DesktopTheme['typography'] = {}
return { fontSans, fontMono, fontUrl }
if (fontSans) {
typography.fontSans = fontSans
}
if (fontMono) {
typography.fontMono = fontMono
}
if (fontUrl) {
typography.fontUrl = fontUrl
}
return typography
}
function lightColors(seed: DesktopTheme, skinName: string): DesktopThemeColors {
@@ -200,7 +218,7 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
}
const root = document.documentElement
const typo = { ...DEFAULT_TYPOGRAPHY, ...theme.typography }
const typo = { ...DEFAULT_TYPOGRAPHY, ...NOUS_FONT_FAMILY_FALLBACK, ...theme.typography }
const layout = { ...DEFAULT_LAYOUT, ...theme.layout }
const c = theme.colors
+3 -3
View File
@@ -133,14 +133,14 @@ export interface SessionInfo {
export interface SessionMessage {
codex_reasoning_items?: unknown
content: null | string
context?: string
content: unknown
context?: unknown
name?: string
reasoning?: null | string
reasoning_content?: null | string
reasoning_details?: unknown
role: 'assistant' | 'system' | 'tool' | 'user'
text?: string
text?: unknown
timestamp?: number
tool_call_id?: null | string
tool_calls?: unknown