mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
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).
167 lines
5.5 KiB
TypeScript
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>
|
|
)
|
|
}
|