From de6251e02f3ff0a6cb11446e1f90ff20a157ca02 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Tue, 4 Aug 2026 06:07:27 -0500 Subject: [PATCH] Replace typing dots with live activity status frames (#96). Parse versioned WS status envelopes in MessageContext, render ActivityIndicator with history/min-display/a11y, and fall back to three dots when the backend sends no status frames. --- llm-fe/ANALYTICS.md | 1 + .../ActivityIndicator.test.tsx | 134 +++++++ .../ActivityIndicator/ActivityIndicator.tsx | 346 ++++++++++++++++++ .../ConversationDetailCard.test.tsx | 30 ++ .../ConversationDetailCard.tsx | 50 +-- .../llm-fe/contexts/MessageContext.test.tsx | 204 +++++++++++ llm-fe/src/llm-fe/contexts/MessageContext.tsx | 74 +++- .../pages/AsyncDashboard2/AsyncDashboard2.tsx | 11 + llm-fe/src/llm-fe/utils/analytics.ts | 2 + llm-fe/src/llm-fe/utils/wsFrames.test.ts | 46 +++ llm-fe/src/llm-fe/utils/wsFrames.ts | 23 ++ 11 files changed, 889 insertions(+), 32 deletions(-) create mode 100644 llm-fe/src/llm-fe/components/ActivityIndicator/ActivityIndicator.test.tsx create mode 100644 llm-fe/src/llm-fe/components/ActivityIndicator/ActivityIndicator.tsx create mode 100644 llm-fe/src/llm-fe/contexts/MessageContext.test.tsx diff --git a/llm-fe/ANALYTICS.md b/llm-fe/ANALYTICS.md index a5a84a8..cd90e24 100644 --- a/llm-fe/ANALYTICS.md +++ b/llm-fe/ANALYTICS.md @@ -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 diff --git a/llm-fe/src/llm-fe/components/ActivityIndicator/ActivityIndicator.test.tsx b/llm-fe/src/llm-fe/components/ActivityIndicator/ActivityIndicator.test.tsx new file mode 100644 index 0000000..8924c14 --- /dev/null +++ b/llm-fe/src/llm-fe/components/ActivityIndicator/ActivityIndicator.test.tsx @@ -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, +) => + render( + + + , + ); + +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(); + } + }); +}); diff --git a/llm-fe/src/llm-fe/components/ActivityIndicator/ActivityIndicator.tsx b/llm-fe/src/llm-fe/components/ActivityIndicator/ActivityIndicator.tsx new file mode 100644 index 0000000..09502c4 --- /dev/null +++ b/llm-fe/src/llm-fe/components/ActivityIndicator/ActivityIndicator.tsx @@ -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(() => { + 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(null); + const lastUpdateRef = useRef(Date.now()); + const currentSinceRef = useRef(Date.now()); + const advanceTimerRef = useRef | 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 ( + + {history.length > 0 && ( + + {history.map((entry, i) => ( + + + {entry.label} + + ))} + + )} + + {state.current ? ( + + + ) : ( + + + + + + )} + + ); +}; + +export default ActivityIndicator; diff --git a/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.test.tsx b/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.test.tsx index 3f67536..680f904 100644 --- a/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.test.tsx +++ b/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.test.tsx @@ -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( + + + + + , + ); + 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( + + + + + , + ); + expect(screen.getByTestId('activity-dots-fallback')).toBeInTheDocument(); + }); }); diff --git a/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx b/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx index 2498fc6..c744554 100644 --- a/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx +++ b/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx @@ -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(null); @@ -269,11 +257,13 @@ const ConversationDetailCard = ({ return ( - - - - - + ); diff --git a/llm-fe/src/llm-fe/contexts/MessageContext.test.tsx b/llm-fe/src/llm-fe/contexts/MessageContext.test.tsx new file mode 100644 index 0000000..b1c3034 --- /dev/null +++ b/llm-fe/src/llm-fe/contexts/MessageContext.test.tsx @@ -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 }) => ( + {} }} + > + {}, + selectedConversation: 1, + setSelectedConversation: () => {}, + deleteConversation: () => {}, + }} + > + + + + + + + + ); + + return { Harness, capture }; +}; + +const Probe = () => { + const ctx = useContext(MessageContext); + return ( +
+ {ctx.activityStage ?? 'null'} + {ctx.activityLabel ?? 'null'} + {ctx.activityDetail ?? 'null'} + {ctx.activityHistory.length} + {ctx.streamInterrupted ? 'yes' : 'no'} + +
+ ); +}; + +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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + await act(async () => {}); + + expect(screen.getByTestId('stream-interrupted')).toHaveTextContent('yes'); + expect(screen.getByTestId('stage')).toHaveTextContent('null'); + }); +}); diff --git a/llm-fe/src/llm-fe/contexts/MessageContext.tsx b/llm-fe/src/llm-fe/contexts/MessageContext.tsx index 87eeca4..f2cc115 100644 --- a/llm-fe/src/llm-fe/contexts/MessageContext.tsx +++ b/llm-fe/src/llm-fe/contexts/MessageContext.tsx @@ -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(initialValues); @@ -47,6 +63,10 @@ const MessageProvider = ( {children}: MessageProviderProps) => { const [conversationDetails, setConversationDetails] = useState([]) const [isGeneratingMessage, setIsGeneratingMessage] = useState(false) const [streamInterrupted, setStreamInterrupted] = useState(false) + const [activityStage, setActivityStage] = useState(null) + const [activityLabel, setActivityLabel] = useState(null) + const [activityDetail, setActivityDetail] = useState(null) + const [activityHistory, setActivityHistory] = useState([]) 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 | 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} diff --git a/llm-fe/src/llm-fe/pages/AsyncDashboard2/AsyncDashboard2.tsx b/llm-fe/src/llm-fe/pages/AsyncDashboard2/AsyncDashboard2.tsx index ecef0eb..8c7b936 100644 --- a/llm-fe/src/llm-fe/pages/AsyncDashboard2/AsyncDashboard2.tsx +++ b/llm-fe/src/llm-fe/pages/AsyncDashboard2/AsyncDashboard2.tsx @@ -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} /> ); }) diff --git a/llm-fe/src/llm-fe/utils/analytics.ts b/llm-fe/src/llm-fe/utils/analytics.ts index 29910f7..ba021e1 100644 --- a/llm-fe/src/llm-fe/utils/analytics.ts +++ b/llm-fe/src/llm-fe/utils/analytics.ts @@ -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]; diff --git a/llm-fe/src/llm-fe/utils/wsFrames.test.ts b/llm-fe/src/llm-fe/utils/wsFrames.test.ts index 73647b8..08571b3 100644 --- a/llm-fe/src/llm-fe/utils/wsFrames.test.ts +++ b/llm-fe/src/llm-fe/utils/wsFrames.test.ts @@ -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(); + }); }); diff --git a/llm-fe/src/llm-fe/utils/wsFrames.ts b/llm-fe/src/llm-fe/utils/wsFrames.ts index fb82a29..9b98bf2 100644 --- a/llm-fe/src/llm-fe/utils/wsFrames.ts +++ b/llm-fe/src/llm-fe/utils/wsFrames.ts @@ -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; + 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 -- 2.54.0