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 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.
193 lines
8.3 KiB
TypeScript
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');
|
|
});
|
|
});
|