chore: uptick

This commit is contained in:
Brooklyn Nicholson
2026-05-02 03:19:39 -05:00
parent 420f68e4e2
commit db884f4646
240 changed files with 25206 additions and 3155 deletions
@@ -0,0 +1,284 @@
'use client'
import { type ToolCallMessagePartProps } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { HelpCircle, Loader2, PencilLine } from 'lucide-react'
import { type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useState } from 'react'
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $clarifyRequest, clearClarifyRequest } from '@/store/clarify'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
interface ClarifyArgs {
question?: string
choices?: string[] | null
}
function readClarifyArgs(args: unknown): ClarifyArgs {
if (!args || typeof args !== 'object') {
return {}
}
const row = args as Record<string, unknown>
const choices = Array.isArray(row.choices) ? row.choices.filter((c): c is string => typeof c === 'string') : null
return {
question: typeof row.question === 'string' ? row.question : undefined,
choices: choices && choices.length > 0 ? choices : null
}
}
export const ClarifyTool = (props: ToolCallMessagePartProps) => {
const isPending = props.result === undefined
// Once Hermes records an answer, fall back to the standard tool block so
// the past Q/A renders consistently with every other tool in the thread.
if (!isPending) {
return <ToolFallback {...props} />
}
return <ClarifyToolPending {...props} />
}
function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
const request = useStore($clarifyRequest)
const gateway = useStore($gateway)
const fromArgs = useMemo(() => readClarifyArgs(args), [args])
const matchingRequest = useMemo(() => {
if (!request) {
return null
}
if (fromArgs.question && request.question && fromArgs.question !== request.question) {
return null
}
return request
}, [fromArgs.question, request])
const question = fromArgs.question || matchingRequest?.question || ''
const choices = useMemo(
() => fromArgs.choices ?? matchingRequest?.choices ?? [],
[fromArgs.choices, matchingRequest?.choices]
)
const hasChoices = choices.length > 0
const [typing, setTyping] = useState(false)
const [draft, setDraft] = useState('')
const [submitting, setSubmitting] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
// Race: tool.start fires a tick before clarify.request, so request_id
// arrives slightly after the tool block mounts. Show the question (from
// args) but disable submit until we have the request id from the gateway.
const ready = Boolean(matchingRequest?.requestId)
const respond = useCallback(
async (answer: string) => {
if (!ready || !matchingRequest) {
notifyError(new Error('Clarify request is not ready yet'), 'Could not send clarify response')
return
}
if (!gateway) {
notifyError(new Error('Hermes gateway is not connected'), 'Could not send clarify response')
return
}
setSubmitting(true)
try {
await gateway.request<{ ok?: boolean }>('clarify.respond', {
request_id: matchingRequest.requestId,
answer
})
triggerHaptic('submit')
clearClarifyRequest(matchingRequest.requestId)
// The matching tool.complete will land shortly after, swapping this
// panel for the ToolFallback view above.
} catch (error) {
notifyError(error, 'Could not send clarify response')
setSubmitting(false)
}
},
[gateway, matchingRequest, ready]
)
const handleTextareaKey = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
const trimmed = draft.trim()
if (trimmed) {
void respond(trimmed)
}
}
},
[draft, respond]
)
const handleSubmitFreeform = useCallback(
(event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
const trimmed = draft.trim()
if (trimmed) {
void respond(trimmed)
}
},
[draft, respond]
)
const handleChoiceKey = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
if (typing || submitting) {
return
}
const numeric = Number.parseInt(event.key, 10)
if (Number.isFinite(numeric) && numeric >= 1 && numeric <= choices.length) {
event.preventDefault()
void respond(choices[numeric - 1]!)
}
},
[choices, respond, submitting, typing]
)
return (
<div
className={cn(
'mb-3 mt-2 grid gap-3 rounded-xl border border-border/70 bg-card/40 px-4 py-3.5 text-sm',
'shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_3%,transparent)]'
)}
data-slot="clarify-inline"
>
<div className="flex items-start gap-2.5">
<span
aria-hidden
className="mt-0.5 grid size-6 shrink-0 place-items-center rounded-md bg-[color-mix(in_srgb,var(--dt-primary)_14%,transparent)] text-primary ring-1 ring-inset ring-primary/15"
>
<HelpCircle className="size-3.5" />
</span>
<div className="grid flex-1 gap-0.5">
<span className="text-[0.6875rem] font-medium uppercase tracking-wide text-muted-foreground/85">
Hermes is asking
</span>
<span className="whitespace-pre-wrap leading-snug text-foreground">
{question || <em className="text-muted-foreground/70">Loading question</em>}
</span>
</div>
</div>
{!typing && hasChoices && (
<div className="grid gap-1.5" onKeyDown={handleChoiceKey} role="group">
{choices.map((choice, index) => (
<button
className={cn(
'group/choice flex w-full items-center gap-3 rounded-lg border border-border/70 bg-background/60 px-3 py-2 text-left text-sm text-foreground/95',
'transition-colors hover:border-border hover:bg-accent/60 disabled:cursor-not-allowed disabled:opacity-55'
)}
data-choice
disabled={!ready || submitting}
key={`${index}-${choice}`}
onClick={() => void respond(choice)}
type="button"
>
<span className="grid size-5 shrink-0 place-items-center rounded-md bg-muted text-[0.6875rem] font-mono tabular-nums text-muted-foreground group-hover/choice:bg-background">
{index + 1}
</span>
<span className="flex-1 wrap-anywhere">{choice}</span>
</button>
))}
<button
className={cn(
'flex w-full items-center gap-3 rounded-lg border border-dashed border-border/60 bg-transparent px-3 py-2 text-left text-sm text-muted-foreground',
'transition-colors hover:border-border hover:bg-accent/40 hover:text-foreground'
)}
disabled={submitting}
onClick={() => {
setTyping(true)
window.setTimeout(() => textareaRef.current?.focus({ preventScroll: true }), 0)
}}
type="button"
>
<span aria-hidden className="grid size-5 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground">
<PencilLine className="size-3" />
</span>
<span className="flex-1">Other (type your answer)</span>
</button>
</div>
)}
{(typing || !hasChoices) && (
<form className="grid gap-2" onSubmit={handleSubmitFreeform}>
<Textarea
className="min-h-20 resize-y rounded-lg border-border/70 bg-background/60 text-sm"
disabled={submitting}
onChange={event => setDraft(event.target.value)}
onKeyDown={handleTextareaKey}
placeholder="Type your answer…"
ref={textareaRef}
value={draft}
/>
<div className="flex items-center justify-between gap-2">
<span className="text-[0.6875rem] text-muted-foreground/85">/Ctrl + Enter to send</span>
<div className="flex items-center gap-1.5">
{hasChoices && (
<Button
disabled={submitting}
onClick={() => {
setTyping(false)
setDraft('')
}}
size="sm"
type="button"
variant="ghost"
>
Back
</Button>
)}
<Button
disabled={!ready || submitting}
onClick={() => void respond('')}
size="sm"
type="button"
variant="ghost"
>
Skip
</Button>
<Button disabled={!ready || submitting || !draft.trim()} size="sm" type="submit">
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : 'Send'}
</Button>
</div>
</div>
</form>
)}
{!typing && hasChoices && (
<div className="flex items-center justify-between text-[0.6875rem] text-muted-foreground/85">
<span>1{choices.length} to pick</span>
<button
className="bg-transparent text-muted-foreground/85 underline-offset-2 hover:text-foreground hover:underline disabled:opacity-50"
disabled={!ready || submitting}
onClick={() => void respond('')}
type="button"
>
Skip
</button>
</div>
)}
</div>
)
}
@@ -6,6 +6,8 @@ import { AtSign, FileText, FolderOpen, ImageIcon, Link as LinkIcon, Wrench } fro
import type { ComponentType, FC } from 'react'
import { Fragment, useMemo } from 'react'
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
import { extractEmbeddedImages } from '@/lib/embedded-images'
import { cn } from '@/lib/utils'
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool'] as const
@@ -188,7 +190,8 @@ function shortLabel(type: HermesRefType, id: string): string {
* Unknown directive types fall through as plain text.
*/
export const DirectiveText: TextMessagePartComponent = ({ text }: TextMessagePartProps) => {
const segments = useMemo(() => hermesDirectiveFormatter.parse(text ?? ''), [text])
const { cleanedText, images } = useMemo(() => extractEmbeddedImages(text ?? ''), [text])
const segments = useMemo(() => hermesDirectiveFormatter.parse(cleanedText), [cleanedText])
return (
<span className="whitespace-pre-line" data-slot="aui_directive-text">
@@ -199,6 +202,20 @@ export const DirectiveText: TextMessagePartComponent = ({ text }: TextMessagePar
<DirectiveChip id={segment.id} key={`m-${index}-${segment.id}`} label={segment.label} type={segment.type} />
)
)}
{images.length > 0 && (
<span className="mt-2 flex flex-wrap gap-2" data-slot="aui_embedded-images">
{images.map((src, index) => (
<ZoomableImage
alt=""
className="max-h-48 max-w-full rounded-lg border border-border/60 object-contain"
draggable={false}
key={`img-${index}`}
slot="aui_embedded-image"
src={src}
/>
))}
</span>
)}
</span>
)
}
@@ -177,7 +177,7 @@ export const Intro: FC<IntroProps> = ({ personality, seed }) => {
}, [advanceFrame, frameOffset])
return (
<div className="pointer-events-none absolute inset-0 z-1 grid place-items-center content-center px-[calc(var(--vsq)*50)] pb-32 text-center text-muted-foreground">
<div className="pointer-events-none absolute inset-0 z-1 flex flex-col items-center justify-center px-[calc(var(--vsq)*50)] text-center text-muted-foreground">
<button
aria-label="Change Hermes pose"
className="pointer-events-auto mb-5 h-56 w-64 cursor-default border-0 bg-transparent p-0"
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest'
import { preprocessMarkdown } from './markdown-text'
describe('preprocessMarkdown', () => {
it('strips inline accidental triple-backtick starts', () => {
const input = [
'Working as intended.',
"Here's your scene: ``` http://localhost:8812/",
'',
'- **Multicolored cube**',
'- **Rotates**'
].join('\n')
const output = preprocessMarkdown(input)
expect(output).not.toContain('```')
expect(output).toContain("Here's your scene:")
expect(output).not.toContain('http://localhost:8812/')
expect(output).toContain('- **Multicolored cube**')
})
it('demotes invalid fenced prose blocks with closers', () => {
const fence = '```'
const input = [
`${fence} http://localhost:8812/`,
'- **Scroll wheel** - zoom',
'- **Right-drag/pan** - disabled',
fence
].join('\n')
const output = preprocessMarkdown(input)
expect(output).not.toContain('```')
expect(output).not.toContain('http://localhost:8812/')
expect(output).toContain('- **Scroll wheel** - zoom')
})
it('demotes prose sentence masquerading as fence info', () => {
const input = ['```Heads up - a bunny got added', '- Pure white (`#ffffff`)', '- Ambient dropped to 0.18'].join('\n')
const output = preprocessMarkdown(input)
expect(output).not.toContain('```heads')
expect(output).toContain('Heads up - a bunny got added')
expect(output).toContain('- Pure white (`#ffffff`)')
})
it('keeps valid code fences intact', () => {
const fence = '```'
const input = [`${fence}ts`, 'const value = 1;', fence].join('\n')
const output = preprocessMarkdown(input)
expect(output).toContain('```ts')
expect(output).toContain('const value = 1;')
})
it('keeps dangling real code fences during streaming', () => {
const input = ['```ts', 'const value = 1;'].join('\n')
const output = preprocessMarkdown(input)
expect(output.startsWith('```ts')).toBe(true)
expect(output).toContain('const value = 1;')
})
it('demotes dangling prose fences', () => {
const input = ['```', '- Pure white (`#ffffff`)', '- Ambient dropped to 0.18'].join('\n')
const output = preprocessMarkdown(input)
expect(output).not.toContain('```')
expect(output).toContain('- Pure white (`#ffffff`)')
})
})
@@ -2,14 +2,24 @@
import { type StreamdownTextComponents, StreamdownTextPrimitive } from '@assistant-ui/react-streamdown'
import { code } from '@streamdown/code'
import { Check, Copy, Download } from 'lucide-react'
import { type ComponentProps, memo, useMemo, useState } from 'react'
import { Check, Copy } from 'lucide-react'
import { type ComponentProps, memo, useEffect, useMemo, useState } from 'react'
import { PreviewAttachment } from '@/components/assistant-ui/preview-attachment'
import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
import { triggerHaptic } from '@/lib/haptics'
import {
filePathFromMediaPath,
mediaExternalUrl,
mediaKind,
mediaMime,
mediaName,
mediaPathFromMarkdownHref
} from '@/lib/media'
import { isLikelyProseCodeBlock, isLikelyProseFence, sanitizeLanguageTag } from '@/lib/markdown-code'
import { previewTargetFromMarkdownHref, stripPreviewTargets } from '@/lib/preview-targets'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
/**
* Strip provider/model "thinking" blocks before markdown render.
@@ -18,14 +28,123 @@ import { notify, notifyError } from '@/store/notifications'
* assistant text. Proper reasoning UI uses dedicated `reasoning.*` parts.
*/
const REASONING_BLOCK_RE = /<(think|thinking|reasoning|scratchpad|analysis)>[\s\S]*?<\/\1>\s*/gi
const PREVIEW_MARKER_RE = /\[Preview:[^\]]+\]\(#preview[:/][^)]+\)/gi
function stripReasoning(text: string): string {
return text.replace(REASONING_BLOCK_RE, '')
const FENCE_LINE_RE = /^([ \t]*)(`{3,}|~{3,})([^\n]*)$/
const MIDLINE_FENCE_RE = /([^\n])```+(?=\s|$)/g
function stripMidlineFenceStarts(text: string): string {
return text.replace(MIDLINE_FENCE_RE, '$1')
}
function pushProseFence(out: string[], indent: string, info: string, lines: string[]) {
if (info) {
out.push(`${indent}${info}`.trimEnd())
}
out.push(...lines)
}
function findClosingFence(lines: string[], start: number, marker: string): number {
for (let cursor = start + 1; cursor < lines.length; cursor += 1) {
const closeMatch = (lines[cursor] || '').match(FENCE_LINE_RE)
if (!closeMatch) {
continue
}
const closeMarker = closeMatch[2] || ''
const closeInfo = (closeMatch[3] || '').trim()
if (!closeInfo && closeMarker[0] === marker[0] && closeMarker.length >= marker.length) {
return cursor
}
}
return -1
}
function normalizeFenceBlocks(text: string): string {
const sourceLines = text.split('\n')
const out: string[] = []
let index = 0
while (index < sourceLines.length) {
const line = sourceLines[index] || ''
const match = line.match(FENCE_LINE_RE)
if (!match) {
out.push(line)
index += 1
continue
}
const indent = match[1] || ''
const marker = match[2] || '```'
const infoRaw = (match[3] || '').trim()
const languageToken = infoRaw.split(/\s+/, 1)[0] || ''
const language = sanitizeLanguageTag(languageToken)
const openerValid = !infoRaw || Boolean(language)
if (!openerValid) {
out.push(`${indent}${infoRaw}`.trimEnd())
index += 1
continue
}
const closeIndex = findClosingFence(sourceLines, index, marker)
const bodyLines = sourceLines.slice(index + 1, closeIndex === -1 ? sourceLines.length : closeIndex)
const body = bodyLines.join('\n')
if (closeIndex === -1) {
if (!body.trim()) {
index += 1
continue
}
if (isLikelyProseFence(infoRaw, body)) {
pushProseFence(out, indent, infoRaw, bodyLines)
} else {
out.push(`${indent}${marker}${language}`)
out.push(...bodyLines)
}
break
}
if (isLikelyProseFence(infoRaw, body)) {
pushProseFence(out, indent, infoRaw, bodyLines)
index = closeIndex + 1
continue
}
out.push(`${indent}${marker}${language}`)
out.push(...bodyLines)
out.push(`${indent}${marker}`)
index = closeIndex + 1
}
return out.join('\n')
}
export function preprocessMarkdown(text: string): string {
const cleaned = text.replace(REASONING_BLOCK_RE, '').replace(PREVIEW_MARKER_RE, '')
const normalizedFences = normalizeFenceBlocks(stripMidlineFenceStarts(cleaned))
return normalizedFences
.split(/((?:```|~~~)[\s\S]*?(?:```|~~~))/g)
.map(part => (/^(?:```|~~~)/.test(part) ? part : stripPreviewTargets(part)))
.join('')
.replace(/[ \t]+\n/g, '\n')
}
function CodeHeader({ language, code }: { language?: string; code?: string }) {
const [copied, setCopied] = useState(false)
if (isLikelyProseCodeBlock(language, code)) {
return null
}
async function handleCopy() {
if (!code) {
return
@@ -46,11 +165,12 @@ function CodeHeader({ language, code }: { language?: string; code?: string }) {
}
}
const label = language && language !== 'unknown' ? language : 'code'
const cleanLanguage = sanitizeLanguageTag(language || '')
const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : ''
return (
<div className="mt-4 flex items-center justify-between gap-2 rounded-t-md border border-b-0 border-border bg-muted/60 px-3 py-1.5 text-xs text-muted-foreground">
<span className="font-mono uppercase tracking-wide">{label}</span>
<div className="m-0 flex items-center justify-between gap-2 rounded-t-md border border-b-0 border-border bg-muted/60 px-3 py-1.5 text-xs text-muted-foreground">
<span className="font-mono uppercase tracking-wide">{label || 'code'}</span>
<button
aria-label={copied ? 'Copied' : 'Copy code'}
className="inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-[0.75rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
@@ -64,164 +184,160 @@ function CodeHeader({ language, code }: { language?: string; code?: string }) {
)
}
function imageFilename(src?: string): string {
if (!src) {
return 'image'
}
async function typedBlobUrl(dataUrl: string, mime: string): Promise<string> {
const blob = await fetch(dataUrl).then(response => response.blob())
try {
const { pathname } = new URL(src, window.location.href)
return pathname.split('/').filter(Boolean).pop() || 'image'
} catch {
return src.split(/[\\/]/).filter(Boolean).pop() || 'image'
}
return URL.createObjectURL(new Blob([await blob.arrayBuffer()], { type: mime }))
}
function isMissingIpcHandler(error: unknown): boolean {
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
return message.includes("No handler registered for 'hermes:saveImageFromUrl'")
}
async function startBrowserDownload(src: string) {
const response = await fetch(src)
if (!response.ok) {
throw new Error(`Could not fetch image: ${response.status}`)
async function mediaSrc(path: string): Promise<string> {
if (/^(?:https?|data):/i.test(path)) {
return path
}
const blobUrl = URL.createObjectURL(await response.blob())
const link = document.createElement('a')
link.href = blobUrl
link.download = imageFilename(src)
link.rel = 'noopener noreferrer'
document.body.appendChild(link)
link.click()
link.remove()
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
if (!window.hermesDesktop?.readFileDataUrl) {
return mediaExternalUrl(path)
}
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
return ['audio', 'video'].includes(mediaKind(path)) ? typedBlobUrl(dataUrl, mediaMime(path)) : dataUrl
}
const imageActionButtonClass =
'absolute right-2 top-2 grid size-8 place-items-center rounded-full border border-border/70 bg-background/80 text-muted-foreground opacity-0 shadow-sm backdrop-blur transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 disabled:opacity-50'
function OpenMediaButton({ kind, path }: { kind: 'audio' | 'video'; path: string }) {
return (
<button
className="mt-2 bg-transparent text-xs font-medium text-muted-foreground underline underline-offset-4 hover:text-foreground"
onClick={() => void window.hermesDesktop?.openExternal(mediaExternalUrl(path))}
type="button"
>
Open {kind} file
</button>
)
}
function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>) {
const [saving, setSaving] = useState(false)
const [lightboxOpen, setLightboxOpen] = useState(false)
const canOpen = Boolean(src)
function MediaAttachment({ path }: { path: string }) {
const [src, setSrc] = useState('')
const [failed, setFailed] = useState(false)
const kind = mediaKind(path)
const name = mediaName(path)
async function handleDownload() {
if (!src || saving) {
return
}
useEffect(() => {
let cancelled = false
let objectUrl = ''
setSaving(true)
try {
if (window.hermesDesktop?.saveImageFromUrl) {
const saved = await window.hermesDesktop.saveImageFromUrl(src)
if (saved) {
notify({
kind: 'success',
title: 'Image saved',
message: imageFilename(src)
})
setFailed(false)
setSrc('')
void mediaSrc(path)
.then(value => {
if (value.startsWith('blob:')) {
objectUrl = value
}
return
}
await startBrowserDownload(src)
} catch (error) {
if (isMissingIpcHandler(error)) {
try {
await startBrowserDownload(src)
notify({
kind: 'info',
title: 'Download started',
message: 'Restart Hermes Desktop to use Save Image.'
})
} catch (fallbackError) {
notifyError(fallbackError, 'Restart Hermes Desktop to save images')
if (!cancelled) {
setSrc(value)
} else if (objectUrl) {
URL.revokeObjectURL(objectUrl)
}
})
.catch(() => {
if (!cancelled) {
setFailed(true)
}
})
return
return () => {
cancelled = true
if (objectUrl) {
URL.revokeObjectURL(objectUrl)
}
notifyError(error, 'Image download failed')
} finally {
setSaving(false)
}
}, [path])
if (kind === 'image' && src) {
return (
<span className="block">
<MarkdownImage alt={name} src={src} />
</span>
)
}
function openLightbox() {
if (canOpen) {
setLightboxOpen(true)
}
if (kind === 'audio' && src) {
return (
<span className="my-3 block max-w-md rounded-xl border border-border/70 bg-card/70 p-3">
<span className="mb-2 block truncate text-xs font-medium text-muted-foreground">{name}</span>
<audio className="block w-full" controls onError={() => setFailed(true)} preload="metadata" src={src} />
{failed && <OpenMediaButton kind="audio" path={path} />}
</span>
)
}
const lightbox = src ? (
<Dialog onOpenChange={setLightboxOpen} open={lightboxOpen}>
<DialogContent
className="grid max-h-[calc(100vh-2rem)] w-auto max-w-[calc(100vw-2rem)] place-items-center overflow-visible border-0 bg-transparent p-0 shadow-none"
showCloseButton={false}
>
<div className="group/lightbox relative max-h-[calc(100vh-2rem)] max-w-[calc(100vw-2rem)] overflow-auto">
<img
alt={alt ?? ''}
className="block max-h-[calc(100vh-2rem)] max-w-full cursor-zoom-out select-auto rounded-lg object-contain shadow-2xl"
onClick={() => setLightboxOpen(false)}
src={src}
/>
<button
aria-label={saving ? 'Saving image' : 'Download image'}
className={cn(imageActionButtonClass, 'group-hover/lightbox:opacity-100')}
disabled={saving}
onClick={event => {
event.stopPropagation()
void handleDownload()
}}
title={saving ? 'Saving image' : 'Download image'}
type="button"
>
<Download className={cn('size-4', saving && 'animate-pulse')} />
</button>
</div>
</DialogContent>
</Dialog>
) : null
if (kind === 'video' && src) {
return (
<span className="my-3 block max-w-2xl rounded-xl border border-border/70 bg-card/70 p-3">
<span className="mb-2 block truncate text-xs font-medium text-muted-foreground">{name}</span>
<video
className="block max-h-112 w-full rounded-lg bg-black"
controls
onError={() => setFailed(true)}
src={src}
/>
{failed && <OpenMediaButton kind="video" path={path} />}
</span>
)
}
return (
<>
<span className="group/image relative my-3 inline-block max-w-full align-top" data-slot="aui_markdown-image">
<button
className="block max-w-full cursor-zoom-in bg-transparent p-0 text-left"
disabled={!canOpen}
onClick={openLightbox}
title={canOpen ? 'Open image' : undefined}
type="button"
>
<img alt={alt ?? ''} className={className} src={src} {...props} />
</button>
{src && (
<button
aria-label={saving ? 'Saving image' : 'Download image'}
className={cn(imageActionButtonClass, 'group-hover/image:opacity-100')}
disabled={saving}
onClick={event => {
event.stopPropagation()
void handleDownload()
}}
title={saving ? 'Saving image' : 'Download image'}
type="button"
>
<Download className={cn('size-4', saving && 'animate-pulse')} />
</button>
)}
</span>
{lightbox}
</>
<a
className="font-medium text-foreground underline underline-offset-4 decoration-foreground/30 wrap-anywhere hover:decoration-foreground/70"
href="#"
onClick={event => {
event.preventDefault()
void window.hermesDesktop?.openExternal(mediaExternalUrl(path))
}}
>
{failed ? `Open ${name}` : `Loading ${name}...`}
</a>
)
}
function MarkdownLink({ className, href, ...props }: ComponentProps<'a'>) {
const mediaPath = mediaPathFromMarkdownHref(href)
const previewTarget = previewTargetFromMarkdownHref(href)
if (mediaPath) {
return <MediaAttachment path={mediaPath} />
}
if (previewTarget) {
return <PreviewAttachment target={previewTarget} />
}
return (
<a
className={cn(
'font-medium text-foreground underline underline-offset-4 decoration-foreground/30 wrap-anywhere hover:decoration-foreground/70',
className
)}
href={href}
rel="noopener noreferrer"
target="_blank"
{...props}
/>
)
}
function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>) {
return (
<ZoomableImage
alt={alt}
className={className}
containerClassName="my-3"
slot="aui_markdown-image"
src={src}
{...props}
/>
)
}
@@ -244,17 +360,7 @@ const MarkdownTextImpl = () => {
p: ({ className, ...props }: ComponentProps<'p'>) => (
<p className={cn('wrap-anywhere leading-relaxed', className)} {...props} />
),
a: ({ className, ...props }: ComponentProps<'a'>) => (
<a
className={cn(
'font-medium text-foreground underline underline-offset-4 decoration-foreground/30 wrap-anywhere hover:decoration-foreground/70',
className
)}
rel="noopener noreferrer"
target="_blank"
{...props}
/>
),
a: MarkdownLink,
hr: ({ className, ...props }: ComponentProps<'hr'>) => (
<hr className={cn('border-border/70', className)} {...props} />
),
@@ -315,7 +421,7 @@ const MarkdownTextImpl = () => {
mode="streaming"
parseIncompleteMarkdown
plugins={{ code }}
preprocess={stripReasoning}
preprocess={preprocessMarkdown}
shikiTheme={['github-light-default', 'github-dark-default']}
/>
)
@@ -0,0 +1,94 @@
import { useStore } from '@nanostores/react'
import { MonitorPlay } from 'lucide-react'
import { useState } from 'react'
import { previewName } from '@/lib/preview-targets'
import { notifyError } from '@/store/notifications'
import { $previewTarget, setPreviewTarget } from '@/store/preview'
import { $currentCwd } from '@/store/session'
export function PreviewAttachment({ target }: { target: string }) {
const cwd = useStore($currentCwd)
const activePreview = useStore($previewTarget)
const [opening, setOpening] = useState(false)
const name = previewName(target)
const isActive = activePreview?.source === target
function localFallbackPreview() {
if (/^https?:\/\//i.test(target)) {
return { kind: 'url' as const, label: previewName(target), source: target, url: target }
}
if (/^file:\/\//i.test(target)) {
return { kind: 'file' as const, label: previewName(target), source: target, url: target }
}
if (/^(?:\/|\.{1,2}\/|~\/).+\.html?$/i.test(target)) {
const path = target.startsWith('file://') ? target : `file://${encodeURI(target)}`
return { kind: 'file' as const, label: previewName(target), source: target, url: path }
}
return null
}
function isMissingPreviewIpc(error: unknown): boolean {
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
return message.includes("No handler registered for 'hermes:normalizePreviewTarget'")
}
async function togglePreview() {
if (opening) {
return
}
if (isActive) {
setPreviewTarget(null)
return
}
setOpening(true)
try {
const preview = await window.hermesDesktop?.normalizePreviewTarget(target, cwd || undefined).catch(error => {
if (isMissingPreviewIpc(error)) {
return localFallbackPreview()
}
throw error
})
if (!preview) {
throw new Error(`Could not open preview target: ${target}`)
}
setPreviewTarget(preview)
} catch (error) {
notifyError(error, 'Preview unavailable')
} finally {
setOpening(false)
}
}
return (
<div className="inline-flex max-w-[min(100%,32rem)] items-center gap-3 rounded-xl border border-border/70 bg-card/70 p-3 text-sm">
<div className="grid size-9 shrink-0 place-items-center rounded-lg bg-accent text-muted-foreground">
<MonitorPlay className="size-4" />
</div>
<div className="min-w-0 max-w-64">
<div className="truncate font-medium text-foreground">{name}</div>
<div className="truncate font-mono text-xs text-muted-foreground">{target}</div>
</div>
<button
className="shrink-0 rounded-lg border border-border/70 px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50"
disabled={opening}
onClick={() => void togglePreview()}
type="button"
>
{opening ? 'Opening...' : isActive ? 'Hide Preview' : 'Toggle Preview'}
</button>
</div>
)
}
@@ -4,6 +4,8 @@ import type { SyntaxHighlighterProps } from '@assistant-ui/react-streamdown'
import type { FC } from 'react'
import ShikiHighlighter from 'react-shiki'
import { isLikelyProseCodeBlock } from '@/lib/markdown-code'
/**
* assistant-ui's recommended `SyntaxHighlighter` slot.
*
@@ -22,10 +24,13 @@ export const SyntaxHighlighter: FC<SyntaxHighlighterProps> = ({
language,
code
}) => {
// Markdown fences include the pre-closing newline in `code`, which
// Shiki tokenizes into a blank final line. Trim so the box ends on
// real code.
const trimmed = (code ?? '').trimEnd()
// Streamdown may hand us fence contents with edge newlines. Strip blank
// fence padding without touching indentation on the first real line.
const trimmed = (code ?? '').replace(/^\n+/, '').trimEnd()
if (isLikelyProseCodeBlock(language, trimmed)) {
return <div className="whitespace-pre-wrap wrap-anywhere text-foreground">{trimmed}</div>
}
return (
<Pre className="aui-shiki m-0 overflow-hidden rounded-b-md border border-t-0 border-border bg-card font-mono text-sm leading-relaxed [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-4 [&_pre]:py-3 [&_pre]:font-mono [&_pre]:leading-relaxed">
@@ -2,10 +2,12 @@ import {
ActionBarPrimitive,
AuiIf,
BranchPickerPrimitive,
ComposerPrimitive,
ErrorPrimitive,
MessagePrimitive,
ThreadPrimitive,
type ToolCallMessagePartProps,
useAuiEvent,
useAuiState
} from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
@@ -17,19 +19,42 @@ import {
GitBranchIcon,
Loader2Icon,
MoreHorizontalIcon,
PencilIcon,
RefreshCwIcon,
Volume2Icon,
VolumeXIcon
VolumeXIcon,
XIcon
} from 'lucide-react'
import { type FC, type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import {
type FC,
type ReactNode,
useCallback,
useEffect,
useRef,
useState
} from 'react'
// Scroll behavior: delegated to `use-stick-to-bottom` (StackBlitz), the
// reference implementation that powers bolt.new and several other streaming
// chat UIs. It handles everything we care about — spring-animated catch-up,
// resize-vs-user-scroll disambiguation, wheel/touch escape, text-selection
// pause, subpixel overshoot, programmatic-scroll event suppression — via 665
// lines of well-tested edge-case handling that we should NOT hand-roll.
//
// We only own the thin glue: jump-to-bottom on session switch / send, and
// keeping `$threadScrolledUp` in sync with `isAtBottom` for the composer's
// dim-when-scrolled-away treatment.
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
import spinners from 'unicode-animations'
import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
import { DirectiveText } from '@/components/assistant-ui/directive-text'
import { GeneratedImageProvider, useGeneratedImageContext } from '@/components/assistant-ui/generated-image-context'
import { ImageGenerationPlaceholder } from '@/components/assistant-ui/image-generation-placeholder'
import { Intro, type IntroProps } from '@/components/assistant-ui/intro'
import { MarkdownText } from '@/components/assistant-ui/markdown-text'
import { PreviewAttachment } from '@/components/assistant-ui/preview-attachment'
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
import {
@@ -41,49 +66,16 @@ import {
} from '@/components/ui/dropdown-menu'
import { Loader } from '@/components/ui/loader'
import { triggerHaptic } from '@/lib/haptics'
import { extractPreviewTargets } from '@/lib/preview-targets'
import { cn } from '@/lib/utils'
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
import { notifyError } from '@/store/notifications'
import { setThreadScrolledUp } from '@/store/thread-scroll'
import { $voicePlayback } from '@/store/voice-playback'
const THINKING_FACES = [
'(。•́︿•̀。)',
'(◔_◔)',
'(¬‿¬)',
'( •_•)>⌐■-■',
'(⌐■_■)',
'(´・_・`)',
'◉_◉',
'(°ロ°)',
'( ˘⌣˘)♡',
'ヽ(>∀<☆)☆',
'٩(๑❛ᴗ❛๑)۶',
'(⊙_⊙)',
'(¬_¬)',
'( ͡° ͜ʖ ͡°)',
'ಠ_ಠ'
]
const RESPONSE_SPINNER = spinners.braille
const THINKING_VERBS = [
'pondering',
'contemplating',
'musing',
'cogitating',
'ruminating',
'deliberating',
'mulling',
'reflecting',
'processing',
'reasoning',
'analyzing',
'computing',
'synthesizing',
'formulating',
'brainstorming'
]
type ThreadLoadingState = 'response' | 'session' | 'working'
type ThreadLoadingState = 'response' | 'session'
interface MessageActionProps {
messageId: string
@@ -91,13 +83,8 @@ interface MessageActionProps {
onBranchInNewChat?: (messageId: string) => void
}
const BOTTOM_DISTANCE_PX = 24
let readAloudAudio: HTMLAudioElement | null = null
function isNearBottom(el: HTMLElement): boolean {
return el.scrollHeight - (el.scrollTop + el.clientHeight) <= BOTTOM_DISTANCE_PX
}
function partText(part: unknown): string {
if (typeof part === 'string') {
return part
@@ -126,142 +113,298 @@ export const Thread: FC<{
onBranchInNewChat?: (messageId: string) => void
sessionKey?: string | null
}> = ({ intro, loading, onBranchInNewChat, sessionKey }) => {
const viewportRef = useRef<HTMLDivElement | null>(null)
const contentRef = useRef<HTMLDivElement | null>(null)
const messageCount = useAuiState(s => s.thread.messages.length)
const isRunning = useAuiState(s => s.thread.isRunning)
const lastMessageId = useAuiState(s => s.thread.messages.at(-1)?.id ?? '')
const shouldStickToBottomRef = useRef(true)
const scrollFrameRef = useRef<number | null>(null)
const sessionKeyRef = useRef<string | null>(sessionKey ?? null)
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
const nearBottom = isNearBottom(event.currentTarget)
shouldStickToBottomRef.current = nearBottom
setThreadScrolledUp(!nearBottom)
}, [])
const handleWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
if (event.deltaY < 0) {
shouldStickToBottomRef.current = false
setThreadScrolledUp(true)
}
}, [])
const scrollToBottom = useCallback(() => {
const viewport = viewportRef.current
if (!viewport) {
return
}
viewport.scrollTop = viewport.scrollHeight
shouldStickToBottomRef.current = true
setThreadScrolledUp(false)
}, [])
const scheduleScrollToBottom = useCallback(() => {
if (scrollFrameRef.current !== null) {
window.cancelAnimationFrame(scrollFrameRef.current)
}
scrollFrameRef.current = window.requestAnimationFrame(() => {
scrollFrameRef.current = null
scrollToBottom()
})
}, [scrollToBottom])
useEffect(() => {
return () => {
if (scrollFrameRef.current !== null) {
window.cancelAnimationFrame(scrollFrameRef.current)
}
setThreadScrolledUp(false)
}
}, [])
useLayoutEffect(() => {
const viewport = viewportRef.current
if (!viewport) {
return
}
const nextSessionKey = sessionKey ?? null
const sessionChanged = sessionKeyRef.current !== nextSessionKey
sessionKeyRef.current = nextSessionKey
const force = loading === 'session' || sessionChanged
if (!force && !shouldStickToBottomRef.current) {
return
}
scheduleScrollToBottom()
}, [isRunning, lastMessageId, loading, messageCount, scheduleScrollToBottom, sessionKey])
useLayoutEffect(() => {
const content = contentRef.current
const viewport = viewportRef.current
if (!content || !viewport) {
return
}
let previousHeight = content.getBoundingClientRect().height
const observer = new ResizeObserver(entries => {
const height = entries[0]?.contentRect.height ?? content.getBoundingClientRect().height
if (height === previousHeight) {
return
}
previousHeight = height
if (!shouldStickToBottomRef.current && !isNearBottom(viewport)) {
return
}
scheduleScrollToBottom()
})
observer.observe(content)
return () => observer.disconnect()
}, [scheduleScrollToBottom])
return (
<GeneratedImageProvider>
<ThreadPrimitive.Root className="relative grid h-full min-h-0 grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent">
<AuiIf condition={s => Boolean(intro) && s.thread.isEmpty}>{intro && <Intro {...intro} />}</AuiIf>
<ThreadPrimitive.Viewport
autoScroll={false}
className="h-full min-h-0 overflow-y-auto overscroll-contain px-[clamp(1rem,10%,12rem)] pt-[calc(var(--vsq)*19)]"
data-slot="aui_thread-viewport"
onScroll={handleScroll}
onWheel={handleWheel}
ref={viewportRef}
scrollToBottomOnInitialize
scrollToBottomOnRunStart
scrollToBottomOnThreadSwitch
>
<div className="flex w-full flex-col gap-3" ref={contentRef}>
<ThreadPrimitive.Messages>
{() => <ThreadMessage onBranchInNewChat={onBranchInNewChat} />}
</ThreadPrimitive.Messages>
{loading === 'response' && <ResponseLoadingIndicator />}
{loading === 'working' && <WorkingIndicator />}
</div>
<ThreadPrimitive.ViewportFooter className="h-(--thread-composer-clearance) shrink-0" />
</ThreadPrimitive.Viewport>
<ThreadPrimitive.ViewportProvider>
{/*
* <StickToBottom> renders a wrapper <div>; <StickToBottom.Content>
* renders an inner scroll container (inline height/width 100%) plus
* an inner content div. So:
* - `className` on <StickToBottom> = outer wrapper sizing
* - `scrollClassName` on <.Content> = scroll container
* - `className` on <.Content> = content (flex column)
*
* `initial: 'instant'`: no animation on first mount.
* `resize: 'instant'`: during streaming, snap to bottom each token.
* Spring animation ('smooth') visibly lags behind fast token
* streams; users read that as jank. 'instant' matches ChatGPT.
*
* The composer is rendered OUTSIDE the scroller as `position:
* absolute; bottom: 0` (floating glass treatment) and overlays the
* bottom of the scroll surface. We compensate by putting a tall
* bottom spacer (>= composer height + margin) inside the scroll
* content so "scroll to bottom" naturally parks the last line of
* content above the composer, not hidden behind it.
*/}
<StickToBottom
className="relative h-full min-h-0"
initial="instant"
resize="instant"
>
<ThreadScrollSync sessionKey={sessionKey} />
<StickToBottom.Content
className="flex w-full flex-col gap-3 px-[clamp(1rem,10%,12rem)] pt-[calc(var(--vsq)*19)]"
data-slot="aui_thread-content"
scrollClassName="overflow-y-auto overscroll-contain"
>
<ThreadPrimitive.Messages
components={{
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} />,
SystemMessage,
UserEditComposer,
UserMessage
}}
/>
{loading === 'response' && <ResponseLoadingIndicator />}
<ComposerClearance />
</StickToBottom.Content>
</StickToBottom>
</ThreadPrimitive.ViewportProvider>
{loading === 'session' && <CenteredThreadSpinner />}
</ThreadPrimitive.Root>
</GeneratedImageProvider>
)
}
/**
* Scroll glue for the chat thread. Replaces hand-rolled follow logic with
* the exact pattern that assistant-ui's own `useThreadViewportAutoScroll`
* uses internally: **raw DOM scroll + an armed behavior ref + a
* ResizeObserver loop that re-pins to bottom until we actually reach it.**
*
* Why not use the library's `scrollToBottom` for sends?
* - It wraps its work in `new Promise(requestAnimationFrame)` so even
* `animation: 'instant'` is 1+ frame async.
* - It does NOT clear `escapedFromLock` on call — if the user had
* scrolled up before sending, the library's resize handler keeps
* un-setting `isAtBottom` between our scroll and the next resize.
* - `ignoreEscapes` only blocks NEW escapes during the animation; it
* doesn't unstick an already-escaped state.
*
* The armed-ref pattern handles all of that:
* 1. `thread.runStart` fires after the runtime has committed the user
* message to state (so scrollHeight already reflects it).
* 2. We arm a ref ('instant') and write `scrollTop = scrollHeight`
* synchronously.
* 3. A ResizeObserver on the content keeps re-pinning each time the
* DOM grows (user message paints, assistant placeholder mounts,
* assistant streams) until scrollTop is actually at bottom — then
* we disarm.
* 4. Any wheel-up or touch-scroll-up disarms immediately so the user
* can always escape.
*
* This mirrors:
* - assistant-ui's `useThreadViewportAutoScroll` (scrollToBottomBehaviorRef
* + useOnResizeContent loop)
* - Vercel ai-chatbot's `useScrollToBottom` (MutationObserver + RO on
* container and children + isAtBottom/isUserScrolling flags)
*
* Must be rendered INSIDE a <StickToBottom> because useStickToBottomContext
* reads from that component's context.
*/
const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) => {
const { scrollRef, isAtBottom, state } = useStickToBottomContext()
const sessionKeyRef = useRef<string | null>(sessionKey ?? null)
// "Armed" behavior ref. Non-null = "keep chasing bottom across resize
// ticks until we get there." Null = "user owns the viewport."
const armedRef = useRef<ScrollBehavior | null>(null)
const messageCount = useAuiState(s => s.thread.messages.length)
const prevMessageCountRef = useRef(messageCount)
useEffect(() => {
setThreadScrolledUp(!isAtBottom)
}, [isAtBottom])
useEffect(() => {
return () => {
setThreadScrolledUp(false)
}
}, [])
// Slam to bottom + arm the ref. Also forces library state flags off
// so its internal resize handler doesn't fight our re-pins.
const armAndPin = useCallback((behavior: ScrollBehavior) => {
const el = scrollRef.current
if (!el) {
return
}
armedRef.current = behavior
// Clear the library's escape/at-bottom flags directly on the mutable
// state object so its resize handler sees a clean follow state.
state.escapedFromLock = false
state.isAtBottom = true
el.scrollTop = el.scrollHeight
}, [scrollRef, state])
// ResizeObserver loop — re-pins to bottom while armed, disarms when
// actually at bottom. This is the assistant-ui pattern.
useEffect(() => {
const el = scrollRef.current
if (!el) {
return
}
const observer = new ResizeObserver(() => {
const behavior = armedRef.current
if (!behavior) {
return
}
const distance = el.scrollHeight - (el.scrollTop + el.clientHeight)
if (distance < 2) {
armedRef.current = null
return
}
el.scrollTop = el.scrollHeight
})
observer.observe(el)
const content = el.firstElementChild
if (content) {
observer.observe(content)
}
return () => observer.disconnect()
}, [scrollRef])
// User-intent detection — any upward gesture disarms the chase.
useEffect(() => {
const el = scrollRef.current
if (!el) {
return
}
const onWheel = (e: WheelEvent) => {
if (e.deltaY < 0) {
armedRef.current = null
}
}
const onTouch = () => {
armedRef.current = null
}
el.addEventListener('wheel', onWheel, { passive: true })
el.addEventListener('touchmove', onTouch, { passive: true })
return () => {
el.removeEventListener('wheel', onWheel)
el.removeEventListener('touchmove', onTouch)
}
}, [scrollRef])
// (1) Session switch — strong intent to see the bottom of the new thread.
useEffect(() => {
const next = sessionKey ?? null
if (sessionKeyRef.current === next) {
return
}
sessionKeyRef.current = next
prevMessageCountRef.current = 0
armAndPin('auto')
}, [armAndPin, sessionKey])
// (2) Bulk message load (session history arriving from storage) — pin
// to bottom and stay armed while the thread's markdown/code/images
// settle over the next several frames.
useEffect(() => {
const prev = prevMessageCountRef.current
prevMessageCountRef.current = messageCount
if (prev === 0 && messageCount > 0) {
armAndPin('auto')
}
}, [armAndPin, messageCount])
// (3) User send — the runtime event `thread.runStart` fires after the
// user message has been committed to state (scrollHeight already reflects
// it). This is the canonical signal per assistant-ui's own code. We
// arm-and-pin synchronously in the callback, then the RO loop above
// keeps us at bottom as the assistant message placeholder + reply stream.
useAuiEvent('thread.runStart', () => {
armAndPin('instant')
})
return null
}
/**
* Invisible bottom spacer whose height matches the currently-measured
* composer height (plus a small gap). Because the composer is rendered
* OUTSIDE the scroll container as `position: absolute; bottom: 0`, "scroll
* to bottom" would otherwise park the last content line behind it. By
* extending the scroll content down with real (blank) space equal to the
* composer's footprint, the library's scroll-to-scrollHeight naturally
* leaves the last message line sitting above the composer.
*
* A ResizeObserver on the composer keeps the spacer in sync when the
* textarea grows (multi-line input), attachments expand, or the composer
* enters a focused/expanded state.
*/
const COMPOSER_BREATHING_ROOM_PX = 20
const ComposerClearance: FC = () => {
const [height, setHeight] = useState<number>(() => {
// Sensible default until the observer wires up (~ 8rem).
if (typeof document === 'undefined') return 128
const composer = document.querySelector<HTMLElement>('[data-slot="composer-root"]')
return composer ? composer.getBoundingClientRect().height + COMPOSER_BREATHING_ROOM_PX : 128
})
useEffect(() => {
const composer = document.querySelector<HTMLElement>('[data-slot="composer-root"]')
if (!composer) {
return
}
const apply = () => {
const h = composer.getBoundingClientRect().height
setHeight(prev => {
const next = Math.round(h + COMPOSER_BREATHING_ROOM_PX)
return Math.abs(prev - next) < 1 ? prev : next
})
}
apply()
const observer = new ResizeObserver(apply)
observer.observe(composer)
return () => observer.disconnect()
}, [])
return <div aria-hidden="true" className="shrink-0" style={{ height: `${height}px` }} />
}
function pickPrimaryPreviewTarget(targets: string[]): string[] {
if (targets.length <= 1) {
return targets
}
const localUrl = targets.find(value => /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/i.test(value))
return [localUrl || targets[targets.length - 1]]
}
const CenteredThreadSpinner: FC = () => (
<div
aria-label="Loading session"
@@ -279,38 +422,16 @@ const CenteredThreadSpinner: FC = () => (
</div>
)
const ThreadMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
const role = useAuiState(s => s.message.role)
const isEditing = useAuiState(s => s.message.composer.isEditing)
// The runtime synthesizes an empty assistant placeholder while isRunning is true
// (last message is user). Rendering the full `MessagePrimitive.Root` for it adds
// ~36px of invisible chrome (gap-2 + min-h-7 footer) which can push the
// loading affordance too far below the user message. Skip it —
// `ResponseLoadingIndicator` in the viewport handles the loading affordance directly.
const isPlaceholder = useAuiState(
s => s.message.role === 'assistant' && s.message.status?.type === 'running' && s.message.content.length === 0
)
if (isEditing) {
return <EditComposer />
}
if (role === 'user') {
return <UserMessage />
}
if (isPlaceholder) {
return null
}
return <AssistantMessage onBranchInNewChat={onBranchInNewChat} />
}
const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
const messageId = useAuiState(s => s.message.id)
const content = useAuiState(s => s.message.content)
const messageText = messageContentText(content)
const previewTargets = pickPrimaryPreviewTarget(extractPreviewTargets(messageText))
const isPlaceholder = useAuiState(s => s.message.status?.type === 'running' && s.message.content.length === 0)
if (isPlaceholder) {
return null
}
return (
<MessagePrimitive.Root
@@ -326,6 +447,13 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
tools: { Fallback: ChainToolFallback }
}}
/>
{previewTargets.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{previewTargets.map(target => (
<PreviewAttachment key={target} target={target} />
))}
</div>
)}
<MessagePrimitive.Error>
<ErrorPrimitive.Root
className="mt-2 rounded-md border border-destructive/20 bg-destructive/5 px-3 py-2 text-sm text-destructive"
@@ -351,45 +479,28 @@ const StatusRow: FC<{ children: ReactNode; label: string }> = ({ children, label
)
const ResponseLoadingIndicator: FC = () => {
const [tick, setTick] = useState(0)
const [frame, setFrame] = useState(0)
const elapsed = useElapsedSeconds()
useEffect(() => {
const id = window.setInterval(() => setTick(t => t + 1), 900)
const id = window.setInterval(
() => setFrame(current => (current + 1) % RESPONSE_SPINNER.frames.length),
RESPONSE_SPINNER.interval
)
return () => window.clearInterval(id)
}, [])
const face = THINKING_FACES[tick % THINKING_FACES.length]
const verb = THINKING_VERBS[tick % THINKING_VERBS.length]
return (
<StatusRow label="Hermes is loading a response">
<span className="shimmer shimmer-repeat-delay-0 min-w-0 truncate text-muted-foreground/55">
{face} {verb}
<span aria-hidden="true" className="font-mono text-base leading-none text-muted-foreground/60">
{RESPONSE_SPINNER.frames[frame]}
</span>
<ActivityTimerText seconds={elapsed} />
</StatusRow>
)
}
const WorkingIndicator: FC = () => {
const elapsed = useElapsedSeconds()
return (
<StatusRow label="Hermes is still working">
<Loader
className="size-4 text-muted-foreground/60"
label="Still working"
strokeScale={0.65}
type="spiral-search"
/>
<span className="shimmer min-w-0 truncate text-muted-foreground/60">Still working</span>
<ActivityTimerText seconds={elapsed} />
</StatusRow>
)
}
const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ result }) => {
const generatedImage = useGeneratedImageContext()
const running = result === undefined
@@ -414,6 +525,10 @@ const ChainToolFallback: FC<ToolCallMessagePartProps> = props => {
return <ImageGenerateTool {...props} />
}
if (props.toolName === 'clarify') {
return <ClarifyTool {...props} />
}
return <ToolFallback {...props} />
}
@@ -637,21 +752,97 @@ const branchButtonClass =
'grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-35'
const UserMessage: FC = () => {
const content = useAuiState(s => s.message.content)
const messageText = messageContentText(content)
return (
<MessagePrimitive.Root
className="group flex max-w-[min(72%,34rem)] flex-col gap-2 self-end rounded-2xl border border-[color-mix(in_srgb,var(--dt-user-bubble-border)_78%,transparent)] bg-[color-mix(in_srgb,var(--dt-user-bubble)_94%,transparent)] px-3 py-2"
className="group flex max-w-[min(72%,34rem)] flex-col items-end gap-2 self-end"
data-role="user"
data-slot="aui_user-message-root"
>
<div className="wrap-anywhere whitespace-pre-line leading-[1.48] text-foreground/95">
<div className="wrap-anywhere whitespace-pre-line rounded-2xl border border-[color-mix(in_srgb,var(--dt-user-bubble-border)_78%,transparent)] bg-[color-mix(in_srgb,var(--dt-user-bubble)_94%,transparent)] px-3 py-2 leading-[1.48] text-foreground/95">
<MessagePrimitive.Parts components={{ Text: DirectiveText }} />
</div>
<div className="min-h-6">
<UserActionBar messageText={messageText} />
</div>
</MessagePrimitive.Root>
)
}
const EditComposer: FC = () => {
// Editing requires a real onEdit implementation against Hermes history.
// Hide the edit composer until that contract is implemented.
return null
const UserActionBar: FC<{ messageText: string }> = ({ messageText }) => (
<div className="relative h-6 w-14 shrink-0">
<ActionBarPrimitive.Root className={ACTION_BAR_CLASS} hideWhenRunning>
<CopyMessageButton text={messageText} />
<ActionBarPrimitive.Edit asChild>
<TooltipIconButton onClick={() => triggerHaptic('selection')} tooltip="Edit">
<PencilIcon />
</TooltipIconButton>
</ActionBarPrimitive.Edit>
</ActionBarPrimitive.Root>
</div>
)
const SLASH_STATUS_RE = /^slash:(?<command>\/[^\n]+)\n(?<output>[\s\S]*)$/
const SystemMessage: FC = () => {
const text = useAuiState(s => messageContentText(s.message.content))
if (!text) {
return null
}
const slashStatus = text.match(SLASH_STATUS_RE)
if (slashStatus?.groups) {
return (
<MessagePrimitive.Root
className="max-w-[min(86%,44rem)] self-center px-2 py-0.5 text-center text-[0.6875rem] leading-5 text-muted-foreground/60"
data-role="system"
data-slot="aui_system-message-root"
>
<span className="font-mono text-muted-foreground/55">{slashStatus.groups.command}</span>
<span className="mx-1.5 text-muted-foreground/35">·</span>
<span className="whitespace-pre-wrap">{slashStatus.groups.output.trim()}</span>
</MessagePrimitive.Root>
)
}
return (
<MessagePrimitive.Root
className="max-w-[min(86%,44rem)] self-center px-2 py-0.5 text-center text-[0.6875rem] leading-5 text-muted-foreground/55"
data-role="system"
data-slot="aui_system-message-root"
>
<span className="whitespace-pre-wrap">{text}</span>
</MessagePrimitive.Root>
)
}
const UserEditComposer: FC = () => (
<ComposerPrimitive.Root
className="flex min-w-[min(18rem,72vw)] max-w-[min(72%,34rem)] flex-col gap-1.5 self-end rounded-2xl border border-[color-mix(in_srgb,var(--dt-user-bubble-border)_88%,transparent)] bg-[color-mix(in_srgb,var(--dt-user-bubble)_98%,transparent)] px-3 py-2 shadow-sm"
data-slot="aui_edit-composer-root"
>
<ComposerPrimitive.Input
autoFocus
className="min-h-8 w-full resize-none bg-transparent leading-[1.48] text-foreground/95 outline-none"
rows={1}
submitMode="enter"
unstable_focusOnScrollToBottom={false}
/>
<div className="flex justify-end gap-1">
<ComposerPrimitive.Cancel asChild>
<TooltipIconButton tooltip="Cancel edit">
<XIcon />
</TooltipIconButton>
</ComposerPrimitive.Cancel>
<ComposerPrimitive.Send asChild>
<TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip="Send edit">
<CheckIcon />
</TooltipIconButton>
</ComposerPrimitive.Send>
</div>
</ComposerPrimitive.Root>
)
@@ -1,12 +1,14 @@
'use client'
import { type ToolCallMessagePartProps } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { ChevronRight } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
import { cn } from '@/lib/utils'
import { $toolInlineDiffs } from '@/store/tool-diffs'
const TOOL_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
@@ -78,6 +80,24 @@ function prettyJson(value: unknown): string {
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
}
function recordValue(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
}
function stripAnsi(value: string): string {
return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '')
}
function stripInlineDiffChrome(value: string): string {
return value ? stripAnsi(value).replace(/^\s*┊\s*review diff\s*\n/i, '').trim() : ''
}
function inlineDiffFromResult(result: unknown): string {
const value = recordValue(result).inline_diff
return typeof value === 'string' ? stripInlineDiffChrome(value) : ''
}
function detailLabel(toolName: string): string {
if (toolName === 'image_generate') {
return 'Prompt'
@@ -121,7 +141,7 @@ function detailText(args: unknown, result: unknown): string {
return prettyJson(args)
}
export const ToolFallback = ({ toolName, args, result }: ToolCallMessagePartProps) => {
export const ToolFallback = ({ toolCallId, toolName, args, result }: ToolCallMessagePartProps) => {
const [open, setOpen] = useState(false)
const isPending = result === undefined
const [tick, setTick] = useState(0)
@@ -129,6 +149,9 @@ export const ToolFallback = ({ toolName, args, result }: ToolCallMessagePartProp
const preview = compactPreview(args) || compactPreview(result)
const label = toolLabel(toolName, isPending)
const detail = detailText(args, result)
const liveDiffs = useStore($toolInlineDiffs)
const sideDiff = toolCallId ? liveDiffs[toolCallId] || '' : ''
const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(result)
const spinnerFrame = TOOL_SPINNER_FRAMES[tick % TOOL_SPINNER_FRAMES.length]
useEffect(() => {
@@ -173,6 +196,35 @@ export const ToolFallback = ({ toolName, args, result }: ToolCallMessagePartProp
{detail}
</div>
)}
{inlineDiff && <InlineDiff text={inlineDiff} />}
</div>
)
}
function InlineDiff({ text }: { text: string }) {
return (
<pre className="ml-4 mt-2 max-h-96 max-w-full overflow-auto rounded-lg border border-border/60 bg-background/70 px-3 py-2 font-mono text-[0.6875rem] leading-relaxed">
{text.split('\n').map((line, index) => {
const added = line.startsWith('+') && !line.startsWith('+++')
const removed = line.startsWith('-') && !line.startsWith('---')
const hunk = line.startsWith('@@')
const fileHeader = line.startsWith('---') || line.startsWith('+++') || / → /.test(line.slice(0, 60))
return (
<span
className={cn(
'block min-w-max whitespace-pre',
added && 'text-emerald-700 dark:text-emerald-300',
removed && 'text-rose-700 dark:text-rose-300',
hunk && 'text-sky-700 dark:text-sky-300',
!added && !removed && !hunk && fileHeader && 'text-muted-foreground/80'
)}
key={`${index}-${line}`}
>
{line || ' '}
</span>
)
})}
</pre>
)
}
@@ -0,0 +1,170 @@
'use client'
import { Download } from 'lucide-react'
import { type ComponentProps, useState } from 'react'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
function imageFilename(src?: string): string {
if (!src) {
return 'image'
}
try {
const { pathname } = new URL(src, window.location.href)
return pathname.split('/').filter(Boolean).pop() || 'image'
} catch {
return src.split(/[\\/]/).filter(Boolean).pop() || 'image'
}
}
function isMissingIpcHandler(error: unknown): boolean {
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
return message.includes("No handler registered for 'hermes:saveImageFromUrl'")
}
async function startBrowserDownload(src: string) {
const response = await fetch(src)
if (!response.ok) {
throw new Error(`Could not fetch image: ${response.status}`)
}
const blobUrl = URL.createObjectURL(await response.blob())
const link = document.createElement('a')
link.href = blobUrl
link.download = imageFilename(src)
link.rel = 'noopener noreferrer'
document.body.appendChild(link)
link.click()
link.remove()
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
}
const imageActionButtonClass =
'absolute right-2 top-2 grid size-8 place-items-center rounded-full border border-border/70 bg-background/80 text-muted-foreground opacity-0 shadow-sm backdrop-blur transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 disabled:opacity-50'
export interface ZoomableImageProps extends ComponentProps<'img'> {
containerClassName?: string
slot?: string
}
export function ZoomableImage({ className, containerClassName, src, alt, slot, ...props }: ZoomableImageProps) {
const [saving, setSaving] = useState(false)
const [lightboxOpen, setLightboxOpen] = useState(false)
const canOpen = Boolean(src)
async function handleDownload() {
if (!src || saving) {
return
}
setSaving(true)
try {
if (window.hermesDesktop?.saveImageFromUrl) {
const saved = await window.hermesDesktop.saveImageFromUrl(src)
if (saved) {
notify({ kind: 'success', title: 'Image saved', message: imageFilename(src) })
}
return
}
await startBrowserDownload(src)
} catch (error) {
if (isMissingIpcHandler(error)) {
try {
await startBrowserDownload(src)
notify({
kind: 'info',
title: 'Download started',
message: 'Restart Hermes Desktop to use Save Image.'
})
} catch (fallbackError) {
notifyError(fallbackError, 'Restart Hermes Desktop to save images')
}
return
}
notifyError(error, 'Image download failed')
} finally {
setSaving(false)
}
}
const lightbox = src ? (
<Dialog onOpenChange={setLightboxOpen} open={lightboxOpen}>
<DialogContent
className="grid max-h-[calc(100vh-2rem)] w-auto max-w-[calc(100vw-2rem)] place-items-center overflow-visible border-0 bg-transparent p-0 shadow-none"
showCloseButton={false}
>
<div className="group/lightbox relative max-h-[calc(100vh-2rem)] max-w-[calc(100vw-2rem)] overflow-auto">
<img
alt={alt ?? ''}
className="block max-h-[calc(100vh-2rem)] max-w-full cursor-zoom-out select-auto rounded-lg object-contain shadow-2xl"
onClick={() => setLightboxOpen(false)}
src={src}
/>
<ImageActionButton onClick={handleDownload} saving={saving} variant="lightbox" />
</div>
</DialogContent>
</Dialog>
) : null
return (
<>
<span
className={cn('group/image relative inline-block max-w-full align-top', containerClassName)}
data-slot={slot ?? 'aui_zoomable-image'}
>
<button
className="block max-w-full cursor-zoom-in bg-transparent p-0 text-left"
disabled={!canOpen}
onClick={() => canOpen && setLightboxOpen(true)}
title={canOpen ? 'Open image' : undefined}
type="button"
>
<img alt={alt ?? ''} className={className} src={src} {...props} />
</button>
{src && <ImageActionButton onClick={handleDownload} saving={saving} variant="inline" />}
</span>
{lightbox}
</>
)
}
function ImageActionButton({
onClick,
saving,
variant
}: {
onClick: () => void
saving: boolean
variant: 'inline' | 'lightbox'
}) {
return (
<button
aria-label={saving ? 'Saving image' : 'Download image'}
className={cn(
imageActionButtonClass,
variant === 'inline' ? 'group-hover/image:opacity-100' : 'group-hover/lightbox:opacity-100'
)}
disabled={saving}
onClick={event => {
event.stopPropagation()
void onClick()
}}
title={saving ? 'Saving image' : 'Download image'}
type="button"
>
<Download className={cn('size-4', saving && 'animate-pulse')} />
</button>
)
}
@@ -60,8 +60,8 @@ export const SessionInspector: FC<SessionInspectorProps> = ({
<aside
aria-hidden={!open}
className={cn(
'relative flex h-screen w-full min-w-0 flex-col overflow-hidden bg-transparent pb-2 pl-2 pr-3 pt-[calc(var(--titlebar-height)+0.25rem)] text-muted-foreground transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
open ? 'translate-x-0 opacity-100' : 'pointer-events-none translate-x-2 opacity-0'
'relative flex h-screen w-full min-w-0 flex-col overflow-hidden bg-transparent pb-2 pl-2 pr-3 pt-[calc(var(--titlebar-height)+0.25rem)] text-muted-foreground transition-none',
open ? 'opacity-100' : 'pointer-events-none opacity-0'
)}
data-open={open}
>
@@ -132,7 +132,7 @@ function WorkspaceSection({
<SectionLabel>cwd</SectionLabel>
{editing ? (
<Input
className="h-7 bg-background px-2 font-mono text-[0.6875rem]"
className={cn(bleed, 'h-7 bg-background px-1.5 font-mono text-[0.6875rem]')}
onBlur={apply}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
@@ -152,7 +152,8 @@ function WorkspaceSection({
<div
className={cn(
quietControl,
'group grid w-full min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1 px-1.5 py-1 font-mono text-[0.6875rem] text-foreground/75'
bleed,
'group grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1 px-1.5 py-1 font-mono text-[0.6875rem] text-foreground/75'
)}
>
<button
@@ -0,0 +1,105 @@
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react'
import * as React from 'react'
import { cn } from '@/lib/utils'
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
return (
<nav
aria-label="pagination"
className={cn('mx-auto flex w-full justify-center', className)}
data-slot="pagination"
{...props}
/>
)
}
function PaginationContent({ className, ...props }: React.ComponentProps<'ul'>) {
return <ul className={cn('flex h-5 flex-row items-center gap-0.5', className)} data-slot="pagination-content" {...props} />
}
function PaginationItem({ className, ...props }: React.ComponentProps<'li'>) {
return <li className={cn('flex h-5 items-center', className)} data-slot="pagination-item" {...props} />
}
interface PaginationButtonProps extends React.ComponentProps<'button'> {
isActive?: boolean
}
function PaginationButton({ className, isActive, ...props }: PaginationButtonProps) {
return (
<button
aria-current={isActive ? 'page' : undefined}
className={cn(
'inline-flex h-5 min-w-5 items-center justify-center rounded border border-transparent px-1 text-[0.6875rem] leading-none tabular-nums transition-colors disabled:pointer-events-none disabled:opacity-45',
isActive
? 'border-border bg-background text-foreground shadow-xs'
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
className
)}
data-active={isActive}
data-slot="pagination-button"
type="button"
{...props}
/>
)
}
function PaginationPrevious({ className, ...props }: React.ComponentProps<'button'>) {
return (
<button
aria-label="Go to previous page"
className={cn(
'inline-flex h-5 items-center justify-center gap-0.5 rounded border border-transparent px-1 text-[0.6875rem] leading-none text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-45',
className
)}
data-slot="pagination-previous"
type="button"
{...props}
>
<ChevronLeft className="size-3" />
<span>Prev</span>
</button>
)
}
function PaginationNext({ className, ...props }: React.ComponentProps<'button'>) {
return (
<button
aria-label="Go to next page"
className={cn(
'inline-flex h-5 items-center justify-center gap-0.5 rounded border border-transparent px-1 text-[0.6875rem] leading-none text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-45',
className
)}
data-slot="pagination-next"
type="button"
{...props}
>
<span>Next</span>
<ChevronRight className="size-3" />
</button>
)
}
function PaginationEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
aria-hidden
className={cn('flex size-5 items-center justify-center', className)}
data-slot="pagination-ellipsis"
{...props}
>
<MoreHorizontal className="size-3" />
</span>
)
}
export {
Pagination,
PaginationButton,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationNext,
PaginationPrevious
}