⏱️ feat: Show Elapsed Time Under the Streaming Response (#15167)

* ⏱️ feat: Show Elapsed Time Under the Streaming Response

A minimalist elapsed-time indicator (5s, then 1m 5s) occupies the footer
slot the hover actions vacate while a response generates, anchored to a
per-index submission-start timestamp so remounts (new-conversation id
hydration, navigation) never reset it. The once-per-second tick is
component-local state, so streaming rows never re-render on its account.

* 🧭 fix: Keep the Original Elapsed Baseline When Reattaching a Stream

Codex round 1: resume-on-load restamped the anchor at reattach time, so
navigating away from a still-streaming conversation and back restarted
the reading at 0s — the exact reset the atom exists to prevent. Resume
paths now leave the anchor alone: a same-session return keeps its ask
baseline, and a reload (atom empty) falls back to the indicator's mount
time, which is what the stamp produced anyway.

* 🪗 fix: Scope the Elapsed Timer to Its Own Generation, Localized and Spoken

Codex round 2, all four findings:

- The anchor is cleared on every terminal path (final, error, abort
  fallback), and resume-on-load only fills an empty one — so a run another
  client started never inherits a stale baseline, while a same-session
  reattach still keeps its original start.
- The indicator additionally requires the newest sibling position:
  latestMessageId follows the selected branch, so a settled older sibling
  paged to mid-regeneration satisfied the latest+submitting gate and got a
  counting timer under settled content.
- Visible digits now come from the shared run-step duration formatter
  (Intl.NumberFormat per locale), replacing the raw-number interpolations.
- The compact reading is aria-hidden with a spoken 'N seconds elapsed'
  equivalent beside it, per the house duration-label pattern; still no
  aria-live, so the tick never announces.
This commit is contained in:
Danny Avila 2026-08-24 11:36:36 -04:00 committed by GitHub
parent 5a8700643c
commit e0d5e11cdf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 441 additions and 12 deletions

View file

@ -0,0 +1,72 @@
import { memo, useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { useTranslation } from 'react-i18next';
import { getElapsedDurationLabels } from '~/utils';
import { useLocalize } from '~/hooks';
import store from '~/store';
const elapsedSeconds = (start: number): number =>
Math.max(0, Math.floor((Date.now() - start) / 1000));
type ElapsedVisibility = {
isSubmitting: boolean;
isLatestMessage: boolean;
isCreatedByUser?: boolean;
siblingIdx?: number;
siblingCount?: number;
};
/**
* Whether the elapsed indicator belongs under a row: the latest assistant row
* while its generation streams but only at the newest sibling position.
* `latestMessageId` follows the SELECTED branch, so during a regeneration a
* settled older sibling the reader paged to mid-stream would otherwise satisfy
* the same latest+submitting gate the withheld hover actions use, and a
* counting timer under settled content misleads in a way hidden buttons don't.
*/
export const shouldShowElapsed = ({
isSubmitting,
isLatestMessage,
isCreatedByUser,
siblingIdx,
siblingCount,
}: ElapsedVisibility): boolean =>
isSubmitting &&
isLatestMessage &&
isCreatedByUser !== true &&
(siblingIdx ?? 0) === (siblingCount ?? 1) - 1;
/**
* Elapsed generation time under the actively streaming response, in the footer
* slot the hover actions occupy once the answer lands. The once-per-second tick
* is component-local state, so parents that re-render per streaming token never
* re-render on its account. The compact reading is hidden from assistive
* technology in favor of a spoken equivalent; neither is an `aria-live` region,
* so the tick never announces.
*/
const Elapsed = memo(function Elapsed({ index }: { index: number }) {
const localize = useLocalize();
const { i18n } = useTranslation();
const submissionStart = useRecoilValue(store.submissionStartFamily(index));
const [mountTime] = useState(() => Date.now());
const start = submissionStart ?? mountTime;
const [seconds, setSeconds] = useState(() => elapsedSeconds(start));
useEffect(() => {
setSeconds(elapsedSeconds(start));
const intervalId = setInterval(() => setSeconds(elapsedSeconds(start)), 1000);
return () => clearInterval(intervalId);
}, [start]);
const labels = getElapsedDurationLabels(seconds * 1000, i18n.language);
return (
<span className="flex items-center text-text-secondary">
<span aria-hidden="true" className="tabular-nums" data-testid="stream-elapsed">
{localize(labels.key, labels.values)}
</span>
<span className="sr-only">{localize(labels.announcedKey, labels.announcedValues)}</span>
</span>
);
});
export default Elapsed;

View file

@ -14,6 +14,7 @@ import { getHeaderModelName } from '~/components/Chat/Messages/ui/HeaderLabel';
import { revealOnRowHoverClasses, messageFooterClasses } from './styles';
import MessageRow from '~/components/Chat/Messages/ui/MessageRow';
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
import Elapsed, { shouldShowElapsed } from './Elapsed';
import ContentParts from './Content/ContentParts';
import SiblingSwitch from './SiblingSwitch';
import HoverButtons from './HoverButtons';
@ -134,6 +135,13 @@ function MessageParts(props: TMessageProps) {
isSubmitting && messageId === latestMessageId && revealOnRowHoverClasses,
)}
/>
{shouldShowElapsed({
isSubmitting,
isLatestMessage: messageId === latestMessageId,
isCreatedByUser,
siblingIdx,
siblingCount,
}) && <Elapsed index={index} />}
<HoverButtons
index={index}
isEditing={edit}

View file

@ -0,0 +1,135 @@
import React from 'react';
import { RecoilRoot, type MutableSnapshot } from 'recoil';
import { render, screen, act } from '@testing-library/react';
import Elapsed, { shouldShowElapsed } from '~/components/Chat/Messages/Elapsed';
import store from '~/store';
function renderElapsed(initializeState?: (snapshot: MutableSnapshot) => void) {
return render(
<RecoilRoot initializeState={initializeState}>
<Elapsed index={0} />
</RecoilRoot>,
);
}
function advance(ms: number) {
act(() => {
jest.advanceTimersByTime(ms);
});
}
describe('Elapsed', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('renders seconds from the submission start anchor and rolls into minutes', () => {
const start = Date.now() - 5_000;
renderElapsed(({ set }) => set(store.submissionStartFamily(0), start));
expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^5s$/);
expect(screen.getByTestId('stream-elapsed')).toHaveAttribute('aria-hidden', 'true');
expect(screen.getByText('5 seconds elapsed')).toHaveClass('sr-only');
advance(54_000);
expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^59s$/);
advance(1_000);
expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^1m 0s$/);
expect(screen.getByText('1 minute elapsed')).toHaveClass('sr-only');
advance(59_000);
expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^1m 59s$/);
advance(1_000);
expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^2m 0s$/);
});
it('counts from mount when no submission start is recorded', () => {
renderElapsed();
expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^0s$/);
advance(3_000);
expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^3s$/);
});
it('clamps a future anchor to zero instead of going negative', () => {
const start = Date.now() + 60_000;
renderElapsed(({ set }) => set(store.submissionStartFamily(0), start));
expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^0s$/);
advance(61_000);
expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^1s$/);
});
it('continues from the anchored start across an unmount and remount', () => {
const start = Date.now() - 30_000;
const view = render(
<RecoilRoot initializeState={({ set }) => set(store.submissionStartFamily(0), start)}>
<Elapsed index={0} />
</RecoilRoot>,
);
expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^30s$/);
view.rerender(
<RecoilRoot initializeState={({ set }) => set(store.submissionStartFamily(0), start)}>
{null}
</RecoilRoot>,
);
expect(screen.queryByTestId('stream-elapsed')).toBeNull();
advance(5_000);
view.rerender(
<RecoilRoot initializeState={({ set }) => set(store.submissionStartFamily(0), start)}>
<Elapsed index={0} />
</RecoilRoot>,
);
expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^35s$/);
});
it('clears its interval on unmount', () => {
const view = renderElapsed();
const timersWhileMounted = jest.getTimerCount();
expect(timersWhileMounted).toBeGreaterThanOrEqual(1);
view.rerender(<RecoilRoot>{null}</RecoilRoot>);
expect(jest.getTimerCount()).toBe(timersWhileMounted - 1);
});
});
describe('shouldShowElapsed', () => {
const streamingRow = {
isSubmitting: true,
isLatestMessage: true,
isCreatedByUser: false,
siblingIdx: 1,
siblingCount: 2,
};
it('shows under the newest sibling of the streaming latest assistant row', () => {
expect(shouldShowElapsed(streamingRow)).toBe(true);
});
it('shows when sibling metadata is absent (a lone response)', () => {
expect(
shouldShowElapsed({ isSubmitting: true, isLatestMessage: true, isCreatedByUser: false }),
).toBe(true);
});
it('hides under an older sibling the reader paged to mid-stream', () => {
expect(shouldShowElapsed({ ...streamingRow, siblingIdx: 0 })).toBe(false);
});
it('hides for user rows, settled rows, and non-latest rows', () => {
expect(shouldShowElapsed({ ...streamingRow, isCreatedByUser: true })).toBe(false);
expect(shouldShowElapsed({ ...streamingRow, isSubmitting: false })).toBe(false);
expect(shouldShowElapsed({ ...streamingRow, isLatestMessage: false })).toBe(false);
});
});

View file

@ -12,6 +12,7 @@ import Message from '~/components/Chat/Messages/Message';
import store from '~/store';
let mockHoverButtonsRenderCount = 0;
let mockContentRenderCount = 0;
jest.mock('~/components/Chat/Messages/HoverButtons', () => ({
__esModule: true,
@ -23,14 +24,18 @@ jest.mock('~/components/Chat/Messages/HoverButtons', () => ({
jest.mock('~/components/Chat/Messages/Content/MessageContent', () => ({
__esModule: true,
default: ({ text }: { text: string }) => <div data-testid="message-content">{text}</div>,
default: ({ text }: { text: string }) => {
mockContentRenderCount += 1;
return <div data-testid="message-content">{text}</div>;
},
}));
jest.mock('~/components/Chat/Messages/Content/ContentParts', () => ({
__esModule: true,
default: ({ content }: { content?: TMessage['content'] }) => (
<div data-testid="structured-message-content">{JSON.stringify(content ?? [])}</div>
),
default: ({ content }: { content?: TMessage['content'] }) => {
mockContentRenderCount += 1;
return <div data-testid="structured-message-content">{JSON.stringify(content ?? [])}</div>;
},
}));
jest.mock('~/components/Chat/Messages/Content/Parts/AuthorHeader', () => ({
@ -135,7 +140,15 @@ function createQueryClient() {
});
}
function DerivedStreamingRow({ structured = false }: { structured?: boolean }) {
function DerivedStreamingRow({
structured = false,
submitting = true,
siblingIdx = 1,
}: {
structured?: boolean;
submitting?: boolean;
siblingIdx?: number;
}) {
const queryClient = useQueryClient();
const latestMessage = useLatestMessage(0);
const latestMessageId = useLatestMessageId(0);
@ -151,7 +164,7 @@ function DerivedStreamingRow({ structured = false }: { structured?: boolean }) {
latestMessageId: latestMessageId ?? undefined,
latestMessageDepth,
handleContinue: jest.fn(),
isSubmitting: true,
isSubmitting: submitting,
abortScroll: false,
setAbortScroll: jest.fn(),
getMessages: () =>
@ -163,7 +176,7 @@ function DerivedStreamingRow({ structured = false }: { structured?: boolean }) {
);
},
}) as unknown as ReturnType<typeof useChatContext>,
[latestMessageDepth, latestMessageId, queryClient],
[latestMessageDepth, latestMessageId, queryClient, submitting],
);
if (!latestMessage) {
@ -178,7 +191,7 @@ function DerivedStreamingRow({ structured = false }: { structured?: boolean }) {
message={latestMessage}
currentEditId={null}
setCurrentEditId={jest.fn()}
siblingIdx={0}
siblingIdx={siblingIdx}
siblingCount={2}
setSiblingIdx={jest.fn()}
/>
@ -187,7 +200,7 @@ function DerivedStreamingRow({ structured = false }: { structured?: boolean }) {
);
}
function renderStreamingRow(structured = false) {
function renderStreamingRow(structured = false, submitting = true, siblingIdx = 1) {
const queryClient = createQueryClient();
queryClient.setQueryData<TMessage[]>(
[QueryKeys.messages, conversation.conversationId],
@ -196,14 +209,18 @@ function renderStreamingRow(structured = false) {
const initializeState = ({ set }: MutableSnapshot) => {
set(store.conversationByIndex(0), conversation);
set(store.isSubmittingFamily(0), true);
set(store.isSubmittingFamily(0), submitting);
};
render(
<QueryClientProvider client={queryClient}>
<RecoilRoot initializeState={initializeState}>
<MemoryRouter initialEntries={[`/c/${conversation.conversationId}`]}>
<DerivedStreamingRow structured={structured} />
<DerivedStreamingRow
structured={structured}
submitting={submitting}
siblingIdx={siblingIdx}
/>
</MemoryRouter>
</RecoilRoot>
</QueryClientProvider>,
@ -215,6 +232,7 @@ function renderStreamingRow(structured = false) {
describe('streaming hover actions', () => {
beforeEach(() => {
mockHoverButtonsRenderCount = 0;
mockContentRenderCount = 0;
});
it('keeps actions mounted while an optimistic assistant row is replaced', async () => {
@ -284,4 +302,61 @@ describe('streaming hover actions', () => {
expect(screen.getByTestId('hover-buttons').parentElement).toHaveClass('min-h-[31px]');
});
/**
* The elapsed-time indicator fills the footer slot the withheld actions leave
* empty, but only under the response that is actively generating.
*/
it.each([
['a plain text', false],
['a structured', true],
])('shows the elapsed timer under %s streaming response', (_label, structured) => {
renderStreamingRow(structured);
expect(screen.getByTestId('stream-elapsed')).toBeInTheDocument();
});
it('renders no elapsed timer once the row is not submitting', () => {
renderStreamingRow(false, false);
expect(screen.queryByTestId('stream-elapsed')).toBeNull();
});
/**
* `latestMessageId` follows the SELECTED branch, so a settled older sibling
* the reader paged to mid-regeneration satisfies the latest+submitting gate.
* The timer additionally requires the newest sibling position a counting
* timer under settled content misleads in a way withheld buttons don't.
*/
it('renders no elapsed timer under an older sibling selected mid-stream', () => {
renderStreamingRow(false, true, 0);
expect(screen.queryByTestId('stream-elapsed')).toBeNull();
expect(screen.getByTestId('hover-buttons')).toBeInTheDocument();
});
/**
* The timer's once-per-second tick is component-local state: advancing the
* clock must re-render nothing beyond the timer itself, or the indicator
* would tax every streaming frame's neighbors.
*/
it('ticks the elapsed timer without re-rendering content or actions', () => {
jest.useFakeTimers();
try {
renderStreamingRow();
const hoverRenders = mockHoverButtonsRenderCount;
const contentRenders = mockContentRenderCount;
act(() => {
jest.advanceTimersByTime(5_000);
});
expect(screen.getByTestId('stream-elapsed')).toBeInTheDocument();
expect(mockHoverButtonsRenderCount).toBe(hoverRenders);
expect(mockContentRenderCount).toBe(contentRenders);
} finally {
jest.useRealTimers();
}
});
});

View file

@ -9,6 +9,7 @@ import {
getMessageAriaLabel,
} from '~/utils';
import { revealOnRowHoverClasses, messageFooterClasses } from '~/components/Chat/Messages/styles';
import Elapsed, { shouldShowElapsed } from '~/components/Chat/Messages/Elapsed';
import MessageContent from '~/components/Chat/Messages/Content/MessageContent';
import { getHeaderModelName } from '~/components/Chat/Messages/ui/HeaderLabel';
import { useLocalize, useMessageActions, useContentMetadata } from '~/hooks';
@ -180,6 +181,13 @@ const MessageRender = memo(function MessageRender({
isSubmitting && isLatestMessage && revealOnRowHoverClasses,
)}
/>
{shouldShowElapsed({
isSubmitting,
isLatestMessage,
isCreatedByUser: msg.isCreatedByUser,
siblingIdx,
siblingCount,
}) && <Elapsed index={index} />}
<HoverButtons
index={index}
isEditing={edit}

View file

@ -11,6 +11,7 @@ import {
import { revealOnRowHoverClasses, messageFooterClasses } from '~/components/Chat/Messages/styles';
import { useAttachments, useLocalize, useMessageActions, useContentMetadata } from '~/hooks';
import AuthorHeader from '~/components/Chat/Messages/Content/Parts/AuthorHeader';
import Elapsed, { shouldShowElapsed } from '~/components/Chat/Messages/Elapsed';
import { getHeaderModelName } from '~/components/Chat/Messages/ui/HeaderLabel';
import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
@ -176,6 +177,13 @@ const ContentRender = memo(function ContentRender({
setSiblingIdx={setSiblingIdx}
className={cn(isSubmitting && isLatestMessage && revealOnRowHoverClasses)}
/>
{shouldShowElapsed({
isSubmitting,
isLatestMessage,
isCreatedByUser: msg.isCreatedByUser,
siblingIdx,
siblingCount,
}) && <Elapsed index={index} />}
<HoverButtons
index={index}
message={msg}

View file

@ -49,6 +49,7 @@ jest.mock('~/store', () => ({
default: {
isTemporary: 'isTemporary',
isSubmittingFamily: () => 'isSubmitting',
submissionStartFamily: () => 'submissionStart',
showStopButtonByIndex: () => 'showStopButton',
pendingManualSkillsByConvoId: () => 'pendingManualSkills',
pendingQuotesByConvoId: () => 'pendingQuotes',

View file

@ -219,6 +219,7 @@ export default function useChatFunctions({
const isTemporary = useRecoilValue(store.isTemporary);
const { getExpiry } = useUserKey(immutableConversation?.endpoint ?? '');
const setIsSubmitting = useSetRecoilState(store.isSubmittingFamily(index));
const setSubmissionStart = useSetRecoilState(store.submissionStartFamily(index));
const setShowStopButton = useSetRecoilState(store.showStopButtonByIndex(index));
const focusRegeneratedResponse = useFocusRegeneratedResponse();
@ -712,6 +713,7 @@ export default function useChatFunctions({
setMessages([...submissionMessages, currentMsg, initialResponse]);
}
setSubmissionStart(Date.now());
setSubmission(submission);
logger.dir('message_stream', submission, { depth: null });
};

View file

@ -342,6 +342,11 @@ export default function useEventHandlers({
const { announcePolite } = useLiveAnnouncer();
const applyAgentTemplate = useApplyAgentTemplate();
const setAbortScroll = useSetRecoilState(store.abortScroll);
/** Cleared on every terminal path below: the elapsed anchor must not outlive
* its generation, or a later externally-started run attached at this index
* would inherit a stale baseline. Navigation teardown deliberately does not
* clear it a reattach to a still-live run keeps its original start. */
const setSubmissionStart = useSetRecoilState(store.submissionStartFamily(runIndex));
const navigate = useNavigate();
const location = useLocation();
@ -734,6 +739,7 @@ export default function useEventHandlers({
isTemporary: _isTemporary = false,
} = submission;
const serverConversation = conversation as TConversation;
setSubmissionStart(null);
try {
// Handle early abort - aborted before any response message was saved.
@ -975,6 +981,7 @@ export default function useEventHandlers({
location.pathname,
applyAgentTemplate,
attachmentHandler,
setSubmissionStart,
restorePendingQuotes,
],
);
@ -983,6 +990,7 @@ export default function useEventHandlers({
({ data, submission }: { data?: TResData; submission: EventSubmission }) => {
const { userMessage, initialResponse } = submission;
setCompleted((prev) => new Set(prev.add(initialResponse.messageId)));
setSubmissionStart(null);
const conversationId =
userMessage.conversationId ?? submission.conversation?.conversationId ?? '';
@ -1075,6 +1083,7 @@ export default function useEventHandlers({
paramId,
newConversation,
setIsSubmitting,
setSubmissionStart,
getMessages,
queryClient,
],
@ -1122,6 +1131,7 @@ export default function useEventHandlers({
console.error('Error in finalHandler during abort:', error);
setShowStopButton(false);
setIsSubmitting(false);
setSubmissionStart(null);
}
return;
} else if (!isAssistantsEndpoint(endpoint)) {
@ -1198,6 +1208,7 @@ export default function useEventHandlers({
newConversation,
setIsSubmitting,
setShowStopButton,
setSubmissionStart,
],
);

View file

@ -246,6 +246,7 @@ export default function useResumeOnLoad(
) {
const queryClient = useQueryClient();
const setSubmission = useSetRecoilState(store.submissionByIndex(runIndex));
const setSubmissionStart = useSetRecoilState(store.submissionStartFamily(runIndex));
const currentSubmission = useRecoilValue(store.submissionByIndex(runIndex));
const currentConversation = useRecoilValue(store.conversationByIndex(runIndex));
const endpoint = currentConversation?.endpoint;
@ -589,6 +590,11 @@ export default function useResumeOnLoad(
});
const messages = getMessages() || [];
/** Fill the elapsed baseline only when none survives: a reattach to the run
* this session already anchored keeps its original start (the atom outlives
* the submission), while a run it never anchored another client's, or any
* attach after the previous run's terminal clear counts from attach. */
setSubmissionStart((prev) => prev ?? Date.now());
// Build submission from resume state if available
if (streamStatus.resumeState) {
@ -658,6 +664,7 @@ export default function useResumeOnLoad(
streamStatus,
getMessages,
setSubmission,
setSubmissionStart,
restoreResumeBranch,
restoreSteerChips,
settleAppliedSteerParts,

View file

@ -1223,6 +1223,10 @@
"com_ui_edited_file": "Edited {{0}}",
"com_ui_editing_file": "Editing {{0}}",
"com_ui_editor_instructions": "Drag the image to reposition • Use zoom slider or buttons to adjust size",
"com_ui_elapsed_announced_minutes": "{{count}} minutes elapsed",
"com_ui_elapsed_announced_minutes_one": "{{count}} minute elapsed",
"com_ui_elapsed_announced_seconds": "{{count}} seconds elapsed",
"com_ui_elapsed_announced_seconds_one": "{{count}} second elapsed",
"com_ui_empty_category": "-",
"com_ui_enabled": "Enabled",
"com_ui_endpoint": "Endpoint",

View file

@ -38,6 +38,22 @@ const submissionByIndex = atomFamily<TSubmission | null, string | number>({
default: null,
});
/**
* Epoch ms baseline for the streaming elapsed indicator at this chat index.
* Stamped when this session submits a generation (every path through `ask`),
* cleared by the terminal handlers when that generation ends, and only FILLED
* never overwritten when resume-on-load attaches a run. The reading
* therefore survives mid-stream remounts (new-conversation id hydration,
* navigating away from a still-live run and back) without a later,
* externally-started generation inheriting a stale baseline. Known residual:
* a run whose end this pane never observed (left mid-stream, finished
* elsewhere) leaves its stamp for the next attach at this index to inherit.
*/
const submissionStartFamily = atomFamily<number | null, string | number>({
key: 'submissionStartByIndex',
default: null,
});
const submissionKeysSelector = selector<(string | number)[]>({
key: 'submissionKeysSelector',
get: ({ get }) => {
@ -683,6 +699,7 @@ export default {
filesByIndex,
presetByIndex,
submissionByIndex,
submissionStartFamily,
textByIndex,
showStopButtonByIndex,
abortScrollFamily,

View file

@ -1,4 +1,4 @@
import { getRunStepDurationLabels } from '../runStepDuration';
import { getRunStepDurationLabels, getElapsedDurationLabels } from '../runStepDuration';
describe('getRunStepDurationLabels', () => {
describe('under ten seconds', () => {
@ -82,3 +82,39 @@ describe('getRunStepDurationLabels', () => {
});
});
});
describe('getElapsedDurationLabels', () => {
it('keeps the run-step visible form and rephrases the spoken form as elapsed', () => {
expect(getElapsedDurationLabels(5_000, 'en')).toEqual({
key: 'com_ui_duration_seconds',
values: { 0: '5' },
announcedKey: 'com_ui_elapsed_announced_seconds',
announcedValues: { count: '5' },
});
});
it('announces the singular form only for exactly one unit', () => {
expect(getElapsedDurationLabels(1_000).announcedKey).toBe(
'com_ui_elapsed_announced_seconds_one',
);
expect(getElapsedDurationLabels(60_000).announcedKey).toBe(
'com_ui_elapsed_announced_minutes_one',
);
});
it('rounds the spoken form to whole minutes past a minute', () => {
expect(getElapsedDurationLabels(90_000, 'en')).toMatchObject({
key: 'com_ui_duration_minutes',
values: { 0: '1', 1: '30' },
announcedKey: 'com_ui_elapsed_announced_minutes',
announcedValues: { count: '2' },
});
});
it('formats every interpolated number for the active locale', () => {
expect(getElapsedDurationLabels(65_000, 'ar-EG')).toMatchObject({
values: { 0: '١', 1: '٥' },
announcedValues: { count: '١' },
});
});
});

View file

@ -96,3 +96,40 @@ export function getRunStepDurationLabels(
announcedValues: { count: formatDurationValue(announcedMinutes, language) },
};
}
/**
* The streaming elapsed indicator's variant of the duration labels: the same
* locale-formatted visible form, with the spoken form phrased for a run still
* in progress ("5 seconds elapsed") rather than a settled one ("took 5
* seconds"). Produced here, beside `getRunStepDurationLabels`, so both forms
* keep sharing one per-locale number formatter.
*/
export function getElapsedDurationLabels(
durationMs: number,
language?: string,
): RunStepDurationLabels {
const { key, values } = getRunStepDurationLabels(durationMs, language);
const totalSeconds = durationMs / MS_PER_SECOND;
if (Math.round(totalSeconds) < SECONDS_PER_MINUTE) {
const seconds = Math.round(totalSeconds);
return {
key,
values,
announcedKey:
seconds === 1 ? 'com_ui_elapsed_announced_seconds_one' : 'com_ui_elapsed_announced_seconds',
announcedValues: { count: formatDurationValue(seconds, language) },
};
}
const announcedMinutes = Math.round(totalSeconds / SECONDS_PER_MINUTE);
return {
key,
values,
announcedKey:
announcedMinutes === 1
? 'com_ui_elapsed_announced_minutes_one'
: 'com_ui_elapsed_announced_minutes',
announcedValues: { count: formatDurationValue(announcedMinutes, language) },
};
}

View file

@ -64,6 +64,13 @@ test.describe('message hover actions', () => {
await expect(streamingEdit).toHaveCount(0);
await expect(streamingFork).toHaveCount(0);
/** What the withheld actions leave behind is the elapsed-time indicator,
* ticking once per second in the slot they reclaim when the answer lands. */
const streamingElapsed = streaming.getByTestId('stream-elapsed');
await expect(streamingElapsed).toHaveText(/^\d+s$/);
const firstReading = (await streamingElapsed.textContent()) ?? '';
await expect(streamingElapsed).not.toHaveText(firstReading, { timeout: 5000 });
/** The settled turn above carries the positive control: the toolbar system is
* mounted and working, so the absences above read as "withheld" rather than
* "nothing rendered yet". */
@ -71,6 +78,7 @@ test.describe('message hover actions', () => {
/** ...and the response earns them back, or "withheld" would just be "gone". */
await expect(stopButton(page)).toBeHidden({ timeout: 60000 });
await expect(streamingElapsed).toHaveCount(0);
await expect(streamingCopy).toBeEnabled();
await expect(streamingEdit).toBeEnabled();
await expect(streamingFork).toBeEnabled();