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();