Merge remote-tracking branch 'origin/main' into bb/remove-composer-message-shadows

# Conflicts:
#	apps/desktop/src/components/assistant-ui/tool-fallback.tsx
This commit is contained in:
Brooklyn Nicholson
2026-06-06 10:47:42 -05:00
95 changed files with 9454 additions and 1437 deletions
@@ -7,6 +7,7 @@ import { type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useSt
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, HelpCircle, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
@@ -63,6 +64,8 @@ export const ClarifyTool = (props: ToolCallMessagePartProps) => {
}
function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
const { t } = useI18n()
const copy = t.assistant.clarify
const request = useStore($clarifyRequest)
const gateway = useStore($gateway)
const fromArgs = useMemo(() => readClarifyArgs(args), [args])
@@ -102,13 +105,13 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
const respond = useCallback(
async (answer: string) => {
if (!ready || !matchingRequest) {
notifyError(new Error('Clarify request is not ready yet'), 'Could not send clarify response')
notifyError(new Error(copy.notReady), copy.sendFailed)
return
}
if (!gateway) {
notifyError(new Error('Hermes gateway is not connected'), 'Could not send clarify response')
notifyError(new Error(copy.gatewayDisconnected), copy.sendFailed)
return
}
@@ -125,7 +128,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
// The matching tool.complete will land shortly after, swapping this
// panel for the ToolFallback view above.
} catch (error) {
notifyError(error, 'Could not send clarify response')
notifyError(error, copy.sendFailed)
setSubmitting(false)
}
},
@@ -172,7 +175,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
<HelpCircle className="size-3.5" />
</span>
<span className="flex-1 whitespace-pre-wrap font-medium leading-snug text-foreground">
{question || <em className="font-normal text-muted-foreground/70">Loading question</em>}
{question || <em className="font-normal text-muted-foreground/70">{copy.loadingQuestion}</em>}
</span>
</div>
@@ -209,7 +212,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
type="button"
>
<RadioDot selected={false} />
<span className="flex-1">Other (type your answer)</span>
<span className="flex-1">{copy.other}</span>
</button>
</div>
)}
@@ -221,12 +224,12 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
disabled={submitting}
onChange={event => setDraft(event.target.value)}
onKeyDown={handleTextareaKey}
placeholder="Type your answer…"
placeholder={copy.placeholder}
ref={textareaRef}
value={draft}
/>
<div className="flex items-center justify-between gap-2">
<span className="text-[0.6875rem] text-muted-foreground/85">/Ctrl + Enter to send</span>
<span className="text-[0.6875rem] text-muted-foreground/85">{copy.shortcut}</span>
<div className="flex items-center gap-1.5">
{hasChoices && (
<Button
@@ -239,7 +242,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
type="button"
variant="ghost"
>
Back
{copy.back}
</Button>
)}
<Button
@@ -249,10 +252,10 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
type="button"
variant="ghost"
>
Skip
{copy.skip}
</Button>
<Button disabled={!ready || submitting || !draft.trim()} size="sm" type="submit">
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : 'Send'}
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : copy.send}
</Button>
</div>
</div>
@@ -75,6 +75,7 @@ import {
import { Loader } from '@/components/ui/loader'
import type { HermesGateway } from '@/hermes'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon } from '@/lib/icons'
@@ -183,22 +184,26 @@ function pickPrimaryPreviewTarget(targets: string[]): string[] {
return [localUrl || targets[targets.length - 1]]
}
const CenteredThreadSpinner: FC = () => (
<div
aria-label="Loading session"
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>
)
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>
)
}
const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
const messageId = useAuiState(s => s.message.id)
@@ -278,10 +283,11 @@ const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentProp
)
const ResponseLoadingIndicator: FC = () => {
const { t } = useI18n()
const elapsed = useElapsedSeconds()
return (
<StatusRow data-slot="aui_response-loading" label="Hermes is loading a response">
<StatusRow data-slot="aui_response-loading" label={t.assistant.thread.loadingResponse}>
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
<ActivityTimerText seconds={elapsed} />
</StatusRow>
@@ -363,6 +369,7 @@ const ThinkingDisclosure: FC<{
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
@@ -419,7 +426,7 @@ const ThinkingDisclosure: FC<{
pending && 'shimmer text-foreground/55'
)}
>
Thinking
{t.assistant.thread.thinking}
</span>
{pending && (
<ActivityTimerText
@@ -537,7 +544,10 @@ function startOfDay(d: Date): number {
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
}
function formatMessageTimestamp(value: Date | string | number | undefined): string {
function formatMessageTimestamp(
value: Date | string | number | undefined,
labels: { today: (time: string) => string; yesterday: (time: string) => string }
): string {
if (!value) {
return ''
}
@@ -551,17 +561,19 @@ function formatMessageTimestamp(value: Date | string | number | undefined): stri
const dayDelta = Math.round((startOfDay(new Date()) - startOfDay(date)) / 86_400_000)
if (dayDelta === 0) {
return `Today, ${TIME_FMT.format(date)}`
return labels.today(TIME_FMT.format(date))
}
if (dayDelta === 1) {
return `Yesterday, ${TIME_FMT.format(date)}`
return labels.yesterday(TIME_FMT.format(date))
}
return SHORT_FMT.format(date)
}
const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, onBranchInNewChat }) => {
const { t } = useI18n()
const copy = t.assistant.thread
const [menuOpen, setMenuOpen] = useState(false)
return (
@@ -580,15 +592,15 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, on
)}
data-slot="aui_msg-actions"
>
<CopyButton appearance="icon" buttonSize="icon" disabled={!messageText} label="Copy" text={messageText} />
<CopyButton appearance="icon" buttonSize="icon" disabled={!messageText} label={copy.copy} text={messageText} />
<ActionBarPrimitive.Reload asChild>
<TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip="Refresh">
<TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip={copy.refresh}>
<Codicon name="refresh" />
</TooltipIconButton>
</ActionBarPrimitive.Reload>
<DropdownMenu onOpenChange={setMenuOpen} open={menuOpen}>
<DropdownMenuTrigger asChild>
<TooltipIconButton tooltip="More actions">
<TooltipIconButton tooltip={copy.moreActions}>
<Codicon name="ellipsis" />
</TooltipIconButton>
</DropdownMenuTrigger>
@@ -596,7 +608,7 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, on
<MessageTimestamp />
<DropdownMenuItem onSelect={() => onBranchInNewChat?.(messageId)}>
<GitBranchIcon />
Branch in new chat
{copy.branchNewChat}
</DropdownMenuItem>
<ReadAloudItem messageId={messageId} text={messageText} />
</DropdownMenuContent>
@@ -607,6 +619,8 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, on
}
const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, text }) => {
const { t } = useI18n()
const copy = t.assistant.thread
const voicePlayback = useStore($voicePlayback)
const readAloudStatus =
@@ -625,9 +639,9 @@ const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, tex
try {
await playSpeechText(text, { messageId, source: 'read-aloud' })
} catch (error) {
notifyError(error, 'Read aloud failed')
notifyError(error, copy.readAloudFailed)
}
}, [messageId, text])
}, [copy.readAloudFailed, messageId, text])
return (
<DropdownMenuItem
@@ -638,14 +652,15 @@ const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, tex
}}
>
<Icon className={isPreparing ? 'animate-spin' : undefined} />
{isPreparing ? 'Preparing audio...' : isSpeaking ? 'Stop reading' : 'Read aloud'}
{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)
const label = formatMessageTimestamp(createdAt, t.assistant.thread)
if (!label) {
return null
@@ -712,6 +727,8 @@ const StopGlyph = <IconPlayerStopFilled aria-hidden className="size-3.5 -transla
const UserMessage: FC<{
onCancel?: () => Promise<void> | void
}> = ({ onCancel }) => {
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)
@@ -803,10 +820,10 @@ const UserMessage: FC<{
) : (
<ActionBarPrimitive.Edit asChild>
<button
aria-label="Edit message"
aria-label={copy.editMessage}
className={bubbleClassName}
onClick={() => triggerHaptic('selection')}
title="Edit message"
title={copy.editMessage}
type="button"
>
{bubbleContent}
@@ -817,14 +834,14 @@ const UserMessage: FC<{
<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="Stop"
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="Stop"
title={copy.stop}
type="button"
>
{StopGlyph}
@@ -833,7 +850,7 @@ const UserMessage: FC<{
<span
aria-hidden="true"
className="flex size-6 items-center justify-center rounded-md text-(--ui-text-tertiary)"
title="Editable checkpoint"
title={copy.editableCheckpoint}
>
<Codicon name="discard" size="0.875rem" />
</span>
@@ -848,18 +865,18 @@ const UserMessage: FC<{
<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="Restore previous checkpoint"
title={copy.restorePrevious}
>
Restore checkpoint
{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="Restore next checkpoint"
title={copy.restoreNext}
>
Go forward
{copy.goForward}
</BranchPickerPrimitive.Next>
</BranchPickerPrimitive.Root>
</div>
@@ -930,6 +947,8 @@ interface UserEditComposerProps {
}
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)
@@ -1406,7 +1425,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
data-expanded={expanded ? 'true' : undefined}
>
<div
aria-label="Edit message"
aria-label={copy.editMessage}
autoFocus
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)] leading-(--dt-line-height) text-foreground/95 outline-none',
@@ -1415,7 +1434,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
expanded ? 'min-h-16' : 'min-h-[1.25rem]'
)}
contentEditable
data-placeholder="Edit message"
data-placeholder={copy.editMessage}
data-slot={RICH_INPUT_SLOT}
onBlur={() => window.setTimeout(closeTrigger, 80)}
onDragOver={handleDragOver}
@@ -1432,7 +1451,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
/>
<ComposerPrimitive.Input className="sr-only" tabIndex={-1} unstable_focusOnScrollToBottom={false} />
<button
aria-label="Send edited message"
aria-label={copy.sendEdited}
className={cn('absolute right-2 bottom-2 size-5', USER_ACTION_ICON_BUTTON_CLASS)}
disabled={!canSubmit || submitting}
onClick={() => {
@@ -1442,7 +1461,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
submitEdit(editor)
}
}}
title="Send edited message"
title={copy.sendEdited}
type="button"
>
{submitting ? StopGlyph : <Codicon name="arrow-up" size={USER_ACTION_ICON_SIZE} />}
@@ -13,6 +13,7 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { ChevronDown, Loader2 } from '@/lib/icons'
import { $gateway } from '@/store/gateway'
@@ -52,6 +53,8 @@ export const PendingToolApproval: FC<{ part: ToolPart }> = ({ part }) => {
const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform)
const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
const { t } = useI18n()
const copy = t.assistant.approval
const gateway = useStore($gateway)
const [submitting, setSubmitting] = useState<ApprovalChoice | null>(null)
// "Always allow" persists the pattern to ~/.hermes/config.yaml permanently, so
@@ -68,7 +71,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
}
if (!gateway) {
notifyError(new Error('Hermes gateway is not connected'), 'Could not send approval response')
notifyError(new Error(copy.gatewayDisconnected), copy.sendFailed)
return
}
@@ -83,7 +86,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
triggerHaptic(choice === 'deny' ? 'cancel' : 'submit')
clearApprovalRequest(request.sessionId)
} catch (error) {
notifyError(error, 'Could not send approval response')
notifyError(error, copy.sendFailed)
setSubmitting(null)
}
},
@@ -123,14 +126,14 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
size="xs"
variant="ghost"
>
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : 'Run'}
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run}
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
</Button>
<span aria-hidden className="w-px self-stretch bg-primary/20" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="More approval options"
aria-label={copy.moreOptions}
className="h-full w-5 rounded-none px-0 text-primary hover:bg-primary/15 hover:text-primary"
disabled={busy}
size="xs"
@@ -140,7 +143,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-44">
<DropdownMenuItem onSelect={() => void respond('session')}>Allow this session</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
// Defer one tick so the menu fully unmounts before the dialog
@@ -149,10 +152,10 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
setTimeout(() => setConfirmAlways(true), 0)
}}
>
Always allow
{copy.alwaysAllowMenu}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive">
Reject
{copy.reject}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -165,18 +168,16 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
size="xs"
variant="ghost"
>
{submitting === 'deny' ? <Loader2 className="size-3 animate-spin" /> : 'Reject'}
{submitting === 'deny' ? <Loader2 className="size-3 animate-spin" /> : copy.reject}
{submitting !== 'deny' && <span className="text-[0.625rem] opacity-55">Esc</span>}
</Button>
<Dialog onOpenChange={setConfirmAlways} open={confirmAlways}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Always allow this command?</DialogTitle>
<DialogTitle>{copy.alwaysTitle}</DialogTitle>
<DialogDescription>
This adds the {request.description} pattern to your permanent allowlist (
<code className="font-mono text-xs">~/.hermes/config.yaml</code>). Hermes wont ask again for commands
like this in this session or any future one.
{copy.alwaysDescription(request.description)}
</DialogDescription>
</DialogHeader>
@@ -188,7 +189,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
<DialogFooter>
<Button onClick={() => setConfirmAlways(false)} size="sm" variant="ghost">
Cancel
{t.common.cancel}
</Button>
<Button
onClick={() => {
@@ -198,7 +199,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
size="sm"
variant="destructive"
>
Always allow
{copy.alwaysAllow}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,5 +1,6 @@
import { normalizeExternalUrl } from '@/lib/external-link'
import { extractToolErrorMessage, formatToolResultSummary } from '@/lib/tool-result-summary'
import { translateNow } from '@/i18n'
export type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web'
export type ToolStatus = 'error' | 'running' | 'success' | 'warning'
@@ -1095,6 +1096,17 @@ function toolDetailText(
}
export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string; text: string } {
const copy = {
command: translateNow('assistant.tool.copyCommand'),
content: translateNow('assistant.tool.copyContent'),
file: translateNow('assistant.tool.copyFile'),
output: translateNow('assistant.tool.copyOutput'),
path: translateNow('assistant.tool.copyPath'),
query: translateNow('assistant.tool.copyQuery'),
results: translateNow('assistant.tool.copyResults'),
url: translateNow('assistant.tool.copyUrl'),
generic: translateNow('common.copy')
}
const args = parseMaybeObject(part.args)
const result = parseMaybeObject(part.result)
const detail = view.detail.trim()
@@ -1102,25 +1114,25 @@ export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string
if (part.toolName === 'terminal' || part.toolName === 'execute_code') {
if (hasSubstantialOutput) {
return { label: 'Copy output', text: detail }
return { label: copy.output, text: detail }
}
const command = firstStringField(args, ['command', 'code']) || contextValue(args)
if (command) {
return { label: 'Copy command', text: command }
return { label: copy.command, text: command }
}
}
if (part.toolName === 'web_extract') {
if (hasSubstantialOutput) {
return { label: 'Copy content', text: detail }
return { label: copy.content, text: detail }
}
const url = firstStringField(args, ['url', 'target']) || findFirstUrl(args, result)
if (url) {
return { label: 'Copy URL', text: url }
return { label: copy.url, text: url }
}
}
@@ -1128,7 +1140,7 @@ export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string
const url = firstStringField(args, ['url', 'target']) || findFirstUrl(args, result)
if (url) {
return { label: 'Copy URL', text: url }
return { label: copy.url, text: url }
}
}
@@ -1136,25 +1148,25 @@ export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string
if (view.searchHits?.length) {
const text = view.searchHits.map(hit => [hit.title, hit.url, hit.snippet].filter(Boolean).join('\n')).join('\n\n')
return { label: 'Copy results', text }
return { label: copy.results, text }
}
const query = firstStringField(args, ['search_term', 'query']) || contextValue(args)
if (query) {
return { label: 'Copy query', text: query }
return { label: copy.query, text: query }
}
}
if (part.toolName === 'read_file') {
if (hasSubstantialOutput) {
return { label: 'Copy file', text: detail }
return { label: copy.file, text: detail }
}
const path = firstStringField(args, ['path', 'file', 'filepath'])
if (path) {
return { label: 'Copy path', text: path }
return { label: copy.path, text: path }
}
}
@@ -1162,15 +1174,15 @@ export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string
const path = firstStringField(args, ['path', 'file', 'filepath'])
if (path) {
return { label: 'Copy path', text: path }
return { label: copy.path, text: path }
}
}
if (detail) {
return { label: 'Copy output', text: detail }
return { label: copy.output, text: detail }
}
return { label: 'Copy', text: view.title }
return { label: copy.generic, text: view.title }
}
function dynamicTitle(
@@ -16,6 +16,7 @@ import { BrailleSpinner } from '@/components/ui/braille-spinner'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import { FadeText } from '@/components/ui/fade-text'
import { useI18n } from '@/i18n'
import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
import { useEnterAnimation } from '@/lib/use-enter-animation'
@@ -70,6 +71,13 @@ const TOOL_SECTION_SURFACE_CLASS =
const TOOL_SECTION_PRE_CLASS = cn(TOOL_SECTION_SURFACE_CLASS, 'font-mono text-[0.7rem] leading-relaxed')
interface ToolStatusCopy {
statusDone: string
statusError: string
statusRecovered: string
statusRunning: string
}
function rawTechnicalTrace(args: unknown, result: unknown): string {
const parts = [args, result]
.filter(value => value !== undefined && value !== null)
@@ -89,11 +97,11 @@ function rawTechnicalTrace(args: unknown, result: unknown): string {
return parts.join('\n')
}
function statusGlyph(status: ToolStatus): ReactNode {
function statusGlyph(status: ToolStatus, copy: ToolStatusCopy): ReactNode {
if (status === 'running') {
return (
<BrailleSpinner
ariaLabel="Running"
ariaLabel={copy.statusRunning}
className="size-3.5 shrink-0 text-[0.95rem] text-(--ui-text-tertiary)"
spinner="breathe"
/>
@@ -101,22 +109,32 @@ function statusGlyph(status: ToolStatus): ReactNode {
}
if (status === 'error') {
return <AlertCircle aria-label="Error" className="size-3.5 shrink-0 text-destructive" />
return <AlertCircle aria-label={copy.statusError} className="size-3.5 shrink-0 text-destructive" />
}
if (status === 'warning') {
return <AlertCircle aria-label="Recovered" className="size-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
return (
<AlertCircle
aria-label={copy.statusRecovered}
className="size-3.5 shrink-0 text-amber-600 dark:text-amber-400"
/>
)
}
return <CheckCircle2 aria-label="Done" className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85" />
return (
<CheckCircle2
aria-label={copy.statusDone}
className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85"
/>
)
}
// Leading glyph for any tool-row header. Status (running/error/warning)
// takes precedence; otherwise falls back to the tool's codicon. Returns
// null when neither applies so callers can render unconditionally.
function ToolGlyph({ icon, status }: { icon?: string; status?: ToolStatus }) {
function ToolGlyph({ copy, icon, status }: { copy: ToolStatusCopy; icon?: string; status?: ToolStatus }) {
const node = status ? (
statusGlyph(status)
statusGlyph(status, copy)
) : icon ? (
<Codicon className="text-(--ui-text-tertiary)" name={icon} size="0.875rem" />
) : null
@@ -176,6 +194,8 @@ function useDisclosureOpen(disclosureId: string, fallbackOpen = false): boolean
}
function ToolEntry({ part }: ToolEntryProps) {
const { t } = useI18n()
const copy = t.assistant.tool
const messageId = useAuiState(s => s.message.id)
const messageRunning = useAuiState(selectMessageRunning)
const embedded = useContext(ToolEmbedContext)
@@ -282,7 +302,7 @@ function ToolEntry({ part }: ToolEntryProps) {
trailing={trailing}
>
<span className="flex min-w-0 items-center gap-1.5">
<ToolGlyph icon={view.icon} status={leadingStatus(isPending, view.status)} />
<ToolGlyph copy={copy} icon={view.icon} status={leadingStatus(isPending, view.status)} />
<FadeText
className={cn(
TOOL_HEADER_TITLE_CLASS,
@@ -308,7 +328,7 @@ function ToolEntry({ part }: ToolEntryProps) {
)}
{view.imageUrl && (
<div className="max-w-72 overflow-hidden rounded-[0.25rem] border border-(--ui-stroke-tertiary)">
<ZoomableImage alt="Tool output" className="h-auto w-full object-cover" src={view.imageUrl} />
<ZoomableImage alt={copy.outputAlt} className="h-auto w-full object-cover" src={view.imageUrl} />
</div>
)}
{hasSearchHits && view.searchHits && (
@@ -379,7 +399,7 @@ function ToolEntry({ part }: ToolEntryProps) {
))}
{showRawSearchDrilldown && (
<details className="max-w-full">
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'mb-0')}>Raw response</summary>
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'mb-0')}>{copy.rawResponse}</summary>
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'mt-1 whitespace-pre-wrap wrap-anywhere')}>
{view.rawResult}
</pre>