diff --git a/apps/desktop/src/components/assistant-ui/assistant-message.tsx b/apps/desktop/src/components/assistant-ui/assistant-message.tsx new file mode 100644 index 00000000000..15efdf3d683 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/assistant-message.tsx @@ -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 ( + +
+ {/* Todos render in the composer status stack now, not inline. */} + + {isRunning && } + {previewTargets.length > 0 && ( +
+ {previewTargets.map(target => ( + + ))} +
+ )} + + + + {onDismissError && ( + onDismissError(messageId)} + side="top" + tooltip={t.assistant.thread.dismissError} + > + + + )} + + +
+ {hasVisibleText && ( + + )} +
+ ) +} + +const AssistantActionBar: FC = ({ messageId, getMessageText, onBranchInNewChat }) => { + const { t } = useI18n() + const copy = t.assistant.thread + const [menuOpen, setMenuOpen] = useState(false) + + return ( +
+ + + + triggerHaptic('submit')} tooltip={copy.refresh}> + + + + + + + + + + e.preventDefault()} sideOffset={6}> + + onBranchInNewChat?.(messageId)}> + + {copy.branchNewChat} + + + + + +
+ ) +} + +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 ( + { + e.preventDefault() + void (isSpeaking ? stopVoicePlayback() : read()) + }} + > + + {isPreparing ? copy.preparingAudio : isSpeaking ? copy.stopReading : copy.readAloud} + + ) +} + +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 {label} +} + +const AssistantFooter: FC = props => ( +
+ + + + + + / + + + + + + +
+) diff --git a/apps/desktop/src/components/assistant-ui/system-message.tsx b/apps/desktop/src/components/assistant-ui/system-message.tsx new file mode 100644 index 00000000000..ce6d0a856c8 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/system-message.tsx @@ -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:(?\/[^\n]+)\n(?[\s\S]*)$/ +const STEER_NOTE_RE = /^steer:(?[\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 ( + + + steered + · + {steerNote.groups.text.trim()} + + ) + } + + 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 ( + + {slashStatus.groups.command} + {multiline ? ( + + ) : ( + <> + · + + + )} + + ) + } + + const multiline = text.includes('\n') + + return ( + + + + ) +} diff --git a/apps/desktop/src/components/assistant-ui/thread-content.test.ts b/apps/desktop/src/components/assistant-ui/thread-content.test.ts new file mode 100644 index 00000000000..732e8c2e3cf --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread-content.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' + +import { + contentHasVisibleText, + messageAttachmentRefs, + messageContentText, + partText, + pickPrimaryPreviewTarget +} from './thread-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']) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/thread-content.ts b/apps/desktop/src/components/assistant-ui/thread-content.ts new file mode 100644 index 00000000000..7f7d9bedd4c --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread-content.ts @@ -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]] +} diff --git a/apps/desktop/src/components/assistant-ui/thread-message-parts.tsx b/apps/desktop/src/components/assistant-ui/thread-message-parts.tsx new file mode 100644 index 00000000000..7d2c87e0e4d --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread-message-parts.tsx @@ -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 = ({ args, result }) => { + const aspectRatio = typeof args?.aspect_ratio === 'string' ? args.aspect_ratio : undefined + + return ( +
+ +
+ ) +} + +const ChainToolFallback: FC = 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 + } + + if (props.toolName === 'clarify') { + return + } + + return +} + +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(null) + const elapsed = useElapsedSeconds(pending, timerKey) + const scrollRef = useRef(null) + const contentRef = useRef(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 ( +
+ setUserOpen(!open)} open={open}> + + + {t.assistant.thread.thinking} + + {pending && ( + + )} + + + {open && ( +
+
{children}
+
+ )} +
+ ) +} + +// 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 ( + + {children} + + ) +} + +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 ( + } + 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 diff --git a/apps/desktop/src/components/assistant-ui/thread-status.tsx b/apps/desktop/src/components/assistant-ui/thread-status.tsx new file mode 100644 index 00000000000..53c6f415a85 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread-status.tsx @@ -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 +}) => ( +
+ {children} +
+) + +// Fixed label while auto-compaction runs — decoupled from backend status text. +const COMPACTION_LABEL = 'Summarizing thread' + +const CompactionHint: FC = () => ( + {COMPACTION_LABEL} +) + +export const CenteredThreadSpinner: FC = () => { + const { t } = useI18n() + + return ( +
+
+ ) +} + +export const ResponseLoadingIndicator: FC = () => { + const { t } = useI18n() + const elapsed = useElapsedSeconds() + const compacting = useStore($compactionActive) + + return ( + + + ) +} + +// 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 ( +
+ + {label} +
+ ) +} + +// 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 ( + + + ) +} diff --git a/apps/desktop/src/components/assistant-ui/thread-timestamp.test.ts b/apps/desktop/src/components/assistant-ui/thread-timestamp.test.ts new file mode 100644 index 00000000000..684456205b7 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread-timestamp.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' + +import { formatMessageTimestamp } from './thread-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) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/thread-timestamp.ts b/apps/desktop/src/components/assistant-ui/thread-timestamp.ts new file mode 100644 index 00000000000..f9df650197a --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread-timestamp.ts @@ -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) +} diff --git a/apps/desktop/src/components/assistant-ui/thread-types.ts b/apps/desktop/src/components/assistant-ui/thread-types.ts new file mode 100644 index 00000000000..0add8b5f8c4 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread-types.ts @@ -0,0 +1,4 @@ +export interface RestoreMessageTarget { + text: string + userOrdinal: number | null +} diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index 24de1186638..fdf95f7e234 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -1,172 +1,24 @@ -import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core' -import { - ActionBarPrimitive, - BranchPickerPrimitive, - ComposerPrimitive, - ErrorPrimitive, - MessagePrimitive, - type ToolCallMessagePartProps, - useAui, - useAuiState, - useMessageRuntime -} from '@assistant-ui/react' -import { useStore } from '@nanostores/react' -import { - type ClipboardEvent, - type ComponentProps, - type FC, - type FocusEvent, - type FormEvent, - type KeyboardEvent, - type DragEvent as ReactDragEvent, - type ReactNode, - useCallback, - useEffect, - useMemo, - useRef, - useState -} from 'react' +import { type FC, useCallback, useMemo, 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 { ClarifyTool } from '@/components/assistant-ui/clarify-tool' -import { DirectiveContent, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text' -import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text' +import { AssistantMessage } from '@/components/assistant-ui/assistant-message' +import { SystemMessage } from '@/components/assistant-ui/system-message' import { ThreadMessageList } from '@/components/assistant-ui/thread-list' -import { ThreadTimeline } from '@/components/assistant-ui/thread-timeline' -import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback' -import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button' -import { UserMessageText } from '@/components/assistant-ui/user-message-text' -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 { Intro, type IntroProps } from '@/components/chat/intro' -import { PreviewAttachment } from '@/components/chat/preview-attachment' -import { Codicon } from '@/components/ui/codicon' -import { ConfirmDialog } from '@/components/ui/confirm-dialog' -import { CopyButton } from '@/components/ui/copy-button' import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { Loader } from '@/components/ui/loader' + BackgroundResumeNotice, + CenteredThreadSpinner, + ResponseLoadingIndicator +} from '@/components/assistant-ui/thread-status' +import { ThreadTimeline } from '@/components/assistant-ui/thread-timeline' +import { type RestoreMessageTarget } from '@/components/assistant-ui/thread-types' +import { UserEditComposer } from '@/components/assistant-ui/user-edit-composer' +import { UserMessage } from '@/components/assistant-ui/user-message' +import { Intro, type IntroProps } from '@/components/chat/intro' +import { ConfirmDialog } from '@/components/ui/confirm-dialog' import type { HermesGateway } from '@/hermes' -import { useResizeObserver } from '@/hooks/use-resize-observer' import { useI18n } from '@/i18n' -import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runtime' -import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' -import { LinkifiedText } from '@/lib/external-link' -import { triggerHaptic } from '@/lib/haptics' -import { GitBranchIcon, Loader2Icon, StopFilled, 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 { $backgroundResume } from '@/store/background-delegation' -import { $compactionActive } from '@/store/compaction' -import type { ComposerAttachment } from '@/store/composer' import { notifyError } from '@/store/notifications' -import { $activeSessionAwaitingInput } from '@/store/prompts' -import { $connection } from '@/store/session' -import { notifyThreadEditClose, notifyThreadEditOpen } from '@/store/thread-scroll' -import { $voicePlayback } from '@/store/voice-playback' -import { isWatchWindow } from '@/store/windows' type ThreadLoadingState = 'response' | 'session' -interface RestoreMessageTarget { - text: string - userOrdinal: number | null -} - -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 -} - -let readAloudAudio: HTMLAudioElement | null = null - -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 : '' -} - -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). -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 const Thread: FC<{ clampToComposer?: boolean @@ -265,1678 +117,3 @@ export const Thread: FC<{ ) } - -function pickPrimaryPreviewTarget(targets: string[]): string[] { - if (targets.length <= 1) { - return targets - } - - const localUrl = targets.find(value => /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/i.test(value)) - - return [localUrl || targets[targets.length - 1]] -} - -const CenteredThreadSpinner: FC = () => { - const { t } = useI18n() - - return ( -
-
- ) -} - -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 ( - -
- {/* Todos render in the composer status stack now, not inline. */} - - {isRunning && } - {previewTargets.length > 0 && ( -
- {previewTargets.map(target => ( - - ))} -
- )} - - - - {onDismissError && ( - onDismissError(messageId)} - side="top" - tooltip={t.assistant.thread.dismissError} - > - - - )} - - -
- {hasVisibleText && ( - - )} -
- ) -} - -const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentPropsWithoutRef<'div'>> = ({ - children, - label, - className, - ...rest -}) => ( -
- {children} -
-) - -// Fixed label while auto-compaction runs — decoupled from backend status text. -const COMPACTION_LABEL = 'Summarizing thread' - -const CompactionHint: FC = () => ( - {COMPACTION_LABEL} -) - -const ResponseLoadingIndicator: FC = () => { - const { t } = useI18n() - const elapsed = useElapsedSeconds() - const compacting = useStore($compactionActive) - - return ( - - - ) -} - -// 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. -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 ( -
- - {label} -
- ) -} - -// 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. -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 ( - - - ) -} - -const ImageGenerateTool: FC = ({ args, result }) => { - const aspectRatio = typeof args?.aspect_ratio === 'string' ? args.aspect_ratio : undefined - - return ( -
- -
- ) -} - -const ChainToolFallback: FC = 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 - } - - if (props.toolName === 'clarify') { - return - } - - return -} - -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(null) - const elapsed = useElapsedSeconds(pending, timerKey) - const scrollRef = useRef(null) - const contentRef = useRef(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 ( -
- setUserOpen(!open)} open={open}> - - - {t.assistant.thread.thinking} - - {pending && ( - - )} - - - {open && ( -
-
{children}
-
- )} -
- ) -} - -// 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 ( - - {children} - - ) -} - -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 ( - } - 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. -const MESSAGE_PARTS_COMPONENTS = { - Reasoning: ReasoningTextPart, - ReasoningGroup: ReasoningAccordionGroup, - Text: MarkdownText, - ToolGroup: ToolGroupSlot, - tools: { Fallback: ChainToolFallback } -} as const - -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() -} - -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) -} - -const AssistantActionBar: FC = ({ messageId, getMessageText, onBranchInNewChat }) => { - const { t } = useI18n() - const copy = t.assistant.thread - const [menuOpen, setMenuOpen] = useState(false) - - return ( -
- - - - triggerHaptic('submit')} tooltip={copy.refresh}> - - - - - - - - - - e.preventDefault()} sideOffset={6}> - - onBranchInNewChat?.(messageId)}> - - {copy.branchNewChat} - - - - - -
- ) -} - -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 ( - { - e.preventDefault() - void (isSpeaking ? stopVoicePlayback() : read()) - }} - > - - {isPreparing ? copy.preparingAudio : isSpeaking ? copy.stopReading : copy.readAloud} - - ) -} - -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 {label} -} - -const AssistantFooter: FC = props => ( -
- - - - - - / - - - - - - -
-) - -const EMPTY_ATTACHMENT_REFS: string[] = [] - -function messageAttachmentRefs(value: unknown): string[] { - if (!Array.isArray(value)) { - return EMPTY_ATTACHMENT_REFS - } - - return value.every(ref => typeof ref === 'string') ? value : EMPTY_ATTACHMENT_REFS -} - -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. - <> -
- {children} -
- {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. -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]' - -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' - -const USER_ACTION_ICON_SIZE = '0.6875rem' -const StopGlyph = - -// 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 ( -
- - - {headline} - - {detail && ( -
- - output - -
-            {detail}
-          
-
- )} -
- ) -} - -const UserMessage: FC<{ - onCancel?: () => Promise | 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(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 ( - - - - ) - } - - 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. -
- {/* 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. */} -
- -
-
- ) - - return ( - - 0 ? ( -
- -
- ) : null - } - messageId={messageId} - > - -
-
- {readOnly ? ( - // Spectator transcript: clicking only toggles the clamp so the - // full prompt is readable — never opens an edit composer. - - ) : ( - // Always editable — clicking opens the edit composer even while a - // turn streams; sending the edit reverts (interrupt + rewind). - - - - )} - {(showStop || showRestore) && ( -
- {showStop ? ( - - ) : ( - - )} -
- )} -
- - - - {copy.restoreCheckpoint} - - - / - - - {copy.goForward} - - -
-
-
-
- ) -} - -const SLASH_STATUS_RE = /^slash:(?\/[^\n]+)\n(?[\s\S]*)$/ -const STEER_NOTE_RE = /^steer:(?[\s\S]+)$/ - -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 ( - - - steered - · - {steerNote.groups.text.trim()} - - ) - } - - 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 ( - - {slashStatus.groups.command} - {multiline ? ( - - ) : ( - <> - · - - - )} - - ) - } - - const multiline = text.includes('\n') - - return ( - - - - ) -} - -interface UserEditComposerProps { - cwd: string | null - gateway: HermesGateway | null - sessionId: string | null -} - -const UserEditComposer: FC = ({ cwd, gateway, sessionId }) => { - const { t } = useI18n() - const copy = t.assistant.thread - const aui = useAui() - const draft = useAuiState(s => s.composer.text) - const rootRef = useRef(null) - const editorRef = useRef(null) - const draftRef = useRef(draft) - const dragDepthRef = useRef(0) - const [dragActive, setDragActive] = useState(false) - const [trigger, setTrigger] = useState(null) - const [triggerActive, setTriggerActive] = useState(0) - const [triggerItems, setTriggerItems] = useState([]) - // 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) => 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): Promise => { - 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 = (method: string, params?: Record) => - gateway.request(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) => { - if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { - return - } - - event.preventDefault() - dragDepthRef.current += 1 - - if (!dragActive) { - setDragActive(true) - } - } - - const handleDragOver = (event: ReactDragEvent) => { - if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { - return - } - - event.preventDefault() - event.dataTransfer.dropEffect = 'copy' - } - - const handleDragLeave = (event: ReactDragEvent) => { - event.preventDefault() - dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) - - if (dragDepthRef.current === 0) { - setDragActive(false) - } - } - - const handleDrop = (event: ReactDragEvent) => { - 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) => { - 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) => { - 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) => { - 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) => { - 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 ( - - -
- {trigger && ( - - )} -
-
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 - /> - -