Skip to content

Commit a1ed992

Browse files
authored
fix(app): prevent terminal mount from stealing focus (#36576)
1 parent 729bd43 commit a1ed992

7 files changed

Lines changed: 245 additions & 44 deletions

File tree

packages/app/e2e/regression/terminal-composer-focus.spec.ts

Lines changed: 122 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import { base64Encode } from "@opencode-ai/core/util/encode"
2-
import { expect, test } from "@playwright/test"
2+
import { expect, test, type Page } from "@playwright/test"
33
import { mockOpenCodeServer } from "../utils/mock-server"
44
import { expectSessionTitle } from "../utils/waits"
55

66
const directory = "C:/OpenCode/TerminalComposerFocus"
77
const projectID = "proj_terminal_composer_focus"
88
const sessionID = "ses_terminal_composer_focus"
99
const ptyID = "pty_terminal_composer_focus"
10+
const newPtyID = "pty_terminal_composer_focus_new"
1011

1112
test.use({ viewport: { width: 1440, height: 900 } })
1213

13-
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
14+
test.beforeEach(async ({ page }) => {
1415
await mockOpenCodeServer(page, {
1516
directory,
1617
project: {
@@ -67,7 +68,9 @@ test("routes typing to the composer unless the open terminal is focused", async
6768
await page.addInitScript(() => {
6869
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
6970
})
71+
})
7072

73+
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
7174
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
7275
await expectSessionTitle(page, "Terminal composer focus")
7376

@@ -87,3 +90,120 @@ test("routes typing to the composer unless the open terminal is focused", async
8790
await expect(composer).toBeFocused()
8891
await expect(composer).toHaveText("a")
8992
})
93+
94+
test("keeps composer focus when a cached terminal finishes mounting", async ({ page }) => {
95+
const ghostty = Promise.withResolvers<void>()
96+
const release = Promise.withResolvers<void>()
97+
const created = { count: 0 }
98+
await page.route("**/pty", (route) => {
99+
created.count += 1
100+
return route.fulfill({
101+
status: 200,
102+
contentType: "application/json",
103+
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
104+
})
105+
})
106+
await page.route(/ghostty-web/, async (route) => {
107+
ghostty.resolve()
108+
await release.promise
109+
await route.continue()
110+
})
111+
await seedCachedTerminal(page)
112+
113+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`, { waitUntil: "commit" })
114+
await expectSessionTitle(page, "Terminal composer focus")
115+
116+
const composer = page.locator('[data-component="prompt-input"]')
117+
const terminal = page.locator('[data-component="terminal"]')
118+
await expect(terminal).toBeVisible()
119+
expect(created.count).toBe(0)
120+
await ghostty.promise
121+
await composer.click()
122+
await expect(composer).toBeFocused()
123+
124+
release.resolve()
125+
await expect(terminal.locator("textarea")).toHaveCount(1)
126+
await page.waitForTimeout(300)
127+
await expect(composer).toBeFocused()
128+
})
129+
130+
test("keeps newer composer focus while an explicit terminal open finishes", async ({ page }) => {
131+
const ghostty = Promise.withResolvers<void>()
132+
const release = Promise.withResolvers<void>()
133+
await page.route(/ghostty-web/, async (route) => {
134+
ghostty.resolve()
135+
await release.promise
136+
await route.continue()
137+
})
138+
139+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
140+
await expectSessionTitle(page, "Terminal composer focus")
141+
142+
const composer = page.locator('[data-component="prompt-input"]')
143+
const terminal = page.locator('[data-component="terminal"]')
144+
await page.keyboard.press("Control+Backquote")
145+
await expect(terminal).toBeVisible()
146+
await ghostty.promise
147+
await composer.click()
148+
await expect(composer).toBeFocused()
149+
150+
release.resolve()
151+
await expect(terminal.locator("textarea")).toHaveCount(1)
152+
await page.waitForTimeout(50)
153+
await expect(composer).toBeFocused()
154+
})
155+
156+
test("focuses a terminal created from the new-terminal button", async ({ page }) => {
157+
const created = { count: 0 }
158+
await page.route("**/pty", (route) => {
159+
created.count += 1
160+
const next = created.count === 1 ? { id: ptyID, title: "Terminal 1" } : { id: newPtyID, title: "Terminal 2" }
161+
return route.fulfill({
162+
status: 200,
163+
contentType: "application/json",
164+
body: JSON.stringify(next),
165+
})
166+
})
167+
await page.route(`**/pty/${newPtyID}`, (route) =>
168+
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
169+
)
170+
await page.route(`**/pty/${newPtyID}/connect-token*`, (route) =>
171+
route.fulfill({
172+
status: 200,
173+
contentType: "application/json",
174+
headers: { "access-control-allow-origin": "*" },
175+
body: JSON.stringify({ ticket: "e2e-ticket" }),
176+
}),
177+
)
178+
await page.routeWebSocket(new RegExp(`/pty/${newPtyID}/connect`), () => undefined)
179+
180+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
181+
await expectSessionTitle(page, "Terminal composer focus")
182+
183+
const composer = page.locator('[data-component="prompt-input"]')
184+
const terminal = page.locator('[data-component="terminal"]')
185+
await page.keyboard.press("Control+Backquote")
186+
await expect(terminal.locator("textarea")).toHaveCount(1)
187+
await composer.click()
188+
await expect(composer).toBeFocused()
189+
190+
await page.getByRole("button", { name: "New terminal" }).click()
191+
await expect(page.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute("aria-selected", "true")
192+
await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
193+
})
194+
195+
function seedCachedTerminal(page: Page) {
196+
return page.addInitScript(
197+
({ terminalKey, ptyID }) => {
198+
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } }))
199+
localStorage.setItem(
200+
terminalKey,
201+
JSON.stringify({
202+
active: ptyID,
203+
all: [{ id: ptyID, title: "Terminal 1", titleNumber: 1 }],
204+
}),
205+
)
206+
},
207+
{ terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
208+
)
209+
}

packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,12 @@ export function SortableTerminalTabV2(props: {
6565

6666
const focus = () => {
6767
if (store.editing) return
68+
terminal.requestFocus(props.terminal.id)
6869
terminal.open(props.terminal.id)
6970
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
7071
focusTerminalById(props.terminal.id)
72+
const input = document.getElementById(`terminal-wrapper-${props.terminal.id}`)?.querySelector("textarea")
73+
if (input === document.activeElement) terminal.consumeFocus(props.terminal.id)
7174
}
7275

7376
const edit = (e?: Event) => {

packages/app/src/components/terminal.tsx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
2323
export interface TerminalProps extends ComponentProps<"div"> {
2424
pty: LocalPTY
2525
autoFocus?: boolean
26+
onAutoFocus?: () => void
2627
onSubmit?: () => void
2728
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
2829
onConnect?: () => void
@@ -185,7 +186,15 @@ export const Terminal = (props: TerminalProps) => {
185186
const authToken = connection.type === "http" ? connection.authToken : false
186187
const sameOrigin = new URL(url, location.href).origin === location.origin
187188
let container!: HTMLDivElement
188-
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
189+
const [local, others] = splitProps(props, [
190+
"pty",
191+
"class",
192+
"classList",
193+
"autoFocus",
194+
"onAutoFocus",
195+
"onConnect",
196+
"onConnectError",
197+
])
189198
const id = local.pty.id
190199
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
191200
const restoreSize =
@@ -416,6 +425,7 @@ export const Terminal = (props: TerminalProps) => {
416425
fitAddon = fit
417426
serializeAddon = serializer
418427

428+
const active = document.activeElement
419429
t.open(container)
420430
useTerminalUiBindings({
421431
container,
@@ -425,7 +435,22 @@ export const Terminal = (props: TerminalProps) => {
425435
handleLinkClick,
426436
})
427437

428-
if (local.autoFocus !== false) focusTerminal()
438+
if (local.autoFocus === true) {
439+
focusTerminal()
440+
local.onAutoFocus?.()
441+
}
442+
if (local.autoFocus !== true) {
443+
const restoreFocus = () => {
444+
const current = document.activeElement
445+
if (current !== container && !container.contains(current)) return
446+
t.blur()
447+
t.textarea?.blur()
448+
if (active instanceof HTMLElement && active.isConnected) active.focus()
449+
}
450+
restoreFocus()
451+
const timer = setTimeout(restoreFocus, 0)
452+
cleanups.push(() => clearTimeout(timer))
453+
}
429454

430455
if (typeof document !== "undefined" && document.fonts) {
431456
void document.fonts.ready.then(scheduleFit)

packages/app/src/context/terminal.tsx

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,43 @@ function createWorkspaceTerminalSession(
163163
all: [],
164164
}),
165165
)
166+
const [ui, setUi] = createStore({
167+
focus: undefined as { request: number; id?: string; pending: boolean } | undefined,
168+
})
169+
const focus = { request: 0 }
170+
171+
const requestFocus = (id?: string, pending = false) => {
172+
focus.request += 1
173+
setUi("focus", { request: focus.request, id, pending })
174+
return focus.request
175+
}
176+
177+
const focusRequested = (id?: string) => {
178+
if (!id) return false
179+
if (!ui.focus || ui.focus.pending) return false
180+
return !ui.focus.id || ui.focus.id === id
181+
}
182+
183+
const consumeFocus = (id: string) => {
184+
if (!focusRequested(id)) return
185+
setUi("focus", undefined)
186+
}
187+
188+
const cancelFocus = (request?: number) => {
189+
if (request !== undefined && ui.focus?.request !== request) return
190+
setUi("focus", undefined)
191+
}
192+
193+
if (typeof document !== "undefined") {
194+
const cancelOnOutsideFocus = (event: FocusEvent) => {
195+
if (!ui.focus) return
196+
if (!(event.target instanceof Element)) return
197+
if (event.target.closest("#terminal-panel")) return
198+
cancelFocus()
199+
}
200+
document.addEventListener("focusin", cancelOnOutsideFocus)
201+
onCleanup(() => document.removeEventListener("focusin", cancelOnOutsideFocus))
202+
}
166203

167204
const pickNextTerminalNumber = () => {
168205
const existingTitleNumbers = new Set(
@@ -267,23 +304,33 @@ function createWorkspaceTerminalSession(
267304
setStore("all", [])
268305
})
269306
},
270-
new() {
307+
new(options?: { focus?: boolean }) {
271308
const nextNumber = pickNextTerminalNumber()
309+
const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined
272310

273311
sdk.client.pty
274312
.create({ title: defaultTitle(nextNumber) })
275313
.then((pty: { data?: { id?: string; title?: string } }) => {
276314
const id = pty.data?.id
277-
if (!id) return
315+
if (!id) {
316+
if (focusRequest !== undefined) cancelFocus(focusRequest)
317+
return
318+
}
278319
const newTerminal = {
279320
id,
280321
title: pty.data?.title ?? defaultTitle(nextNumber),
281322
titleNumber: nextNumber,
282323
}
283-
setStore("all", store.all.length, newTerminal)
284-
setStore("active", id)
324+
batch(() => {
325+
setStore("all", store.all.length, newTerminal)
326+
setStore("active", id)
327+
if (focusRequest !== undefined && ui.focus?.request === focusRequest) {
328+
setUi("focus", { request: focusRequest, id, pending: false })
329+
}
330+
})
285331
})
286332
.catch((error: unknown) => {
333+
if (focusRequest !== undefined) cancelFocus(focusRequest)
287334
console.error("Failed to create terminal", error)
288335
})
289336
},
@@ -324,6 +371,18 @@ function createWorkspaceTerminalSession(
324371
open(id: string) {
325372
setStore("active", id)
326373
},
374+
requestFocus(id?: string) {
375+
requestFocus(id)
376+
},
377+
focusRequested(id?: string) {
378+
return focusRequested(id)
379+
},
380+
consumeFocus(id: string) {
381+
consumeFocus(id)
382+
},
383+
cancelFocus() {
384+
cancelFocus()
385+
},
327386
next() {
328387
const index = store.all.findIndex((x) => x.id === store.active)
329388
if (index === -1) return
@@ -442,13 +501,17 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
442501
ready: () => workspace().ready(),
443502
all: () => workspace().all(),
444503
active: () => workspace().active(),
445-
new: () => workspace().new(),
504+
new: (options?: { focus?: boolean }) => workspace().new(options),
446505
update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
447506
trim: (id: string) => workspace().trim(id),
448507
trimAll: () => workspace().trimAll(),
449508
clone: (id: string) => workspace().clone(id),
450509
bind: () => workspace(),
451510
open: (id: string) => workspace().open(id),
511+
requestFocus: (id?: string) => workspace().requestFocus(id),
512+
focusRequested: (id?: string) => workspace().focusRequested(id),
513+
consumeFocus: (id: string) => workspace().consumeFocus(id),
514+
cancelFocus: () => workspace().cancelFocus(),
452515
close: (id: string) => workspace().close(id),
453516
move: (id: string, to: number) => workspace().move(id, to),
454517
next: () => workspace().next(),

0 commit comments

Comments
 (0)