mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-15 11:22:10 +08:00
Replace the chat processing banner with a minimal activity indicator and
rebuild the state model underneath it. The old banner was driven by five
overlapping pieces of state (isLoading, canAbortSession, claudeStatus in the
chat, plus two app-level Sets updated in lockstep through four callbacks)
that had to be kept in sync imperatively. Because completion and status
events mutated the *viewed* session's flags regardless of which session they
belonged to, a background session finishing could hide the indicator for a
still-running session, returning to a finished session could briefly show a
stale banner, and a late status reply could override a newer request.
The fix is structural rather than patch-by-patch: a single
Map<sessionId, {statusText, canInterrupt, startedAt}> in useSessionProtection
is now the only source of truth for "this session is working". The indicator,
stop button, composer streaming state, and session protection are all derived
from the viewed session's entry on render, so there is no stale local copy to
restore or reset when switching sessions. A PENDING_SESSION_ID sentinel
covers the window before a new conversation receives its real session id.
Terminal events delete the entry atomically, which is why the indicator
disappears the instant the final chunk arrives. Stale check-session-status
replies are discarded via an ifStartedBefore guard (an idle reply older than
the entry's startedAt describes a previous request, not the current one).
The second half unifies the provider lifecycle contract, because the frontend
could not be made race-free while each provider terminated differently:
- cursor emitted complete twice per run (result line + process close), which
double-played the completion sound and let a late close-complete clear a
newer request's indicator
- aborts produced two completes (the abort-session reply plus the provider's
own non-aborted one), so cancelling a run played the celebration sound
- codex omitted exitCode; others attached ad-hoc fields (resultText, isError,
isNewSession) the client had to know about
- claude/codex failures ended with only an error event while gemini/cursor
also emit kind:'error' for mid-run stderr noise, so 'error' was ambiguous
between "the run died" and "a process wrote to stderr"
Every run now ends with exactly one complete built by createCompleteMessage()
({sessionId, actualSessionId, exitCode, success, aborted}); abort-session
sends it on behalf of cancelled runs and providers detect the abort and skip
their own. error is demoted to an informational row, so stderr noise no
longer kills the indicator mid-run, and the client celebrates only
success: true completes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
81 lines
3.3 KiB
TypeScript
81 lines
3.3 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { Shimmer } from '../../../../shared/view/ui';
|
|
import type { SessionActivity } from '../../../../hooks/useSessionProtection';
|
|
|
|
type ActivityIndicatorProps = {
|
|
activity: SessionActivity | null;
|
|
onAbort?: () => void;
|
|
};
|
|
|
|
const ACTION_KEYS = [
|
|
'claudeStatus.actions.thinking',
|
|
'claudeStatus.actions.processing',
|
|
'claudeStatus.actions.analyzing',
|
|
'claudeStatus.actions.working',
|
|
'claudeStatus.actions.computing',
|
|
'claudeStatus.actions.reasoning',
|
|
];
|
|
const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
|
|
|
|
/**
|
|
* Minimal response-in-progress indicator, in the spirit of the inline status
|
|
* lines in Claude Code / Codex / OpenCode: a shimmering activity label, the
|
|
* elapsed time, and an interrupt affordance. Rendered only while the viewed
|
|
* session has an entry in the processing map; it disappears the instant that
|
|
* entry is removed.
|
|
*/
|
|
export default function ActivityIndicator({ activity, onAbort }: ActivityIndicatorProps) {
|
|
const { t } = useTranslation('chat');
|
|
const startedAt = activity?.startedAt ?? null;
|
|
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
|
|
|
useEffect(() => {
|
|
if (startedAt === null) return;
|
|
const update = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000)));
|
|
update();
|
|
const timer = setInterval(update, 1000);
|
|
return () => clearInterval(timer);
|
|
}, [startedAt]);
|
|
|
|
if (!activity) return null;
|
|
|
|
const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] }));
|
|
const label = (activity.statusText || actionWords[Math.floor(elapsedSeconds / 4) % actionWords.length])
|
|
.replace(/\.+$/, '');
|
|
|
|
const minutes = Math.floor(elapsedSeconds / 60);
|
|
const seconds = elapsedSeconds % 60;
|
|
const elapsedLabel = minutes < 1
|
|
? t('claudeStatus.elapsed.seconds', { count: seconds, defaultValue: '{{count}}s' })
|
|
: t('claudeStatus.elapsed.minutesSeconds', { minutes, seconds, defaultValue: '{{minutes}}m {{seconds}}s' });
|
|
|
|
return (
|
|
<div className="animate-in fade-in mb-2 w-full duration-300">
|
|
<div className="mx-auto flex max-w-4xl items-center gap-2 px-1">
|
|
<span className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-primary" aria-hidden />
|
|
<Shimmer className="text-xs font-medium">{`${label}…`}</Shimmer>
|
|
<span className="text-xs tabular-nums text-muted-foreground/60">{elapsedLabel}</span>
|
|
|
|
{activity.canInterrupt && onAbort && (
|
|
<button
|
|
type="button"
|
|
onClick={onAbort}
|
|
className="ml-auto flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
|
aria-label={t('claudeStatus.stop', { defaultValue: 'Stop' })}
|
|
>
|
|
<svg className="h-2.5 w-2.5 fill-current" viewBox="0 0 24 24" aria-hidden>
|
|
<rect x="5" y="5" width="14" height="14" rx="2" />
|
|
</svg>
|
|
<span>{t('claudeStatus.stop', { defaultValue: 'Stop' })}</span>
|
|
<kbd className="hidden rounded border border-border/60 px-1 text-[10px] text-muted-foreground/70 sm:inline-block">
|
|
esc
|
|
</kbd>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|