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
|
||||
}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user