chore: uptick
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,13 +7,13 @@ import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$inspectorOpen,
|
||||
$isSidebarResizing,
|
||||
$sidebarOpen,
|
||||
$sidebarWidth,
|
||||
setSidebarOpen,
|
||||
setSidebarResizing,
|
||||
setSidebarWidth
|
||||
} from '@/store/layout'
|
||||
import { $previewTarget } from '@/store/preview'
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar'
|
||||
@@ -44,11 +44,16 @@ export function AppShell({
|
||||
const connection = useStore($connection)
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const inspectorOpen = useStore($inspectorOpen)
|
||||
const isSidebarResizing = useStore($isSidebarResizing)
|
||||
const previewTarget = useStore($previewTarget)
|
||||
|
||||
const displayedSidebarWidth = sidebarOpen ? sidebarWidth : Math.round(sidebarWidth * 0.8)
|
||||
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition)
|
||||
const showRightRail = rightRailOpen && inspectorOpen
|
||||
const showRightRail = rightRailOpen && (inspectorOpen || Boolean(previewTarget))
|
||||
|
||||
// Right rail yields to chat min-width before the chat column starts crushing the composer.
|
||||
const inspectorColumn = showRightRail
|
||||
? 'min(var(--inspector-width), max(0px, calc(100vw - var(--sidebar-width) - var(--chat-min-width) - 2 * var(--shell-gap))))'
|
||||
: '0px'
|
||||
|
||||
const startSidebarResize = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
@@ -105,16 +110,13 @@ export function AppShell({
|
||||
|
||||
<main
|
||||
className={cn(
|
||||
'relative grid h-screen w-full grid-cols-[var(--sidebar-width)_minmax(0,1fr)_var(--inspector-col)] overflow-hidden bg-background pr-0.75 pb-0.75 pt-0.75',
|
||||
isSidebarResizing
|
||||
? 'transition-none'
|
||||
: 'transition-[grid-template-columns,gap] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||
sidebarOpen || showRightRail ? 'gap-2.5' : 'gap-0'
|
||||
'relative grid h-screen w-full grid-cols-[var(--sidebar-width)_minmax(0,1fr)_var(--inspector-col)] overflow-hidden bg-background pr-0.75 pb-0.75 pt-0.75 transition-none',
|
||||
sidebarOpen || showRightRail ? 'gap-(--shell-gap)' : 'gap-0'
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--inspector-width': inspectorWidth,
|
||||
'--inspector-col': showRightRail ? inspectorWidth : '0px'
|
||||
'--inspector-col': inspectorColumn
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
'use client'
|
||||
|
||||
import { type ToolCallMessagePartProps } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { HelpCircle, Loader2, PencilLine } from 'lucide-react'
|
||||
import { type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $clarifyRequest, clearClarifyRequest } from '@/store/clarify'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
interface ClarifyArgs {
|
||||
question?: string
|
||||
choices?: string[] | null
|
||||
}
|
||||
|
||||
function readClarifyArgs(args: unknown): ClarifyArgs {
|
||||
if (!args || typeof args !== 'object') {
|
||||
return {}
|
||||
}
|
||||
|
||||
const row = args as Record<string, unknown>
|
||||
const choices = Array.isArray(row.choices) ? row.choices.filter((c): c is string => typeof c === 'string') : null
|
||||
|
||||
return {
|
||||
question: typeof row.question === 'string' ? row.question : undefined,
|
||||
choices: choices && choices.length > 0 ? choices : null
|
||||
}
|
||||
}
|
||||
|
||||
export const ClarifyTool = (props: ToolCallMessagePartProps) => {
|
||||
const isPending = props.result === undefined
|
||||
|
||||
// Once Hermes records an answer, fall back to the standard tool block so
|
||||
// the past Q/A renders consistently with every other tool in the thread.
|
||||
if (!isPending) {
|
||||
return <ToolFallback {...props} />
|
||||
}
|
||||
|
||||
return <ClarifyToolPending {...props} />
|
||||
}
|
||||
|
||||
function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
const request = useStore($clarifyRequest)
|
||||
const gateway = useStore($gateway)
|
||||
const fromArgs = useMemo(() => readClarifyArgs(args), [args])
|
||||
|
||||
const matchingRequest = useMemo(() => {
|
||||
if (!request) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (fromArgs.question && request.question && fromArgs.question !== request.question) {
|
||||
return null
|
||||
}
|
||||
|
||||
return request
|
||||
}, [fromArgs.question, request])
|
||||
|
||||
const question = fromArgs.question || matchingRequest?.question || ''
|
||||
|
||||
const choices = useMemo(
|
||||
() => fromArgs.choices ?? matchingRequest?.choices ?? [],
|
||||
[fromArgs.choices, matchingRequest?.choices]
|
||||
)
|
||||
|
||||
const hasChoices = choices.length > 0
|
||||
|
||||
const [typing, setTyping] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
|
||||
// Race: tool.start fires a tick before clarify.request, so request_id
|
||||
// arrives slightly after the tool block mounts. Show the question (from
|
||||
// args) but disable submit until we have the request id from the gateway.
|
||||
const ready = Boolean(matchingRequest?.requestId)
|
||||
|
||||
const respond = useCallback(
|
||||
async (answer: string) => {
|
||||
if (!ready || !matchingRequest) {
|
||||
notifyError(new Error('Clarify request is not ready yet'), 'Could not send clarify response')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!gateway) {
|
||||
notifyError(new Error('Hermes gateway is not connected'), 'Could not send clarify response')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
|
||||
try {
|
||||
await gateway.request<{ ok?: boolean }>('clarify.respond', {
|
||||
request_id: matchingRequest.requestId,
|
||||
answer
|
||||
})
|
||||
triggerHaptic('submit')
|
||||
clearClarifyRequest(matchingRequest.requestId)
|
||||
// The matching tool.complete will land shortly after, swapping this
|
||||
// panel for the ToolFallback view above.
|
||||
} catch (error) {
|
||||
notifyError(error, 'Could not send clarify response')
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
[gateway, matchingRequest, ready]
|
||||
)
|
||||
|
||||
const handleTextareaKey = useCallback(
|
||||
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
const trimmed = draft.trim()
|
||||
|
||||
if (trimmed) {
|
||||
void respond(trimmed)
|
||||
}
|
||||
}
|
||||
},
|
||||
[draft, respond]
|
||||
)
|
||||
|
||||
const handleSubmitFreeform = useCallback(
|
||||
(event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
const trimmed = draft.trim()
|
||||
|
||||
if (trimmed) {
|
||||
void respond(trimmed)
|
||||
}
|
||||
},
|
||||
[draft, respond]
|
||||
)
|
||||
|
||||
const handleChoiceKey = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (typing || submitting) {
|
||||
return
|
||||
}
|
||||
|
||||
const numeric = Number.parseInt(event.key, 10)
|
||||
|
||||
if (Number.isFinite(numeric) && numeric >= 1 && numeric <= choices.length) {
|
||||
event.preventDefault()
|
||||
void respond(choices[numeric - 1]!)
|
||||
}
|
||||
},
|
||||
[choices, respond, submitting, typing]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mb-3 mt-2 grid gap-3 rounded-xl border border-border/70 bg-card/40 px-4 py-3.5 text-sm',
|
||||
'shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_3%,transparent)]'
|
||||
)}
|
||||
data-slot="clarify-inline"
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="mt-0.5 grid size-6 shrink-0 place-items-center rounded-md bg-[color-mix(in_srgb,var(--dt-primary)_14%,transparent)] text-primary ring-1 ring-inset ring-primary/15"
|
||||
>
|
||||
<HelpCircle className="size-3.5" />
|
||||
</span>
|
||||
<div className="grid flex-1 gap-0.5">
|
||||
<span className="text-[0.6875rem] font-medium uppercase tracking-wide text-muted-foreground/85">
|
||||
Hermes is asking
|
||||
</span>
|
||||
<span className="whitespace-pre-wrap leading-snug text-foreground">
|
||||
{question || <em className="text-muted-foreground/70">Loading question…</em>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!typing && hasChoices && (
|
||||
<div className="grid gap-1.5" onKeyDown={handleChoiceKey} role="group">
|
||||
{choices.map((choice, index) => (
|
||||
<button
|
||||
className={cn(
|
||||
'group/choice flex w-full items-center gap-3 rounded-lg border border-border/70 bg-background/60 px-3 py-2 text-left text-sm text-foreground/95',
|
||||
'transition-colors hover:border-border hover:bg-accent/60 disabled:cursor-not-allowed disabled:opacity-55'
|
||||
)}
|
||||
data-choice
|
||||
disabled={!ready || submitting}
|
||||
key={`${index}-${choice}`}
|
||||
onClick={() => void respond(choice)}
|
||||
type="button"
|
||||
>
|
||||
<span className="grid size-5 shrink-0 place-items-center rounded-md bg-muted text-[0.6875rem] font-mono tabular-nums text-muted-foreground group-hover/choice:bg-background">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="flex-1 wrap-anywhere">{choice}</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-lg border border-dashed border-border/60 bg-transparent px-3 py-2 text-left text-sm text-muted-foreground',
|
||||
'transition-colors hover:border-border hover:bg-accent/40 hover:text-foreground'
|
||||
)}
|
||||
disabled={submitting}
|
||||
onClick={() => {
|
||||
setTyping(true)
|
||||
window.setTimeout(() => textareaRef.current?.focus({ preventScroll: true }), 0)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden className="grid size-5 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground">
|
||||
<PencilLine className="size-3" />
|
||||
</span>
|
||||
<span className="flex-1">Other (type your answer)</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(typing || !hasChoices) && (
|
||||
<form className="grid gap-2" onSubmit={handleSubmitFreeform}>
|
||||
<Textarea
|
||||
className="min-h-20 resize-y rounded-lg border-border/70 bg-background/60 text-sm"
|
||||
disabled={submitting}
|
||||
onChange={event => setDraft(event.target.value)}
|
||||
onKeyDown={handleTextareaKey}
|
||||
placeholder="Type your answer…"
|
||||
ref={textareaRef}
|
||||
value={draft}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[0.6875rem] text-muted-foreground/85">⌘/Ctrl + Enter to send</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasChoices && (
|
||||
<Button
|
||||
disabled={submitting}
|
||||
onClick={() => {
|
||||
setTyping(false)
|
||||
setDraft('')
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={!ready || submitting}
|
||||
onClick={() => void respond('')}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
<Button disabled={!ready || submitting || !draft.trim()} size="sm" type="submit">
|
||||
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : 'Send'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!typing && hasChoices && (
|
||||
<div className="flex items-center justify-between text-[0.6875rem] text-muted-foreground/85">
|
||||
<span>1–{choices.length} to pick</span>
|
||||
<button
|
||||
className="bg-transparent text-muted-foreground/85 underline-offset-2 hover:text-foreground hover:underline disabled:opacity-50"
|
||||
disabled={!ready || submitting}
|
||||
onClick={() => void respond('')}
|
||||
type="button"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { AtSign, FileText, FolderOpen, ImageIcon, Link as LinkIcon, Wrench } fro
|
||||
import type { ComponentType, FC } from 'react'
|
||||
import { Fragment, useMemo } from 'react'
|
||||
|
||||
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
|
||||
import { extractEmbeddedImages } from '@/lib/embedded-images'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool'] as const
|
||||
@@ -188,7 +190,8 @@ function shortLabel(type: HermesRefType, id: string): string {
|
||||
* Unknown directive types fall through as plain text.
|
||||
*/
|
||||
export const DirectiveText: TextMessagePartComponent = ({ text }: TextMessagePartProps) => {
|
||||
const segments = useMemo(() => hermesDirectiveFormatter.parse(text ?? ''), [text])
|
||||
const { cleanedText, images } = useMemo(() => extractEmbeddedImages(text ?? ''), [text])
|
||||
const segments = useMemo(() => hermesDirectiveFormatter.parse(cleanedText), [cleanedText])
|
||||
|
||||
return (
|
||||
<span className="whitespace-pre-line" data-slot="aui_directive-text">
|
||||
@@ -199,6 +202,20 @@ export const DirectiveText: TextMessagePartComponent = ({ text }: TextMessagePar
|
||||
<DirectiveChip id={segment.id} key={`m-${index}-${segment.id}`} label={segment.label} type={segment.type} />
|
||||
)
|
||||
)}
|
||||
{images.length > 0 && (
|
||||
<span className="mt-2 flex flex-wrap gap-2" data-slot="aui_embedded-images">
|
||||
{images.map((src, index) => (
|
||||
<ZoomableImage
|
||||
alt=""
|
||||
className="max-h-48 max-w-full rounded-lg border border-border/60 object-contain"
|
||||
draggable={false}
|
||||
key={`img-${index}`}
|
||||
slot="aui_embedded-image"
|
||||
src={src}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ export const Intro: FC<IntroProps> = ({ personality, seed }) => {
|
||||
}, [advanceFrame, frameOffset])
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-1 grid place-items-center content-center px-[calc(var(--vsq)*50)] pb-32 text-center text-muted-foreground">
|
||||
<div className="pointer-events-none absolute inset-0 z-1 flex flex-col items-center justify-center px-[calc(var(--vsq)*50)] text-center text-muted-foreground">
|
||||
<button
|
||||
aria-label="Change Hermes pose"
|
||||
className="pointer-events-auto mb-5 h-56 w-64 cursor-default border-0 bg-transparent p-0"
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { preprocessMarkdown } from './markdown-text'
|
||||
|
||||
describe('preprocessMarkdown', () => {
|
||||
it('strips inline accidental triple-backtick starts', () => {
|
||||
const input = [
|
||||
'Working as intended.',
|
||||
"Here's your scene: ``` http://localhost:8812/",
|
||||
'',
|
||||
'- **Multicolored cube**',
|
||||
'- **Rotates**'
|
||||
].join('\n')
|
||||
|
||||
const output = preprocessMarkdown(input)
|
||||
|
||||
expect(output).not.toContain('```')
|
||||
expect(output).toContain("Here's your scene:")
|
||||
expect(output).not.toContain('http://localhost:8812/')
|
||||
expect(output).toContain('- **Multicolored cube**')
|
||||
})
|
||||
|
||||
it('demotes invalid fenced prose blocks with closers', () => {
|
||||
const fence = '```'
|
||||
const input = [
|
||||
`${fence} http://localhost:8812/`,
|
||||
'- **Scroll wheel** - zoom',
|
||||
'- **Right-drag/pan** - disabled',
|
||||
fence
|
||||
].join('\n')
|
||||
|
||||
const output = preprocessMarkdown(input)
|
||||
|
||||
expect(output).not.toContain('```')
|
||||
expect(output).not.toContain('http://localhost:8812/')
|
||||
expect(output).toContain('- **Scroll wheel** - zoom')
|
||||
})
|
||||
|
||||
it('demotes prose sentence masquerading as fence info', () => {
|
||||
const input = ['```Heads up - a bunny got added', '- Pure white (`#ffffff`)', '- Ambient dropped to 0.18'].join('\n')
|
||||
const output = preprocessMarkdown(input)
|
||||
|
||||
expect(output).not.toContain('```heads')
|
||||
expect(output).toContain('Heads up - a bunny got added')
|
||||
expect(output).toContain('- Pure white (`#ffffff`)')
|
||||
})
|
||||
|
||||
it('keeps valid code fences intact', () => {
|
||||
const fence = '```'
|
||||
const input = [`${fence}ts`, 'const value = 1;', fence].join('\n')
|
||||
|
||||
const output = preprocessMarkdown(input)
|
||||
|
||||
expect(output).toContain('```ts')
|
||||
expect(output).toContain('const value = 1;')
|
||||
})
|
||||
|
||||
it('keeps dangling real code fences during streaming', () => {
|
||||
const input = ['```ts', 'const value = 1;'].join('\n')
|
||||
const output = preprocessMarkdown(input)
|
||||
|
||||
expect(output.startsWith('```ts')).toBe(true)
|
||||
expect(output).toContain('const value = 1;')
|
||||
})
|
||||
|
||||
it('demotes dangling prose fences', () => {
|
||||
const input = ['```', '- Pure white (`#ffffff`)', '- Ambient dropped to 0.18'].join('\n')
|
||||
const output = preprocessMarkdown(input)
|
||||
|
||||
expect(output).not.toContain('```')
|
||||
expect(output).toContain('- Pure white (`#ffffff`)')
|
||||
})
|
||||
})
|
||||
@@ -2,14 +2,24 @@
|
||||
|
||||
import { type StreamdownTextComponents, StreamdownTextPrimitive } from '@assistant-ui/react-streamdown'
|
||||
import { code } from '@streamdown/code'
|
||||
import { Check, Copy, Download } from 'lucide-react'
|
||||
import { type ComponentProps, memo, useMemo, useState } from 'react'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { type ComponentProps, memo, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { PreviewAttachment } from '@/components/assistant-ui/preview-attachment'
|
||||
import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import {
|
||||
filePathFromMediaPath,
|
||||
mediaExternalUrl,
|
||||
mediaKind,
|
||||
mediaMime,
|
||||
mediaName,
|
||||
mediaPathFromMarkdownHref
|
||||
} from '@/lib/media'
|
||||
import { isLikelyProseCodeBlock, isLikelyProseFence, sanitizeLanguageTag } from '@/lib/markdown-code'
|
||||
import { previewTargetFromMarkdownHref, stripPreviewTargets } from '@/lib/preview-targets'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
/**
|
||||
* Strip provider/model "thinking" blocks before markdown render.
|
||||
@@ -18,14 +28,123 @@ import { notify, notifyError } from '@/store/notifications'
|
||||
* assistant text. Proper reasoning UI uses dedicated `reasoning.*` parts.
|
||||
*/
|
||||
const REASONING_BLOCK_RE = /<(think|thinking|reasoning|scratchpad|analysis)>[\s\S]*?<\/\1>\s*/gi
|
||||
const PREVIEW_MARKER_RE = /\[Preview:[^\]]+\]\(#preview[:/][^)]+\)/gi
|
||||
|
||||
function stripReasoning(text: string): string {
|
||||
return text.replace(REASONING_BLOCK_RE, '')
|
||||
const FENCE_LINE_RE = /^([ \t]*)(`{3,}|~{3,})([^\n]*)$/
|
||||
const MIDLINE_FENCE_RE = /([^\n])```+(?=\s|$)/g
|
||||
|
||||
function stripMidlineFenceStarts(text: string): string {
|
||||
return text.replace(MIDLINE_FENCE_RE, '$1')
|
||||
}
|
||||
|
||||
function pushProseFence(out: string[], indent: string, info: string, lines: string[]) {
|
||||
if (info) {
|
||||
out.push(`${indent}${info}`.trimEnd())
|
||||
}
|
||||
|
||||
out.push(...lines)
|
||||
}
|
||||
|
||||
function findClosingFence(lines: string[], start: number, marker: string): number {
|
||||
for (let cursor = start + 1; cursor < lines.length; cursor += 1) {
|
||||
const closeMatch = (lines[cursor] || '').match(FENCE_LINE_RE)
|
||||
|
||||
if (!closeMatch) {
|
||||
continue
|
||||
}
|
||||
|
||||
const closeMarker = closeMatch[2] || ''
|
||||
const closeInfo = (closeMatch[3] || '').trim()
|
||||
|
||||
if (!closeInfo && closeMarker[0] === marker[0] && closeMarker.length >= marker.length) {
|
||||
return cursor
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
function normalizeFenceBlocks(text: string): string {
|
||||
const sourceLines = text.split('\n')
|
||||
const out: string[] = []
|
||||
let index = 0
|
||||
|
||||
while (index < sourceLines.length) {
|
||||
const line = sourceLines[index] || ''
|
||||
const match = line.match(FENCE_LINE_RE)
|
||||
|
||||
if (!match) {
|
||||
out.push(line)
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const indent = match[1] || ''
|
||||
const marker = match[2] || '```'
|
||||
const infoRaw = (match[3] || '').trim()
|
||||
const languageToken = infoRaw.split(/\s+/, 1)[0] || ''
|
||||
const language = sanitizeLanguageTag(languageToken)
|
||||
const openerValid = !infoRaw || Boolean(language)
|
||||
|
||||
if (!openerValid) {
|
||||
out.push(`${indent}${infoRaw}`.trimEnd())
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const closeIndex = findClosingFence(sourceLines, index, marker)
|
||||
const bodyLines = sourceLines.slice(index + 1, closeIndex === -1 ? sourceLines.length : closeIndex)
|
||||
const body = bodyLines.join('\n')
|
||||
|
||||
if (closeIndex === -1) {
|
||||
if (!body.trim()) {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (isLikelyProseFence(infoRaw, body)) {
|
||||
pushProseFence(out, indent, infoRaw, bodyLines)
|
||||
} else {
|
||||
out.push(`${indent}${marker}${language}`)
|
||||
out.push(...bodyLines)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
if (isLikelyProseFence(infoRaw, body)) {
|
||||
pushProseFence(out, indent, infoRaw, bodyLines)
|
||||
index = closeIndex + 1
|
||||
continue
|
||||
}
|
||||
|
||||
out.push(`${indent}${marker}${language}`)
|
||||
out.push(...bodyLines)
|
||||
out.push(`${indent}${marker}`)
|
||||
index = closeIndex + 1
|
||||
}
|
||||
|
||||
return out.join('\n')
|
||||
}
|
||||
|
||||
export function preprocessMarkdown(text: string): string {
|
||||
const cleaned = text.replace(REASONING_BLOCK_RE, '').replace(PREVIEW_MARKER_RE, '')
|
||||
const normalizedFences = normalizeFenceBlocks(stripMidlineFenceStarts(cleaned))
|
||||
|
||||
return normalizedFences
|
||||
.split(/((?:```|~~~)[\s\S]*?(?:```|~~~))/g)
|
||||
.map(part => (/^(?:```|~~~)/.test(part) ? part : stripPreviewTargets(part)))
|
||||
.join('')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
}
|
||||
|
||||
function CodeHeader({ language, code }: { language?: string; code?: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
if (isLikelyProseCodeBlock(language, code)) {
|
||||
return null
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
if (!code) {
|
||||
return
|
||||
@@ -46,11 +165,12 @@ function CodeHeader({ language, code }: { language?: string; code?: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
const label = language && language !== 'unknown' ? language : 'code'
|
||||
const cleanLanguage = sanitizeLanguageTag(language || '')
|
||||
const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : ''
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex items-center justify-between gap-2 rounded-t-md border border-b-0 border-border bg-muted/60 px-3 py-1.5 text-xs text-muted-foreground">
|
||||
<span className="font-mono uppercase tracking-wide">{label}</span>
|
||||
<div className="m-0 flex items-center justify-between gap-2 rounded-t-md border border-b-0 border-border bg-muted/60 px-3 py-1.5 text-xs text-muted-foreground">
|
||||
<span className="font-mono uppercase tracking-wide">{label || 'code'}</span>
|
||||
<button
|
||||
aria-label={copied ? 'Copied' : 'Copy code'}
|
||||
className="inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-[0.75rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
@@ -64,164 +184,160 @@ function CodeHeader({ language, code }: { language?: string; code?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function imageFilename(src?: string): string {
|
||||
if (!src) {
|
||||
return 'image'
|
||||
}
|
||||
async function typedBlobUrl(dataUrl: string, mime: string): Promise<string> {
|
||||
const blob = await fetch(dataUrl).then(response => response.blob())
|
||||
|
||||
try {
|
||||
const { pathname } = new URL(src, window.location.href)
|
||||
|
||||
return pathname.split('/').filter(Boolean).pop() || 'image'
|
||||
} catch {
|
||||
return src.split(/[\\/]/).filter(Boolean).pop() || 'image'
|
||||
}
|
||||
return URL.createObjectURL(new Blob([await blob.arrayBuffer()], { type: mime }))
|
||||
}
|
||||
|
||||
function isMissingIpcHandler(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
|
||||
|
||||
return message.includes("No handler registered for 'hermes:saveImageFromUrl'")
|
||||
}
|
||||
|
||||
async function startBrowserDownload(src: string) {
|
||||
const response = await fetch(src)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Could not fetch image: ${response.status}`)
|
||||
async function mediaSrc(path: string): Promise<string> {
|
||||
if (/^(?:https?|data):/i.test(path)) {
|
||||
return path
|
||||
}
|
||||
|
||||
const blobUrl = URL.createObjectURL(await response.blob())
|
||||
const link = document.createElement('a')
|
||||
link.href = blobUrl
|
||||
link.download = imageFilename(src)
|
||||
link.rel = 'noopener noreferrer'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
|
||||
if (!window.hermesDesktop?.readFileDataUrl) {
|
||||
return mediaExternalUrl(path)
|
||||
}
|
||||
|
||||
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
|
||||
|
||||
return ['audio', 'video'].includes(mediaKind(path)) ? typedBlobUrl(dataUrl, mediaMime(path)) : dataUrl
|
||||
}
|
||||
|
||||
const imageActionButtonClass =
|
||||
'absolute right-2 top-2 grid size-8 place-items-center rounded-full border border-border/70 bg-background/80 text-muted-foreground opacity-0 shadow-sm backdrop-blur transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 disabled:opacity-50'
|
||||
function OpenMediaButton({ kind, path }: { kind: 'audio' | 'video'; path: string }) {
|
||||
return (
|
||||
<button
|
||||
className="mt-2 bg-transparent text-xs font-medium text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
||||
onClick={() => void window.hermesDesktop?.openExternal(mediaExternalUrl(path))}
|
||||
type="button"
|
||||
>
|
||||
Open {kind} file
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>) {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false)
|
||||
const canOpen = Boolean(src)
|
||||
function MediaAttachment({ path }: { path: string }) {
|
||||
const [src, setSrc] = useState('')
|
||||
const [failed, setFailed] = useState(false)
|
||||
const kind = mediaKind(path)
|
||||
const name = mediaName(path)
|
||||
|
||||
async function handleDownload() {
|
||||
if (!src || saving) {
|
||||
return
|
||||
}
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
let objectUrl = ''
|
||||
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
if (window.hermesDesktop?.saveImageFromUrl) {
|
||||
const saved = await window.hermesDesktop.saveImageFromUrl(src)
|
||||
|
||||
if (saved) {
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: 'Image saved',
|
||||
message: imageFilename(src)
|
||||
})
|
||||
setFailed(false)
|
||||
setSrc('')
|
||||
void mediaSrc(path)
|
||||
.then(value => {
|
||||
if (value.startsWith('blob:')) {
|
||||
objectUrl = value
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await startBrowserDownload(src)
|
||||
} catch (error) {
|
||||
if (isMissingIpcHandler(error)) {
|
||||
try {
|
||||
await startBrowserDownload(src)
|
||||
notify({
|
||||
kind: 'info',
|
||||
title: 'Download started',
|
||||
message: 'Restart Hermes Desktop to use Save Image.'
|
||||
})
|
||||
} catch (fallbackError) {
|
||||
notifyError(fallbackError, 'Restart Hermes Desktop to save images')
|
||||
if (!cancelled) {
|
||||
setSrc(value)
|
||||
} else if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setFailed(true)
|
||||
}
|
||||
})
|
||||
|
||||
return
|
||||
return () => {
|
||||
cancelled = true
|
||||
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
|
||||
notifyError(error, 'Image download failed')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [path])
|
||||
|
||||
if (kind === 'image' && src) {
|
||||
return (
|
||||
<span className="block">
|
||||
<MarkdownImage alt={name} src={src} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function openLightbox() {
|
||||
if (canOpen) {
|
||||
setLightboxOpen(true)
|
||||
}
|
||||
if (kind === 'audio' && src) {
|
||||
return (
|
||||
<span className="my-3 block max-w-md rounded-xl border border-border/70 bg-card/70 p-3">
|
||||
<span className="mb-2 block truncate text-xs font-medium text-muted-foreground">{name}</span>
|
||||
<audio className="block w-full" controls onError={() => setFailed(true)} preload="metadata" src={src} />
|
||||
{failed && <OpenMediaButton kind="audio" path={path} />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const lightbox = src ? (
|
||||
<Dialog onOpenChange={setLightboxOpen} open={lightboxOpen}>
|
||||
<DialogContent
|
||||
className="grid max-h-[calc(100vh-2rem)] w-auto max-w-[calc(100vw-2rem)] place-items-center overflow-visible border-0 bg-transparent p-0 shadow-none"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<div className="group/lightbox relative max-h-[calc(100vh-2rem)] max-w-[calc(100vw-2rem)] overflow-auto">
|
||||
<img
|
||||
alt={alt ?? ''}
|
||||
className="block max-h-[calc(100vh-2rem)] max-w-full cursor-zoom-out select-auto rounded-lg object-contain shadow-2xl"
|
||||
onClick={() => setLightboxOpen(false)}
|
||||
src={src}
|
||||
/>
|
||||
<button
|
||||
aria-label={saving ? 'Saving image' : 'Download image'}
|
||||
className={cn(imageActionButtonClass, 'group-hover/lightbox:opacity-100')}
|
||||
disabled={saving}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
void handleDownload()
|
||||
}}
|
||||
title={saving ? 'Saving image' : 'Download image'}
|
||||
type="button"
|
||||
>
|
||||
<Download className={cn('size-4', saving && 'animate-pulse')} />
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : null
|
||||
if (kind === 'video' && src) {
|
||||
return (
|
||||
<span className="my-3 block max-w-2xl rounded-xl border border-border/70 bg-card/70 p-3">
|
||||
<span className="mb-2 block truncate text-xs font-medium text-muted-foreground">{name}</span>
|
||||
<video
|
||||
className="block max-h-112 w-full rounded-lg bg-black"
|
||||
controls
|
||||
onError={() => setFailed(true)}
|
||||
src={src}
|
||||
/>
|
||||
{failed && <OpenMediaButton kind="video" path={path} />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="group/image relative my-3 inline-block max-w-full align-top" data-slot="aui_markdown-image">
|
||||
<button
|
||||
className="block max-w-full cursor-zoom-in bg-transparent p-0 text-left"
|
||||
disabled={!canOpen}
|
||||
onClick={openLightbox}
|
||||
title={canOpen ? 'Open image' : undefined}
|
||||
type="button"
|
||||
>
|
||||
<img alt={alt ?? ''} className={className} src={src} {...props} />
|
||||
</button>
|
||||
{src && (
|
||||
<button
|
||||
aria-label={saving ? 'Saving image' : 'Download image'}
|
||||
className={cn(imageActionButtonClass, 'group-hover/image:opacity-100')}
|
||||
disabled={saving}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
void handleDownload()
|
||||
}}
|
||||
title={saving ? 'Saving image' : 'Download image'}
|
||||
type="button"
|
||||
>
|
||||
<Download className={cn('size-4', saving && 'animate-pulse')} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
{lightbox}
|
||||
</>
|
||||
<a
|
||||
className="font-medium text-foreground underline underline-offset-4 decoration-foreground/30 wrap-anywhere hover:decoration-foreground/70"
|
||||
href="#"
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
void window.hermesDesktop?.openExternal(mediaExternalUrl(path))
|
||||
}}
|
||||
>
|
||||
{failed ? `Open ${name}` : `Loading ${name}...`}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function MarkdownLink({ className, href, ...props }: ComponentProps<'a'>) {
|
||||
const mediaPath = mediaPathFromMarkdownHref(href)
|
||||
const previewTarget = previewTargetFromMarkdownHref(href)
|
||||
|
||||
if (mediaPath) {
|
||||
return <MediaAttachment path={mediaPath} />
|
||||
}
|
||||
|
||||
if (previewTarget) {
|
||||
return <PreviewAttachment target={previewTarget} />
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
className={cn(
|
||||
'font-medium text-foreground underline underline-offset-4 decoration-foreground/30 wrap-anywhere hover:decoration-foreground/70',
|
||||
className
|
||||
)}
|
||||
href={href}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>) {
|
||||
return (
|
||||
<ZoomableImage
|
||||
alt={alt}
|
||||
className={className}
|
||||
containerClassName="my-3"
|
||||
slot="aui_markdown-image"
|
||||
src={src}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -244,17 +360,7 @@ const MarkdownTextImpl = () => {
|
||||
p: ({ className, ...props }: ComponentProps<'p'>) => (
|
||||
<p className={cn('wrap-anywhere leading-relaxed', className)} {...props} />
|
||||
),
|
||||
a: ({ className, ...props }: ComponentProps<'a'>) => (
|
||||
<a
|
||||
className={cn(
|
||||
'font-medium text-foreground underline underline-offset-4 decoration-foreground/30 wrap-anywhere hover:decoration-foreground/70',
|
||||
className
|
||||
)}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
a: MarkdownLink,
|
||||
hr: ({ className, ...props }: ComponentProps<'hr'>) => (
|
||||
<hr className={cn('border-border/70', className)} {...props} />
|
||||
),
|
||||
@@ -315,7 +421,7 @@ const MarkdownTextImpl = () => {
|
||||
mode="streaming"
|
||||
parseIncompleteMarkdown
|
||||
plugins={{ code }}
|
||||
preprocess={stripReasoning}
|
||||
preprocess={preprocessMarkdown}
|
||||
shikiTheme={['github-light-default', 'github-dark-default']}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { MonitorPlay } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { previewName } from '@/lib/preview-targets'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $previewTarget, setPreviewTarget } from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
|
||||
export function PreviewAttachment({ target }: { target: string }) {
|
||||
const cwd = useStore($currentCwd)
|
||||
const activePreview = useStore($previewTarget)
|
||||
const [opening, setOpening] = useState(false)
|
||||
const name = previewName(target)
|
||||
const isActive = activePreview?.source === target
|
||||
|
||||
function localFallbackPreview() {
|
||||
if (/^https?:\/\//i.test(target)) {
|
||||
return { kind: 'url' as const, label: previewName(target), source: target, url: target }
|
||||
}
|
||||
|
||||
if (/^file:\/\//i.test(target)) {
|
||||
return { kind: 'file' as const, label: previewName(target), source: target, url: target }
|
||||
}
|
||||
|
||||
if (/^(?:\/|\.{1,2}\/|~\/).+\.html?$/i.test(target)) {
|
||||
const path = target.startsWith('file://') ? target : `file://${encodeURI(target)}`
|
||||
|
||||
return { kind: 'file' as const, label: previewName(target), source: target, url: path }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function isMissingPreviewIpc(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
|
||||
|
||||
return message.includes("No handler registered for 'hermes:normalizePreviewTarget'")
|
||||
}
|
||||
|
||||
async function togglePreview() {
|
||||
if (opening) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
setPreviewTarget(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setOpening(true)
|
||||
|
||||
try {
|
||||
const preview = await window.hermesDesktop?.normalizePreviewTarget(target, cwd || undefined).catch(error => {
|
||||
if (isMissingPreviewIpc(error)) {
|
||||
return localFallbackPreview()
|
||||
}
|
||||
|
||||
throw error
|
||||
})
|
||||
|
||||
if (!preview) {
|
||||
throw new Error(`Could not open preview target: ${target}`)
|
||||
}
|
||||
|
||||
setPreviewTarget(preview)
|
||||
} catch (error) {
|
||||
notifyError(error, 'Preview unavailable')
|
||||
} finally {
|
||||
setOpening(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="inline-flex max-w-[min(100%,32rem)] items-center gap-3 rounded-xl border border-border/70 bg-card/70 p-3 text-sm">
|
||||
<div className="grid size-9 shrink-0 place-items-center rounded-lg bg-accent text-muted-foreground">
|
||||
<MonitorPlay className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 max-w-64">
|
||||
<div className="truncate font-medium text-foreground">{name}</div>
|
||||
<div className="truncate font-mono text-xs text-muted-foreground">{target}</div>
|
||||
</div>
|
||||
<button
|
||||
className="shrink-0 rounded-lg border border-border/70 px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
disabled={opening}
|
||||
onClick={() => void togglePreview()}
|
||||
type="button"
|
||||
>
|
||||
{opening ? 'Opening...' : isActive ? 'Hide Preview' : 'Toggle Preview'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import type { SyntaxHighlighterProps } from '@assistant-ui/react-streamdown'
|
||||
import type { FC } from 'react'
|
||||
import ShikiHighlighter from 'react-shiki'
|
||||
|
||||
import { isLikelyProseCodeBlock } from '@/lib/markdown-code'
|
||||
|
||||
/**
|
||||
* assistant-ui's recommended `SyntaxHighlighter` slot.
|
||||
*
|
||||
@@ -22,10 +24,13 @@ export const SyntaxHighlighter: FC<SyntaxHighlighterProps> = ({
|
||||
language,
|
||||
code
|
||||
}) => {
|
||||
// Markdown fences include the pre-closing newline in `code`, which
|
||||
// Shiki tokenizes into a blank final line. Trim so the box ends on
|
||||
// real code.
|
||||
const trimmed = (code ?? '').trimEnd()
|
||||
// Streamdown may hand us fence contents with edge newlines. Strip blank
|
||||
// fence padding without touching indentation on the first real line.
|
||||
const trimmed = (code ?? '').replace(/^\n+/, '').trimEnd()
|
||||
|
||||
if (isLikelyProseCodeBlock(language, trimmed)) {
|
||||
return <div className="whitespace-pre-wrap wrap-anywhere text-foreground">{trimmed}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<Pre className="aui-shiki m-0 overflow-hidden rounded-b-md border border-t-0 border-border bg-card font-mono text-sm leading-relaxed [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-4 [&_pre]:py-3 [&_pre]:font-mono [&_pre]:leading-relaxed">
|
||||
|
||||
@@ -2,10 +2,12 @@ import {
|
||||
ActionBarPrimitive,
|
||||
AuiIf,
|
||||
BranchPickerPrimitive,
|
||||
ComposerPrimitive,
|
||||
ErrorPrimitive,
|
||||
MessagePrimitive,
|
||||
ThreadPrimitive,
|
||||
type ToolCallMessagePartProps,
|
||||
useAuiEvent,
|
||||
useAuiState
|
||||
} from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
@@ -17,19 +19,42 @@ import {
|
||||
GitBranchIcon,
|
||||
Loader2Icon,
|
||||
MoreHorizontalIcon,
|
||||
PencilIcon,
|
||||
RefreshCwIcon,
|
||||
Volume2Icon,
|
||||
VolumeXIcon
|
||||
VolumeXIcon,
|
||||
XIcon
|
||||
} from 'lucide-react'
|
||||
import { type FC, type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
type FC,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
// Scroll behavior: delegated to `use-stick-to-bottom` (StackBlitz), the
|
||||
// reference implementation that powers bolt.new and several other streaming
|
||||
// chat UIs. It handles everything we care about — spring-animated catch-up,
|
||||
// resize-vs-user-scroll disambiguation, wheel/touch escape, text-selection
|
||||
// pause, subpixel overshoot, programmatic-scroll event suppression — via 665
|
||||
// lines of well-tested edge-case handling that we should NOT hand-roll.
|
||||
//
|
||||
// We only own the thin glue: jump-to-bottom on session switch / send, and
|
||||
// keeping `$threadScrolledUp` in sync with `isAtBottom` for the composer's
|
||||
// dim-when-scrolled-away treatment.
|
||||
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
|
||||
import spinners from 'unicode-animations'
|
||||
|
||||
import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
|
||||
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
|
||||
import { DirectiveText } from '@/components/assistant-ui/directive-text'
|
||||
import { GeneratedImageProvider, useGeneratedImageContext } from '@/components/assistant-ui/generated-image-context'
|
||||
import { ImageGenerationPlaceholder } from '@/components/assistant-ui/image-generation-placeholder'
|
||||
import { Intro, type IntroProps } from '@/components/assistant-ui/intro'
|
||||
import { MarkdownText } from '@/components/assistant-ui/markdown-text'
|
||||
import { PreviewAttachment } from '@/components/assistant-ui/preview-attachment'
|
||||
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
|
||||
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
|
||||
import {
|
||||
@@ -41,49 +66,16 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { extractPreviewTargets } from '@/lib/preview-targets'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { setThreadScrolledUp } from '@/store/thread-scroll'
|
||||
import { $voicePlayback } from '@/store/voice-playback'
|
||||
|
||||
const THINKING_FACES = [
|
||||
'(。•́︿•̀。)',
|
||||
'(◔_◔)',
|
||||
'(¬‿¬)',
|
||||
'( •_•)>⌐■-■',
|
||||
'(⌐■_■)',
|
||||
'(´・_・`)',
|
||||
'◉_◉',
|
||||
'(°ロ°)',
|
||||
'( ˘⌣˘)♡',
|
||||
'ヽ(>∀<☆)☆',
|
||||
'٩(๑❛ᴗ❛๑)۶',
|
||||
'(⊙_⊙)',
|
||||
'(¬_¬)',
|
||||
'( ͡° ͜ʖ ͡°)',
|
||||
'ಠ_ಠ'
|
||||
]
|
||||
const RESPONSE_SPINNER = spinners.braille
|
||||
|
||||
const THINKING_VERBS = [
|
||||
'pondering',
|
||||
'contemplating',
|
||||
'musing',
|
||||
'cogitating',
|
||||
'ruminating',
|
||||
'deliberating',
|
||||
'mulling',
|
||||
'reflecting',
|
||||
'processing',
|
||||
'reasoning',
|
||||
'analyzing',
|
||||
'computing',
|
||||
'synthesizing',
|
||||
'formulating',
|
||||
'brainstorming'
|
||||
]
|
||||
|
||||
type ThreadLoadingState = 'response' | 'session' | 'working'
|
||||
type ThreadLoadingState = 'response' | 'session'
|
||||
|
||||
interface MessageActionProps {
|
||||
messageId: string
|
||||
@@ -91,13 +83,8 @@ interface MessageActionProps {
|
||||
onBranchInNewChat?: (messageId: string) => void
|
||||
}
|
||||
|
||||
const BOTTOM_DISTANCE_PX = 24
|
||||
let readAloudAudio: HTMLAudioElement | null = null
|
||||
|
||||
function isNearBottom(el: HTMLElement): boolean {
|
||||
return el.scrollHeight - (el.scrollTop + el.clientHeight) <= BOTTOM_DISTANCE_PX
|
||||
}
|
||||
|
||||
function partText(part: unknown): string {
|
||||
if (typeof part === 'string') {
|
||||
return part
|
||||
@@ -126,142 +113,298 @@ export const Thread: FC<{
|
||||
onBranchInNewChat?: (messageId: string) => void
|
||||
sessionKey?: string | null
|
||||
}> = ({ intro, loading, onBranchInNewChat, sessionKey }) => {
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null)
|
||||
const contentRef = useRef<HTMLDivElement | null>(null)
|
||||
const messageCount = useAuiState(s => s.thread.messages.length)
|
||||
const isRunning = useAuiState(s => s.thread.isRunning)
|
||||
const lastMessageId = useAuiState(s => s.thread.messages.at(-1)?.id ?? '')
|
||||
const shouldStickToBottomRef = useRef(true)
|
||||
const scrollFrameRef = useRef<number | null>(null)
|
||||
const sessionKeyRef = useRef<string | null>(sessionKey ?? null)
|
||||
|
||||
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
||||
const nearBottom = isNearBottom(event.currentTarget)
|
||||
shouldStickToBottomRef.current = nearBottom
|
||||
setThreadScrolledUp(!nearBottom)
|
||||
}, [])
|
||||
|
||||
const handleWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
|
||||
if (event.deltaY < 0) {
|
||||
shouldStickToBottomRef.current = false
|
||||
setThreadScrolledUp(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const viewport = viewportRef.current
|
||||
|
||||
if (!viewport) {
|
||||
return
|
||||
}
|
||||
|
||||
viewport.scrollTop = viewport.scrollHeight
|
||||
shouldStickToBottomRef.current = true
|
||||
setThreadScrolledUp(false)
|
||||
}, [])
|
||||
|
||||
const scheduleScrollToBottom = useCallback(() => {
|
||||
if (scrollFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(scrollFrameRef.current)
|
||||
}
|
||||
|
||||
scrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||
scrollFrameRef.current = null
|
||||
scrollToBottom()
|
||||
})
|
||||
}, [scrollToBottom])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(scrollFrameRef.current)
|
||||
}
|
||||
|
||||
setThreadScrolledUp(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const viewport = viewportRef.current
|
||||
|
||||
if (!viewport) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextSessionKey = sessionKey ?? null
|
||||
const sessionChanged = sessionKeyRef.current !== nextSessionKey
|
||||
sessionKeyRef.current = nextSessionKey
|
||||
const force = loading === 'session' || sessionChanged
|
||||
|
||||
if (!force && !shouldStickToBottomRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
scheduleScrollToBottom()
|
||||
}, [isRunning, lastMessageId, loading, messageCount, scheduleScrollToBottom, sessionKey])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const content = contentRef.current
|
||||
const viewport = viewportRef.current
|
||||
|
||||
if (!content || !viewport) {
|
||||
return
|
||||
}
|
||||
|
||||
let previousHeight = content.getBoundingClientRect().height
|
||||
|
||||
const observer = new ResizeObserver(entries => {
|
||||
const height = entries[0]?.contentRect.height ?? content.getBoundingClientRect().height
|
||||
|
||||
if (height === previousHeight) {
|
||||
return
|
||||
}
|
||||
|
||||
previousHeight = height
|
||||
|
||||
if (!shouldStickToBottomRef.current && !isNearBottom(viewport)) {
|
||||
return
|
||||
}
|
||||
|
||||
scheduleScrollToBottom()
|
||||
})
|
||||
|
||||
observer.observe(content)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [scheduleScrollToBottom])
|
||||
|
||||
return (
|
||||
<GeneratedImageProvider>
|
||||
<ThreadPrimitive.Root className="relative grid h-full min-h-0 grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent">
|
||||
<AuiIf condition={s => Boolean(intro) && s.thread.isEmpty}>{intro && <Intro {...intro} />}</AuiIf>
|
||||
|
||||
<ThreadPrimitive.Viewport
|
||||
autoScroll={false}
|
||||
className="h-full min-h-0 overflow-y-auto overscroll-contain px-[clamp(1rem,10%,12rem)] pt-[calc(var(--vsq)*19)]"
|
||||
data-slot="aui_thread-viewport"
|
||||
onScroll={handleScroll}
|
||||
onWheel={handleWheel}
|
||||
ref={viewportRef}
|
||||
scrollToBottomOnInitialize
|
||||
scrollToBottomOnRunStart
|
||||
scrollToBottomOnThreadSwitch
|
||||
>
|
||||
<div className="flex w-full flex-col gap-3" ref={contentRef}>
|
||||
<ThreadPrimitive.Messages>
|
||||
{() => <ThreadMessage onBranchInNewChat={onBranchInNewChat} />}
|
||||
</ThreadPrimitive.Messages>
|
||||
{loading === 'response' && <ResponseLoadingIndicator />}
|
||||
{loading === 'working' && <WorkingIndicator />}
|
||||
</div>
|
||||
<ThreadPrimitive.ViewportFooter className="h-(--thread-composer-clearance) shrink-0" />
|
||||
</ThreadPrimitive.Viewport>
|
||||
<ThreadPrimitive.ViewportProvider>
|
||||
{/*
|
||||
* <StickToBottom> renders a wrapper <div>; <StickToBottom.Content>
|
||||
* renders an inner scroll container (inline height/width 100%) plus
|
||||
* an inner content div. So:
|
||||
* - `className` on <StickToBottom> = outer wrapper sizing
|
||||
* - `scrollClassName` on <.Content> = scroll container
|
||||
* - `className` on <.Content> = content (flex column)
|
||||
*
|
||||
* `initial: 'instant'`: no animation on first mount.
|
||||
* `resize: 'instant'`: during streaming, snap to bottom each token.
|
||||
* Spring animation ('smooth') visibly lags behind fast token
|
||||
* streams; users read that as jank. 'instant' matches ChatGPT.
|
||||
*
|
||||
* The composer is rendered OUTSIDE the scroller as `position:
|
||||
* absolute; bottom: 0` (floating glass treatment) and overlays the
|
||||
* bottom of the scroll surface. We compensate by putting a tall
|
||||
* bottom spacer (>= composer height + margin) inside the scroll
|
||||
* content so "scroll to bottom" naturally parks the last line of
|
||||
* content above the composer, not hidden behind it.
|
||||
*/}
|
||||
<StickToBottom
|
||||
className="relative h-full min-h-0"
|
||||
initial="instant"
|
||||
resize="instant"
|
||||
>
|
||||
<ThreadScrollSync sessionKey={sessionKey} />
|
||||
<StickToBottom.Content
|
||||
className="flex w-full flex-col gap-3 px-[clamp(1rem,10%,12rem)] pt-[calc(var(--vsq)*19)]"
|
||||
data-slot="aui_thread-content"
|
||||
scrollClassName="overflow-y-auto overscroll-contain"
|
||||
>
|
||||
<ThreadPrimitive.Messages
|
||||
components={{
|
||||
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} />,
|
||||
SystemMessage,
|
||||
UserEditComposer,
|
||||
UserMessage
|
||||
}}
|
||||
/>
|
||||
{loading === 'response' && <ResponseLoadingIndicator />}
|
||||
<ComposerClearance />
|
||||
</StickToBottom.Content>
|
||||
</StickToBottom>
|
||||
</ThreadPrimitive.ViewportProvider>
|
||||
{loading === 'session' && <CenteredThreadSpinner />}
|
||||
</ThreadPrimitive.Root>
|
||||
</GeneratedImageProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll glue for the chat thread. Replaces hand-rolled follow logic with
|
||||
* the exact pattern that assistant-ui's own `useThreadViewportAutoScroll`
|
||||
* uses internally: **raw DOM scroll + an armed behavior ref + a
|
||||
* ResizeObserver loop that re-pins to bottom until we actually reach it.**
|
||||
*
|
||||
* Why not use the library's `scrollToBottom` for sends?
|
||||
* - It wraps its work in `new Promise(requestAnimationFrame)` so even
|
||||
* `animation: 'instant'` is 1+ frame async.
|
||||
* - It does NOT clear `escapedFromLock` on call — if the user had
|
||||
* scrolled up before sending, the library's resize handler keeps
|
||||
* un-setting `isAtBottom` between our scroll and the next resize.
|
||||
* - `ignoreEscapes` only blocks NEW escapes during the animation; it
|
||||
* doesn't unstick an already-escaped state.
|
||||
*
|
||||
* The armed-ref pattern handles all of that:
|
||||
* 1. `thread.runStart` fires after the runtime has committed the user
|
||||
* message to state (so scrollHeight already reflects it).
|
||||
* 2. We arm a ref ('instant') and write `scrollTop = scrollHeight`
|
||||
* synchronously.
|
||||
* 3. A ResizeObserver on the content keeps re-pinning each time the
|
||||
* DOM grows (user message paints, assistant placeholder mounts,
|
||||
* assistant streams) until scrollTop is actually at bottom — then
|
||||
* we disarm.
|
||||
* 4. Any wheel-up or touch-scroll-up disarms immediately so the user
|
||||
* can always escape.
|
||||
*
|
||||
* This mirrors:
|
||||
* - assistant-ui's `useThreadViewportAutoScroll` (scrollToBottomBehaviorRef
|
||||
* + useOnResizeContent loop)
|
||||
* - Vercel ai-chatbot's `useScrollToBottom` (MutationObserver + RO on
|
||||
* container and children + isAtBottom/isUserScrolling flags)
|
||||
*
|
||||
* Must be rendered INSIDE a <StickToBottom> because useStickToBottomContext
|
||||
* reads from that component's context.
|
||||
*/
|
||||
const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) => {
|
||||
const { scrollRef, isAtBottom, state } = useStickToBottomContext()
|
||||
const sessionKeyRef = useRef<string | null>(sessionKey ?? null)
|
||||
|
||||
// "Armed" behavior ref. Non-null = "keep chasing bottom across resize
|
||||
// ticks until we get there." Null = "user owns the viewport."
|
||||
const armedRef = useRef<ScrollBehavior | null>(null)
|
||||
|
||||
const messageCount = useAuiState(s => s.thread.messages.length)
|
||||
const prevMessageCountRef = useRef(messageCount)
|
||||
|
||||
useEffect(() => {
|
||||
setThreadScrolledUp(!isAtBottom)
|
||||
}, [isAtBottom])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setThreadScrolledUp(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Slam to bottom + arm the ref. Also forces library state flags off
|
||||
// so its internal resize handler doesn't fight our re-pins.
|
||||
const armAndPin = useCallback((behavior: ScrollBehavior) => {
|
||||
const el = scrollRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
armedRef.current = behavior
|
||||
// Clear the library's escape/at-bottom flags directly on the mutable
|
||||
// state object so its resize handler sees a clean follow state.
|
||||
state.escapedFromLock = false
|
||||
state.isAtBottom = true
|
||||
el.scrollTop = el.scrollHeight
|
||||
}, [scrollRef, state])
|
||||
|
||||
// ResizeObserver loop — re-pins to bottom while armed, disarms when
|
||||
// actually at bottom. This is the assistant-ui pattern.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
const behavior = armedRef.current
|
||||
|
||||
if (!behavior) {
|
||||
return
|
||||
}
|
||||
|
||||
const distance = el.scrollHeight - (el.scrollTop + el.clientHeight)
|
||||
|
||||
if (distance < 2) {
|
||||
armedRef.current = null
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
el.scrollTop = el.scrollHeight
|
||||
})
|
||||
|
||||
observer.observe(el)
|
||||
|
||||
const content = el.firstElementChild
|
||||
|
||||
if (content) {
|
||||
observer.observe(content)
|
||||
}
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [scrollRef])
|
||||
|
||||
// User-intent detection — any upward gesture disarms the chase.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (e.deltaY < 0) {
|
||||
armedRef.current = null
|
||||
}
|
||||
}
|
||||
const onTouch = () => {
|
||||
armedRef.current = null
|
||||
}
|
||||
|
||||
el.addEventListener('wheel', onWheel, { passive: true })
|
||||
el.addEventListener('touchmove', onTouch, { passive: true })
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('wheel', onWheel)
|
||||
el.removeEventListener('touchmove', onTouch)
|
||||
}
|
||||
}, [scrollRef])
|
||||
|
||||
// (1) Session switch — strong intent to see the bottom of the new thread.
|
||||
useEffect(() => {
|
||||
const next = sessionKey ?? null
|
||||
|
||||
if (sessionKeyRef.current === next) {
|
||||
return
|
||||
}
|
||||
|
||||
sessionKeyRef.current = next
|
||||
prevMessageCountRef.current = 0
|
||||
armAndPin('auto')
|
||||
}, [armAndPin, sessionKey])
|
||||
|
||||
// (2) Bulk message load (session history arriving from storage) — pin
|
||||
// to bottom and stay armed while the thread's markdown/code/images
|
||||
// settle over the next several frames.
|
||||
useEffect(() => {
|
||||
const prev = prevMessageCountRef.current
|
||||
prevMessageCountRef.current = messageCount
|
||||
|
||||
if (prev === 0 && messageCount > 0) {
|
||||
armAndPin('auto')
|
||||
}
|
||||
}, [armAndPin, messageCount])
|
||||
|
||||
// (3) User send — the runtime event `thread.runStart` fires after the
|
||||
// user message has been committed to state (scrollHeight already reflects
|
||||
// it). This is the canonical signal per assistant-ui's own code. We
|
||||
// arm-and-pin synchronously in the callback, then the RO loop above
|
||||
// keeps us at bottom as the assistant message placeholder + reply stream.
|
||||
useAuiEvent('thread.runStart', () => {
|
||||
armAndPin('instant')
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Invisible bottom spacer whose height matches the currently-measured
|
||||
* composer height (plus a small gap). Because the composer is rendered
|
||||
* OUTSIDE the scroll container as `position: absolute; bottom: 0`, "scroll
|
||||
* to bottom" would otherwise park the last content line behind it. By
|
||||
* extending the scroll content down with real (blank) space equal to the
|
||||
* composer's footprint, the library's scroll-to-scrollHeight naturally
|
||||
* leaves the last message line sitting above the composer.
|
||||
*
|
||||
* A ResizeObserver on the composer keeps the spacer in sync when the
|
||||
* textarea grows (multi-line input), attachments expand, or the composer
|
||||
* enters a focused/expanded state.
|
||||
*/
|
||||
const COMPOSER_BREATHING_ROOM_PX = 20
|
||||
|
||||
const ComposerClearance: FC = () => {
|
||||
const [height, setHeight] = useState<number>(() => {
|
||||
// Sensible default until the observer wires up (~ 8rem).
|
||||
if (typeof document === 'undefined') return 128
|
||||
const composer = document.querySelector<HTMLElement>('[data-slot="composer-root"]')
|
||||
|
||||
return composer ? composer.getBoundingClientRect().height + COMPOSER_BREATHING_ROOM_PX : 128
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const composer = document.querySelector<HTMLElement>('[data-slot="composer-root"]')
|
||||
|
||||
if (!composer) {
|
||||
return
|
||||
}
|
||||
|
||||
const apply = () => {
|
||||
const h = composer.getBoundingClientRect().height
|
||||
|
||||
setHeight(prev => {
|
||||
const next = Math.round(h + COMPOSER_BREATHING_ROOM_PX)
|
||||
|
||||
return Math.abs(prev - next) < 1 ? prev : next
|
||||
})
|
||||
}
|
||||
|
||||
apply()
|
||||
const observer = new ResizeObserver(apply)
|
||||
observer.observe(composer)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
return <div aria-hidden="true" className="shrink-0" style={{ height: `${height}px` }} />
|
||||
}
|
||||
|
||||
function pickPrimaryPreviewTarget(targets: string[]): string[] {
|
||||
if (targets.length <= 1) {
|
||||
return targets
|
||||
}
|
||||
|
||||
const localUrl = targets.find(value => /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/i.test(value))
|
||||
|
||||
return [localUrl || targets[targets.length - 1]]
|
||||
}
|
||||
|
||||
const CenteredThreadSpinner: FC = () => (
|
||||
<div
|
||||
aria-label="Loading session"
|
||||
@@ -279,38 +422,16 @@ const CenteredThreadSpinner: FC = () => (
|
||||
</div>
|
||||
)
|
||||
|
||||
const ThreadMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
|
||||
const role = useAuiState(s => s.message.role)
|
||||
const isEditing = useAuiState(s => s.message.composer.isEditing)
|
||||
|
||||
// The runtime synthesizes an empty assistant placeholder while isRunning is true
|
||||
// (last message is user). Rendering the full `MessagePrimitive.Root` for it adds
|
||||
// ~36px of invisible chrome (gap-2 + min-h-7 footer) which can push the
|
||||
// loading affordance too far below the user message. Skip it —
|
||||
// `ResponseLoadingIndicator` in the viewport handles the loading affordance directly.
|
||||
const isPlaceholder = useAuiState(
|
||||
s => s.message.role === 'assistant' && s.message.status?.type === 'running' && s.message.content.length === 0
|
||||
)
|
||||
|
||||
if (isEditing) {
|
||||
return <EditComposer />
|
||||
}
|
||||
|
||||
if (role === 'user') {
|
||||
return <UserMessage />
|
||||
}
|
||||
|
||||
if (isPlaceholder) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <AssistantMessage onBranchInNewChat={onBranchInNewChat} />
|
||||
}
|
||||
|
||||
const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
|
||||
const messageId = useAuiState(s => s.message.id)
|
||||
const content = useAuiState(s => s.message.content)
|
||||
const messageText = messageContentText(content)
|
||||
const previewTargets = pickPrimaryPreviewTarget(extractPreviewTargets(messageText))
|
||||
const isPlaceholder = useAuiState(s => s.message.status?.type === 'running' && s.message.content.length === 0)
|
||||
|
||||
if (isPlaceholder) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
@@ -326,6 +447,13 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
tools: { Fallback: ChainToolFallback }
|
||||
}}
|
||||
/>
|
||||
{previewTargets.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{previewTargets.map(target => (
|
||||
<PreviewAttachment key={target} target={target} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<MessagePrimitive.Error>
|
||||
<ErrorPrimitive.Root
|
||||
className="mt-2 rounded-md border border-destructive/20 bg-destructive/5 px-3 py-2 text-sm text-destructive"
|
||||
@@ -351,45 +479,28 @@ const StatusRow: FC<{ children: ReactNode; label: string }> = ({ children, label
|
||||
)
|
||||
|
||||
const ResponseLoadingIndicator: FC = () => {
|
||||
const [tick, setTick] = useState(0)
|
||||
const [frame, setFrame] = useState(0)
|
||||
const elapsed = useElapsedSeconds()
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setTick(t => t + 1), 900)
|
||||
const id = window.setInterval(
|
||||
() => setFrame(current => (current + 1) % RESPONSE_SPINNER.frames.length),
|
||||
RESPONSE_SPINNER.interval
|
||||
)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [])
|
||||
|
||||
const face = THINKING_FACES[tick % THINKING_FACES.length]
|
||||
const verb = THINKING_VERBS[tick % THINKING_VERBS.length]
|
||||
|
||||
return (
|
||||
<StatusRow label="Hermes is loading a response">
|
||||
<span className="shimmer shimmer-repeat-delay-0 min-w-0 truncate text-muted-foreground/55">
|
||||
{face} {verb}…
|
||||
<span aria-hidden="true" className="font-mono text-base leading-none text-muted-foreground/60">
|
||||
{RESPONSE_SPINNER.frames[frame]}
|
||||
</span>
|
||||
<ActivityTimerText seconds={elapsed} />
|
||||
</StatusRow>
|
||||
)
|
||||
}
|
||||
|
||||
const WorkingIndicator: FC = () => {
|
||||
const elapsed = useElapsedSeconds()
|
||||
|
||||
return (
|
||||
<StatusRow label="Hermes is still working">
|
||||
<Loader
|
||||
className="size-4 text-muted-foreground/60"
|
||||
label="Still working"
|
||||
strokeScale={0.65}
|
||||
type="spiral-search"
|
||||
/>
|
||||
<span className="shimmer min-w-0 truncate text-muted-foreground/60">Still working…</span>
|
||||
<ActivityTimerText seconds={elapsed} />
|
||||
</StatusRow>
|
||||
)
|
||||
}
|
||||
|
||||
const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ result }) => {
|
||||
const generatedImage = useGeneratedImageContext()
|
||||
const running = result === undefined
|
||||
@@ -414,6 +525,10 @@ const ChainToolFallback: FC<ToolCallMessagePartProps> = props => {
|
||||
return <ImageGenerateTool {...props} />
|
||||
}
|
||||
|
||||
if (props.toolName === 'clarify') {
|
||||
return <ClarifyTool {...props} />
|
||||
}
|
||||
|
||||
return <ToolFallback {...props} />
|
||||
}
|
||||
|
||||
@@ -637,21 +752,97 @@ const branchButtonClass =
|
||||
'grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-35'
|
||||
|
||||
const UserMessage: FC = () => {
|
||||
const content = useAuiState(s => s.message.content)
|
||||
const messageText = messageContentText(content)
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="group flex max-w-[min(72%,34rem)] flex-col gap-2 self-end rounded-2xl border border-[color-mix(in_srgb,var(--dt-user-bubble-border)_78%,transparent)] bg-[color-mix(in_srgb,var(--dt-user-bubble)_94%,transparent)] px-3 py-2"
|
||||
className="group flex max-w-[min(72%,34rem)] flex-col items-end gap-2 self-end"
|
||||
data-role="user"
|
||||
data-slot="aui_user-message-root"
|
||||
>
|
||||
<div className="wrap-anywhere whitespace-pre-line leading-[1.48] text-foreground/95">
|
||||
<div className="wrap-anywhere whitespace-pre-line rounded-2xl border border-[color-mix(in_srgb,var(--dt-user-bubble-border)_78%,transparent)] bg-[color-mix(in_srgb,var(--dt-user-bubble)_94%,transparent)] px-3 py-2 leading-[1.48] text-foreground/95">
|
||||
<MessagePrimitive.Parts components={{ Text: DirectiveText }} />
|
||||
</div>
|
||||
<div className="min-h-6">
|
||||
<UserActionBar messageText={messageText} />
|
||||
</div>
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const EditComposer: FC = () => {
|
||||
// Editing requires a real onEdit implementation against Hermes history.
|
||||
// Hide the edit composer until that contract is implemented.
|
||||
return null
|
||||
const UserActionBar: FC<{ messageText: string }> = ({ messageText }) => (
|
||||
<div className="relative h-6 w-14 shrink-0">
|
||||
<ActionBarPrimitive.Root className={ACTION_BAR_CLASS} hideWhenRunning>
|
||||
<CopyMessageButton text={messageText} />
|
||||
<ActionBarPrimitive.Edit asChild>
|
||||
<TooltipIconButton onClick={() => triggerHaptic('selection')} tooltip="Edit">
|
||||
<PencilIcon />
|
||||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.Edit>
|
||||
</ActionBarPrimitive.Root>
|
||||
</div>
|
||||
)
|
||||
|
||||
const SLASH_STATUS_RE = /^slash:(?<command>\/[^\n]+)\n(?<output>[\s\S]*)$/
|
||||
|
||||
const SystemMessage: FC = () => {
|
||||
const text = useAuiState(s => messageContentText(s.message.content))
|
||||
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
|
||||
const slashStatus = text.match(SLASH_STATUS_RE)
|
||||
|
||||
if (slashStatus?.groups) {
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="max-w-[min(86%,44rem)] self-center px-2 py-0.5 text-center text-[0.6875rem] leading-5 text-muted-foreground/60"
|
||||
data-role="system"
|
||||
data-slot="aui_system-message-root"
|
||||
>
|
||||
<span className="font-mono text-muted-foreground/55">{slashStatus.groups.command}</span>
|
||||
<span className="mx-1.5 text-muted-foreground/35">·</span>
|
||||
<span className="whitespace-pre-wrap">{slashStatus.groups.output.trim()}</span>
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="max-w-[min(86%,44rem)] self-center px-2 py-0.5 text-center text-[0.6875rem] leading-5 text-muted-foreground/55"
|
||||
data-role="system"
|
||||
data-slot="aui_system-message-root"
|
||||
>
|
||||
<span className="whitespace-pre-wrap">{text}</span>
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const UserEditComposer: FC = () => (
|
||||
<ComposerPrimitive.Root
|
||||
className="flex min-w-[min(18rem,72vw)] max-w-[min(72%,34rem)] flex-col gap-1.5 self-end rounded-2xl border border-[color-mix(in_srgb,var(--dt-user-bubble-border)_88%,transparent)] bg-[color-mix(in_srgb,var(--dt-user-bubble)_98%,transparent)] px-3 py-2 shadow-sm"
|
||||
data-slot="aui_edit-composer-root"
|
||||
>
|
||||
<ComposerPrimitive.Input
|
||||
autoFocus
|
||||
className="min-h-8 w-full resize-none bg-transparent leading-[1.48] text-foreground/95 outline-none"
|
||||
rows={1}
|
||||
submitMode="enter"
|
||||
unstable_focusOnScrollToBottom={false}
|
||||
/>
|
||||
<div className="flex justify-end gap-1">
|
||||
<ComposerPrimitive.Cancel asChild>
|
||||
<TooltipIconButton tooltip="Cancel edit">
|
||||
<XIcon />
|
||||
</TooltipIconButton>
|
||||
</ComposerPrimitive.Cancel>
|
||||
<ComposerPrimitive.Send asChild>
|
||||
<TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip="Send edit">
|
||||
<CheckIcon />
|
||||
</TooltipIconButton>
|
||||
</ComposerPrimitive.Send>
|
||||
</div>
|
||||
</ComposerPrimitive.Root>
|
||||
)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { type ToolCallMessagePartProps } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $toolInlineDiffs } from '@/store/tool-diffs'
|
||||
|
||||
const TOOL_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
||||
|
||||
@@ -78,6 +80,24 @@ function prettyJson(value: unknown): string {
|
||||
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
|
||||
}
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
|
||||
}
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '')
|
||||
}
|
||||
|
||||
function stripInlineDiffChrome(value: string): string {
|
||||
return value ? stripAnsi(value).replace(/^\s*┊\s*review diff\s*\n/i, '').trim() : ''
|
||||
}
|
||||
|
||||
function inlineDiffFromResult(result: unknown): string {
|
||||
const value = recordValue(result).inline_diff
|
||||
|
||||
return typeof value === 'string' ? stripInlineDiffChrome(value) : ''
|
||||
}
|
||||
|
||||
function detailLabel(toolName: string): string {
|
||||
if (toolName === 'image_generate') {
|
||||
return 'Prompt'
|
||||
@@ -121,7 +141,7 @@ function detailText(args: unknown, result: unknown): string {
|
||||
return prettyJson(args)
|
||||
}
|
||||
|
||||
export const ToolFallback = ({ toolName, args, result }: ToolCallMessagePartProps) => {
|
||||
export const ToolFallback = ({ toolCallId, toolName, args, result }: ToolCallMessagePartProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const isPending = result === undefined
|
||||
const [tick, setTick] = useState(0)
|
||||
@@ -129,6 +149,9 @@ export const ToolFallback = ({ toolName, args, result }: ToolCallMessagePartProp
|
||||
const preview = compactPreview(args) || compactPreview(result)
|
||||
const label = toolLabel(toolName, isPending)
|
||||
const detail = detailText(args, result)
|
||||
const liveDiffs = useStore($toolInlineDiffs)
|
||||
const sideDiff = toolCallId ? liveDiffs[toolCallId] || '' : ''
|
||||
const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(result)
|
||||
const spinnerFrame = TOOL_SPINNER_FRAMES[tick % TOOL_SPINNER_FRAMES.length]
|
||||
|
||||
useEffect(() => {
|
||||
@@ -173,6 +196,35 @@ export const ToolFallback = ({ toolName, args, result }: ToolCallMessagePartProp
|
||||
{detail}
|
||||
</div>
|
||||
)}
|
||||
{inlineDiff && <InlineDiff text={inlineDiff} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InlineDiff({ text }: { text: string }) {
|
||||
return (
|
||||
<pre className="ml-4 mt-2 max-h-96 max-w-full overflow-auto rounded-lg border border-border/60 bg-background/70 px-3 py-2 font-mono text-[0.6875rem] leading-relaxed">
|
||||
{text.split('\n').map((line, index) => {
|
||||
const added = line.startsWith('+') && !line.startsWith('+++')
|
||||
const removed = line.startsWith('-') && !line.startsWith('---')
|
||||
const hunk = line.startsWith('@@')
|
||||
const fileHeader = line.startsWith('---') || line.startsWith('+++') || / → /.test(line.slice(0, 60))
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'block min-w-max whitespace-pre',
|
||||
added && 'text-emerald-700 dark:text-emerald-300',
|
||||
removed && 'text-rose-700 dark:text-rose-300',
|
||||
hunk && 'text-sky-700 dark:text-sky-300',
|
||||
!added && !removed && !hunk && fileHeader && 'text-muted-foreground/80'
|
||||
)}
|
||||
key={`${index}-${line}`}
|
||||
>
|
||||
{line || ' '}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
'use client'
|
||||
|
||||
import { Download } from 'lucide-react'
|
||||
import { type ComponentProps, useState } from 'react'
|
||||
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
function imageFilename(src?: string): string {
|
||||
if (!src) {
|
||||
return 'image'
|
||||
}
|
||||
|
||||
try {
|
||||
const { pathname } = new URL(src, window.location.href)
|
||||
|
||||
return pathname.split('/').filter(Boolean).pop() || 'image'
|
||||
} catch {
|
||||
return src.split(/[\\/]/).filter(Boolean).pop() || 'image'
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingIpcHandler(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
|
||||
|
||||
return message.includes("No handler registered for 'hermes:saveImageFromUrl'")
|
||||
}
|
||||
|
||||
async function startBrowserDownload(src: string) {
|
||||
const response = await fetch(src)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Could not fetch image: ${response.status}`)
|
||||
}
|
||||
|
||||
const blobUrl = URL.createObjectURL(await response.blob())
|
||||
const link = document.createElement('a')
|
||||
link.href = blobUrl
|
||||
link.download = imageFilename(src)
|
||||
link.rel = 'noopener noreferrer'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
|
||||
}
|
||||
|
||||
const imageActionButtonClass =
|
||||
'absolute right-2 top-2 grid size-8 place-items-center rounded-full border border-border/70 bg-background/80 text-muted-foreground opacity-0 shadow-sm backdrop-blur transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 disabled:opacity-50'
|
||||
|
||||
export interface ZoomableImageProps extends ComponentProps<'img'> {
|
||||
containerClassName?: string
|
||||
slot?: string
|
||||
}
|
||||
|
||||
export function ZoomableImage({ className, containerClassName, src, alt, slot, ...props }: ZoomableImageProps) {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false)
|
||||
const canOpen = Boolean(src)
|
||||
|
||||
async function handleDownload() {
|
||||
if (!src || saving) {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
if (window.hermesDesktop?.saveImageFromUrl) {
|
||||
const saved = await window.hermesDesktop.saveImageFromUrl(src)
|
||||
|
||||
if (saved) {
|
||||
notify({ kind: 'success', title: 'Image saved', message: imageFilename(src) })
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await startBrowserDownload(src)
|
||||
} catch (error) {
|
||||
if (isMissingIpcHandler(error)) {
|
||||
try {
|
||||
await startBrowserDownload(src)
|
||||
notify({
|
||||
kind: 'info',
|
||||
title: 'Download started',
|
||||
message: 'Restart Hermes Desktop to use Save Image.'
|
||||
})
|
||||
} catch (fallbackError) {
|
||||
notifyError(fallbackError, 'Restart Hermes Desktop to save images')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
notifyError(error, 'Image download failed')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const lightbox = src ? (
|
||||
<Dialog onOpenChange={setLightboxOpen} open={lightboxOpen}>
|
||||
<DialogContent
|
||||
className="grid max-h-[calc(100vh-2rem)] w-auto max-w-[calc(100vw-2rem)] place-items-center overflow-visible border-0 bg-transparent p-0 shadow-none"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<div className="group/lightbox relative max-h-[calc(100vh-2rem)] max-w-[calc(100vw-2rem)] overflow-auto">
|
||||
<img
|
||||
alt={alt ?? ''}
|
||||
className="block max-h-[calc(100vh-2rem)] max-w-full cursor-zoom-out select-auto rounded-lg object-contain shadow-2xl"
|
||||
onClick={() => setLightboxOpen(false)}
|
||||
src={src}
|
||||
/>
|
||||
<ImageActionButton onClick={handleDownload} saving={saving} variant="lightbox" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className={cn('group/image relative inline-block max-w-full align-top', containerClassName)}
|
||||
data-slot={slot ?? 'aui_zoomable-image'}
|
||||
>
|
||||
<button
|
||||
className="block max-w-full cursor-zoom-in bg-transparent p-0 text-left"
|
||||
disabled={!canOpen}
|
||||
onClick={() => canOpen && setLightboxOpen(true)}
|
||||
title={canOpen ? 'Open image' : undefined}
|
||||
type="button"
|
||||
>
|
||||
<img alt={alt ?? ''} className={className} src={src} {...props} />
|
||||
</button>
|
||||
{src && <ImageActionButton onClick={handleDownload} saving={saving} variant="inline" />}
|
||||
</span>
|
||||
{lightbox}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ImageActionButton({
|
||||
onClick,
|
||||
saving,
|
||||
variant
|
||||
}: {
|
||||
onClick: () => void
|
||||
saving: boolean
|
||||
variant: 'inline' | 'lightbox'
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
aria-label={saving ? 'Saving image' : 'Download image'}
|
||||
className={cn(
|
||||
imageActionButtonClass,
|
||||
variant === 'inline' ? 'group-hover/image:opacity-100' : 'group-hover/lightbox:opacity-100'
|
||||
)}
|
||||
disabled={saving}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
void onClick()
|
||||
}}
|
||||
title={saving ? 'Saving image' : 'Download image'}
|
||||
type="button"
|
||||
>
|
||||
<Download className={cn('size-4', saving && 'animate-pulse')} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -60,8 +60,8 @@ export const SessionInspector: FC<SessionInspectorProps> = ({
|
||||
<aside
|
||||
aria-hidden={!open}
|
||||
className={cn(
|
||||
'relative flex h-screen w-full min-w-0 flex-col overflow-hidden bg-transparent pb-2 pl-2 pr-3 pt-[calc(var(--titlebar-height)+0.25rem)] text-muted-foreground transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||
open ? 'translate-x-0 opacity-100' : 'pointer-events-none translate-x-2 opacity-0'
|
||||
'relative flex h-screen w-full min-w-0 flex-col overflow-hidden bg-transparent pb-2 pl-2 pr-3 pt-[calc(var(--titlebar-height)+0.25rem)] text-muted-foreground transition-none',
|
||||
open ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
)}
|
||||
data-open={open}
|
||||
>
|
||||
@@ -132,7 +132,7 @@ function WorkspaceSection({
|
||||
<SectionLabel>cwd</SectionLabel>
|
||||
{editing ? (
|
||||
<Input
|
||||
className="h-7 bg-background px-2 font-mono text-[0.6875rem]"
|
||||
className={cn(bleed, 'h-7 bg-background px-1.5 font-mono text-[0.6875rem]')}
|
||||
onBlur={apply}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
@@ -152,7 +152,8 @@ function WorkspaceSection({
|
||||
<div
|
||||
className={cn(
|
||||
quietControl,
|
||||
'group grid w-full min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1 px-1.5 py-1 font-mono text-[0.6875rem] text-foreground/75'
|
||||
bleed,
|
||||
'group grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1 px-1.5 py-1 font-mono text-[0.6875rem] text-foreground/75'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="pagination"
|
||||
className={cn('mx-auto flex w-full justify-center', className)}
|
||||
data-slot="pagination"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationContent({ className, ...props }: React.ComponentProps<'ul'>) {
|
||||
return <ul className={cn('flex h-5 flex-row items-center gap-0.5', className)} data-slot="pagination-content" {...props} />
|
||||
}
|
||||
|
||||
function PaginationItem({ className, ...props }: React.ComponentProps<'li'>) {
|
||||
return <li className={cn('flex h-5 items-center', className)} data-slot="pagination-item" {...props} />
|
||||
}
|
||||
|
||||
interface PaginationButtonProps extends React.ComponentProps<'button'> {
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
function PaginationButton({ className, isActive, ...props }: PaginationButtonProps) {
|
||||
return (
|
||||
<button
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
'inline-flex h-5 min-w-5 items-center justify-center rounded border border-transparent px-1 text-[0.6875rem] leading-none tabular-nums transition-colors disabled:pointer-events-none disabled:opacity-45',
|
||||
isActive
|
||||
? 'border-border bg-background text-foreground shadow-xs'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||
className
|
||||
)}
|
||||
data-active={isActive}
|
||||
data-slot="pagination-button"
|
||||
type="button"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationPrevious({ className, ...props }: React.ComponentProps<'button'>) {
|
||||
return (
|
||||
<button
|
||||
aria-label="Go to previous page"
|
||||
className={cn(
|
||||
'inline-flex h-5 items-center justify-center gap-0.5 rounded border border-transparent px-1 text-[0.6875rem] leading-none text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-45',
|
||||
className
|
||||
)}
|
||||
data-slot="pagination-previous"
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeft className="size-3" />
|
||||
<span>Prev</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationNext({ className, ...props }: React.ComponentProps<'button'>) {
|
||||
return (
|
||||
<button
|
||||
aria-label="Go to next page"
|
||||
className={cn(
|
||||
'inline-flex h-5 items-center justify-center gap-0.5 rounded border border-transparent px-1 text-[0.6875rem] leading-none text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-45',
|
||||
className
|
||||
)}
|
||||
data-slot="pagination-next"
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="size-3" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn('flex size-5 items-center justify-center', className)}
|
||||
data-slot="pagination-ellipsis"
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-3" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationButton,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationNext,
|
||||
PaginationPrevious
|
||||
}
|
||||
Vendored
+25
@@ -11,7 +11,14 @@ declare global {
|
||||
selectPaths: (options?: HermesSelectPathsOptions) => Promise<string[]>
|
||||
writeClipboard: (text: string) => Promise<boolean>
|
||||
saveImageFromUrl: (url: string) => Promise<boolean>
|
||||
saveImageBuffer: (data: ArrayBuffer | Uint8Array, ext: string) => Promise<string>
|
||||
saveClipboardImage: () => Promise<string>
|
||||
getPathForFile: (file: File) => string
|
||||
normalizePreviewTarget: (target: string, baseDir?: string) => Promise<HermesPreviewTarget | null>
|
||||
watchPreviewFile: (url: string) => Promise<HermesPreviewWatch>
|
||||
stopPreviewFileWatch: (id: string) => Promise<boolean>
|
||||
openExternal: (url: string) => Promise<void>
|
||||
onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void
|
||||
onBackendExit: (callback: (payload: BackendExit) => void) => () => void
|
||||
}
|
||||
}
|
||||
@@ -37,6 +44,24 @@ export interface HermesNotification {
|
||||
silent?: boolean
|
||||
}
|
||||
|
||||
export interface HermesPreviewTarget {
|
||||
kind: 'file' | 'url'
|
||||
label: string
|
||||
source: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface HermesPreviewWatch {
|
||||
id: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface HermesPreviewFileChanged {
|
||||
id: string
|
||||
path: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface HermesSelectPathsOptions {
|
||||
title?: string
|
||||
defaultPath?: string
|
||||
|
||||
@@ -180,9 +180,9 @@ export class HermesGateway {
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSessions(limit = 40): Promise<PaginatedSessions> {
|
||||
export async function listSessions(limit = 40, minMessages = 0): Promise<PaginatedSessions> {
|
||||
const result = await window.hermesDesktop.api<PaginatedSessions>({
|
||||
path: `/api/sessions?limit=${limit}&offset=0&min_messages=1`
|
||||
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}`
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { chatMessageText, toChatMessages } from './chat-messages'
|
||||
import { appendAssistantTextPart, chatMessageText, renderMediaTags, toChatMessages, upsertToolPart } from './chat-messages'
|
||||
|
||||
describe('toChatMessages', () => {
|
||||
it('hides attached context payloads from user message display', () => {
|
||||
@@ -15,4 +15,87 @@ describe('toChatMessages', () => {
|
||||
|
||||
expect(chatMessageText(message)).toBe('@file:tsconfig.tsbuildinfo\n\nwhat is this file')
|
||||
})
|
||||
|
||||
it('renders MEDIA tags as assistant attachment links', () => {
|
||||
const [message] = toChatMessages([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: "MEDIA:/Users/brooklyn/.hermes/cache/audio/tts_20260501_222725.mp3\n\nhow's that sound?",
|
||||
timestamp: 1
|
||||
}
|
||||
])
|
||||
|
||||
expect(chatMessageText(message)).toBe(
|
||||
"[Audio: tts_20260501_222725.mp3](#media:%2FUsers%2Fbrooklyn%2F.hermes%2Fcache%2Faudio%2Ftts_20260501_222725.mp3)\n\nhow's that sound?"
|
||||
)
|
||||
})
|
||||
|
||||
it('coerces non-string message content without throwing', () => {
|
||||
const [message] = toChatMessages([
|
||||
{
|
||||
content: {
|
||||
text: 'hello from object content'
|
||||
},
|
||||
role: 'assistant',
|
||||
timestamp: 1
|
||||
}
|
||||
])
|
||||
|
||||
expect(chatMessageText(message)).toBe('hello from object content')
|
||||
})
|
||||
|
||||
it('applies attached-context filtering when user content is object-shaped', () => {
|
||||
const [message] = toChatMessages([
|
||||
{
|
||||
content: {
|
||||
text:
|
||||
'look\n\n--- Attached Context ---\n\n📄 @file:foo.ts (10 tokens)\n```ts\nconst x = 1\n```'
|
||||
},
|
||||
role: 'user',
|
||||
timestamp: 1
|
||||
}
|
||||
])
|
||||
|
||||
expect(chatMessageText(message)).toBe('@file:foo.ts\n\nlook')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderMediaTags', () => {
|
||||
it('renders standalone and inline MEDIA tags as links', () => {
|
||||
expect(renderMediaTags('here\nMEDIA:/tmp/voice.mp3\nthere')).toBe(
|
||||
'here\n[Audio: voice.mp3](#media:%2Ftmp%2Fvoice.mp3)\nthere'
|
||||
)
|
||||
expect(renderMediaTags('audio: MEDIA:/tmp/voice.mp3 done')).toBe(
|
||||
'audio: [Audio: voice.mp3](#media:%2Ftmp%2Fvoice.mp3) done'
|
||||
)
|
||||
expect(renderMediaTags('MEDIA:/tmp/demo.mp4')).toBe('[Video: demo.mp4](#media:%2Ftmp%2Fdemo.mp4)')
|
||||
})
|
||||
|
||||
it('renders streamed assistant media once the tag is complete', () => {
|
||||
const parts = appendAssistantTextPart(appendAssistantTextPart([], 'ok\nMEDIA:'), '/tmp/voice.mp3')
|
||||
const text = chatMessageText({ id: 'a', role: 'assistant', parts })
|
||||
|
||||
expect(text).toBe('ok\n[Audio: voice.mp3](#media:%2Ftmp%2Fvoice.mp3)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('upsertToolPart', () => {
|
||||
it('preserves inline diffs from tool completion events', () => {
|
||||
const parts = upsertToolPart(
|
||||
[],
|
||||
{
|
||||
inline_diff: '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new',
|
||||
name: 'patch',
|
||||
tool_id: 'tool-1'
|
||||
},
|
||||
'complete'
|
||||
)
|
||||
|
||||
const [part] = parts
|
||||
|
||||
expect(part?.type).toBe('tool-call')
|
||||
expect(part && 'result' in part ? part.result : undefined).toMatchObject({
|
||||
inline_diff: '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ThreadMessageLike } from '@assistant-ui/react'
|
||||
|
||||
import { mediaDisplayLabel, mediaMarkdownHref } from '@/lib/media'
|
||||
import type { SessionMessage } from '@/types/hermes'
|
||||
|
||||
export type ChatMessagePart = Exclude<ThreadMessageLike['content'], string>[number]
|
||||
@@ -25,6 +26,7 @@ export type GatewayEventPayload = {
|
||||
preview?: string
|
||||
summary?: string
|
||||
error?: string | boolean
|
||||
inline_diff?: string
|
||||
duration_s?: number
|
||||
todos?: unknown
|
||||
model?: string
|
||||
@@ -33,6 +35,10 @@ export type GatewayEventPayload = {
|
||||
cwd?: string
|
||||
branch?: string
|
||||
personality?: string
|
||||
// clarify.request
|
||||
request_id?: string
|
||||
question?: string
|
||||
choices?: string[] | null
|
||||
}
|
||||
|
||||
export function textPart(text: string): ChatMessagePart {
|
||||
@@ -43,6 +49,37 @@ export function reasoningPart(text: string): ChatMessagePart {
|
||||
return { type: 'reasoning', text }
|
||||
}
|
||||
|
||||
const MEDIA_LINE_RE =
|
||||
/(^|\n)[\t ]*[`"']?MEDIA:\s*(?<line>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)[`"']?[\t ]*(?:\n|$)/g
|
||||
|
||||
const MEDIA_TAG_RE = /[`"']?MEDIA:\s*(?<inline>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)[`"']?/g
|
||||
|
||||
function unquoteMediaPath(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
const quote = trimmed[0]
|
||||
|
||||
return quote && quote === trimmed.at(-1) && ['"', "'", '`'].includes(quote) ? trimmed.slice(1, -1) : trimmed
|
||||
}
|
||||
|
||||
function mediaLink(value: string): string {
|
||||
const path = unquoteMediaPath(value)
|
||||
|
||||
return `[${mediaDisplayLabel(path)}](${mediaMarkdownHref(path)})`
|
||||
}
|
||||
|
||||
export function renderMediaTags(text: string): string {
|
||||
return text
|
||||
.replace(MEDIA_LINE_RE, (_match, lead: string, value: string) => `${lead}${mediaLink(value)}\n`)
|
||||
.replace(MEDIA_TAG_RE, (_match, value: string) => mediaLink(value))
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function assistantTextPart(text: string): ChatMessagePart {
|
||||
return textPart(renderMediaTags(text))
|
||||
}
|
||||
|
||||
export function chatMessageText(message: ChatMessage): string {
|
||||
return message.parts
|
||||
.filter((part): part is Extract<ChatMessagePart, { type: 'text' }> => part.type === 'text')
|
||||
@@ -54,19 +91,57 @@ const ATTACHED_CONTEXT_MARKER_RE = /(?:^|\n)--- Attached Context ---\s*\n/
|
||||
const CONTEXT_WARNINGS_MARKER_RE = /(?:^|\n)--- Context Warnings ---[\s\S]*$/
|
||||
const CONTEXT_REF_RE = /@(file|folder|url|image|tool):(?:"[^"\n]+"|'[^'\n]+'|`[^`\n]+`|\S+)/g
|
||||
|
||||
function displayContentForMessage(role: SessionMessage['role'], content: string): string {
|
||||
if (role !== 'user') {
|
||||
return content
|
||||
function textFromUnknown(value: unknown, depth = 0): string {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
const marker = content.match(ATTACHED_CONTEXT_MARKER_RE)
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (depth > 2) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(item => textFromUnknown(item, depth + 1)).join('')
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const row = value as Record<string, unknown>
|
||||
const textValue = row.text ?? row.output_text ?? row.content ?? row.message
|
||||
const nestedText = textFromUnknown(textValue, depth + 1)
|
||||
|
||||
if (nestedText) {
|
||||
return nestedText
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function displayContentForMessage(role: SessionMessage['role'], content: unknown): string {
|
||||
const textContent = textFromUnknown(content)
|
||||
|
||||
if (role !== 'user') {
|
||||
return textContent
|
||||
}
|
||||
|
||||
const marker = textContent.match(ATTACHED_CONTEXT_MARKER_RE)
|
||||
|
||||
if (!marker || marker.index === undefined) {
|
||||
return content.replace(CONTEXT_WARNINGS_MARKER_RE, '').trim()
|
||||
return textContent.replace(CONTEXT_WARNINGS_MARKER_RE, '').trim()
|
||||
}
|
||||
|
||||
const visibleText = content.slice(0, marker.index).replace(CONTEXT_WARNINGS_MARKER_RE, '').trim()
|
||||
const attachedContext = content.slice(marker.index + marker[0].length)
|
||||
const visibleText = textContent.slice(0, marker.index).replace(CONTEXT_WARNINGS_MARKER_RE, '').trim()
|
||||
const attachedContext = textContent.slice(marker.index + marker[0].length)
|
||||
const refs = [...new Set(Array.from(attachedContext.matchAll(CONTEXT_REF_RE)).map(match => match[0]))]
|
||||
|
||||
return [refs.join('\n'), visibleText].filter(Boolean).join('\n\n') || visibleText
|
||||
@@ -87,6 +162,17 @@ export function appendTextPart(parts: ChatMessagePart[], delta: string): ChatMes
|
||||
return next
|
||||
}
|
||||
|
||||
export function appendAssistantTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
|
||||
const next = appendTextPart(parts, delta)
|
||||
const last = next.at(-1)
|
||||
|
||||
if (last?.type === 'text') {
|
||||
next[next.length - 1] = { ...last, text: renderMediaTags(last.text) }
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
export function appendReasoningPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
|
||||
const next = [...parts]
|
||||
const last = next.at(-1)
|
||||
@@ -119,6 +205,7 @@ function toolArgs(payload: GatewayEventPayload | undefined): Record<string, unkn
|
||||
|
||||
function toolResult(payload: GatewayEventPayload | undefined): Record<string, unknown> {
|
||||
return {
|
||||
...(payload?.inline_diff ? { inline_diff: payload.inline_diff } : {}),
|
||||
...(payload?.summary ? { summary: payload.summary } : {}),
|
||||
...(payload?.message ? { message: payload.message } : {}),
|
||||
...(payload?.preview ? { preview: payload.preview } : {}),
|
||||
@@ -198,15 +285,21 @@ function firstNonEmptyObject(...values: unknown[]): Record<string, unknown> {
|
||||
return {}
|
||||
}
|
||||
|
||||
function parseStoredToolResult(content: string): unknown {
|
||||
if (!content.trim()) {
|
||||
function parseStoredToolResult(content: unknown): unknown {
|
||||
if (content && typeof content === 'object') {
|
||||
return content
|
||||
}
|
||||
|
||||
const textContent = textFromUnknown(content)
|
||||
|
||||
if (!textContent.trim()) {
|
||||
return ''
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(content)
|
||||
return JSON.parse(textContent)
|
||||
} catch {
|
||||
return content
|
||||
return textContent
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +326,7 @@ function toolPartFromStoredCall(call: unknown, fallbackIndex: number): ChatMessa
|
||||
function applyStoredToolResult(messages: ChatMessage[], toolMessage: SessionMessage): boolean {
|
||||
const toolCallId = toolMessage.tool_call_id || undefined
|
||||
const toolName = toolMessage.tool_name || toolMessage.name || 'tool'
|
||||
const content = toolMessage.content || toolMessage.text || toolMessage.context || toolMessage.name || ''
|
||||
const content = toolMessage.content || toolMessage.text || toolMessage.context || toolMessage.name
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const message = messages[i]
|
||||
@@ -270,7 +363,7 @@ function applyStoredToolResult(messages: ChatMessage[], toolMessage: SessionMess
|
||||
function applyStoredToolResultToParts(parts: ChatMessagePart[], toolMessage: SessionMessage): ChatMessagePart[] | null {
|
||||
const toolCallId = toolMessage.tool_call_id || undefined
|
||||
const toolName = toolMessage.tool_name || toolMessage.name || 'tool'
|
||||
const content = toolMessage.content || toolMessage.text || toolMessage.context || toolMessage.name || ''
|
||||
const content = toolMessage.content || toolMessage.text || toolMessage.context || toolMessage.name
|
||||
|
||||
const partIndex = parts.findIndex(
|
||||
part =>
|
||||
@@ -295,7 +388,7 @@ function applyStoredToolResultToParts(parts: ChatMessagePart[], toolMessage: Ses
|
||||
|
||||
function storedToolMessagePart(toolMessage: SessionMessage, fallbackIndex: number): ChatMessagePart {
|
||||
const name = toolMessage.tool_name || toolMessage.name || 'tool'
|
||||
const context = toolMessage.context || toolMessage.text || toolMessage.content || ''
|
||||
const context = textFromUnknown(toolMessage.context || toolMessage.text || toolMessage.content || '')
|
||||
const args = context ? { context } : {}
|
||||
|
||||
return {
|
||||
@@ -385,7 +478,7 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
|
||||
return
|
||||
}
|
||||
|
||||
const content = message.content || message.text || message.context || message.name || ''
|
||||
const content = message.content || message.text || message.context || message.name
|
||||
const displayContent = displayContentForMessage(message.role, content)
|
||||
const parts: ChatMessagePart[] = []
|
||||
|
||||
@@ -399,7 +492,7 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
|
||||
}
|
||||
|
||||
if (displayContent) {
|
||||
parts.push(textPart(displayContent))
|
||||
parts.push(message.role === 'assistant' ? assistantTextPart(displayContent) : textPart(displayContent))
|
||||
}
|
||||
|
||||
if (message.role === 'assistant' && Array.isArray(message.tool_calls)) {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
desktopSlashDescription,
|
||||
desktopSlashUnavailableMessage,
|
||||
desktopSkinSlashCompletions,
|
||||
filterDesktopCommandsCatalog,
|
||||
isDesktopSlashCommand,
|
||||
isDesktopSlashSuggestion
|
||||
} from './desktop-slash-commands'
|
||||
|
||||
describe('desktop slash command curation', () => {
|
||||
it('keeps core desktop chat commands in suggestions', () => {
|
||||
expect(isDesktopSlashSuggestion('/new')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/branch')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/skin')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/usage')).toBe(true)
|
||||
})
|
||||
|
||||
it('lets explicitly typed extension commands run without suggesting them', () => {
|
||||
expect(isDesktopSlashSuggestion('/my-skill')).toBe(false)
|
||||
expect(isDesktopSlashCommand('/my-skill')).toBe(true)
|
||||
})
|
||||
|
||||
it('hides terminal, messaging, and dedicated-UI commands from suggestions', () => {
|
||||
expect(isDesktopSlashSuggestion('/clear')).toBe(false)
|
||||
expect(isDesktopSlashSuggestion('/compact')).toBe(false)
|
||||
expect(isDesktopSlashSuggestion('/redraw')).toBe(false)
|
||||
expect(isDesktopSlashSuggestion('/approve')).toBe(false)
|
||||
expect(isDesktopSlashSuggestion('/model')).toBe(false)
|
||||
expect(isDesktopSlashSuggestion('/skills')).toBe(false)
|
||||
expect(isDesktopSlashSuggestion('/voice')).toBe(false)
|
||||
expect(isDesktopSlashSuggestion('/curator')).toBe(false)
|
||||
})
|
||||
|
||||
it('allows aliases to execute without cluttering the popover', () => {
|
||||
expect(isDesktopSlashSuggestion('/reset')).toBe(false)
|
||||
expect(isDesktopSlashCommand('/reset')).toBe(true)
|
||||
})
|
||||
|
||||
it('filters command catalogs down to core desktop commands', () => {
|
||||
const filtered = filterDesktopCommandsCatalog({
|
||||
categories: [
|
||||
{
|
||||
name: 'Session',
|
||||
pairs: [
|
||||
['/new', 'Start a new session'],
|
||||
['/clear', 'Clear terminal screen']
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'User commands',
|
||||
pairs: [['/ship-it', 'Run release checklist']]
|
||||
}
|
||||
],
|
||||
pairs: [
|
||||
['/new', 'Start a new session'],
|
||||
['/model', 'Switch model'],
|
||||
['/ship-it', 'Run release checklist']
|
||||
],
|
||||
skill_count: 2
|
||||
})
|
||||
|
||||
expect(filtered.categories).toEqual([{ name: 'Session', pairs: [['/new', 'Start a new desktop chat']] }])
|
||||
expect(filtered.pairs).toEqual([['/new', 'Start a new desktop chat']])
|
||||
expect(filtered.skill_count).toBe(2)
|
||||
})
|
||||
|
||||
it('uses desktop-specific labels for commands with different UI behavior', () => {
|
||||
expect(desktopSlashDescription('/branch', 'Branch the current session')).toBe(
|
||||
'Branch the latest message into a new chat'
|
||||
)
|
||||
expect(desktopSlashDescription('/skin', 'Show or change the display skin/theme')).toBe(
|
||||
'Switch desktop theme or cycle to the next one'
|
||||
)
|
||||
})
|
||||
|
||||
it('builds /skin completions from desktop themes', () => {
|
||||
const completions = desktopSkinSlashCompletions(
|
||||
[
|
||||
{ name: 'mono', label: 'Mono', description: 'Clean grayscale' },
|
||||
{ name: 'midnight', label: 'Midnight', description: 'Deep blue' },
|
||||
{ name: 'slate', label: 'Slate', description: 'Cool slate blue' }
|
||||
],
|
||||
'mono',
|
||||
'm'
|
||||
)
|
||||
|
||||
expect(completions).toEqual([
|
||||
{
|
||||
text: '/skin mono',
|
||||
display: '/skin mono',
|
||||
meta: 'Mono (current) - Clean grayscale'
|
||||
},
|
||||
{
|
||||
text: '/skin midnight',
|
||||
display: '/skin midnight',
|
||||
meta: 'Midnight - Deep blue'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('explains known commands that desktop owns elsewhere', () => {
|
||||
expect(desktopSlashUnavailableMessage('/model sonnet')).toContain('model picker')
|
||||
expect(desktopSlashUnavailableMessage('/skills')).toContain('desktop sidebar')
|
||||
expect(desktopSlashUnavailableMessage('/clear')).toContain('terminal interface')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,251 @@
|
||||
export interface CommandsCatalogSection {
|
||||
name: string
|
||||
pairs: [string, string][]
|
||||
}
|
||||
|
||||
export interface CommandsCatalogLike {
|
||||
categories?: CommandsCatalogSection[]
|
||||
pairs?: [string, string][]
|
||||
skill_count?: number
|
||||
warning?: string
|
||||
}
|
||||
|
||||
export interface DesktopSlashCompletion {
|
||||
display: string
|
||||
meta: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface DesktopThemeCommandOption {
|
||||
description: string
|
||||
label: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const DESKTOP_COMMAND_META = [
|
||||
['/agents', 'Show active desktop sessions and running tasks'],
|
||||
['/background', 'Run a prompt in the background'],
|
||||
['/branch', 'Branch the latest message into a new chat'],
|
||||
['/compress', 'Compress this conversation context'],
|
||||
['/debug', 'Create a debug report'],
|
||||
['/goal', 'Manage the standing goal for this session'],
|
||||
['/help', 'Show desktop slash commands'],
|
||||
['/new', 'Start a new desktop chat'],
|
||||
['/queue', 'Queue a prompt for the next turn'],
|
||||
['/resume', 'Resume a saved session'],
|
||||
['/retry', 'Retry the last user message'],
|
||||
['/rollback', 'List or restore filesystem checkpoints'],
|
||||
['/skin', 'Switch desktop theme or cycle to the next one'],
|
||||
['/status', 'Show current session status'],
|
||||
['/steer', 'Steer the current run after the next tool call'],
|
||||
['/stop', 'Stop running background processes'],
|
||||
['/title', 'Rename the current session'],
|
||||
['/undo', 'Remove the last user/assistant exchange'],
|
||||
['/usage', 'Show token usage for this session']
|
||||
] as const
|
||||
|
||||
const DESKTOP_COMMANDS: ReadonlySet<string> = new Set(DESKTOP_COMMAND_META.map(([command]) => command))
|
||||
|
||||
const DESKTOP_ALIASES = new Map([
|
||||
['/bg', '/background'],
|
||||
['/btw', '/background'],
|
||||
['/fork', '/branch'],
|
||||
['/q', '/queue'],
|
||||
['/reload_mcp', '/reload-mcp'],
|
||||
['/reload_skills', '/reload-skills'],
|
||||
['/reset', '/new'],
|
||||
['/tasks', '/agents']
|
||||
])
|
||||
|
||||
const DESKTOP_COMMAND_DESCRIPTIONS: ReadonlyMap<string, string> = new Map(DESKTOP_COMMAND_META)
|
||||
|
||||
const PICKER_OWNED_COMMANDS = new Set(['/model', '/provider'])
|
||||
|
||||
const TERMINAL_ONLY_COMMANDS = new Set([
|
||||
'/browser',
|
||||
'/busy',
|
||||
'/clear',
|
||||
'/commands',
|
||||
'/compact',
|
||||
'/config',
|
||||
'/copy',
|
||||
'/cron',
|
||||
'/details',
|
||||
'/exit',
|
||||
'/footer',
|
||||
'/gateway',
|
||||
'/gquota',
|
||||
'/history',
|
||||
'/image',
|
||||
'/indicator',
|
||||
'/logs',
|
||||
'/mouse',
|
||||
'/paste',
|
||||
'/platforms',
|
||||
'/plugins',
|
||||
'/quit',
|
||||
'/redraw',
|
||||
'/reload',
|
||||
'/restart',
|
||||
'/save',
|
||||
'/sb',
|
||||
'/set-home',
|
||||
'/sethome',
|
||||
'/snap',
|
||||
'/snapshot',
|
||||
'/statusbar',
|
||||
'/toolsets',
|
||||
'/tools',
|
||||
'/update',
|
||||
'/verbose'
|
||||
])
|
||||
|
||||
const MESSAGING_ONLY_COMMANDS = new Set(['/approve', '/deny'])
|
||||
|
||||
const SETTINGS_OWNED_COMMANDS = new Set(['/skills'])
|
||||
|
||||
const ADVANCED_COMMANDS = new Set([
|
||||
'/curator',
|
||||
'/fast',
|
||||
'/insights',
|
||||
'/kanban',
|
||||
'/personality',
|
||||
'/profile',
|
||||
'/reasoning',
|
||||
'/reload-mcp',
|
||||
'/reload-skills',
|
||||
'/voice',
|
||||
'/yolo'
|
||||
])
|
||||
|
||||
const BLOCKED_COMMANDS = new Set([
|
||||
...PICKER_OWNED_COMMANDS,
|
||||
...TERMINAL_ONLY_COMMANDS,
|
||||
...MESSAGING_ONLY_COMMANDS,
|
||||
...SETTINGS_OWNED_COMMANDS,
|
||||
...ADVANCED_COMMANDS
|
||||
])
|
||||
|
||||
function normalizeCommand(command: string): string {
|
||||
const trimmed = command.trim()
|
||||
const base = (trimmed.startsWith('/') ? trimmed : `/${trimmed}`).split(/\s+/, 1)[0]?.toLowerCase() || ''
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
export function canonicalDesktopSlashCommand(command: string): string {
|
||||
const normalized = normalizeCommand(command)
|
||||
|
||||
return DESKTOP_ALIASES.get(normalized) || normalized
|
||||
}
|
||||
|
||||
export function isDesktopSlashCommand(command: string): boolean {
|
||||
const normalized = normalizeCommand(command)
|
||||
const canonical = canonicalDesktopSlashCommand(normalized)
|
||||
|
||||
if (BLOCKED_COMMANDS.has(normalized) || BLOCKED_COMMANDS.has(canonical)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return DESKTOP_COMMANDS.has(canonical) || !isKnownHermesSlashCommand(normalized)
|
||||
}
|
||||
|
||||
export function isDesktopSlashSuggestion(command: string): boolean {
|
||||
const normalized = normalizeCommand(command)
|
||||
const canonical = canonicalDesktopSlashCommand(normalized)
|
||||
|
||||
return DESKTOP_COMMANDS.has(canonical) && !DESKTOP_ALIASES.has(normalized)
|
||||
}
|
||||
|
||||
export function desktopSlashUnavailableMessage(command: string): string | null {
|
||||
const normalized = normalizeCommand(command)
|
||||
const canonical = canonicalDesktopSlashCommand(normalized)
|
||||
|
||||
if (PICKER_OWNED_COMMANDS.has(canonical)) {
|
||||
return `/${canonical.slice(1)} uses the desktop model picker instead of a slash command.`
|
||||
}
|
||||
|
||||
if (SETTINGS_OWNED_COMMANDS.has(canonical)) {
|
||||
return `/${canonical.slice(1)} is managed from the desktop sidebar.`
|
||||
}
|
||||
|
||||
if (MESSAGING_ONLY_COMMANDS.has(canonical)) {
|
||||
return `/${canonical.slice(1)} is only used from messaging platforms.`
|
||||
}
|
||||
|
||||
if (ADVANCED_COMMANDS.has(canonical)) {
|
||||
return `/${canonical.slice(1)} is not shown in the desktop slash palette. Use the relevant desktop control or terminal interface instead.`
|
||||
}
|
||||
|
||||
if (TERMINAL_ONLY_COMMANDS.has(normalized) || TERMINAL_ONLY_COMMANDS.has(canonical)) {
|
||||
return `/${canonical.slice(1)} is only available in the terminal interface.`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function desktopSlashDescription(command: string, fallback = ''): string {
|
||||
const canonical = canonicalDesktopSlashCommand(command)
|
||||
|
||||
return DESKTOP_COMMAND_DESCRIPTIONS.get(canonical) || fallback
|
||||
}
|
||||
|
||||
export function desktopSkinSlashCompletions(
|
||||
themes: DesktopThemeCommandOption[],
|
||||
activeThemeName: string,
|
||||
argPrefix: string
|
||||
): DesktopSlashCompletion[] {
|
||||
const prefix = argPrefix.trim().toLowerCase()
|
||||
const commands: DesktopSlashCompletion[] = [
|
||||
{
|
||||
text: '/skin list',
|
||||
display: '/skin list',
|
||||
meta: 'Show available desktop themes'
|
||||
},
|
||||
{
|
||||
text: '/skin next',
|
||||
display: '/skin next',
|
||||
meta: 'Cycle to the next desktop theme'
|
||||
},
|
||||
...themes.map(theme => ({
|
||||
text: `/skin ${theme.name}`,
|
||||
display: `/skin ${theme.name}`,
|
||||
meta: `${theme.label}${theme.name === activeThemeName ? ' (current)' : ''} - ${theme.description}`
|
||||
}))
|
||||
]
|
||||
|
||||
if (!prefix) {
|
||||
return commands
|
||||
}
|
||||
|
||||
return commands.filter(item => item.text.slice('/skin '.length).toLowerCase().startsWith(prefix))
|
||||
}
|
||||
|
||||
export function filterDesktopCommandsCatalog(catalog: CommandsCatalogLike): CommandsCatalogLike {
|
||||
const categories = catalog.categories
|
||||
?.map(section => ({
|
||||
...section,
|
||||
pairs: section.pairs
|
||||
.filter(([command]) => isDesktopSlashSuggestion(command))
|
||||
.map(([command, description]) => [command, desktopSlashDescription(command, description)] as [string, string])
|
||||
}))
|
||||
.filter(section => section.pairs.length > 0)
|
||||
|
||||
const pairs = catalog.pairs
|
||||
?.filter(([command]) => isDesktopSlashSuggestion(command))
|
||||
.map(([command, description]) => [command, desktopSlashDescription(command, description)] as [string, string])
|
||||
|
||||
return {
|
||||
...catalog,
|
||||
...(categories ? { categories } : {}),
|
||||
...(pairs ? { pairs } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function isKnownHermesSlashCommand(command: string): boolean {
|
||||
return (
|
||||
DESKTOP_COMMANDS.has(command) ||
|
||||
DESKTOP_ALIASES.has(command) ||
|
||||
BLOCKED_COMMANDS.has(command)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { extractEmbeddedImages } from './embedded-images'
|
||||
|
||||
const SAMPLE_PNG_DATA_URL = 'data:image/png;base64,' + 'A'.repeat(120)
|
||||
|
||||
describe('extractEmbeddedImages', () => {
|
||||
it('returns text untouched when no data URL is present', () => {
|
||||
expect(extractEmbeddedImages('describe this')).toEqual({ cleanedText: 'describe this', images: [] })
|
||||
})
|
||||
|
||||
it('lifts a bare data:image URL out of prose', () => {
|
||||
const result = extractEmbeddedImages(`describe this ${SAMPLE_PNG_DATA_URL}`)
|
||||
|
||||
expect(result.cleanedText).toBe('describe this')
|
||||
expect(result.images).toEqual([SAMPLE_PNG_DATA_URL])
|
||||
})
|
||||
|
||||
it('lifts a JSON-wrapped image_url envelope out of prose', () => {
|
||||
const result = extractEmbeddedImages(
|
||||
`describe this{"type":"image_url","image_url":{"url":"${SAMPLE_PNG_DATA_URL}"}}`
|
||||
)
|
||||
|
||||
expect(result.cleanedText).toBe('describe this')
|
||||
expect(result.images).toEqual([SAMPLE_PNG_DATA_URL])
|
||||
})
|
||||
|
||||
it('extracts multiple embedded images', () => {
|
||||
const second = 'data:image/jpeg;base64,' + 'B'.repeat(96)
|
||||
const result = extractEmbeddedImages(`first ${SAMPLE_PNG_DATA_URL} mid ${second} tail`)
|
||||
|
||||
expect(result.cleanedText).toBe('first mid tail')
|
||||
expect(result.images).toEqual([SAMPLE_PNG_DATA_URL, second])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
const EMBEDDED_IMAGE_RE =
|
||||
/(\{\s*"type"\s*:\s*"image_url"\s*,\s*"image_url"\s*:\s*\{\s*"url"\s*:\s*")?(data:image\/[\w.+-]+;base64,[A-Za-z0-9+/=]{64,})("\s*\}\s*\})?/g
|
||||
|
||||
const DATA_URL_RE = /^data:([\w./+-]+);base64,(.*)$/i
|
||||
|
||||
export const DATA_IMAGE_URL_RE = /^data:image\/[\w.+-]+;base64,/i
|
||||
|
||||
export interface EmbeddedImageExtraction {
|
||||
cleanedText: string
|
||||
images: string[]
|
||||
}
|
||||
|
||||
export function dataUrlToBlob(dataUrl: string): Blob | null {
|
||||
const match = DATA_URL_RE.exec(dataUrl.trim())
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const bytes = atob(match[2])
|
||||
const buffer = new Uint8Array(bytes.length)
|
||||
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
buffer[i] = bytes.charCodeAt(i)
|
||||
}
|
||||
|
||||
return new Blob([buffer], { type: match[1] })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function extractEmbeddedImages(text: string): EmbeddedImageExtraction {
|
||||
if (!text || !text.includes('data:image/')) {
|
||||
return { cleanedText: text, images: [] }
|
||||
}
|
||||
|
||||
const images: string[] = []
|
||||
const cleanedText = text
|
||||
.replace(EMBEDDED_IMAGE_RE, (_match, _open, dataUrl: string) => {
|
||||
images.push(dataUrl)
|
||||
|
||||
return ''
|
||||
})
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
|
||||
return { cleanedText, images }
|
||||
}
|
||||
|
||||
export function embeddedImageUrls(text: string): string[] {
|
||||
return extractEmbeddedImages(text).images
|
||||
}
|
||||
|
||||
export function textWithoutEmbeddedImages(text: string): string {
|
||||
return extractEmbeddedImages(text).cleanedText
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isLikelyProseCodeBlock } from './markdown-code'
|
||||
|
||||
describe('isLikelyProseCodeBlock', () => {
|
||||
it('detects prose that Streamdown mislabels as an unknown language', () => {
|
||||
expect(
|
||||
isLikelyProseCodeBlock(
|
||||
'heads',
|
||||
[
|
||||
'- Pure white (`#ffffff`), roughness 0.55, no emissive',
|
||||
'- Black wireframe edges at 35% opacity',
|
||||
'',
|
||||
'Want the bunny gone, or want me to keep riffing on it?'
|
||||
].join('\n')
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps real code blocks', () => {
|
||||
expect(isLikelyProseCodeBlock('ts', 'const value = { bunny: true };\nreturn value')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
const VALID_LANGUAGE_RE = /^[a-z0-9][a-z0-9+#-]*$/i
|
||||
const NON_CODE_FENCE_LANGUAGES = new Set(['', 'text', 'plain', 'plaintext', 'md', 'markdown'])
|
||||
const COMMON_CODE_LANGUAGES = new Set([
|
||||
'bash',
|
||||
'c',
|
||||
'cpp',
|
||||
'css',
|
||||
'diff',
|
||||
'go',
|
||||
'html',
|
||||
'java',
|
||||
'javascript',
|
||||
'js',
|
||||
'json',
|
||||
'jsx',
|
||||
'markdown',
|
||||
'md',
|
||||
'php',
|
||||
'python',
|
||||
'py',
|
||||
'ruby',
|
||||
'rust',
|
||||
'rs',
|
||||
'sh',
|
||||
'sql',
|
||||
'swift',
|
||||
'tsx',
|
||||
'ts',
|
||||
'typescript',
|
||||
'xml',
|
||||
'yaml',
|
||||
'yml'
|
||||
])
|
||||
|
||||
interface CodeSignals {
|
||||
bulletLines: number
|
||||
codeSignals: number
|
||||
hasMarkdown: boolean
|
||||
proseLines: number
|
||||
trimmed: string
|
||||
}
|
||||
|
||||
export function sanitizeLanguageTag(tag: string): string {
|
||||
const trimmed = tag.trim()
|
||||
const first = trimmed.split(/\s/, 1)[0] || ''
|
||||
|
||||
return VALID_LANGUAGE_RE.test(first) && first.length <= 16 ? first.toLowerCase() : ''
|
||||
}
|
||||
|
||||
function proseLineCount(body: string): number {
|
||||
return body
|
||||
.split('\n')
|
||||
.filter(line => {
|
||||
const trimmed = line.trim()
|
||||
|
||||
return Boolean(trimmed) && /^[A-Za-z0-9"'`*-]/.test(trimmed)
|
||||
})
|
||||
.length
|
||||
}
|
||||
|
||||
const CODE_SIGNAL_RE = [
|
||||
/(^|\s)(const|let|var|function|class|import|export|return|if|for|while|switch)\b/gim,
|
||||
/=>|==|===|!=|!==|\{|\}|;|<\/?[a-z][^>]*>/gi,
|
||||
/^\s*(#include|SELECT|INSERT|UPDATE|DELETE|CREATE|DROP)\b/gim
|
||||
]
|
||||
|
||||
function codeSignalCount(body: string): number {
|
||||
return CODE_SIGNAL_RE.reduce((total, pattern) => total + (body.match(pattern)?.length ?? 0), 0)
|
||||
}
|
||||
|
||||
function codeSignals(body: string): CodeSignals {
|
||||
const trimmed = body.trim()
|
||||
const markdownSignals = (trimmed.match(/\*\*[^*]+\*\*/g) || []).length + (trimmed.match(/`[^`\n]+`/g) || []).length
|
||||
|
||||
return {
|
||||
bulletLines: (trimmed.match(/^\s*[-*]\s+\S+/gm) || []).length,
|
||||
codeSignals: codeSignalCount(trimmed),
|
||||
hasMarkdown: markdownSignals > 0,
|
||||
proseLines: proseLineCount(trimmed),
|
||||
trimmed
|
||||
}
|
||||
}
|
||||
|
||||
export function isLikelyProseFence(info: string, body: string): boolean {
|
||||
const trimmedInfo = info.trim()
|
||||
const rawInfo = trimmedInfo.toLowerCase()
|
||||
const language = sanitizeLanguageTag(info)
|
||||
const infoToken = trimmedInfo.split(/\s+/, 1)[0] || ''
|
||||
const hasInfoTail = Boolean(trimmedInfo) && trimmedInfo !== infoToken
|
||||
|
||||
if (/^[-*+]\s/.test(rawInfo) || /^https?:\/\//.test(rawInfo)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const signals = codeSignals(body)
|
||||
|
||||
if (!signals.trimmed) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (hasInfoTail && signals.codeSignals <= 2 && (signals.proseLines >= 2 || signals.bulletLines >= 1)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!NON_CODE_FENCE_LANGUAGES.has(language)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
(signals.bulletLines >= 2 && signals.hasMarkdown && signals.codeSignals <= 2) ||
|
||||
(signals.proseLines >= 3 && signals.codeSignals === 0)
|
||||
)
|
||||
}
|
||||
|
||||
export function isLikelyProseCodeBlock(language: string | undefined, code: string | undefined): boolean {
|
||||
const cleanLanguage = sanitizeLanguageTag(language || '')
|
||||
const signals = codeSignals(code || '')
|
||||
|
||||
if (!signals.trimmed || signals.codeSignals >= 3) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (signals.bulletLines >= 1 && (signals.hasMarkdown || signals.proseLines >= 2)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (NON_CODE_FENCE_LANGUAGES.has(cleanLanguage)) {
|
||||
return signals.proseLines >= 3 && signals.codeSignals === 0
|
||||
}
|
||||
|
||||
return !COMMON_CODE_LANGUAGES.has(cleanLanguage) && signals.proseLines >= 2 && signals.codeSignals <= 1
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
export type MediaKind = 'audio' | 'image' | 'video' | 'file'
|
||||
|
||||
interface MediaInfo {
|
||||
kind: MediaKind
|
||||
mime: string
|
||||
}
|
||||
|
||||
const MEDIA_BY_EXT: Record<string, MediaInfo> = {
|
||||
avi: { kind: 'video', mime: 'video/x-msvideo' },
|
||||
bmp: { kind: 'image', mime: 'image/bmp' },
|
||||
flac: { kind: 'audio', mime: 'audio/flac' },
|
||||
gif: { kind: 'image', mime: 'image/gif' },
|
||||
jpeg: { kind: 'image', mime: 'image/jpeg' },
|
||||
jpg: { kind: 'image', mime: 'image/jpeg' },
|
||||
m4a: { kind: 'audio', mime: 'audio/mp4' },
|
||||
mkv: { kind: 'video', mime: 'video/x-matroska' },
|
||||
mov: { kind: 'video', mime: 'video/quicktime' },
|
||||
mp3: { kind: 'audio', mime: 'audio/mpeg' },
|
||||
mp4: { kind: 'video', mime: 'video/mp4' },
|
||||
ogg: { kind: 'audio', mime: 'audio/ogg' },
|
||||
opus: { kind: 'audio', mime: 'audio/ogg; codecs=opus' },
|
||||
png: { kind: 'image', mime: 'image/png' },
|
||||
svg: { kind: 'image', mime: 'image/svg+xml' },
|
||||
wav: { kind: 'audio', mime: 'audio/wav' },
|
||||
webm: { kind: 'video', mime: 'video/webm' },
|
||||
webp: { kind: 'image', mime: 'image/webp' }
|
||||
}
|
||||
|
||||
function mediaInfo(path: string): MediaInfo | undefined {
|
||||
const ext = path.split(/[?#]/, 1)[0]?.split('.').pop()?.toLowerCase()
|
||||
|
||||
return ext ? MEDIA_BY_EXT[ext] : undefined
|
||||
}
|
||||
|
||||
export function mediaKind(path: string): MediaKind {
|
||||
return mediaInfo(path)?.kind ?? 'file'
|
||||
}
|
||||
|
||||
export function mediaMime(path: string): string {
|
||||
return mediaInfo(path)?.mime ?? 'application/octet-stream'
|
||||
}
|
||||
|
||||
export function mediaName(path: string): string {
|
||||
try {
|
||||
const url = new URL(path)
|
||||
|
||||
return url.pathname.split('/').filter(Boolean).pop() || path
|
||||
} catch {
|
||||
return path.split(/[\\/]/).filter(Boolean).pop() || path
|
||||
}
|
||||
}
|
||||
|
||||
export function mediaMarkdownHref(path: string): string {
|
||||
return `#media:${encodeURIComponent(path)}`
|
||||
}
|
||||
|
||||
export function mediaExternalUrl(path: string): string {
|
||||
return /^(?:https?|file):/i.test(path) ? path : `file://${path}`
|
||||
}
|
||||
|
||||
export function mediaPathFromMarkdownHref(href?: string): string | null {
|
||||
if (!href?.startsWith('#media:')) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return decodeURIComponent(href.slice('#media:'.length))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function filePathFromMediaPath(path: string): string {
|
||||
if (!path.startsWith('file:')) {
|
||||
return path
|
||||
}
|
||||
|
||||
try {
|
||||
return decodeURIComponent(new URL(path).pathname)
|
||||
} catch {
|
||||
return path.replace(/^file:\/\//, '')
|
||||
}
|
||||
}
|
||||
|
||||
export function mediaDisplayLabel(path: string): string {
|
||||
const escaped = mediaName(path).replace(/[[\]\\]/g, '\\$&')
|
||||
const kind = mediaKind(path)
|
||||
|
||||
return `${kind[0].toUpperCase()}${kind.slice(1)}: ${escaped}`
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
extractPreviewCandidates,
|
||||
extractPreviewTargets,
|
||||
isLikelyPreviewCandidate,
|
||||
previewTargetFromMarkdownHref,
|
||||
renderPreviewTargets,
|
||||
stripPreviewTargets
|
||||
} from './preview-targets'
|
||||
|
||||
describe('preview target detection', () => {
|
||||
it('extracts local server URLs and html files', () => {
|
||||
expect(
|
||||
extractPreviewCandidates(
|
||||
'Open http://localhost:5173/ and /tmp/mycelium-bunnies/index.html, not https://example.com/app.'
|
||||
)
|
||||
).toEqual(['http://localhost:5173/', '/tmp/mycelium-bunnies/index.html'])
|
||||
})
|
||||
|
||||
it('accepts relative html files and file URLs', () => {
|
||||
expect(extractPreviewCandidates('Wrote ./dist/index.html and file:///tmp/demo.html.')).toEqual([
|
||||
'./dist/index.html',
|
||||
'file:///tmp/demo.html'
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores remote web URLs', () => {
|
||||
expect(isLikelyPreviewCandidate('https://example.com/demo')).toBe(false)
|
||||
expect(isLikelyPreviewCandidate('http://127.0.0.1:3000')).toBe(true)
|
||||
})
|
||||
|
||||
it('renders previewable paths as markdown links', () => {
|
||||
expect(renderPreviewTargets('ready\n/tmp/mycelium-bunnies.html\nopen it')).toBe(
|
||||
'ready\n[Preview: mycelium-bunnies.html](#preview/%2Ftmp%2Fmycelium-bunnies.html)\nopen it'
|
||||
)
|
||||
})
|
||||
|
||||
it('decodes preview markdown hrefs', () => {
|
||||
expect(previewTargetFromMarkdownHref('#preview/%2Ftmp%2Fdemo.html')).toBe('/tmp/demo.html')
|
||||
expect(previewTargetFromMarkdownHref('#preview:%2Ftmp%2Fdemo.html')).toBe('/tmp/demo.html')
|
||||
expect(previewTargetFromMarkdownHref('#media:%2Ftmp%2Fdemo.mp4')).toBeNull()
|
||||
})
|
||||
|
||||
it('extracts preview targets from already-rendered preview markers', () => {
|
||||
expect(extractPreviewTargets('[Preview: demo.html](#preview:%2Ftmp%2Fdemo.html)')).toEqual(['/tmp/demo.html'])
|
||||
})
|
||||
|
||||
it('strips preview targets from visible assistant text', () => {
|
||||
expect(stripPreviewTargets('ready\n/tmp/mycelium-bunnies.html\nopen it')).toBe('ready\nopen it')
|
||||
expect(stripPreviewTargets('[Preview: demo.html](#preview:%2Ftmp%2Fdemo.html)\nopen it')).toBe('open it')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,216 @@
|
||||
const LOCAL_HOSTS = new Set(['0.0.0.0', '127.0.0.1', '::1', '[::1]', 'localhost'])
|
||||
const HTML_EXT_RE = /\.html?(?:[?#].*)?$/i
|
||||
const URL_RE = /\bhttps?:\/\/[^\s<>"'`)\]]+/gi
|
||||
const FILE_URL_RE = /\bfile:\/\/[^\s<>"'`)\]]+/gi
|
||||
const POSIX_HTML_PATH_RE = /(?:^|[\s("'`])(?<path>\/[^\s<>"'`]*?\.html?)(?:[),.;:!?]*)(?=$|[\s)"'`])/gi
|
||||
const RELATIVE_HTML_PATH_RE = /(?:^|[\s("'`])(?<path>\.{1,2}\/[^\s<>"'`]*?\.html?)(?:[),.;:!?]*)(?=$|[\s)"'`])/gi
|
||||
const PREVIEW_MARKDOWN_RE = /\[Preview:[^\]]+\]\((?<href>#preview[:/][^)]+)\)/gi
|
||||
|
||||
interface PreviewCandidateMatch {
|
||||
end: number
|
||||
index: number
|
||||
value: string
|
||||
}
|
||||
|
||||
function stripTrailingPunctuation(value: string): string {
|
||||
return value.replace(/[),.;:!?]+$/, '')
|
||||
}
|
||||
|
||||
function isLocalPreviewUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return LOCAL_HOSTS.has(url.hostname.toLowerCase())
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function isLikelyPreviewCandidate(value: string): boolean {
|
||||
const trimmed = stripTrailingPunctuation(value.trim())
|
||||
|
||||
return trimmed.startsWith('file://') || HTML_EXT_RE.test(trimmed) || isLocalPreviewUrl(trimmed)
|
||||
}
|
||||
|
||||
function collectPreviewMatches(text: string): PreviewCandidateMatch[] {
|
||||
const matches: PreviewCandidateMatch[] = []
|
||||
|
||||
const collect = (index: number | undefined, raw: string, value = raw) => {
|
||||
if (index === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const candidate = stripTrailingPunctuation(value.trim())
|
||||
|
||||
if (!candidate || !isLikelyPreviewCandidate(candidate)) {
|
||||
return
|
||||
}
|
||||
|
||||
const offset = raw.indexOf(value)
|
||||
const start = index + Math.max(0, offset)
|
||||
|
||||
matches.push({
|
||||
end: start + candidate.length,
|
||||
index: start,
|
||||
value: candidate
|
||||
})
|
||||
}
|
||||
|
||||
for (const match of text.matchAll(URL_RE)) {
|
||||
collect(match.index, match[0])
|
||||
}
|
||||
|
||||
for (const match of text.matchAll(FILE_URL_RE)) {
|
||||
collect(match.index, match[0])
|
||||
}
|
||||
|
||||
for (const match of text.matchAll(POSIX_HTML_PATH_RE)) {
|
||||
collect(match.index, match[0], match.groups?.path || '')
|
||||
}
|
||||
|
||||
for (const match of text.matchAll(RELATIVE_HTML_PATH_RE)) {
|
||||
collect(match.index, match[0], match.groups?.path || '')
|
||||
}
|
||||
|
||||
return matches.sort((a, b) => a.index - b.index)
|
||||
}
|
||||
|
||||
export function extractPreviewCandidates(text: string): string[] {
|
||||
const candidates: string[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
const push = (value: string) => {
|
||||
const candidate = stripTrailingPunctuation(value.trim())
|
||||
|
||||
if (!candidate || seen.has(candidate) || !isLikelyPreviewCandidate(candidate)) {
|
||||
return
|
||||
}
|
||||
|
||||
seen.add(candidate)
|
||||
candidates.push(candidate)
|
||||
}
|
||||
|
||||
for (const match of collectPreviewMatches(text)) {
|
||||
push(match.value)
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
export function stripPreviewTargets(text: string): string {
|
||||
const matches = collectPreviewMatches(text)
|
||||
let cursor = 0
|
||||
let stripped = ''
|
||||
|
||||
for (const match of matches) {
|
||||
if (match.index < cursor) {
|
||||
continue
|
||||
}
|
||||
|
||||
const lineStart = text.lastIndexOf('\n', Math.max(0, match.index - 1)) + 1
|
||||
const nextLineBreak = text.indexOf('\n', match.end)
|
||||
const lineEnd = nextLineBreak === -1 ? text.length : nextLineBreak + 1
|
||||
const beforeOnLine = text.slice(lineStart, match.index)
|
||||
const afterOnLine = text.slice(match.end, nextLineBreak === -1 ? text.length : nextLineBreak)
|
||||
|
||||
if (lineStart >= cursor && !beforeOnLine.trim() && !afterOnLine.trim()) {
|
||||
stripped += text.slice(cursor, lineStart)
|
||||
cursor = lineEnd
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
stripped += text.slice(cursor, match.index)
|
||||
cursor = match.end
|
||||
}
|
||||
|
||||
stripped += text.slice(cursor)
|
||||
|
||||
return stripped
|
||||
.replace(PREVIEW_MARKDOWN_RE, '')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function extractPreviewTargets(text: string): string[] {
|
||||
const targets = extractPreviewCandidates(text)
|
||||
const seen = new Set(targets)
|
||||
|
||||
for (const match of text.matchAll(PREVIEW_MARKDOWN_RE)) {
|
||||
const target = previewTargetFromMarkdownHref(match.groups?.href)
|
||||
|
||||
if (target && !seen.has(target)) {
|
||||
seen.add(target)
|
||||
targets.push(target)
|
||||
}
|
||||
}
|
||||
|
||||
return targets
|
||||
}
|
||||
|
||||
export function previewMarkdownHref(target: string): string {
|
||||
return `#preview/${encodeURIComponent(target)}`
|
||||
}
|
||||
|
||||
export function previewTargetFromMarkdownHref(href?: string): string | null {
|
||||
if (!href?.startsWith('#preview:') && !href?.startsWith('#preview/')) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return decodeURIComponent(href.slice('#preview'.length + 1))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function previewName(target: string): string {
|
||||
try {
|
||||
const url = new URL(target)
|
||||
|
||||
if (url.protocol === 'file:') {
|
||||
return decodeURIComponent(url.pathname).split(/[\\/]/).filter(Boolean).pop() || target
|
||||
}
|
||||
|
||||
const file = url.pathname.split('/').filter(Boolean).pop()
|
||||
|
||||
return file || url.host
|
||||
} catch {
|
||||
return target.split(/[\\/]/).filter(Boolean).pop() || target
|
||||
}
|
||||
}
|
||||
|
||||
export function previewDisplayLabel(target: string): string {
|
||||
const escaped = previewName(target).replace(/[[\]\\]/g, '\\$&')
|
||||
|
||||
return `Preview: ${escaped}`
|
||||
}
|
||||
|
||||
function previewLink(value: string): string {
|
||||
return `[${previewDisplayLabel(value)}](${previewMarkdownHref(value)})`
|
||||
}
|
||||
|
||||
export function renderPreviewTargets(text: string): string {
|
||||
const matches = collectPreviewMatches(text)
|
||||
let cursor = 0
|
||||
let rendered = ''
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const match of matches) {
|
||||
if (match.index < cursor || seen.has(match.value)) {
|
||||
continue
|
||||
}
|
||||
|
||||
rendered += text.slice(cursor, match.index)
|
||||
rendered += previewLink(match.value)
|
||||
cursor = match.end
|
||||
seen.add(match.value)
|
||||
}
|
||||
|
||||
return rendered + text.slice(cursor)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
export interface ClarifyRequest {
|
||||
requestId: string
|
||||
question: string
|
||||
choices: string[] | null
|
||||
sessionId: string | null
|
||||
}
|
||||
|
||||
// Holds the request_id (and metadata) for the most recent in-flight
|
||||
// clarify call. The inline ClarifyTool component (rendered inside the
|
||||
// assistant message stream) reads this to know which request_id to send
|
||||
// back over `clarify.respond`.
|
||||
export const $clarifyRequest = atom<ClarifyRequest | null>(null)
|
||||
|
||||
export function setClarifyRequest(request: ClarifyRequest): void {
|
||||
$clarifyRequest.set(request)
|
||||
}
|
||||
|
||||
export function clearClarifyRequest(requestId?: string): void {
|
||||
const current = $clarifyRequest.get()
|
||||
|
||||
if (!current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (requestId && current.requestId !== requestId) {
|
||||
return
|
||||
}
|
||||
|
||||
$clarifyRequest.set(null)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export interface ComposerAttachment {
|
||||
refText?: string
|
||||
previewUrl?: string
|
||||
path?: string
|
||||
attachedSessionId?: string
|
||||
}
|
||||
|
||||
export const $composerDraft = atom('')
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
|
||||
// The active gateway instance, exposed for inline message-stream components
|
||||
// (e.g. inline ClarifyTool) that need to call gateway methods without having
|
||||
// the instance threaded down through props from `ChatView`.
|
||||
export const $gateway = atom<HermesGateway | null>(null)
|
||||
|
||||
export function setGateway(gateway: HermesGateway | null): void {
|
||||
if ($gateway.get() === gateway) {
|
||||
return
|
||||
}
|
||||
|
||||
$gateway.set(gateway)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
export interface PreviewTarget {
|
||||
kind: 'file' | 'url'
|
||||
label: string
|
||||
source: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export const $previewTarget = atom<PreviewTarget | null>(null)
|
||||
|
||||
export function setPreviewTarget(target: PreviewTarget | null) {
|
||||
$previewTarget.set(target)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
const $toolDiffs = atom<Record<string, string>>({})
|
||||
|
||||
export function recordToolDiff(toolCallId: string, diff: string) {
|
||||
if (!toolCallId || !diff) {
|
||||
return
|
||||
}
|
||||
|
||||
const current = $toolDiffs.get()
|
||||
|
||||
if (current[toolCallId] === diff) {
|
||||
return
|
||||
}
|
||||
|
||||
$toolDiffs.set({ ...current, [toolCallId]: diff })
|
||||
}
|
||||
|
||||
export function getToolDiff(toolCallId: string): string {
|
||||
return toolCallId ? $toolDiffs.get()[toolCallId] || '' : ''
|
||||
}
|
||||
|
||||
export const $toolInlineDiffs = $toolDiffs
|
||||
+75
-14
@@ -118,14 +118,29 @@
|
||||
--radius: 0.75rem;
|
||||
/* Thread ViewportFooter — gap from last msg → composer (scroll only) */
|
||||
--thread-composer-clearance: 8rem;
|
||||
/* Composer shell — gap under bar to chat pane bottom */
|
||||
/* Composer geometry — single source of truth for shell + controls. */
|
||||
--composer-shell-pad-block-end: 2.5rem;
|
||||
--composer-inline-clearance: clamp(1rem, 5vw, 4rem);
|
||||
--composer-min-width: 34rem;
|
||||
--composer-target-width: 68%;
|
||||
--composer-max-width: 56rem;
|
||||
--composer-control-size: 2rem;
|
||||
--composer-control-gap: 0.375rem;
|
||||
--composer-row-gap: 0.375rem;
|
||||
--composer-surface-pad-x: 0.5rem;
|
||||
--composer-surface-pad-y: 0.375rem;
|
||||
--composer-input-min-height: 2rem;
|
||||
--composer-input-max-height: 9.375rem;
|
||||
--composer-input-inline-min-width: 8rem;
|
||||
--composer-fallback-height: 2.75rem;
|
||||
--vsq: min(0.5vh, 0.5vw);
|
||||
--image-preview-max-width: 34rem;
|
||||
--image-preview-height: clamp(16.25rem, calc(var(--vsq) * 100), 26.25rem);
|
||||
|
||||
/* Sidebar layout */
|
||||
/* Shell layout */
|
||||
--sidebar-width: 14rem;
|
||||
--chat-min-width: 24rem;
|
||||
--shell-gap: 0.625rem;
|
||||
--titlebar-control-size: 1.25rem;
|
||||
--titlebar-control-height: 1.375rem;
|
||||
|
||||
@@ -190,7 +205,9 @@ input,
|
||||
textarea,
|
||||
[contenteditable]:not([contenteditable='false']),
|
||||
[data-slot='aui_user-message-root'],
|
||||
[data-slot='aui_assistant-message-content'] {
|
||||
[data-slot='aui_assistant-message-content'],
|
||||
[data-selectable-text='true'],
|
||||
[data-selectable-text='true'] * {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
@@ -247,17 +264,23 @@ canvas {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@supports (content-visibility: auto) {
|
||||
[data-slot='aui_user-message-root'],
|
||||
[data-slot='aui_assistant-message-root'] {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 10rem;
|
||||
}
|
||||
|
||||
[data-slot='aui_user-message-root'] {
|
||||
contain-intrinsic-size: auto 4rem;
|
||||
}
|
||||
}
|
||||
/*
|
||||
* Previously applied `content-visibility: auto` + `contain-intrinsic-size` to
|
||||
* message roots for virtualization-lite perf. REMOVED because it interacts
|
||||
* badly with a stick-to-bottom scroller:
|
||||
*
|
||||
* 1. Session loads, messages render at their real heights.
|
||||
* 2. Scroller pins to `scrollHeight - clientHeight`.
|
||||
* 3. A few seconds later the browser's content-visibility heuristic kicks
|
||||
* in for off-screen messages and collapses them to the 10rem intrinsic
|
||||
* placeholder — shrinking total scrollHeight by a large margin.
|
||||
* 4. The browser clamps scrollTop to the new (smaller) scrollHeight, and
|
||||
* the user's viewport "scrolls up by a weird %" a few seconds after
|
||||
* the session loads. Feels like a scroll bug; actually CSS.
|
||||
*
|
||||
* If we want perf here again, the correct path is a real virtualizer (e.g.
|
||||
* react-virtuoso) with stable item sizing — not a CSS heuristic.
|
||||
*/
|
||||
|
||||
.aui-md img {
|
||||
display: block;
|
||||
@@ -281,6 +304,25 @@ canvas {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.hermes-preview-webview {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
[data-slot='composer-root'] {
|
||||
width: clamp(var(--composer-min-width), var(--composer-target-width), var(--composer-max-width));
|
||||
max-width: calc(100% - var(--composer-inline-clearance));
|
||||
}
|
||||
|
||||
/* Thread scroll container (from use-stick-to-bottom).
|
||||
* `scroll-behavior: auto` is critical: use-stick-to-bottom writes scrollTop
|
||||
* directly and temporarily forces this to 'auto' during its programmatic
|
||||
* scrolls, but we default it to 'auto' anyway so no smooth-scroll fight can
|
||||
* ever happen. We leave overflow-anchor at the browser default ('auto'); the
|
||||
* library handles follow-mode imperatively. */
|
||||
[data-slot='aui_thread-content'] {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
.aui-md a,
|
||||
.aui-md code {
|
||||
overflow-wrap: anywhere;
|
||||
@@ -314,6 +356,25 @@ canvas {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
/* Streamdown wraps every fenced block in <div data-streamdown="code-block">
|
||||
* with `flex flex-col gap-2 p-2 border bg-sidebar rounded-xl my-4`. Our own
|
||||
* CodeHeader + SyntaxHighlighter already supply the chrome, so undo the
|
||||
* library's wrapper to keep the header flush with the code body. */
|
||||
.aui-md [data-streamdown='code-block'],
|
||||
[data-streamdown='code-block'] {
|
||||
padding: 0 !important;
|
||||
gap: 0 !important;
|
||||
border: 0 !important;
|
||||
background: transparent !important;
|
||||
border-radius: 0 !important;
|
||||
margin: 1rem 0 !important;
|
||||
}
|
||||
|
||||
.aui-md [data-streamdown='code-block'] > *,
|
||||
[data-streamdown='code-block'] > * {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.aui-md h1 {
|
||||
margin: 1.6rem 0 0.55rem;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
DEFAULT_LAYOUT,
|
||||
DEFAULT_TYPOGRAPHY,
|
||||
defaultTheme,
|
||||
nousLightTheme
|
||||
nousLightTheme,
|
||||
nousTheme
|
||||
} from './presets'
|
||||
import type { DesktopTheme, DesktopThemeColors, ThemeDensity } from './types'
|
||||
|
||||
@@ -37,6 +38,10 @@ const DENSITY_MULTIPLIERS: Record<ThemeDensity, string> = {
|
||||
|
||||
const INJECTED_FONT_URLS = new Set<string>()
|
||||
const SKIN_THEME_LIST = BUILTIN_THEME_LIST.filter(t => t.name !== 'nous-light')
|
||||
const NOUS_FONT_FAMILY_FALLBACK = {
|
||||
fontSans: nousTheme.typography?.fontSans ?? DEFAULT_TYPOGRAPHY.fontSans,
|
||||
fontMono: nousTheme.typography?.fontMono ?? DEFAULT_TYPOGRAPHY.fontMono
|
||||
}
|
||||
|
||||
function effectiveMode(mode: ThemeMode, systemDark = matchesQuery('(prefers-color-scheme: dark)')): 'light' | 'dark' {
|
||||
return mode === 'system' ? (systemDark ? 'dark' : 'light') : mode
|
||||
@@ -101,8 +106,21 @@ function fontOnly(theme: DesktopTheme): DesktopTheme['typography'] {
|
||||
}
|
||||
|
||||
const { fontSans, fontMono, fontUrl } = theme.typography
|
||||
const typography: DesktopTheme['typography'] = {}
|
||||
|
||||
return { fontSans, fontMono, fontUrl }
|
||||
if (fontSans) {
|
||||
typography.fontSans = fontSans
|
||||
}
|
||||
|
||||
if (fontMono) {
|
||||
typography.fontMono = fontMono
|
||||
}
|
||||
|
||||
if (fontUrl) {
|
||||
typography.fontUrl = fontUrl
|
||||
}
|
||||
|
||||
return typography
|
||||
}
|
||||
|
||||
function lightColors(seed: DesktopTheme, skinName: string): DesktopThemeColors {
|
||||
@@ -200,7 +218,7 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
|
||||
}
|
||||
|
||||
const root = document.documentElement
|
||||
const typo = { ...DEFAULT_TYPOGRAPHY, ...theme.typography }
|
||||
const typo = { ...DEFAULT_TYPOGRAPHY, ...NOUS_FONT_FAMILY_FALLBACK, ...theme.typography }
|
||||
const layout = { ...DEFAULT_LAYOUT, ...theme.layout }
|
||||
const c = theme.colors
|
||||
|
||||
|
||||
@@ -133,14 +133,14 @@ export interface SessionInfo {
|
||||
|
||||
export interface SessionMessage {
|
||||
codex_reasoning_items?: unknown
|
||||
content: null | string
|
||||
context?: string
|
||||
content: unknown
|
||||
context?: unknown
|
||||
name?: string
|
||||
reasoning?: null | string
|
||||
reasoning_content?: null | string
|
||||
reasoning_details?: unknown
|
||||
role: 'assistant' | 'system' | 'tool' | 'user'
|
||||
text?: string
|
||||
text?: unknown
|
||||
timestamp?: number
|
||||
tool_call_id?: null | string
|
||||
tool_calls?: unknown
|
||||
|
||||
Reference in New Issue
Block a user