Replace typing dots with live activity status frames (#96).
Unit Tests / test (pull_request) Successful in 12s
Unit Tests / test (pull_request) Successful in 12s
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.
This commit is contained in:
@@ -31,6 +31,7 @@ Page views: always via `Tracker` / `tracker.js` on prod + beta.
|
|||||||
| Message Rated | `MESSAGE_RATED` | MessageActions | `{ rating: 'up' \| 'down' \| 'cleared' }` |
|
| Message Rated | `MESSAGE_RATED` | MessageActions | `{ rating: 'up' \| 'down' \| 'cleared' }` |
|
||||||
| Message Rating Reason | `MESSAGE_RATING_REASON` | MessageActions | `{ reason, hasComment }` |
|
| Message Rating Reason | `MESSAGE_RATING_REASON` | MessageActions | `{ reason, hasComment }` |
|
||||||
| Message Exported | `MESSAGE_EXPORTED` | MessageActions, AsyncDashboard2 | `{ format, scope }` |
|
| Message Exported | `MESSAGE_EXPORTED` | MessageActions, AsyncDashboard2 | `{ format, scope }` |
|
||||||
|
| Activity Stage Completed | `ACTIVITY_STAGE_COMPLETED` | MessageContext | `{ stage, durationMs }` (no label/detail text) |
|
||||||
|
|
||||||
## Identify
|
## 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(agentBubble).not.toBeNull();
|
||||||
expect(getComputedStyle(agentBubble!).color).toBe('rgb(255, 255, 255)');
|
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 { Link } from 'react-router-dom';
|
||||||
import styled, { keyframes } from 'styled-components';
|
import styled, { keyframes } from 'styled-components';
|
||||||
import { isRagFeatureNotAllowed, parseChatErrorPayload } from '../../utils/chatErrors';
|
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 type { PromptRating } from '../../utils/promptFeedback';
|
||||||
import CustomPreBlock from '../CustomPreBlock/CustomPreBlock';
|
import CustomPreBlock from '../CustomPreBlock/CustomPreBlock';
|
||||||
import SourcesList from '../SourcesList/SourcesList';
|
import SourcesList from '../SourcesList/SourcesList';
|
||||||
import MessageActions from '../MessageActions/MessageActions';
|
import MessageActions from '../MessageActions/MessageActions';
|
||||||
|
import ActivityIndicator from '../ActivityIndicator/ActivityIndicator';
|
||||||
|
|
||||||
const fadeIn = keyframes`
|
const fadeIn = keyframes`
|
||||||
from { opacity: 0; transform: translateY(10px); }
|
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`
|
const UpgradeNotice = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -188,6 +165,12 @@ type ConversationDetailCardProps = {
|
|||||||
createdTimestamp?: Date | string | null;
|
createdTimestamp?: Date | string | null;
|
||||||
initialRating?: PromptRating | null;
|
initialRating?: PromptRating | null;
|
||||||
onRatingChange?: (rating: PromptRating | null) => void;
|
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 }) => {
|
const MyPlot = ({ format, image }: { format: string; image: string }) => {
|
||||||
@@ -220,6 +203,11 @@ const ConversationDetailCard = ({
|
|||||||
createdTimestamp = null,
|
createdTimestamp = null,
|
||||||
initialRating = null,
|
initialRating = null,
|
||||||
onRatingChange,
|
onRatingChange,
|
||||||
|
activityStage = null,
|
||||||
|
activityLabel = null,
|
||||||
|
activityDetail = null,
|
||||||
|
activityHistory = [],
|
||||||
|
activityInterrupted = false,
|
||||||
}: ConversationDetailCardProps): JSX.Element => {
|
}: ConversationDetailCardProps): JSX.Element => {
|
||||||
const [highlightIndex, setHighlightIndex] = useState<number | null>(null);
|
const [highlightIndex, setHighlightIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
@@ -269,11 +257,13 @@ const ConversationDetailCard = ({
|
|||||||
return (
|
return (
|
||||||
<MessageContainer $isUser={false}>
|
<MessageContainer $isUser={false}>
|
||||||
<Bubble $isUser={false}>
|
<Bubble $isUser={false}>
|
||||||
<LoadingContainer>
|
<ActivityIndicator
|
||||||
<LoadingDot />
|
stage={activityStage}
|
||||||
<LoadingDot />
|
label={activityLabel}
|
||||||
<LoadingDot />
|
detail={activityDetail}
|
||||||
</LoadingContainer>
|
history={activityHistory}
|
||||||
|
interrupted={activityInterrupted}
|
||||||
|
/>
|
||||||
</Bubble>
|
</Bubble>
|
||||||
</MessageContainer>
|
</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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,12 @@ import { ConversationPrompt, ConversationPromptType } from "../data";
|
|||||||
import { axiosInstance } from "../../axiosApi";
|
import { axiosInstance } from "../../axiosApi";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { AnalyticsEvents, trackEvent } from "../utils/analytics";
|
import { AnalyticsEvents, trackEvent } from "../utils/analytics";
|
||||||
import { parseCitationsData, parseVersionedFrame } from "../utils/wsFrames";
|
import {
|
||||||
|
ActivityHistoryEntry,
|
||||||
|
parseCitationsData,
|
||||||
|
parseStatusData,
|
||||||
|
parseVersionedFrame,
|
||||||
|
} from "../utils/wsFrames";
|
||||||
|
|
||||||
type MessageProviderProps ={
|
type MessageProviderProps ={
|
||||||
children? : ReactNode;
|
children? : ReactNode;
|
||||||
@@ -21,6 +26,12 @@ type IMessageContext = {
|
|||||||
/** True when a stream was interrupted by a socket drop; cleared on refetch/retry. */
|
/** True when a stream was interrupted by a socket drop; cleared on refetch/retry. */
|
||||||
streamInterrupted: boolean;
|
streamInterrupted: boolean;
|
||||||
clearStreamInterrupted: () => void;
|
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 = {
|
const initialValues = {
|
||||||
@@ -31,6 +42,11 @@ const initialValues = {
|
|||||||
isGeneratingMessage: false,
|
isGeneratingMessage: false,
|
||||||
streamInterrupted: false,
|
streamInterrupted: false,
|
||||||
clearStreamInterrupted: () => {},
|
clearStreamInterrupted: () => {},
|
||||||
|
activityStage: null,
|
||||||
|
activityLabel: null,
|
||||||
|
activityDetail: null,
|
||||||
|
activityHistory: [],
|
||||||
|
clearActivity: () => {},
|
||||||
}
|
}
|
||||||
|
|
||||||
const MessageContext = createContext<IMessageContext>(initialValues);
|
const MessageContext = createContext<IMessageContext>(initialValues);
|
||||||
@@ -47,6 +63,10 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
|||||||
const [conversationDetails, setConversationDetails] = useState<ConversationPrompt[]>([])
|
const [conversationDetails, setConversationDetails] = useState<ConversationPrompt[]>([])
|
||||||
const [isGeneratingMessage, setIsGeneratingMessage] = useState<boolean>(false)
|
const [isGeneratingMessage, setIsGeneratingMessage] = useState<boolean>(false)
|
||||||
const [streamInterrupted, setStreamInterrupted] = 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 messageRef = useRef('')
|
||||||
const messageResponsePart = useRef(0);
|
const messageResponsePart = useRef(0);
|
||||||
@@ -55,9 +75,19 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
|||||||
const isGeneratingRef = useRef(false)
|
const isGeneratingRef = useRef(false)
|
||||||
const prevReconnectGenerationRef = useRef(0)
|
const prevReconnectGenerationRef = useRef(0)
|
||||||
const refetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
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 clearStreamInterrupted = () => setStreamInterrupted(false)
|
||||||
|
|
||||||
|
const clearActivity = () => {
|
||||||
|
activeStageRef.current = null
|
||||||
|
setActivityStage(null)
|
||||||
|
setActivityLabel(null)
|
||||||
|
setActivityDetail(null)
|
||||||
|
setActivityHistory([])
|
||||||
|
}
|
||||||
|
|
||||||
async function GetConversationDetails(conversationId: number | undefined) {
|
async function GetConversationDetails(conversationId: number | undefined) {
|
||||||
if (!conversationId) {
|
if (!conversationId) {
|
||||||
setConversationDetails([])
|
setConversationDetails([])
|
||||||
@@ -142,6 +172,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
|||||||
setIsGeneratingMessage(false)
|
setIsGeneratingMessage(false)
|
||||||
setStateMessage('')
|
setStateMessage('')
|
||||||
setStreamInterrupted(true)
|
setStreamInterrupted(true)
|
||||||
|
clearActivity()
|
||||||
|
|
||||||
const details = [...conversationRef.current]
|
const details = [...conversationRef.current]
|
||||||
if (details.length > 0) {
|
if (details.length > 0) {
|
||||||
@@ -187,8 +218,41 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
|||||||
conversationRef.current = details
|
conversationRef.current = details
|
||||||
setConversationDetails(details)
|
setConversationDetails(details)
|
||||||
schedulePostStreamRefetch()
|
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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
// status / unknown types: no-op (compatible with #96)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// other unknown types: no-op
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,6 +272,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
|||||||
isGeneratingRef.current = false
|
isGeneratingRef.current = false
|
||||||
setIsGeneratingMessage(false)
|
setIsGeneratingMessage(false)
|
||||||
setStreamInterrupted(false)
|
setStreamInterrupted(false)
|
||||||
|
clearActivity()
|
||||||
schedulePostStreamRefetch()
|
schedulePostStreamRefetch()
|
||||||
}
|
}
|
||||||
else if (message === 'START_OF_THE_STREAM_ENDER_GAME_42'){
|
else if (message === 'START_OF_THE_STREAM_ENDER_GAME_42'){
|
||||||
@@ -263,6 +328,11 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
|||||||
isGeneratingMessage,
|
isGeneratingMessage,
|
||||||
streamInterrupted,
|
streamInterrupted,
|
||||||
clearStreamInterrupted,
|
clearStreamInterrupted,
|
||||||
|
activityStage,
|
||||||
|
activityLabel,
|
||||||
|
activityDetail,
|
||||||
|
activityHistory,
|
||||||
|
clearActivity,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</MessageContext.Provider>
|
</MessageContext.Provider>
|
||||||
|
|||||||
@@ -370,6 +370,11 @@ const AsyncDashboardInner = (): JSX.Element => {
|
|||||||
setConversationDetails,
|
setConversationDetails,
|
||||||
stateMessage,
|
stateMessage,
|
||||||
streamInterrupted,
|
streamInterrupted,
|
||||||
|
activityStage,
|
||||||
|
activityLabel,
|
||||||
|
activityDetail,
|
||||||
|
activityHistory,
|
||||||
|
clearActivity,
|
||||||
} = useContext(MessageContext);
|
} = useContext(MessageContext);
|
||||||
|
|
||||||
const conversationRef = useRef(conversationDetails);
|
const conversationRef = useRef(conversationDetails);
|
||||||
@@ -450,6 +455,7 @@ const AsyncDashboardInner = (): JSX.Element => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
clearActivity();
|
||||||
const tempConversations: ConversationPrompt[] = [
|
const tempConversations: ConversationPrompt[] = [
|
||||||
...conversationDetails,
|
...conversationDetails,
|
||||||
new ConversationPrompt({ message: trimmedPrompt, user_created: true }),
|
new ConversationPrompt({ message: trimmedPrompt, user_created: true }),
|
||||||
@@ -606,6 +612,11 @@ const AsyncDashboardInner = (): JSX.Element => {
|
|||||||
onRatingChange={(rating) =>
|
onRatingChange={(rating) =>
|
||||||
updatePromptRating(convo_detail.id, rating)
|
updatePromptRating(convo_detail.id, rating)
|
||||||
}
|
}
|
||||||
|
activityStage={isLiveStream ? activityStage : null}
|
||||||
|
activityLabel={isLiveStream ? activityLabel : null}
|
||||||
|
activityDetail={isLiveStream ? activityDetail : null}
|
||||||
|
activityHistory={isLiveStream ? activityHistory : []}
|
||||||
|
activityInterrupted={isLiveStream && streamInterrupted}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const queue: QueuedCall[] = [];
|
|||||||
* | Subscription Cancel Started | Cancel intent (#75) |
|
* | Subscription Cancel Started | Cancel intent (#75) |
|
||||||
* | Account Delete Started / Success / Failed | Self-delete (#34 companion) |
|
* | Account Delete Started / Success / Failed | Self-delete (#34 companion) |
|
||||||
* | Message Copied / Rated / Rating Reason / Exported | Message actions (#97) |
|
* | Message Copied / Rated / Rating Reason / Exported | Message actions (#97) |
|
||||||
|
* | Activity Stage Completed | Live activity status stage rolled over (#96) |
|
||||||
*/
|
*/
|
||||||
export const AnalyticsEvents = {
|
export const AnalyticsEvents = {
|
||||||
LOGIN_SUCCESS: 'Login Success',
|
LOGIN_SUCCESS: 'Login Success',
|
||||||
@@ -51,6 +52,7 @@ export const AnalyticsEvents = {
|
|||||||
MESSAGE_RATED: 'Message Rated',
|
MESSAGE_RATED: 'Message Rated',
|
||||||
MESSAGE_RATING_REASON: 'Message Rating Reason',
|
MESSAGE_RATING_REASON: 'Message Rating Reason',
|
||||||
MESSAGE_EXPORTED: 'Message Exported',
|
MESSAGE_EXPORTED: 'Message Exported',
|
||||||
|
ACTIVITY_STAGE_COMPLETED: 'Activity Stage Completed',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];
|
export type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
parseCitationsData,
|
parseCitationsData,
|
||||||
|
parseStatusData,
|
||||||
parseVersionedFrame,
|
parseVersionedFrame,
|
||||||
} from './wsFrames';
|
} from './wsFrames';
|
||||||
|
|
||||||
@@ -49,4 +50,49 @@ describe('wsFrames', () => {
|
|||||||
expect(parseCitationsData(null)).toEqual([]);
|
expect(parseCitationsData(null)).toEqual([]);
|
||||||
expect(parseCitationsData('nope')).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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,6 +39,29 @@ export function parseVersionedFrame(raw: string): VersionedFrame | null {
|
|||||||
return 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[] {
|
export function parseCitationsData(data: unknown): Citation[] {
|
||||||
if (!Array.isArray(data)) return [];
|
if (!Array.isArray(data)) return [];
|
||||||
return data
|
return data
|
||||||
|
|||||||
Reference in New Issue
Block a user