mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 22:12:23 +00:00
Mark Assistant provider retries as live activity
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user