hermes-agent/apps/desktop/src/components/assistant-ui/thread/status.tsx
Brooklyn Nicholson fa7bce0789 refactor(desktop): colocate hook/component families into scoped folders
Single-scoped helpers/sub-files were sitting flat in shared/grab-bag dirs.
Fold each family into its own folder (index = the export, dir resolution keeps
public import paths intact), dropping the now-redundant filename prefix:

- session/hooks/use-prompt-actions.ts (+ -utils, + tests)
  -> use-prompt-actions/{index,utils}.ts (+ tests)
- components/assistant-ui/thread* + assistant/system/user message renderers
  -> assistant-ui/thread/{index,content,status,message-parts,timestamp,types,
     list,timeline,timeline-data,assistant-message,system-message,user-message,
     user-edit-composer,user-message-text} (+ tests)
- components/assistant-ui/tool-fallback(+model)/tool-approval
  -> assistant-ui/tool/{fallback,fallback-model,approval} (+ tests)

Pure move + import rewrites; no behaviour change. App-wide shared primitives
(markdown-text, directive-text, tooltip-icon-button, clarify-tool, ansi-text,
message-render-boundary) stay flat. desktop-controller intentionally left in
app/ (route root; foldering would churn ~80 relative imports for no gain).
2026-06-30 02:42:07 -05:00

167 lines
5.5 KiB
TypeScript

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
}) => (
<div
aria-label={label}
aria-live="polite"
className={cn('flex max-w-full items-center gap-2 self-start text-sm text-muted-foreground/70', className)}
role="status"
{...rest}
>
{children}
</div>
)
// Fixed label while auto-compaction runs — decoupled from backend status text.
const COMPACTION_LABEL = 'Summarizing thread'
const CompactionHint: FC = () => (
<span className="shimmer min-w-0 truncate text-muted-foreground/55">{COMPACTION_LABEL}</span>
)
export 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>
)
}
export const ResponseLoadingIndicator: FC = () => {
const { t } = useI18n()
const elapsed = useElapsedSeconds()
const compacting = useStore($compactionActive)
return (
<StatusRow
data-slot="aui_response-loading"
label={compacting ? COMPACTION_LABEL : t.assistant.thread.loadingResponse}
>
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
{compacting && <CompactionHint />}
<ActivityTimerText seconds={elapsed} />
</StatusRow>
)
}
// 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 (
<div
aria-live="polite"
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/55"
data-slot="aui_background-resume"
role="status"
>
<Codicon className="text-muted-foreground/55" name="sync" size="0.75rem" />
<span className="shimmer min-w-0 truncate">{label}</span>
</div>
)
}
// 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 (
<StatusRow
className="mt-1.5"
data-slot="aui_stream-stall"
label={compacting ? COMPACTION_LABEL : 'Hermes is thinking'}
>
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
{compacting && <CompactionHint />}
<ActivityTimerText seconds={elapsed} />
</StatusRow>
)
}