diff --git a/apps/desktop/e2e/correction-session-switch.spec.ts b/apps/desktop/e2e/correction-session-switch.spec.ts index dd32caa61e71..0b9083807606 100644 --- a/apps/desktop/e2e/correction-session-switch.spec.ts +++ b/apps/desktop/e2e/correction-session-switch.spec.ts @@ -21,8 +21,18 @@ const INFERENCE_SWITCH_TRIGGER = 'E2E_INFERENCE_SWITCH_TRIGGER' const INFERENCE_PROMPT = `${INFERENCE_SWITCH_TRIGGER}: original inference prompt must remain singular.` const INFERENCE_CORRECTION = `${INFERENCE_SWITCH_TRIGGER}: correction sent while inference is live.` +// A ⌘T-style tab can put several chat surfaces on the page at once, so every +// locator here is scoped to the ACTIVE one (the most recently mounted surface) +// rather than `.first()` / a bare document query, which would silently target +// the wrong session's composer or transcript. +const SURFACE = '[data-composer-target]' + +function activeSurface(page: Page) { + return page.locator(SURFACE).last() +} + async function send(page: Page, text: string): Promise { - const composer = page.locator('[contenteditable="true"]').first() + const composer = activeSurface(page).locator('[contenteditable="true"]').first() await composer.waitFor({ state: 'visible', timeout: 15_000 }) await composer.click() await composer.type(text, { delay: 5 }) @@ -30,8 +40,9 @@ async function send(page: Page, text: string): Promise { } async function steer(page: Page, text: string): Promise { - const composer = page.locator('[contenteditable="true"]').first() - const primary = page.locator('[data-slot="composer-root"] button[type="submit"]') + const surface = activeSurface(page) + const composer = surface.locator('[contenteditable="true"]').first() + const primary = surface.locator('[data-slot="composer-root"] button[type="submit"]') await composer.waitFor({ state: 'visible', timeout: 15_000 }) await composer.click() @@ -42,55 +53,78 @@ async function steer(page: Page, text: string): Promise { async function waitForTranscriptText(page: Page, text: string): Promise { await page.waitForFunction( - (expected: string) => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected), - text, + ([expected, surfaceSelector]: [string, string]) => { + const surfaces = document.querySelectorAll(surfaceSelector) + const active = surfaces[surfaces.length - 1] + + return (active?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected) + }, + [text, SURFACE] as [string, string], { timeout: 30_000 }, ) } async function textNodeOccurrences(page: Page, text: string): Promise { - return page.evaluate((expected: string) => { - const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') - if (!viewport) return 0 + return page.evaluate( + ([expected, surfaceSelector]: [string, string]) => { + const surfaces = document.querySelectorAll(surfaceSelector) + const viewport = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]') + if (!viewport) return 0 - const walker = document.createTreeWalker(viewport, NodeFilter.SHOW_TEXT) - let count = 0 - while (walker.nextNode()) { - if (walker.currentNode.textContent?.includes(expected)) { - count += 1 + const walker = document.createTreeWalker(viewport, NodeFilter.SHOW_TEXT) + let count = 0 + while (walker.nextNode()) { + if (walker.currentNode.textContent?.includes(expected)) { + count += 1 + } } - } - return count - }, text) + return count + }, + [text, SURFACE] as [string, string], + ) } async function transcriptTextOrder(page: Page): Promise { - return page.evaluate(() => { - const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') + return page.evaluate((surfaceSelector: string) => { + const surfaces = document.querySelectorAll(surfaceSelector) + const viewport = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]') if (!viewport) return [] return Array.from(viewport.querySelectorAll('[data-role="message"], [data-message-id]')) .map(message => message.textContent?.trim() ?? '') .filter(Boolean) - }) + }, SURFACE) } async function transcriptMessageOrder(page: Page): Promise { - return page.evaluate(() => { - const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') + return page.evaluate((surfaceSelector: string) => { + const surfaces = document.querySelectorAll(surfaceSelector) + const viewport = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]') if (!viewport) return [] return Array.from(viewport.querySelectorAll('[data-role="user"], [data-role="assistant"]')) .map(message => message.textContent?.trim() ?? '') .filter(Boolean) - }) + }, SURFACE) } +/** + * The sidebar "+" opens a NEW TAB beside the current chat rather than + * replacing it, so the prior session stays mounted in its own surface. Wait + * for the newly-mounted surface to show an empty transcript instead of waiting + * for the old text to disappear from the page (it never will). + */ async function openFreshDraft(page: Page, priorSessionText: string): Promise { await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click() await page.waitForFunction( - (priorText: string) => !(document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(priorText), - priorSessionText, + ([priorText, surfaceSelector]: [string, string]) => { + const surfaces = document.querySelectorAll(surfaceSelector) + const active = surfaces[surfaces.length - 1] + const transcript = active?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '' + + return surfaces.length > 0 && !transcript.includes(priorText) + }, + [priorSessionText, SURFACE] as [string, string], { timeout: 15_000 }, ) } diff --git a/apps/desktop/e2e/image-attachment-resume.spec.ts b/apps/desktop/e2e/image-attachment-resume.spec.ts index cfa9829a4e2e..3c653f2e2c4f 100644 --- a/apps/desktop/e2e/image-attachment-resume.spec.ts +++ b/apps/desktop/e2e/image-attachment-resume.spec.ts @@ -93,28 +93,54 @@ function sessionRow(page: Page) { return page.locator('[data-slot="sidebar"] button').filter({ hasText: CAPTION }).first() } +// A ⌘T-style tab can put several chat surfaces on the page at once, so +// transcript queries target the ACTIVE (most recently mounted) surface rather +// than the first match, which would read the wrong session. +const SURFACE = '[data-composer-target]' + +function activeViewportText(surfaceSelector: string): string { + const surfaces = document.querySelectorAll(surfaceSelector) + + return surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '' +} + async function openSeededSession(page: Page): Promise { const row = sessionRow(page) await row.waitFor({ state: 'visible', timeout: 60_000 }) await row.click() await page.waitForFunction( - expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected), - CAPTION, + ([expected, surfaceSelector]: [string, string]) => { + const surfaces = document.querySelectorAll(surfaceSelector) + const text = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '' + + return text.includes(expected) + }, + [CAPTION, SURFACE] as [string, string], { timeout: 30_000 }, ) } +/** + * The sidebar "+" opens a NEW TAB beside the current chat instead of replacing + * it, so the seeded session stays mounted in its own surface. Assert the new + * surface is empty rather than waiting for the old caption to leave the page. + */ async function openNewSession(page: Page): Promise { await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click() await page.waitForFunction( - expected => !(document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected), - CAPTION, + ([expected, surfaceSelector]: [string, string]) => { + const surfaces = document.querySelectorAll(surfaceSelector) + const text = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '' + + return surfaces.length > 0 && !text.includes(expected) + }, + [CAPTION, SURFACE] as [string, string], { timeout: 15_000 }, ) } async function transcriptText(page: Page): Promise { - return page.evaluate(() => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '') + return page.evaluate(activeViewportText, SURFACE) } async function assertRendersThumbnail(page: Page, label: string): Promise { diff --git a/apps/desktop/e2e/warm-resume-jitter.spec.ts b/apps/desktop/e2e/warm-resume-jitter.spec.ts index 72e71087d690..657c503bd9ca 100644 --- a/apps/desktop/e2e/warm-resume-jitter.spec.ts +++ b/apps/desktop/e2e/warm-resume-jitter.spec.ts @@ -294,12 +294,15 @@ test('warm-route resume paints transcript exactly once (no jitter)', async ({}, .first() await newSessionButton.click() - // Wait for the new-chat empty state. + // Wait for the new-chat empty state. The "+" opens a NEW TAB beside the + // resumed session rather than replacing it, so the old transcript stays + // mounted — assert the newly-added surface is the empty one. await page.waitForFunction( (firstMsg: string) => { - const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') - if (!viewport) return false - const text = viewport.textContent ?? '' + const surfaces = document.querySelectorAll('[data-composer-target]') + const active = surfaces[surfaces.length - 1] + if (!active) return false + const text = active.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '' return !text.includes(firstMsg) }, FIRST_USER_MSG, @@ -392,11 +395,14 @@ test('warm-route resume after background inference completes (no jitter)', async .locator('[data-slot="sidebar"] button[aria-label="New session"]') .first() await newSessionButton.click() + // "+" stacks a new tab, so the prior transcript stays mounted in its own + // surface — check the newly-added surface rather than the whole page. await page.waitForFunction( (prompt: string) => { - const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') - if (!viewport) return false - return !(viewport.textContent ?? '').includes(prompt) + const surfaces = document.querySelectorAll('[data-composer-target]') + const active = surfaces[surfaces.length - 1] + if (!active) return false + return !(active.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(prompt) }, PROMPT, { timeout: 15_000 }, diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index c3d975b63e81..a182ae68c3da 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -481,14 +481,17 @@ export function ContribWiring({ children }: { children: ReactNode }) { void refreshActiveProfile() }, [activeGatewayProfile, refreshCurrentModel, refreshHermesConfig]) - // New session anchored to a workspace (sidebar "+" on a project/worktree). - // Seeds cwd + branch from the clicked workspace; an explicit worktree path - // also drills the sidebar into that project so the new lane is visible. - // Once a chat is loaded the button opens a TAB instead of replacing it — - // see newSessionOpensTab. + // New session anchored to a workspace. Seeds cwd + branch from the clicked + // workspace; an explicit worktree path also drills the sidebar into that + // project so the new lane is visible. + // + // `openTab` is the sidebar "+" behavior: once a chat is loaded, stack a new + // tab instead of replacing it (see newSessionOpensTab). The composer's + // "branch off into a new worktree" flow keeps the fresh-draft path — it + // prefills the MAIN composer right after, so it has to own that surface. const startSessionInWorkspace = useCallback( - (path: null | string) => { - if (newSessionOpensTab(activeSessionIdRef.current, $selectedStoredSessionId.get())) { + (path: null | string, options?: { openTab?: boolean }) => { + if (options?.openTab && newSessionOpensTab(activeSessionIdRef.current, $selectedStoredSessionId.get())) { void openNewSessionTile('center', { cwd: path, listed: false }) return @@ -799,7 +802,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { navigate(CRON_ROUTE) }, onNavigate: selectSidebarItem, - onNewSessionInWorkspace: startSessionInWorkspace, + onNewSessionInWorkspace: path => startSessionInWorkspace(path, { openTab: true }), onNewSessionSplit: dir => void openNewSessionTile(dir), onPasteClipboardImage: opts => composer.pasteClipboardImage(opts), onPickFiles: () => void composer.pickContextPaths('file'),