From 07b53425c75f403b59efb4b044e4a8f24277d914 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 17 Oct 2026 21:43:35 -0700 Subject: [PATCH] fix(app): focus the composer when switching session tabs (#56273) --- .../e2e/regression/session-summary.spec.ts | 10 ++-- packages/app/e2e/regression/tabs.spec.ts | 49 +++++++++++++++++-- .../app/src/session/composer/controller.ts | 25 +++++++++- 3 files changed, 75 insertions(+), 9 deletions(-) diff --git a/packages/app/e2e/regression/session-summary.spec.ts b/packages/app/e2e/regression/session-summary.spec.ts index 186c99fdc7b..cf5375b5c56 100644 --- a/packages/app/e2e/regression/session-summary.spec.ts +++ b/packages/app/e2e/regression/session-summary.spec.ts @@ -12,7 +12,7 @@ for (const custom of [false, true]) { await installStressSessionTabs(page) // The legacy command ID; the keybind migration renames it to `details.toggle`. - if (custom) await seed(page, { settings: { keybinds: { "session.summary.toggle": "f8" } } }) + if (custom) await seed(page, { settings: { keybinds: { "session.summary.toggle": "alt+f8" } } }) await page.goto(sessionHref(fixture.sourceID)) const trigger = page.getByRole("button", { name: "Session details", exact: true }) const summary = page.getByRole("dialog", { name: "Session details", exact: true }) @@ -27,9 +27,9 @@ for (const custom of [false, true]) { const tooltip = page.getByRole("tooltip") await expect(tooltip).toContainText("Summary") const mac = await page.evaluate(() => /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)) - const shortcut = custom ? "F8" : mac ? "Meta+Shift+Y" : "Control+Shift+Y" + const shortcut = custom ? "Alt+F8" : mac ? "Meta+Shift+Y" : "Control+Shift+Y" await expect(tooltip.locator('[data-slot="keybind-v2-label"]')).toHaveText( - custom ? ["F8"] : mac ? ["⇧", "⌘", "Y"] : ["Ctrl", "Shift", "Y"], + custom ? (mac ? ["⌥", "F8"] : ["Alt", "F8"]) : mac ? ["⇧", "⌘", "Y"] : ["Ctrl", "Shift", "Y"], ) for (const id of [fixture.sourceID, fixture.targetID, fixture.sourceID]) { @@ -53,13 +53,13 @@ for (const custom of [false, true]) { // The override moved, so storage keeps only its new id and Shortcuts lists no row titled with the old one. const keybinds = () => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "null")?.keybinds) - await expect.poll(keybinds).toEqual({ "details.toggle": "f8" }) + await expect.poll(keybinds).toEqual({ "details.toggle": "alt+f8" }) await page.goto("/settings") const settings = page.getByTestId("settings-screen") await settings.getByRole("tab", { name: "Shortcuts", exact: true }).click() await expect(settings.getByText("Toggle summary", { exact: true })).toBeVisible() await expect(settings.getByText("session.summary.toggle", { exact: true })).toHaveCount(0) - expect(await keybinds()).toEqual({ "details.toggle": "f8" }) + expect(await keybinds()).toEqual({ "details.toggle": "alt+f8" }) }) } diff --git a/packages/app/e2e/regression/tabs.spec.ts b/packages/app/e2e/regression/tabs.spec.ts index f32378e8a28..b65032d76be 100644 --- a/packages/app/e2e/regression/tabs.spec.ts +++ b/packages/app/e2e/regression/tabs.spec.ts @@ -125,10 +125,10 @@ test("inactive session attention dots share the close control without marking th test("a tab does not reopen its title editor while a rename is saving", async ({ page }) => { await mockWorkspace(page, { name: "Tabs", sessions: [a, b] }) await page.goto(sessionHref(a.id)) - const title = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(a.id)}"]) [data-slot="tab-title"]`) + const title = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"]) [data-slot="tab-title"]`) const editor = page.locator('[data-slot="tab-title"][contenteditable="true"]') - await expect(title).toHaveText(a.title) - const save = await holdRoute(page, (url) => url.pathname === `/api/session/${a.id}`, { method: "PATCH" }) + await expect(title).toHaveText(b.title) + const save = await holdRoute(page, (url) => url.pathname === `/api/session/${b.id}`, { method: "PATCH" }) await title.dblclick() await expect(editor).toBeFocused() @@ -189,6 +189,49 @@ test("option arrows move to the previous or next tab, and with shift to the next await expectPath(page, sessionHref(c.id)) }) +for (const navigation of ["horizontal", "vertical", "keyboard"] as const) { + test(`switching session tabs restores the caret and pastes into the destination draft (${navigation})`, async ({ + page, + context, + }) => { + const tabLayout = navigation === "vertical" ? "vertical" : "horizontal" + + const strip = page.locator( + tabLayout === "vertical" ? '[data-slot="vertical-tabs-sidebar"]' : '[data-slot="titlebar-tabs"]', + ) + + await context.grantPermissions(["clipboard-read", "clipboard-write"]) + await openSession(page, { + name: "TabFocus", + sessions: [a, b], + seed: { settings: { appearance: { tabLayout } } }, + }) + const editor = page.locator('[data-component="composer-editor"]') + const tabB = strip.locator(`[data-titlebar-tab-link][href="${sessionHref(b.id)}"]`) + await editor.fill("Draft A") + + await (navigation === "keyboard" ? page.keyboard.press("Control+Alt+ArrowRight") : tabB.click()) + await expectPath(page, sessionHref(b.id)) + await expect(editor).toBeFocused() + await page.evaluate(() => navigator.clipboard.writeText("Draft B")) + await page.keyboard.press("ControlOrMeta+V") + await expect(editor).toHaveText("Draft B") + + await page.keyboard.press("Control+Alt+ArrowLeft") + await expectPath(page, sessionHref(a.id)) + await expect(editor).toBeFocused() + await expect(editor).toHaveText("Draft A") + await page.evaluate(() => navigator.clipboard.writeText(" appended")) + await page.keyboard.press("ControlOrMeta+V") + await expect(editor).toHaveText("Draft A appended") + + await tabB.click() + await expectPath(page, sessionHref(b.id)) + await expect(editor).toBeFocused() + await expect(editor).toHaveText("Draft B") + }) +} + for (const row of [ { tabLayout: "horizontal", width: 360, hasTouch: false, direction: "ltr" }, { tabLayout: "vertical", width: 390, hasTouch: true, direction: "rtl" }, diff --git a/packages/app/src/session/composer/controller.ts b/packages/app/src/session/composer/controller.ts index 1a281524616..2e5cfe8e73d 100644 --- a/packages/app/src/session/composer/controller.ts +++ b/packages/app/src/session/composer/controller.ts @@ -1,6 +1,8 @@ -import { createEffect, createMemo, on, type Accessor } from "solid-js" +import { createEffect, createMemo, on, onCleanup, type Accessor } from "solid-js" +import { useDialog } from "@opencode/ui/context/dialog" import type { ComposerControls } from "@/composer/adapter" import { setCursorPosition } from "@/composer/editor/dom" +import { promptLength } from "@/composer/prompt-parts" import { createComposerModel } from "@/composer/model" import { useSettings } from "@/settings/model" import { createActiveComposerAdapter } from "./adapter" @@ -14,6 +16,7 @@ export function createSessionComposerController(input: { dock: Parameters[0] }) { const settings = useSettings() + const dialog = useDialog() const region = createSessionComposerRegionController(input.dock) let editor: HTMLDivElement | undefined @@ -45,6 +48,26 @@ export function createSessionComposerController(input: { }) const composer = createComposerModel(adapter, { queue }) + // This controller is recreated when a session becomes active. Wait for its draft and + // the editor's DOM commit, without reacting to subsequent cursor or message updates. + createEffect( + on(adapter.ready, (ready) => { + if (!ready) return + + const frame = requestAnimationFrame(() => { + if (!editor?.isConnected || !region.showComposer() || region.child() || dialog.active) return + const active = document.activeElement + + // A control chosen while the session was loading owns focus now. + if (active && active !== document.body && active !== editor && !active.matches("[data-titlebar-tab-link]")) + return + editor.focus({ preventScroll: true }) + setCursorPosition(editor, adapter.state.cursor() ?? promptLength(adapter.state.current())) + }) + + onCleanup(() => cancelAnimationFrame(frame)) + }), + ) const editable = createMemo(() => region.showComposer() && !region.child()) // Requests hide the view without disposing its draft or queue edit. createEffect(on(editable, () => composer.onDragLeave()))