chore: uptick
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user