mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-14 06:09:11 +08:00
Compare commits
1 Commits
ethie/node
...
salvage/40
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9750888ba1 |
@@ -17,24 +17,15 @@ import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-te
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { chatMessageText } from '@/lib/chat-messages'
|
||||
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
|
||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $composerAttachments, clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
|
||||
import {
|
||||
browseBackward,
|
||||
browseForward,
|
||||
deriveUserHistory,
|
||||
isBrowsingHistory,
|
||||
resetBrowseState
|
||||
} from '@/store/composer-input-history'
|
||||
import {
|
||||
$queuedPromptsBySession,
|
||||
enqueueQueuedPrompt,
|
||||
promoteQueuedPrompt,
|
||||
type QueuedPromptEntry,
|
||||
removeQueuedPrompt,
|
||||
shouldAutoDrainOnSettle,
|
||||
@@ -54,7 +45,6 @@ import {
|
||||
focusComposerInput,
|
||||
markActiveComposer,
|
||||
onComposerFocusRequest,
|
||||
onComposerInsertRefsRequest,
|
||||
onComposerInsertRequest
|
||||
} from './focus'
|
||||
import { HelpHint } from './help-hint'
|
||||
@@ -62,12 +52,7 @@ import { useAtCompletions } from './hooks/use-at-completions'
|
||||
import { useSlashCompletions } from './hooks/use-slash-completions'
|
||||
import { useVoiceConversation } from './hooks/use-voice-conversation'
|
||||
import { useVoiceRecorder } from './hooks/use-voice-recorder'
|
||||
import {
|
||||
dragHasAttachments,
|
||||
droppedFileInlineRef,
|
||||
type InlineRefInput,
|
||||
insertInlineRefsIntoEditor
|
||||
} from './inline-refs'
|
||||
import { dragHasAttachments, droppedFileInlineRef, insertInlineRefsIntoEditor } from './inline-refs'
|
||||
import { QueuePanel } from './queue-panel'
|
||||
import {
|
||||
composerPlainText,
|
||||
@@ -93,6 +78,29 @@ const COMPOSER_SINGLE_LINE_MAX_PX = 36
|
||||
const COMPOSER_FADE_BACKGROUND =
|
||||
'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))'
|
||||
|
||||
// Resting composer placeholders. New sessions get open-ended starters; an
|
||||
// existing chat gets phrasings that read as a continuation of the thread.
|
||||
// One is picked at random per session (stable until the session changes).
|
||||
const NEW_SESSION_PLACEHOLDERS = [
|
||||
'What are we building?',
|
||||
'Give Hermes a task',
|
||||
"What's on your mind?",
|
||||
'Describe what you need',
|
||||
'What should we tackle?',
|
||||
'Ask anything',
|
||||
'Start with a goal'
|
||||
]
|
||||
|
||||
const FOLLOW_UP_PLACEHOLDERS = [
|
||||
'Send a follow-up',
|
||||
'Add more context',
|
||||
'Refine the request',
|
||||
"What's next?",
|
||||
'Keep it going',
|
||||
'Push it further',
|
||||
'Adjust or continue'
|
||||
]
|
||||
|
||||
const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)]
|
||||
|
||||
interface QueueEditState {
|
||||
@@ -123,7 +131,6 @@ export function ChatBar({
|
||||
onPickFolders,
|
||||
onPickImages,
|
||||
onRemoveAttachment,
|
||||
onSteer,
|
||||
onSubmit,
|
||||
onTranscribeAudio
|
||||
}: ChatBarProps) {
|
||||
@@ -132,7 +139,6 @@ export function ChatBar({
|
||||
const attachments = useStore($composerAttachments)
|
||||
const queuedPromptsBySession = useStore($queuedPromptsBySession)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
const sessionMessages = useStore($messages)
|
||||
const activeQueueSessionKey = queueSessionKey || sessionId || null
|
||||
|
||||
const queuedPrompts = useMemo(
|
||||
@@ -146,6 +152,12 @@ export function ChatBar({
|
||||
const draftRef = useRef(draft)
|
||||
const previousBusyRef = useRef(busy)
|
||||
const drainingQueueRef = useRef(false)
|
||||
// Set when the user explicitly interrupts the running turn via the Stop
|
||||
// button (busy + empty composer). It suppresses the next busy→false
|
||||
// auto-drain so an explicit Stop actually halts instead of immediately
|
||||
// firing the head of the queue. The queue is preserved; the user resumes
|
||||
// it deliberately via Cmd/Ctrl+K, Enter, or the per-row "send now" arrow.
|
||||
const userInterruptedRef = useRef(false)
|
||||
const urlInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const [urlOpen, setUrlOpen] = useState(false)
|
||||
@@ -166,21 +178,13 @@ export function ChatBar({
|
||||
const slash = useSlashCompletions({ gateway: gateway ?? null })
|
||||
|
||||
const stacked = expanded || narrow || tight
|
||||
const trimmedDraft = draft.trim()
|
||||
const hasComposerPayload = trimmedDraft.length > 0 || attachments.length > 0
|
||||
const hasComposerPayload = draft.trim().length > 0 || attachments.length > 0
|
||||
const canSubmit = busy || hasComposerPayload
|
||||
const editingQueuedPrompt = queueEdit ? (queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null) : null
|
||||
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
|
||||
// Steer only makes sense mid-turn, text-only (the gateway can't carry images
|
||||
// into a tool result) and never for a slash command (those execute inline).
|
||||
const canSteer =
|
||||
busy && !!onSteer && attachments.length === 0 && trimmedDraft.length > 0 && !SLASH_COMMAND_RE.test(trimmedDraft)
|
||||
const showHelpHint = draft === '?'
|
||||
|
||||
const { t } = useI18n()
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const newSessionPlaceholders = t.composer.newSessionPlaceholders
|
||||
const followUpPlaceholders = t.composer.followUpPlaceholders
|
||||
|
||||
// Resting placeholder: a starter for brand-new sessions, a continuation for
|
||||
// existing ones. Picked once and only re-rolled when we genuinely move to a
|
||||
@@ -188,7 +192,7 @@ export function ChatBar({
|
||||
// started session (null → id, on the first send) is treated as the same
|
||||
// conversation so the placeholder doesn't visibly flip mid-stream.
|
||||
const [restingPlaceholder, setRestingPlaceholder] = useState(() =>
|
||||
pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders)
|
||||
pickPlaceholder(sessionId ? FOLLOW_UP_PLACEHOLDERS : NEW_SESSION_PLACEHOLDERS)
|
||||
)
|
||||
|
||||
const prevSessionIdRef = useRef(sessionId)
|
||||
@@ -207,17 +211,16 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
resetBrowseState(prev)
|
||||
setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders))
|
||||
}, [followUpPlaceholders, newSessionPlaceholders, sessionId])
|
||||
setRestingPlaceholder(pickPlaceholder(sessionId ? FOLLOW_UP_PLACEHOLDERS : NEW_SESSION_PLACEHOLDERS))
|
||||
}, [sessionId])
|
||||
|
||||
// When the bar is disabled it's because the gateway isn't open. Distinguish a
|
||||
// cold start ("Starting Hermes...") from a dropped connection we're trying to
|
||||
// restore (e.g. after the Mac slept) so the stuck state reads as recoverable.
|
||||
const placeholder = disabled
|
||||
? gatewayState === 'closed' || gatewayState === 'error'
|
||||
? t.composer.placeholderReconnecting
|
||||
: t.composer.placeholderStarting
|
||||
? 'Reconnecting to Hermes…'
|
||||
: 'Starting Hermes...'
|
||||
: restingPlaceholder
|
||||
|
||||
const focusInput = useCallback(() => {
|
||||
@@ -429,7 +432,7 @@ export function ChatBar({
|
||||
requestMainFocus()
|
||||
}
|
||||
|
||||
const insertInlineRefs = (refs: InlineRefInput[]) => {
|
||||
const insertInlineRefs = (refs: string[]) => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor) {
|
||||
@@ -449,19 +452,6 @@ export function ChatBar({
|
||||
return true
|
||||
}
|
||||
|
||||
// Latest-closure ref so the (once-only) subscription always calls the current
|
||||
// insertInlineRefs without re-subscribing every render.
|
||||
const insertInlineRefsRef = useRef(insertInlineRefs)
|
||||
insertInlineRefsRef.current = insertInlineRefs
|
||||
|
||||
useEffect(() => {
|
||||
return onComposerInsertRefsRequest(({ refs, target }) => {
|
||||
if (target === 'main') {
|
||||
insertInlineRefsRef.current(refs)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const selectSkinSlashCommand = (command: string) => {
|
||||
draftRef.current = command
|
||||
aui.composer().setText(command)
|
||||
@@ -559,10 +549,16 @@ export function ChatBar({
|
||||
}
|
||||
}, [trigger])
|
||||
|
||||
// Pull the live contentEditable text into draftRef + the AUI composer state
|
||||
// (which drives `hasComposerPayload` → the send button). Shared by the input
|
||||
// and compositionend paths so committed IME text reaches state through either.
|
||||
const flushEditorToDraft = (editor: HTMLDivElement) => {
|
||||
const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {
|
||||
// During IME composition the DOM contains uncommitted preedit text
|
||||
// mixed with real content. Skip state writes — compositionend will
|
||||
// deliver the finalized text via a clean input event.
|
||||
if (composingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const editor = event.currentTarget
|
||||
|
||||
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
|
||||
editor.replaceChildren()
|
||||
}
|
||||
@@ -577,17 +573,6 @@ export function ChatBar({
|
||||
window.setTimeout(refreshTrigger, 0)
|
||||
}
|
||||
|
||||
const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {
|
||||
// During IME composition the DOM contains uncommitted preedit text
|
||||
// mixed with real content. Skip state writes — compositionend flushes
|
||||
// the finalized text (see onCompositionEnd).
|
||||
if (composingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
}
|
||||
|
||||
const triggerAdapter: Unstable_TriggerAdapter | null =
|
||||
trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
|
||||
|
||||
@@ -730,87 +715,6 @@ export function ChatBar({
|
||||
}
|
||||
}
|
||||
|
||||
// ArrowUp/ArrowDown navigate, in priority order: the queue (edit entries in
|
||||
// place) then sent-message history. The history ring is derived from live
|
||||
// session messages each press — single source of truth, no mirror.
|
||||
if (event.key === 'ArrowUp') {
|
||||
const currentDraft = draftRef.current
|
||||
|
||||
// Editing a queued turn → walk to the older entry.
|
||||
if (queueEdit && stepQueuedEdit(-1)) {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Empty composer + a queued turn → open the newest queued entry for edit
|
||||
// (the row's pencil), not a text recall. Enter saves it back to the queue.
|
||||
if (!currentDraft.trim() && !queueEdit && queuedPrompts.length > 0) {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
beginQueuedEdit(queuedPrompts[queuedPrompts.length - 1]!)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Don't hijack a typed draft unless already browsing — they'd lose it.
|
||||
if (currentDraft.trim() && !isBrowsingHistory(sessionId)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
|
||||
const history = deriveUserHistory(sessionMessages, chatMessageText)
|
||||
const entry = browseBackward(sessionId, currentDraft, history)
|
||||
|
||||
if (entry !== null) {
|
||||
loadIntoComposer(entry, $composerAttachments.get())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
// Editing a queued turn → walk to the newer entry (past the newest exits).
|
||||
if (queueEdit) {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
stepQueuedEdit(1)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Browsing sent history → step toward the present, restoring the draft.
|
||||
if (isBrowsingHistory(sessionId)) {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
|
||||
const history = deriveUserHistory(sessionMessages, chatMessageText)
|
||||
const result = browseForward(sessionId, history)
|
||||
|
||||
if (result !== null) {
|
||||
loadIntoComposer(result.text, $composerAttachments.get())
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+Enter is reserved for steering the live run — never a send.
|
||||
// Steer when there's a steerable draft, otherwise swallow it so it can't
|
||||
// surprise-send. (Plain Enter still queues while busy / sends when idle.)
|
||||
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey) && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
|
||||
if (canSteer) {
|
||||
steerDraft()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -820,32 +724,7 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
// Empty Enter while busy is a no-op — interrupting is explicit (Stop/Esc),
|
||||
// never a stray Enter after sending. With a payload, submitDraft queues it.
|
||||
if (busy && !hasComposerPayload) {
|
||||
return
|
||||
}
|
||||
|
||||
submitDraft()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
// Editing a queued turn → Esc cancels the edit, restoring the prior draft.
|
||||
if (queueEdit) {
|
||||
event.preventDefault()
|
||||
exitQueuedEdit('cancel')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise Esc interrupts the running turn (Stop-button parity).
|
||||
if (busy) {
|
||||
event.preventDefault()
|
||||
triggerHaptic('cancel')
|
||||
void Promise.resolve(onCancel())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1011,42 +890,6 @@ export function ChatBar({
|
||||
focusInput()
|
||||
}
|
||||
|
||||
// Walk queued entries while editing (ArrowUp = older, ArrowDown = newer),
|
||||
// saving the in-progress edit on each step. Stepping newer past the last
|
||||
// entry exits edit mode and restores the pre-edit draft.
|
||||
const stepQueuedEdit = (direction: -1 | 1) => {
|
||||
if (!queueEdit) {
|
||||
return false
|
||||
}
|
||||
|
||||
const index = queuedPrompts.findIndex(e => e.id === queueEdit.entryId)
|
||||
const target = index + direction
|
||||
|
||||
if (index < 0 || target < 0) {
|
||||
return index >= 0 // at the oldest: swallow; missing entry: let it fall through
|
||||
}
|
||||
|
||||
const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, {
|
||||
attachments: cloneAttachments($composerAttachments.get()),
|
||||
text: draftRef.current
|
||||
})
|
||||
|
||||
const next = queuedPrompts[target]
|
||||
|
||||
if (next) {
|
||||
setQueueEdit({ ...queueEdit, entryId: next.id })
|
||||
loadIntoComposer(next.text, next.attachments)
|
||||
} else {
|
||||
setQueueEdit(null)
|
||||
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
|
||||
}
|
||||
|
||||
triggerHaptic(saved ? 'success' : 'selection')
|
||||
focusInput()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const exitQueuedEdit = (action: 'cancel' | 'save'): boolean => {
|
||||
if (!queueEdit) {
|
||||
return false
|
||||
@@ -1089,26 +932,6 @@ export function ChatBar({
|
||||
return true
|
||||
}, [activeQueueSessionKey, attachments, clearDraft, draft])
|
||||
|
||||
// Steer the live turn (nudge without interrupting). Clears the draft up front
|
||||
// for snappy feedback; if the gateway rejects (no live tool window) the words
|
||||
// are re-queued so nothing is lost — same safety net as a plain queue.
|
||||
const steerDraft = useCallback(() => {
|
||||
if (!onSteer || !canSteer) {
|
||||
return
|
||||
}
|
||||
|
||||
const text = draftRef.current.trim()
|
||||
|
||||
triggerHaptic('submit')
|
||||
clearDraft()
|
||||
|
||||
void Promise.resolve(onSteer(text)).then(accepted => {
|
||||
if (!accepted && activeQueueSessionKey) {
|
||||
enqueueQueuedPrompt(activeQueueSessionKey, { text, attachments: [] })
|
||||
}
|
||||
})
|
||||
}, [activeQueueSessionKey, canSteer, clearDraft, onSteer])
|
||||
|
||||
// All queue drain paths share one lock + send-then-remove sequence.
|
||||
// `pickEntry` lets each caller choose head, by-id, or skip-edited.
|
||||
const runDrain = useCallback(
|
||||
@@ -1135,14 +958,13 @@ export function ChatBar({
|
||||
}
|
||||
|
||||
removeQueuedPrompt(activeQueueSessionKey, entry.id)
|
||||
resetBrowseState(sessionId)
|
||||
|
||||
return true
|
||||
} finally {
|
||||
drainingQueueRef.current = false
|
||||
}
|
||||
},
|
||||
[activeQueueSessionKey, onSubmit, queuedPrompts, sessionId]
|
||||
[activeQueueSessionKey, onSubmit, queuedPrompts]
|
||||
)
|
||||
|
||||
const drainNextQueued = useCallback(
|
||||
@@ -1156,40 +978,41 @@ export function ChatBar({
|
||||
)
|
||||
|
||||
const sendQueuedNow = useCallback(
|
||||
(id: string) => {
|
||||
if (!activeQueueSessionKey || id === queueEdit?.entryId) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (busy) {
|
||||
// Promote to the head, then interrupt. The gateway always emits a
|
||||
// settle (message.complete + session.info running:false) when the
|
||||
// turn unwinds, and the busy→false auto-drain below sends this entry.
|
||||
promoteQueuedPrompt(activeQueueSessionKey, id)
|
||||
triggerHaptic('selection')
|
||||
void Promise.resolve(onCancel())
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return runDrain(entries => entries.find(e => e.id === id))
|
||||
},
|
||||
[activeQueueSessionKey, busy, onCancel, queueEdit, runDrain]
|
||||
(id: string) => runDrain(entries => entries.find(e => e.id === id && id !== queueEdit?.entryId)),
|
||||
[queueEdit, runDrain]
|
||||
)
|
||||
|
||||
// Auto-drain on busy → false (turn settled). Queued turns always flow once
|
||||
// the session is idle again — whether the turn finished naturally or the
|
||||
// user interrupted it. Interrupting to reach a queued message is the whole
|
||||
// point of the queue, so we never suppress the drain. To cancel queued
|
||||
// turns, the user deletes them from the panel.
|
||||
// Auto-drain on busy → false (turn settled). An explicit user interrupt
|
||||
// (Stop button) sets userInterruptedRef so we skip exactly one auto-drain:
|
||||
// the user asked to halt, so we must not immediately re-send the queue.
|
||||
// The queued turns stay intact and the user resumes them on demand.
|
||||
useEffect(() => {
|
||||
const wasBusy = previousBusyRef.current
|
||||
previousBusyRef.current = busy
|
||||
|
||||
// Clear the interrupt latch when a new turn starts (false → true). This
|
||||
// guards the sub-frame race where a Stop click lands after busy already
|
||||
// flipped false (button not yet unmounted): the stale latch can no longer
|
||||
// survive into the next turn and wrongly suppress its natural auto-drain.
|
||||
if (busy && !wasBusy) {
|
||||
userInterruptedRef.current = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const interrupted = userInterruptedRef.current
|
||||
|
||||
// Consume the interrupt latch on any settle so a later natural completion
|
||||
// is not wrongly suppressed.
|
||||
if (!busy && wasBusy && interrupted) {
|
||||
userInterruptedRef.current = false
|
||||
}
|
||||
|
||||
if (
|
||||
shouldAutoDrainOnSettle({
|
||||
isBusy: busy,
|
||||
queueLength: queuedPrompts.length,
|
||||
userInterrupted: interrupted,
|
||||
wasBusy
|
||||
})
|
||||
) {
|
||||
@@ -1230,8 +1053,12 @@ export function ChatBar({
|
||||
} else if (hasComposerPayload) {
|
||||
queueCurrentDraft()
|
||||
} else {
|
||||
// Stop button (the only way to reach here while busy with an empty
|
||||
// composer — empty Enter is short-circuited in the keydown handler).
|
||||
// Stop button: an explicit interrupt must actually halt the running
|
||||
// turn. Mark the interrupt so the busy→false auto-drain effect skips
|
||||
// re-sending the queue — otherwise a queued follow-up would fire the
|
||||
// instant we cancel and Stop would appear to "never work". Queued
|
||||
// turns are preserved; the user sends them on demand.
|
||||
userInterruptedRef.current = true
|
||||
triggerHaptic('cancel')
|
||||
void Promise.resolve(onCancel())
|
||||
}
|
||||
@@ -1240,7 +1067,6 @@ export function ChatBar({
|
||||
} else if (draft.trim() || attachments.length > 0) {
|
||||
const submitted = draft
|
||||
triggerHaptic('submit')
|
||||
resetBrowseState(sessionId)
|
||||
clearDraft()
|
||||
clearComposerAttachments()
|
||||
void onSubmit(submitted, { attachments })
|
||||
@@ -1310,7 +1136,6 @@ export function ChatBar({
|
||||
}
|
||||
|
||||
triggerHaptic('submit')
|
||||
resetBrowseState(sessionId)
|
||||
clearDraft()
|
||||
await onSubmit(text)
|
||||
}
|
||||
@@ -1344,7 +1169,6 @@ export function ChatBar({
|
||||
<ComposerControls
|
||||
busy={busy}
|
||||
busyAction={busyAction}
|
||||
canSteer={canSteer}
|
||||
canSubmit={canSubmit}
|
||||
conversation={{
|
||||
active: voiceConversationActive,
|
||||
@@ -1362,7 +1186,6 @@ export function ChatBar({
|
||||
disabled={disabled}
|
||||
hasComposerPayload={hasComposerPayload}
|
||||
onDictate={dictate}
|
||||
onSteer={steerDraft}
|
||||
state={state}
|
||||
voiceStatus={voiceStatus}
|
||||
/>
|
||||
@@ -1371,7 +1194,7 @@ export function ChatBar({
|
||||
const input = (
|
||||
<div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}>
|
||||
<div
|
||||
aria-label={t.composer.message}
|
||||
aria-label="Message"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
className={cn(
|
||||
@@ -1385,17 +1208,8 @@ export function ChatBar({
|
||||
data-placeholder={placeholder}
|
||||
data-slot={RICH_INPUT_SLOT}
|
||||
onBlur={() => window.setTimeout(closeTrigger, 80)}
|
||||
onCompositionEnd={event => {
|
||||
onCompositionEnd={() => {
|
||||
composingRef.current = false
|
||||
|
||||
// The input events fired *during* composition were skipped (they
|
||||
// carried uncommitted preedit text), and Chromium does NOT reliably
|
||||
// emit a trailing input event after compositionend on Windows IMEs.
|
||||
// Without flushing here, committed multi-character IME input (e.g.
|
||||
// Chinese "你好", Japanese, Korean) never reaches composer state, so
|
||||
// `hasComposerPayload` stays false and the send button stays hidden
|
||||
// until an unrelated edit forces a sync (#39614).
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
}}
|
||||
onCompositionStart={() => {
|
||||
composingRef.current = true
|
||||
@@ -1470,11 +1284,7 @@ export function ChatBar({
|
||||
)}
|
||||
<SkinSlashPopover draft={draft} onSelect={selectSkinSlashCommand} />
|
||||
{activeQueueSessionKey && queuedPrompts.length > 0 && (
|
||||
// Out of flow so the queue never inflates the composer's measured
|
||||
// height (that drives thread bottom padding → chat resizes on
|
||||
// queue). Overlaps -mb-2 onto the surface's top border for a shared
|
||||
// edge; capped + scrollable. Overlays the chat instead of pushing it.
|
||||
<div className="absolute inset-x-0 bottom-full z-6 -mb-2 max-h-[40vh] overflow-y-auto">
|
||||
<div className="relative z-6 mb-1 px-0.5">
|
||||
<QueuePanel
|
||||
busy={busy}
|
||||
editingId={queueEdit?.entryId ?? null}
|
||||
@@ -1496,11 +1306,10 @@ export function ChatBar({
|
||||
<div className="relative w-full rounded-[inherit]">
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] shadow-composer transition-[border-color,box-shadow] duration-200 ease-out',
|
||||
'relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] transition-[border-color] duration-200 ease-out',
|
||||
COMPOSER_DROP_FADE_CLASS,
|
||||
'group-focus-within/composer:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)] group-focus-within/composer:shadow-composer-focus',
|
||||
'group-focus-within/composer:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)]',
|
||||
'group-has-data-[state=open]/composer:border-t-transparent',
|
||||
'group-has-data-[state=open]/composer:shadow-[0_0.0625rem_0_0.0625rem_color-mix(in_srgb,var(--dt-composer-ring)_calc(35%*var(--composer-ring-strength)),transparent),0_0.5rem_1.5rem_color-mix(in_srgb,var(--shadow-ink)_6%,transparent)]',
|
||||
dragActive && COMPOSER_DROP_ACTIVE_CLASS
|
||||
)}
|
||||
data-slot="composer-surface"
|
||||
@@ -1532,7 +1341,7 @@ export function ChatBar({
|
||||
{queueEdit && editingQueuedPrompt && (
|
||||
<div className="flex items-center justify-between gap-2 rounded-lg border border-[color-mix(in_srgb,var(--dt-composer-ring)_32%,transparent)] bg-accent/18 px-2 py-1">
|
||||
<div className="min-w-0 text-[0.7rem] text-muted-foreground/88">
|
||||
{t.composer.editingQueuedInComposer}
|
||||
Editing queued turn in composer
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
@@ -1541,14 +1350,14 @@ export function ChatBar({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{t.common.cancel}
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="h-6 rounded-md px-2 text-[0.68rem]"
|
||||
onClick={() => exitQueuedEdit('save')}
|
||||
type="button"
|
||||
>
|
||||
{t.common.save}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1593,7 +1402,7 @@ export function ChatBarFallback() {
|
||||
)}
|
||||
data-slot="composer-root"
|
||||
>
|
||||
<div className="composer-fallback-surface relative isolate h-(--composer-fallback-height) w-full rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] shadow-composer">
|
||||
<div className="composer-fallback-surface relative isolate h-(--composer-fallback-height) w-full rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))]">
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
|
||||
@@ -7,7 +7,6 @@ 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'
|
||||
@@ -64,8 +63,6 @@ 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])
|
||||
@@ -105,13 +102,13 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
const respond = useCallback(
|
||||
async (answer: string) => {
|
||||
if (!ready || !matchingRequest) {
|
||||
notifyError(new Error(copy.notReady), copy.sendFailed)
|
||||
notifyError(new Error('Clarify request is not ready yet'), 'Could not send clarify response')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!gateway) {
|
||||
notifyError(new Error(copy.gatewayDisconnected), copy.sendFailed)
|
||||
notifyError(new Error('Hermes gateway is not connected'), 'Could not send clarify response')
|
||||
|
||||
return
|
||||
}
|
||||
@@ -128,7 +125,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, copy.sendFailed)
|
||||
notifyError(error, 'Could not send clarify response')
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
@@ -167,15 +164,15 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
data-slot="clarify-inline"
|
||||
>
|
||||
<span aria-hidden className="arc-border" />
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="grid size-6 shrink-0 place-items-center rounded-md bg-[color-mix(in_srgb,var(--dt-primary)_14%,transparent)] text-primary ring-1 ring-inset ring-primary/15"
|
||||
className="mt-px grid size-6 shrink-0 place-items-center rounded-md bg-[color-mix(in_srgb,var(--dt-primary)_14%,transparent)] text-primary ring-1 ring-inset ring-primary/15"
|
||||
>
|
||||
<HelpCircle className="size-3.5" />
|
||||
</span>
|
||||
<span className="flex-1 whitespace-pre-wrap font-medium leading-snug text-foreground">
|
||||
{question || <em className="font-normal text-muted-foreground/70">{copy.loadingQuestion}</em>}
|
||||
{question || <em className="font-normal text-muted-foreground/70">Loading question…</em>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -212,7 +209,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
type="button"
|
||||
>
|
||||
<RadioDot selected={false} />
|
||||
<span className="flex-1">{copy.other}</span>
|
||||
<span className="flex-1">Other (type your answer)</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -224,12 +221,12 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
disabled={submitting}
|
||||
onChange={event => setDraft(event.target.value)}
|
||||
onKeyDown={handleTextareaKey}
|
||||
placeholder={copy.placeholder}
|
||||
placeholder="Type your answer…"
|
||||
ref={textareaRef}
|
||||
value={draft}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[0.6875rem] text-muted-foreground/85">{copy.shortcut}</span>
|
||||
<span className="text-[0.6875rem] text-muted-foreground/85">⌘/Ctrl + Enter to send</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasChoices && (
|
||||
<Button
|
||||
@@ -242,7 +239,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{copy.back}
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@@ -252,10 +249,10 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{copy.skip}
|
||||
Skip
|
||||
</Button>
|
||||
<Button disabled={!ready || submitting || !draft.trim()} size="sm" type="submit">
|
||||
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : copy.send}
|
||||
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : 'Send'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,7 +75,6 @@ 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'
|
||||
@@ -118,6 +117,10 @@ function messageContentText(content: unknown): string {
|
||||
return Array.isArray(content) ? content.map(partText).join('').trim() : ''
|
||||
}
|
||||
|
||||
const INTERRUPTED_ONLY_RE = /^_?\[interrupted\]_?$/i
|
||||
|
||||
const isInterruptedOnlyMessage = (text: string) => INTERRUPTED_ONLY_RE.test(text.trim())
|
||||
|
||||
export const Thread: FC<{
|
||||
clampToComposer?: boolean
|
||||
cwd?: string | null
|
||||
@@ -184,26 +187,22 @@ function pickPrimaryPreviewTarget(targets: string[]): string[] {
|
||||
return [localUrl || targets[targets.length - 1]]
|
||||
}
|
||||
|
||||
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 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 AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
|
||||
const messageId = useAuiState(s => s.message.id)
|
||||
@@ -221,6 +220,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
|
||||
const messageStatus = useAuiState(s => s.message.status?.type)
|
||||
const isPlaceholder = messageStatus === 'running' && content.length === 0
|
||||
const interruptedOnly = useMemo(() => isInterruptedOnlyMessage(messageText), [messageText])
|
||||
const enterRef = useEnterAnimation(messageStatus === 'running', `assistant-message:${messageId}`)
|
||||
|
||||
if (isPlaceholder) {
|
||||
@@ -236,7 +236,10 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
ref={enterRef}
|
||||
>
|
||||
<div
|
||||
className="wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground"
|
||||
className={cn(
|
||||
'wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground',
|
||||
interruptedOnly && 'text-[0.8rem] leading-5 text-muted-foreground/82'
|
||||
)}
|
||||
data-slot="aui_assistant-message-content"
|
||||
>
|
||||
{hoistedTodos.length > 0 && <HoistedTodoPanel todos={hoistedTodos} />}
|
||||
@@ -257,7 +260,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
</ErrorPrimitive.Root>
|
||||
</MessagePrimitive.Error>
|
||||
</div>
|
||||
{messageText.trim().length > 0 && (
|
||||
{messageText.trim().length > 0 && !interruptedOnly && (
|
||||
<AssistantFooter messageId={messageId} messageText={messageText} onBranchInNewChat={onBranchInNewChat} />
|
||||
)}
|
||||
</MessagePrimitive.Root>
|
||||
@@ -282,11 +285,10 @@ 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={t.assistant.thread.loadingResponse}>
|
||||
<StatusRow data-slot="aui_response-loading" label="Hermes is loading a response">
|
||||
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
|
||||
<ActivityTimerText seconds={elapsed} />
|
||||
</StatusRow>
|
||||
@@ -335,7 +337,6 @@ 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
|
||||
@@ -392,7 +393,7 @@ const ThinkingDisclosure: FC<{
|
||||
pending && 'shimmer text-foreground/55'
|
||||
)}
|
||||
>
|
||||
{t.assistant.thread.thinking}
|
||||
Thinking
|
||||
</span>
|
||||
{pending && (
|
||||
<ActivityTimerText
|
||||
@@ -437,7 +438,7 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star
|
||||
s.thread.isRunning &&
|
||||
s.message.status?.type === 'running' &&
|
||||
s.message.parts
|
||||
.slice(Math.max(0, startIndex))
|
||||
.slice(Math.max(0, startIndex), Math.min(s.message.parts.length, endIndex))
|
||||
.some(p => p?.type === 'reasoning' && p.status?.type !== 'complete')
|
||||
)
|
||||
|
||||
@@ -494,10 +495,7 @@ 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 {
|
||||
function formatMessageTimestamp(value: Date | string | number | undefined): string {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
@@ -511,19 +509,17 @@ function formatMessageTimestamp(
|
||||
const dayDelta = Math.round((startOfDay(new Date()) - startOfDay(date)) / 86_400_000)
|
||||
|
||||
if (dayDelta === 0) {
|
||||
return labels.today(TIME_FMT.format(date))
|
||||
return `Today, ${TIME_FMT.format(date)}`
|
||||
}
|
||||
|
||||
if (dayDelta === 1) {
|
||||
return labels.yesterday(TIME_FMT.format(date))
|
||||
return `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 (
|
||||
@@ -542,15 +538,15 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, on
|
||||
)}
|
||||
data-slot="aui_msg-actions"
|
||||
>
|
||||
<CopyButton appearance="icon" buttonSize="icon" disabled={!messageText} label={copy.copy} text={messageText} />
|
||||
<CopyButton appearance="icon" buttonSize="icon" disabled={!messageText} label="Copy" text={messageText} />
|
||||
<ActionBarPrimitive.Reload asChild>
|
||||
<TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip={copy.refresh}>
|
||||
<TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip="Refresh">
|
||||
<Codicon name="refresh" />
|
||||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.Reload>
|
||||
<DropdownMenu onOpenChange={setMenuOpen} open={menuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<TooltipIconButton tooltip={copy.moreActions}>
|
||||
<TooltipIconButton tooltip="More actions">
|
||||
<Codicon name="ellipsis" />
|
||||
</TooltipIconButton>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -558,7 +554,7 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, on
|
||||
<MessageTimestamp />
|
||||
<DropdownMenuItem onSelect={() => onBranchInNewChat?.(messageId)}>
|
||||
<GitBranchIcon />
|
||||
{copy.branchNewChat}
|
||||
Branch in new chat
|
||||
</DropdownMenuItem>
|
||||
<ReadAloudItem messageId={messageId} text={messageText} />
|
||||
</DropdownMenuContent>
|
||||
@@ -569,8 +565,6 @@ 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 =
|
||||
@@ -589,9 +583,9 @@ const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, tex
|
||||
try {
|
||||
await playSpeechText(text, { messageId, source: 'read-aloud' })
|
||||
} catch (error) {
|
||||
notifyError(error, copy.readAloudFailed)
|
||||
notifyError(error, 'Read aloud failed')
|
||||
}
|
||||
}, [copy.readAloudFailed, messageId, text])
|
||||
}, [messageId, text])
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
@@ -602,15 +596,14 @@ const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, tex
|
||||
}}
|
||||
>
|
||||
<Icon className={isPreparing ? 'animate-spin' : undefined} />
|
||||
{isPreparing ? copy.preparingAudio : isSpeaking ? copy.stopReading : copy.readAloud}
|
||||
{isPreparing ? 'Preparing audio...' : isSpeaking ? 'Stop reading' : 'Read aloud'}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
const MessageTimestamp: FC = () => {
|
||||
const { t } = useI18n()
|
||||
const createdAt = useAuiState(s => s.message.createdAt)
|
||||
const label = formatMessageTimestamp(createdAt, t.assistant.thread)
|
||||
const label = formatMessageTimestamp(createdAt)
|
||||
|
||||
if (!label) {
|
||||
return null
|
||||
@@ -662,11 +655,11 @@ function StickyHumanMessageContainer({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
// Shared "user bubble" base. Both the read-only message and the inline
|
||||
// edit composer render the same bubble surface (rounded glass card,
|
||||
// shadow-composer); they only differ in border weight, cursor, and
|
||||
// padding-right (the read-only view reserves room for the restore icon).
|
||||
// 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).
|
||||
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-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left shadow-composer'
|
||||
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left'
|
||||
|
||||
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'
|
||||
@@ -677,8 +670,6 @@ 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)
|
||||
@@ -770,10 +761,10 @@ const UserMessage: FC<{
|
||||
) : (
|
||||
<ActionBarPrimitive.Edit asChild>
|
||||
<button
|
||||
aria-label={copy.editMessage}
|
||||
aria-label="Edit message"
|
||||
className={bubbleClassName}
|
||||
onClick={() => triggerHaptic('selection')}
|
||||
title={copy.editMessage}
|
||||
title="Edit message"
|
||||
type="button"
|
||||
>
|
||||
{bubbleContent}
|
||||
@@ -784,14 +775,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={copy.stop}
|
||||
aria-label="Stop"
|
||||
className={cn('pointer-events-auto size-5', USER_ACTION_ICON_BUTTON_CLASS)}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void onCancel?.()
|
||||
}}
|
||||
title={copy.stop}
|
||||
title="Stop"
|
||||
type="button"
|
||||
>
|
||||
{StopGlyph}
|
||||
@@ -800,7 +791,7 @@ const UserMessage: FC<{
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex size-6 items-center justify-center rounded-md text-(--ui-text-tertiary)"
|
||||
title={copy.editableCheckpoint}
|
||||
title="Editable checkpoint"
|
||||
>
|
||||
<Codicon name="discard" size="0.875rem" />
|
||||
</span>
|
||||
@@ -815,18 +806,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={copy.restorePrevious}
|
||||
title="Restore previous checkpoint"
|
||||
>
|
||||
{copy.restoreCheckpoint}
|
||||
Restore checkpoint
|
||||
</BranchPickerPrimitive.Previous>
|
||||
<span className="checkpoint-divider opacity-55">
|
||||
<BranchPickerPrimitive.Number />/<BranchPickerPrimitive.Count />
|
||||
</span>
|
||||
<BranchPickerPrimitive.Next
|
||||
className="checkpoint-restore-text rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
|
||||
title={copy.restoreNext}
|
||||
title="Restore next checkpoint"
|
||||
>
|
||||
{copy.goForward}
|
||||
Go forward
|
||||
</BranchPickerPrimitive.Next>
|
||||
</BranchPickerPrimitive.Root>
|
||||
</div>
|
||||
@@ -837,7 +828,6 @@ const UserMessage: FC<{
|
||||
}
|
||||
|
||||
const SLASH_STATUS_RE = /^slash:(?<command>\/[^\n]+)\n(?<output>[\s\S]*)$/
|
||||
const STEER_NOTE_RE = /^steer:(?<text>[\s\S]+)$/
|
||||
|
||||
const SystemMessage: FC = () => {
|
||||
const text = useAuiState(s => messageContentText(s.message.content))
|
||||
@@ -846,23 +836,6 @@ const SystemMessage: FC = () => {
|
||||
return null
|
||||
}
|
||||
|
||||
const steerNote = text.match(STEER_NOTE_RE)
|
||||
|
||||
if (steerNote?.groups) {
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="flex max-w-[min(86%,44rem)] items-center gap-1.5 self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/60"
|
||||
data-role="system"
|
||||
data-slot="aui_system-message-root"
|
||||
>
|
||||
<Codicon className="text-muted-foreground/55" name="compass" size="0.75rem" />
|
||||
<span className="text-muted-foreground/55">steered</span>
|
||||
<span className="text-muted-foreground/35">·</span>
|
||||
<span className="whitespace-pre-wrap">{steerNote.groups.text.trim()}</span>
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const slashStatus = text.match(SLASH_STATUS_RE)
|
||||
|
||||
if (slashStatus?.groups) {
|
||||
@@ -897,8 +870,6 @@ 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)
|
||||
@@ -1375,7 +1346,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
data-expanded={expanded ? 'true' : undefined}
|
||||
>
|
||||
<div
|
||||
aria-label={copy.editMessage}
|
||||
aria-label="Edit message"
|
||||
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',
|
||||
@@ -1384,7 +1355,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
expanded ? 'min-h-16' : 'min-h-[1.25rem]'
|
||||
)}
|
||||
contentEditable
|
||||
data-placeholder={copy.editMessage}
|
||||
data-placeholder="Edit message"
|
||||
data-slot={RICH_INPUT_SLOT}
|
||||
onBlur={() => window.setTimeout(closeTrigger, 80)}
|
||||
onDragOver={handleDragOver}
|
||||
@@ -1401,7 +1372,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
/>
|
||||
<ComposerPrimitive.Input className="sr-only" tabIndex={-1} unstable_focusOnScrollToBottom={false} />
|
||||
<button
|
||||
aria-label={copy.sendEdited}
|
||||
aria-label="Send edited message"
|
||||
className={cn('absolute right-2 bottom-2 size-5', USER_ACTION_ICON_BUTTON_CLASS)}
|
||||
disabled={!canSubmit || submitting}
|
||||
onClick={() => {
|
||||
@@ -1411,7 +1382,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
submitEdit(editor)
|
||||
}
|
||||
}}
|
||||
title={copy.sendEdited}
|
||||
title="Send edited message"
|
||||
type="button"
|
||||
>
|
||||
{submitting ? StopGlyph : <Codicon name="arrow-up" size={USER_ACTION_ICON_SIZE} />}
|
||||
|
||||
@@ -33,3 +33,34 @@ describe('buildToolView image handling', () => {
|
||||
expect(buildToolView(part({ result: { url } }), '').imageUrl).toBe(url)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildToolView terminal exit-code status', () => {
|
||||
const terminal = (result: Record<string, unknown>) =>
|
||||
buildToolView(part({ result, toolName: 'terminal' }), '')
|
||||
|
||||
// A non-zero exit code with real output is not a failure (grep no-match,
|
||||
// diff differences, piped commands surfacing the last stage's code, etc.) —
|
||||
// it should render as success so the card isn't painted red.
|
||||
it('treats non-zero exit with output as success', () => {
|
||||
expect(terminal({ exit_code: 7, output: 'node ... 5174 (LISTEN)' }).status).toBe('success')
|
||||
expect(terminal({ exit_code: 1, stdout: 'partial results' }).status).toBe('success')
|
||||
})
|
||||
|
||||
// No output + non-zero exit is a genuine failure worth flagging.
|
||||
it('treats non-zero exit with no output as error', () => {
|
||||
expect(terminal({ exit_code: 127, output: '' }).status).toBe('error')
|
||||
expect(terminal({ exit_code: 1 }).status).toBe('error')
|
||||
})
|
||||
|
||||
it('treats zero exit as success', () => {
|
||||
expect(terminal({ exit_code: 0, output: 'done' }).status).toBe('success')
|
||||
})
|
||||
|
||||
// Explicit error signals still win regardless of output presence.
|
||||
it('keeps explicit error signals red even with output', () => {
|
||||
expect(terminal({ error: 'boom', exit_code: 0, output: 'partial' }).status).toBe('error')
|
||||
expect(buildToolView(part({ isError: true, result: { output: 'x' }, toolName: 'terminal' }), '').status).toBe(
|
||||
'error'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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'
|
||||
@@ -743,9 +742,20 @@ function toolErrorText(part: ToolPart, result: Record<string, unknown>): string
|
||||
return firstStringField(result, ['message', 'reason', 'detail']) || `Tool returned status "${result.status}".`
|
||||
}
|
||||
|
||||
// A non-zero exit code alone is a weak failure signal: grep returns 1 on
|
||||
// no-match, diff returns 1 on differences, piped commands surface the last
|
||||
// stage's code, etc. — all routinely produce useful output and aren't
|
||||
// failures. Only treat it as an error when the command produced no real
|
||||
// output to show; otherwise render the output normally (not red).
|
||||
const exit = numberValue(result.exit_code)
|
||||
|
||||
return exit !== null && exit !== 0 ? `Command failed with exit code ${exit}.` : ''
|
||||
if (exit !== null && exit !== 0) {
|
||||
const hasOutput = Boolean(firstStringField(result, ['output', 'stdout', 'stderr'])?.trim())
|
||||
|
||||
return hasOutput ? '' : `Command failed with exit code ${exit}.`
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function toolStatus(part: ToolPart, resultRecord: Record<string, unknown>): ToolStatus {
|
||||
@@ -1082,17 +1092,6 @@ 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()
|
||||
@@ -1100,25 +1099,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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1126,7 +1125,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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1134,25 +1133,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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1160,15 +1159,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.generic, text: view.title }
|
||||
return { label: 'Copy', text: view.title }
|
||||
}
|
||||
|
||||
function dynamicTitle(
|
||||
|
||||
@@ -943,7 +943,6 @@ canvas {
|
||||
|
||||
[data-slot='composer-surface'] {
|
||||
border-color: var(--ui-stroke-secondary) !important;
|
||||
box-shadow: var(--shadow-composer) !important;
|
||||
}
|
||||
|
||||
[data-slot='composer-fade'] {
|
||||
|
||||
Reference in New Issue
Block a user