Mark Assistant provider retries as live activity

This commit is contained in:
rcourtman
2026-06-08 04:00:30 +01:00
parent 50295914c1
commit 10fa9eca3a
7 changed files with 97 additions and 24 deletions
@@ -348,6 +348,10 @@ deriving an older display status from `workflowStatusHistory`.
export status lines must normalize internal tool identifiers such as
`pulse_query`, `pulse_read`, and `pulse_exec` into operator-facing activity
labels, while preserving the raw typed event payload for Details/debug paths.
Provider retry states are a distinct live activity kind: a `provider_retry`
workflow status must render as retrying in the active-turn footer and live
workflow row so the operator sees selected-route recovery in progress rather
than another generic waiting/thinking state.
Assistant-authored visible answer prose is part of the same boundary: model
text may describe the action in operator vocabulary such as "read command"
or "inventory lookup", but raw internal identifiers such as `pulse_read`
@@ -26,7 +26,7 @@ import { ThinkingBlock } from './ThinkingBlock';
import { getAssistantAnswerText } from './assistantAnswerText';
import { stripAssistantOutputArtifacts } from './assistantOutputHygiene';
import { formatAssistantTurnDuration } from './assistantTurnSummary';
import { formatAssistantWorkflowStatus } from './activeTurnStatus';
import { assistantWorkflowStatusKind, formatAssistantWorkflowStatus } from './activeTurnStatus';
import { groupStreamEventsForDisplay } from './streamEventGrouping';
import {
createPacedWorkflowStatus,
@@ -429,6 +429,22 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
}
return `${message}${elapsedSuffix}`;
};
const workflowStatusToneClass = (status?: WorkflowStatus) => {
const kind = assistantWorkflowStatusKind(status);
if (kind === 'retrying') {
return 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-200';
}
if (kind === 'tool') {
return 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/30 dark:text-blue-200';
}
return 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/30 dark:text-blue-200';
};
const workflowStatusDotClass = (status?: WorkflowStatus) => {
const kind = assistantWorkflowStatusKind(status);
if (kind === 'retrying') return 'bg-amber-500';
if (kind === 'tool') return 'bg-blue-500';
return 'bg-blue-500';
};
const workflowStatusText = createMemo(() =>
formatWorkflowStatus(currentWorkflowStatus(), true),
);
@@ -578,11 +594,18 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
</Show>
<Show when={shouldShowHeaderWorkflowStatus()}>
<span
class="inline-flex min-w-0 max-w-[18rem] items-center gap-1.5 rounded border border-blue-200 bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/30 dark:text-blue-200"
class={`inline-flex min-w-0 max-w-[18rem] items-center gap-1.5 rounded border px-1.5 py-0.5 text-[10px] font-medium ${workflowStatusToneClass(
currentWorkflowStatus(),
)}`}
title={workflowStatusText()}
aria-live="polite"
data-status-kind={assistantWorkflowStatusKind(currentWorkflowStatus())}
>
<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-blue-500 animate-pulse" />
<span
class={`h-1.5 w-1.5 shrink-0 rounded-full animate-pulse ${workflowStatusDotClass(
currentWorkflowStatus(),
)}`}
/>
<span class="truncate">{workflowStatusText()}</span>
</span>
</Show>
@@ -660,16 +683,21 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
}
>
<div
class="my-1 inline-flex max-w-full items-center gap-2 rounded-md border border-blue-200 bg-blue-50 px-2.5 py-1.5 text-xs font-medium text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/30 dark:text-blue-200"
class={`my-1 inline-flex max-w-full items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs font-medium ${workflowStatusToneClass(
visibleWorkflowStatus(),
)}`}
role="status"
aria-live="polite"
title={formatWorkflowStatus(
visibleWorkflowStatus(),
props.message.isStreaming,
)}
data-status-kind={assistantWorkflowStatusKind(visibleWorkflowStatus())}
>
<span
class="h-1.5 w-1.5 shrink-0 rounded-full bg-blue-500 animate-pulse"
class={`h-1.5 w-1.5 shrink-0 rounded-full animate-pulse ${workflowStatusDotClass(
visibleWorkflowStatus(),
)}`}
aria-hidden="true"
/>
<span class="min-w-0 truncate">
@@ -5559,6 +5559,9 @@ describe('AIChat', () => {
expect(screen.getByLabelText('Assistant active turn status')).toHaveTextContent(
'Provider connection failed before any output; retrying. · attempt 2/3 · retrying in 1.9s',
);
expect(
screen.getByTestId('assistant-activity-dock').querySelector('[data-status-kind="retrying"]'),
).not.toBeNull();
});
it('prefers workflow progress over selected model route evidence', () => {
@@ -762,6 +762,7 @@ describe('MessageItem', () => {
'Provider connection failed before any output; retrying. · attempt 2/3 · retrying in 1.2s',
),
).toBeInTheDocument();
expect(screen.getByRole('status')).toHaveAttribute('data-status-kind', 'retrying');
expect(screen.queryByText('Thinking...')).not.toBeInTheDocument();
});
@@ -807,6 +808,7 @@ describe('MessageItem', () => {
/Provider connection failed before any output; retrying\. · attempt 2\/3 · retrying in 1\.9s/,
),
).toBeInTheDocument();
expect(screen.getByRole('status')).toHaveAttribute('data-status-kind', 'retrying');
});
it('paces replacing workflow activity through one live transcript row', async () => {
@@ -357,7 +357,7 @@ describe('getAssistantActiveTurnStatus', () => {
1_200,
),
).toEqual({
type: 'thinking',
type: 'retrying',
text: 'Provider connection failed before any output; retrying. · attempt 2/3 · retrying in 3.2s',
startedAt: 1_200,
});
@@ -470,7 +470,7 @@ describe('getAssistantActiveTurnStatus', () => {
true,
),
).toEqual({
type: 'thinking',
type: 'retrying',
text: 'Provider connection failed before any output; retrying. · attempt 2/2 · retrying in 200ms',
startedAt,
});
@@ -496,7 +496,7 @@ describe('getAssistantActiveTurnStatus', () => {
2_300,
),
).toEqual({
type: 'thinking',
type: 'retrying',
text: 'Provider connection failed before any output; retrying. · attempt 2/2 · retrying in 1.9s',
startedAt,
});
@@ -522,7 +522,7 @@ describe('getAssistantActiveTurnStatus', () => {
4_500,
),
).toEqual({
type: 'thinking',
type: 'retrying',
text: 'Provider connection failed before any output; retrying. · attempt 2/2 · retrying now',
startedAt,
});
@@ -9,7 +9,7 @@ import {
toolValueText,
} from './toolPresentation';
export type AssistantActiveTurnStatusKind = 'thinking' | 'tool' | 'generating';
export type AssistantActiveTurnStatusKind = 'thinking' | 'tool' | 'generating' | 'retrying';
export interface AssistantActiveTurnStatus {
text: string;
@@ -204,6 +204,14 @@ const formatPendingToolStatus = (tool?: PendingTool): string => {
return `Running ${toolLabel}`;
};
export const assistantWorkflowStatusKind = (
status?: WorkflowStatus,
): AssistantActiveTurnStatusKind => {
if (status?.phase === 'provider_retry') return 'retrying';
if (status?.tool) return 'tool';
return 'thinking';
};
const activePendingToolFromEvents = (events?: StreamDisplayEvent[]): PendingTool | undefined => {
const completedToolKeys = new Set<string>();
@@ -429,7 +437,7 @@ const latestStreamActivityStatus = (
const text = formatAssistantWorkflowStatus(workflowStatus, now);
if (text) {
candidate = {
type: workflowStatus?.tool ? 'tool' : 'thinking',
type: assistantWorkflowStatusKind(workflowStatus),
text,
startedAt: workflowStatus?.startedAt || event.startedAt,
activityAt: eventActivityAt(event),
@@ -506,7 +514,7 @@ const workflowStatusCandidate = (
const text = formatAssistantWorkflowStatus(status, now);
if (!text) return null;
return {
type: status?.tool ? 'tool' : 'thinking',
type: assistantWorkflowStatusKind(status),
text,
startedAt: status?.startedAt,
activityAt: status?.startedAt,
@@ -2441,6 +2441,25 @@ export const AIChat: Component<AIChatProps> = (props) => {
if (elapsedSeconds < 2) return status.text;
return `${status.text} (${elapsedSeconds}s)`;
});
const currentStatusKind = createMemo(() => currentStatus()?.type);
const currentStatusRowClass = createMemo(() => {
if (currentStatusKind() === 'retrying') {
return 'bg-amber-50/80 text-amber-900 dark:bg-amber-950/25 dark:text-amber-100';
}
return '';
});
const currentStatusIconClass = createMemo(() => {
if (currentStatusKind() === 'retrying') {
return 'animate-spin text-amber-600 dark:text-amber-300';
}
if (currentStatusKind() === 'generating') {
return 'text-emerald-500 dark:text-emerald-300';
}
return 'animate-spin text-blue-600 dark:text-blue-300';
});
const currentStatusDotClass = createMemo(() =>
currentStatusKind() === 'retrying' ? 'bg-amber-400' : 'bg-blue-400',
);
const activeTurnRoute = createMemo<AssistantActiveRoutePresentation | null>(() => {
if (!currentStatus()) return null;
const model = activeAssistantMessage()?.model?.trim() || selectedChatModel().trim();
@@ -4487,33 +4506,42 @@ export const AIChat: Component<AIChatProps> = (props) => {
data-testid="assistant-activity-dock"
>
<Show when={currentStatus()}>
<div class="flex min-h-8 min-w-0 items-center gap-2 px-2.5 py-1.5 text-xs">
<div
class={`flex min-h-8 min-w-0 items-center gap-2 px-2.5 py-1.5 text-xs ${currentStatusRowClass()}`}
data-status-kind={currentStatusKind()}
>
<div
class="flex min-w-0 flex-1 items-center gap-2"
role="status"
aria-label="Assistant active turn status"
aria-live="polite"
>
<LoaderCircleIcon
class={`h-3.5 w-3.5 shrink-0 ${
currentStatus()?.type === 'generating'
? 'text-emerald-500 dark:text-emerald-300'
: 'animate-spin text-blue-600 dark:text-blue-300'
}`}
aria-hidden="true"
/>
<Show
when={currentStatusKind() === 'retrying'}
fallback={
<LoaderCircleIcon
class={`h-3.5 w-3.5 shrink-0 ${currentStatusIconClass()}`}
aria-hidden="true"
/>
}
>
<RefreshCwIcon
class={`h-3.5 w-3.5 shrink-0 ${currentStatusIconClass()}`}
aria-hidden="true"
/>
</Show>
<span class="min-w-0 flex-1 truncate font-medium">{currentStatusText()}</span>
<span class="flex shrink-0 gap-0.5" aria-hidden="true">
<span
class="h-1 w-1 rounded-full bg-blue-400 animate-bounce"
class={`h-1 w-1 rounded-full animate-bounce ${currentStatusDotClass()}`}
style="animation-delay: 0ms; animation-duration: 1s"
/>
<span
class="h-1 w-1 rounded-full bg-blue-400 animate-bounce"
class={`h-1 w-1 rounded-full animate-bounce ${currentStatusDotClass()}`}
style="animation-delay: 150ms; animation-duration: 1s"
/>
<span
class="h-1 w-1 rounded-full bg-blue-400 animate-bounce"
class={`h-1 w-1 rounded-full animate-bounce ${currentStatusDotClass()}`}
style="animation-delay: 300ms; animation-duration: 1s"
/>
</span>