Files
open-design/e2e/ui/home-send-split-width.test.ts
Ray XiandGitHub 938a09b12c [OPEND-3334] feat(web): one project view from the Home send — no hand-off, one Working state (#8267)
* fix(web): keep the project split still across the Home hand-off

The optimistic creation frame left `.split` on its stylesheet default
(460px) while ProjectView resolved the saved width, or the equal split of
the container, only after it mounted — and that first write ran through
the 200ms width transition, so the chat column moved once the real view
arrived (OPEND-3207).

Move the split geometry into `project-split-layout.ts`, have both surfaces
resolve their first-paint width through `resolveProjectSplitLayout`, and
settle a surface's first measured write without the transition.

Red spec: e2e/ui/home-send-split-width.test.ts (red on main, green here).

* test(web): read the 4px handle constant from project-split-layout

The material spec pinned `SPLIT_RESIZE_HANDLE_WIDTH = 4` in ProjectView's
source; the constant now lives in project-split-layout.ts and ProjectView
imports it, so assert on the module and on the import.

* test(cms): wait for the Test modal impression before switching language

The locale case only passed while the impression frame never fired: on a
fast machine the next language switch unmounted the modal before its
requestAnimationFrame recorded the impression. CI is slow enough for the
frame to land, and the switch that follows then closes the modal for good.
Waiting for the recorded impression makes that order deterministic.

* fix(cms): keep the open Test modal across a same-deployment reload

A language switch (or lease renewal) reloads the selected Test deployment.
While the new decisions are in flight the runtime publishes a session
without decisions, and the modal treated that gap as the presentation
ending: it dropped its "open" marker, then the returning decision looked
like a new offer of an activity this account had already seen and stayed
closed.

Key the open presentation by the selected deployment and snapshot. A
reload of the same selection continues it; a redeploy, a different
account, a dismissal, or the session ending still releases it.

* test(e2e): red spec for the Home → project hand-off parity (OPEND-3334)

Two pins for the hand-off between the optimistic project frame and
ProjectView: pixel parity of the chat and workspace columns across the
hand-off (animations frozen), and a single assistant status label + class
set from the click to the first content. Expected red on main: the frame is
a hand-drawn copy of ProjectView and drifts from it (old "Preparing…" footer
vs the execution record's "Working" head, simplified composer, disabled
empty-state pills, tabs dock, switcher name).

* test(e2e): enroll the Home hand-off specs in the ui_p0 entry-settings group

home-amr-pending, home-send-single-loading and home-send-split-width were
merged (or opened) without being enrolled in uiP0Groups, so no merge lane
had ever executed them; only the prerelease full pool would have. Enroll
them with the other Home-entry files.

* test(e2e): enroll the hand-off parity spec in ui_p0 (OPEND-3334)

* feat(web): draw the Home → project hand-off frame with the real view's parts (OPEND-3334)

The optimistic creation frame was a hand-drawn copy of ProjectView and had
drifted from it, so the hand-off moved: the old assistant footer's
"Preparing…" flipped to the execution record's "Working" head, the composer
gained its accessories, the empty-state pills lit up, the tabs dock gained
the history control.

The frame now draws the same parts: the role row named through the shared
`assistantRoleNameForAgent`, the real `ExecutionShell` on an empty running
shell ("Working", its orb, its geometry), the real composer accessories
(design-system picker + agent/model menu) inert, the history control's
footprint in the dock, and live-looking, inert empty-state pills. Behind an
execution shell the old footer no longer paints its running label, so a
running turn states its status once.

App and ProjectView pass the agent name, config, agents, daemon liveness and
design systems the frame needs; the pending record carries the send's
design-system id.

* test(e2e): compare the status row's element set per hand-off phase

The record head is more than one element (its shimmer span and the summary
wrapper both carry the text), so counting distinct class sets across the
whole log flagged an identical head as a change. Compare the set the frame
drew with the set the view drew instead.

* feat(web): let the real ChatPane draw the Home hand-off's first turn (OPEND-3334)

The pending frame and ProjectView's hand-off card were hand-drawn copies of
the chat column, so every ChatPane internal they missed (the fixed composer
layer, the chat seam, the log's top inset) was a jump at the hand-off. Both
now render the real ChatPane on an optimistic first turn built from the
creation record: a user message and a running, empty assistant placeholder.

- runtime/chat/creation-handoff: pure builder for that turn; staged images
  preview through object URLs until the files are in the project.
- ChatPane: `detached` mode (inert pane + composer layer) for the frame;
  the composer portal target resolves at mount so the composer is portaled
  before the first paint instead of one frame after it.
- ProjectView: mounts ChatPane during the hand-off and feeds it the
  optimistic turn until a real message exists; the overlay card, the hidden
  pane CSS and `composerLayerHidden` are gone.
- agentModelSelection: one `selectedAssistantIdentity` for the real and the
  optimistic turn.

* fix(web): give the pending frame's workspace column the add-tab and project-menu controls (OPEND-3334)

The parity pin now passes on the chat column and fails on the workspace
column: the frame lacked FileWorkspace's add-tab button and the project
menu trigger, so the top bar was 4px shorter and the empty state below it
moved at the hand-off.

* test(web): decouple pending handoff assertions from CSS classes

Generated-By: looper 0.13.0 (runner=fixer, agent=codex)

* fix(web): the Home hand-off's first turn fades in once, no remount, no workspace reload (OPEND-3334)

A rAF probe of the Home send found the first turn mounted four times, each
playing the 200ms `msg-enter` fade (QA's recording shows the flashes):
the pending frame (the one intended entrance); ProjectView taking over;
the ChatPane key moving from `conversation-unavailable` to the resolved
conversation id; and the real rows replacing the optimistic ones under new
ids. The workspace went pending-empty -> "Loading..." -> empty.

- ChatPane `quietEntrance`: rows first rendered while it is on get
  `msg--quiet-enter` (no fade) for their lifetime; dropping the flag never
  restarts an animation. ProjectView sets it while the hand-off runs.
- ProjectView keeps the pane's key when the hand-off pane's first
  conversation id resolves; a later switch still re-keys.
- A project born from the Home send (not an imported folder) shows the
  empty workspace until the first listing, as the pending frame did.

* test(e2e): pin one entrance, one view pane and no workspace reload across the Home hand-off (OPEND-3334)

* fixer: address PR #8267 follow-up items

Generated-By: looper 0.13.0 (runner=fixer, agent=codex)

* fix(web): keep the file-list authority gate across the Home hand-off (OPEND-3334)

e3596b032c made a Home-created project's workspace authoritative before
its first listing was accepted, so the empty-state CTAs were live while
Home attachments were still uploading, and a failed first read looked
like a genuinely empty project (review by nettee).

Restore `filesAuthoritative={committedFilesGeneration > 0}`. Until that
first listing, `emptyLookBeforeFirstListing` draws the same empty state
the hand-off frame showed, from DesignFilesPanel's one empty-state
markup, but inert and aria-busy and without CTA test ids. The live empty
state (or the files) replaces it once a listing is accepted, so the
workspace still never detours through "Loading…".

* feat(daemon): accept a caller-minted seed conversation id on project create (OPEND-3334)

* refactor(web): mount ProjectView from the Home send and hold project reads until the create answers (OPEND-3334)

* test(web): pin the held reads and the single pane across the Home send (OPEND-3334)

* test(e2e): key the Home hand-off specs on the creation phase, pin one pane and one user row (OPEND-3334)

* test(e2e): no project-scoped request leaves before the create answers (OPEND-3334)

* test(web): type the fetch spy in the held-reads case

* fix(web): hold the pending-prompt clear, tab hydrate, folders and share-status reads until the create answers (OPEND-3334)

The e2e network assertion caught five project-scoped requests leaving
before POST /api/projects answered. A Home send's view is also an
auto-send from mount now: App writes the auto-send keys after the
create, so the prompt must not fall back to a composer draft.

* fix(web): keep the run-status feed off a project until its create answers (OPEND-3334)

The rail and the tab switcher list the optimistic row from the click, and
their shared feed polled /api/runs?projectId= for it every 4s. The read
predates this branch; the e2e network assertion caught it intermittently.

* fix(web): the optimistic first turn belongs to ProjectView, not App's creation record (OPEND-3334)

QA on bd5fe20579: App releases the creation record 8s after the create;
Home uploads outliving that made the first turn and its Working row
vanish until they finished. The view now captures the turn at mount and
retires it when the real turn owns the rows, when the auto-send is turned
down, or when the record is gone with nothing left to send. The seed
conversation id is kept for the view's life so the pane never re-keys.
2026-10-22 03:52:55 +00:00

115 lines
5.1 KiB
TypeScript

import { expect, test } from '@/playwright/suite';
import type { Page } from '@playwright/test';
import { gotoEntryHome } from '@/playwright/amr';
import { applyStandardMocks, routeSuccessfulRuns, suppressWhatsNew } from '@/playwright/mock-factory';
import { T } from '@/timeouts';
/*
* OPEND-3207 · the project split must not move after a Home send lands.
*
* A Home send opens the optimistic project frame first (OPEND-2617 / 2614)
* and ProjectView takes over once `POST /api/projects` answers. The two
* surfaces used to size the chat column from different sources: the pending
* frame left `.split` on its stylesheet default (460px), while ProjectView
* resolved the user's saved width, or the equal split of the container when
* nothing was saved, only after it mounted. The column therefore widened or
* narrowed once the real view arrived, dragging the composer and the preview's
* left edge with it — the jump the two QA recordings show in both directions.
*
* These cases sample the chat column every animation frame from the first
* paint of the hand-off frame until the user's turn is on screen, and require
* one width for the whole stretch. No stopwatch: a 200ms transition and a hard
* cut fail the same way.
*/
declare global {
interface Window {
__odSplitWidthLog?: Array<{ at: number; width: number; pending: boolean; user: boolean }>;
}
}
const CHAT_PANEL_WIDTH_STORAGE_KEY = 'open-design.project.chatPanelWidth';
async function installSplitWidthProbe(page: Page): Promise<void> {
await page.addInitScript(() => {
const log: Array<{ at: number; width: number; pending: boolean; user: boolean }> = [];
window.__odSplitWidthLog = log;
let last: string | null = null;
const sample = () => {
const slot = document.querySelector('.split-chat-slot') as HTMLElement | null;
if (slot && slot.getClientRects().length > 0) {
const width = Math.round(slot.getBoundingClientRect().width);
const pending = document.querySelector('[data-creation-phase="pending"]') !== null;
const user = document.querySelector('[data-testid="chat-log"] .msg.user') !== null;
const key = `${width}|${pending}|${user}`;
if (key !== last) {
last = key;
log.push({ at: performance.now(), width, pending, user });
}
}
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
});
}
async function sendFromHome(page: Page, prompt: string): Promise<void> {
const input = page.getByTestId('home-hero-input');
await expect(input).toBeVisible({ timeout: T.medium });
await input.fill(prompt);
const submit = page.getByTestId('home-hero-submit');
await expect(submit).toBeEnabled();
await submit.click();
}
/**
* Every chat-column width sampled from the first hand-off paint through the
* frame the user turn appears, plus the widths that followed it, so a shift
* that lands right after the transcript settles is caught too.
*/
async function expectStableSplitWidth(page: Page, expectedWidth?: number): Promise<void> {
await expect(page.locator('[data-testid="chat-log"] .msg.user').first()).toBeVisible({ timeout: T.long });
await expect(page.locator('[data-creation-phase="pending"]')).toHaveCount(0, { timeout: T.medium });
// Let any width transition that was still running finish before reading.
await page.waitForTimeout(400);
const log = await page.evaluate(() => window.__odSplitWidthLog ?? []);
const firstPending = log.findIndex((entry) => entry.pending);
expect(firstPending, 'the pending phase was sampled').toBeGreaterThanOrEqual(0);
const stretch = log.slice(firstPending);
const readable = stretch
.map((entry) => `${Math.round(entry.at - stretch[0]!.at)}ms ${entry.width}px${entry.pending ? ' pending' : ''}${entry.user ? ' user' : ''}`)
.join('\n');
const widths = Array.from(new Set(stretch.map((entry) => entry.width)));
expect(widths, `the chat column changed width after the hand-off:\n${readable}`).toHaveLength(1);
if (expectedWidth !== undefined) {
expect(widths[0], `the chat column did not hold the saved width:\n${readable}`).toBe(expectedWidth);
}
}
test.describe.configure({ timeout: T.xlong });
test.beforeEach(async ({ page }) => {
await suppressWhatsNew(page);
await installSplitWidthProbe(page);
});
test('[P0] a Home send with no saved chat width keeps one column width from the hand-off frame to the first turn', async ({ page }) => {
await applyStandardMocks(page);
await routeSuccessfulRuns(page, { runId: 'home-split-width-default' });
await gotoEntryHome(page);
await sendFromHome(page, 'Gamified habit app: draft the onboarding flow.');
await expectStableSplitWidth(page);
});
test('[P0] a Home send with a saved chat width shows that width from the hand-off frame onwards', async ({ page }) => {
await applyStandardMocks(page);
await page.addInitScript((key: string) => {
window.localStorage.setItem(key, '380');
}, CHAT_PANEL_WIDTH_STORAGE_KEY);
await routeSuccessfulRuns(page, { runId: 'home-split-width-saved' });
await gotoEntryHome(page);
await sendFromHome(page, 'Gamified habit app: draft the streak screen.');
await expectStableSplitWidth(page, 380);
});