mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
fix(app): focus the side panel when it opens (#57297)
This commit is contained in:
committed by
GitHub
Unverified
parent
627bcc0f87
commit
7a0520bbff
@@ -256,6 +256,34 @@ story("keeps in-page shortcuts like ⌘K in the page while forwarding ⌘⇧R an
|
||||
await expect(root.getByTestId("session-state")).toHaveText("open")
|
||||
})
|
||||
|
||||
story(
|
||||
"reopening the side panel focuses a blank tab's address and closing it returns to the composer",
|
||||
async ({ page }) => {
|
||||
const root = page.getByTestId("browser-region-fixture")
|
||||
const mac = await page.evaluate(() => /Mac|iPhone|iPad|iPod/.test(navigator.platform))
|
||||
const toggle = mac ? "Meta+Shift+r" : "Control+Shift+r"
|
||||
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
|
||||
const composer = root.getByRole("textbox", { name: "Composer", exact: true })
|
||||
const side = root.getByTestId("side-state")
|
||||
|
||||
await expect(root.getByText("Registrations: 1", { exact: true })).toBeVisible()
|
||||
await root.getByRole("button", { name: "Beta", exact: true }).click()
|
||||
await expect(root.getByText("Registrations: 2", { exact: true })).toBeVisible()
|
||||
await root.getByRole("button", { name: "Blank page", exact: true }).click()
|
||||
await expect(root.getByRole("tab", { name: "New tab", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
await composer.focus()
|
||||
await page.keyboard.press(toggle)
|
||||
await expect(side).toHaveText("closed")
|
||||
await page.keyboard.press(toggle)
|
||||
await expect(side).toHaveText("open")
|
||||
await expect(address).toBeFocused()
|
||||
await page.keyboard.press(toggle)
|
||||
await expect(side).toHaveText("closed")
|
||||
await expect(composer).toBeFocused()
|
||||
},
|
||||
)
|
||||
|
||||
story("opens a browser tab with ⌘T inside the pane and a session everywhere else", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-region-fixture")
|
||||
const mac = await page.evaluate(() => /Mac|iPhone|iPad|iPod/.test(navigator.platform))
|
||||
|
||||
@@ -428,6 +428,7 @@ export function mountSessionRegion(input: { definitions: readonly Definition[];
|
||||
remap() {},
|
||||
}),
|
||||
closed: createClosedTabs(),
|
||||
side: () => ({ opened: () => true, toggle() {} }),
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@@ -113,9 +113,13 @@ export function createRegion(input: {
|
||||
screen: SessionScreen
|
||||
tabs: Tabs
|
||||
closed: ReturnType<typeof createClosedTabs>
|
||||
/** Whether the side region is open, and the user's toggle for it. */
|
||||
side: Accessor<{ opened(): boolean; toggle(): void }>
|
||||
/** Runs when a close leaves no stored tab, in the same batch as the close. */
|
||||
onEmptied?: () => void
|
||||
}) {
|
||||
// The text field focus left when the user opened the side region, which a close from inside it returns to.
|
||||
let returnFocus: HTMLElement | undefined
|
||||
const host = useExtensionHost()
|
||||
const stored = () => input.tabs().all()
|
||||
const providers = createMemo(() => host.items(Panel).filter((item) => item.value.region === input.region))
|
||||
@@ -325,6 +329,35 @@ export function createRegion(input: {
|
||||
select(key: string) {
|
||||
input.tabs().setActive(key)
|
||||
},
|
||||
/** Opens or closes the side region for the user. Opening moves focus into it; closing gives focus back. */
|
||||
toggle() {
|
||||
const side = input.side()
|
||||
const focused = document.activeElement
|
||||
|
||||
if (side.opened()) {
|
||||
const inside = !!document.getElementById("review-panel")?.contains(focused)
|
||||
const back = returnFocus
|
||||
returnFocus = undefined
|
||||
side.toggle()
|
||||
|
||||
if (inside && back?.isConnected) back.focus({ preventScroll: true })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
returnFocus =
|
||||
focused instanceof HTMLElement && (focused.isContentEditable || /^(INPUT|TEXTAREA)$/.test(focused.tagName))
|
||||
? focused
|
||||
: undefined
|
||||
side.toggle()
|
||||
const key = active()
|
||||
const entry = key ? byKey().get(key) : undefined
|
||||
|
||||
if (entry) entry.provider.enter?.(panelInput(input.view, input.screen, { tab: entry.tab }))
|
||||
const root = document.getElementById("review-panel")
|
||||
|
||||
if (root && !root.contains(document.activeElement)) root.focus({ preventScroll: true, focusVisible: false })
|
||||
},
|
||||
close(key: string) {
|
||||
const entry = byKey().get(key)
|
||||
const last = stored().length === 1 && stored()[0] === key
|
||||
|
||||
@@ -462,7 +462,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
id: "review.toggle",
|
||||
title: language.t("command.review.toggle"),
|
||||
keybind: "mod+shift+r",
|
||||
onSelect: () => actions.session.layout.view().side.toggle(),
|
||||
onSelect: actions.region.toggle,
|
||||
}),
|
||||
...(actions.region.pane("bottom").keys().length > 0
|
||||
? [
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
|
||||
export function SessionReviewToggle() {
|
||||
export function SessionReviewToggle(props: { onToggle: () => void }) {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const { view } = useSessionLayout()
|
||||
@@ -19,7 +19,7 @@ export function SessionReviewToggle() {
|
||||
reviewKeybind: command.keybindParts("review.toggle"),
|
||||
reviewVisible: true,
|
||||
reviewOpened: view().side.opened(),
|
||||
onReviewToggle: () => view().side.toggle(),
|
||||
onReviewToggle: props.onToggle,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -83,6 +83,7 @@ function SessionScreenContent(props: {
|
||||
screen: props.screen,
|
||||
tabs: session.layout.tabs,
|
||||
closed: attachment.closed,
|
||||
side: () => session.layout.view().side,
|
||||
// Closing the last tab closes the side region, which opens with a new tab next time.
|
||||
onEmptied: () => {
|
||||
if (session.layout.view().side.opened()) session.layout.view().side.toggle()
|
||||
@@ -365,7 +366,7 @@ function SessionScreenContent(props: {
|
||||
onPointerDown={hideTimelineScrollbar}
|
||||
onClick={hideTimelineScrollbar}
|
||||
>
|
||||
<SessionReviewToggle />
|
||||
<SessionReviewToggle onToggle={region.toggle} />
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
|
||||
@@ -133,7 +133,7 @@ flowchart LR
|
||||
- Renders stay mounted. The host hands them the next object through a reactive getter, so read `props.session` (panels), `input.session` (slots) or `state.session` (tab labels) where you use it, and never copy it into a variable.
|
||||
- The workspace files, line comments and composer follow the route, so they belong to the session screen. An action through it targets the session routed at that moment. Read it inside a render or a handler, not once in setup. Key per-screen state, such as cached tab objects, by the screen; key per-session state by `session.key`.
|
||||
- `ctx.screen.current()` and `ctx.sessions.current()` are published together in one batched effect after the screen's first render, with v2's original session-mount timing. Renders already receive their owning screen and session directly. Both public accessors are undefined during the first render, on Home, on a draft, before the interface mounts and once the route leaves the mounted screen. The screen has no `session` property.
|
||||
- All panel callbacks take one named input: `list({ session, screen, open })`, `render({ tab, session, screen })`, `focus({ tab, session, screen, restored })`, `close({ tab, session, screen })`, `reopen({ tab, session, screen })`, `normalize({ id, session, screen })`. Session fields are getters; the screen is the constant owning screen. Render tab fields are live getters too; lifecycle callbacks receive their event's tab. Stored ids and `restored` describe that call. Do not destructure live getters. Every `session.*` slot gets a non-null `screen`; `window.bottom` does not.
|
||||
- All panel callbacks take one named input: `list({ session, screen, open })`, `render({ tab, session, screen })`, `focus({ tab, session, screen, restored })`, `enter({ tab, session, screen })`, `close({ tab, session, screen })`, `reopen({ tab, session, screen })`, `normalize({ id, session, screen })`. Session fields are getters; the screen is the constant owning screen. Render tab fields are live getters too; lifecycle callbacks receive their event's tab. Stored ids and `restored` describe that call. Do not destructure live getters. Every `session.*` slot gets a non-null `screen`; `window.bottom` does not.
|
||||
- Render effects can run before public publication too: change-list views call `Changes.watch(input.screen, source)`, not `ctx.screen.current()`, so their first demand is never lost.
|
||||
- `fallback: true` opts a tab into selection when the stored selection is missing or cannot be selected; false or omission opts out. The host takes the first eligible regular tab, then a `first` tab, then a pinned tab, preserving order within each tier. Closing a tab still selects its neighbour.
|
||||
|
||||
|
||||
@@ -593,9 +593,11 @@ type RegionHost = {
|
||||
view: Accessor<MountedSession>
|
||||
screen: SessionScreen
|
||||
tabs: Accessor<StripTabs>
|
||||
side: Accessor<{ opened(): boolean; toggle(): void }>
|
||||
}): {
|
||||
keys(): readonly string[]
|
||||
active(): string | undefined
|
||||
toggle(): void
|
||||
entry(key: string): { readonly tab: PanelTab; readonly extension: string; readonly provider: Panel } | undefined
|
||||
close(key: string): void
|
||||
reopen(): void
|
||||
@@ -629,6 +631,8 @@ type RegionFixtureState = {
|
||||
closed: boolean
|
||||
/** How many times the review side panel toggled from a shortcut. */
|
||||
reviews: number
|
||||
/** Whether the side region is open. The pane stays mounted either way, so a page keeps its shortcuts. */
|
||||
opened: boolean
|
||||
/** How many times the shell's new session shortcut ran. */
|
||||
sessions: number
|
||||
/** How many browser tabs the pane asked main to open. */
|
||||
@@ -666,6 +670,7 @@ export function mountBrowserRegion(input: RegionHost) {
|
||||
away: false,
|
||||
closed: false,
|
||||
reviews: 0,
|
||||
opened: true,
|
||||
sessions: 0,
|
||||
opens: 0,
|
||||
reopens: 0,
|
||||
@@ -960,6 +965,7 @@ export function mountBrowserRegion(input: RegionHost) {
|
||||
if (next.length !== all.length || next.some((key, index) => key !== all[index])) setAll(view().key, next)
|
||||
},
|
||||
}),
|
||||
side: () => ({ opened: () => store.opened, toggle: () => setStore("opened", (opened) => !opened) }),
|
||||
})
|
||||
|
||||
setSideRegion(() => region)
|
||||
@@ -1038,7 +1044,10 @@ export function mountBrowserRegion(input: RegionHost) {
|
||||
>
|
||||
<input.Commands
|
||||
close={() => setStore("closed", true)}
|
||||
review={() => setStore("reviews", (count) => count + 1)}
|
||||
review={() => {
|
||||
setStore("reviews", (count) => count + 1)
|
||||
sideRegion()?.toggle()
|
||||
}}
|
||||
newSession={() => setStore("sessions", (count) => count + 1)}
|
||||
reopenSession={() => setStore("reopens", (count) => count + 1)}
|
||||
reopenSide={() => sideRegion()?.reopen()}
|
||||
@@ -1077,6 +1086,8 @@ export function mountBrowserRegion(input: RegionHost) {
|
||||
<p data-testid="tree">{JSON.stringify(tree())}</p>
|
||||
<p data-testid="session-state">{store.closed ? "closed" : "open"}</p>
|
||||
<p data-testid="review-toggles">{store.reviews}</p>
|
||||
<p data-testid="side-state">{store.opened ? "open" : "closed"}</p>
|
||||
<textarea aria-label="Composer" />
|
||||
<p data-testid="new-sessions">{store.sessions}</p>
|
||||
<p data-testid="browser-opens">{store.opens}</p>
|
||||
<p data-testid="session-reopens">{store.reopens}</p>
|
||||
|
||||
@@ -252,6 +252,12 @@ const setup: Setup<typeof definition> = (ctx) => {
|
||||
return url && url !== "about:blank" ? () => model()?.openURL(session, url) : undefined
|
||||
},
|
||||
focus: (input) => model()?.focusTab(input.session, input.tab.id),
|
||||
// A blank tab starts in its address field, like a browser's new tab.
|
||||
enter: () => {
|
||||
const pane = model()?.pane()
|
||||
|
||||
if (pane?.visible() && !pane.address()) pane.focusAddress()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -332,6 +332,14 @@ export interface Panel {
|
||||
readonly restored: boolean
|
||||
},
|
||||
): void
|
||||
/**
|
||||
* Runs when the user opens the side region on this tab with its toggle, once the tab is on screen. Move keyboard
|
||||
* focus to the tab's own target, such as a text field. When focus is still outside the region afterwards, the host
|
||||
* focuses the region itself.
|
||||
*
|
||||
* @param input - The selected tab, routed session and owning screen. Do not destructure.
|
||||
*/
|
||||
enter?(input: PanelProps): void
|
||||
}
|
||||
|
||||
/** A search entry of a `SettingsPage`, indexed without mounting the page. */
|
||||
|
||||
@@ -281,6 +281,12 @@ const setup: Setup<typeof Terminal> = (ctx) => {
|
||||
|
||||
if (!input.restored) terminal.requestFocus(pty.id)
|
||||
},
|
||||
enter: (input) => {
|
||||
const terminal = model.load(input.session)
|
||||
const pty = terminal.byTab(input.tab.id)
|
||||
|
||||
if (pty) terminal.requestFocus(pty.id)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user