diff --git a/packages/app/component-tests/extension-host.fixture.tsx b/packages/app/component-tests/extension-host.fixture.tsx index b4f0239f750..c5f093e9b77 100644 --- a/packages/app/component-tests/extension-host.fixture.tsx +++ b/packages/app/component-tests/extension-host.fixture.tsx @@ -67,7 +67,7 @@ const layout: Layout = { toggle() {}, state: () => "closed", stored: () => [], - side: { opened: () => false, toggle() {} }, + side: { opened: () => false, toggle() {}, menu: () => [] }, sidebar: { opened: () => true }, scroll: { get: () => undefined, set() {} }, settings() {}, @@ -334,7 +334,7 @@ function standIn(overrides: Partial): Interface { toggle() {}, state: () => "closed", stored: () => [], - side: { opened: () => true, toggle() {} }, + side: { opened: () => true, toggle() {}, menu: () => [] }, sidebar: { opened: () => false }, scroll: { get: () => undefined, set() {} }, }, diff --git a/packages/app/e2e/regression/mobile-session.spec.ts b/packages/app/e2e/regression/mobile-session.spec.ts index d14a7abda96..0b04c765506 100644 --- a/packages/app/e2e/regression/mobile-session.spec.ts +++ b/packages/app/e2e/regression/mobile-session.spec.ts @@ -1,6 +1,6 @@ import { expect, test, type Locator, type Page } from "@playwright/test" import { seed, sessionHref, type SeedInput } from "../utils/app" -import { openFileLauncher } from "../utils/side-panel" +import { browseFiles, openFileLauncher } from "../utils/side-panel" import { fixture, mockStressTimeline } from "../utils/session-fixture" import { fileNode } from "../utils/workspace" @@ -268,7 +268,7 @@ test("the summary's changes row switches the view and keeps the side tabs", asyn // After a reload forgets the preview, a stored Open file tab is still the slot a narrow-screen file open replaces. const launcher = panel.getByRole("tab", { name: "Open file", exact: true }) - await openFileLauncher(panel) + await browseFiles(page) await expect(launcher).toHaveAttribute("aria-selected", "true") await expect(readme).toHaveCount(0) await page.reload() diff --git a/packages/app/e2e/regression/review.spec.ts b/packages/app/e2e/regression/review.spec.ts index 0ba242dc484..c830d6a1d58 100644 --- a/packages/app/e2e/regression/review.spec.ts +++ b/packages/app/e2e/regression/review.spec.ts @@ -2,7 +2,7 @@ import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise import { expect, test, type Locator, type Page } from "@playwright/test" import { base64Encode } from "@opencode/util/encode" import { SERVER, holdRoute, project, provider, session } from "../utils/app" -import { openFileLauncher } from "../utils/side-panel" +import { browseFiles, openFileLauncher, openReviewLauncher, reviewTabs } from "../utils/side-panel" import { mockOpenCodeServer } from "../utils/mock-server" import { fileDiff, fileNode, openSession } from "../utils/workspace" import { expectSessionTitle } from "../utils/waits" @@ -24,6 +24,8 @@ for (const view of ["desktop", "mobile"] as const) { else await page.getByRole("tablist", { name: "Session view" }).getByRole("tab", { name: "Changes" }).click() const panel = view === "desktop" ? page.locator("#review-panel") : page.locator('[data-component="session-review"]') + + if (view === "desktop") await openReviewLauncher(panel) await expect(panel.getByText("Track, review, and undo changes in this project")).toBeVisible() await expect(panel.getByRole("button", { name: "Git changes" })).toHaveCount(0) const init = panel.getByRole("button", { name: "Create Git repository" }) @@ -45,7 +47,10 @@ test("Git initialization belongs to its session and refreshes after a session sw { id: "ses_init_a", title: "Initialize Alpha" }, { id: "ses_init_b", title: "Initialize Beta" }, ], - seed: { panes: { ses_init_a: { review: true }, ses_init_b: { review: true } } }, + seed: { + panes: { ses_init_a: { review: true }, ses_init_b: { review: true } }, + storage: reviewTabs("C:/OpenCode/ReviewInitSwitch", "ses_init_a", "ses_init_b"), + }, }) // The successful init finishes while another session is routed. @@ -97,7 +102,10 @@ test("a rejected Git init releases its session's button without showing an error { id: "ses_init_failure_a", title: "Initialize Alpha" }, { id: "ses_init_failure_b", title: "Initialize Beta" }, ], - seed: { panes: { ses_init_failure_a: { review: true }, ses_init_failure_b: { review: true } } }, + seed: { + panes: { ses_init_failure_a: { review: true }, ses_init_failure_b: { review: true } }, + storage: reviewTabs("C:/OpenCode/ReviewInitFailure", "ses_init_failure_a", "ses_init_failure_b"), + }, }) const request = await holdRoute(page, (url) => url.pathname === "/api/vcs/init", { method: "POST" }) const init = page.locator("#review-panel").getByRole("button", { name: "Create Git repository", exact: true }) @@ -139,7 +147,10 @@ test("open file tab browses, searches, and tracks missing files", async ({ page }, seed: { panes: { ses_reviewopenfile: { review: true } }, - storage: { "opencode.global.dat:review-panel-v2": { sidebarOpened: false } }, + storage: { + ...reviewTabs("C:/OpenCode/ReviewOpenFile", "ses_reviewopenfile"), + "opencode.global.dat:review-panel-v2": { sidebarOpened: false }, + }, }, }) @@ -151,6 +162,8 @@ test("open file tab browses, searches, and tracks missing files", async ({ page await contextButton.click() await expect(tab("Context")).toHaveAttribute("aria-selected", "true") await openFileLauncher(panel) + const filter = panel.getByRole("combobox", { name: "Filter files" }) + await expect(filter).toBeFocused() await expect(tab("Open file")).toHaveAttribute("aria-selected", "true") await expect(sidebarToggle).toBeDisabled() await expect(sidebar).toBeVisible() @@ -158,9 +171,8 @@ test("open file tab browses, searches, and tracks missing files", async ({ page await expect(tab("Context")).toHaveAttribute("aria-selected", "true") await expect(tab("Open file")).toHaveAttribute("aria-selected", "false") await expect(sidebar).toBeHidden() - await openFileLauncher(panel) - const filter = panel.getByRole("combobox", { name: "Filter files" }) - await expect(filter).toBeFocused() + // The New tab view hides launchers whose tab is open, so the strip's tab returns to it. + await tab("Open file").click() await expect(tab("Open file")).toHaveAttribute("aria-selected", "true") await expect(panel.getByText("ReviewOpenFile", { exact: true })).toBeVisible() @@ -192,7 +204,7 @@ test("open file tab browses, searches, and tracks missing files", async ({ page await expect(tab("README.md")).toHaveAttribute("aria-selected", "true") await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible() - await openFileLauncher(panel) + await browseFiles(page) await expect(tab("README.md")).toHaveCount(0) await expect(sidebar).toBeVisible() await filter.fill("nested") @@ -205,7 +217,7 @@ test("open file tab browses, searches, and tracks missing files", async ({ page await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible() expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 }) - await openFileLauncher(panel) + await browseFiles(page) await expect(tab("nested.ts")).toHaveCount(1) await expect(tab("Open file")).toHaveAttribute("aria-selected", "true") await expect(sidebarToggle).toBeDisabled() @@ -256,7 +268,7 @@ test("open file tab browses, searches, and tracks missing files", async ({ page await expect(tab("file-79.ts")).toHaveCount(1) await contextButton.click() await expect(tab("Context")).toHaveCount(0) - await expect(tab("nested.ts")).toHaveAttribute("aria-selected", "true") + await expect(panel.locator("#session-side-panel-review-tab")).toHaveAttribute("aria-selected", "true") }) for (const listed of [false, true]) { @@ -316,7 +328,7 @@ test("context closes the side region only when its button opened it", async ({ p await expect(context).toHaveCount(0) await expect(toggle).toHaveAttribute("aria-expanded", "false") - // Reopening the region by hand makes it the user's, so closing Context leaves Review open. + // With another tab open, closing Context leaves the region open on it. await contextButton.click() await expect(context).toHaveAttribute("aria-selected", "true") await toggle.click() @@ -324,6 +336,23 @@ test("context closes the side region only when its button opened it", async ({ p await toggle.click() await expect(toggle).toHaveAttribute("aria-expanded", "true") await expect(context).toHaveAttribute("aria-selected", "true") + // "+" opens the New tab view over Context; picking a launcher replaces it with the tab it opens. + const newTab = panel.getByRole("tab", { name: "New tab", exact: true }) + await panel.getByRole("button", { name: "Add tab", exact: true }).click() + await expect(newTab).toHaveAttribute("aria-selected", "true") + await panel.getByRole("button", { name: /^Review/ }).click() + await expect(newTab).toHaveCount(0) + await expect(panel.locator("#session-side-panel-review-tab")).toHaveAttribute("aria-selected", "true") + // Closing Review returns to Context, the tab the view opened over, without bringing the replaced view back. + await panel + .locator('div[data-value="review:changes"]') + .getByRole("button", { name: "Close tab", exact: true }) + .click() + await expect(context).toHaveAttribute("aria-selected", "true") + await expect(newTab).toHaveCount(0) + await panel.getByRole("button", { name: "Add tab", exact: true }).click() + await panel.getByRole("button", { name: /^Review/ }).click() + await context.click() await contextButton.click() await expect(context).toHaveCount(0) await expect(toggle).toHaveAttribute("aria-expanded", "true") @@ -506,7 +535,12 @@ test("a restored file tab keeps the file tree on Files Changed", async ({ page } seed: { panes: { ses_treerestore: { review: true } }, settings: { general: { showFileTree: true } }, - storage: { "opencode.global.dat:layout": { fileTree: { opened: true, width: 240, tab: "changes" } } }, + storage: { + "opencode.global.dat:layout": { + ...reviewTabs(directory, "ses_treerestore")["opencode.global.dat:layout"], + fileTree: { opened: true, width: 240, tab: "changes" }, + }, + }, }, }) const panel = page.locator("#review-panel") @@ -767,6 +801,7 @@ test("image files load into the review panel and keep it painted while loading", }, }) await page.getByRole("button", { name: "Toggle review" }).click() + await openReviewLauncher(page.locator("#review-panel")) await expect(page.locator('#review-panel [data-component="session-review-v2"]')).toBeVisible() // Records every painted frame from before the click until after the image read completes. @@ -947,6 +982,7 @@ test("restores review state and the side-panel tab per session", async ({ page } // Each session selects a file other than the first, which the review would show without a stored selection. await toggle.click() + await openReviewLauncher(panel) await page.getByRole("button", { name: "gamma.ts" }).click() await selectedFile("gamma.ts") await openFileLauncher(panel) @@ -955,6 +991,7 @@ test("restores review state and the side-panel tab per session", async ({ page } await switchSession("Beta review state") await toggle.click() + await openReviewLauncher(panel) await expect(review).toHaveAttribute("aria-selected", "true") await page.getByRole("button", { name: "Git changes" }).click() await page.getByRole("option", { name: "Branch changes" }).click() @@ -1024,6 +1061,7 @@ test("shows and restores last turn changes from the session diff", async ({ page ) const panel = page.locator("#review-panel") await page.getByRole("button", { name: "Toggle review" }).click() + await openReviewLauncher(panel) await page.getByRole("button", { name: "Git changes" }).click() await page.getByRole("option", { name: "Last turn changes" }).click() await expect(page.getByRole("button", { name: "Last turn changes" })).toBeVisible() @@ -1066,6 +1104,7 @@ test("keeps the review state a session stored before extensions", async ({ page .getByRole("button", { name: file, exact: true }) await page.getByRole("button", { name: "Toggle review" }).click() + await openReviewLauncher(page.locator("#review-panel")) await expect(mode("Branch changes")).toBeVisible() await expect(page.locator('[data-slot="session-review-v2-file-name"]')).toHaveText("beta.ts") // The narrow review lists every file and expands the ones left open. @@ -1120,6 +1159,7 @@ test("desktop review waits for the session's stored mode before it loads changes await page.goto(desktop()) await expectSessionTitle(page, title) await toggle.click() + await openReviewLauncher(page.locator("#review-panel")) await mode("Git changes").click() await page.getByRole("option", { name: "Branch changes" }).click() await expect(file).toHaveText("beta.ts") @@ -1160,6 +1200,7 @@ test("a chosen mode the session stops offering shows as Git, and comes back when const file = page.locator('[data-slot="session-review-v2-file-name"]') await page.getByRole("button", { name: "Toggle review", exact: true }).click() + await openReviewLauncher(page.locator("#review-panel")) await mode("Git changes").click() await page.getByRole("option", { name: "Branch changes" }).click() await expect(file).toHaveText("beta.ts") @@ -1181,6 +1222,7 @@ test("names a single change source and keeps the review header rows at 48px", as await openSession(page, { name: "ReviewHeader", project: { vcs: "hg" }, vcsDiff: [fileDiff("src/alpha.ts")] }) await page.getByRole("button", { name: "Toggle review", exact: true }).click() const panel = page.locator("#review-panel") + await openReviewLauncher(panel) const toggle = panel.getByRole("button", { name: "Toggle file tree" }) const header = panel.locator('[data-slot="session-review-v2-sidebar-header"]') const toolbar = panel.locator('[data-slot="session-review-v2-toolbar"]') @@ -1200,6 +1242,15 @@ test("names a single change source and keeps the review header rows at 48px", as await expect(toggle).toHaveAttribute("aria-expanded", "true") await expect(header).toHaveCSS("height", "48px") await expect(toolbar).toHaveCSS("height", "48px") + + // Closing the last tab closes the side region; it opens empty, on its launchers, next time. + const region = page.getByRole("button", { name: "Toggle review", exact: true }) + await panel.locator("#session-side-panel-review-tab").hover() + await panel.getByRole("button", { name: "Close tab", exact: true }).click() + await expect(region).toHaveAttribute("aria-expanded", "false") + await region.click() + await expect(panel.getByRole("tab")).toHaveCount(0) + await expect(panel.getByRole("button", { name: /^Review/ })).toBeVisible() }) for (const direction of ["ltr", "rtl"] as const) { diff --git a/packages/app/e2e/regression/side-panels.spec.ts b/packages/app/e2e/regression/side-panels.spec.ts index 182889c721d..5da9d59ee75 100644 --- a/packages/app/e2e/regression/side-panels.spec.ts +++ b/packages/app/e2e/regression/side-panels.spec.ts @@ -1,6 +1,7 @@ import { expect, test, type Locator, type Page } from "@playwright/test" import { tabKey } from "../utils/app" import { fileDiff, openSession } from "../utils/workspace" +import { openReviewLauncher } from "../utils/side-panel" import { expectSessionTitle } from "../utils/waits" test("review and terminal tabs follow the session tab and the terminal stays mounted", async ({ page }) => { @@ -30,6 +31,7 @@ test("review and terminal tabs follow the session tab and the terminal stays mou } await page.getByRole("button", { name: "Toggle review" }).click() + await openReviewLauncher(page.locator("#review-panel")) await expect(review).toBeVisible() await expect(chat).toHaveCSS("width", "580px") await expect(reviewTab).toHaveText("Files Changed 2740") @@ -46,6 +48,7 @@ test("review and terminal tabs follow the session tab and the terminal stays mou await expect(terminal).toBeHidden() await expectMarked(terminal) await page.getByRole("button", { name: "Toggle review" }).click() + await openReviewLauncher(page.locator("#review-panel")) await expect(review).toBeVisible() await expect(chat).toHaveCSS("width", "520px") // The workspace's terminal reopens in this session's strip instead of starting another shell. @@ -110,7 +113,7 @@ test("review content stays aligned with the side region in both directions", asy await openSession(page, { name: "SideStack", vcsDiff: diffs }) const toggle = page.getByRole("button", { name: "Toggle review" }) await toggle.click() - await expect(page.locator("#review-panel")).toBeVisible() + await openReviewLauncher(page.locator("#review-panel")) await expectTreeSentinel(page) for (const direction of ["ltr", "rtl"] as const) { diff --git a/packages/app/e2e/regression/tabs.spec.ts b/packages/app/e2e/regression/tabs.spec.ts index 49b5f655e7c..d62e8e13e21 100644 --- a/packages/app/e2e/regression/tabs.spec.ts +++ b/packages/app/e2e/regression/tabs.spec.ts @@ -12,7 +12,7 @@ import { session, sessionHref, } from "../utils/app" -import { openFileLauncher } from "../utils/side-panel" +import { openFileLauncher, reviewTabs } from "../utils/side-panel" import { mockServers } from "../utils/mock-server" import { fixture, mockStressTimeline } from "../utils/session-fixture" import { fileNode, mockWorkspace, openSession } from "../utils/workspace" @@ -912,7 +912,10 @@ test("tab shortcuts cycle side-panel tabs when the side panel is focused with mu sessions: [alpha, beta], fileList: (path) => (path ? [] : ["README.md"].map((file) => fileNode(directory, file))), fileContent: (path) => ({ type: "text", content: `contents:${path}` }), - seed: { panes: { [alpha.id]: { review: true }, [beta.id]: { review: true } } }, + seed: { + panes: { [alpha.id]: { review: true }, [beta.id]: { review: true } }, + storage: reviewTabs(directory, alpha.id), + }, }) const panel = page.locator("#review-panel") diff --git a/packages/app/e2e/regression/terminal-input.spec.ts b/packages/app/e2e/regression/terminal-input.spec.ts index 62778bc7a7a..985a7804053 100644 --- a/packages/app/e2e/regression/terminal-input.spec.ts +++ b/packages/app/e2e/regression/terminal-input.spec.ts @@ -142,7 +142,7 @@ test("opens terminals as side panel tabs from the + menu", async ({ page }) => { await editor.click() await expect(editor).toBeFocused() await panel.getByRole("button", { name: "Add tab", exact: true }).click() - await page.getByRole("menuitem", { name: /^Terminal/ }).click() + await panel.getByRole("button", { name: /^Terminal/ }).click() } for (const number of [2, 3]) { diff --git a/packages/app/e2e/utils/side-panel.ts b/packages/app/e2e/utils/side-panel.ts index 9af0a2f9449..e8b1efd676a 100644 --- a/packages/app/e2e/utils/side-panel.ts +++ b/packages/app/e2e/utils/side-panel.ts @@ -1,10 +1,32 @@ -import type { Locator } from "@playwright/test" +import type { Locator, Page } from "@playwright/test" +import { base64Encode } from "@opencode/util/encode" -/** Opens the side panel's "Open file" launcher tab from the strip's + menu. */ +/** Opens the side panel's "Open file" launcher tab from the new tab view the strip's + opens. */ export async function openFileLauncher(panel: Locator) { await panel.getByRole("button", { name: "Add tab", exact: true }).click() - await panel - .page() - .getByRole("menuitem", { name: /^Open file/ }) - .click() + await panel.getByRole("button", { name: /^Open file/ }).click() +} + +/** Opens the file browser with its shortcut, which still works while a file tab hides "Open file" from the New tab view. */ +export async function browseFiles(page: Page) { + await page.keyboard.press("Control+Alt+P") +} + +/** Opens the Review tab from the launcher an empty side panel lists. */ +export async function openReviewLauncher(panel: Locator) { + await panel.getByRole("button", { name: /^Review/ }).click() +} + +/** Storage that restores the Review tab, selected, in each session's side strip, as the app writes it. */ +export function reviewTabs(directory: string, ...sessions: string[]) { + return { + "opencode.global.dat:layout": { + sessionTabs: Object.fromEntries( + sessions.map((id) => [ + `local\u0000${base64Encode(directory)}/${id}`, + { all: ["review:changes"], active: "review:changes" }, + ]), + ), + }, + } } diff --git a/packages/app/src/runtime/extension/host-apis.tsx b/packages/app/src/runtime/extension/host-apis.tsx index 485e69d66a0..1425fd8736f 100644 --- a/packages/app/src/runtime/extension/host-apis.tsx +++ b/packages/app/src/runtime/extension/host-apis.tsx @@ -48,6 +48,7 @@ import { useLanguage } from "@/runtime/i18n/language" import { createEmbeds } from "./embeds" import { useExtensionHost, type HostApiFactories } from "./host" import { createLocatedWrites } from "./located" +import { createSessionPanelMenu } from "./panel-menu" import type { Region } from "./panels" import { deferredHandle, globalStoreTarget, persistedHandle, storeImports, storeName } from "./stores" @@ -306,6 +307,7 @@ export function createHostApis() { side: { opened: (session) => current()?.layout.side.opened(session) ?? false, toggle: (session) => write((value) => value.layout.side.toggle(session)), + menu: () => current()?.layout.side.menu() ?? [], }, sidebar: { opened: () => current()?.layout.sidebar.opened() ?? true }, scroll: { @@ -365,6 +367,7 @@ export function createExtensionAttachment(apis: HostApis) { const host = useExtensionHost() const command = useCommand() const location = useLocation() + const menu = createSessionPanelMenu() const desktop = createMediaQuery("(min-width: 768px)") const narrow = () => !desktop() // The mounted session screen, from its first render, and its object for the session it routes once it has mounted; @@ -678,7 +681,13 @@ export function createExtensionAttachment(apis: HostApis) { if (!value) return located.hold(session, () => close(key, session)) const tab = listed(session, value).find((entry) => entry.key === key)?.tab - layout.panel.close(value, key) + const all = layout.panel.state(value).all + batch(() => { + layout.panel.close(value, key) + + // Closing the last tab closes the side region, which opens with a new tab next time. + if (all.length === 1 && all[0] === key) tabs.setRegion(shellTab(session), "side", false) + }) const view = mountedSession(session) const currentScreen = screen() @@ -808,6 +817,7 @@ export function createExtensionAttachment(apis: HostApis) { side: { opened: sideOpened, toggle: (session) => tabs.setRegion(shellTab(session), "side", !sideOpened(session)), + menu, }, // Open is the stored preference's default, which holds until a session screen shows the preference. sidebar: { opened: () => sidebar()?.opened() ?? true }, diff --git a/packages/app/src/runtime/extension/panel-menu.ts b/packages/app/src/runtime/extension/panel-menu.ts new file mode 100644 index 00000000000..06117f554f1 --- /dev/null +++ b/packages/app/src/runtime/extension/panel-menu.ts @@ -0,0 +1,15 @@ +import { createMemo } from "solid-js" +import { MenuItem } from "@opencode/gui-extensions/sdk" +import { useExtensionHost } from "./host" + +/** The side region's + menu: every extension's `session.panel` item, in order. */ +export function createSessionPanelMenu() { + const host = useExtensionHost() + + return createMemo(() => + host + .list(MenuItem) + .flatMap((item) => (item.menu === "session.panel" ? [item] : [])) + .toSorted((a, b) => (a.order ?? 0) - (b.order ?? 0)), + ) +} diff --git a/packages/app/src/runtime/extension/panels.tsx b/packages/app/src/runtime/extension/panels.tsx index dec716fb754..58f27443d02 100644 --- a/packages/app/src/runtime/extension/panels.tsx +++ b/packages/app/src/runtime/extension/panels.tsx @@ -1,4 +1,15 @@ -import { createEffect, createMemo, For, mergeProps, on, onCleanup, Show, type Accessor, type JSX } from "solid-js" +import { + batch, + createEffect, + createMemo, + For, + mergeProps, + on, + onCleanup, + Show, + type Accessor, + type JSX, +} from "solid-js" import { Schema } from "effect" import { createMediaQuery } from "@solid-primitives/media" import { @@ -84,6 +95,8 @@ export function createRegion(input: { view: Accessor screen: SessionScreen tabs: Tabs + /** Runs when a close leaves no stored tab, in the same batch as the close. */ + onEmptied?: () => void }) { const host = useExtensionHost() const stored = () => input.tabs().all() @@ -205,6 +218,8 @@ export function createRegion(input: { entries, /** Drawn strip order by key. Renders iterate keys so a provider's fresh tab objects never remount a trigger. */ keys: createMemo(() => drawn().map((entry) => entry.key), [], { equals: same }), + /** The strip holds no tab, hidden ones included. */ + empty: createMemo(() => strip().length === 0), entry: (key: string) => byKey().get(key), active, selected: createMemo(() => { @@ -222,7 +237,12 @@ export function createRegion(input: { }, close(key: string) { const entry = byKey().get(key) - input.tabs().close(key) + const last = stored().length === 1 && stored()[0] === key + batch(() => { + input.tabs().close(key) + + if (last) input.onEmptied?.() + }) if (entry) entry.provider.close?.( diff --git a/packages/app/src/runtime/extension/side-region.tsx b/packages/app/src/runtime/extension/side-region.tsx index ea2f23141f0..5809af48cbc 100644 --- a/packages/app/src/runtime/extension/side-region.tsx +++ b/packages/app/src/runtime/extension/side-region.tsx @@ -1,4 +1,4 @@ -import { For, Show, createEffect, createMemo, onCleanup, type JSX } from "solid-js" +import { For, Show, createEffect, createMemo, createSignal, on, onCleanup, untrack, type JSX } from "solid-js" import { createMediaQuery } from "@solid-primitives/media" import { createEventListener } from "@solid-primitives/event-listener" import { DragDropProvider, PointerSensor } from "@dnd-kit/solid" @@ -15,7 +15,6 @@ import { Keybind } from "@opencode/ui/keybind" import { Tooltip } from "@opencode/ui/tooltip" import { Menu } from "@opencode/ui/menu" import { - MenuItem, type MountedSession, type SessionScreen, type PanelSidebar, @@ -26,8 +25,8 @@ import { useLanguage } from "@/runtime/i18n/language" import { useLayout } from "@/shell/state/layout" import { useSessionLayout } from "@/session/session-layout" import type { Sizing } from "@/session/helpers" -import { useExtensionHost } from "./host" import { PanelTrigger } from "./panel-trigger" +import { createSessionPanelMenu } from "./panel-menu" import { RegionContent, type Region } from "./panels" import { ExtensionSlot } from "./render" import { createTabStripScroll } from "./tab-strip-scroll" @@ -46,7 +45,6 @@ export function SideRegion(props: { }) { const layout = useLayout() const language = useLanguage() - const host = useExtensionHost() const { tabs, view, params } = useSessionLayout() const isDesktop = createMediaQuery("(min-width: 768px)") @@ -67,13 +65,31 @@ export function SideRegion(props: { const treeWidth = createMemo(() => (fileOpen() ? `${fileTreeWidth()}px` : "0px")) - const menu = createMemo(() => - host - .list(MenuItem) - .flatMap((item) => (item.menu === "session.panel" ? [item] : [])) - .toSorted((a, b) => (a.order ?? 0) - (b.order ?? 0)), + const menu = createSessionPanelMenu() + const primary = createMemo(() => menu().find((item) => item.primary)) + const launchers = createMemo(() => menu().filter((item) => !item.primary && !item.opened)) + + // Opening the side region with no tabs starts a new tab, once per opening and session. Keyed by the session, not the + // item, whose object changes with every routed session. + const starting = createMemo(() => + tabsOpen() && layout.ready() && props.region.empty() && primary() ? props.view.key : undefined, ) + createEffect(on(starting, (key) => key && untrack(primary)?.run())) + + // Without a new tab item, "+" opens a host-owned new tab view listing the launchers. It shows while the session and + // the region's selection are the ones it opened over, so a tab that opens and selects itself replaces it. + const [adding, setAdding] = createSignal<{ session: string; over: string | undefined }>() + + const newTab = createMemo(() => { + const value = adding() + + return !!value && value.session === props.view.key && value.over === props.region.active() + }) + + // Once replaced, the view is gone: returning to the tab or session it opened over does not show it again. + createEffect(on(newTab, (shown) => !shown && setAdding(undefined), { defer: true })) + let root: HTMLElement | undefined let tabList: HTMLDivElement | undefined let selectionEvent: Event | undefined @@ -140,8 +156,10 @@ export function SideRegion(props: { }} > { + if (value === "new") return + // Kobalte selects the first tab while triggers register, including while the // "+" menu's click is still dispatching. Persist input events on a tab only; the // region owns fallback selection. @@ -150,8 +168,10 @@ export function SideRegion(props: { selectionEvent.eventPhase !== Event.NONE && selectionEvent.target instanceof Element && selectionEvent.target.closest('[role="tab"]') - ) + ) { + setAdding(undefined) props.region.select(value) + } }} >
@@ -186,12 +206,66 @@ export function SideRegion(props: { )} + + setAdding(undefined)} + closeButton={ + { + event.preventDefault() + event.stopPropagation() + }} + onClick={(event) => { + event.preventDefault() + event.stopPropagation() + setAdding(undefined) + }} + icon={} + aria-label={language.t("common.closeTab")} + /> + } + hideCloseButton + > + {language.t("session.tab.new")} + +
1} - fallback={{(item) => }} + when={ + primary() ?? (launchers().length === 0 && menu().length === 1 ? menu()[0] : undefined) + } + fallback={ + 0} + fallback={ + 1}> + + + } + > + + } + variant="ghost-muted" + size="large" + onClick={() => + setAdding({ session: props.view.key, over: props.region.active() }) + } + aria-label={language.t("session.tab.add")} + /> + + + } > - + {(item) => }
@@ -218,31 +292,49 @@ export function SideRegion(props: {
- - -
-
- -
- {language.t("session.files.selectToOpen")} -
-
-
+ + + - "side", - reserve: () => false, - animate: () => !props.size.active(), - sidebar: props.sidebar, - }} - /> + + + {/* With a new tab item, the new tab it opens is on its way. */} + + 0} + fallback={ +
+
+ +
+ {language.t("session.files.selectToOpen")} +
+
+
+ } + > + +
+
+
+
+ {/* Kept mounted under the new tab view, so terminals and editors keep their state. */} +
+ tabsOpen() && !newTab(), + present: tabsVisible, + placement: () => "side", + reserve: () => false, + animate: () => !props.size.active(), + sidebar: props.sidebar, + }} + /> +
@@ -383,3 +475,41 @@ function AddMenu(props: { items: readonly SessionPanelMenuItem[] }): JSX.Element ) } + +/** The + menu items as rows, in the empty side region and the new tab view. */ +function Launchers(props: { items: readonly SessionPanelMenuItem[] }): JSX.Element { + return ( +
+
+ {(item) => } +
+
+ ) +} + +function LauncherRow(props: { item: SessionPanelMenuItem }): JSX.Element { + const command = useCommand() + const keybind = createMemo(() => (props.item.keybind ? command.keybindParts(props.item.keybind) : [])) + + return ( + + ) +} diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 4ee45ce0b1a..abbf094acc2 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -525,6 +525,7 @@ export const dict = { "session.tab.more": "More...", "session.view.select": "Session view", "session.tab.add": "Add tab", + "session.tab.new": "New tab", "session.tab.unknown": "Unknown Session", "session.panel.reviewAndFiles": "Review and files", "session.error.notFound": "This session cannot be found", diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index a1d62890402..b6d60a80580 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -75,6 +75,10 @@ function SessionScreenContent(props: { view: props.view, screen: props.screen, tabs: session.layout.tabs, + // 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() + }, }) onCleanup(attachment.region(region)) diff --git a/packages/gui-extensions/src/browser/bar.css b/packages/gui-extensions/src/browser/bar.css index 0d889ee2391..84c02d1a010 100644 --- a/packages/gui-extensions/src/browser/bar.css +++ b/packages/gui-extensions/src/browser/bar.css @@ -211,6 +211,51 @@ } } +/* A side panel tab the new tab page offers. */ +[data-component="browser-launcher-row"] { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + width: 100%; + height: 41px; + padding-inline: 14px; + border: 0.5px solid var(--v2-border-border-base); + border-radius: 8px; + background: var(--v2-background-bg-base); + color: var(--v2-text-text-base); + font-size: 13px; + font-weight: 440; + line-height: var(--line-height-compact); + letter-spacing: -0.04px; + text-align: start; + cursor: default; + + &:hover { + background: var(--v2-overlay-simple-overlay-hover); + } + + [data-slot="browser-launcher-row-text"] { + display: flex; + flex: 1 1 0; + align-items: center; + gap: 8px; + min-width: 0; + } + + [data-slot="browser-launcher-row-title"] { + flex: none; + } + + [data-slot="browser-launcher-row-description"] { + min-width: 0; + overflow: hidden; + color: var(--v2-text-text-muted); + text-overflow: ellipsis; + white-space: nowrap; + } +} + /* Suggestions read the address at full strength; the new tab page mutes it. */ [data-component="browser-suggestions"] [data-slot="browser-page-row-url"] { color: var(--v2-text-text-base); diff --git a/packages/gui-extensions/src/browser/model.ts b/packages/gui-extensions/src/browser/model.ts index a1bbba185d8..4c024c22371 100644 --- a/packages/gui-extensions/src/browser/model.ts +++ b/packages/gui-extensions/src/browser/model.ts @@ -68,6 +68,9 @@ type PaneHandle = { export type Model = ReturnType +/** A tab showing the new tab page: no page, and none loading. */ +const blank = (tab: Browser.Tab) => (!tab.url || tab.url === "about:blank") && !tab.loading + // Attachments belong to the shell session tab, not the session route: native pages and the agent's // browser survive visiting Settings or another tab and close when the session tab does. export function createModel(ctx: SetupContext) { @@ -153,8 +156,9 @@ export function createModel(ctx: SetupContext) { ) const close = (id: string) => { - live.get(id)?.dispose() + const entry = live.get(id) live.delete(id) + entry?.dispose() opening.delete(id) Array.from(recorded.keys()) .filter((item) => item.startsWith(`${id}\n`)) @@ -290,6 +294,28 @@ export function createModel(ctx: SetupContext) { }), ) + // A new tab holds the place of the tab the user picks next: once another tab is selected, it closes unless a + // page started loading in it. It leaves the strip in the same update, so the picked tab takes its place without + // waiting for the desktop to close its page. + createKeyed( + () => + state.attachments[id]?.browser?.tabs.find((item) => { + const placement = layout.state(key(item.id), ref) + + return blank(item) && (placement === "visible" || placement === "active") + })?.id, + (tabID) => + onCleanup(() => { + const item = state.attachments[id]?.browser?.tabs.find((candidate) => candidate.id === tabID) + + if (live.get(id) !== entry || !item || !blank(item) || layout.state(key(tabID), ref) !== "open") return + layout.close(key(tabID), ref) + + // The host calls the panel's `close` for the routed session only; another session's page closes here. + if (sessions.current()?.key !== ref.key) command(ref, { type: "tabs.close", tabID }) + }), + ) + // Previews made while another session was on screen open once the user returns to this one. createKeyed( () => sessions.current()?.key === ref.key && ctx.screen.current(), diff --git a/packages/gui-extensions/src/browser/panel.fixture.tsx b/packages/gui-extensions/src/browser/panel.fixture.tsx index 8bfb8d7ca72..f44b6728464 100644 --- a/packages/gui-extensions/src/browser/panel.fixture.tsx +++ b/packages/gui-extensions/src/browser/panel.fixture.tsx @@ -356,7 +356,14 @@ export function mountBrowserPane(input: PaneHost) { uses: { pane: { load: () => live } }, sessions: { current: session, list: () => Array.from(views.values()).filter((view) => view.key !== "Empty") }, screen: { current: () => screen }, - layout: { narrow: () => false, stored: () => [], state: () => "visible", open() {}, close() {} }, + layout: { + narrow: () => false, + stored: () => [], + state: () => "visible", + open() {}, + close() {}, + side: { opened: () => true, toggle() {}, menu: () => [] }, + }, links: { open() {} }, stores: { history: { @@ -740,7 +747,7 @@ export function mountBrowserRegion(input: RegionHost) { strip(session.key).all.flatMap((key) => key.startsWith(`${extension}:`) ? [key.slice(extension.length + 1)] : [], ), - side: { opened: () => true, toggle() {} }, + side: { opened: () => true, toggle() {}, menu: () => [] }, sidebar: { opened: () => true }, scroll: { get: () => undefined, set() {} }, settings() {}, diff --git a/packages/gui-extensions/src/browser/panel.tsx b/packages/gui-extensions/src/browser/panel.tsx index c8d396793bb..1fa3b3d0521 100644 --- a/packages/gui-extensions/src/browser/panel.tsx +++ b/packages/gui-extensions/src/browser/panel.tsx @@ -14,7 +14,15 @@ import { createResizeObserver } from "@solid-primitives/resize-observer" import { createMemo, For, on, onCleanup, Show, type Accessor, type JSX } from "solid-js" import { createStore } from "solid-js/store" import type { Browser } from "@opencode/plugin-browser/rpc" -import { createKeyed, useExtension, usePanel, type PanelTab, type MountedSession, type SessionScreen } from "../sdk" +import { + createKeyed, + useExtension, + usePanel, + type PanelTab, + type MountedSession, + type SessionPanelMenuItem, + type SessionScreen, +} from "../sdk" import { addressParts, connection, loopback, resolveAddress, searches } from "./address" import { commentNote, pageNote } from "./comment" import { bare, completes, highlight, suggest, type Visit } from "./history" @@ -190,6 +198,8 @@ export default function SessionBrowserPane(props: { const listed = () => suggestions().length > 0 && !store.dismissed const chosen = () => (listed() ? suggestions()[store.active] : undefined) const recent = () => props.model.visits().slice(0, RECENT) + // The side panel's other new tab choices; picking one replaces this tab. + const launchers = () => extension.layout.side.menu().filter((item) => !item.primary && !item.opened) // A loopback address opens on the machine that runs the server; the system browser here reaches it only when the // server runs here too. @@ -875,33 +885,45 @@ export default function SessionBrowserPane(props: { >