Live activity indicator replacing typing dots (#96) (#104)
Deploy Beta / unit-tests (push) Successful in 12s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Failing after 2m16s

## Summary
- Closes [#96](#96).
- Parses versioned WS `status` frames in `MessageContext` (`activityStage` / `label` / `detail` / `history`).
- New `ActivityIndicator`: completed stages with checks, ~400ms min display, elapsed/still-working notes, a11y live region, reduced-motion, dots fallback when no frames.
- Wired through `ConversationDetailCard` + `AsyncDashboard2`; analytics `ACTIVITY_STAGE_COMPLETED`.

## Depends on
Backend status emission from [chat_backend PR](https://git.aimloperations.com/ai_ml_operations/chat_backend/pulls) (`feat/62-p4-evals-63-agentic`). Without it, UI keeps three-dot fallback.

## Test plan
- [x] Jest: `wsFrames`, `ActivityIndicator`, `ConversationDetailCard`, `MessageContext`
- [ ] Manual light/dark + 320px width
- [ ] Manual with backend status frames: Evaluating → Searching → Refining
- [ ] Manual: stream interrupt clears indicator
- [ ] Capacitor Android smokeReviewed-on: #104
This commit was merged in pull request #104.
This commit is contained in:
2026-08-04 04:08:57 -07:00
parent ce86f56d37
commit 3313738990
11 changed files with 889 additions and 32 deletions
+1
View File
@@ -31,6 +31,7 @@ Page views: always via `Tracker` / `tracker.js` on prod + beta.
| Message Rated | `MESSAGE_RATED` | MessageActions | `{ rating: 'up' \| 'down' \| 'cleared' }` |
| Message Rating Reason | `MESSAGE_RATING_REASON` | MessageActions | `{ reason, hasComment }` |
| Message Exported | `MESSAGE_EXPORTED` | MessageActions, AsyncDashboard2 | `{ format, scope }` |
| Activity Stage Completed | `ACTIVITY_STAGE_COMPLETED` | MessageContext | `{ stage, durationMs }` (no label/detail text) |
## Identify
@@ -0,0 +1,134 @@
import React from 'react';
import { act, render, screen } from '@testing-library/react';
import { ThemeProvider } from 'styled-components';
import ActivityIndicator from './ActivityIndicator';
import type { ActivityHistoryEntry } from '../../utils/wsFrames';
const theme = {
main: '#336699',
focus: '#224466',
darkMode: true,
colors: {
text: '#ffffff',
cardBackground: 'rgba(0,0,0,0.3)',
cardBorder: 'rgba(255,255,255,0.1)',
},
};
const renderIndicator = (
props: React.ComponentProps<typeof ActivityIndicator>,
) =>
render(
<ThemeProvider theme={theme as never}>
<ActivityIndicator {...props} />
</ThemeProvider>,
);
describe('ActivityIndicator (#96)', () => {
it('has status role and polite live region regardless of state', () => {
renderIndicator({ stage: null });
const status = screen.getByRole('status');
expect(status).toHaveAttribute('aria-live', 'polite');
});
it('falls back to three dots when stage is null', () => {
renderIndicator({ stage: null });
expect(screen.getByTestId('activity-dots-fallback')).toBeInTheDocument();
});
it('renders the label verbatim for unknown/future stage values', () => {
renderIndicator({ stage: 'some_future_stage', label: 'Doing something new' });
expect(screen.getByText('Doing something new')).toBeInTheDocument();
expect(screen.queryByTestId('activity-dots-fallback')).not.toBeInTheDocument();
});
it('shows optional detail text', () => {
renderIndicator({
stage: 'searching',
label: 'Searching the web',
detail: 'query: best pizza in town',
});
expect(screen.getByText('Searching the web')).toBeInTheDocument();
expect(screen.getByText('query: best pizza in town')).toBeInTheDocument();
});
it('renders completed stages above with check marks', () => {
const history: ActivityHistoryEntry[] = [
{ stage: 'searching', label: 'Searched the web', startedAt: 0, finishedAt: 100 },
{ stage: 'reading', label: 'Read 3 sources', startedAt: 100, finishedAt: 400 },
];
renderIndicator({
stage: 'writing',
label: null,
history,
});
expect(screen.getByText('Searched the web')).toBeInTheDocument();
expect(screen.getByText('Read 3 sources')).toBeInTheDocument();
expect(screen.getAllByText('✓')).toHaveLength(2);
});
it('hides everything when interrupted', () => {
const { container } = renderIndicator({
stage: 'searching',
label: 'Searching the web',
interrupted: true,
});
expect(container).toBeEmptyDOMElement();
});
it('shows a generic label when stage is present but label is cleared (writing stage)', () => {
renderIndicator({ stage: 'writing', label: null });
expect(screen.getByText('Working…')).toBeInTheDocument();
});
describe('prefers-reduced-motion', () => {
const originalMatchMedia = window.matchMedia;
afterEach(() => {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: originalMatchMedia,
});
});
it('disables the spinner animation when the user prefers reduced motion', () => {
const matchMediaMock = jest.fn().mockImplementation((query: string) => ({
matches: query.includes('prefers-reduced-motion'),
media: query,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
addListener: jest.fn(),
removeListener: jest.fn(),
}));
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: matchMediaMock,
});
renderIndicator({ stage: 'searching', label: 'Searching the web' });
const spinner = screen.getByTestId('activity-spinner');
expect(spinner).toHaveAttribute('data-reduced-motion', 'true');
});
});
it('shows elapsed time after ~10s and "Still working…" after ~30s of silence', () => {
jest.useFakeTimers();
try {
renderIndicator({ stage: 'searching', label: 'Searching the web' });
act(() => {
jest.advanceTimersByTime(11_000);
});
expect(screen.getByText('11s')).toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(20_000);
});
expect(screen.getByText('Still working…')).toBeInTheDocument();
} finally {
jest.useRealTimers();
}
});
});
@@ -0,0 +1,346 @@
import React, { useEffect, useReducer, useRef, useState } from 'react';
import styled, { css, keyframes } from 'styled-components';
import type { ActivityHistoryEntry } from '../../utils/wsFrames';
/** Minimum time a single stage stays visible before advancing to a queued update (#96). */
const MIN_DISPLAY_MS = 400;
const ELAPSED_THRESHOLD_MS = 10_000;
const SILENCE_THRESHOLD_MS = 30_000;
function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState<boolean>(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
return window.matchMedia('(prefers-reduced-motion: reduce)')?.matches ?? false;
});
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return undefined;
const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
if (!mql) return undefined;
const handler = (e: MediaQueryListEvent) => setReduced(e.matches);
if (typeof mql.addEventListener === 'function') {
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}
// Safari < 14 fallback
mql.addListener(handler);
return () => mql.removeListener(handler);
}, []);
return reduced;
}
type QueueItem = { stage: string; label: string | null; detail: string | null };
type IndicatorState = {
current: QueueItem | null;
queue: QueueItem[];
lastIncomingStage: string | null;
};
type IndicatorAction =
| { type: 'INCOMING'; item: QueueItem | null }
| { type: 'ADVANCE' }
| { type: 'RESET' };
const initialIndicatorState: IndicatorState = {
current: null,
queue: [],
lastIncomingStage: null,
};
function indicatorReducer(state: IndicatorState, action: IndicatorAction): IndicatorState {
switch (action.type) {
case 'RESET':
return initialIndicatorState;
case 'INCOMING': {
if (!action.item) return initialIndicatorState;
// Same stage as the last update seen — refresh content in place (label/detail tweak),
// no need to enforce the min-display queue since it isn't a stage transition.
if (action.item.stage === state.lastIncomingStage) {
if (state.queue.length > 0) {
const queue = [...state.queue];
queue[queue.length - 1] = action.item;
return { ...state, queue };
}
return { ...state, current: action.item };
}
if (!state.current) {
return { current: action.item, queue: [], lastIncomingStage: action.item.stage };
}
return {
...state,
queue: [...state.queue, action.item],
lastIncomingStage: action.item.stage,
};
}
case 'ADVANCE': {
if (state.queue.length === 0) return state;
const [next, ...rest] = state.queue;
return { ...state, current: next, queue: rest };
}
default:
return state;
}
}
const spin = keyframes`
to { transform: rotate(360deg); }
`;
const bounce = keyframes`
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
`;
const Root = styled.div`
display: flex;
flex-direction: column;
gap: 0.35rem;
max-width: 100%;
min-width: 0;
width: 100%;
color: ${({ theme }) => theme.colors.text};
font-size: 0.9rem;
@media (max-width: 360px) {
font-size: 0.85rem;
}
`;
const HistoryList = styled.ul`
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.2rem;
opacity: 0.6;
min-width: 0;
`;
const HistoryItem = styled.li`
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
min-width: 0;
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
`;
const CheckMark = styled.span`
flex-shrink: 0;
color: ${({ theme }) => (theme.darkMode ? '#8ee6a0' : '#2e7d32')};
font-weight: 700;
`;
const CurrentRow = styled.div`
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
`;
const Spinner = styled.span<{ $reducedMotion: boolean }>`
flex-shrink: 0;
width: 14px;
height: 14px;
box-sizing: border-box;
border-radius: 50%;
border: 2px solid currentColor;
border-top-color: transparent;
opacity: 0.7;
${({ $reducedMotion }) =>
$reducedMotion
? css`
animation: none;
`
: css`
animation: ${spin} 0.8s linear infinite;
`}
`;
const TextColumn = styled.div`
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
`;
const LabelText = styled.span`
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
`;
const DetailText = styled.span`
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.78rem;
opacity: 0.65;
margin-top: 0.1rem;
`;
const MetaText = styled.span`
flex-shrink: 0;
font-size: 0.75rem;
opacity: 0.55;
white-space: nowrap;
`;
const DotsRow = styled.div`
display: flex;
align-items: center;
padding: 0.15rem 0;
`;
const Dot = styled.span<{ $delay: string; $reducedMotion: boolean }>`
width: 8px;
height: 8px;
margin: 0 4px;
border-radius: 50%;
background: currentColor;
${({ $reducedMotion, $delay }) =>
$reducedMotion
? css`
opacity: 0.6;
`
: css`
animation: ${bounce} 1.4s infinite ease-in-out both;
animation-delay: ${$delay};
`}
`;
export type ActivityIndicatorProps = {
/** Current stage key from the latest "status" frame, or null before the first update. */
stage: string | null;
label?: string | null;
detail?: string | null;
/** Previously completed stages for this turn, oldest first. */
history?: ActivityHistoryEntry[];
/** Hides the indicator entirely once a stream is interrupted (#96). */
interrupted?: boolean;
};
const ActivityIndicator = ({
stage,
label = null,
detail = null,
history = [],
interrupted = false,
}: ActivityIndicatorProps): JSX.Element | null => {
const [state, dispatch] = useReducer(indicatorReducer, initialIndicatorState);
const reducedMotion = usePrefersReducedMotion();
const overallStartRef = useRef<number | null>(null);
const lastUpdateRef = useRef<number>(Date.now());
const currentSinceRef = useRef<number>(Date.now());
const advanceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [, forceTick] = useState(0);
useEffect(() => {
if (interrupted || stage == null) {
overallStartRef.current = null;
dispatch({ type: 'RESET' });
return;
}
if (overallStartRef.current == null) {
overallStartRef.current = Date.now();
}
lastUpdateRef.current = Date.now();
dispatch({ type: 'INCOMING', item: { stage, label, detail } });
}, [stage, label, detail, interrupted]);
// Reset the per-stage clock whenever the displayed stage changes.
useEffect(() => {
currentSinceRef.current = Date.now();
}, [state.current?.stage]);
// Enforce the minimum display time per stage before advancing the queue.
useEffect(() => {
if (advanceTimerRef.current) {
clearTimeout(advanceTimerRef.current);
advanceTimerRef.current = null;
}
if (state.queue.length === 0) return undefined;
const elapsed = Date.now() - currentSinceRef.current;
const remaining = Math.max(MIN_DISPLAY_MS - elapsed, 0);
advanceTimerRef.current = setTimeout(() => {
dispatch({ type: 'ADVANCE' });
}, remaining);
return () => {
if (advanceTimerRef.current) clearTimeout(advanceTimerRef.current);
};
}, [state.queue.length]);
// Tick once a second while active so elapsed/"still working" copy stays fresh.
useEffect(() => {
if (!state.current) return undefined;
const id = setInterval(() => forceTick((n) => n + 1), 1000);
return () => clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [Boolean(state.current)]);
if (interrupted) return null;
const now = Date.now();
const elapsedMs = overallStartRef.current != null ? now - overallStartRef.current : 0;
const silentMs = now - lastUpdateRef.current;
const showElapsed = state.current != null && elapsedMs >= ELAPSED_THRESHOLD_MS;
const stillWorking = state.current != null && silentMs >= SILENCE_THRESHOLD_MS;
const elapsedLabel = showElapsed ? `${Math.floor(elapsedMs / 1000)}s` : null;
return (
<Root role="status" aria-live="polite">
{history.length > 0 && (
<HistoryList>
{history.map((entry, i) => (
<HistoryItem key={`${entry.stage}-${entry.startedAt}-${i}`}>
<CheckMark aria-hidden="true"></CheckMark>
<span>{entry.label}</span>
</HistoryItem>
))}
</HistoryList>
)}
{state.current ? (
<CurrentRow>
<Spinner
aria-hidden="true"
$reducedMotion={reducedMotion}
data-testid="activity-spinner"
data-reduced-motion={reducedMotion}
/>
<TextColumn>
<LabelText>
{stillWorking ? 'Still working…' : state.current.label || 'Working…'}
</LabelText>
{state.current.detail && (
<DetailText title={state.current.detail}>{state.current.detail}</DetailText>
)}
</TextColumn>
{elapsedLabel && !stillWorking && <MetaText>{elapsedLabel}</MetaText>}
</CurrentRow>
) : (
<DotsRow data-testid="activity-dots-fallback">
<Dot $delay="-0.32s" $reducedMotion={reducedMotion} />
<Dot $delay="-0.16s" $reducedMotion={reducedMotion} />
<Dot $delay="0s" $reducedMotion={reducedMotion} />
</DotsRow>
)}
</Root>
);
};
export default ActivityIndicator;
@@ -116,4 +116,34 @@ describe('ConversationDetailCard', () => {
expect(agentBubble).not.toBeNull();
expect(getComputedStyle(agentBubble!).color).toBe('rgb(255, 255, 255)');
});
it('renders activity label instead of dots when stage is set (#96)', () => {
render(
<MemoryRouter>
<ThemeProvider theme={darkTheme as never}>
<ConversationDetailCard
message=""
user_created={false}
activityStage="searching"
activityLabel="Searching the web"
activityDetail="Taylor Swift wedding"
/>
</ThemeProvider>
</MemoryRouter>,
);
expect(screen.getByText('Searching the web')).toBeInTheDocument();
expect(screen.getByText('Taylor Swift wedding')).toBeInTheDocument();
expect(screen.queryByTestId('activity-dots-fallback')).not.toBeInTheDocument();
});
it('falls back to dots when empty message has no activity stage (#96)', () => {
render(
<MemoryRouter>
<ThemeProvider theme={darkTheme as never}>
<ConversationDetailCard message="" user_created={false} />
</ThemeProvider>
</MemoryRouter>,
);
expect(screen.getByTestId('activity-dots-fallback')).toBeInTheDocument();
});
});
@@ -3,11 +3,12 @@ import Markdown from 'markdown-to-jsx';
import { Link } from 'react-router-dom';
import styled, { keyframes } from 'styled-components';
import { isRagFeatureNotAllowed, parseChatErrorPayload } from '../../utils/chatErrors';
import type { Citation } from '../../utils/wsFrames';
import type { ActivityHistoryEntry, Citation } from '../../utils/wsFrames';
import type { PromptRating } from '../../utils/promptFeedback';
import CustomPreBlock from '../CustomPreBlock/CustomPreBlock';
import SourcesList from '../SourcesList/SourcesList';
import MessageActions from '../MessageActions/MessageActions';
import ActivityIndicator from '../ActivityIndicator/ActivityIndicator';
const fadeIn = keyframes`
from { opacity: 0; transform: translateY(10px); }
@@ -95,30 +96,6 @@ const Bubble = styled.div<{ $isUser: boolean }>`
}
`;
const LoadingDot = styled.div`
width: 8px;
height: 8px;
background: currentColor;
border-radius: 50%;
margin: 0 4px;
animation: bounce 1.4s infinite ease-in-out both;
&:nth-child(1) { animation-delay: -0.32s; }
&:nth-child(2) { animation-delay: -0.16s; }
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
`;
const LoadingContainer = styled.div`
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
`;
const UpgradeNotice = styled.div`
display: flex;
flex-direction: column;
@@ -188,6 +165,12 @@ type ConversationDetailCardProps = {
createdTimestamp?: Date | string | null;
initialRating?: PromptRating | null;
onRatingChange?: (rating: PromptRating | null) => void;
/** Live activity status for the empty-message (streaming placeholder) bubble (#96). */
activityStage?: string | null;
activityLabel?: string | null;
activityDetail?: string | null;
activityHistory?: ActivityHistoryEntry[];
activityInterrupted?: boolean;
};
const MyPlot = ({ format, image }: { format: string; image: string }) => {
@@ -220,6 +203,11 @@ const ConversationDetailCard = ({
createdTimestamp = null,
initialRating = null,
onRatingChange,
activityStage = null,
activityLabel = null,
activityDetail = null,
activityHistory = [],
activityInterrupted = false,
}: ConversationDetailCardProps): JSX.Element => {
const [highlightIndex, setHighlightIndex] = useState<number | null>(null);
@@ -269,11 +257,13 @@ const ConversationDetailCard = ({
return (
<MessageContainer $isUser={false}>
<Bubble $isUser={false}>
<LoadingContainer>
<LoadingDot />
<LoadingDot />
<LoadingDot />
</LoadingContainer>
<ActivityIndicator
stage={activityStage}
label={activityLabel}
detail={activityDetail}
history={activityHistory}
interrupted={activityInterrupted}
/>
</Bubble>
</MessageContainer>
);
@@ -0,0 +1,204 @@
import React, { useContext } from 'react';
import { act, render, screen } from '@testing-library/react';
import { MessageContext, MessageProvider } from './MessageContext';
import { WebSocketContext } from './WebSocketContext';
import { AccountContext } from './AccountContext';
import { ConversationContext } from './ConversationContext';
import { trackEvent } from '../utils/analytics';
jest.mock('../../axiosApi', () => ({
axiosInstance: {
get: jest.fn().mockResolvedValue({ data: [] }),
},
}));
jest.mock('../utils/analytics', () => ({
AnalyticsEvents: {
CONVERSATION_CREATED: 'Conversation Created',
ACTIVITY_STAGE_COMPLETED: 'Activity Stage Completed',
},
trackEvent: jest.fn(),
}));
type Capture = { callback: ((message: string) => void) | null };
const buildHarness = () => {
const capture: Capture = { callback: null };
const subscribe = (_channel: string, cb: (message: string) => void) => {
capture.callback = cb;
};
const unsubscribe = jest.fn();
const Harness = ({ reconnectGeneration = 0 }: { reconnectGeneration?: number }) => (
<AccountContext.Provider
value={{ account: { email: 'test@example.com' } as never, setAccount: () => {} }}
>
<ConversationContext.Provider
value={{
conversations: [],
setConversations: () => {},
selectedConversation: 1,
setSelectedConversation: () => {},
deleteConversation: () => {},
}}
>
<WebSocketContext.Provider
value={[subscribe, unsubscribe, null, jest.fn(), true, 'CONNECTED', reconnectGeneration] as never}
>
<MessageProvider>
<Probe />
</MessageProvider>
</WebSocketContext.Provider>
</ConversationContext.Provider>
</AccountContext.Provider>
);
return { Harness, capture };
};
const Probe = () => {
const ctx = useContext(MessageContext);
return (
<div>
<span data-testid="stage">{ctx.activityStage ?? 'null'}</span>
<span data-testid="label">{ctx.activityLabel ?? 'null'}</span>
<span data-testid="detail">{ctx.activityDetail ?? 'null'}</span>
<span data-testid="history-count">{ctx.activityHistory.length}</span>
<span data-testid="stream-interrupted">{ctx.streamInterrupted ? 'yes' : 'no'}</span>
<button type="button" onClick={() => ctx.clearActivity()}>
clear
</button>
</div>
);
};
const send = (capture: Capture, message: string) => {
act(() => {
capture.callback?.(message);
});
};
const sendStatus = (
capture: Capture,
stage: string,
label: string,
detail: string | null = null,
) => {
send(
capture,
JSON.stringify({ v: 1, type: 'status', data: { stage, label, detail } }),
);
};
describe('MessageContext activity status (#96)', () => {
beforeEach(() => {
(trackEvent as jest.Mock).mockClear();
});
it('applies the first status frame as the current stage/label/detail', async () => {
const { Harness, capture } = buildHarness();
render(<Harness />);
await act(async () => {});
sendStatus(capture, 'searching', 'Searching the web', 'query: cats');
expect(screen.getByTestId('stage')).toHaveTextContent('searching');
expect(screen.getByTestId('label')).toHaveTextContent('Searching the web');
expect(screen.getByTestId('detail')).toHaveTextContent('query: cats');
expect(screen.getByTestId('history-count')).toHaveTextContent('0');
});
it('rolls a completed stage into history and tracks ACTIVITY_STAGE_COMPLETED (no label/detail text)', async () => {
const { Harness, capture } = buildHarness();
render(<Harness />);
await act(async () => {});
sendStatus(capture, 'searching', 'Searching the web');
sendStatus(capture, 'reading', 'Reading sources');
expect(screen.getByTestId('stage')).toHaveTextContent('reading');
expect(screen.getByTestId('label')).toHaveTextContent('Reading sources');
expect(screen.getByTestId('history-count')).toHaveTextContent('1');
expect(trackEvent).toHaveBeenCalledWith(
'Activity Stage Completed',
expect.objectContaining({ stage: 'searching', durationMs: expect.any(Number) }),
);
const call = (trackEvent as jest.Mock).mock.calls.find(
([name]) => name === 'Activity Stage Completed',
);
expect(Object.keys(call![1])).toEqual(['stage', 'durationMs']);
});
it('clears the label display (but not the stage) once the writing stage starts', async () => {
const { Harness, capture } = buildHarness();
render(<Harness />);
await act(async () => {});
sendStatus(capture, 'searching', 'Searching the web');
sendStatus(capture, 'writing', 'Writing the answer');
expect(screen.getByTestId('stage')).toHaveTextContent('writing');
expect(screen.getByTestId('label')).toHaveTextContent('null');
});
it('clears activity on END_OF_THE_STREAM', async () => {
const { Harness, capture } = buildHarness();
render(<Harness />);
await act(async () => {});
sendStatus(capture, 'searching', 'Searching the web');
sendStatus(capture, 'writing', 'Writing the answer');
send(capture, 'END_OF_THE_STREAM_ENDER_GAME_42');
expect(screen.getByTestId('stage')).toHaveTextContent('null');
expect(screen.getByTestId('label')).toHaveTextContent('null');
expect(screen.getByTestId('detail')).toHaveTextContent('null');
expect(screen.getByTestId('history-count')).toHaveTextContent('0');
});
it('ignores malformed status frames without throwing', async () => {
const { Harness, capture } = buildHarness();
render(<Harness />);
await act(async () => {});
send(
capture,
JSON.stringify({ v: 1, type: 'status', data: { stage: 'searching' } }),
);
expect(screen.getByTestId('stage')).toHaveTextContent('null');
});
it('clears activity via clearActivity()', async () => {
const { Harness, capture } = buildHarness();
render(<Harness />);
await act(async () => {});
sendStatus(capture, 'searching', 'Searching the web');
expect(screen.getByTestId('stage')).toHaveTextContent('searching');
act(() => {
screen.getByRole('button', { name: 'clear' }).click();
});
expect(screen.getByTestId('stage')).toHaveTextContent('null');
});
it('clears activity on the streamInterrupted reconnect path', async () => {
const { Harness, capture } = buildHarness();
const { rerender } = render(<Harness reconnectGeneration={0} />);
await act(async () => {});
// Simulate an in-flight generating turn so the reconnect effect treats it as interrupted.
send(capture, 'CONVERSATION_ID');
sendStatus(capture, 'searching', 'Searching the web');
expect(screen.getByTestId('stage')).toHaveTextContent('searching');
rerender(<Harness reconnectGeneration={1} />);
await act(async () => {});
expect(screen.getByTestId('stream-interrupted')).toHaveTextContent('yes');
expect(screen.getByTestId('stage')).toHaveTextContent('null');
});
});
+72 -2
View File
@@ -6,7 +6,12 @@ import { ConversationPrompt, ConversationPromptType } from "../data";
import { axiosInstance } from "../../axiosApi";
import { AxiosResponse } from "axios";
import { AnalyticsEvents, trackEvent } from "../utils/analytics";
import { parseCitationsData, parseVersionedFrame } from "../utils/wsFrames";
import {
ActivityHistoryEntry,
parseCitationsData,
parseStatusData,
parseVersionedFrame,
} from "../utils/wsFrames";
type MessageProviderProps ={
children? : ReactNode;
@@ -21,6 +26,12 @@ type IMessageContext = {
/** True when a stream was interrupted by a socket drop; cleared on refetch/retry. */
streamInterrupted: boolean;
clearStreamInterrupted: () => void;
/** Live activity status from versioned "status" WS frames (#96). */
activityStage: string | null;
activityLabel: string | null;
activityDetail: string | null;
activityHistory: ActivityHistoryEntry[];
clearActivity: () => void;
}
const initialValues = {
@@ -31,6 +42,11 @@ const initialValues = {
isGeneratingMessage: false,
streamInterrupted: false,
clearStreamInterrupted: () => {},
activityStage: null,
activityLabel: null,
activityDetail: null,
activityHistory: [],
clearActivity: () => {},
}
const MessageContext = createContext<IMessageContext>(initialValues);
@@ -47,6 +63,10 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
const [conversationDetails, setConversationDetails] = useState<ConversationPrompt[]>([])
const [isGeneratingMessage, setIsGeneratingMessage] = useState<boolean>(false)
const [streamInterrupted, setStreamInterrupted] = useState<boolean>(false)
const [activityStage, setActivityStage] = useState<string | null>(null)
const [activityLabel, setActivityLabel] = useState<string | null>(null)
const [activityDetail, setActivityDetail] = useState<string | null>(null)
const [activityHistory, setActivityHistory] = useState<ActivityHistoryEntry[]>([])
const messageRef = useRef('')
const messageResponsePart = useRef(0);
@@ -55,9 +75,19 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
const isGeneratingRef = useRef(false)
const prevReconnectGenerationRef = useRef(0)
const refetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
/** Tracks the in-progress activity stage so we can roll it into history with a duration. */
const activeStageRef = useRef<{ stage: string; label: string; startedAt: number } | null>(null)
const clearStreamInterrupted = () => setStreamInterrupted(false)
const clearActivity = () => {
activeStageRef.current = null
setActivityStage(null)
setActivityLabel(null)
setActivityDetail(null)
setActivityHistory([])
}
async function GetConversationDetails(conversationId: number | undefined) {
if (!conversationId) {
setConversationDetails([])
@@ -142,6 +172,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
setIsGeneratingMessage(false)
setStateMessage('')
setStreamInterrupted(true)
clearActivity()
const details = [...conversationRef.current]
if (details.length > 0) {
@@ -187,8 +218,41 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
conversationRef.current = details
setConversationDetails(details)
schedulePostStreamRefetch()
} else if (frame.type === 'status') {
const statusData = parseStatusData(frame.data)
if (statusData) {
const now = Date.now()
const previous = activeStageRef.current
if (previous && previous.stage !== statusData.stage) {
const durationMs = now - previous.startedAt
setActivityHistory((history) => [
...history,
{
stage: previous.stage,
label: previous.label,
startedAt: previous.startedAt,
finishedAt: now,
},
])
trackEvent(AnalyticsEvents.ACTIVITY_STAGE_COMPLETED, {
stage: previous.stage,
durationMs,
})
}
activeStageRef.current = {
stage: statusData.stage,
label: statusData.label,
startedAt: previous && previous.stage === statusData.stage
? previous.startedAt
: now,
}
setActivityStage(statusData.stage)
// Tokens take over the label once the model starts writing.
setActivityLabel(statusData.stage === 'writing' ? null : statusData.label)
setActivityDetail(statusData.detail ?? null)
}
}
// status / unknown types: no-op (compatible with #96)
// other unknown types: no-op
return
}
@@ -208,6 +272,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
isGeneratingRef.current = false
setIsGeneratingMessage(false)
setStreamInterrupted(false)
clearActivity()
schedulePostStreamRefetch()
}
else if (message === 'START_OF_THE_STREAM_ENDER_GAME_42'){
@@ -263,6 +328,11 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
isGeneratingMessage,
streamInterrupted,
clearStreamInterrupted,
activityStage,
activityLabel,
activityDetail,
activityHistory,
clearActivity,
}}>
{children}
</MessageContext.Provider>
@@ -370,6 +370,11 @@ const AsyncDashboardInner = (): JSX.Element => {
setConversationDetails,
stateMessage,
streamInterrupted,
activityStage,
activityLabel,
activityDetail,
activityHistory,
clearActivity,
} = useContext(MessageContext);
const conversationRef = useRef(conversationDetails);
@@ -450,6 +455,7 @@ const AsyncDashboardInner = (): JSX.Element => {
return;
}
try {
clearActivity();
const tempConversations: ConversationPrompt[] = [
...conversationDetails,
new ConversationPrompt({ message: trimmedPrompt, user_created: true }),
@@ -606,6 +612,11 @@ const AsyncDashboardInner = (): JSX.Element => {
onRatingChange={(rating) =>
updatePromptRating(convo_detail.id, rating)
}
activityStage={isLiveStream ? activityStage : null}
activityLabel={isLiveStream ? activityLabel : null}
activityDetail={isLiveStream ? activityDetail : null}
activityHistory={isLiveStream ? activityHistory : []}
activityInterrupted={isLiveStream && streamInterrupted}
/>
);
})
+2
View File
@@ -26,6 +26,7 @@ const queue: QueuedCall[] = [];
* | Subscription Cancel Started | Cancel intent (#75) |
* | Account Delete Started / Success / Failed | Self-delete (#34 companion) |
* | Message Copied / Rated / Rating Reason / Exported | Message actions (#97) |
* | Activity Stage Completed | Live activity status stage rolled over (#96) |
*/
export const AnalyticsEvents = {
LOGIN_SUCCESS: 'Login Success',
@@ -51,6 +52,7 @@ export const AnalyticsEvents = {
MESSAGE_RATED: 'Message Rated',
MESSAGE_RATING_REASON: 'Message Rating Reason',
MESSAGE_EXPORTED: 'Message Exported',
ACTIVITY_STAGE_COMPLETED: 'Activity Stage Completed',
} as const;
export type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];
+46
View File
@@ -1,5 +1,6 @@
import {
parseCitationsData,
parseStatusData,
parseVersionedFrame,
} from './wsFrames';
@@ -49,4 +50,49 @@ describe('wsFrames', () => {
expect(parseCitationsData(null)).toEqual([]);
expect(parseCitationsData('nope')).toEqual([]);
});
it('parses a status frame', () => {
const raw = JSON.stringify({
v: 1,
type: 'status',
data: { stage: 'searching', label: 'Searching the web', detail: 'query: cats' },
});
const frame = parseVersionedFrame(raw);
expect(frame?.type).toBe('status');
expect(parseStatusData(frame!.data)).toEqual({
stage: 'searching',
label: 'Searching the web',
detail: 'query: cats',
});
});
it('parses status data with no detail as null', () => {
expect(parseStatusData({ stage: 'writing', label: 'Writing the answer' })).toEqual({
stage: 'writing',
label: 'Writing the answer',
detail: null,
});
});
it('passes through unknown/future stage values verbatim', () => {
expect(
parseStatusData({ stage: 'some_future_stage', label: 'Doing something new' }),
).toEqual({
stage: 'some_future_stage',
label: 'Doing something new',
detail: null,
});
});
it('returns null for malformed status data without throwing', () => {
expect(parseStatusData(null)).toBeNull();
expect(parseStatusData(undefined)).toBeNull();
expect(parseStatusData('nope')).toBeNull();
expect(parseStatusData([])).toBeNull();
expect(parseStatusData({})).toBeNull();
expect(parseStatusData({ stage: 'searching' })).toBeNull();
expect(parseStatusData({ label: 'Searching the web' })).toBeNull();
expect(parseStatusData({ stage: 123, label: 'Searching' })).toBeNull();
expect(parseStatusData({ stage: 'searching', label: 42 })).toBeNull();
});
});
+23
View File
@@ -39,6 +39,29 @@ export function parseVersionedFrame(raw: string): VersionedFrame | null {
return null;
}
export type StatusFrameData = {
stage: string;
label: string;
detail?: string | null;
};
export type ActivityHistoryEntry = {
stage: string;
label: string;
startedAt: number;
finishedAt: number;
};
export function parseStatusData(data: unknown): StatusFrameData | null {
if (!data || typeof data !== 'object') return null;
const obj = data as Record<string, unknown>;
if (typeof obj.stage !== 'string' || !obj.stage) return null;
// label may be empty (e.g. stage "writing" — tokens take over)
if (typeof obj.label !== 'string') return null;
const detail = typeof obj.detail === 'string' ? obj.detail : null;
return { stage: obj.stage, label: obj.label, detail };
}
export function parseCitationsData(data: unknown): Citation[] {
if (!Array.isArray(data)) return [];
return data