From e0d5e11cdf20b4414ba83135ed013d71364bb01b Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 24 Aug 2026 11:36:36 -0400 Subject: [PATCH] =?UTF-8?q?=E2=8F=B1=EF=B8=8F=20feat:=20Show=20Elapsed=20T?= =?UTF-8?q?ime=20Under=20the=20Streaming=20Response=20(#15167)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ⏱️ 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. --- .../src/components/Chat/Messages/Elapsed.tsx | 72 ++++++++++ .../components/Chat/Messages/MessageParts.tsx | 8 ++ .../Chat/Messages/__tests__/Elapsed.spec.tsx | 135 ++++++++++++++++++ .../__tests__/HoverActions.streaming.spec.tsx | 97 +++++++++++-- .../Chat/Messages/ui/MessageRender.tsx | 8 ++ .../src/components/Messages/ContentRender.tsx | 8 ++ .../useChatFunctions.regenerate.spec.tsx | 1 + client/src/hooks/Chat/useChatFunctions.ts | 2 + client/src/hooks/SSE/useEventHandlers.ts | 11 ++ client/src/hooks/SSE/useResumeOnLoad.ts | 7 + client/src/locales/en/translation.json | 4 + client/src/store/families.ts | 17 +++ .../utils/__tests__/runStepDuration.spec.ts | 38 ++++- client/src/utils/runStepDuration.ts | 37 +++++ e2e/specs/mock/hover-actions.spec.ts | 8 ++ 15 files changed, 441 insertions(+), 12 deletions(-) create mode 100644 client/src/components/Chat/Messages/Elapsed.tsx create mode 100644 client/src/components/Chat/Messages/__tests__/Elapsed.spec.tsx diff --git a/client/src/components/Chat/Messages/Elapsed.tsx b/client/src/components/Chat/Messages/Elapsed.tsx new file mode 100644 index 0000000000..913f75b81b --- /dev/null +++ b/client/src/components/Chat/Messages/Elapsed.tsx @@ -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 ( + + + {localize(labels.announcedKey, labels.announcedValues)} + + ); +}); + +export default Elapsed; diff --git a/client/src/components/Chat/Messages/MessageParts.tsx b/client/src/components/Chat/Messages/MessageParts.tsx index 620e642eba..1afd543e44 100644 --- a/client/src/components/Chat/Messages/MessageParts.tsx +++ b/client/src/components/Chat/Messages/MessageParts.tsx @@ -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, + }) && } void) { + return render( + + + , + ); +} + +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( + set(store.submissionStartFamily(0), start)}> + + , + ); + + expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^30s$/); + + view.rerender( + set(store.submissionStartFamily(0), start)}> + {null} + , + ); + expect(screen.queryByTestId('stream-elapsed')).toBeNull(); + + advance(5_000); + view.rerender( + set(store.submissionStartFamily(0), start)}> + + , + ); + 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({null}); + 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); + }); +}); diff --git a/client/src/components/Chat/Messages/__tests__/HoverActions.streaming.spec.tsx b/client/src/components/Chat/Messages/__tests__/HoverActions.streaming.spec.tsx index ed0bb12166..e091805766 100644 --- a/client/src/components/Chat/Messages/__tests__/HoverActions.streaming.spec.tsx +++ b/client/src/components/Chat/Messages/__tests__/HoverActions.streaming.spec.tsx @@ -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 }) =>
{text}
, + default: ({ text }: { text: string }) => { + mockContentRenderCount += 1; + return
{text}
; + }, })); jest.mock('~/components/Chat/Messages/Content/ContentParts', () => ({ __esModule: true, - default: ({ content }: { content?: TMessage['content'] }) => ( -
{JSON.stringify(content ?? [])}
- ), + default: ({ content }: { content?: TMessage['content'] }) => { + mockContentRenderCount += 1; + return
{JSON.stringify(content ?? [])}
; + }, })); 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, - [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( [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( - + , @@ -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(); + } + }); }); diff --git a/client/src/components/Chat/Messages/ui/MessageRender.tsx b/client/src/components/Chat/Messages/ui/MessageRender.tsx index 110dea397b..355ae59c2c 100644 --- a/client/src/components/Chat/Messages/ui/MessageRender.tsx +++ b/client/src/components/Chat/Messages/ui/MessageRender.tsx @@ -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, + }) && } + {shouldShowElapsed({ + isSubmitting, + isLatestMessage, + isCreatedByUser: msg.isCreatedByUser, + siblingIdx, + siblingCount, + }) && } ({ default: { isTemporary: 'isTemporary', isSubmittingFamily: () => 'isSubmitting', + submissionStartFamily: () => 'submissionStart', showStopButtonByIndex: () => 'showStopButton', pendingManualSkillsByConvoId: () => 'pendingManualSkills', pendingQuotesByConvoId: () => 'pendingQuotes', diff --git a/client/src/hooks/Chat/useChatFunctions.ts b/client/src/hooks/Chat/useChatFunctions.ts index b34058bb76..195d23a557 100644 --- a/client/src/hooks/Chat/useChatFunctions.ts +++ b/client/src/hooks/Chat/useChatFunctions.ts @@ -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 }); }; diff --git a/client/src/hooks/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts index d1b7d4f247..1550c24e06 100644 --- a/client/src/hooks/SSE/useEventHandlers.ts +++ b/client/src/hooks/SSE/useEventHandlers.ts @@ -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, ], ); diff --git a/client/src/hooks/SSE/useResumeOnLoad.ts b/client/src/hooks/SSE/useResumeOnLoad.ts index f0b0f7653f..f68baee934 100644 --- a/client/src/hooks/SSE/useResumeOnLoad.ts +++ b/client/src/hooks/SSE/useResumeOnLoad.ts @@ -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, diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 29743ca1a3..b66e186052 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -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", diff --git a/client/src/store/families.ts b/client/src/store/families.ts index 122db2c160..53a9e1522e 100644 --- a/client/src/store/families.ts +++ b/client/src/store/families.ts @@ -38,6 +38,22 @@ const submissionByIndex = atomFamily({ 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({ + 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, diff --git a/client/src/utils/__tests__/runStepDuration.spec.ts b/client/src/utils/__tests__/runStepDuration.spec.ts index c4af8e3c5f..1288048ec5 100644 --- a/client/src/utils/__tests__/runStepDuration.spec.ts +++ b/client/src/utils/__tests__/runStepDuration.spec.ts @@ -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: '١' }, + }); + }); +}); diff --git a/client/src/utils/runStepDuration.ts b/client/src/utils/runStepDuration.ts index 964fff1c85..35d314fe10 100644 --- a/client/src/utils/runStepDuration.ts +++ b/client/src/utils/runStepDuration.ts @@ -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) }, + }; +} diff --git a/e2e/specs/mock/hover-actions.spec.ts b/e2e/specs/mock/hover-actions.spec.ts index 4a53bd51d8..98ef71e0a8 100644 --- a/e2e/specs/mock/hover-actions.spec.ts +++ b/e2e/specs/mock/hover-actions.spec.ts @@ -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();