/** * E2E tests for the tile-unread bug — two scenarios: * * 1. TAB (stacked, not visible) — a session opened as a tab via ⌃-click is * NOT visible on screen. When it finishes, the green "unread" dot IS * correct — the user isn't looking at it. This test PASSES. * * 2. SPLIT (side-by-side, visible) — a session dragged to the edge of the * workspace zone opens as a split tile, visible on screen at the same time * as the main session. When it finishes, it should NOT get the green * "unread" dot — the user is looking right at it. This test FAILS until * the fix in session-states.ts:174 lands (the unread check only compares * against $selectedStoredSessionId and ignores $sessionTiles). * * Prerequisite: `npm run build` must have been run so dist/ exists. */ import { expect, test } from '@playwright/test' import { type MockBackendFixture, setupMockBackend, waitForAppReady, } from './fixtures' import { type BackgroundReleaseHandle, createBackgroundReleaseHandle, restartMockServer, SIDEBAR_CROSS_TEXTS, } from './mock-server' /** Finished-unread dot aria-label. */ const UNREAD_DOT_LABEL = 'Finished — unread' /** Background-running dot aria-label. */ const BG_DOT_LABEL = 'Background task running' /** Foreground turn-running dot aria-label. */ const SESSION_RUNNING_DOT_LABEL = 'Session running' /** * The auto-title auxiliary call hits the SAME mock provider as the chat turn, * and its request carries the user's message — trigger keyword included. The * mock's trigger matching is text-based, so the title call consumes a script * index: the real chat turn then gets turn 2 (final answer, NO tool calls), * the background process is never spawned, and the bg dot never appears. * Whether that happens depends on which request lands first — the CI flake * this spec had. Disable auto-title so script indices line up with real chat * turns (same fix as interim-messages.spec.ts). */ const DISABLE_AUTO_TITLE = 'auxiliary:\n title_generation:\n enabled: false' /** Locate a session's sidebar row by its preview text. */ function sessionRow(page: import('@playwright/test').Page, text: string) { return page.locator('[data-slot="sidebar"] button').filter({ hasText: text }).first() } /** Common setup: start a turn with a held bg process + subagent, wait for * the turn to complete, then switch to a new session so the first session is * no longer $selectedStoredSessionId (required before opening a tile). */ async function startTurnAndSwitchAway(page: import('@playwright/test').Page) { // Send E2E_SIDEBAR_CROSS — starts a turn with sleep 5 + subagent. const composer = page.locator('[contenteditable="true"]').first() await composer.waitFor({ state: 'visible', timeout: 10_000 }) await composer.click() await composer.type('E2E_SIDEBAR_CROSS', { delay: 20 }) await page.keyboard.press('Enter') // Wait for the user's message to appear. await page.waitForFunction( () => (document.body.textContent ?? '').includes('E2E_SIDEBAR_CROSS'), undefined, { timeout: 15_000 }, ) // NOTE: while the turn is busy the dot-state priority paints the session as // "working" ('Session running'), which OUTRANKS the background claim — the // 'Background task running' dot only appears once the turn completes while // the (sentinel-held) process is still alive. Polling for the bg dot mid-turn // races the turn length (two model trips + a real subagent spawn) against // the poll budget, which is exactly the flake this spec had on CI. So: wait // for the turn to END first, then assert the bg dot as a stable state. // The final answer text streams before message.complete, so text visibility // alone is not a completion barrier. Wait for the foreground-running state // to clear before asserting the background-process state. await page.waitForFunction( (text) => (document.body.textContent ?? '').includes(text), SIDEBAR_CROSS_TEXTS.finalText, { timeout: 90_000 }, ) await expect .poll( () => page.locator(`[aria-label="${SESSION_RUNNING_DOT_LABEL}"]`).count(), { timeout: 30_000, message: 'session running dot should disappear after turn completes' }, ) .toBe(0) // The background dot must be visible now: the turn is done but the process // is held open by the sentinel, so this is a stable state rather than a // window we have to catch in time. Poll rather than sampling once — the // dot flip is event-driven off the busy=false publish and can land a tick // after the running dot clears. await expect .poll( () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(), { timeout: 30_000, message: 'background dot should be visible after turn completes' }, ) .toBeGreaterThan(0) // Switch to a new session — session A is no longer $selectedStoredSessionId. // This is required: openSessionTile bails if the session is already selected. await page.locator('button:has-text("New session")').first().click() await page.waitForTimeout(2000) } /** Release the held background process, then wait for its dot to clear. */ async function waitForBgProcessToFinish( page: import('@playwright/test').Page, release?: BackgroundReleaseHandle, ) { release?.release() await expect .poll( () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(), { timeout: 30_000, message: 'background dot should disappear after process finishes' }, ) .toBe(0) } // ──────────────────────────────────────────────────────────────────────── // Test 1: TAB (not visible) — unread dot IS correct (PASSES) // ──────────────────────────────────────────────────────────────────────── test.describe('sidebar states — tab (hidden) unread is correct', () => { test.describe.configure({ mode: 'serial' }) let fixture: MockBackendFixture const bgRelease = createBackgroundReleaseHandle() test.beforeAll(async () => { restartMockServer() fixture = await setupMockBackend({ extraConfig: DISABLE_AUTO_TITLE, mockServer: { backgroundReleasePath: bgRelease.path }, }) await waitForAppReady(fixture, 120_000) }) test.afterAll(async () => { bgRelease.release() await fixture?.cleanup() bgRelease.cleanup() }) test('session opened as a tab (not visible) correctly gets unread dot', async () => { const page = fixture.page await startTurnAndSwitchAway(page) // Evidence: session A is in the background (bg dot in sidebar). await page.screenshot({ path: 'test-results/tile-bug-tab-switched-away.png' }) // ⌃-click opens the session as a TAB (center dock = stacked, not visible // unless it's the active tab). The session is NOT on screen. // // With auto-title disabled the sidebar row is titled by the user's // message (the trigger keyword), not the assistant's final text. const row = sessionRow(page, 'E2E_SIDEBAR_CROSS') await row.click({ modifiers: ['Control'] }) await page.waitForTimeout(2000) // Evidence: the tab is open but the session is not visible on screen. await page.screenshot({ path: 'test-results/tile-bug-tab-opened.png' }) await waitForBgProcessToFinish(page, bgRelease) // A tab that's not the active tab IS hidden — the unread dot is correct. // The user is NOT looking at it, so marking it "unread" is right. // // Poll rather than sampling once: "finished-unread" is an event-driven // transition that lands slightly after the running dot clears, and with a // released (rather than slowly-expiring) process there is no incidental // slack between the two. Same reasoning as the cross-session spec. await expect .poll( () => page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count(), { timeout: 30_000, message: 'hidden tab should be marked unread' }, ) .toBeGreaterThan(0) await page.screenshot({ path: 'test-results/tile-bug-tab-unread-correct.png' }) }) }) // ──────────────────────────────────────────────────────────────────────── // Test 2: SPLIT (visible) — unread dot is WRONG (FAILS until fix) // ──────────────────────────────────────────────────────────────────────── test.describe.skip('sidebar states — split (visible) unread bug (RED)', () => { test.describe.configure({ mode: 'serial' }) let fixture: MockBackendFixture const bgRelease = createBackgroundReleaseHandle() test.beforeAll(async () => { restartMockServer() fixture = await setupMockBackend({ extraConfig: DISABLE_AUTO_TITLE, mockServer: { backgroundReleasePath: bgRelease.path }, }) await waitForAppReady(fixture, 120_000) }) test.afterAll(async () => { bgRelease.release() await fixture?.cleanup() bgRelease.cleanup() }) test('session visible in a split tile does NOT get unread dot when it finishes', async () => { const page = fixture.page await startTurnAndSwitchAway(page) // Evidence: session A is in the background (bg dot in sidebar). await page.screenshot({ path: 'test-results/tile-bug-split-switched-away.png' }) // Drag the session row from the sidebar to the right edge of the workspace // zone to create a SPLIT (side-by-side) tile. This triggers the real // startSessionDrag → onCommit → openSessionTile(id, 'right', anchor) path. // With auto-title disabled the sidebar row is titled by the user's message. const row = sessionRow(page, 'E2E_SIDEBAR_CROSS') const rowBox = await row.boundingBox() expect(rowBox, 'session row must be visible').not.toBeNull() // Find the workspace zone — the main chat area. We drop on its right edge. const workspace = page.locator('[data-session-anchor="workspace"]') const wsBox = await workspace.boundingBox() expect(wsBox, 'workspace zone must be visible').not.toBeNull() // Drag from the session row to the right edge of the workspace. // The drag-session's subZonePosition resolves a right-edge drop as 'right' // (a split dock), not 'center' (which would be a composer link). await page.mouse.move(rowBox!.x + rowBox!.width / 2, rowBox!.y + rowBox!.height / 2) await page.mouse.down() // Move in steps so the drag-session's pointermove handler tracks the // position and resolves the drop zone (a single jump can miss the // threshold/engage logic). const targetX = wsBox!.x + wsBox!.width - 20 const targetY = wsBox!.y + wsBox!.height / 2 const steps = 10 for (let i = 1; i <= steps; i++) { const x = rowBox!.x + rowBox!.width / 2 + (targetX - (rowBox!.x + rowBox!.width / 2)) * (i / steps) const y = rowBox!.y + rowBox!.height / 2 + (targetY - (rowBox!.y + rowBox!.height / 2)) * (i / steps) await page.mouse.move(x, y) await page.waitForTimeout(30) } await page.mouse.up() await page.waitForTimeout(2000) // Evidence: the split tile is now open side-by-side — both sessions visible. await page.screenshot({ path: 'test-results/tile-bug-split-opened.png' }) await waitForBgProcessToFinish(page, bgRelease) // THE BUG: the session visible in the split tile should NOT have the green // "finished unread" dot — the user is looking right at it. This assertion // FAILS until the fix in session-states.ts:174 lands. const unreadCount = await page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count() expect(unreadCount, 'session visible in a split tile should NOT be marked unread').toBe(0) // Evidence: the green dot should NOT be here — this screenshot shows the bug. await page.screenshot({ path: 'test-results/tile-bug-split-unread-should-not-exist.png' }) }) })