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
}
>