mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
⏱️ 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:
parent
5a8700643c
commit
e0d5e11cdf
15 changed files with 441 additions and 12 deletions
72
client/src/components/Chat/Messages/Elapsed.tsx
Normal file
72
client/src/components/Chat/Messages/Elapsed.tsx
Normal 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;
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
135
client/src/components/Chat/Messages/__tests__/Elapsed.spec.tsx
Normal file
135
client/src/components/Chat/Messages/__tests__/Elapsed.spec.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ jest.mock('~/store', () => ({
|
|||
default: {
|
||||
isTemporary: 'isTemporary',
|
||||
isSubmittingFamily: () => 'isSubmitting',
|
||||
submissionStartFamily: () => 'submissionStart',
|
||||
showStopButtonByIndex: () => 'showStopButton',
|
||||
pendingManualSkillsByConvoId: () => 'pendingManualSkills',
|
||||
pendingQuotesByConvoId: () => 'pendingQuotes',
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: '١' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue