Files
open-design/e2e/ui/fork-note-ellipsis.test.ts
lefarcenandGitHub 819c10d49f fix(chat): make the failure card, the queued action and the fork note stop disappearing (#7871)
* fix(web): ellipsize the fork divider note instead of hard-cutting it

The fork divider footnote lost its ellipsis when #7863 merged the
two-block layout into one line. `.fork-note` became `display: flex` so
the branch icon and the label could sit side by side, but the three
truncation declarations (`overflow: hidden`, `text-overflow: ellipsis`,
`white-space: nowrap`) stayed on `.fork-sep span` — the very same
element. Bare text inside a flex container lives in an anonymous flex
item, and `text-overflow` is not inherited, so the anonymous box falls
back to `clip`: the 62% cap still clipped, just without the three dots.

Move the label into its own `.fork-note-label` span and hang the
truncation on that layer, with `min-width: 0` to release the flex item's
default `min-width: auto`. `.fork-sep span` becomes `.fork-sep > span`
so the inner label is not pinned to `flex: none` — a non-shrinkable flex
item is always exactly as wide as its content and can never overflow.

Also finishes OPEND-2714's copy: the 16 locales other than en / zh-CN /
zh-TW still carried translations of the retired English sentence. They
now say what `Continued from chat` says, each within 1.5x the English
length so they stay inside the 62% cap.

* test(web): witness the fork note ellipsis in a browser, not in chat.css

apps/web/src/components/chat/AGENTS.md §5 forbids asserting CSS class
names or declarations in chat tests, and the spec added in the previous
commit did exactly that: it located the label by className, parsed
chat.css, and pinned the resolved values of text-overflow, overflow,
white-space, min-width and flex. Delete it.

Replace it with a narrow browser witness. e2e/ui/fork-note-ellipsis.test.ts
forks a conversation through the real daemon endpoint, renders it in the
longest shipped locale (German), squeezes the chat panel until the label
overflows, then checks two observable things: the label really is
overflowing its box, and its paint differs from the same element forced to
`text-overflow: clip`. Comparing the element against itself keeps the
oracle baseline-free, and a build that still hard-cuts paints identically
under both, so the check cannot pass vacuously.

Asserting the declarations was the wrong instrument anyway: the broken
build declared `text-overflow: ellipsis` too. What was wrong was the box
it sat on, and only a laid-out browser can see that.

The one structural fact worth keeping - the label is carried by its own
element rather than as bare text beside the icon - moves into the existing
fork-divider spec and keys off a data-testid instead of a class name.

Enrolls the new file in the project-workspace UI P0 group. An unenrolled
P1 would run only in the prerelease full pool, which means shipping a
witness that nothing on the merge path ever executes.

* test(e2e): wait for the fork divider before squeezing the chat panel

The width squeeze is a raw custom-property write onto the split, which
React's own width effect would overwrite if a late fetch re-rendered
between the write and the measurement. Wait for the divider to paint at
the default width first, then squeeze with nothing awaited in between.

Also corrects a stale doc comment that named a helper this file never had.

* fix(e2e): squeeze the chat panel without racing its width transition

CI caught the guard doing exactly what it was written to catch: it failed
with "content 161px vs box 161px", meaning the note never overflowed and
the ellipsis check would have been measuring nothing.

The cause was the squeeze, not the copy. `--project-chat-panel-width` is a
registered `@property` with a 200ms transition, so writing it tweens from
the default 460px and the measurement taken right after read the panel
mid-flight. Suspend the transition the way the product's own resize handle
does (`.is-resizing-chat`), and do the write and the read inside a single
evaluate so no re-render can land between them.

Also measures the label before and after the squeeze, so the next failure
names its own cause: a panel that never narrowed is a fixture problem, a
narrowed panel that still fits the text is a copy problem. The previous
message could not tell those apart.

* chore(web): drop the now-unused splitList export from the cascade helper

It was exported only to feed the CSS-parsing spec that the previous commit
deleted. Nothing outside the helper calls it any more, so put it back to a
module-private function and leave the shared test infrastructure exactly as
it was on main.

* fix(web): collapse the queued row's leading action into one 「引导对话」 button

The queued-send row's leading action had two faces: a 74px labelled
「引导对话」 while `canSteerCurrentTurn` held, and an unlabelled 22px
"send now" icon otherwise. Both were wired to the same
`sendQueuedChatSendNow` — the gate only changed the name, the tooltip and
the analytics `element` value.

Product ruled on 2026-09-08: 「引导对话就是原本的立即发送啊,只不过我们
换了个名字跟 codex 客户端对齐了下」. So the gate goes, the fallback face
goes, and the row keeps one button that is always 「引导对话」.

The delivered spec agrees. `729fa43ce7:docs/design/chat-panel-next.html`
component 17 "Queue" draws exactly one action there —
`aria-label="引导对话" data-tip="引导对话"` with `<svg/><span>引导对话</span>`
— and never the icon-only send-now key. `data-tip` is the plain name, so
`title` / `data-tooltip` / `aria-label` collapse from
`chat.queuedSteerInterrupts` back to `chat.queuedSteer`; visible text and
accessible name are now identical (WCAG 2.5.3 Label in Name).

Order is untouched: steer → edit → delete (OPEND-2715).

Analytics: this surface no longer emits `ui_click` with
`element: 'send_now'`. The surviving button reports `'steer'`, matching
the name it now carries. The `send_now` member stays in the contract union
because PostHog still holds the historical events.

Props: `onSteerQueuedSend` existed only to carry the gate, so it is
removed from ChatPane, SideChatTab and both ProjectView call sites, along
with `canSteerCurrentTurn`. `onSendQueuedNow` survives as the single
handler — it has the second pass-through face in `SideChatTab`'s
`controlledChat`, and it names what the handler actually does.
`steerBlockedReason` is left alone: it had no producer before this change
either, and retiring a deliberately parked placeholder is its own call.

* fix(prompts): stop asking the model for a per-question help string

OPEND-2707. The clarification card stopped rendering the per-question
subtitle (`q.help`) in #7863, but the prompt layer kept asking the model to
write one — so the model spent tokens on copy nobody would ever see, and
believed it had already told the user something it had not. The user ruled on
2026-09-08 to go all the way and retire the prompt half too.

Three prompt surfaces carried it:

- `core-slim.ts` routed leftover context into "the individual question
  labels/help"; context now has one destination, the question label.
- The UI locale override (daemon + contracts mirror) listed "helper text"
  among the question-form controls that must be localized — naming the field
  without naming it. The other four entries in that list all still render.
- The ElevenLabs voice picker is the only form the repo itself authors, and
  it shipped the only `help` string in the tree. Its content is real
  information (the visible labels are voice descriptions; the submitted value
  is the voice_id), so it moves into the question label rather than being
  dropped. `FormQuestion` has no per-question `description`, and
  `placeholder` is already taken by "Choose a voice".

That form is `JSON.stringify`d whole into the system prompt, so it doubles as
the model's worked example of what a question may look like — leaving a
`help` key there would have quietly undone the rest of the change.

`e2e/tests/question-form-help-retired.test.ts` guards all seven authorized
prompt paths, mirroring `question-form-visual-style-retired.test.ts`. It
reads source bytes, so it also asserts the parser keeps tolerating `help` —
the dormant field must not be removed along with the prompt guidance.

Snapshot: only `totalChars` moves, on the three scenarios that compose the
touched text; no section is added or removed.

* docs(web): record FormQuestion.help as dormant, not dead

OPEND-2707 follow-up. With the renderer, the CSS rule, the prompt guidance
and the last host-authored producer all gone, `help` now has no producer and
no consumer anywhere in the tree. It still stays.

Removing `help?: string` would break
`QuestionForm.no-question-subtitle.test.tsx` and `QuestionForm.test.tsx` —
and the first of those is the only positive evidence that the subtitle is not
rendered. Deleting a zero-cost field at the price of the test that guards the
invariant is a bad trade. Cached prompts, older clients and the model's own
memory of the old format can also still send a question carrying the key, and
the parser should keep tolerating it rather than change parse shape as a side
effect of this retirement. Same reasoning as the six `qf.visual*` i18n keys in
`specs/current/chat-panel-decisions-sheet.md`.

The field now documents when and why it went dormant, and how to bring the
rendering half back (the pre-#7863 revision of both files).

Also corrects `DesignDirection.mood` in both prompt mirrors: its docblock said
the text is "shown to the user as `help`", which was already inaccurate — it
is the `mood` blurb on a `direction-cards` option, and that renderer has had
no call site since the visual-style question was retired.

* fix(web): keep the disconnect failure card when no reconnect row is there

A turn that failed with `DAEMON_STREAM_DISCONNECTED` left nothing at all on
screen once the user stepped out of the project and came back: no failure card,
no reconnect line, no way to retry.

`anotherSurfaceOwnsFailure` in `ChatPane` handed the story to the reconnect
line unconditionally, on the premise (stated in the comment right above it)
that the reconnect line "is always there". It is not: `ProjectView` has a
dedicated unmount effect that clears `reconnectView` on project change / screen
exit, so on re-entry the card yields to a surface that has already left.

The neighbouring balance rung already encodes the right invariant —
`balanceCardCannotTakeTheHandoff` refuses the handoff when the upgrade card
cannot be drawn. The same invariant was only written on one of the two rungs.
Both rungs now name their handoff target first, then ask one shared question:
is that surface actually present.

Red spec: `ChatPane.error-card-ladder.test.tsx` — a disconnected run with no
reconnect row must render the card, its own `chat.connectionDropped` copy, and
a retry action. Reverse anchor (row present -> no card) is green on both sides
of the fix.

The two older cases in that file asserted "no card" while rendering *without* a
reconnect row: they pinned this defect rather than the invariant. Both now put
the handoff target on screen, so they pin what they were written for — that
either signal (structured code, or a legacy detail-only row) is recognized as
the disconnect failure.

* fix(web): shorten the Cloud switch CTA to match the delivery spec

The error card's primary button read 「切换到 OpenDesign Cloud 并重试」
while the delivery spec (docs/design/chat-panel-next.html, cell 79
"特殊错误 · 运行环境为 CLI / BYOK") writes 「切换到 Cloud」.

Product ruled in person on 2026-09-08: "切换到 cloud 就行了,你怎么写那么
长的文案『切换到 Cloud 并重试』" and "具体的报错文案不一定跟设计稿对齐,
按钮文案对齐先". So only `chat.amrCard.switchCta` is aligned here — the
card's title and body keep their own product copy and are deliberately
left untouched, including the separate `chat.amrCard.switchBody` key.

All 19 locales are shortened to the same "Switch to Cloud" register with
the trailing "and retry" clause dropped. Comments in ChatPane.tsx and
amr-guidance.ts that quoted the old label are updated so they stop
describing copy that no longer exists; the reasoning around them is
unchanged. Playwright assertions in e2e/ui that pinned the old label
follow the new string.

* docs(specs): record the three rulings taken on 2026-09-08

* fix(web): keep the failure card when a failed turn has no words for it

A turn that ends in a terminal failure loses its entire recovery surface
whenever the card cannot compute a description. The whole card — title,
message, and all four actions (contact support, export logs, retry,
switch to Cloud) — hangs off one `displayError` ternary, so a null
description takes the explanation and every way out with it. What is left
on screen is the execution shell's bare "run failed" line, written on the
assumption that the card below it says why.

The three description sources all go empty together more often than the
shape suggests: the mapping table's own last-resort row carries no
`messageKey`, the pane-level `error` state is reset when ProjectView
unmounts on navigation, and the run's raw detail never reached the message
for the `AGENT_UNAVAILABLE` / `BYOK_PROVIDER_REQUIRED` /
`AGENT_RUNTIME_DEF_INVALID` family (daemon `fail()` emits without going
through `persistRunEventToAssistantMessage`) or for the `runStatus:'failed'`
rows ProjectView writes itself when self-healing a phantom running turn.

`resolveRunErrorCardDescription` now takes `turnEndedInTerminalFailure` and
falls back to `RUN_FAILURE_FALLBACK_MESSAGE_KEY` instead of rendering
nothing. It is the last branch, so every handoff and every more precise
source still wins ahead of it. The signal is `retryableAssistantMessage`,
which already gates the recovery entries and already treats a delivery
failure (`no_result` / `delivery_failed`) as terminal, minus the empty
API response — that one has a surface of its own ("No output") and must
not get a second one.

Also stops an empty pane-error string from shadowing the run's own detail.
That branch carried a comment saying the silence was a separate question;
this is the same question, answered the same way.
2026-09-08 08:50:41 +00:00

234 lines
9.5 KiB
TypeScript

import { expect, test } from '@/playwright/suite';
import type { Locator, Page } from '@playwright/test';
import {
AMR_PERSONAL_WORKSPACE_HEADERS,
createProjectViaApi,
dismissPrivacyDialog,
expectWorkspaceReady,
putAppConfig,
seedBrowserConfig,
} from '@/playwright/amr';
import { routeAgents } from '@/playwright/mock-factory';
import { T } from '@/timeouts';
/**
* The fork divider's footnote must ELLIPSIZE when it does not fit, not get cut
* mid-glyph.
*
* #7863 collapsed the divider into one line, which made `.fork-note` a flex
* container while the truncation declarations stayed on that same element.
* Bare text in a flex container lives in an anonymous flex item, and
* `text-overflow` is not inherited, so the anonymous box fell back to `clip`:
* the width cap still bit, but the cut had no ellipsis. English
* ("Continued from chat", 19 chars) mostly stays under the cap, so only the
* longer shipped locales showed it.
*
* This is a browser-only witness on purpose. jsdom does not lay out, so
* "is there an ellipsis" cannot be observed there, and asserting the CSS
* declarations instead would be false green — the broken build declared
* `text-overflow: ellipsis` too; what was wrong was the box it sat on. Per
* `apps/web/src/components/chat/AGENTS.md` §5 the web-side tests must not
* assert class names or declarations either, so the oracle below reads only
* observable geometry and pixels through a stable `data-testid`.
*/
const AGENT = {
id: 'amr',
name: 'OpenDesign AMR',
bin: 'vela',
available: true,
version: 'test',
models: [{ id: 'default', label: 'Default' }],
};
/**
* German is the longest of the 19 shipped `assistant.forkNote` translations
* (`Fortsetzung der Konversation`, 28 characters against English's 19), so it
* is the one the divider's 62% cap reaches first. The expected string is not
* duplicated here — `apps/web` is another app's private source and e2e must
* not borrow it — so if the translation is ever shortened, the overflow check
* below fails with the measured widths and the string it actually rendered,
* rather than passing vacuously.
*/
const LOCALE = 'de';
/** Narrow enough that 62% of the divider cannot fit the German label. */
const CHAT_PANEL_WIDTH_PX = 280;
async function seedForkedConversation(page: Page): Promise<Locator> {
await page.addInitScript((locale) => {
window.localStorage.setItem('open-design:locale', locale);
window.localStorage.setItem('open-design:locale-source', 'manual');
}, LOCALE);
await routeAgents(page, [AGENT]);
const config = {
mode: 'daemon',
apiKey: '',
baseUrl: '',
model: '',
agentId: 'amr',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
privacyDecisionAt: 1,
mediaProviders: {},
agentModels: { amr: { model: 'default', reasoning: 'default' } },
};
await seedBrowserConfig(page, config);
await putAppConfig(page, config);
const projectId = `fork-note-ellipsis-${Date.now()}`.replace(/[^A-Za-z0-9._-]/g, '-');
const { conversationId } = await createProjectViaApi(page, projectId, 'Fork note ellipsis');
// The daemon only stamps the divider when it can inherit a source title
// ("拿不到源标题就不盖" in routes/project/conversations.ts), so name the
// source conversation before forking off it.
const titled = await page.request.patch(
`/api/projects/${projectId}/conversations/${conversationId}`,
{
headers: { ...AMR_PERSONAL_WORKSPACE_HEADERS },
data: { title: 'Storefront prototype' },
},
);
expect(titled.ok(), `title source conversation: ${await titled.text()}`).toBeTruthy();
const assistantMessageId = `a-${projectId}`;
const seeded = await page.request.put(
`/api/projects/${projectId}/conversations/${conversationId}/messages/${assistantMessageId}`,
{
headers: { ...AMR_PERSONAL_WORKSPACE_HEADERS },
data: {
role: 'assistant',
content: 'Both pages are done.',
agentId: 'amr',
runStatus: 'succeeded',
createdAt: Date.now() - 1_000,
startedAt: Date.now() - 1_000,
endedAt: Date.now() - 500,
preTurnFileNames: [],
events: [],
},
},
);
expect(seeded.ok(), `seed assistant message: ${await seeded.text()}`).toBeTruthy();
// Fork through the real product endpoint. The daemon copies the transcript
// into a fresh conversation and stamps `forkedInto` on the last carried
// message — that stamp is what renders the divider. Writing the stamp
// directly would prove a shape no user flow produces.
const forked = await page.request.post(`/api/projects/${projectId}/conversations`, {
headers: { ...AMR_PERSONAL_WORKSPACE_HEADERS },
data: {
seedFromConversationId: conversationId,
forkAfterMessageId: assistantMessageId,
},
});
expect(forked.ok(), `fork conversation: ${await forked.text()}`).toBeTruthy();
const { conversation } = (await forked.json()) as { conversation?: { id?: string } };
expect(conversation?.id, 'fork response carried no conversation id').toBeTruthy();
await page.goto(`/projects/${projectId}/conversations/${conversation!.id}`, {
waitUntil: 'domcontentloaded',
});
await dismissPrivacyDialog(page);
await expectWorkspaceReady(page);
// Let the divider paint at the default width first, so the squeeze below has
// something to measure.
const label = page.getByTestId('assistant-fork-note-label');
await expect(label).toBeVisible({ timeout: T.long });
await expect(label).toHaveText(/\S/);
return label;
}
interface LabelBox {
scrollWidth: number;
clientWidth: number;
text: string;
}
/**
* Squeeze the chat panel (optional) and measure the label in ONE round trip.
*
* Two things make a naive "write, then measure" wrong here:
* - React clamps its own panel state to `MIN_CHAT_PANEL_WIDTH` (345px), so
* the width has to be written straight onto the split the way the resize
* handle does rather than through the product's own state.
* - `--project-chat-panel-width` is a registered `@property` with a 200ms
* transition, so a bare write TWEENS from the default 460px and anything
* measured right after reads the panel mid-flight. The product suspends
* that transition with `.is-resizing-chat` while the handle writes the
* width every frame; borrow the same switch instead of inventing one.
*
* Writing and reading inside a single `evaluate` also means no re-render can
* land between them, and the `scrollWidth` read forces the layout that applies
* the new width.
*/
async function measureLabel(page: Page, squeezeTo?: number): Promise<LabelBox | null> {
return page.locator('.split').evaluate((element, panelWidth) => {
const split = element as HTMLElement;
if (panelWidth != null) {
split.classList.add('is-resizing-chat');
split.style.setProperty('--project-chat-panel-width', `${panelWidth}px`);
}
const label = split.querySelector<HTMLElement>('[data-testid="assistant-fork-note-label"]');
if (!label) return null;
return {
scrollWidth: label.scrollWidth,
clientWidth: label.clientWidth,
text: label.textContent ?? '',
};
}, squeezeTo);
}
test('[P0] a long locale ellipsizes the fork divider note in a narrow chat panel', async ({
page,
}) => {
const label = await seedForkedConversation(page);
// 1a. The squeeze landed. Measured before and after so a future failure says
// which half broke: a panel that never narrowed is a fixture problem,
// while a narrowed panel that still fits the text is a copy problem.
const relaxed = await measureLabel(page);
expect(relaxed, 'the divider never rendered').not.toBeNull();
const box = await measureLabel(page, CHAT_PANEL_WIDTH_PX);
expect(box, 'the divider vanished while the chat panel was being squeezed').not.toBeNull();
expect(
box!.clientWidth,
`squeezing the chat panel to ${CHAT_PANEL_WIDTH_PX}px did not narrow the note `
+ `(${relaxed!.clientWidth}px before, ${box!.clientWidth}px after) `
+ '— the width write is not reaching the split, so nothing below is being measured',
).toBeLessThan(relaxed!.clientWidth);
// 1b. The scenario is live: at this width the label really does overflow its
// own box. Without this the ellipsis check below could pass on a label
// that simply fits, and the guard would be measuring nothing.
expect(
box!.scrollWidth,
`the divider note still fits at ${CHAT_PANEL_WIDTH_PX}px `
+ `(content ${box!.scrollWidth}px vs box ${box!.clientWidth}px, text ${JSON.stringify(box!.text)}) `
+ '— narrow the panel further or pick a longer shipped locale, otherwise this guard measures nothing',
).toBeGreaterThan(box!.clientWidth);
// 2. The overflow is rendered as an ellipsis, not a hard cut. The control is
// the same element forced to `clip`: if the product were still clipping,
// forcing `clip` would change nothing and the two paints would match.
// Comparing the element against itself keeps this baseline-free, so it
// needs no committed snapshot and cannot drift with unrelated chrome.
const ellipsized = await label.screenshot({ animations: 'disabled' });
await label.evaluate((element) => {
(element as HTMLElement).style.textOverflow = 'clip';
});
const hardCut = await label.screenshot({ animations: 'disabled' });
expect(ellipsized.byteLength, 'the label painted nothing').toBeGreaterThan(0);
expect(
ellipsized.equals(hardCut),
'the overflowing note paints identically with and without `text-overflow: ellipsis` '
+ '— it is being cut off mid-glyph instead of ellipsized',
).toBe(false);
});