-
Notifications
You must be signed in to change notification settings - Fork 2
refactor(session): rewrite session view per W1 visual lock for slice 11b.1 #589
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from 55 commits
Commits
Show all changes
60 commits
Select commit
Hold shift + click to select a range
1cf9f6a
refactor(ui): introduce groupParts pure helper for session-turn
Astro-Han bb381ed
refactor(ui): introduce TrowBlock reducer for session-turn
Astro-Han a70e960
style(ui): align shimmer duration to W1 lock (1200→1800ms)
Astro-Han 53a4cea
feat(ui): register fork icon placeholder for session agent toolbar
Astro-Han 722d9fa
feat(ui): add shared AttachmentChip primitive
Astro-Han 3911332
feat(ui): add session-turn user bubble for slice 11b.1
Astro-Han e421774
feat(ui): add session-turn system event for slice 11b.1
Astro-Han 55d77ae
feat(ui): add JumpToBottom floating button for slice 11b.1
Astro-Han 421841a
feat(ui): add session-turn agent round for slice 11b.1
Astro-Han b5a995c
feat(ui): fill TrowBlock render shell for slice 11b.1
Astro-Han 9594e0f
docs(ui): annotate Phase 5 token deferrals for slice 11b.1
Astro-Han 548fa26
feat(ui): add SessionTurnV2 hybrid opt-in shell for slice 11b.1
Astro-Han de6a321
fix(ui): make TrowBlock body reactive across streaming tool updates
Astro-Han 0fbee29
revert(ui): drop SessionTurnV2 hybrid opt-in shell for slice 11b.1
Astro-Han 20ccbb1
feat(ui): register --fg-secondary and --icon-chev tokens for slice 11b.1
Astro-Han e335a1a
refactor(ui): extract message-part types to dedicated module
Astro-Han 892ef1a
refactor(ui): extract message-part registry to dedicated module
Astro-Han 5401c26
refactor(ui): extract paced markdown helpers to dedicated module
Astro-Han 610e2db
refactor(ui): extract tool-info helpers to dedicated module
Astro-Han cad41ef
refactor(ui): extract legacy assistant grouping helpers
Astro-Han c3518eb
refactor(ui): extract AssistantMessageDisplay + AssistantParts + Cont…
Astro-Han eef95b8
refactor(ui): extract UserMessageDisplay to dedicated module
Astro-Han 1c01839
refactor(ui): extract tool dispatcher + display chrome to dedicated m…
Astro-Han 78e9506
refactor(ui): extract MessageDivider + text/reasoning/compaction rend…
Astro-Han 36100de
refactor(ui): extract lightweight tool renderers to tools-basic module
Astro-Han d44e0e4
refactor(ui): extract task/agent tool renderer to tools-agent module
Astro-Han 1b43dac
refactor(ui): extract bash tool renderer to tools-shell module
Astro-Han e82bf8f
refactor(ui): extract file-modifying tools + slim message-part aggreg…
Astro-Han c87cb47
refactor(app): extract 5 helper modules from message-timeline
Astro-Han e4b21bd
refactor(app): finish message-timeline split + drop dead title editor
Astro-Han ade42fe
refactor(ui): extract turn-changes + diffs lists + helpers from sessi…
Astro-Han d81e0bf
feat(ui-i18n): add slice 11b.1 W1 surface keys
Astro-Han 9a1ec65
feat(app/session): mount JumpToBottom leaf in message-timeline
Astro-Han 116d542
feat(app/session): wire fork action for W1 agent toolbar
Astro-Han cb90f6b
feat(session-turn): mount W1 leaf components on the default user path
Astro-Han e177559
test(e2e/session): add W1 surface specs covering E1-E16 golden paths
Astro-Han 77f8e8c
fix(session-turn): align W1 bubble + agent toolbar per AstroHan W1 re…
Astro-Han 4457e73
fix(auto-scroll): hold ResizeObserver auto-snap after upward wheel
Astro-Han add81e2
fix(session-turn): wrap W1 toolbar buttons in Tooltip for hover hints
Astro-Han c5a030f
fix(session-turn): selection regression + trow chev direction + neste…
Astro-Han a4b44e2
fix(session-turn): lift dark user bubble fill so the shell stays visible
Astro-Han 6b62f8b
Revert "fix(session-turn): lift dark user bubble fill so the shell st…
Astro-Han d8addf2
fix(session-turn): give dark user bubble a hairline ring for shape
Astro-Han e2e5def
fix(session-timeline): close P0 #6 scroll-up-snaps-back via 3 minimal…
Astro-Han 90e3903
fix(session-turn): scope L417 frame + caption typography to per-tool …
Astro-Han 186d5f8
fix(session-turn): give light user bubble a hairline ring too
Astro-Han 55893a9
fix(session-turn): trow visual fixes — drop per-tool frame, fix chev
Astro-Han b48a860
fix(session-turn): tighten "Thinking…" to pre-first-visible-output only
Astro-Han 8ac986b
test(app): tag W1 regression smoke gate
Astro-Han 8ef83fa
fix(session-turn): trow expand/collapse animation + tighter spacing +…
Astro-Han 83d1dc7
fix(session-turn): wire showReasoningSummaries to AgentRound + trunca…
Astro-Han 9943552
fix(session-turn): drop W1-broken shell/edit tool defaultOpen toggles
Astro-Han b2ee6cb
fix(session-timeline): surface trow toggle as a layout_interaction in…
Astro-Han 47a4760
fix(session): unify timeline scroll ownership
Astro-Han 9e071d2
style(session-turn): tighten trow result body row stride
Astro-Han 969f6a9
perf(ui): render streaming markdown per-block so flushes only diff th…
Astro-Han f7646cb
perf(app): coalesce part deltas across status events
Astro-Han 61e7a0f
perf(app): throttle message part delta flushes
Astro-Han aed3994
perf(ui): suppress streaming-period animations inside the active agen…
Astro-Han f49fd6d
perf(app): unmount the active terminal when the side panel hides it
Astro-Han File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,155 @@ | ||
| import { test, expect } from "../fixtures" | ||
|
|
||
| /** | ||
| * Slice 11b.1 — issue #440 §5.2 E1 + E9 + E10. | ||
| * | ||
| * Smoke E2E for the W1 user-bubble + agent-round surfaces now that | ||
| * `session-turn.tsx` mounts the W1 leaves on the default user path | ||
| * (Phase 2b integration commit). | ||
| * | ||
| * The verifications are structural / behavioral — visual rhythm, | ||
| * shimmer cadence, and reduce-motion are covered by the dev:desktop | ||
| * checklist (D-items) and by the unit tests on the leaves themselves. | ||
| */ | ||
|
|
||
| const USER_BUBBLE = '[data-component="session-turn-user-bubble"]' | ||
| const BUBBLE_TEXT = `${USER_BUBBLE} [data-slot="bubble-text"]` | ||
| const BUBBLE_COPY = `${USER_BUBBLE} [data-action="copy"]` | ||
| const BUBBLE_RESET = `${USER_BUBBLE} [data-action="reset"]` | ||
|
|
||
| const AGENT_ROUND = '[data-component="session-turn-agent-round"]' | ||
| const AGENT_WORKING_TIME = `${AGENT_ROUND} [data-slot="agent-working-time"]` | ||
| const AGENT_PROSE = `${AGENT_ROUND} [data-slot="agent-prose"]` | ||
| const AGENT_COPY = `${AGENT_ROUND} [data-action="copy"]` | ||
| const AGENT_FORK = `${AGENT_ROUND} [data-action="fork"]` | ||
|
|
||
| test("@smoke E1 — user message + agent stream mounts W1 leaves on the default path", async ({ page, llm, project }) => { | ||
| test.setTimeout(120_000) | ||
| await project.open() | ||
|
|
||
| const reply = "W1 agent reply text" | ||
| await llm.text(reply) | ||
| await project.prompt("E1 verify W1 surface") | ||
|
|
||
| // User bubble is the W1 leaf, not the legacy `<UserMessageDisplay>`. | ||
| await expect(page.locator(USER_BUBBLE)).toHaveCount(1, { timeout: 30_000 }) | ||
| await expect(page.locator(BUBBLE_TEXT)).toContainText("E1 verify W1 surface") | ||
|
|
||
| // Agent round is the W1 leaf, agent-toolbar is mounted (visibility | ||
| // is CSS-only; the DOM presence + aria-hidden gate is the testable | ||
| // surface here). | ||
| await expect(page.locator(AGENT_ROUND)).toHaveCount(1, { timeout: 30_000 }) | ||
| await expect(page.locator(AGENT_PROSE)).toContainText(reply, { timeout: 30_000 }) | ||
|
|
||
| // Working-time tick exists once the round has started; the freeze | ||
| // value after the round completes is whatever the assistant message | ||
| // `time.completed - time.created` resolved to. | ||
| await expect(page.locator(AGENT_WORKING_TIME)).toHaveCount(1, { timeout: 30_000 }) | ||
| const tick = await page.locator(AGENT_WORKING_TIME).textContent() | ||
| expect(tick).toMatch(/\d+s/) | ||
| }) | ||
|
|
||
| test("@smoke E9 — bubble [Copy] writes the user text to the clipboard", async ({ page, llm, project, browserName }) => { | ||
| test.setTimeout(120_000) | ||
| // Clipboard API is gated to the active document and requires the | ||
| // Playwright permission grant before navigation in chromium. | ||
| test.skip(browserName !== "chromium", "navigator.clipboard.writeText only granted in chromium fixture") | ||
| await page.context().grantPermissions(["clipboard-read", "clipboard-write"]) | ||
|
|
||
| await project.open() | ||
| await llm.text("agent ack") | ||
| await project.prompt("E9 copy this user text") | ||
| await expect(page.locator(USER_BUBBLE)).toHaveCount(1, { timeout: 30_000 }) | ||
|
|
||
| await page.locator(BUBBLE_COPY).click() | ||
| await expect(page.locator(`${BUBBLE_COPY}[data-copied]`)).toHaveCount(1, { timeout: 5_000 }) | ||
|
|
||
| const value = await page.evaluate(() => navigator.clipboard.readText()) | ||
| expect(value).toContain("E9 copy this user text") | ||
| }) | ||
|
|
||
| test("@smoke E10 — agent toolbar [Copy] writes the concatenated prose to the clipboard", async ({ | ||
| page, | ||
| llm, | ||
| project, | ||
| browserName, | ||
| }) => { | ||
| test.setTimeout(120_000) | ||
| test.skip(browserName !== "chromium", "navigator.clipboard.writeText only granted in chromium fixture") | ||
| await page.context().grantPermissions(["clipboard-read", "clipboard-write"]) | ||
|
|
||
| const prose = "Agent prose paragraph one\n\nparagraph two" | ||
|
|
||
| await project.open() | ||
| await llm.text(prose) | ||
| await project.prompt("E10 user kickoff") | ||
| await expect(page.locator(AGENT_PROSE)).toContainText("paragraph two", { timeout: 30_000 }) | ||
|
|
||
| // The agent-toolbar copy button stays visibility-hidden while the | ||
| // round is running. Wait for the streaming agent message to fully | ||
| // settle by polling the SDK before asserting the copy fires. | ||
| const sessionID = await page.evaluate(() => { | ||
| const url = new URL(window.location.href) | ||
| return url.pathname.split("/session/")[1] ?? "" | ||
| }) | ||
| if (!sessionID) throw new Error("expected a session id in the URL") | ||
| await expect | ||
| .poll( | ||
| async () => | ||
| await project.sdk.session | ||
| .messages({ sessionID, limit: 50 }) | ||
| .then((r) => (r.data ?? []).filter((m) => m.info.role === "assistant" && m.info.time.completed !== undefined).length), | ||
| { timeout: 30_000, intervals: [200, 500, 1_000] }, | ||
| ) | ||
| .toBeGreaterThan(0) | ||
|
|
||
| // Force the toolbar visible via hover so the click lands. The leaf | ||
| // keeps `pointer-events:none` while running. | ||
| await page.locator(AGENT_ROUND).hover() | ||
| await page.locator(AGENT_COPY).click() | ||
| await expect(page.locator(`${AGENT_COPY}[data-copied]`)).toHaveCount(1, { timeout: 5_000 }) | ||
|
|
||
| const value = await page.evaluate(() => navigator.clipboard.readText()) | ||
| expect(value).toContain("Agent prose paragraph one") | ||
| }) | ||
|
|
||
| test("@smoke E7 — bubble [Reset] adopts the legacy revert path (revertDock, no dialog)", async ({ | ||
| page, | ||
| llm, | ||
| project, | ||
| }) => { | ||
| test.setTimeout(120_000) | ||
| await project.open() | ||
|
|
||
| await llm.text("agent ack") | ||
| await project.prompt("E7 reset target") | ||
| await expect(page.locator(USER_BUBBLE)).toHaveCount(1, { timeout: 30_000 }) | ||
| await expect(page.locator(AGENT_PROSE)).toContainText("agent ack", { timeout: 30_000 }) | ||
|
|
||
| await page.locator(USER_BUBBLE).hover() | ||
| await page.locator(BUBBLE_RESET).click() | ||
|
|
||
| // The W1 [Reset] mounts the existing revert dock above the composer. | ||
| // The bubble for the resetted message is removed from the timeline. | ||
| await expect(page.locator(BUBBLE_TEXT)).toHaveCount(0, { timeout: 30_000 }) | ||
| }) | ||
|
|
||
| test("@smoke E8 — agent toolbar [Fork] is mounted next to [Copy] for the W1 surface", async ({ | ||
| page, | ||
| llm, | ||
| project, | ||
| }) => { | ||
| test.setTimeout(120_000) | ||
| await project.open() | ||
|
|
||
| await llm.text("ack pre-fork") | ||
| await project.prompt("E8 fork source") | ||
| await expect(page.locator(AGENT_ROUND)).toHaveCount(1, { timeout: 30_000 }) | ||
| await expect(page.locator(AGENT_PROSE)).toContainText("ack pre-fork", { timeout: 30_000 }) | ||
|
|
||
| // Fork stays mounted in the DOM (visibility is CSS) so a presence | ||
| // assertion is enough — the navigation behaviour is covered by | ||
| // session-w1-fork.spec when the host wires sdk.session.fork. | ||
| await page.locator(AGENT_ROUND).hover() | ||
| await expect(page.locator(AGENT_FORK)).toHaveCount(1) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import { test, expect } from "../fixtures" | ||
|
|
||
| /** | ||
| * Slice 11b.1 — issue #440 §5.2 E14. | ||
| * | ||
| * Tool error path: failed tool calls render the per-row "failed" | ||
| * gloss inside the trow-body. The summary copy switches to the | ||
| * "with-failed" variant when the grouped case has at least one | ||
| * failure. No color shift on the parent (DESIGN.md L466). | ||
| * | ||
| * The single-tool / grouped paths share the same `reduceTrowBlock` | ||
| * + `trowSummaryI18nKey` reducer; the unit reducer test | ||
| * (`session-turn-trow-block.reducer.test.ts`) already pins the pure | ||
| * matrix. This spec validates the E2E wiring: the leaf reads the | ||
| * SDK ToolPart `state.status === "error"` correctly and surfaces | ||
| * the trow-block data-failed attribute that the CSS keys off. | ||
| */ | ||
|
|
||
| const TROW_BLOCK = '[data-component="session-turn-trow-block"]' | ||
| const TROW_BODY = `${TROW_BLOCK} [data-slot="trow-body"]` | ||
| const AGENT_PROSE = '[data-component="session-turn-agent-round"] [data-slot="agent-prose"]' | ||
|
|
||
| test("@smoke E14 — failed tool surfaces the data-failed marker on the trow", async ({ page, llm, project }) => { | ||
| test.setTimeout(120_000) | ||
| await project.open() | ||
|
|
||
| // Mock a bash tool that fails. The TestLLMServer will mark the | ||
| // tool state.status as `error` because the tool call has no | ||
| // settled output and the assistant returns a fail envelope. | ||
| await llm.tool("bash", { command: "false", description: "always fails" }) | ||
| await llm.text("tool failed") | ||
|
|
||
| await project.prompt("E14 tool failure") | ||
| await expect(page.locator(AGENT_PROSE)).toContainText("tool failed", { timeout: 60_000 }) | ||
|
|
||
| const trow = page.locator(TROW_BLOCK).first() | ||
| await expect(trow).toHaveCount(1, { timeout: 60_000 }) | ||
|
|
||
| // Expand to make sure the per-row body is rendered (i.e. renderTool | ||
| // ran and the rich body was mounted). | ||
| await page.locator(`${TROW_BLOCK} summary`).first().click() | ||
| await expect(page.locator(TROW_BODY)).toBeVisible({ timeout: 10_000 }) | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| import { test, expect } from "../fixtures" | ||
|
|
||
| /** | ||
| * Slice 11b.1 — issue #440 §5.2 E2 + E5 + E15 + E16. | ||
| * | ||
| * Covers the secondary W1 surfaces that hang off the bubble + agent | ||
| * round: attachment rail above the bubble (E2), interrupted system | ||
| * event line (E5), multi-turn gap rhythm (E15), and the command | ||
| * palette /fork backup that still ships in 11b.1 alongside the new | ||
| * toolbar Fork (E16). | ||
| */ | ||
|
|
||
| const USER_BUBBLE = '[data-component="session-turn-user-bubble"]' | ||
| const BUBBLE_ATTACHMENT_ROW = `${USER_BUBBLE} [data-slot="bubble-attachment-row"]` | ||
| const BUBBLE_ATTACHMENT_CHIP = '[data-component="attachment-chip"]' | ||
| const AGENT_ROUND = '[data-component="session-turn-agent-round"]' | ||
| const AGENT_PROSE = `${AGENT_ROUND} [data-slot="agent-prose"]` | ||
| const SYSTEM_EVENT = '[data-component="session-turn-event"]' | ||
| const SYSTEM_EVENT_INTERRUPTED = `${SYSTEM_EVENT}[data-kind="interrupted"]` | ||
|
|
||
| test("@smoke E2 — attachment row sits above the bubble for file / image parts", async ({ page, llm, project }) => { | ||
| test.setTimeout(120_000) | ||
| await project.open() | ||
| const sdk = project.sdk | ||
|
|
||
| await llm.text("ack attachment") | ||
|
|
||
| // Use the SDK directly to seed a user message with an attached file | ||
| // part — the browser file picker is out of scope for an E2E smoke | ||
| // test, the rendering contract is what we're verifying here. | ||
| const session = await sdk.session.create({ directory: sdk.directory, title: "E2 attachment" }) | ||
| if (!session.data?.id) throw new Error("session.create failed") | ||
| const sessionID = session.data.id | ||
| project.trackSession(sessionID) | ||
|
|
||
| await sdk.session.promptAsync({ | ||
| sessionID, | ||
| parts: [ | ||
| { type: "text", text: "E2 with attachment" }, | ||
| { | ||
| type: "file", | ||
| mime: "image/png", | ||
| filename: "tiny.png", | ||
| url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", | ||
| }, | ||
| ], | ||
| }) | ||
|
|
||
| await project.gotoSession(sessionID) | ||
| await expect(page.locator(USER_BUBBLE)).toHaveCount(1, { timeout: 30_000 }) | ||
| await expect(page.locator(BUBBLE_ATTACHMENT_ROW)).toHaveCount(1, { timeout: 30_000 }) | ||
| await expect(page.locator(BUBBLE_ATTACHMENT_CHIP).first()).toBeVisible({ timeout: 30_000 }) | ||
| }) | ||
|
|
||
| test("@smoke E5 — Ctrl+C interrupt renders the muted system-event caption", async ({ page, llm, project }) => { | ||
| test.setTimeout(120_000) | ||
| await project.open() | ||
|
|
||
| // Queue a hung response so we can interrupt mid-stream. | ||
| await llm.hang() | ||
|
|
||
| await project.prompt("E5 interrupt me") | ||
| await expect(page.locator(AGENT_ROUND)).toHaveCount(1, { timeout: 30_000 }) | ||
|
|
||
| // Hit the existing escape / interrupt keybinding. The legacy | ||
| // interrupt divider used a `MessageDivider`; the W1 surface drops | ||
| // that and routes the same signal through `SystemEvent` inside the | ||
| // agent round. | ||
| await page.keyboard.press("Escape") | ||
| await expect(page.locator(SYSTEM_EVENT_INTERRUPTED)).toHaveCount(1, { timeout: 30_000 }) | ||
| }) | ||
|
|
||
| test("E15 — multi-turn rounds each get their own working-time + agent round", async ({ | ||
| page, | ||
| llm, | ||
| project, | ||
| }) => { | ||
| test.setTimeout(180_000) | ||
| await project.open() | ||
|
|
||
| await llm.text("first round done") | ||
| await project.prompt("E15 first turn") | ||
| await expect(page.locator(AGENT_PROSE)).toContainText("first round done", { timeout: 60_000 }) | ||
|
|
||
| await llm.text("second round done") | ||
| await project.prompt("E15 second turn") | ||
|
|
||
| await expect(page.locator(USER_BUBBLE)).toHaveCount(2, { timeout: 60_000 }) | ||
| await expect(page.locator(AGENT_ROUND)).toHaveCount(2, { timeout: 60_000 }) | ||
| // Each round has its own working-time tick — locator scoped to the | ||
| // agent-round leaf prevents bleed between rounds. | ||
| await expect(page.locator(`${AGENT_ROUND} [data-slot="agent-working-time"]`)).toHaveCount(2) | ||
| }) | ||
|
|
||
| test("E16 — command palette /fork backup is still mounted alongside the W1 toolbar Fork", async ({ | ||
| page, | ||
| llm, | ||
| project, | ||
| }) => { | ||
| test.setTimeout(120_000) | ||
| await project.open() | ||
|
|
||
| await llm.text("pre-fork ack") | ||
| await project.prompt("E16 dialog backup") | ||
| await expect(page.locator(AGENT_PROSE)).toContainText("pre-fork ack", { timeout: 30_000 }) | ||
|
|
||
| // Open the palette and type /fork — the entry should still be | ||
| // discoverable until the post-11b.1 retirement PR removes it. | ||
| await page.keyboard.press("Meta+K") | ||
| await page.keyboard.type("/fork") | ||
| await expect(page.locator('[data-slash-id="session.fork"]').first()).toBeVisible({ timeout: 10_000 }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| import { test, expect } from "../fixtures" | ||
| import { promptSelector, scrollViewportSelector, sessionTurnListSelector } from "../selectors" | ||
|
|
||
| /** | ||
| * Slice 11b.1 — issue #440 §5.2 E6. | ||
| * | ||
| * Verifies the W1 floating jump-to-bottom button — the new | ||
| * `JumpToBottom` leaf mounted by `message-timeline.tsx` (Phase 2b | ||
| * commit). The button must: | ||
| * | ||
| * - be invisible while the user is pinned to the bottom; | ||
| * - appear immediately when the user scrolls up (no "has new | ||
| * content since unpin" gate, per design doc §3.4); | ||
| * - scroll the viewport to the bottom on click and re-hide. | ||
| */ | ||
|
|
||
| const JUMP_BUTTON = '[data-component="session-turn-jump"]' | ||
|
|
||
| test("E6 — ↓ button mounts and re-pins the timeline to the bottom on click", async ({ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use a lowercase descriptive test name. Rename the title to match the suite convention (lowercase, descriptive phrasing). As per coding guidelines: Use lowercase, descriptive test names (e.g., 'sidebar can be toggled'). 🤖 Prompt for AI Agents |
||
| page, | ||
| llm, | ||
| project, | ||
| }) => { | ||
| test.setTimeout(180_000) | ||
| await project.open() | ||
|
|
||
| // Seed enough turns to force overflow. | ||
| for (let i = 0; i < 6; i++) { | ||
| await llm.text(`turn ${i} agent reply with enough text to push the column past the viewport height`) | ||
| } | ||
| for (let i = 0; i < 6; i++) { | ||
| await project.prompt( | ||
| `turn ${i} user message with enough content to take up vertical room ${"a".repeat(120)}`, | ||
| ) | ||
| } | ||
|
|
||
| await expect(page.locator(promptSelector)).toBeVisible({ timeout: 60_000 }) | ||
|
|
||
| // Scroll the timeline viewport up so the user is no longer pinned. | ||
| await page.evaluate( | ||
| ({ scrollViewportSelector, turnListSelector }) => { | ||
| const list = document.querySelector(turnListSelector) | ||
| const viewport = list?.closest(scrollViewportSelector) | ||
| if (viewport instanceof HTMLElement) viewport.scrollTop = 0 | ||
| }, | ||
| { scrollViewportSelector, turnListSelector: sessionTurnListSelector }, | ||
| ) | ||
|
|
||
| await expect(page.locator(JUMP_BUTTON)).toBeVisible({ timeout: 10_000 }) | ||
|
|
||
| await page.locator(JUMP_BUTTON).click() | ||
|
|
||
| // After click the viewport should be back near the bottom and the | ||
| // button should hide. We don't pin a literal `distance === 0` — | ||
| // the resume scroll path uses an animated scroll, and re-pin is | ||
| // committed once the user is within the bottom threshold. | ||
| await expect | ||
| .poll( | ||
| async () => | ||
| await page.evaluate( | ||
| ({ scrollViewportSelector, turnListSelector }) => { | ||
| const list = document.querySelector(turnListSelector) | ||
| const viewport = list?.closest(scrollViewportSelector) | ||
| if (!(viewport instanceof HTMLElement)) return -1 | ||
| return viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop | ||
| }, | ||
| { scrollViewportSelector, turnListSelector: sessionTurnListSelector }, | ||
| ), | ||
| { timeout: 15_000 }, | ||
| ) | ||
| .toBeLessThan(40) | ||
|
|
||
| await expect(page.locator(JUMP_BUTTON)).toBeHidden({ timeout: 10_000 }) | ||
| }) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
In Playwright, doesexpect(locator.first()).toHaveCount(1)fail when the original locator matches multiple elements, or does it only assert the first element exists?💡 Result:
expect(locator.first()).toHaveCount(1)only asserts that the first element exists (i.e., the original locator matches at least one element). It does not fail if the original locator matches multiple elements, becauselocator.first()returns a new Locator that resolves to exactly one DOM node (the first matching element), andtoHaveCount(1)ensures that this new Locator resolves to exactly 1 DOM node, which it always does by design.[1][2] This is analogous to howexpect(locator.first()).toBeVisible()is used to assert that at least the first element from a list is visible, without failing if there are more.[1] If you want to assert that the original locator matches exactly one element, useexpect(locator).toHaveCount(1)instead.[1][3]Citations:
Assert exact count on the unscoped locator, not on
.first().The current assertion
expect(trow).toHaveCount(1)on.first()will pass whenever at least one TROW_BLOCK exists, even if multiple blocks render. Useexpect(page.locator(TROW_BLOCK)).toHaveCount(1)to validate exactly one block exists.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents