fix(app): focus the composer when switching session tabs (#56273)

This commit is contained in:
Kit Langton
2026-10-17 21:43:35 -07:00
committed by GitHub
Unverified
parent 7a636bb737
commit 07b53425c7
3 changed files with 75 additions and 9 deletions
@@ -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" })
})
}
+46 -3
View File
@@ -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" },
@@ -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<typeof createSessionComposerRegionController>[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()))