mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-14 14:12:44 +00:00
refactor(desktop): colocate hook/component families into scoped folders
Single-scoped helpers/sub-files were sitting flat in shared/grab-bag dirs.
Fold each family into its own folder (index = the export, dir resolution keeps
public import paths intact), dropping the now-redundant filename prefix:
- session/hooks/use-prompt-actions.ts (+ -utils, + tests)
-> use-prompt-actions/{index,utils}.ts (+ tests)
- components/assistant-ui/thread* + assistant/system/user message renderers
-> assistant-ui/thread/{index,content,status,message-parts,timestamp,types,
list,timeline,timeline-data,assistant-message,system-message,user-message,
user-edit-composer,user-message-text} (+ tests)
- components/assistant-ui/tool-fallback(+model)/tool-approval
-> assistant-ui/tool/{fallback,fallback-model,approval} (+ tests)
Pure move + import rewrites; no behaviour change. App-wide shared primitives
(markdown-text, directive-text, tooltip-icon-button, clarify-tool, ansi-text,
message-render-boundary) stay flat. desktop-controller intentionally left in
app/ (route root; foldering would churn ~80 relative imports for no gain).
This commit is contained in:
parent
c9269fbfb6
commit
fa7bce0789
32 changed files with 40 additions and 40 deletions
|
|
@ -0,0 +1,258 @@
|
|||
import {
|
||||
ActionBarPrimitive,
|
||||
BranchPickerPrimitive,
|
||||
ErrorPrimitive,
|
||||
MessagePrimitive,
|
||||
useAuiState,
|
||||
useMessageRuntime
|
||||
} from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type FC, useCallback, useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
contentHasVisibleText,
|
||||
messageContentText,
|
||||
pickPrimaryPreviewTarget
|
||||
} from '@/components/assistant-ui/thread/content'
|
||||
import { MESSAGE_PARTS_COMPONENTS } from '@/components/assistant-ui/thread/message-parts'
|
||||
import { StreamStallIndicator } from '@/components/assistant-ui/thread/status'
|
||||
import { formatMessageTimestamp } from '@/components/assistant-ui/thread/timestamp'
|
||||
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
|
||||
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon, XIcon } from '@/lib/icons'
|
||||
import { extractPreviewTargets } from '@/lib/preview-targets'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $voicePlayback } from '@/store/voice-playback'
|
||||
|
||||
interface MessageActionProps {
|
||||
messageId: string
|
||||
/** Lazy accessor — reads the live message text at action time. Passing the
|
||||
* text itself as a prop forces the whole footer to re-render on every
|
||||
* streaming delta flush (the text changes ~30×/s), which profiling showed
|
||||
* was a large slice of per-token script time on long transcripts. */
|
||||
getMessageText: () => string
|
||||
onBranchInNewChat?: (messageId: string) => void
|
||||
}
|
||||
|
||||
export const AssistantMessage: FC<{
|
||||
onBranchInNewChat?: (messageId: string) => void
|
||||
onDismissError?: (messageId: string) => void
|
||||
}> = ({ onBranchInNewChat, onDismissError }) => {
|
||||
const messageId = useAuiState(s => s.message.id)
|
||||
const messageRuntime = useMessageRuntime()
|
||||
const { t } = useI18n()
|
||||
|
||||
// PERF: this component must NOT subscribe to the streaming text. Every
|
||||
// selector here returns a value that stays referentially stable across
|
||||
// token flushes (booleans, status strings, '' while running), so the
|
||||
// 30 Hz delta stream only re-renders the markdown part and the tiny
|
||||
// StreamStallIndicator leaf — not the footer/preview/root subtree.
|
||||
const messageStatus = useAuiState(s => s.message.status?.type)
|
||||
const isRunning = messageStatus === 'running'
|
||||
const isPlaceholder = useAuiState(s => s.message.status?.type === 'running' && s.message.content.length === 0)
|
||||
const hasVisibleText = useAuiState(s => contentHasVisibleText(s.message.content))
|
||||
|
||||
// Preview targets only materialize once the turn completes — while running
|
||||
// the selector returns '' (stable), so per-token flushes skip the regex
|
||||
// scan and the re-render it would cause.
|
||||
const completedText = useAuiState(s =>
|
||||
s.message.status?.type === 'running' ? '' : messageContentText(s.message.content)
|
||||
)
|
||||
|
||||
const previewTargets = useMemo(() => {
|
||||
if (!completedText || !/(https?:\/\/|file:\/\/)/i.test(completedText)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return pickPrimaryPreviewTarget(extractPreviewTargets(completedText))
|
||||
}, [completedText])
|
||||
|
||||
const getMessageText = useCallback(() => messageContentText(messageRuntime.getState().content), [messageRuntime])
|
||||
|
||||
const enterRef = useEnterAnimation(isRunning, `assistant-message:${messageId}`)
|
||||
|
||||
if (isPlaceholder) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="group flex w-full min-w-0 max-w-full flex-col gap-0 self-start overflow-hidden"
|
||||
data-role="assistant"
|
||||
data-slot="aui_assistant-message-root"
|
||||
data-streaming={isRunning ? 'true' : undefined}
|
||||
ref={enterRef}
|
||||
>
|
||||
<div
|
||||
className="wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground"
|
||||
data-slot="aui_assistant-message-content"
|
||||
>
|
||||
{/* Todos render in the composer status stack now, not inline. */}
|
||||
<MessagePrimitive.Parts components={MESSAGE_PARTS_COMPONENTS} />
|
||||
{isRunning && <StreamStallIndicator />}
|
||||
{previewTargets.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{previewTargets.map(target => (
|
||||
<PreviewAttachment key={target} source="explicit-link" target={target} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<MessagePrimitive.Error>
|
||||
<ErrorPrimitive.Root
|
||||
className="mt-1.5 flex items-start gap-1.5 text-[0.78rem] leading-5 text-[color-mix(in_srgb,var(--dt-destructive)_78%,var(--ui-text-secondary))]"
|
||||
role="alert"
|
||||
>
|
||||
<ErrorPrimitive.Message className="min-w-0 flex-1" />
|
||||
{onDismissError && (
|
||||
<TooltipIconButton
|
||||
className="-my-0.5 shrink-0 text-current opacity-70 hover:opacity-100"
|
||||
onClick={() => onDismissError(messageId)}
|
||||
side="top"
|
||||
tooltip={t.assistant.thread.dismissError}
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</TooltipIconButton>
|
||||
)}
|
||||
</ErrorPrimitive.Root>
|
||||
</MessagePrimitive.Error>
|
||||
</div>
|
||||
{hasVisibleText && (
|
||||
<AssistantFooter getMessageText={getMessageText} messageId={messageId} onBranchInNewChat={onBranchInNewChat} />
|
||||
)}
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const AssistantActionBar: FC<MessageActionProps> = ({ messageId, getMessageText, onBranchInNewChat }) => {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.thread
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="relative flex w-full shrink-0 justify-end">
|
||||
<ActionBarPrimitive.Root
|
||||
className={cn(
|
||||
// NOTE: intentionally NOT `hideWhenRunning`. That prop unmounts the
|
||||
// bar while the thread streams, which collapses every completed
|
||||
// assistant message's footer by this bar's height and shifts the
|
||||
// whole conversation when the turn resolves. The bar is already
|
||||
// invisible by default (opacity-0 + pointer-events-none, reveals on
|
||||
// hover), so keeping it mounted reserves stable layout height with
|
||||
// no visual change during streaming.
|
||||
'relative flex flex-row items-center justify-end gap-2 py-1.5 opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100',
|
||||
menuOpen && 'pointer-events-auto opacity-100 [&_button]:opacity-100'
|
||||
)}
|
||||
data-slot="aui_msg-actions"
|
||||
>
|
||||
<CopyButton appearance="icon" buttonSize="icon" label={copy.copy} text={getMessageText} />
|
||||
<ActionBarPrimitive.Reload asChild>
|
||||
<TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip={copy.refresh}>
|
||||
<Codicon name="refresh" />
|
||||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.Reload>
|
||||
<DropdownMenu onOpenChange={setMenuOpen} open={menuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<TooltipIconButton tooltip={copy.moreActions}>
|
||||
<Codicon name="ellipsis" />
|
||||
</TooltipIconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" onCloseAutoFocus={e => e.preventDefault()} sideOffset={6}>
|
||||
<MessageTimestamp />
|
||||
<DropdownMenuItem onSelect={() => onBranchInNewChat?.(messageId)}>
|
||||
<GitBranchIcon />
|
||||
{copy.branchNewChat}
|
||||
</DropdownMenuItem>
|
||||
<ReadAloudItem getText={getMessageText} messageId={messageId} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</ActionBarPrimitive.Root>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ReadAloudItem: FC<{ getText: () => string; messageId: string }> = ({ getText, messageId }) => {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.thread
|
||||
const voicePlayback = useStore($voicePlayback)
|
||||
|
||||
const readAloudStatus =
|
||||
voicePlayback.source === 'read-aloud' && voicePlayback.messageId === messageId ? voicePlayback.status : 'idle'
|
||||
|
||||
const isPreparing = readAloudStatus === 'preparing'
|
||||
const isSpeaking = readAloudStatus === 'speaking'
|
||||
const anyPlaybackActive = voicePlayback.status !== 'idle'
|
||||
const Icon = isPreparing ? Loader2Icon : isSpeaking ? VolumeXIcon : Volume2Icon
|
||||
|
||||
const read = useCallback(async () => {
|
||||
const text = getText()
|
||||
|
||||
if (!text || $voicePlayback.get().status !== 'idle') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await playSpeechText(text, { messageId, source: 'read-aloud' })
|
||||
} catch (error) {
|
||||
notifyError(error, copy.readAloudFailed)
|
||||
}
|
||||
}, [copy.readAloudFailed, getText, messageId])
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
disabled={isPreparing || (!isSpeaking && anyPlaybackActive)}
|
||||
onSelect={e => {
|
||||
e.preventDefault()
|
||||
void (isSpeaking ? stopVoicePlayback() : read())
|
||||
}}
|
||||
>
|
||||
<Icon className={isPreparing ? 'animate-spin' : undefined} />
|
||||
{isPreparing ? copy.preparingAudio : isSpeaking ? copy.stopReading : copy.readAloud}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
const MessageTimestamp: FC = () => {
|
||||
const { t } = useI18n()
|
||||
const createdAt = useAuiState(s => s.message.createdAt)
|
||||
const label = formatMessageTimestamp(createdAt, t.assistant.thread)
|
||||
|
||||
if (!label) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <DropdownMenuLabel className="text-xs font-normal text-muted-foreground">{label}</DropdownMenuLabel>
|
||||
}
|
||||
|
||||
const AssistantFooter: FC<MessageActionProps> = props => (
|
||||
<div className="flex min-h-6 flex-col items-end gap-1 pr-(--message-text-indent) pl-(--message-text-indent)">
|
||||
<BranchPickerPrimitive.Root
|
||||
className="inline-flex h-6 items-center gap-1 text-xs text-muted-foreground"
|
||||
hideWhenSingleBranch
|
||||
>
|
||||
<BranchPickerPrimitive.Previous className="grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35">
|
||||
<Codicon name="chevron-left" size="0.875rem" />
|
||||
</BranchPickerPrimitive.Previous>
|
||||
<span className="tabular-nums">
|
||||
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
|
||||
</span>
|
||||
<BranchPickerPrimitive.Next className="grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35">
|
||||
<Codicon name="chevron-right" size="0.875rem" />
|
||||
</BranchPickerPrimitive.Next>
|
||||
</BranchPickerPrimitive.Root>
|
||||
<AssistantActionBar {...props} />
|
||||
</div>
|
||||
)
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
// Lists and blockquotes have chrome beside the text (markers, the quote
|
||||
// border) whose side is driven by the box's CSS direction, which the
|
||||
// unicode-bidi:plaintext rules never touch. These tests pin the split of
|
||||
// responsibilities: ul/ol/blockquote carry dir="auto" so the browser
|
||||
// resolves their box direction from content, inline code carries dir="ltr"
|
||||
// so it neither votes in that resolution nor reorders, and plain prose
|
||||
// blocks stay attribute-free (the plaintext CSS owns them). jsdom does not
|
||||
// resolve dir="auto", so the contract is asserted at the attribute level.
|
||||
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { Thread } from '.'
|
||||
|
||||
const createdAt = new Date('2026-06-01T00:00:00.000Z')
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
window.setTimeout(() => callback(performance.now()), 0)
|
||||
)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
|
||||
|
||||
Element.prototype.scrollTo = function scrollTo() {}
|
||||
|
||||
function stubOffsetDimension(
|
||||
prop: 'offsetHeight' | 'offsetWidth',
|
||||
clientProp: 'clientHeight' | 'clientWidth',
|
||||
fallback: number
|
||||
) {
|
||||
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, prop, {
|
||||
configurable: true,
|
||||
get() {
|
||||
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
|
||||
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
|
||||
|
||||
function userMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'hi' }],
|
||||
attachments: [],
|
||||
createdAt,
|
||||
metadata: { custom: {} }
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function assistantMessage(text: string): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text }],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function Harness({ text }: { text: string }) {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [userMessage(), assistantMessage(text)],
|
||||
isRunning: false,
|
||||
onNew: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('block-level direction chrome', () => {
|
||||
it('lists carry dir="auto" so markers follow the resolved direction', async () => {
|
||||
render(<Harness text={'מקומות:\n\n1. חוף גורדון\n2. שוק הכרמל\n\n- פריט\n- item'} />)
|
||||
|
||||
const item = await screen.findByText(/חוף גורדון/)
|
||||
|
||||
expect(item.closest('ol')?.getAttribute('dir')).toBe('auto')
|
||||
|
||||
const bullet = await screen.findByText(/פריט/)
|
||||
|
||||
expect(bullet.closest('ul')?.getAttribute('dir')).toBe('auto')
|
||||
})
|
||||
|
||||
it('blockquotes carry dir="auto" so the border follows the resolved direction', async () => {
|
||||
render(<Harness text={'> ציטוט קצר בעברית'} />)
|
||||
|
||||
const quote = await screen.findByText(/ציטוט קצר/)
|
||||
|
||||
expect(quote.closest('blockquote')?.getAttribute('dir')).toBe('auto')
|
||||
})
|
||||
|
||||
it('inline code carries dir="ltr" so it does not vote in dir="auto" resolution', async () => {
|
||||
render(<Harness text={'1. `npm install` מתקין תלויות'} />)
|
||||
|
||||
const code = await screen.findByText('npm install')
|
||||
|
||||
expect(code.tagName).toBe('CODE')
|
||||
expect(code.getAttribute('dir')).toBe('ltr')
|
||||
expect(code.closest('ol')?.getAttribute('dir')).toBe('auto')
|
||||
})
|
||||
|
||||
it('plain prose blocks stay attribute-free (plaintext CSS owns them)', async () => {
|
||||
render(<Harness text={'שלום לכולם'} />)
|
||||
|
||||
const paragraph = await screen.findByText(/שלום לכולם/)
|
||||
|
||||
expect(paragraph.closest('p')?.hasAttribute('dir')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
contentHasVisibleText,
|
||||
messageAttachmentRefs,
|
||||
messageContentText,
|
||||
partText,
|
||||
pickPrimaryPreviewTarget
|
||||
} from './content'
|
||||
|
||||
describe('partText', () => {
|
||||
it('returns plain strings as-is', () => {
|
||||
expect(partText('hello')).toBe('hello')
|
||||
})
|
||||
|
||||
it('reads text from untyped and text parts', () => {
|
||||
expect(partText({ text: 'a' })).toBe('a')
|
||||
expect(partText({ type: 'text', text: 'b' })).toBe('b')
|
||||
})
|
||||
|
||||
it('ignores non-text parts and malformed input', () => {
|
||||
expect(partText({ type: 'tool', text: 'x' })).toBe('')
|
||||
expect(partText({ text: 42 })).toBe('')
|
||||
expect(partText(null)).toBe('')
|
||||
expect(partText(undefined)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('messageContentText', () => {
|
||||
it('trims string content', () => {
|
||||
expect(messageContentText(' hi ')).toBe('hi')
|
||||
})
|
||||
|
||||
it('concatenates array text parts and trims', () => {
|
||||
expect(messageContentText([{ text: ' a' }, { type: 'text', text: 'b ' }])).toBe('ab')
|
||||
})
|
||||
|
||||
it('returns empty string for non-string, non-array content', () => {
|
||||
expect(messageContentText(null)).toBe('')
|
||||
expect(messageContentText({ text: 'x' })).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('contentHasVisibleText', () => {
|
||||
it('detects visible text in strings and arrays', () => {
|
||||
expect(contentHasVisibleText('hi')).toBe(true)
|
||||
expect(contentHasVisibleText([{ text: ' ' }, { text: 'x' }])).toBe(true)
|
||||
})
|
||||
|
||||
it('is false when there is no visible text', () => {
|
||||
expect(contentHasVisibleText(' ')).toBe(false)
|
||||
expect(contentHasVisibleText([{ text: ' ' }, { type: 'tool', text: 'y' }])).toBe(false)
|
||||
expect(contentHasVisibleText(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('messageAttachmentRefs', () => {
|
||||
it('returns string arrays untouched', () => {
|
||||
const value = ['@file:a', '@file:b']
|
||||
expect(messageAttachmentRefs(value)).toBe(value)
|
||||
})
|
||||
|
||||
it('returns a stable empty array for invalid input', () => {
|
||||
const a = messageAttachmentRefs(null)
|
||||
const b = messageAttachmentRefs([1, 2])
|
||||
expect(a).toEqual([])
|
||||
expect(a).toBe(b)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pickPrimaryPreviewTarget', () => {
|
||||
it('returns the input when one or zero targets', () => {
|
||||
expect(pickPrimaryPreviewTarget([])).toEqual([])
|
||||
expect(pickPrimaryPreviewTarget(['https://x.dev'])).toEqual(['https://x.dev'])
|
||||
})
|
||||
|
||||
it('prefers a localhost URL when present', () => {
|
||||
expect(pickPrimaryPreviewTarget(['https://example.com', 'http://localhost:3000'])).toEqual([
|
||||
'http://localhost:3000'
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to the last target when no localhost URL', () => {
|
||||
expect(pickPrimaryPreviewTarget(['https://a.dev', 'https://b.dev'])).toEqual(['https://b.dev'])
|
||||
})
|
||||
})
|
||||
63
apps/desktop/src/components/assistant-ui/thread/content.ts
Normal file
63
apps/desktop/src/components/assistant-ui/thread/content.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
const EMPTY_ATTACHMENT_REFS: string[] = []
|
||||
|
||||
export function partText(part: unknown): string {
|
||||
if (typeof part === 'string') {
|
||||
return part
|
||||
}
|
||||
|
||||
if (!part || typeof part !== 'object') {
|
||||
return ''
|
||||
}
|
||||
|
||||
const row = part as { text?: unknown; type?: unknown }
|
||||
|
||||
return (!row.type || row.type === 'text') && typeof row.text === 'string' ? row.text : ''
|
||||
}
|
||||
|
||||
export function messageContentText(content: unknown): string {
|
||||
if (typeof content === 'string') {
|
||||
return content.trim()
|
||||
}
|
||||
|
||||
return Array.isArray(content) ? content.map(partText).join('').trim() : ''
|
||||
}
|
||||
|
||||
// Cheap streaming-stable "does this message have visible text" check: returns
|
||||
// on the first non-whitespace text part without concatenating the whole
|
||||
// message. Used as a useAuiState selector so its boolean output stays stable
|
||||
// across token flushes (flips false→true once per turn).
|
||||
export function contentHasVisibleText(content: unknown): boolean {
|
||||
if (typeof content === 'string') {
|
||||
return content.trim().length > 0
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const part of content) {
|
||||
if (partText(part).trim().length > 0) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function messageAttachmentRefs(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return EMPTY_ATTACHMENT_REFS
|
||||
}
|
||||
|
||||
return value.every(ref => typeof ref === 'string') ? value : EMPTY_ATTACHMENT_REFS
|
||||
}
|
||||
|
||||
export 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]]
|
||||
}
|
||||
119
apps/desktop/src/components/assistant-ui/thread/index.tsx
Normal file
119
apps/desktop/src/components/assistant-ui/thread/index.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { type FC, useCallback, useMemo, useState } from 'react'
|
||||
|
||||
import { AssistantMessage } from '@/components/assistant-ui/thread/assistant-message'
|
||||
import { ThreadMessageList } from '@/components/assistant-ui/thread/list'
|
||||
import {
|
||||
BackgroundResumeNotice,
|
||||
CenteredThreadSpinner,
|
||||
ResponseLoadingIndicator
|
||||
} from '@/components/assistant-ui/thread/status'
|
||||
import { SystemMessage } from '@/components/assistant-ui/thread/system-message'
|
||||
import { ThreadTimeline } from '@/components/assistant-ui/thread/timeline'
|
||||
import { type RestoreMessageTarget } from '@/components/assistant-ui/thread/types'
|
||||
import { UserEditComposer } from '@/components/assistant-ui/thread/user-edit-composer'
|
||||
import { UserMessage } from '@/components/assistant-ui/thread/user-message'
|
||||
import { Intro, type IntroProps } from '@/components/chat/intro'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
type ThreadLoadingState = 'response' | 'session'
|
||||
|
||||
export const Thread: FC<{
|
||||
clampToComposer?: boolean
|
||||
cwd?: string | null
|
||||
gateway?: HermesGateway | null
|
||||
intro?: IntroProps
|
||||
loading?: ThreadLoadingState
|
||||
onBranchInNewChat?: (messageId: string) => void
|
||||
onCancel?: () => Promise<void> | void
|
||||
onDismissError?: (messageId: string) => void
|
||||
onRestoreToMessage?: (messageId: string, target?: RestoreMessageTarget) => Promise<void> | void
|
||||
sessionId?: string | null
|
||||
sessionKey?: string | null
|
||||
}> = ({
|
||||
clampToComposer = false,
|
||||
cwd = null,
|
||||
gateway = null,
|
||||
intro,
|
||||
loading,
|
||||
onBranchInNewChat,
|
||||
onCancel,
|
||||
onDismissError,
|
||||
onRestoreToMessage,
|
||||
sessionId = null,
|
||||
sessionKey
|
||||
}) => {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.thread
|
||||
|
||||
const [restoreConfirmTarget, setRestoreConfirmTarget] = useState<
|
||||
(RestoreMessageTarget & { messageId: string }) | null
|
||||
>(null)
|
||||
|
||||
const closeRestoreConfirm = useCallback(() => setRestoreConfirmTarget(null), [])
|
||||
|
||||
const confirmRestore = useCallback(() => {
|
||||
if (!restoreConfirmTarget || !onRestoreToMessage) {
|
||||
throw new Error('Restore is unavailable for this message.')
|
||||
}
|
||||
|
||||
const { messageId, text, userOrdinal } = restoreConfirmTarget
|
||||
|
||||
closeRestoreConfirm()
|
||||
void Promise.resolve(onRestoreToMessage(messageId, { text, userOrdinal })).catch((error: unknown) => {
|
||||
notifyError(error, 'Restore failed')
|
||||
})
|
||||
}, [closeRestoreConfirm, onRestoreToMessage, restoreConfirmTarget])
|
||||
|
||||
const requestRestoreConfirm = useCallback((messageId: string, target: RestoreMessageTarget) => {
|
||||
setRestoreConfirmTarget({ messageId, ...target })
|
||||
}, [])
|
||||
|
||||
const messageComponents = useMemo(
|
||||
() => ({
|
||||
AssistantMessage: () => (
|
||||
<AssistantMessage onBranchInNewChat={onBranchInNewChat} onDismissError={onDismissError} />
|
||||
),
|
||||
SystemMessage,
|
||||
UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />,
|
||||
UserMessage: () => (
|
||||
<UserMessage
|
||||
onCancel={onCancel}
|
||||
onRequestRestoreConfirm={onRestoreToMessage ? requestRestoreConfirm : undefined}
|
||||
/>
|
||||
)
|
||||
}),
|
||||
[cwd, gateway, onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage, requestRestoreConfirm, sessionId]
|
||||
)
|
||||
|
||||
const emptyPlaceholder = intro ? (
|
||||
<div className="flex min-h-0 w-full flex-col items-center justify-center pt-[var(--composer-measured-height)]">
|
||||
<Intro {...intro} />
|
||||
</div>
|
||||
) : undefined
|
||||
|
||||
return (
|
||||
<div className="relative grid h-full min-h-0 max-w-full grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent contain-[layout_paint]">
|
||||
<ThreadMessageList
|
||||
clampToComposer={clampToComposer}
|
||||
components={messageComponents}
|
||||
emptyPlaceholder={emptyPlaceholder}
|
||||
loadingIndicator={loading === 'response' ? <ResponseLoadingIndicator /> : <BackgroundResumeNotice />}
|
||||
sessionKey={sessionKey}
|
||||
/>
|
||||
{loading === 'session' && <CenteredThreadSpinner />}
|
||||
<ThreadTimeline />
|
||||
<ConfirmDialog
|
||||
confirmLabel={copy.restoreConfirm}
|
||||
description={copy.restoreBody}
|
||||
destructive
|
||||
onClose={closeRestoreConfirm}
|
||||
onConfirm={confirmRestore}
|
||||
open={Boolean(restoreConfirmTarget)}
|
||||
title={copy.restoreTitle}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
338
apps/desktop/src/components/assistant-ui/thread/list.tsx
Normal file
338
apps/desktop/src/components/assistant-ui/thread/list.tsx
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
|
||||
import {
|
||||
type ComponentProps,
|
||||
type CSSProperties,
|
||||
type FC,
|
||||
memo,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { useStickToBottom } from 'use-stick-to-bottom'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
onScrollToBottomRequest,
|
||||
onThreadEditClose,
|
||||
onThreadEditOpen,
|
||||
resetThreadScroll,
|
||||
setThreadAtBottom
|
||||
} from '@/store/thread-scroll'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
|
||||
import { MessageRenderBoundary } from '../message-render-boundary'
|
||||
|
||||
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
|
||||
|
||||
type MessageGroup = { id: string; weight: number } & (
|
||||
| { index: number; kind: 'standalone' }
|
||||
| { indices: number[]; kind: 'turn' }
|
||||
)
|
||||
|
||||
// DOM is bounded by a rendered-PART budget, not a message/turn count: a single
|
||||
// assistant message folds every tool call into a part, so heavy sessions are
|
||||
// ~40 turns / ~100 messages but ~1000 parts — and parts are what drive node
|
||||
// count. "Show earlier" prepends another page; whole turns stay intact so the
|
||||
// sticky human bubble never loses its turn. This is the long-session perf lever
|
||||
// WITHOUT a virtualizer — pure rendering, never touches scrollTop, so it can't
|
||||
// fight use-stick-to-bottom (the single scroll owner).
|
||||
const RENDER_BUDGET = 300
|
||||
|
||||
interface ThreadMessageListProps {
|
||||
clampToComposer: boolean
|
||||
components: ThreadMessageComponents
|
||||
emptyPlaceholder?: ReactNode
|
||||
loadingIndicator?: ReactNode
|
||||
sessionKey?: string | null
|
||||
}
|
||||
|
||||
// Group each user message with the assistant turn(s) that follow it so the
|
||||
// human bubble can `position: sticky` against the scroller across its whole
|
||||
// turn (see StickyHumanMessageContainer in thread.tsx).
|
||||
function buildGroups(signature: string): MessageGroup[] {
|
||||
if (!signature) {
|
||||
return []
|
||||
}
|
||||
|
||||
const messages = signature.split('\n').map(row => {
|
||||
const [index, id, role, weight] = row.split(':')
|
||||
|
||||
return { id, index: Number(index), role, weight: Number(weight) || 1 }
|
||||
})
|
||||
|
||||
const groups: MessageGroup[] = []
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i]
|
||||
|
||||
if (message.role !== 'user') {
|
||||
groups.push({ id: message.id, index: message.index, kind: 'standalone', weight: message.weight })
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const indices = [message.index]
|
||||
let weight = message.weight
|
||||
|
||||
while (i + 1 < messages.length && messages[i + 1].role !== 'user') {
|
||||
weight += messages[++i].weight
|
||||
indices.push(messages[i].index)
|
||||
}
|
||||
|
||||
groups.push({ id: message.id, indices, kind: 'turn', weight })
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
||||
clampToComposer,
|
||||
components,
|
||||
emptyPlaceholder,
|
||||
loadingIndicator,
|
||||
sessionKey
|
||||
}) => {
|
||||
const messageSignature = useAuiState(s =>
|
||||
s.thread.messages
|
||||
.map((message, index) => `${index}:${message.id}:${message.role}:${message.content?.length ?? 1}`)
|
||||
.join('\n')
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const groups = buildGroups(messageSignature)
|
||||
const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder)
|
||||
|
||||
// use-stick-to-bottom owns scrollTop (single writer): follow while locked,
|
||||
// escape on user scroll-up, re-lock at bottom. Snap instantly, not spring — a
|
||||
// spring can't tell live-token growth from a session-switch bulk relayout, and
|
||||
// chasing the latter reads as the view scrolling to random spots before
|
||||
// settling. Its refs hang off our own DOM so the sticky human bubbles survive.
|
||||
const { scrollRef, contentRef, isAtBottom, scrollToBottom, stopScroll } = useStickToBottom({
|
||||
initial: 'instant',
|
||||
resize: 'instant'
|
||||
})
|
||||
|
||||
const [renderBudget, setRenderBudget] = useState(RENDER_BUDGET)
|
||||
|
||||
// Walk turns newest-first, summing their part weights until the budget is met;
|
||||
// everything before that first kept turn is hidden.
|
||||
let firstVisible = groups.length
|
||||
|
||||
for (let i = groups.length - 1, weight = 0; i >= 0; i--) {
|
||||
weight += groups[i].weight
|
||||
firstVisible = i
|
||||
|
||||
if (weight >= renderBudget) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const hiddenCount = firstVisible
|
||||
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
|
||||
const restoreFromBottomRef = useRef<number | null>(null)
|
||||
// Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
|
||||
// hide the titlebar tool cluster + session header, but the OS traffic lights
|
||||
// still sit in the top-left, so reserve the titlebar gap above the transcript.
|
||||
const secondaryWindow = isSecondaryWindow()
|
||||
// NB: CSS calc() requires whitespace around the +/- operator. This string is
|
||||
// assigned verbatim to the --sticky-human-top inline style below (it does not
|
||||
// go through Tailwind, which would auto-space it), so the spaces are load-
|
||||
// bearing — without them the declaration is invalid, gets dropped, and the
|
||||
// sticky user bubble falls back to its ~4px default and slides under the OS
|
||||
// traffic lights.
|
||||
const secondaryTitlebarGap = 'calc(var(--titlebar-height) + 0.75rem)'
|
||||
|
||||
const threadContentTopPad = secondaryWindow
|
||||
? 'pt-[calc(var(--titlebar-height)+0.75rem)]'
|
||||
: 'pt-[calc(var(--titlebar-height)-0.5rem)]'
|
||||
|
||||
useEffect(() => setThreadAtBottom(isAtBottom), [isAtBottom])
|
||||
useEffect(() => () => resetThreadScroll(), [])
|
||||
|
||||
// Floating jump button (outside this subtree) → return to the bottom.
|
||||
useEffect(() => onScrollToBottomRequest(() => void scrollToBottom()), [scrollToBottom])
|
||||
|
||||
const endEditHold = useCallback(() => {
|
||||
scrollRef.current?.removeAttribute('data-editing')
|
||||
}, [scrollRef])
|
||||
|
||||
// Inline edit grows a sticky bubble. Escape before focus/layout so the
|
||||
// resize-follow can't snap scrollTop; native anchoring holds the viewport.
|
||||
const beginEditHold = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
endEditHold()
|
||||
stopScroll()
|
||||
el.setAttribute('data-editing', 'true')
|
||||
}, [endEditHold, scrollRef, stopScroll])
|
||||
|
||||
useEffect(() => onThreadEditOpen(beginEditHold), [beginEditHold])
|
||||
useEffect(() => onThreadEditClose(endEditHold), [endEditHold])
|
||||
useEffect(() => () => endEditHold(), [endEditHold])
|
||||
// New run → snap to the latest turn.
|
||||
useAuiEvent('thread.runStart', () => void scrollToBottom())
|
||||
|
||||
// Reset the cap and pin to bottom on mount + every session switch (messages
|
||||
// swap in place on a long-lived runtime, so sessionKey is the only signal).
|
||||
// The swap is multi-step and lays out over many frames; letting the library
|
||||
// follow re-pins every frame to a moving target — visible as ~10 scroll jumps.
|
||||
// Instead: quiet it, glue to the true bottom until the height holds steady,
|
||||
// then hand back locked. Live streaming afterward uses the normal resize follow.
|
||||
useLayoutEffect(() => {
|
||||
setRenderBudget(RENDER_BUDGET)
|
||||
|
||||
const el = scrollRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
stopScroll()
|
||||
el.scrollTop = el.scrollHeight
|
||||
|
||||
let frame = 0
|
||||
let stableFrames = 0
|
||||
let lastHeight = el.scrollHeight
|
||||
|
||||
const settle = () => {
|
||||
const node = scrollRef.current
|
||||
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
|
||||
const height = node.scrollHeight
|
||||
|
||||
stableFrames = height === lastHeight ? stableFrames + 1 : 0
|
||||
lastHeight = height
|
||||
node.scrollTop = height
|
||||
|
||||
// ~5 steady frames ≈ layout has settled; the frame cap bounds slow loads.
|
||||
if (stableFrames >= 5 || ++frame > 90) {
|
||||
void scrollToBottom('instant')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(settle)
|
||||
}
|
||||
|
||||
let rafId = requestAnimationFrame(settle)
|
||||
|
||||
return () => cancelAnimationFrame(rafId)
|
||||
}, [scrollRef, scrollToBottom, sessionKey, stopScroll])
|
||||
|
||||
// Prepend an older page while preserving the on-screen position. The user is
|
||||
// scrolled up (reading history) so the stick-to-bottom lock is escaped and
|
||||
// won't fight this manual restore.
|
||||
const showEarlier = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
|
||||
restoreFromBottomRef.current = el ? el.scrollHeight - el.scrollTop : null
|
||||
setRenderBudget(budget => budget + RENDER_BUDGET)
|
||||
}, [scrollRef])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current
|
||||
|
||||
if (el && restoreFromBottomRef.current != null) {
|
||||
el.scrollTop = el.scrollHeight - restoreFromBottomRef.current
|
||||
restoreFromBottomRef.current = null
|
||||
}
|
||||
}, [scrollRef, renderBudget])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
|
||||
style={
|
||||
{
|
||||
height: clampToComposer ? 'var(--thread-viewport-height)' : '100%',
|
||||
...(secondaryWindow ? { '--sticky-human-top': secondaryTitlebarGap } : {})
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{secondaryWindow && (
|
||||
// Secondary windows hide the titlebar chrome, so the scroller runs to
|
||||
// the window's top edge and streamed text slides up under the OS
|
||||
// traffic lights. Content padding alone scrolls away with the text — a
|
||||
// fixed opaque strip (the titlebar's drag region) masks anything behind
|
||||
// it and keeps the window draggable, matching the main window's header.
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 top-0 z-10 h-(--titlebar-height) bg-background [-webkit-app-region:drag]"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
|
||||
data-following={isAtBottom ? 'true' : 'false'}
|
||||
data-slot="aui_thread-viewport"
|
||||
ref={scrollRef as React.RefCallback<HTMLDivElement>}
|
||||
>
|
||||
{renderEmpty ? (
|
||||
<div
|
||||
className="mx-auto grid h-full w-full max-w-(--composer-width) grid-rows-[minmax(0,1fr)_auto] min-w-0 gap-(--conversation-turn-gap) px-6 py-8"
|
||||
data-slot="aui_thread-content"
|
||||
>
|
||||
{emptyPlaceholder}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn('mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6', threadContentTopPad)}
|
||||
data-slot="aui_thread-content"
|
||||
ref={contentRef as React.RefCallback<HTMLDivElement>}
|
||||
>
|
||||
{hiddenCount > 0 && (
|
||||
<button
|
||||
className="mx-auto mb-(--conversation-turn-gap) rounded-full border border-border/65 bg-(--composer-fill) px-3 py-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={showEarlier}
|
||||
type="button"
|
||||
>
|
||||
{t.assistant.thread.showEarlier}
|
||||
</button>
|
||||
)}
|
||||
{visibleGroups.map(group => (
|
||||
<div
|
||||
className="flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)"
|
||||
key={group.id}
|
||||
>
|
||||
<MessageRenderBoundary resetKey={messageSignature}>
|
||||
{group.kind === 'turn' ? (
|
||||
<div
|
||||
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
|
||||
data-slot="aui_turn-pair"
|
||||
>
|
||||
{group.indices.map(index => (
|
||||
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
|
||||
)}
|
||||
</MessageRenderBoundary>
|
||||
</div>
|
||||
))}
|
||||
{loadingIndicator}
|
||||
{clampToComposer && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="shrink-0"
|
||||
data-slot="aui_composer-clearance"
|
||||
style={{ height: 'var(--thread-last-message-clearance)' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ThreadMessageList = memo(ThreadMessageListInner)
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react'
|
||||
import { type ComponentProps, type FC, type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
|
||||
import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
|
||||
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool/fallback'
|
||||
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { DisclosureRow } from '@/components/chat/disclosure-row'
|
||||
import { GeneratedImage } from '@/components/chat/generated-image-result'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ args, result }) => {
|
||||
const aspectRatio = typeof args?.aspect_ratio === 'string' ? args.aspect_ratio : undefined
|
||||
|
||||
return (
|
||||
<div className="mt-1.5">
|
||||
<GeneratedImage aspectRatio={aspectRatio} result={result} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChainToolFallback: FC<ToolCallMessagePartProps> = props => {
|
||||
// todo parts are hoisted to a dedicated panel above the message content.
|
||||
if (props.toolName === 'todo') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (props.toolName === 'image_generate') {
|
||||
return <ImageGenerateTool {...props} />
|
||||
}
|
||||
|
||||
if (props.toolName === 'clarify') {
|
||||
return <ClarifyTool {...props} />
|
||||
}
|
||||
|
||||
return <ToolFallback {...props} />
|
||||
}
|
||||
|
||||
const ThinkingDisclosure: FC<{
|
||||
children: ReactNode
|
||||
messageRunning?: boolean
|
||||
pending?: boolean
|
||||
timerKey?: string
|
||||
}> = ({ children, messageRunning = false, pending = false, timerKey }) => {
|
||||
const { t } = useI18n()
|
||||
// `null` = no explicit user toggle yet, defer to the streaming default.
|
||||
// The default is "auto-open while streaming, auto-collapse when done" so
|
||||
// reasoning surfaces a live preview without manual interaction. The first
|
||||
// explicit toggle wins from then on.
|
||||
const [userOpen, setUserOpen] = useState<boolean | null>(null)
|
||||
const elapsed = useElapsedSeconds(pending, timerKey)
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const contentRef = useRef<HTMLDivElement | null>(null)
|
||||
const enterRef = useEnterAnimation(messageRunning, timerKey)
|
||||
|
||||
const open = userOpen ?? pending
|
||||
const isPreview = pending && userOpen === null
|
||||
|
||||
// While the preview is live, pin the scroll container to the bottom on
|
||||
// every content growth so the latest tokens are always visible. Combined
|
||||
// with the top mask in styles.css, this reads as text settling in from
|
||||
// below while older lines fade out at the top.
|
||||
useEffect(() => {
|
||||
if (!isPreview) {
|
||||
return
|
||||
}
|
||||
|
||||
const el = scrollRef.current
|
||||
const content = contentRef.current
|
||||
|
||||
if (!el || !content) {
|
||||
return
|
||||
}
|
||||
|
||||
const pin = () => {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
|
||||
pin()
|
||||
const observer = new ResizeObserver(pin)
|
||||
observer.observe(content)
|
||||
|
||||
return () => observer.disconnect()
|
||||
// Re-run when the disclosure toggles so the observer attaches to the new
|
||||
// DOM after expand/collapse (refs are conditionally rendered on `open`).
|
||||
}, [isPreview, open])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)"
|
||||
data-slot="aui_thinking-disclosure"
|
||||
ref={enterRef}
|
||||
>
|
||||
<DisclosureRow onToggle={() => setUserOpen(!open)} open={open}>
|
||||
<span className="flex min-w-0 items-baseline gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[length:var(--conversation-tool-font-size)] font-medium leading-(--conversation-line-height) text-(--ui-text-secondary)',
|
||||
pending && 'shimmer text-foreground/55'
|
||||
)}
|
||||
>
|
||||
{t.assistant.thread.thinking}
|
||||
</span>
|
||||
{pending && (
|
||||
<ActivityTimerText
|
||||
className="text-[length:var(--conversation-caption-font-size)] tabular-nums text-(--ui-text-tertiary)"
|
||||
seconds={elapsed}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</DisclosureRow>
|
||||
{open && (
|
||||
<div
|
||||
className={cn(
|
||||
// Body sits flush with the "Thinking" header — no left indent —
|
||||
// and inherits the disclosure-level opacity fade defined in
|
||||
// styles.css (~0.67 at rest, 1 on hover/focus).
|
||||
'mt-0.5 w-full min-w-0 max-w-full overflow-hidden wrap-anywhere pb-1',
|
||||
isPreview && 'thinking-preview max-h-40'
|
||||
)}
|
||||
ref={scrollRef}
|
||||
>
|
||||
<div ref={contentRef}>{children}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Self-gate "Thinking…" on this message's own reasoning parts. Reading
|
||||
// `thread.isRunning` directly would flicker shimmer/timer on every old
|
||||
// assistant whenever the external-store runtime clears+reimports its
|
||||
// repository (one ref-identity bump per streaming delta).
|
||||
const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; startIndex: number }> = ({
|
||||
children,
|
||||
endIndex,
|
||||
startIndex
|
||||
}) => {
|
||||
const messageId = useAuiState(s => s.message.id)
|
||||
const messageRunning = useAuiState(s => s.message.status?.type === 'running')
|
||||
|
||||
const pending = useAuiState(
|
||||
s =>
|
||||
s.thread.isRunning &&
|
||||
s.message.status?.type === 'running' &&
|
||||
s.message.parts
|
||||
.slice(Math.max(0, startIndex), endIndex + 1)
|
||||
.some(p => p?.type === 'reasoning' && p.status?.type !== 'complete')
|
||||
)
|
||||
|
||||
// A reasoning group with no actual text is pure noise — drop the whole
|
||||
// "Thinking" disclosure rather than leave an empty header eating a row. This
|
||||
// applies live too: encrypted/spinner-coerced reasoning (Opus reasoning max)
|
||||
// never carries visible text, and the bottom-of-thread loader already signals
|
||||
// "thinking", so an empty header is never wanted. Real reasoning surfaces the
|
||||
// instant its first token lands.
|
||||
const hasContent = useAuiState(s =>
|
||||
s.message.parts
|
||||
.slice(Math.max(0, startIndex), endIndex + 1)
|
||||
.some(p => p?.type === 'reasoning' && typeof p.text === 'string' && p.text.trim().length > 0)
|
||||
)
|
||||
|
||||
if (!hasContent) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ThinkingDisclosure messageRunning={messageRunning} pending={pending} timerKey={`reasoning:${messageId}`}>
|
||||
{children}
|
||||
</ThinkingDisclosure>
|
||||
)
|
||||
}
|
||||
|
||||
const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => {
|
||||
const displayText = text.trimStart()
|
||||
const messageRunning = useAuiState(s => s.message.status?.type === 'running')
|
||||
const isRunning = status?.type === 'running' || messageRunning
|
||||
|
||||
return (
|
||||
<MarkdownTextContent
|
||||
containerClassName="text-xs leading-snug text-muted-foreground/85"
|
||||
containerProps={{ 'data-slot': 'aui_reasoning-text' } as ComponentProps<'div'>}
|
||||
isRunning={isRunning}
|
||||
text={displayText}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Module-level constant so the `components` prop on `MessagePrimitive.Parts`
|
||||
// has a stable identity across renders. Without this every AssistantMessage
|
||||
// render would create a fresh `components` object, invalidating the memo on
|
||||
// `MessagePrimitivePartByIndex` and forcing every tool/reasoning child to
|
||||
// re-render on every streaming delta. Memo invalidation alone doesn't
|
||||
// remount, but combined with the previous ToolFallback group-swap it was a
|
||||
// big chunk of the per-delta work.
|
||||
export const MESSAGE_PARTS_COMPONENTS = {
|
||||
Reasoning: ReasoningTextPart,
|
||||
ReasoningGroup: ReasoningAccordionGroup,
|
||||
Text: MarkdownText,
|
||||
ToolGroup: ToolGroupSlot,
|
||||
tools: { Fallback: ChainToolFallback }
|
||||
} as const
|
||||
167
apps/desktop/src/components/assistant-ui/thread/status.tsx
Normal file
167
apps/desktop/src/components/assistant-ui/thread/status.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import { useAuiState } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type FC, type ReactNode, useEffect, useState } from 'react'
|
||||
|
||||
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $backgroundResume } from '@/store/background-delegation'
|
||||
import { $compactionActive } from '@/store/compaction'
|
||||
import { $activeSessionAwaitingInput } from '@/store/prompts'
|
||||
|
||||
const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentPropsWithoutRef<'div'>> = ({
|
||||
children,
|
||||
label,
|
||||
className,
|
||||
...rest
|
||||
}) => (
|
||||
<div
|
||||
aria-label={label}
|
||||
aria-live="polite"
|
||||
className={cn('flex max-w-full items-center gap-2 self-start text-sm text-muted-foreground/70', className)}
|
||||
role="status"
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Fixed label while auto-compaction runs — decoupled from backend status text.
|
||||
const COMPACTION_LABEL = 'Summarizing thread'
|
||||
|
||||
const CompactionHint: FC = () => (
|
||||
<span className="shimmer min-w-0 truncate text-muted-foreground/55">{COMPACTION_LABEL}</span>
|
||||
)
|
||||
|
||||
export const CenteredThreadSpinner: FC = () => {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label={t.assistant.thread.loadingSession}
|
||||
className="pointer-events-none absolute inset-0 z-1 grid place-items-center"
|
||||
role="status"
|
||||
>
|
||||
<Loader
|
||||
aria-hidden="true"
|
||||
className="size-12 text-midground/70"
|
||||
pathSteps={220}
|
||||
role="presentation"
|
||||
strokeScale={0.72}
|
||||
type="rose-curve"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ResponseLoadingIndicator: FC = () => {
|
||||
const { t } = useI18n()
|
||||
const elapsed = useElapsedSeconds()
|
||||
const compacting = useStore($compactionActive)
|
||||
|
||||
return (
|
||||
<StatusRow
|
||||
data-slot="aui_response-loading"
|
||||
label={compacting ? COMPACTION_LABEL : t.assistant.thread.loadingResponse}
|
||||
>
|
||||
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
|
||||
{compacting && <CompactionHint />}
|
||||
<ActivityTimerText seconds={elapsed} />
|
||||
</StatusRow>
|
||||
)
|
||||
}
|
||||
|
||||
// Parked-background affordance: a top-level delegate_task runs in the
|
||||
// background, so the parent turn ends and the app goes idle while the subagent
|
||||
// keeps working and its result re-enters as a fresh turn later. Instead of a
|
||||
// spinner (reads as "stuck"), reuse the same compact, centered system-note
|
||||
// chrome as the steer / slash-status lines (SystemMessage above) so it sits in
|
||||
// the thread like every other meta line. Idle-only (gated upstream). Null when
|
||||
// nothing is parked.
|
||||
export const BackgroundResumeNotice: FC = () => {
|
||||
const { t } = useI18n()
|
||||
const resume = useStore($backgroundResume)
|
||||
|
||||
if (!resume) {
|
||||
return null
|
||||
}
|
||||
|
||||
const label = resume.activity ?? t.assistant.thread.resumeWhenBackgroundDone(resume.count)
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="flex max-w-[min(86%,44rem)] items-center gap-1.5 self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/55"
|
||||
data-slot="aui_background-resume"
|
||||
role="status"
|
||||
>
|
||||
<Codicon className="text-muted-foreground/55" name="sync" size="0.75rem" />
|
||||
<span className="shimmer min-w-0 truncate">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Seconds of no visible output (text or part count) before a still-running turn
|
||||
// is treated as stalled and the thinking indicator returns at the tail.
|
||||
const STREAM_STALL_S = 2
|
||||
|
||||
// Tail "still thinking" indicator: the pre-first-token spinner goes away once
|
||||
// text flows, but if the stream then goes quiet mid-turn (tool think-time,
|
||||
// provider stall) nothing signals that work continues. Watch a per-flush
|
||||
// activity signal; when it hasn't changed for STREAM_STALL_S, re-show the
|
||||
// dither + a timer counting from the last activity.
|
||||
//
|
||||
// Subscribes to the activity signal ITSELF (rather than taking it as a prop)
|
||||
// so that per-token updates re-render only this leaf, not the whole
|
||||
// AssistantMessage subtree.
|
||||
export const StreamStallIndicator: FC = () => {
|
||||
const activity = useAuiState(s => {
|
||||
let textLength = 0
|
||||
|
||||
for (const part of s.message.content) {
|
||||
const text = (part as { text?: unknown }).text
|
||||
|
||||
if (typeof text === 'string') {
|
||||
textLength += text.length
|
||||
}
|
||||
}
|
||||
|
||||
return `${s.message.content.length}:${textLength}`
|
||||
})
|
||||
|
||||
const [stalled, setStalled] = useState(false)
|
||||
const compacting = useStore($compactionActive)
|
||||
// A pending clarify / approval / sudo / secret means the turn is paused on the
|
||||
// user, not working — so don't resurrect the "thinking" timer while they
|
||||
// decide (matches the pet's awaitingInput pose taking priority over busy).
|
||||
const awaitingInput = useStore($activeSessionAwaitingInput)
|
||||
|
||||
useEffect(() => {
|
||||
setStalled(false)
|
||||
const id = window.setTimeout(() => setStalled(true), STREAM_STALL_S * 1000)
|
||||
|
||||
return () => window.clearTimeout(id)
|
||||
}, [activity])
|
||||
|
||||
const active = (stalled || compacting) && !awaitingInput
|
||||
const elapsed = useElapsedSeconds(active)
|
||||
|
||||
if (!active) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusRow
|
||||
className="mt-1.5"
|
||||
data-slot="aui_stream-stall"
|
||||
label={compacting ? COMPACTION_LABEL : 'Hermes is thinking'}
|
||||
>
|
||||
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
|
||||
{compacting && <CompactionHint />}
|
||||
<ActivityTimerText seconds={elapsed} />
|
||||
</StatusRow>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,552 @@
|
|||
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { Thread } from '.'
|
||||
|
||||
const createdAt = new Date('2026-05-01T00:00:00.000Z')
|
||||
|
||||
const resizeObservers = new Set<TestResizeObserver>()
|
||||
|
||||
class TestResizeObserver {
|
||||
private target: Element | null = null
|
||||
|
||||
constructor(private readonly callback: ResizeObserverCallback) {
|
||||
resizeObservers.add(this)
|
||||
}
|
||||
|
||||
observe(target: Element) {
|
||||
this.target = target
|
||||
}
|
||||
|
||||
unobserve() {}
|
||||
|
||||
disconnect() {
|
||||
resizeObservers.delete(this)
|
||||
}
|
||||
|
||||
trigger(height: number) {
|
||||
if (!this.target) {
|
||||
return
|
||||
}
|
||||
|
||||
this.callback(
|
||||
[
|
||||
{
|
||||
contentRect: { height } as DOMRectReadOnly,
|
||||
target: this.target
|
||||
} as ResizeObserverEntry
|
||||
],
|
||||
this as unknown as ResizeObserver
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
window.setTimeout(() => callback(performance.now()), 0)
|
||||
)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
|
||||
|
||||
Element.prototype.scrollTo = function scrollTo() {}
|
||||
|
||||
Element.prototype.animate = function animate() {
|
||||
return {
|
||||
cancel: () => {},
|
||||
finished: Promise.resolve()
|
||||
} as unknown as Animation
|
||||
}
|
||||
|
||||
// jsdom returns 0 for offset*; some layout code reads those to size the
|
||||
// viewport. Fall through to client* (which tests can override) or a sane
|
||||
// default so message rows render with non-zero dimensions.
|
||||
function stubOffsetDimension(
|
||||
prop: 'offsetHeight' | 'offsetWidth',
|
||||
clientProp: 'clientHeight' | 'clientWidth',
|
||||
fallback: number
|
||||
) {
|
||||
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, prop, {
|
||||
configurable: true,
|
||||
get() {
|
||||
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
|
||||
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
|
||||
|
||||
async function wait(ms: number) {
|
||||
await act(async () => {
|
||||
await new Promise(resolve => window.setTimeout(resolve, ms))
|
||||
})
|
||||
}
|
||||
|
||||
function userMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Stream a response' }],
|
||||
attachments: [],
|
||||
createdAt,
|
||||
metadata: { custom: {} }
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function assistantMessage(text: string, running = true): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text }],
|
||||
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function assistantErrorMessage(error: string): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-error-1',
|
||||
role: 'assistant',
|
||||
content: [],
|
||||
status: { type: 'incomplete', reason: 'error', error },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function assistantReasoningMessage(text: string, running = false): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-reasoning-1',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'reasoning', text }],
|
||||
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function assistantMultiReasoningMessage(texts: string[]): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-reasoning-multi-1',
|
||||
role: 'assistant',
|
||||
content: texts.map(text => ({ type: 'reasoning', text })),
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function assistantSeparatedReasoningMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-reasoning-separated-1',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'reasoning', text: ' Complete first thought.', status: { type: 'complete' } },
|
||||
{ type: 'text', text: 'Interim answer.' },
|
||||
{ type: 'reasoning', text: ' Streaming second thought.', status: { type: 'running' } }
|
||||
],
|
||||
status: { type: 'running' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function assistantTodoMessage(
|
||||
todos: Array<{ content: string; id: string; status: 'cancelled' | 'completed' | 'in_progress' | 'pending' }>,
|
||||
running = true
|
||||
): ThreadMessage {
|
||||
const suffix = todos.map(todo => `${todo.id}:${todo.status}`).join('|') || 'empty'
|
||||
|
||||
return {
|
||||
id: `assistant-todo-${running ? 'running' : 'done'}-${suffix}`,
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'todo-1',
|
||||
toolName: 'todo',
|
||||
args: { todos },
|
||||
argsText: JSON.stringify({ todos }),
|
||||
...(running ? {} : { result: { todos } })
|
||||
}
|
||||
],
|
||||
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function assistantImageMessage(running = false): ThreadMessage {
|
||||
return {
|
||||
id: `assistant-image-${running ? 'running' : 'done'}`,
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'image-1',
|
||||
toolName: 'image_generate',
|
||||
args: { prompt: 'draw a cat' },
|
||||
argsText: JSON.stringify({ prompt: 'draw a cat' }),
|
||||
...(running ? {} : { result: { image: 'https://cdn.example/cat.png', success: true } })
|
||||
}
|
||||
],
|
||||
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function StreamingHarness() {
|
||||
const [messages, setMessages] = useState<ThreadMessage[]>([userMessage()])
|
||||
const [isRunning, setIsRunning] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const first = window.setTimeout(() => {
|
||||
setMessages([userMessage(), assistantMessage('first chunk')])
|
||||
}, 50)
|
||||
|
||||
const second = window.setTimeout(() => {
|
||||
setMessages([userMessage(), assistantMessage('first chunk second chunk')])
|
||||
}, 500)
|
||||
|
||||
const complete = window.setTimeout(() => {
|
||||
setMessages([userMessage(), assistantMessage('first chunk second chunk', false)])
|
||||
setIsRunning(false)
|
||||
}, 700)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(first)
|
||||
window.clearTimeout(second)
|
||||
window.clearTimeout(complete)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages,
|
||||
isRunning,
|
||||
onNew: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread loading={isRunning && messages.at(-1)?.role !== 'assistant' ? 'response' : undefined} />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TodoHarness({ message }: { message: ThreadMessage }) {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [message],
|
||||
isRunning: message.status?.type === 'running',
|
||||
onNew: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function MessageHarness({ message }: { message: ThreadMessage }) {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [message],
|
||||
isRunning: false,
|
||||
onNew: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function RunningMessageHarness({ message }: { message: ThreadMessage }) {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [message],
|
||||
isRunning: true,
|
||||
onNew: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function ReasoningHarness() {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [assistantReasoningMessage(' The user is asking what this file is.')],
|
||||
isRunning: false,
|
||||
onNew: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function RunningReasoningHarness() {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [assistantReasoningMessage('```ts\nconst answer = 42\n', true)],
|
||||
isRunning: true,
|
||||
onNew: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupedReasoningHarness() {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [assistantMultiReasoningMessage([' First thought.', ' Second thought.'])],
|
||||
isRunning: false,
|
||||
onNew: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function IntroHarness() {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [],
|
||||
isRunning: false,
|
||||
onNew: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread intro={{ personality: 'default', seed: 1 }} />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function DismissibleErrorHarness({ onDismissError }: { onDismissError: (messageId: string) => void }) {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [assistantErrorMessage('OpenRouter rejected the request (403).')],
|
||||
isRunning: false,
|
||||
onNew: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread onDismissError={onDismissError} />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('assistant-ui streaming renderer', () => {
|
||||
beforeEach(() => {
|
||||
resizeObservers.clear()
|
||||
})
|
||||
|
||||
it('renders assistant text incrementally before completion', async () => {
|
||||
const { container } = render(<StreamingHarness />)
|
||||
|
||||
expect(screen.getByRole('status', { name: 'Hermes is loading a response' })).toBeTruthy()
|
||||
|
||||
await wait(80)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.textContent).toContain('first chunk')
|
||||
})
|
||||
expect(container.textContent).not.toContain('second chunk')
|
||||
expect(screen.queryByRole('status', { name: 'Hermes is loading a response' })).toBeNull()
|
||||
|
||||
await wait(500)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.textContent).toContain('first chunk second chunk')
|
||||
})
|
||||
|
||||
await wait(250)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.textContent).toContain('first chunk second chunk')
|
||||
})
|
||||
})
|
||||
|
||||
it('does not render composer clearance for intro-only threads', () => {
|
||||
const { container } = render(<IntroHarness />)
|
||||
|
||||
expect(container.querySelector('[data-slot="aui_composer-clearance"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders assistant provider errors inline', () => {
|
||||
render(<MessageHarness message={assistantErrorMessage('OpenRouter rejected the request (403).')} />)
|
||||
|
||||
expect(screen.getByRole('alert').textContent).toContain('OpenRouter rejected the request (403).')
|
||||
})
|
||||
|
||||
it('omits the dismiss control when no onDismissError handler is supplied', () => {
|
||||
render(<MessageHarness message={assistantErrorMessage('OpenRouter rejected the request (403).')} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Dismiss error' })).toBeNull()
|
||||
})
|
||||
|
||||
it('invokes onDismissError with the errored message id when the dismiss control is clicked', () => {
|
||||
const onDismissError = vi.fn()
|
||||
render(<DismissibleErrorHarness onDismissError={onDismissError} />)
|
||||
|
||||
const dismiss = screen.getByRole('button', { name: 'Dismiss error' })
|
||||
fireEvent.click(dismiss)
|
||||
|
||||
expect(onDismissError).toHaveBeenCalledTimes(1)
|
||||
expect(onDismissError).toHaveBeenCalledWith('assistant-error-1')
|
||||
})
|
||||
|
||||
// Scroll behavior (follow-at-bottom, escape-on-scroll-up, re-engage) is owned
|
||||
// by the use-stick-to-bottom library and covered by its own test suite. We
|
||||
// don't re-assert its scrollTop mechanics here — doing so in jsdom (no real
|
||||
// layout, spring animation via rAF) only produces brittle change-detector
|
||||
// tests. The rendering/streaming-content tests below remain the contract.
|
||||
|
||||
it('renders an incomplete streaming fenced code block as a code card', async () => {
|
||||
const { container } = render(<RunningMessageHarness message={assistantMessage('```ts\nconst answer = 42\n')} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-slot="code-card"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
expect(container.textContent).toContain('const answer = 42')
|
||||
expect(container.textContent).not.toContain('```ts')
|
||||
})
|
||||
|
||||
it('renders an incomplete streaming reasoning fenced code block as a code card', async () => {
|
||||
const { container } = render(<RunningReasoningHarness />)
|
||||
const ui = within(container)
|
||||
const thinkingToggle = ui.getByRole('button', { name: /thinking/i })
|
||||
|
||||
if (thinkingToggle.getAttribute('aria-expanded') !== 'true') {
|
||||
fireEvent.click(thinkingToggle)
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-slot="code-card"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toContain('const answer = 42')
|
||||
})
|
||||
expect(container.textContent).not.toContain('```ts')
|
||||
})
|
||||
|
||||
it('renders reasoning text without a leading token space', () => {
|
||||
const { container } = render(<ReasoningHarness />)
|
||||
const ui = within(container)
|
||||
|
||||
fireEvent.click(ui.getByRole('button', { name: /thinking/i }))
|
||||
|
||||
expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toBe(
|
||||
'The user is asking what this file is.'
|
||||
)
|
||||
})
|
||||
|
||||
it('groups consecutive reasoning parts under one thinking disclosure', () => {
|
||||
const { container } = render(<GroupedReasoningHarness />)
|
||||
|
||||
const disclosures = container.querySelectorAll('[data-slot="aui_thinking-disclosure"]')
|
||||
expect(disclosures.length).toBe(1)
|
||||
|
||||
fireEvent.click(disclosures[0].querySelector('button')!)
|
||||
|
||||
const reasoningParts = container.querySelectorAll('[data-slot="aui_reasoning-text"]')
|
||||
expect(reasoningParts.length).toBe(2)
|
||||
expect(reasoningParts[0]?.textContent).toBe('First thought.')
|
||||
expect(reasoningParts[1]?.textContent).toBe('Second thought.')
|
||||
})
|
||||
|
||||
it('does not reopen an earlier completed thinking group when a later group is running', () => {
|
||||
const { container } = render(<RunningMessageHarness message={assistantSeparatedReasoningMessage()} />)
|
||||
|
||||
const disclosures = container.querySelectorAll('[data-slot="aui_thinking-disclosure"]')
|
||||
expect(disclosures.length).toBe(2)
|
||||
|
||||
expect(disclosures[0].querySelector('button')?.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(disclosures[1].querySelector('button')?.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(container.textContent).not.toContain('Complete first thought.')
|
||||
expect(container.textContent).toContain('Interim answer.')
|
||||
})
|
||||
|
||||
it('does not render an inline todo panel — todos live in the composer status stack', () => {
|
||||
const { container } = render(
|
||||
<TodoHarness
|
||||
message={assistantTodoMessage([
|
||||
{ content: 'Gather ingredients', id: 'prep', status: 'completed' },
|
||||
{ content: 'Boil water', id: 'boil', status: 'in_progress' }
|
||||
])}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(container.querySelector('[data-slot="aui_todo-hoisted"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders completed image generation results in the tool slot', async () => {
|
||||
const { container } = render(<MessageHarness message={assistantImageMessage()} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: 'Generated image' }).getAttribute('src')).toBe(
|
||||
'https://cdn.example/cat.png'
|
||||
)
|
||||
})
|
||||
expect(container.querySelector('[data-slot="aui_generated-image"]')).toBeTruthy()
|
||||
expect(screen.queryByRole('status', { name: /rendering image/i })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import { MessagePrimitive, useAuiState } from '@assistant-ui/react'
|
||||
import { type FC } from 'react'
|
||||
|
||||
import { messageContentText } from '@/components/assistant-ui/thread/content'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { LinkifiedText } from '@/lib/external-link'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const SLASH_STATUS_RE = /^slash:(?<command>\/[^\n]+)\n(?<output>[\s\S]*)$/
|
||||
const STEER_NOTE_RE = /^steer:(?<text>[\s\S]+)$/
|
||||
|
||||
export const SystemMessage: FC = () => {
|
||||
const text = useAuiState(s => messageContentText(s.message.content))
|
||||
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
|
||||
const steerNote = text.match(STEER_NOTE_RE)
|
||||
|
||||
if (steerNote?.groups) {
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="flex max-w-[min(86%,44rem)] items-center gap-1.5 self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/60"
|
||||
data-role="system"
|
||||
data-slot="aui_system-message-root"
|
||||
>
|
||||
<Codicon className="text-muted-foreground/55" name="compass" size="0.75rem" />
|
||||
<span className="text-muted-foreground/55">steered</span>
|
||||
<span className="text-muted-foreground/35">·</span>
|
||||
<span className="whitespace-pre-wrap">{steerNote.groups.text.trim()}</span>
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const slashStatus = text.match(SLASH_STATUS_RE)
|
||||
|
||||
if (slashStatus?.groups) {
|
||||
const output = slashStatus.groups.output.trim()
|
||||
// Single-line status (e.g. "model → x") reads best centered inline; padded
|
||||
// multiline output (catalogs, usage tables) needs left-aligned, wider room
|
||||
// or the column alignment breaks.
|
||||
const multiline = output.includes('\n')
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className={cn(
|
||||
'w-[60%] max-w-[44rem] self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/60',
|
||||
multiline ? 'text-left' : 'text-center'
|
||||
)}
|
||||
data-role="system"
|
||||
data-slot="aui_system-message-root"
|
||||
>
|
||||
<span className="font-mono text-muted-foreground/55">{slashStatus.groups.command}</span>
|
||||
{multiline ? (
|
||||
<LinkifiedText className="mt-0.5 block whitespace-pre-wrap" explicitOnly pretty={false} text={output} />
|
||||
) : (
|
||||
<>
|
||||
<span className="mx-1.5 text-muted-foreground/35">·</span>
|
||||
<LinkifiedText className="whitespace-pre-wrap" explicitOnly pretty={false} text={output} />
|
||||
</>
|
||||
)}
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const multiline = text.includes('\n')
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className={cn(
|
||||
'w-[60%] max-w-[44rem] self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/55',
|
||||
multiline ? 'text-left' : 'text-center'
|
||||
)}
|
||||
data-role="system"
|
||||
data-slot="aui_system-message-root"
|
||||
>
|
||||
<LinkifiedText className="whitespace-pre-wrap" explicitOnly pretty={false} text={text} />
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { activeTimelineIndex, deriveTimelineEntries, timelinePreview } from './timeline-data'
|
||||
|
||||
describe('timelinePreview', () => {
|
||||
it('collapses whitespace to a single line', () => {
|
||||
expect(timelinePreview('hello\n\n world\tagain')).toBe('hello world again')
|
||||
})
|
||||
|
||||
it('truncates with an ellipsis past the limit', () => {
|
||||
const out = timelinePreview('abcdefghij', 5)
|
||||
expect(out).toBe('abcd…')
|
||||
expect(out.length).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveTimelineEntries', () => {
|
||||
it('keeps non-empty user prompts in order', () => {
|
||||
expect(
|
||||
deriveTimelineEntries([
|
||||
{ id: 'u1', role: 'user', text: 'first' },
|
||||
{ id: 'a1', role: 'assistant', text: 'answer' },
|
||||
{ id: 'u2', role: 'user', text: ' second ' }
|
||||
])
|
||||
).toEqual([
|
||||
{ id: 'u1', preview: 'first' },
|
||||
{ id: 'u2', preview: 'second' }
|
||||
])
|
||||
})
|
||||
|
||||
it('drops blanks and background-process notifications', () => {
|
||||
expect(
|
||||
deriveTimelineEntries([
|
||||
{ id: 'u1', role: 'user', text: ' ' },
|
||||
{ id: 'u2', role: 'user', text: '[IMPORTANT: Background process 123 finished]' },
|
||||
{ id: 'u3', role: 'user', text: 'real prompt' }
|
||||
]).map(e => e.id)
|
||||
).toEqual(['u3'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('activeTimelineIndex', () => {
|
||||
it('returns the last prompt scrolled to or above the top edge', () => {
|
||||
expect(activeTimelineIndex([-400, -10, 320])).toBe(1)
|
||||
})
|
||||
|
||||
it('falls back to the first rendered entry', () => {
|
||||
expect(activeTimelineIndex([null, 120, 480])).toBe(1)
|
||||
expect(activeTimelineIndex([null, null])).toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
// Pure timeline helpers — no React/DOM; tested in thread-timeline-data.test.ts.
|
||||
|
||||
export interface TimelineSourceMessage {
|
||||
id: string
|
||||
role: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface TimelineEntry {
|
||||
id: string
|
||||
preview: string
|
||||
}
|
||||
|
||||
// Injected as user messages for alternation; not human prompts (thread.tsx).
|
||||
const PROCESS_NOTIFICATION_RE = /^\[IMPORTANT: Background process [\s\S]*\]$/
|
||||
|
||||
const PREVIEW_MAX = 120
|
||||
|
||||
export function timelinePreview(text: string, max: number = PREVIEW_MAX): string {
|
||||
const collapsed = text.replace(/\s+/g, ' ').trim()
|
||||
|
||||
if (collapsed.length <= max) {
|
||||
return collapsed
|
||||
}
|
||||
|
||||
return `${collapsed.slice(0, max - 1).trimEnd()}…`
|
||||
}
|
||||
|
||||
export function deriveTimelineEntries(messages: readonly TimelineSourceMessage[]): TimelineEntry[] {
|
||||
const entries: TimelineEntry[] = []
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role !== 'user') {
|
||||
continue
|
||||
}
|
||||
|
||||
const text = message.text.trim()
|
||||
|
||||
if (!text || PROCESS_NOTIFICATION_RE.test(text)) {
|
||||
continue
|
||||
}
|
||||
|
||||
entries.push({ id: message.id, preview: timelinePreview(text) })
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
/** Last user prompt at/above the viewport top (with slack); else first rendered. */
|
||||
export function activeTimelineIndex(offsets: readonly (number | null)[], slack: number = 8): number {
|
||||
let active = -1
|
||||
let firstRendered = -1
|
||||
|
||||
for (let i = 0; i < offsets.length; i++) {
|
||||
const offset = offsets[i]
|
||||
|
||||
if (offset == null) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (firstRendered === -1) {
|
||||
firstRendered = i
|
||||
}
|
||||
|
||||
if (offset <= slack) {
|
||||
active = i
|
||||
}
|
||||
}
|
||||
|
||||
if (active !== -1) {
|
||||
return active
|
||||
}
|
||||
|
||||
return firstRendered === -1 ? 0 : firstRendered
|
||||
}
|
||||
312
apps/desktop/src/components/assistant-ui/thread/timeline.tsx
Normal file
312
apps/desktop/src/components/assistant-ui/thread/timeline.tsx
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
import { useAuiState } from '@assistant-ui/react'
|
||||
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import {
|
||||
activeTimelineIndex,
|
||||
deriveTimelineEntries,
|
||||
type TimelineEntry,
|
||||
type TimelineSourceMessage
|
||||
} from './timeline-data'
|
||||
|
||||
const MIN_ENTRIES = 4
|
||||
const VIEWPORT = '[data-slot="aui_thread-viewport"]'
|
||||
const HOVER_CLOSE_MS = 140
|
||||
|
||||
const ROW_CLASS =
|
||||
'relative flex w-full min-w-0 max-w-full cursor-pointer select-none overflow-hidden rounded-md px-2 py-1 text-left outline-hidden transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none'
|
||||
|
||||
// Surface (border-color/bg/shadow/blur) comes from the shared
|
||||
// `[data-slot='thread-timeline-popover']` rule in styles.css, so it's 1:1 with
|
||||
// the dropdown/select/dialog menus. We only own layout + the border/radius here.
|
||||
const POPOVER_SHELL =
|
||||
'absolute right-full top-1/2 z-50 max-h-[min(22rem,calc(100vh-8rem))] w-80 max-w-[min(20rem,calc(100vw-2rem))] -translate-y-1/2 overflow-x-hidden overflow-y-auto overscroll-contain rounded-lg border p-1 text-popover-foreground transition-[opacity,transform] duration-100 ease-out group-hover/timeline:transition-none'
|
||||
|
||||
function userPromptText(content: unknown): string {
|
||||
if (typeof content === 'string') {
|
||||
return content
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
let out = ''
|
||||
|
||||
for (const part of content) {
|
||||
if (typeof part === 'string') {
|
||||
out += part
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (!part || typeof part !== 'object') {
|
||||
continue
|
||||
}
|
||||
|
||||
const row = part as { text?: unknown; type?: unknown }
|
||||
|
||||
if ((!row.type || row.type === 'text') && typeof row.text === 'string') {
|
||||
out += row.text
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/** Index-keyed ref-array setter — `ref={listRef(refs, i)}`. */
|
||||
const listRef =
|
||||
<T,>(refs: React.RefObject<(T | null)[]>, index: number) =>
|
||||
(node: T | null) => {
|
||||
refs.current[index] = node
|
||||
}
|
||||
|
||||
/** Mouse enter/leave pair forwarding `on` to the shared paint(). */
|
||||
const hoverProps = (index: number, paint: (index: number, on: boolean) => void) => ({
|
||||
onMouseEnter: () => paint(index, true),
|
||||
onMouseLeave: () => paint(index, false)
|
||||
})
|
||||
|
||||
// Constant-duration jump (eased), NOT native `behavior:'smooth'` — Chromium's
|
||||
// smooth scroll animates proportional to distance, so jumping across a long
|
||||
// thread crawls for seconds. A fixed ~260ms feels instant near or far. A
|
||||
// shared rAF handle cancels a prior jump so rapid tick clicks don't fight.
|
||||
let jumpRaf = 0
|
||||
|
||||
function jumpScroll(viewport: HTMLElement, top: number, duration = 170): void {
|
||||
cancelAnimationFrame(jumpRaf)
|
||||
const start = viewport.scrollTop
|
||||
const delta = top - start
|
||||
|
||||
if (Math.abs(delta) < 2) {
|
||||
viewport.scrollTop = top
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const t0 = performance.now()
|
||||
const ease = (t: number) => 1 - (1 - t) ** 3 // easeOutCubic
|
||||
|
||||
const step = (now: number) => {
|
||||
const p = Math.min(1, (now - t0) / duration)
|
||||
viewport.scrollTop = start + delta * ease(p)
|
||||
|
||||
if (p < 1) {
|
||||
jumpRaf = requestAnimationFrame(step)
|
||||
}
|
||||
}
|
||||
|
||||
jumpRaf = requestAnimationFrame(step)
|
||||
}
|
||||
|
||||
function scrollToPrompt(id: string) {
|
||||
const viewport = document.querySelector<HTMLElement>(VIEWPORT)
|
||||
const node = viewport?.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(id)}"]`)
|
||||
|
||||
if (!viewport || !node) {
|
||||
return
|
||||
}
|
||||
|
||||
const top = viewport.scrollTop + (node.getBoundingClientRect().top - viewport.getBoundingClientRect().top) - 8
|
||||
|
||||
triggerHaptic('selection')
|
||||
jumpScroll(viewport, Math.max(0, top))
|
||||
}
|
||||
|
||||
/** Right-edge prompt rail — hover previews, click to jump. ≥4 user turns only. */
|
||||
export const ThreadTimeline: FC = () => {
|
||||
const sourceSignature = useAuiState(s => {
|
||||
const rows: TimelineSourceMessage[] = []
|
||||
|
||||
for (const message of s.thread.messages) {
|
||||
if (message.role !== 'user') {
|
||||
continue
|
||||
}
|
||||
|
||||
rows.push({ id: message.id, role: 'user', text: userPromptText(message.content) })
|
||||
}
|
||||
|
||||
return JSON.stringify(rows)
|
||||
})
|
||||
|
||||
const entries = useMemo(
|
||||
() => deriveTimelineEntries(JSON.parse(sourceSignature) as TimelineSourceMessage[]),
|
||||
[sourceSignature]
|
||||
)
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const [open, setOpen] = useState(false)
|
||||
const closeTimerRef = useRef<number | undefined>(undefined)
|
||||
|
||||
// Hover sync lives on the DOM, not in React state — the tick and its popover
|
||||
// row are siblings in different subtrees, so a shared index-keyed paint() lights
|
||||
// both without a re-render (and without coupling them through a parent atom).
|
||||
const tickRefs = useRef<(HTMLSpanElement | null)[]>([])
|
||||
const rowRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
|
||||
// Hover sync: light the tick + its popover row, and scroll that row into view
|
||||
// when the list overflows so the hovered prompt is always visible.
|
||||
const paint = useCallback((index: number, on: boolean) => {
|
||||
const tick = tickRefs.current[index]
|
||||
|
||||
if (tick) {
|
||||
tick.style.opacity = on ? '1' : ''
|
||||
}
|
||||
|
||||
const row = rowRefs.current[index]
|
||||
row?.classList.toggle('bg-(--ui-row-hover-background)', on)
|
||||
|
||||
if (on) {
|
||||
row?.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
}, [])
|
||||
|
||||
const keepOpen = useCallback(() => {
|
||||
window.clearTimeout(closeTimerRef.current)
|
||||
setOpen(true)
|
||||
}, [])
|
||||
|
||||
const closeSoon = useCallback(() => {
|
||||
window.clearTimeout(closeTimerRef.current)
|
||||
closeTimerRef.current = window.setTimeout(() => setOpen(false), HOVER_CLOSE_MS)
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => window.clearTimeout(closeTimerRef.current), [])
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = document.querySelector<HTMLElement>(VIEWPORT)
|
||||
|
||||
if (!viewport || entries.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let raf = 0
|
||||
|
||||
const compute = () => {
|
||||
raf = 0
|
||||
|
||||
const top = viewport.getBoundingClientRect().top
|
||||
|
||||
const offsets = entries.map(entry => {
|
||||
const node = viewport.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(entry.id)}"]`)
|
||||
|
||||
return node ? node.getBoundingClientRect().top - top : null
|
||||
})
|
||||
|
||||
const next = activeTimelineIndex(offsets)
|
||||
|
||||
setActiveIndex(prev => (prev === next ? prev : next))
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
if (!raf) {
|
||||
raf = requestAnimationFrame(compute)
|
||||
}
|
||||
}
|
||||
|
||||
compute()
|
||||
viewport.addEventListener('scroll', onScroll, { passive: true })
|
||||
|
||||
return () => {
|
||||
viewport.removeEventListener('scroll', onScroll)
|
||||
|
||||
if (raf) {
|
||||
cancelAnimationFrame(raf)
|
||||
}
|
||||
}
|
||||
}, [entries])
|
||||
|
||||
if (entries.length < MIN_ENTRIES) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label="Conversation timeline"
|
||||
className="group/timeline pointer-events-auto absolute right-0 top-1/2 z-40 flex -translate-y-1/2 flex-col items-end"
|
||||
data-slot="thread-timeline"
|
||||
data-suppress-pane-reveal=""
|
||||
onMouseEnter={keepOpen}
|
||||
onMouseLeave={closeSoon}
|
||||
role="navigation"
|
||||
>
|
||||
<TimelineTicks
|
||||
activeIndex={activeIndex}
|
||||
entries={entries}
|
||||
onHover={paint}
|
||||
onJump={scrollToPrompt}
|
||||
tickRefs={tickRefs}
|
||||
/>
|
||||
<TimelinePopover
|
||||
activeIndex={activeIndex}
|
||||
entries={entries}
|
||||
onHover={paint}
|
||||
onJump={scrollToPrompt}
|
||||
open={open}
|
||||
rowRefs={rowRefs}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TimelinePopover: FC<{
|
||||
activeIndex: number
|
||||
entries: TimelineEntry[]
|
||||
onHover: (index: number, on: boolean) => void
|
||||
onJump: (id: string) => void
|
||||
open: boolean
|
||||
rowRefs: React.RefObject<(HTMLButtonElement | null)[]>
|
||||
}> = ({ activeIndex, entries, onHover, onJump, open, rowRefs }) => (
|
||||
<div
|
||||
className={cn(
|
||||
POPOVER_SHELL,
|
||||
open ? 'pointer-events-auto opacity-100 translate-x-0' : 'pointer-events-none translate-x-1 opacity-0'
|
||||
)}
|
||||
data-slot="thread-timeline-popover"
|
||||
>
|
||||
{entries.map((entry, index) => (
|
||||
<button
|
||||
aria-label={entry.preview}
|
||||
className={cn(ROW_CLASS, index === activeIndex && 'bg-(--ui-row-active-background) text-foreground')}
|
||||
key={entry.id}
|
||||
onClick={() => onJump(entry.id)}
|
||||
ref={listRef(rowRefs, index)}
|
||||
type="button"
|
||||
{...hoverProps(index, onHover)}
|
||||
>
|
||||
<span className="block w-full min-w-0 truncate font-medium leading-snug text-foreground">{entry.preview}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
const TimelineTicks: FC<{
|
||||
activeIndex: number
|
||||
entries: TimelineEntry[]
|
||||
onHover: (index: number, on: boolean) => void
|
||||
onJump: (id: string) => void
|
||||
tickRefs: React.RefObject<(HTMLSpanElement | null)[]>
|
||||
}> = ({ activeIndex, entries, onHover, onJump, tickRefs }) => (
|
||||
<div className="flex flex-col items-end py-1" data-slot="thread-timeline-ticks">
|
||||
{entries.map((entry, index) => (
|
||||
<button
|
||||
aria-label={entry.preview}
|
||||
className="flex h-2 w-7 cursor-pointer items-center justify-end pr-1"
|
||||
key={entry.id}
|
||||
onClick={() => onJump(entry.id)}
|
||||
type="button"
|
||||
{...hoverProps(index, onHover)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'block h-px w-3 transition-opacity duration-100 ease-out',
|
||||
index === activeIndex ? 'bg-(--theme-primary)' : 'dither text-(--ui-text-quaternary) opacity-70'
|
||||
)}
|
||||
ref={listRef(tickRefs, index)}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { formatMessageTimestamp } from './timestamp'
|
||||
|
||||
const labels = {
|
||||
today: (time: string) => `Today at ${time}`,
|
||||
yesterday: (time: string) => `Yesterday at ${time}`
|
||||
}
|
||||
|
||||
describe('formatMessageTimestamp', () => {
|
||||
it('returns an empty string for missing values', () => {
|
||||
expect(formatMessageTimestamp(undefined, labels)).toBe('')
|
||||
expect(formatMessageTimestamp('not-a-date', labels)).toBe('')
|
||||
})
|
||||
|
||||
it('uses the today label for timestamps earlier today', () => {
|
||||
const now = new Date()
|
||||
const earlierToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 30)
|
||||
expect(formatMessageTimestamp(earlierToday, labels)).toMatch(/^Today at /)
|
||||
})
|
||||
|
||||
it('uses the yesterday label for timestamps the prior day', () => {
|
||||
const now = new Date()
|
||||
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 8, 0)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
expect(formatMessageTimestamp(yesterday, labels)).toMatch(/^Yesterday at /)
|
||||
})
|
||||
|
||||
it('falls back to an absolute format for older timestamps', () => {
|
||||
const old = new Date(2020, 0, 15, 9, 30)
|
||||
const out = formatMessageTimestamp(old, labels)
|
||||
expect(out).not.toMatch(/^Today at /)
|
||||
expect(out).not.toMatch(/^Yesterday at /)
|
||||
expect(out.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
39
apps/desktop/src/components/assistant-ui/thread/timestamp.ts
Normal file
39
apps/desktop/src/components/assistant-ui/thread/timestamp.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
const TIME_FMT = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' })
|
||||
|
||||
const SHORT_FMT = new Intl.DateTimeFormat(undefined, {
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
month: 'short'
|
||||
})
|
||||
|
||||
function startOfDay(d: Date): number {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
|
||||
}
|
||||
|
||||
export function formatMessageTimestamp(
|
||||
value: Date | string | number | undefined,
|
||||
labels: { today: (time: string) => string; yesterday: (time: string) => string }
|
||||
): string {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const dayDelta = Math.round((startOfDay(new Date()) - startOfDay(date)) / 86_400_000)
|
||||
|
||||
if (dayDelta === 0) {
|
||||
return labels.today(TIME_FMT.format(date))
|
||||
}
|
||||
|
||||
if (dayDelta === 1) {
|
||||
return labels.yesterday(TIME_FMT.format(date))
|
||||
}
|
||||
|
||||
return SHORT_FMT.format(date)
|
||||
}
|
||||
4
apps/desktop/src/components/assistant-ui/thread/types.ts
Normal file
4
apps/desktop/src/components/assistant-ui/thread/types.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export interface RestoreMessageTarget {
|
||||
text: string
|
||||
userOrdinal: number | null
|
||||
}
|
||||
|
|
@ -0,0 +1,701 @@
|
|||
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react'
|
||||
import {
|
||||
type ClipboardEvent,
|
||||
type FC,
|
||||
type FocusEvent,
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
type DragEvent as ReactDragEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
|
||||
import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from '@/app/chat/composer/drop-affordance'
|
||||
import {
|
||||
type ComposerInsertMode,
|
||||
focusComposerInput,
|
||||
markActiveComposer,
|
||||
onComposerFocusRequest,
|
||||
onComposerInsertRequest
|
||||
} from '@/app/chat/composer/focus'
|
||||
import { useAtCompletions } from '@/app/chat/composer/hooks/use-at-completions'
|
||||
import { useSlashCompletions } from '@/app/chat/composer/hooks/use-slash-completions'
|
||||
import {
|
||||
dragHasAttachments,
|
||||
droppedFileInlineRefs,
|
||||
type InlineRefInput,
|
||||
insertInlineRefsIntoEditor
|
||||
} from '@/app/chat/composer/inline-refs'
|
||||
import {
|
||||
composerPlainText,
|
||||
placeCaretEnd,
|
||||
refChipElement,
|
||||
renderComposerContents,
|
||||
RICH_INPUT_SLOT
|
||||
} from '@/app/chat/composer/rich-editor'
|
||||
import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils'
|
||||
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
|
||||
import {
|
||||
extractDroppedFiles,
|
||||
HERMES_PATHS_MIME,
|
||||
isImagePath,
|
||||
partitionDroppedFiles
|
||||
} from '@/app/chat/hooks/use-composer-actions'
|
||||
import { uploadComposerAttachment } from '@/app/session/hooks/use-prompt-actions'
|
||||
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||
import {
|
||||
StickyHumanMessageContainer,
|
||||
StopGlyph,
|
||||
USER_ACTION_ICON_BUTTON_CLASS,
|
||||
USER_ACTION_ICON_SIZE,
|
||||
USER_BUBBLE_BASE_CLASS
|
||||
} from '@/components/assistant-ui/thread/user-message'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runtime'
|
||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Loader2Icon } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $connection } from '@/store/session'
|
||||
import { notifyThreadEditClose } from '@/store/thread-scroll'
|
||||
|
||||
interface UserEditComposerProps {
|
||||
cwd: string | null
|
||||
gateway: HermesGateway | null
|
||||
sessionId: string | null
|
||||
}
|
||||
|
||||
export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }) => {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.thread
|
||||
const aui = useAui()
|
||||
const draft = useAuiState(s => s.composer.text)
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
const editorRef = useRef<HTMLDivElement | null>(null)
|
||||
const draftRef = useRef(draft)
|
||||
const dragDepthRef = useRef(0)
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [trigger, setTrigger] = useState<TriggerState | null>(null)
|
||||
const [triggerActive, setTriggerActive] = useState(0)
|
||||
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
|
||||
// See index.tsx: set in keydown when the open popover consumes a nav/control
|
||||
// key so the matching keyup skips refreshTrigger (timing-immune vs reading
|
||||
// `trigger`, which keyup sees as already-null after Escape).
|
||||
const triggerKeyConsumedRef = useRef(false)
|
||||
const [triggerPlacement, setTriggerPlacement] = useState<'bottom' | 'top'>('top')
|
||||
const [focusRequestId, setFocusRequestId] = useState(0)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
// True while OS-drop files are being staged/uploaded into the session. Blocks
|
||||
// submit and shows a spinner so confirming the edit can't race the async
|
||||
// upload and drop the gateway-side ref before it lands in the draft.
|
||||
const [staging, setStaging] = useState(false)
|
||||
const expanded = draft.includes('\n')
|
||||
const canSubmit = draft.trim().length > 0
|
||||
const at = useAtCompletions({ cwd, gateway, sessionId })
|
||||
const slash = useSlashCompletions({ gateway })
|
||||
|
||||
useEffect(() => () => notifyThreadEditClose(), [])
|
||||
|
||||
const focusEditor = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
focusComposerInput(editor)
|
||||
|
||||
if (editor) {
|
||||
placeCaretEnd(editor)
|
||||
}
|
||||
|
||||
markActiveComposer('edit')
|
||||
}, [])
|
||||
|
||||
const requestEditFocus = useCallback(() => {
|
||||
setFocusRequestId(id => id + 1)
|
||||
}, [])
|
||||
|
||||
const appendExternalText = useCallback(
|
||||
(text: string, mode: ComposerInsertMode) => {
|
||||
const value = text.trim()
|
||||
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
|
||||
const base = mode === 'inline' ? draftRef.current.trimEnd() : draftRef.current
|
||||
const sep = mode === 'inline' ? (base ? ' ' : '') : base && !base.endsWith('\n') ? '\n\n' : ''
|
||||
const next = `${base}${sep}${value}`
|
||||
|
||||
draftRef.current = next
|
||||
aui.composer().setText(next)
|
||||
|
||||
const editor = editorRef.current
|
||||
|
||||
if (editor) {
|
||||
renderComposerContents(editor, next)
|
||||
placeCaretEnd(editor)
|
||||
}
|
||||
|
||||
setFocusRequestId(id => id + 1)
|
||||
},
|
||||
[aui]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
draftRef.current = draft
|
||||
|
||||
const editor = editorRef.current
|
||||
|
||||
if (
|
||||
editor &&
|
||||
(editor.childNodes.length === 0 || (document.activeElement !== editor && composerPlainText(editor) !== draft))
|
||||
) {
|
||||
renderComposerContents(editor, draft)
|
||||
|
||||
if (document.activeElement === editor) {
|
||||
placeCaretEnd(editor)
|
||||
}
|
||||
}
|
||||
}, [draft])
|
||||
|
||||
useEffect(() => {
|
||||
focusEditor()
|
||||
}, [focusEditor, focusRequestId])
|
||||
|
||||
useEffect(() => {
|
||||
const offFocus = onComposerFocusRequest(target => {
|
||||
if (target === 'edit') {
|
||||
setFocusRequestId(id => id + 1)
|
||||
}
|
||||
})
|
||||
|
||||
const offInsert = onComposerInsertRequest(({ mode, target, text }) => {
|
||||
if (target === 'edit') {
|
||||
appendExternalText(text, mode)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
offFocus()
|
||||
offInsert()
|
||||
}
|
||||
}, [appendExternalText])
|
||||
|
||||
const syncDraftFromEditor = useCallback(
|
||||
(editor: HTMLDivElement) => {
|
||||
const nextDraft = composerPlainText(editor)
|
||||
|
||||
if (nextDraft !== draftRef.current) {
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
}
|
||||
|
||||
return nextDraft
|
||||
},
|
||||
[aui]
|
||||
)
|
||||
|
||||
const refreshTrigger = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const before = textBeforeCaret(editor)
|
||||
const detected = detectTrigger(before ?? composerPlainText(editor))
|
||||
|
||||
if (detected) {
|
||||
const rect = editor.getBoundingClientRect()
|
||||
const spaceAbove = rect.top
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
|
||||
setTriggerPlacement(spaceAbove < 220 && spaceBelow > spaceAbove ? 'bottom' : 'top')
|
||||
}
|
||||
|
||||
setTrigger(detected)
|
||||
|
||||
// Only reset the highlight when the trigger actually changed (opened, or
|
||||
// the query/kind differs). Re-detecting the *same* trigger — e.g. on a
|
||||
// caret move (mouseup) or a stray refresh — must preserve the user's
|
||||
// current selection instead of snapping back to the first item.
|
||||
if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) {
|
||||
setTriggerActive(0)
|
||||
}
|
||||
}, [trigger])
|
||||
|
||||
const closeTrigger = useCallback(() => {
|
||||
setTrigger(null)
|
||||
setTriggerItems([])
|
||||
setTriggerActive(0)
|
||||
}, [])
|
||||
|
||||
const triggerAdapter: Unstable_TriggerAdapter | null =
|
||||
trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
|
||||
|
||||
useEffect(() => {
|
||||
if (!trigger || !triggerAdapter?.search) {
|
||||
setTriggerItems([])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setTriggerItems(triggerAdapter.search(trigger.query))
|
||||
}, [trigger, triggerAdapter])
|
||||
|
||||
useEffect(() => {
|
||||
setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1)))
|
||||
}, [triggerItems.length])
|
||||
|
||||
const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false
|
||||
|
||||
const replaceTriggerWithChip = useCallback(
|
||||
(item: Unstable_TriggerItem) => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor || !trigger) {
|
||||
return
|
||||
}
|
||||
|
||||
const serialized = hermesDirectiveFormatter.serialize(item)
|
||||
const starter = serialized.endsWith(':')
|
||||
const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
|
||||
const directive = !starter && serialized.match(/^@([^:]+):(.+)$/)
|
||||
|
||||
const finish = () => {
|
||||
draftRef.current = composerPlainText(editor)
|
||||
aui.composer().setText(draftRef.current)
|
||||
requestEditFocus()
|
||||
starter ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
|
||||
}
|
||||
|
||||
const sel = window.getSelection()
|
||||
const range = sel?.rangeCount ? sel.getRangeAt(0) : null
|
||||
const node = range?.startContainer
|
||||
const offset = range?.startOffset ?? 0
|
||||
|
||||
if (!sel || !range || node?.nodeType !== Node.TEXT_NODE || offset < trigger.tokenLength) {
|
||||
const current = composerPlainText(editor)
|
||||
renderComposerContents(editor, `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${text}`)
|
||||
placeCaretEnd(editor)
|
||||
|
||||
return finish()
|
||||
}
|
||||
|
||||
const replaceRange = document.createRange()
|
||||
replaceRange.setStart(node, offset - trigger.tokenLength)
|
||||
replaceRange.setEnd(node, offset)
|
||||
replaceRange.deleteContents()
|
||||
|
||||
if (directive) {
|
||||
const chip = refChipElement(directive[1], directive[2])
|
||||
const space = document.createTextNode(' ')
|
||||
const fragment = document.createDocumentFragment()
|
||||
fragment.append(chip, space)
|
||||
replaceRange.insertNode(fragment)
|
||||
|
||||
const caret = document.createRange()
|
||||
caret.setStart(space, 1)
|
||||
caret.collapse(true)
|
||||
sel.removeAllRanges()
|
||||
sel.addRange(caret)
|
||||
|
||||
return finish()
|
||||
}
|
||||
|
||||
document.execCommand('insertText', false, text)
|
||||
finish()
|
||||
},
|
||||
[aui, closeTrigger, refreshTrigger, requestEditFocus, trigger]
|
||||
)
|
||||
|
||||
const insertRefStrings = useCallback(
|
||||
(refs: InlineRefInput[]) => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor || refs.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const nextDraft = insertInlineRefsIntoEditor(editor, refs)
|
||||
|
||||
if (nextDraft === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
requestEditFocus()
|
||||
|
||||
return true
|
||||
},
|
||||
[aui, requestEditFocus]
|
||||
)
|
||||
|
||||
const insertDroppedRefs = useCallback(
|
||||
(candidates: ReturnType<typeof extractDroppedFiles>) => insertRefStrings(droppedFileInlineRefs(candidates, cwd)),
|
||||
[cwd, insertRefStrings]
|
||||
)
|
||||
|
||||
// OS/Finder drops carry an absolute path on THIS machine — the gateway can't
|
||||
// read it in remote mode, and an image needs its bytes uploaded for vision.
|
||||
// Stage each through the same file.attach/image.attach_bytes pipeline the main
|
||||
// composer uses, then insert the *gateway-side* ref the agent can resolve —
|
||||
// never the raw local path (the MahmoudR remote-attach bug, which the main
|
||||
// composer fixes but this edit composer used to reproduce).
|
||||
const uploadOsDropRefs = useCallback(
|
||||
async (osDrops: ReturnType<typeof extractDroppedFiles>): Promise<InlineRefInput[]> => {
|
||||
if (!gateway || !sessionId) {
|
||||
// No session to stage into — best-effort inline refs (matches old path).
|
||||
return droppedFileInlineRefs(osDrops, cwd)
|
||||
}
|
||||
|
||||
const remote = $connection.get()?.mode === 'remote'
|
||||
|
||||
const requestGateway = <T,>(method: string, params?: Record<string, unknown>) =>
|
||||
gateway.request<T>(method, params)
|
||||
|
||||
const refs: InlineRefInput[] = []
|
||||
|
||||
for (const candidate of osDrops) {
|
||||
const path = candidate.path || ''
|
||||
|
||||
if (!path) {
|
||||
continue
|
||||
}
|
||||
|
||||
const kind: ComposerAttachment['kind'] =
|
||||
candidate.file?.type.startsWith('image/') || isImagePath(candidate.file?.name || path) ? 'image' : 'file'
|
||||
|
||||
try {
|
||||
const uploaded = await uploadComposerAttachment(
|
||||
{ detail: path, id: attachmentId(kind, path), kind, label: pathLabel(path), path },
|
||||
{ remote, requestGateway, sessionId }
|
||||
)
|
||||
|
||||
const ref = attachmentDisplayText(uploaded)
|
||||
|
||||
if (ref) {
|
||||
refs.push(ref)
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, t.desktop.dropFiles)
|
||||
}
|
||||
}
|
||||
|
||||
return refs
|
||||
},
|
||||
[cwd, gateway, sessionId, t.desktop.dropFiles]
|
||||
)
|
||||
|
||||
const resetDragState = useCallback(() => {
|
||||
dragDepthRef.current = 0
|
||||
setDragActive(false)
|
||||
}, [])
|
||||
|
||||
const handleDragEnter = (event: ReactDragEvent<HTMLElement>) => {
|
||||
if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
dragDepthRef.current += 1
|
||||
|
||||
if (!dragActive) {
|
||||
setDragActive(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOver = (event: ReactDragEvent<HTMLElement>) => {
|
||||
if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
|
||||
const handleDragLeave = (event: ReactDragEvent<HTMLElement>) => {
|
||||
event.preventDefault()
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
|
||||
|
||||
if (dragDepthRef.current === 0) {
|
||||
setDragActive(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (event: ReactDragEvent<HTMLElement>) => {
|
||||
if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
|
||||
return
|
||||
}
|
||||
|
||||
const candidates = extractDroppedFiles(event.dataTransfer)
|
||||
|
||||
if (!candidates.length) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
resetDragState()
|
||||
|
||||
// In-app drags (project tree / gutter) are workspace-relative paths that
|
||||
// resolve on the gateway as-is, so they stay inline refs. OS drops need to
|
||||
// be staged + uploaded first, then their gateway-side ref is inserted.
|
||||
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
|
||||
|
||||
if (insertDroppedRefs(inAppRefs)) {
|
||||
triggerHaptic('selection')
|
||||
}
|
||||
|
||||
if (osDrops.length) {
|
||||
setStaging(true)
|
||||
void uploadOsDropRefs(osDrops)
|
||||
.then(refs => {
|
||||
if (insertRefStrings(refs)) {
|
||||
triggerHaptic('selection')
|
||||
}
|
||||
})
|
||||
.finally(() => setStaging(false))
|
||||
}
|
||||
}
|
||||
|
||||
const handleInput = (event: FormEvent<HTMLDivElement>) => {
|
||||
const editor = event.currentTarget
|
||||
|
||||
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
|
||||
editor.replaceChildren()
|
||||
}
|
||||
|
||||
syncDraftFromEditor(editor)
|
||||
window.setTimeout(refreshTrigger, 0)
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||
const pastedText = event.clipboardData.getData('text')
|
||||
|
||||
if (!pastedText || DATA_IMAGE_URL_RE.test(pastedText.trim())) {
|
||||
event.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
document.execCommand('insertText', false, pastedText)
|
||||
syncDraftFromEditor(event.currentTarget)
|
||||
}
|
||||
|
||||
const submitEdit = (editor: HTMLDivElement) => {
|
||||
const nextDraft = syncDraftFromEditor(editor)
|
||||
|
||||
if (submitting || staging || !nextDraft.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
aui.composer().send()
|
||||
}
|
||||
|
||||
const handleEditBlur = useCallback(
|
||||
(event: FocusEvent<HTMLDivElement>) => {
|
||||
const nextTarget = event.relatedTarget
|
||||
|
||||
if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
|
||||
return
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
const root = rootRef.current
|
||||
const active = document.activeElement
|
||||
|
||||
if (submitting || (root && active && root.contains(active))) {
|
||||
return
|
||||
}
|
||||
|
||||
closeTrigger()
|
||||
aui.composer().cancel()
|
||||
}, 80)
|
||||
},
|
||||
[aui, closeTrigger, submitting]
|
||||
)
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (trigger && triggerItems.length > 0) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
setTriggerActive(idx => (idx + 1) % triggerItems.length)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
const item = triggerItems[triggerActive]
|
||||
|
||||
if (item) {
|
||||
replaceTriggerWithChip(item)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
closeTrigger()
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
aui.composer().cancel()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
submitEdit(event.currentTarget)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = () => {
|
||||
// If this keyup belongs to a key the open trigger popover already consumed
|
||||
// in keydown (Arrow/Enter/Tab/Escape), skip the refresh. Those keys never
|
||||
// edit text, and for Escape the keydown already closed the menu — a refresh
|
||||
// here would re-detect the still-present `/` and instantly reopen it. We
|
||||
// read a ref set during keydown rather than `trigger`, because by keyup
|
||||
// time React has re-rendered and `trigger` may already be null.
|
||||
if (triggerKeyConsumedRef.current) {
|
||||
triggerKeyConsumedRef.current = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
window.setTimeout(refreshTrigger, 0)
|
||||
}
|
||||
|
||||
return (
|
||||
<ComposerPrimitive.Root className="contents" data-slot="aui_edit-composer-root">
|
||||
<StickyHumanMessageContainer>
|
||||
<div
|
||||
className="composer-human-message-container human-execution-message-top relative flex w-full items-start rounded-md bg-(--ui-chat-surface-background)"
|
||||
onBlur={handleEditBlur}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
ref={rootRef}
|
||||
>
|
||||
{trigger && (
|
||||
<ComposerTriggerPopover
|
||||
activeIndex={triggerActive}
|
||||
items={triggerItems}
|
||||
kind={trigger.kind}
|
||||
loading={triggerLoading}
|
||||
onHover={setTriggerActive}
|
||||
onPick={replaceTriggerWithChip}
|
||||
placement={triggerPlacement}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
USER_BUBBLE_BASE_CLASS,
|
||||
'ui-prompt-input__container relative border-(--ui-stroke-secondary) data-[expanded=true]:min-h-20',
|
||||
COMPOSER_DROP_FADE_CLASS,
|
||||
dragActive && COMPOSER_DROP_ACTIVE_CLASS
|
||||
)}
|
||||
data-expanded={expanded ? 'true' : undefined}
|
||||
>
|
||||
<div
|
||||
aria-label={copy.editMessage}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
className={cn(
|
||||
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] text-foreground/95 outline-none',
|
||||
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
|
||||
'**:data-ref-text:cursor-default',
|
||||
expanded ? 'min-h-16' : 'min-h-[1.25rem]'
|
||||
)}
|
||||
contentEditable
|
||||
data-placeholder={copy.editMessage}
|
||||
data-slot={RICH_INPUT_SLOT}
|
||||
onBlur={() => window.setTimeout(closeTrigger, 80)}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onFocus={() => markActiveComposer('edit')}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
onMouseUp={refreshTrigger}
|
||||
onPaste={handlePaste}
|
||||
ref={editorRef}
|
||||
role="textbox"
|
||||
spellCheck={false}
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
<ComposerPrimitive.Input
|
||||
asChild
|
||||
className="sr-only"
|
||||
submitMode="ctrlEnter"
|
||||
tabIndex={-1}
|
||||
unstable_focusOnScrollToBottom={false}
|
||||
>
|
||||
<textarea
|
||||
aria-hidden
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
className="sr-only"
|
||||
spellCheck={false}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</ComposerPrimitive.Input>
|
||||
{staging && (
|
||||
<span
|
||||
className="pointer-events-none absolute bottom-2 left-2 inline-flex items-center gap-1 rounded-full bg-background/80 px-1.5 py-0.5 text-[0.62rem] text-muted-foreground backdrop-blur-[1px]"
|
||||
data-slot="aui_edit-staging"
|
||||
>
|
||||
<Loader2Icon className="size-3 animate-spin" />
|
||||
{copy.attachingFile}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
aria-label={copy.sendEdited}
|
||||
className={cn('absolute right-2 bottom-2 size-5', USER_ACTION_ICON_BUTTON_CLASS)}
|
||||
disabled={!canSubmit || submitting || staging}
|
||||
onClick={() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (editor) {
|
||||
submitEdit(editor)
|
||||
}
|
||||
}}
|
||||
title={copy.sendEdited}
|
||||
type="button"
|
||||
>
|
||||
{submitting ? StopGlyph : <Codicon name="arrow-up" size={USER_ACTION_ICON_SIZE} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</StickyHumanMessageContainer>
|
||||
</ComposerPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import { ExportedMessageRepository } from '@assistant-ui/core/internal'
|
||||
// Clicking a user bubble must open the inline edit composer — through the
|
||||
// app's incremental external-store runtime (which reimplements capability
|
||||
// resolution, incl. `edit: onEdit !== undefined`) and the stock runtime.
|
||||
//
|
||||
// Note: this covers the React/runtime wiring only. The Electron-level failure
|
||||
// mode (titlebar -webkit-app-region:drag swallowing clicks on *stuck* sticky
|
||||
// bubbles) is not reproducible in jsdom — see USER_BUBBLE_BASE_CLASS's no-drag
|
||||
// carve-out in thread.tsx.
|
||||
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
|
||||
|
||||
import { Thread } from '.'
|
||||
|
||||
const createdAt = new Date('2026-05-01T00:00:00.000Z')
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
window.setTimeout(() => callback(performance.now()), 0)
|
||||
)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
|
||||
|
||||
Element.prototype.scrollTo = function scrollTo() {}
|
||||
|
||||
function stubOffsetDimension(
|
||||
prop: 'offsetHeight' | 'offsetWidth',
|
||||
clientProp: 'clientHeight' | 'clientWidth',
|
||||
fallback: number
|
||||
) {
|
||||
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, prop, {
|
||||
configurable: true,
|
||||
get() {
|
||||
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
|
||||
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
|
||||
|
||||
function userMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'edit me please' }],
|
||||
attachments: [],
|
||||
createdAt,
|
||||
metadata: { custom: {} }
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function assistantMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
// Mirrors chat/index.tsx: incremental runtime + messageRepository + onEdit.
|
||||
function IncrementalHarness({ onEdit }: { onEdit: () => Promise<void> }) {
|
||||
const repository = ExportedMessageRepository.fromArray([userMessage(), assistantMessage()])
|
||||
|
||||
const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
|
||||
messageRepository: repository,
|
||||
isRunning: false,
|
||||
setMessages: () => {},
|
||||
onNew: async () => {},
|
||||
onEdit,
|
||||
onCancel: async () => {},
|
||||
onReload: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Control: stock external store runtime.
|
||||
function StockHarness({ onEdit }: { onEdit: () => Promise<void> }) {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [userMessage(), assistantMessage()],
|
||||
isRunning: false,
|
||||
onNew: async () => {},
|
||||
onEdit
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('click-to-edit user message', () => {
|
||||
it('opens the edit composer with the incremental runtime', async () => {
|
||||
const { container } = render(<IncrementalHarness onEdit={async () => {}} />)
|
||||
|
||||
const bubble = await screen.findByRole('button', { name: 'Edit message' })
|
||||
|
||||
fireEvent.click(bubble)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('opens the edit composer with the stock runtime', async () => {
|
||||
const { container } = render(<StockHarness onEdit={async () => {}} />)
|
||||
|
||||
const bubble = await screen.findByRole('button', { name: 'Edit message' })
|
||||
|
||||
fireEvent.click(bubble)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
import type { FC } from 'react'
|
||||
import { Fragment, useMemo } from 'react'
|
||||
|
||||
import { DirectiveContent } from '@/components/assistant-ui/directive-text'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// User messages should render the bare-minimum of markdown: backtick `code`
|
||||
// spans and ``` fenced blocks. We deliberately don't pull in the full
|
||||
// assistant Markdown pipeline (Streamdown + KaTeX + syntax highlighter)
|
||||
// because user input rarely contains structured docs and the heavy pipeline
|
||||
// adds a lot of runtime cost per bubble.
|
||||
//
|
||||
// Directive chips (`@file:`, `@image:`, ...) still resolve via DirectiveContent
|
||||
// inside the plain-text segments.
|
||||
|
||||
interface FenceSegment {
|
||||
kind: 'fence'
|
||||
code: string
|
||||
lang: string | null
|
||||
}
|
||||
|
||||
interface InlineSegment {
|
||||
kind: 'inline'
|
||||
text: string
|
||||
}
|
||||
|
||||
interface InlineCodeSegment {
|
||||
kind: 'inline-code'
|
||||
code: string
|
||||
}
|
||||
|
||||
interface InlineTextSegment {
|
||||
kind: 'inline-text'
|
||||
text: string
|
||||
}
|
||||
|
||||
type TopSegment = FenceSegment | InlineSegment
|
||||
type InlineNode = InlineCodeSegment | InlineTextSegment
|
||||
|
||||
const FENCE_RE = /```([^\n`]*)\n([\s\S]*?)```/g
|
||||
|
||||
// Greedy backtick run length so ``code with `backticks` inside`` works.
|
||||
const INLINE_CODE_RE = /(`+)([^`\n][\s\S]*?)\1/g
|
||||
|
||||
function splitFences(text: string): TopSegment[] {
|
||||
const segments: TopSegment[] = []
|
||||
let cursor = 0
|
||||
|
||||
for (const match of text.matchAll(FENCE_RE)) {
|
||||
const start = match.index ?? 0
|
||||
|
||||
if (start > cursor) {
|
||||
segments.push({ kind: 'inline', text: text.slice(cursor, start) })
|
||||
}
|
||||
|
||||
segments.push({
|
||||
kind: 'fence',
|
||||
lang: (match[1] || '').trim() || null,
|
||||
code: match[2] ?? ''
|
||||
})
|
||||
cursor = start + match[0].length
|
||||
}
|
||||
|
||||
if (cursor < text.length) {
|
||||
segments.push({ kind: 'inline', text: text.slice(cursor) })
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
function splitInlineCode(text: string): InlineNode[] {
|
||||
const nodes: InlineNode[] = []
|
||||
let cursor = 0
|
||||
|
||||
for (const match of text.matchAll(INLINE_CODE_RE)) {
|
||||
const start = match.index ?? 0
|
||||
|
||||
if (start > cursor) {
|
||||
nodes.push({ kind: 'inline-text', text: text.slice(cursor, start) })
|
||||
}
|
||||
|
||||
nodes.push({ kind: 'inline-code', code: match[2] })
|
||||
cursor = start + match[0].length
|
||||
}
|
||||
|
||||
if (cursor < text.length) {
|
||||
nodes.push({ kind: 'inline-text', text: text.slice(cursor) })
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
interface UserMessageTextProps {
|
||||
text: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const UserMessageText: FC<UserMessageTextProps> = ({ className, text }) => {
|
||||
const top = useMemo(() => splitFences(text), [text])
|
||||
|
||||
return (
|
||||
<span className={cn('block', className)} data-slot="aui_user-message-text">
|
||||
{top.map((segment, segmentIndex) => {
|
||||
if (segment.kind === 'fence') {
|
||||
return (
|
||||
<pre
|
||||
className="my-1.5 max-w-full overflow-x-auto rounded-md border border-border/45 bg-[color-mix(in_srgb,currentColor_5%,transparent)] px-2.5 py-2 font-mono text-[0.86em] leading-snug"
|
||||
data-slot="aui_user-fence"
|
||||
key={`fence-${segmentIndex}`}
|
||||
>
|
||||
<code className="block whitespace-pre">{segment.code}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={`inline-${segmentIndex}`}>
|
||||
<InlineSegmentView text={segment.text} />
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const InlineSegmentView: FC<{ text: string }> = ({ text }) => {
|
||||
const nodes = useMemo(() => splitInlineCode(text), [text])
|
||||
|
||||
return (
|
||||
// styles.css bidi hook (#44150); whitespace-pre-line makes each line its own
|
||||
// UAX#9 paragraph so it resolves direction independently.
|
||||
<span className="wrap-anywhere block whitespace-pre-line" data-slot="aui_user-inline-text">
|
||||
{nodes.map((node, nodeIndex) =>
|
||||
node.kind === 'inline-code' ? (
|
||||
<code
|
||||
className="mx-px rounded bg-[color-mix(in_srgb,currentColor_8%,transparent)] px-1 py-px font-mono text-[0.92em]"
|
||||
data-slot="aui_user-inline-code"
|
||||
key={`code-${nodeIndex}`}
|
||||
>
|
||||
{node.code}
|
||||
</code>
|
||||
) : (
|
||||
// Pass plain-text bits through DirectiveContent so @file:/@url: chips
|
||||
// still render. DirectiveContent already preserves whitespace.
|
||||
<Fragment key={`text-${nodeIndex}`}>
|
||||
<DirectiveContent text={node.text} />
|
||||
</Fragment>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
367
apps/desktop/src/components/assistant-ui/thread/user-message.tsx
Normal file
367
apps/desktop/src/components/assistant-ui/thread/user-message.tsx
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
import { ActionBarPrimitive, BranchPickerPrimitive, MessagePrimitive, useAuiState } from '@assistant-ui/react'
|
||||
import { type FC, type ReactNode, useCallback, useRef, useState } from 'react'
|
||||
|
||||
import { DirectiveContent } from '@/components/assistant-ui/directive-text'
|
||||
import { messageAttachmentRefs, messageContentText } from '@/components/assistant-ui/thread/content'
|
||||
import { type RestoreMessageTarget } from '@/components/assistant-ui/thread/types'
|
||||
import { UserMessageText } from '@/components/assistant-ui/thread/user-message-text'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { StopFilled } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notifyThreadEditOpen } from '@/store/thread-scroll'
|
||||
import { isWatchWindow } from '@/store/windows'
|
||||
|
||||
export function StickyHumanMessageContainer({
|
||||
attachments,
|
||||
children,
|
||||
messageId
|
||||
}: {
|
||||
attachments?: ReactNode
|
||||
children: ReactNode
|
||||
messageId?: string
|
||||
}) {
|
||||
return (
|
||||
// Fragment, not a wrapper: a wrapping element becomes the sticky's
|
||||
// containing block (it'd stick within its own height = never). The bubble
|
||||
// and attachments are flow siblings so the bubble pins against the scroller
|
||||
// while attachments below it scroll away.
|
||||
<>
|
||||
<div
|
||||
className="group/user-message sticky z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-1"
|
||||
data-message-id={messageId}
|
||||
data-role="user"
|
||||
data-slot="aui_user-message-root"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{attachments}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Shared "user bubble" base. Both the read-only message and the inline
|
||||
// edit composer render the same bubble surface (rounded glass card);
|
||||
// they only differ in border weight, cursor, and padding-right (the
|
||||
// read-only view reserves room for the restore icon).
|
||||
//
|
||||
// no-drag: sticky bubbles park at --sticky-human-top (~4px), sliding under the
|
||||
// titlebar's [-webkit-app-region:drag] strips (app-shell.tsx). Electron resolves
|
||||
// drag regions at the compositor level — z-index and pointer-events don't help —
|
||||
// so without the carve-out, clicking a stuck bubble drags the window instead of
|
||||
// opening the edit composer.
|
||||
export const USER_BUBBLE_BASE_CLASS =
|
||||
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-y-auto rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left [-webkit-app-region:no-drag]'
|
||||
|
||||
export const USER_ACTION_ICON_BUTTON_CLASS =
|
||||
'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'
|
||||
|
||||
export const USER_ACTION_ICON_SIZE = '0.6875rem'
|
||||
export const StopGlyph = <StopFilled aria-hidden className="size-3.5 -translate-y-px" />
|
||||
|
||||
// Background-process notifications are injected into the conversation as user
|
||||
// messages (the agent must react to them, and message-role alternation forbids
|
||||
// a synthetic system row mid-loop). They are NOT something the human typed, so
|
||||
// render them as a compact system-style notice instead of a user bubble.
|
||||
// Shape: see tools/process_registry.py format_process_notification().
|
||||
const PROCESS_NOTIFICATION_RE = /^\[IMPORTANT: Background process [\s\S]*\]$/
|
||||
|
||||
const ProcessNotificationNote: FC<{ text: string }> = ({ text }) => {
|
||||
const body = text.replace(/^\[IMPORTANT:\s*/, '').replace(/\]$/, '')
|
||||
const newline = body.indexOf('\n')
|
||||
const headline = (newline === -1 ? body : body.slice(0, newline)).trim()
|
||||
const detail = newline === -1 ? '' : body.slice(newline + 1).trim()
|
||||
|
||||
return (
|
||||
<div className="flex max-w-[min(86%,44rem)] flex-col gap-0.5 self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/60">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Codicon className="shrink-0 text-muted-foreground/55" name="terminal" size="0.75rem" />
|
||||
<span className="wrap-anywhere">{headline}</span>
|
||||
</span>
|
||||
{detail && (
|
||||
<details className="pl-[1.3125rem]">
|
||||
<summary className="cursor-pointer select-none text-muted-foreground/45 hover:text-muted-foreground/70">
|
||||
output
|
||||
</summary>
|
||||
<pre
|
||||
className="mt-0.5 max-h-48 overflow-auto whitespace-pre-wrap font-mono text-[0.625rem] leading-4 text-muted-foreground/55"
|
||||
data-selectable-text="true"
|
||||
>
|
||||
{detail}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const UserMessage: FC<{
|
||||
onCancel?: () => Promise<void> | void
|
||||
onRequestRestoreConfirm?: (messageId: string, target: RestoreMessageTarget) => void
|
||||
}> = ({ onCancel, onRequestRestoreConfirm }) => {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.thread
|
||||
const messageId = useAuiState(s => s.message.id)
|
||||
const content = useAuiState(s => s.message.content)
|
||||
const messageText = messageContentText(content)
|
||||
const threadRunning = useAuiState(s => s.thread.isRunning)
|
||||
|
||||
const latestUserId = useAuiState(s => {
|
||||
for (let i = s.thread.messages.length - 1; i >= 0; i--) {
|
||||
const message = s.thread.messages[i] as { id?: string; role?: string }
|
||||
|
||||
if (message.role === 'user') {
|
||||
return message.id ?? null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const runtimeUserOrdinal = useAuiState(s => {
|
||||
let ordinal = 0
|
||||
|
||||
for (const message of s.thread.messages) {
|
||||
if (message.role !== 'user') {
|
||||
continue
|
||||
}
|
||||
|
||||
if (message.id === s.message.id) {
|
||||
return ordinal
|
||||
}
|
||||
|
||||
ordinal += 1
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const attachmentRefs = useAuiState(s => {
|
||||
const custom = (s.message.metadata?.custom ?? {}) as { attachmentRefs?: unknown }
|
||||
|
||||
return messageAttachmentRefs(custom.attachmentRefs)
|
||||
})
|
||||
|
||||
// Sticky human bubbles clamp to ~2 lines with a soft fade so a long prompt
|
||||
// doesn't dominate the viewport while the response streams underneath; the
|
||||
// clamp lifts on hover / focus (see styles.css). We measure the *unclamped*
|
||||
// inner wrapper so the ResizeObserver only fires on real content / width
|
||||
// changes, not on every frame while the outer max-height animates open.
|
||||
const clampInnerRef = useRef<HTMLDivElement | null>(null)
|
||||
const [bodyClamped, setBodyClamped] = useState(false)
|
||||
const lastClampHeightRef = useRef(-1)
|
||||
const lineHeightRef = useRef(0)
|
||||
|
||||
// Watch windows spectate a subagent run driven elsewhere — prompts can't be
|
||||
// edited, restored, or stopped from here. The bubble stays a button that
|
||||
// toggles the 2-line clamp so long prompts are still fully readable.
|
||||
const readOnly = isWatchWindow()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const clampActive = !(readOnly && expanded)
|
||||
|
||||
const measureClamp = useCallback((entries: readonly ResizeObserverEntry[]) => {
|
||||
const inner = clampInnerRef.current
|
||||
const outer = inner?.parentElement
|
||||
|
||||
if (!inner || !outer) {
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer the size the ResizeObserver already computed — reading
|
||||
// `scrollHeight` outside RO timing forces a synchronous layout, and with
|
||||
// many user bubbles observed at once those reads interleave with the
|
||||
// style write below into a read-write-read reflow cascade.
|
||||
const entryHeight = entries.find(entry => entry.target === inner)?.borderBoxSize?.[0]?.blockSize
|
||||
const fullHeight = Math.ceil(entryHeight ?? inner.scrollHeight)
|
||||
|
||||
if (fullHeight === lastClampHeightRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
lastClampHeightRef.current = fullHeight
|
||||
|
||||
// Line-height is stable for the life of the bubble (font settings don't
|
||||
// change under it) — resolve the computed style once.
|
||||
if (!lineHeightRef.current) {
|
||||
const styles = getComputedStyle(inner)
|
||||
lineHeightRef.current = parseFloat(styles.lineHeight) || 1.5 * parseFloat(styles.fontSize) || 20
|
||||
}
|
||||
|
||||
outer.style.setProperty('--human-msg-full', `${fullHeight}px`)
|
||||
setBodyClamped(fullHeight > lineHeightRef.current * 2 + 1)
|
||||
}, [])
|
||||
|
||||
useResizeObserver(measureClamp, clampInnerRef)
|
||||
|
||||
// Injected background-process notification, not a human prompt — render the
|
||||
// compact system-style notice (after all hooks above have run).
|
||||
if (PROCESS_NOTIFICATION_RE.test(messageText.trim())) {
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="flex w-full min-w-0 flex-col items-stretch"
|
||||
data-role="user"
|
||||
data-slot="aui_user-message-root"
|
||||
>
|
||||
<ProcessNotificationNote text={messageText.trim()} />
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const hasBody = messageText.trim().length > 0
|
||||
const isLatestUser = messageId === latestUserId
|
||||
const showStop = !readOnly && isLatestUser && threadRunning && Boolean(onCancel)
|
||||
// Restore (re-run this exact prompt) is available everywhere the Stop button
|
||||
// isn't — including mid-stream on older prompts, since the action interrupts
|
||||
// the live turn before rewinding.
|
||||
const showRestore = !readOnly && !showStop && Boolean(onRequestRestoreConfirm) && hasBody
|
||||
|
||||
const bubbleClassName = cn(
|
||||
USER_BUBBLE_BASE_CLASS,
|
||||
'cursor-pointer pr-9 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 transition-colors',
|
||||
'border-(--ui-stroke-tertiary) hover:border-(--ui-stroke-secondary)'
|
||||
)
|
||||
|
||||
const bubbleContent = hasBody && (
|
||||
// Render the user's text through a minimal markdown pipeline:
|
||||
// backtick `code` and ``` fenced ``` blocks, with directive chips
|
||||
// (`@file:` etc.) still resolved inside the plain-text spans.
|
||||
<div
|
||||
className={cn(clampActive && 'sticky-human-clamp')}
|
||||
data-clamped={clampActive && bodyClamped ? 'true' : undefined}
|
||||
>
|
||||
{/* Match the edit composer's collapsed line box (min-h-[1.25rem]) so
|
||||
clicking to edit can't grow the bubble by a sub-pixel and reflow the
|
||||
turn 1px. */}
|
||||
<div className="min-h-[1.25rem]" ref={clampInnerRef}>
|
||||
<UserMessageText className="wrap-anywhere" text={messageText} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root asChild>
|
||||
<StickyHumanMessageContainer
|
||||
attachments={
|
||||
// Attachments live BELOW the sticky bubble in normal flow, so they
|
||||
// scroll away behind the pinned bubble instead of riding along with
|
||||
// it. Image refs render as thumbnails, file refs as chips; no border.
|
||||
attachmentRefs.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1 -mt-3 mb-2">
|
||||
<DirectiveContent text={attachmentRefs.join(' ')} />
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
messageId={messageId}
|
||||
>
|
||||
<ActionBarPrimitive.Root className="relative w-full max-w-full" data-slot="aui_user-bubble-actions">
|
||||
<div className="human-message-with-todos-wrapper flex w-full flex-col gap-0">
|
||||
<div className="relative w-full">
|
||||
{readOnly ? (
|
||||
// Spectator transcript: clicking only toggles the clamp so the
|
||||
// full prompt is readable — never opens an edit composer.
|
||||
<button
|
||||
aria-expanded={bodyClamped ? expanded : undefined}
|
||||
className={cn(bubbleClassName, !bodyClamped && 'cursor-default')}
|
||||
onClick={() => {
|
||||
if (!bodyClamped) {
|
||||
return
|
||||
}
|
||||
|
||||
triggerHaptic('selection')
|
||||
setExpanded(value => !value)
|
||||
}}
|
||||
title={bodyClamped ? (expanded ? t.common.collapse : copy.expandMessage) : undefined}
|
||||
type="button"
|
||||
>
|
||||
{bubbleContent}
|
||||
</button>
|
||||
) : (
|
||||
// Always editable — clicking opens the edit composer even while a
|
||||
// turn streams; sending the edit reverts (interrupt + rewind).
|
||||
<ActionBarPrimitive.Edit asChild>
|
||||
<button
|
||||
aria-label={copy.editMessage}
|
||||
className={bubbleClassName}
|
||||
onClick={() => triggerHaptic('selection')}
|
||||
onPointerDown={() => notifyThreadEditOpen()}
|
||||
title={copy.editMessage}
|
||||
type="button"
|
||||
>
|
||||
{bubbleContent}
|
||||
</button>
|
||||
</ActionBarPrimitive.Edit>
|
||||
)}
|
||||
{(showStop || showRestore) && (
|
||||
<div className="pointer-events-none absolute right-2 bottom-2 z-10 flex items-center justify-center opacity-0 transition-opacity group-hover/user-message:opacity-100 group-focus-within/user-message:opacity-100">
|
||||
{showStop ? (
|
||||
<button
|
||||
aria-label={copy.stop}
|
||||
className={cn('pointer-events-auto size-5', USER_ACTION_ICON_BUTTON_CLASS)}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void onCancel?.()
|
||||
}}
|
||||
title={copy.stop}
|
||||
type="button"
|
||||
>
|
||||
{StopGlyph}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
aria-label={copy.restoreCheckpoint}
|
||||
className={cn('pointer-events-auto size-6', USER_ACTION_ICON_BUTTON_CLASS)}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
onRequestRestoreConfirm?.(messageId, {
|
||||
text: messageText,
|
||||
userOrdinal: runtimeUserOrdinal
|
||||
})
|
||||
}}
|
||||
onPointerDown={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
title={copy.restoreFromHere}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="discard" size="0.875rem" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<BranchPickerPrimitive.Root
|
||||
className={cn(
|
||||
'checkpoint-container flex items-center gap-1 pb-0 pt-1 pl-1.5 text-[0.75rem] leading-none text-(--ui-text-tertiary)',
|
||||
readOnly && 'hidden'
|
||||
)}
|
||||
hideWhenSingleBranch
|
||||
>
|
||||
<span aria-hidden className="checkpoint-icon size-1.5 rounded-full border border-current" />
|
||||
<BranchPickerPrimitive.Previous
|
||||
className="checkpoint-restore-text rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
|
||||
title={copy.restorePrevious}
|
||||
>
|
||||
{copy.restoreCheckpoint}
|
||||
</BranchPickerPrimitive.Previous>
|
||||
<span className="checkpoint-divider opacity-55">
|
||||
<BranchPickerPrimitive.Number />/<BranchPickerPrimitive.Count />
|
||||
</span>
|
||||
<BranchPickerPrimitive.Next
|
||||
className="checkpoint-restore-text rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
|
||||
title={copy.restoreNext}
|
||||
>
|
||||
{copy.goForward}
|
||||
</BranchPickerPrimitive.Next>
|
||||
</BranchPickerPrimitive.Root>
|
||||
</div>
|
||||
</ActionBarPrimitive.Root>
|
||||
</StickyHumanMessageContainer>
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue