LibreChat/e2e/specs/mock/hover-actions.spec.ts
Danny Avila e0d5e11cdf
⏱️ 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.
2026-08-24 11:36:36 -04:00

193 lines
8.3 KiB
TypeScript

import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import {
MOCK_ENDPOINTS,
NEW_CHAT_PATH,
messagesView,
selectMockEndpoint,
sendMessage,
sendMessageAndWaitForCompletion,
} from './helpers';
/**
* Regression guard for the actions offered on a half-written response.
*
* Edit and fork cannot act on a message that is still streaming, so the toolbar
* omits them outright rather than rendering them disabled: the shared Button's
* `disabled:opacity-50` (specificity 0,2,0) outranks a plain `opacity-0` (0,1,0)
* and would repaint a dimmed ghost of the hidden action. Asserting absence is
* what makes that ghost unrepresentable, and jsdom resolves no stylesheet, so
* the guard lives here rather than in Jest.
*/
const uniqueLabel = (prefix: string) =>
`${prefix}-${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
const userTurn = (page: Page) =>
messagesView(page)
.locator('.message-render')
.filter({ has: page.locator('.user-turn') })
.last();
const assistantTurn = (page: Page) =>
messagesView(page)
.locator('.message-render')
.filter({ has: page.locator('.agent-turn') })
.last();
const stopButton = (page: Page) => page.getByRole('button', { name: 'Stop generating' });
test.describe('message hover actions', () => {
test('withholds inapplicable actions while a generation streams', async ({ page }) => {
test.setTimeout(120000);
const label = uniqueLabel('hover-edit');
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
const run = await sendMessage(page, `E2E_SLOW_REPLY:${label}`);
expect(run.ok()).toBeTruthy();
await expect(messagesView(page).getByText('chunk-010')).toBeVisible({ timeout: 15000 });
const streaming = assistantTurn(page);
const streamingEdit = streaming.locator('button[id^="edit-"]');
const streamingFork = streaming.getByRole('button', { name: 'Open Fork Menu' });
/** Pin the window: if the stream already settled, every assertion below
* would be checking the wrong state and pass for the wrong reason. */
await expect(stopButton(page)).toBeVisible();
/** Copying half a sentence is never what the reader wants, so the response
* offers nothing at all until it settles. */
const streamingCopy = streaming.getByRole('button', { name: 'Copy to clipboard' });
await expect(streamingCopy).toHaveCount(0);
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". */
await expect(userTurn(page).locator('button[id^="edit-"]')).toBeEnabled();
/** ...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();
});
/**
* A trigger whose surface is open must survive the pointer leaving the row,
* or the editor and the fork popover end up anchored to an invisible button.
*
* Both assertions deliberately move focus out of the row first. `.message-render`
* carries the `group`, so an editor focused inside it satisfies
* `group-focus-within:opacity-100` on its own: asserting while the textarea still
* holds focus passes whether or not the active state is honoured.
*/
test('keeps a triggered action visible once the pointer leaves the row', async ({ page }) => {
test.setTimeout(120000);
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
/** A second turn demotes the first row out of `isLast`, the only state that
* fades the actions at all. */
expect((await sendMessageAndWaitForCompletion(page, 'First turn.')).ok()).toBeTruthy();
expect((await sendMessageAndWaitForCompletion(page, 'Second turn.')).ok()).toBeTruthy();
const row = messagesView(page)
.locator('.message-render')
.filter({ has: page.locator('.user-turn') })
.first();
const editButton = row.locator('button[id^="edit-"]');
const forkButton = row.getByRole('button', { name: 'Open Fork Menu' });
/** Baseline: an idle action really does fade, so the assertions below are
* measuring the active state rather than a row that never hides anything. */
await row.hover();
await expect(editButton).toBeEnabled();
await page.mouse.move(0, 0);
await expect(editButton).toHaveCSS('opacity', '0');
await row.hover();
await editButton.click();
await expect(row.getByTestId('message-text-editor')).toBeVisible();
await page.locator('body').click({ position: { x: 5, y: 5 } });
await page.mouse.move(0, 0);
await expect(row.getByTestId('message-text-editor')).toBeVisible();
await expect(editButton).toHaveCSS('opacity', '1');
/** Escape only lands while the textarea holds focus, and the pointer left the
* row several steps ago, so close the editor through its own control. */
await row.hover();
await row.getByRole('button', { name: 'Cancel' }).click();
await expect(row.getByTestId('message-text-editor')).toHaveCount(0);
await page.mouse.move(0, 0);
await expect(forkButton).toHaveCSS('opacity', '0');
/** The fork popover is portalled, so the row holds no focus while it is open. */
await row.hover();
await forkButton.click();
await page.mouse.move(0, 0);
await expect(forkButton).toHaveCSS('opacity', '1');
});
/**
* Holding only the trigger open leaves the rest of the toolbar faded, so the row
* reads as a single floating button while its surface is open. Any active action
* keeps every sibling opaque.
*/
test('keeps the whole toolbar visible while one action is open', async ({ page }) => {
test.setTimeout(120000);
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
expect((await sendMessageAndWaitForCompletion(page, 'First turn.')).ok()).toBeTruthy();
expect((await sendMessageAndWaitForCompletion(page, 'Second turn.')).ok()).toBeTruthy();
const row = messagesView(page)
.locator('.message-render')
.filter({ has: page.locator('.user-turn') })
.first();
const editButton = row.locator('button[id^="edit-"]');
const forkButton = row.getByRole('button', { name: 'Open Fork Menu' });
const copyButton = row.getByRole('button', { name: 'Copy to clipboard' });
await row.hover();
await expect(editButton).toBeEnabled();
await page.mouse.move(0, 0);
await expect(copyButton).toHaveCSS('opacity', '0');
await expect(forkButton).toHaveCSS('opacity', '0');
await row.hover();
await forkButton.click();
await page.mouse.move(0, 0);
await expect(forkButton).toHaveCSS('opacity', '1');
await expect(copyButton).toHaveCSS('opacity', '1');
await expect(editButton).toHaveCSS('opacity', '1');
/** Closing by Escape rather than the trigger is the path that used to strand the
* fork button in its active state, which would now pin the whole toolbar open. */
/** Closing by Escape rather than the trigger is the path that used to strand the
* fork button in its active state, which would now pin the whole toolbar open.
* Escape hands focus back to the trigger, so drop it before measuring the fade
* or `group-focus-within` keeps the row lit on its own. */
await page.keyboard.press('Escape');
await expect(page.locator('.popover-animate')).toHaveCount(0);
await page.locator('body').click({ position: { x: 5, y: 5 } });
await page.mouse.move(0, 0);
await expect(copyButton).toHaveCSS('opacity', '0');
await expect(forkButton).toHaveCSS('opacity', '0');
});
});