LibreChat/e2e/specs/mock/thread-fold.spec.ts
Marco Beretta 4f5c9fec4f
🎨 refactor: adopt the @librechat/client design system (semantic color tokens + component migration) (#13879)
* refactor: unify Tailwind color tokens into a single source

Both the client SPA and @librechat/client Tailwind configs now consume one
createTailwindColors() map, eliminating config drift. Fixes the package-side
build along the way: shadcn tokens are wrapped in hsl(), the broken opacity
helper is removed, and text-destructive/border-destructive/switch-unchecked
plus the gray/green palettes are included.

* refactor: replace hardcoded colors in sidebar conversation list with tokens

Migrate the Conversations sidebar section to semantic tokens: focus rings to
ring-text-primary (keeps >=3:1 contrast in both modes; the mid-gray ring would
fail WCAG 1.4.11 on dark), the active-conversation indicator and hover-fade
gradient to surface/text tokens, and the pagination controls. Removes every
dark: color twin; no behavior change.

* feat: add semantic status-color tokens; migrate MCP status badge

Add a status-color layer (status-{success|info|warning|error|neutral} plus
-subtle variants) to style.css and the unified createTailwindColors map, with a
blue palette for the info hue. Migrate MCPStatusBadge (badges + dots) and
MCPCardActions to the new tokens, removing all hardcoded status colors and
dark: twins. Status colors are now themeable like the rest of the system.

* refactor: migrate status badges to semantic status-color tokens

Migrate the genuine status badges to the status-* tokens: MCPConfigDialog
connection pills (info/warning/neutral/error/success + dot), MemoryUsageBadge
usage levels, and DialogImage quality badge (also gains dark-mode support it
previously lacked). Removes hardcoded colors and dark: twins.

* feat: add Alert component and migrate alert banners to it

Add a reusable Alert component (@librechat/client) with error/success/warning/
info/neutral variants backed by the status-color tokens, default per-variant
icons, and role=alert. Migrate the duplicated colored-div banners to it:
Auth ErrorMessage, RequestPasswordReset success, and the identical error boxes
in ToolSelectDialog, AssistantToolsDialog, and MCPToolSelectDialog.

* refactor: migrate remaining alert banners and error states to tokens

Migrate the last banners to the Alert component: ResetPassword success,
MessageContent connection error, and MemoryInfo storage-full errors. Tokenize
the Agents ErrorDisplay error state in place (icon badge, headings, message,
retry button) since it's a full error state, not a compact callout. Also
tokenize ResetPassword field-validation errors to text-text-destructive
(fixes the low-contrast dark:text-red-900).

* refactor: tokenize SidePanel Memories/Parameters/Bookmarks colors

Delete-confirm buttons to surface-destructive tokens (MemoryCardActions,
BookmarkCardActions), drop redundant text-white on submit Buttons (the variant
already sets it), legacy preset button green hover/focus to submit tokens, and
slider hover borders to border-light. Leaves DynamicCheckbox dark overrides for
a separate pass against the Checkbox component.

* refactor: tokenize Settings danger/destructive buttons

Map the DangerButton, the Data tab destructive actions (RevokeKeys, ClearChats,
DeleteCache), and the DeleteAccount button from bg-red-*/bg-destructive to the
surface-destructive tokens.

* refactor: tokenize Chat file-upload table and upload status colors

Tokenize TemplateTable th/td/border classes (surface-primary, border-light,
text-primary/secondary) and FileUpload status colors (text-text-secondary,
text-text-destructive, text-status-success) plus the import button hover.

* fix: explicit type annotations on Alert for isolatedDeclarations

@librechat/client builds with tsdown --isolatedDeclarations, which requires
exported consts to have explicit type annotations (TS9010). Annotate
alertVariants and Alert to match the Button.tsx pattern.

* refactor: add soft status-border token layer for Alert and lighten dark status foregrounds

* refactor: tokenize Chat menus, popovers, and message surfaces

* refactor: tokenize Chat message content, tool output, and file UI colors

* refactor: add semantic link color token and migrate hyperlinks to it

* refactor: tokenize Files and Auth surfaces, text, borders, and CTAs

* refactor: add accent-primary brand token; tokenize Nav/Input/Prompts/Endpoints colors

* refactor: tokenize Auth brand-green accents, Skills, Sharing, Plugins, MCP colors

* refactor: tokenize OAuth, Share, ui, Bookmarks, Tools, Messages, Web, SharePoint colors

* refactor: final solid-color cleanup (brand-green accents, neutral grays, error text)

* refactor: migrate status callout banners to status-subtle/border tokens

* refactor: tokenize token-usage gauge, mic, and oauth countdown status colors

* refactor: replace shadcn color vocabulary with semantic tokens

Remove the shadcn/ui color tokens (background, foreground, card, popover,
muted, accent, secondary, destructive, input) and migrate every usage to
LibreChat semantic surface/text/border tokens.

Add surface-inverted/text-inverted for the neutral inverted CTA and
surface-fixed/text-fixed for controls that must not flip with the theme
(favicon chips, QR container, carousel arrows). New tokens are defined once
in style.css (light + dark), createTailwindColors, the theme types,
applyTheme and the default/dark theme objects so they stay overridable at
runtime.

Collapse paired dark: color variants into the dark-aware tokens and tokenize
the remaining raw palette and white/black utilities, mapping status colors to
the status-* tokens and legacy ring-black/ring-white focus rings to
ring-text-primary.

Retain the background, primary and ring tokens, which are still referenced by
the SidePanel/Agents and SidePanel/Builder panels (excluded from this pass).

* refactor: tokenize remaining status, neutral and message-text colors

Map the leftover semantic colors to tokens: skill error/dirty states and the
selected-version/selected-skill highlights move to status-warning/status-success,
the global indicator to status-success, and the markdown message text to
text-text-primary. Drop the redundant dark: overrides on the dynamic checkbox,
which the Checkbox primitive already handles.

What remains is intentional and stays raw: categorical color sets (category
icons, principal avatars, per-tool toggle accents), brand marks, the
WCAG-tuned toast severities, code/diagram surfaces, scrims, and text-white on
submit/destructive action surfaces.

* refactor: remove unused CSS rules, dead comments, and duplicate keyframes

Drop ~829 lines of dead styles across style.css (2992->2355) and
mobile.css (323->131): unreferenced classes (legacy token utilities,
orphaned animations, form/prose/scrollbar leftovers), commented-out
blocks, and duplicate/orphaned keyframes. Library-injected (hljs, sandpack,
codemirror, markdown language) and dynamically-applied (scroll-animation,
icon sizes) classes were retained.

* fix: resolve ESLint and frontend test failures

- Format with prettier (Alert, MCPStatusBadge, ApiKeys, Memory, etc.) after
  --no-verify commits skipped the hook
- Localize the 'Or' auth divider (com_auth_or) instead of a bare literal
- Drop dead InvocationModePicker imports in Skill forms; fix VerifyEmail
  unused arg + useEffect deps
- Revert out-of-scope color edits in legacy Files/VectorStore views that
  carried pre-existing untranslated-string lint debt
- Update Memory tests to assert status-* tokens (text-status-error,
  bg-status-error-subtle) instead of the old hardcoded red classes

* refactor: migrate theme tokens to RGB channels for opacity support

Convert semantic + palette CSS variable values in style.css from hex to bare
'R G B' channel triplets, and emit Tailwind colors as
rgb(var(--token) / <alpha-value>) via createTailwindColors. This makes opacity
modifiers (bg-surface-primary/50, bg-border-medium/60, etc.) resolve correctly
and remain dark-aware, fixing ~26 existing usages that previously fell back to a
hardcoded light hex.

- Wrap direct var(--token) color usages in CSS rules as rgb(var(--token))
  (style.css, Dropdown.css, Tooltip.css) and two inline component styles
- applyTheme writes bare triplets to match the new wrapping
- shadcn tokens (HSL) and the JS palette (hex) are unchanged

* fix: prettier formatting after dev rebase

* refactor(client): migrate low-risk primitives to @librechat/client

Swap raw <label>, <textarea>, and native title= tooltips for the
@librechat/client Label, Textarea, and TooltipAnchor components across
Agents, Endpoints settings, Export modal, Prompts, Sharing, and Memory
dialogs. Add localization keys (scroll, sibling navigation, none
selected, select var) for the remaining swap waves.

* refactor(client): migrate buttons, inputs and labels to @librechat/client

Swap raw <button>, <input> and <label> elements for the @librechat/client
Button, Input and Label components across Auth, Chat, Conversations,
Endpoints, Nav, Prompts, Skills, Tools and Web. Preserve bespoke geometry
and behavior via cn className merging, keep data-testid/aria wiring, and
localize previously hardcoded aria-labels. Skip swaps that would break
floating-label animations, tiny bespoke controls or inline-text links.
Add com_ui_reload_page key.

* refactor(client): migrate dialogs, toggles and remaining controls to @librechat/client

Swap behavioral controls for @librechat/client equivalents: HeadlessUI
and legacy dialogs to OGDialog, native checkbox/switch to Checkbox/Switch
(onCheckedChange), and remaining buttons/inputs across Chat, Skills,
Tools, Sharing, Memories and Settings. Convert applicable native title=
tooltips to TooltipAnchor and localize close/scroll aria-labels. Skip
swaps that would break floating-label animations or bespoke select
behavior. Update co-located test mocks to provide the newly-used Button
and cn dependencies.

* style(client): soften dropdown and settings search inputs

Remove the heavy focus ring on the settings search and the searchable
Dropdown's search input, replacing it with a subtle border-light. Make
the search field background inherit the dropdown surface so it matches in
both light and dark mode, and reduce the Dropdown trigger border from
medium to light.

* refactor(client): migrate Agent Builder and Tool Library to @librechat/client

Swap raw buttons, inputs, labels, textareas and native title tooltips for
the @librechat/client Button/Input/Label/Textarea/TooltipAnchor components
across the Agent Builder panel (SidePanel/Agents) and the Tools
marketplace. Remove heavy input focus rings in favor of subtle borders,
soften dropdown trigger borders, and convert stray shadcn/raw colors in
touched lines to semantic tokens. Localize the tool delete aria-label and
toast messages. Update co-located test mocks to provide the newly-used
Button component.

* fix(client): keep Input border static on pointer focus

The pointer-focus override in Field.css used border-color: var(--border-light),
which became an invalid value after the theme moved to RGB channel tokens and
was silently dropped, letting the border fall back to currentColor (text-primary)
on mouse focus. Wrap it in rgb() so mouse focus produces no border, ring, or
outline change; keyboard focus keeps its ring for accessibility.

* refactor(client): remove residual shadcn color tokens

The background/primary/primary-foreground/ring and unused chart-* tokens were
retained only for the then-unmigrated Agent Builder. With that panel migrated,
replace the last usages with LibreChat semantic tokens (ring-primary/ring-ring
-> ring-text-primary; bg-primary/text-primary-foreground -> bg-surface-inverted
/text-text-inverted; text-primary -> text-text-primary; bg-background ->
bg-surface-primary) and drop the token definitions from createTailwindColors,
applyTheme, the theme objects, types, and style.css.

* fix(client): address semantic theme review feedback

* fix(client): use boolean Monaco hover option

* fix(client): resolve CI validation failures

* test(client): update shared component mocks

* fix(client): expose status tokens to runtime themes and document channel format

Add the status, text-destructive and border-destructive families to IThemeRGB,
IThemeVariables, IThemeColors, mapTheme and the bundled light/dark themes so
ThemeProvider consumers can theme Alert and the status badges instead of falling
back to the stylesheet palette.

Update the theme README to document the channel-triplet contract that the RGB
migration introduced, since the previous examples used complete CSS colors that
now produce invalid declarations.

* test(e2e): use accessible message action locators

* fix(client): address theme env, dialog padding and locked button review feedback

Expose every IThemeRGB token through REACT_APP_THEME_* instead of the
hand-maintained subset that omitted the status, destructive, inverted and
fixed families.

Drop the padding OGDialogContent contributes to the Tool Library so the
header divider spans the panel again, and stop disabled:opacity-100 from
overriding the locked delete-account button's dimmed state.

* fix(client): read theme environment variables from the build-time env

getThemeFromEnv read process.env, which vite-plugin-node-polyfills replaces
with an empty shim in the browser, so every REACT_APP_THEME_* value was
dropped and the loader always returned undefined.

Read import.meta.env instead and register the REACT_APP_THEME_ prefix with
Vite so the values are inlined at build time. The env source is now a
parameter, which lets the tests cover the mapping without mutating globals.

* fix(client): replace Tailwind classes that no longer resolve

Several class names in the client and shared component package emit no CSS
rule at all: legacy token- names with no definition, Tailwind v1/v4 names,
and plain typos. They fail silently past typecheck and tests.

- text-md -> text-base (Tailwind has no md font size)
- text-grey-100, text-tertiary -> text-text-tertiary
- text-token-secondary -> text-text-secondary
- bg-token-surface-primary/tertiary, bg-token-main-surface-secondary and
  border-token-border-hover -> their semantic tokens
- bg-surface, bg-surface-50 -> bg-surface-primary
- bg-surface-primary-hover -> bg-surface-hover
- outline-hidden -> outline-none where focus styling already exists
- drop focus:shadow-outline, border-d-0 and the malformed
  ring-offset-ring-offset, which have no meaningful replacement

MemoryArtifacts keeps its default outline instead of gaining outline-none,
since that button has no other focus indicator. MentionItem drops its dead
background rather than adopting one, which would have matched its hover
colour and erased the hover affordance.

Localize the two literal strings the pre-commit lint flagged in the touched
files, reusing the existing com_ui_upload_image and com_ui_more_count keys.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-08-06 09:15:17 -04:00

184 lines
8.2 KiB
TypeScript

import { expect, test } from '@playwright/test';
import type { Page, Response } from '@playwright/test';
import {
MOCK_ENDPOINTS,
NEW_CHAT_PATH,
messagesView,
replyPrompt,
replyText,
selectMockEndpoint,
sendMessage,
} from './helpers';
/**
* Regression suite for the "folded thread" incident (PR: order-robust message
* tree + identity-stable sibling selection). The original failure: after
* preempt/interrupt churn completed a turn, the client cache held children
* ordered before their parent and the thread view collapsed to the latest
* branch (with a correct-looking sibling counter) until a reload. These tests
* pin the user-visible invariants on the real stack: every turn stays visible
* through churn, the rendered thread matches its own post-reload rendering,
* and paging to an older branch is not undone by later tree writes.
*/
const uniqueLabel = (prefix: string) =>
`${prefix}-${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
const countedPrompt = (label: string) => `E2E_COUNTED_REPLY:${label}`;
const countedReplyText = (label: string, count: number) => `E2E counted reply ${label} #${count}`;
const messageInput = (page: Page) => page.getByRole('textbox', { name: 'Message input' });
const messageTurns = (page: Page) => messagesView(page).locator('.message-render');
const siblingCounter = (page: Page) =>
page.getByRole('navigation', { name: 'Sibling message navigation' }).getByRole('status').first();
function isSteerRequest(response: Response) {
return (
response.request().method() === 'POST' &&
new URL(response.url()).pathname === '/api/agents/chat/steer'
);
}
async function openMockChat(page: Page) {
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
}
async function sendAndExpectReply(page: Page, prompt: string, reply: string) {
const response = await sendMessage(page, prompt);
expect(response.ok()).toBeTruthy();
await expect(messagesView(page).getByText(reply)).toBeVisible({ timeout: 30000 });
}
async function clickSibling(page: Page, messageTextValue: string, direction: 'Previous' | 'Next') {
const render = messagesView(page)
.locator('.message-render')
.filter({ hasText: messageTextValue })
.last();
await render.scrollIntoViewIfNeeded();
await render.hover();
await render.getByRole('button', { name: `${direction} sibling message` }).click();
}
test.describe('thread fold regressions', () => {
test.afterEach(async ({ page }) => {
await page.evaluate(() => window.localStorage.removeItem('steerInterruptsByDefault'));
});
test('thread survives a mid-stream interrupt and matches its own post-reload rendering', async ({
page,
}) => {
test.setTimeout(180000);
const label = uniqueLabel('fold-churn');
const setupPrompt = replyPrompt(`${label}-setup`);
const setupReply = replyText(`${label}-setup`);
const interruptText = `Interrupt churn ${label}`;
await openMockChat(page);
await sendAndExpectReply(page, setupPrompt, setupReply);
await expect(page).toHaveURL(/\/c\/[0-9a-fA-F-]{36}$/, { timeout: 15000 });
const run = await sendMessage(page, `E2E_SLOW_REPLY:${label}`);
expect(run.ok()).toBeTruthy();
await expect(messagesView(page).getByText('chunk-010')).toBeVisible({ timeout: 15000 });
/** Queue mid-run, then escalate to an interrupt: the closest scripted
* reproduction of the incident's preempt churn (mid-stream seal, new
* generation, resume-path cache writes). */
const input = messageInput(page);
await input.click();
await input.fill(interruptText);
await input.press('ControlOrMeta+Enter');
const row = page.getByTestId('queued-message-row').filter({ hasText: interruptText });
await expect(row).toBeVisible({ timeout: 10000 });
const [steerResponse] = await Promise.all([
page.waitForResponse(isSteerRequest, { timeout: 15000 }),
row.getByTestId('queued-interrupt-now').click(),
]);
expect(steerResponse.status()).toBe(202);
await expect(
messagesView(page).getByTestId('steer-part').filter({ hasText: interruptText }),
).toHaveCount(1, { timeout: 90000 });
await expect(messagesView(page).getByText(`E2E slow reply continued ${label}`)).toBeVisible({
timeout: 30000,
});
await expect(page.getByRole('button', { name: 'Stop generating' })).toBeHidden({
timeout: 30000,
});
/** The fold's fingerprint was a live rendering that no longer matched the
* durable thread. EVERY turn must still be on screen after the churn... */
await expect(messagesView(page).getByText(setupPrompt)).toBeVisible();
await expect(messagesView(page).getByText(setupReply)).toBeVisible();
await expect(messagesView(page).getByText('chunk-010')).toBeVisible();
await expect(messageTurns(page)).toHaveCount(4);
/** ...and reloading (the incident's only fix) must change nothing. */
await page.reload({ timeout: 15000 });
await expect(messagesView(page).getByText(setupPrompt)).toBeVisible({ timeout: 30000 });
await expect(messagesView(page).getByText(setupReply)).toBeVisible();
await expect(messagesView(page).getByText('chunk-010')).toBeVisible();
await expect(
messagesView(page).getByTestId('steer-part').filter({ hasText: interruptText }),
).toHaveCount(1, { timeout: 30000 });
await expect(messageTurns(page)).toHaveCount(4);
});
test('older-branch selection and sibling counters survive a follow-up turn and reload', async ({
page,
}) => {
test.setTimeout(180000);
const label = uniqueLabel('fold-branch');
const rootPrompt = countedPrompt(label);
const firstReply = countedReplyText(label, 1);
const regeneratedReply = countedReplyText(label, 2);
const followPrompt = replyPrompt(`${label}-follow`);
const followReply = replyText(`${label}-follow`);
await openMockChat(page);
await sendAndExpectReply(page, rootPrompt, firstReply);
await expect(page).toHaveURL(/\/c\/[0-9a-fA-F-]{36}$/, { timeout: 15000 });
const render = messagesView(page)
.locator('.message-render')
.filter({ hasText: firstReply })
.last();
await render.hover();
await render.getByRole('button', { name: 'Regenerate', exact: true }).last().click();
await expect(messagesView(page).getByText(regeneratedReply)).toBeVisible({ timeout: 30000 });
await expect(siblingCounter(page)).toHaveText('2 / 2');
/** Page to the older branch; the selection must hold, not snap back. */
await clickSibling(page, regeneratedReply, 'Previous');
await expect(messagesView(page).getByText(firstReply)).toBeVisible();
await expect(messagesView(page).getByText(regeneratedReply)).toBeHidden();
await expect(siblingCounter(page)).toHaveText('1 / 2');
/** A follow-up streamed from the older branch churns the tree on every
* delta and appends a deeper level — none of which may move THIS level's
* selection or corrupt its counter. */
await sendAndExpectReply(page, followPrompt, followReply);
await expect(messagesView(page).getByText(firstReply)).toBeVisible();
await expect(messagesView(page).getByText(regeneratedReply)).toBeHidden();
await expect(siblingCounter(page)).toHaveText('1 / 2');
/** Reload rebuilds selection from scratch (in-memory sibling atoms are
* gone); whichever branch the default lands on, the durable tree must be
* intact: both branches reachable through the switcher and the follow-up
* turn present on branch one. A folded tree would strand one branch. */
await page.reload({ timeout: 15000 });
await expect(siblingCounter(page)).toHaveText(/[12] \/ 2/, { timeout: 30000 });
if (!(await messagesView(page).getByText(followReply).isVisible())) {
await clickSibling(page, regeneratedReply, 'Previous');
}
await expect(messagesView(page).getByText(followReply)).toBeVisible({ timeout: 15000 });
await expect(messagesView(page).getByText(firstReply)).toBeVisible();
await expect(siblingCounter(page)).toHaveText('1 / 2');
await clickSibling(page, firstReply, 'Next');
await expect(messagesView(page).getByText(regeneratedReply)).toBeVisible();
await expect(messagesView(page).getByText(followReply)).toBeHidden();
await expect(siblingCounter(page)).toHaveText('2 / 2');
});
});