Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 94 additions & 3 deletions web/src/components/AssistantChat/HappyComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,31 @@ export interface TextInputState {
selection: { start: number; end: number }
}

/**
* One rejected send. `id` is bumped per failure so two failures with the
* same `text` still trigger a fresh restore (the dedupe key is the id, not
* the text).
*
* - `text` is the original input that should be put back into the composer.
* - `message` is the user-facing error string we render inline.
* - `scheduledAt` is the absolute epoch-ms the rejected send was bound for,
* or null for an immediate send. When non-null, the composer also
* restores the schedule via `onSchedule` so the operator can edit and
* retry without silently downgrading a scheduled send to immediate.
*
* Owned by the route component (`router.tsx`); the composer is a pure
* consumer that:
* 1. restores the text once per `id` via `api.composer().setText`,
* 2. restores the schedule (if any) via `onSchedule`, and
* 3. shows a red ring + inline message until the user types or sends.
*/
export type ComposerSendError = {
id: number
text: string
message: string
scheduledAt: number | null
}

const defaultSuggestionHandler = async (): Promise<Suggestion[]> => []

export function HappyComposer(props: {
Expand Down Expand Up @@ -81,6 +106,11 @@ export function HappyComposer(props: {
pendingSchedule?: PendingSchedule | null
onSchedule?: (pending: PendingSchedule) => void
onClearSchedule?: () => void
// Set when the most recent send failed (4xx/5xx/network). The composer
// restores the original text once per `sendError.id` and renders an
// inline error affordance until the user dismisses or starts editing.
sendError?: ComposerSendError | null
onClearSendError?: () => void
}) {
const { t } = useTranslation()
const {
Expand Down Expand Up @@ -119,7 +149,9 @@ export function HappyComposer(props: {
onVoiceMicToggle,
pendingSchedule: pendingScheduleProp,
onSchedule: onScheduleProp,
onClearSchedule: onClearScheduleProp
onClearSchedule: onClearScheduleProp,
sendError = null,
onClearSendError
} = props

// Use ?? so missing values fall back to default (destructuring defaults only handle undefined)
Expand Down Expand Up @@ -171,6 +203,40 @@ export function HappyComposer(props: {

useComposerDraft(sessionId, composerText, (text) => api.composer().setText(text))

// assistant-ui clears `composer.text` synchronously the moment a send is
// invoked AND `SessionChat.handleSend` clears `pendingSchedule` the
// moment the mutation is accepted, so by the time the mutation's
// onError fires both the typed text and the schedule are gone. When
// the route hands us a `sendError`, splice both back in -- once per
// `sendError.id` so a second failure with the same text still triggers
// a fresh restore.
const restoredErrorIdRef = useRef<number | null>(null)
useEffect(() => {
if (!sendError) {
return
}
if (restoredErrorIdRef.current === sendError.id) {
return
}
restoredErrorIdRef.current = sendError.id
// Only restore when the composer is empty. If the user has already
// typed something new (rare -- composer is `disabled` during send,
// but possible if isSending toggles before this effect runs), we
// would otherwise stomp on their fresh input.
if (composerText.length === 0 && sendError.text.length > 0) {
api.composer().setText(sendError.text)
}
// Restore the pending schedule too. `scheduledAt` was already
// resolved to an absolute epoch-ms before the failed send (presets
// are computed at send time -- see `resolvePendingSchedule`), so
// we feed it back as an 'absolute' PendingSchedule. The existing
// shouldAutoClearPendingSchedule effect in SessionChat handles the
// case where the absolute time has passed by the time we restore.
if (sendError.scheduledAt !== null && onScheduleProp) {
onScheduleProp({ type: 'absolute', ms: sendError.scheduledAt })
}
}, [sendError, api, composerText, onScheduleProp])

useEffect(() => {
setInputState((prev) => {
if (prev.text === composerText) return prev
Expand Down Expand Up @@ -426,7 +492,13 @@ export function HappyComposer(props: {
end: e.target.selectionEnd
}
setInputState({ text: e.target.value, selection })
}, [])
// Editing the restored text is the operator's "I'm handling it"
// signal -- drop the inline error so the affordance doesn't shout
// at them while they fix the message.
if (sendError && onClearSendError) {
onClearSendError()
}
}, [sendError, onClearSendError])

const handleSelect = useCallback((e: ReactSyntheticEvent<HTMLTextAreaElement>) => {
const target = e.target as HTMLTextAreaElement
Expand Down Expand Up @@ -533,6 +605,11 @@ export function HappyComposer(props: {
// and async inactive-session resume failure. Clearing here unconditionally
// would race ahead of that check and drop the user's schedule on every
// rejected send path.
//
// The inline send-error affordance is intentionally NOT cleared here:
// the route-level state (`onSuccess`/`onError` in router.tsx) replaces
// or clears it based on the actual mutation result, so the user keeps
// the error context while the new attempt is in flight.
}, [api])

const overlays = useMemo(() => {
Expand Down Expand Up @@ -812,7 +889,21 @@ export function HappyComposer(props: {
voiceStatus={voiceStatus}
/>

<div className="overflow-hidden rounded-[20px] bg-[var(--app-secondary-bg)]">
{sendError ? (
<div
role="alert"
data-testid="composer-send-error"
className="mb-2 rounded-md bg-[var(--app-subtle-bg)] px-3 py-2 text-sm text-red-600"
>
{sendError.message}
</div>
) : null}

<div
className={`overflow-hidden rounded-[20px] bg-[var(--app-secondary-bg)] ${
sendError ? 'ring-1 ring-red-500' : ''
}`}
>
{attachments.length > 0 ? (
<div className="flex flex-wrap gap-2 px-4 pt-3">
<ComposerPrimitive.Attachments components={{ Attachment: AttachmentItem }} />
Expand Down
10 changes: 9 additions & 1 deletion web/src/components/SessionChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { buildConversationOutline } from '@/chat/outline'
import { buildVisibleChatBlocks, isToolGroupBlock, type ToolGroupBlock } from '@/chat/toolGroups'
import { isQueuedForInvocation, mergeMessages } from '@/lib/messages'
import { inactiveSessionCanResume } from '@/lib/sessionResume'
import { HappyComposer } from '@/components/AssistantChat/HappyComposer'
import { HappyComposer, type ComposerSendError } from '@/components/AssistantChat/HappyComposer'
import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
import { resolvePendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
import { HappyThread } from '@/components/AssistantChat/HappyThread'
Expand Down Expand Up @@ -151,6 +151,12 @@ export function SessionChat(props: {
onRetryMessage?: (localId: string) => void
autocompleteSuggestions?: (query: string) => Promise<Suggestion[]>
availableSlashCommands?: readonly SlashCommand[]
// The latest send the hub rejected (4xx/5xx/network). When set, the
// composer is asked to restore the typed text and surface an inline
// error -- see HappyComposer. Cleared by `onClearSendError` once the
// user dismisses or starts editing.
sendError?: ComposerSendError | null
onClearSendError?: () => void
}) {
const { haptic } = usePlatform()
const { t } = useTranslation()
Expand Down Expand Up @@ -724,6 +730,8 @@ export function SessionChat(props: {
voiceMicMuted={voice?.micMuted}
onVoiceToggle={voice && voiceBackendReady ? handleVoiceToggle : undefined}
onVoiceMicToggle={voice && voiceBackendReady ? handleVoiceMicToggle : undefined}
sendError={props.sendError ?? null}
onClearSendError={props.onClearSendError}
/>
</div>
</AssistantRuntimeProvider>
Expand Down
Loading
Loading