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:
@@ -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 won’t 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>
|
||||
|
||||
Reference in New Issue
Block a user