import { useAuiState } from '@assistant-ui/react' import { useStore } from '@nanostores/react' import { type FC, type ReactNode, useEffect, useState } from 'react' import { useElapsedSeconds } from '@/components/chat/activity-timer' import { ActivityTimerText } from '@/components/chat/activity-timer-text' import { Codicon } from '@/components/ui/codicon' import { Loader } from '@/components/ui/loader' import { useI18n } from '@/i18n' import { cn } from '@/lib/utils' import { $backgroundResume } from '@/store/background-delegation' import { $compactionActive } from '@/store/compaction' import { $activeSessionAwaitingInput } from '@/store/prompts' const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentPropsWithoutRef<'div'>> = ({ children, label, className, ...rest }) => (
{children}
) // Fixed label while auto-compaction runs — decoupled from backend status text. const COMPACTION_LABEL = 'Summarizing thread' const CompactionHint: FC = () => ( {COMPACTION_LABEL} ) export const CenteredThreadSpinner: FC = () => { const { t } = useI18n() return (
) } export const ResponseLoadingIndicator: FC = () => { const { t } = useI18n() const elapsed = useElapsedSeconds() const compacting = useStore($compactionActive) return ( ) } // Parked-background affordance: a top-level delegate_task runs in the // background, so the parent turn ends and the app goes idle while the subagent // keeps working and its result re-enters as a fresh turn later. Instead of a // spinner (reads as "stuck"), reuse the same compact, centered system-note // chrome as the steer / slash-status lines (SystemMessage above) so it sits in // the thread like every other meta line. Idle-only (gated upstream). Null when // nothing is parked. export const BackgroundResumeNotice: FC = () => { const { t } = useI18n() const resume = useStore($backgroundResume) if (!resume) { return null } const label = resume.activity ?? t.assistant.thread.resumeWhenBackgroundDone(resume.count) return (
{label}
) } // Seconds of no visible output (text or part count) before a still-running turn // is treated as stalled and the thinking indicator returns at the tail. const STREAM_STALL_S = 2 // Tail "still thinking" indicator: the pre-first-token spinner goes away once // text flows, but if the stream then goes quiet mid-turn (tool think-time, // provider stall) nothing signals that work continues. Watch a per-flush // activity signal; when it hasn't changed for STREAM_STALL_S, re-show the // dither + a timer counting from the last activity. // // Subscribes to the activity signal ITSELF (rather than taking it as a prop) // so that per-token updates re-render only this leaf, not the whole // AssistantMessage subtree. export const StreamStallIndicator: FC = () => { const activity = useAuiState(s => { let textLength = 0 for (const part of s.message.content) { const text = (part as { text?: unknown }).text if (typeof text === 'string') { textLength += text.length } } return `${s.message.content.length}:${textLength}` }) const [stalled, setStalled] = useState(false) const compacting = useStore($compactionActive) // A pending clarify / approval / sudo / secret means the turn is paused on the // user, not working — so don't resurrect the "thinking" timer while they // decide (matches the pet's awaitingInput pose taking priority over busy). const awaitingInput = useStore($activeSessionAwaitingInput) useEffect(() => { setStalled(false) const id = window.setTimeout(() => setStalled(true), STREAM_STALL_S * 1000) return () => window.clearTimeout(id) }, [activity]) const active = (stalled || compacting) && !awaitingInput const elapsed = useElapsedSeconds(active) if (!active) { return null } return ( ) }