From 4f6060ad94c44829ee27206a5a63bf0c10cdfd11 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:51:07 +0800 Subject: [PATCH] feat(app): route settings and refine shell styling (#46984) --- .../regression/mobile-review-summary.spec.ts | 4 ++ .../app/e2e/regression/mobile-shell.spec.ts | 2 + .../remote-session-settings.spec.ts | 13 +++- .../e2e/regression/settings-loading.spec.ts | 18 ++++++ .../regression/tab-navigate-mousedown.spec.ts | 4 +- packages/app/src/composer/editor/editor.css | 3 +- packages/app/src/new-session/view.tsx | 2 +- packages/app/src/settings/settings.css | 18 +++--- packages/app/src/settings/shell.tsx | 37 ++++------- packages/app/src/settings/surface.tsx | 52 ++++++++++----- packages/app/src/shell/routes/routes.tsx | 3 + packages/app/src/shell/shell.tsx | 15 +---- packages/app/src/shell/state/layout.test.ts | 6 +- packages/app/src/shell/state/layout.tsx | 2 + .../app/src/shell/titlebar/history.test.ts | 53 +++++++++++----- packages/app/src/shell/titlebar/history.ts | 26 +++++--- packages/app/src/shell/titlebar/tab-nav.tsx | 15 ++++- packages/app/src/shell/titlebar/tab-strip.tsx | 2 +- packages/app/src/shell/titlebar/titlebar.tsx | 63 +++++++++++++------ .../ui/src/typography/wordmark/wordmark.tsx | 2 +- 20 files changed, 226 insertions(+), 114 deletions(-) diff --git a/packages/app/e2e/regression/mobile-review-summary.spec.ts b/packages/app/e2e/regression/mobile-review-summary.spec.ts index 5c28373eb82..d170a139f4d 100644 --- a/packages/app/e2e/regression/mobile-review-summary.spec.ts +++ b/packages/app/e2e/regression/mobile-review-summary.spec.ts @@ -92,6 +92,8 @@ for (const direction of ["ltr", "rtl"] as const) { .poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.mobileDiffWrap)) .toBe(false) await settings.getByRole("button", { name: "Back to app", exact: true }).click() + await expect(page).toHaveURL(stressSessionHref(fixture.targetID)) + await page.getByRole("tab", { name: "Changes", exact: true }).click() await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "scroll") await expect .poll(() => modified.locator("[data-code]").evaluate((element) => element.scrollWidth > element.clientWidth)) @@ -114,6 +116,8 @@ for (const direction of ["ltr", "rtl"] as const) { .poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.mobileDiffWrap)) .toBe(true) await settings.getByRole("button", { name: "Back to app", exact: true }).click() + await expect(page).toHaveURL(stressSessionHref(fixture.targetID)) + await navigation.getByRole("tab", { name: "Changes", exact: true }).click() await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "wrap") const openFile = modified.getByRole("button", { name: "Open file", exact: true }) await expect(openFile).toBeVisible() diff --git a/packages/app/e2e/regression/mobile-shell.spec.ts b/packages/app/e2e/regression/mobile-shell.spec.ts index be968c85b26..3e7ad693dcc 100644 --- a/packages/app/e2e/regression/mobile-shell.spec.ts +++ b/packages/app/e2e/regression/mobile-shell.spec.ts @@ -67,6 +67,7 @@ test("mobile settings section menu stays above a full-width panel", async ({ pag const menu = settings.getByRole("button", { name: "Preferences", exact: true }) const panel = settings.getByRole("tabpanel") await expect(settings.getByRole("heading", { name: "General", exact: true })).toBeVisible() + await expect(page).toHaveURL("/settings") await expect(menu).toBeVisible() await menu.click() await expect(page.getByRole("menuitemradio", { name: "Preferences", exact: true })).toBeChecked() @@ -87,6 +88,7 @@ test("mobile settings section menu stays above a full-width panel", async ({ pag .toBe(true) await expect.poll(async () => (await panel.boundingBox())?.width ?? 0).toBeGreaterThan(350) await settings.getByRole("button", { name: "Back to app", exact: true }).click() + await expect(page).toHaveURL("/") await expect(settings).toBeHidden() await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(fixture.sessions.length) }) diff --git a/packages/app/e2e/regression/remote-session-settings.spec.ts b/packages/app/e2e/regression/remote-session-settings.spec.ts index 35296f93e5e..ebb9f36dfc3 100644 --- a/packages/app/e2e/regression/remote-session-settings.spec.ts +++ b/packages/app/e2e/regression/remote-session-settings.spec.ts @@ -30,9 +30,11 @@ test("session settings use the remote server context", async ({ page }) => { const settings = page.getByTestId("settings-screen") await expect(settings).toBeVisible() + await expect(page).toHaveURL("/settings") + await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0) + await expect(page.getByRole("button", { name: "Home", exact: true })).toHaveAttribute("aria-pressed", "false") await expect(page.getByRole("dialog")).toHaveCount(0) await expect(settings.getByRole("tablist")).toHaveCSS("width", "328px") - await expect(sessionHeading).toBeAttached() await expect(sessionHeading).toBeHidden() const autoAccept = settings.locator('[data-action="settings-auto-accept-permissions"]') const input = autoAccept.getByRole("switch") @@ -66,6 +68,15 @@ test("session settings use the remote server context", async ({ page }) => { await expect(settings.getByRole("switch", { name: "Server A Model" })).toHaveCount(0) await settings.getByRole("button", { name: "Back to app" }).click() await expect(settings).toBeHidden() + await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`) + await expect(sessionHeading).toBeVisible() + await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toContainText(sessionB.title) + await page.keyboard.press("Control+]") + await expect(page).toHaveURL("/settings") + await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveAttribute("aria-selected", "true") + await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0) + await page.keyboard.press("Escape") + await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`) await expect(sessionHeading).toBeVisible() }) diff --git a/packages/app/e2e/regression/settings-loading.spec.ts b/packages/app/e2e/regression/settings-loading.spec.ts index 2d06a944e33..e30b2cf2880 100644 --- a/packages/app/e2e/regression/settings-loading.spec.ts +++ b/packages/app/e2e/regression/settings-loading.spec.ts @@ -32,6 +32,24 @@ test.beforeEach(async ({ page }) => { await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences" })).toBeVisible() }) +test("settings has its own route and returns through app history", async ({ page }) => { + const settings = page.getByTestId("settings-screen") + const home = page.getByRole("button", { name: "Home", exact: true }) + await expect(page).toHaveURL("/settings") + await expect(home).toHaveAttribute("aria-pressed", "false") + await settings.getByRole("button", { name: "Back to app", exact: true }).click() + await expect(page).toHaveURL("/") + await expect(home).toHaveAttribute("aria-pressed", "true") + await page.keyboard.press("Control+]") + await expect(page).toHaveURL("/settings") + await expect(settings.getByRole("tab", { name: "Preferences", exact: true })).toBeVisible() + await expect(home).toHaveAttribute("aria-pressed", "false") + await home.click() + await expect(page).toHaveURL("/") + await expect(settings).toBeHidden() + await expect(home).toHaveAttribute("aria-pressed", "true") +}) + test("workspaces opens without waiting for inventory or sessions", async ({ page }) => { const inventory = Promise.withResolvers() const sessions = Promise.withResolvers() diff --git a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts index d12e0bfc92d..57887d360b8 100644 --- a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts +++ b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts @@ -29,7 +29,7 @@ test("new session tab matches neighboring session widths", async ({ page }, test await page.goto(href) const tabs = page.locator("[data-titlebar-tab-slot]") - await expect(tabs.locator("[data-titlebar-tab-title]")).toHaveText([sessionA.title, "New session", sessionB.title]) + await expect(tabs.locator("[data-titlebar-tab-title]")).toHaveText([sessionA.title, "Session", sessionB.title]) await testInfo.attach("new-session-between-tabs", { body: await page.locator('[data-slot="titlebar-v2"]').screenshot(), contentType: "image/png", @@ -194,7 +194,7 @@ test("vertical tabs show project details, resize, and navigate", async ({ page } .poll(async () => { const bounds = await sidebar.boundingBox() const button = await status.boundingBox() - return !!bounds && !!button && bounds.x + bounds.width - button.x - button.width <= 12 + return !!bounds && !!button && button.x >= bounds.x && button.x - bounds.x <= 12 }) .toBe(true) await expect(page.locator('[data-slot="titlebar-v2"]')).toBeHidden() diff --git a/packages/app/src/composer/editor/editor.css b/packages/app/src/composer/editor/editor.css index ec5f9c55d7e..1352a806295 100644 --- a/packages/app/src/composer/editor/editor.css +++ b/packages/app/src/composer/editor/editor.css @@ -2,6 +2,7 @@ content: "\200B"; } -[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"] { +[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"], +[data-color-scheme="dark"] [data-component="new-session"] [data-component="composer"] { background: var(--v2-background-bg-layer-01); } diff --git a/packages/app/src/new-session/view.tsx b/packages/app/src/new-session/view.tsx index ee7210c40a9..ffdede6ae52 100644 --- a/packages/app/src/new-session/view.tsx +++ b/packages/app/src/new-session/view.tsx @@ -52,7 +52,7 @@ export function NewSessionView(props: {
diff --git a/packages/app/src/settings/settings.css b/packages/app/src/settings/settings.css index 18de59ecc72..e6cbfc66c11 100644 --- a/packages/app/src/settings/settings.css +++ b/packages/app/src/settings/settings.css @@ -7,13 +7,17 @@ .settings-screen { display: flex; - width: 100%; - height: 100%; + flex: 1; + width: calc(100% - 16px); min-width: 0; min-height: 0; + margin-inline: 8px; + margin-block: var(--shell-top-inset, 8px) var(--shell-bottom-inset, 8px); justify-content: center; overflow: hidden; - background: var(--v2-background-bg-deep); + border-radius: 10px; + background: var(--v2-background-bg-base); + box-shadow: var(--v2-elevation-raised); outline: none; container: settings-screen / inline-size; } @@ -27,7 +31,7 @@ @media (max-width: 767px) { .settings-screen { --settings-mobile-inner-inset: 8px; - padding-block: var(--settings-top-inset, var(--shell-top-inset, 8px)) var(--shell-bottom-inset, 8px); + margin-block-start: var(--settings-top-inset, var(--shell-top-inset, 8px)); } } @@ -51,7 +55,7 @@ .settings-screen .settings-tab-header { padding: 48px 0 32px; - background: linear-gradient(to bottom, var(--v2-background-bg-deep) calc(100% - 24px), transparent); + background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent); } .settings-screen .settings-tab-body { @@ -349,7 +353,7 @@ gap: 16px; padding: 8px var(--settings-mobile-inner-inset, 16px); border-bottom: 0.5px solid var(--v2-border-border-muted); - background: var(--v2-background-bg-deep); + background: var(--v2-background-bg-base); } .settings-mobile-nav::after { @@ -358,7 +362,7 @@ inset-inline: 0; inset-block-start: 100%; height: 1px; - background: var(--v2-background-bg-deep); + background: var(--v2-background-bg-base); pointer-events: none; } diff --git a/packages/app/src/settings/shell.tsx b/packages/app/src/settings/shell.tsx index 372c66ba670..e3680ccd09b 100644 --- a/packages/app/src/settings/shell.tsx +++ b/packages/app/src/settings/shell.tsx @@ -1,14 +1,4 @@ -import { - Component, - createEffect, - createMemo, - createSignal, - For, - Show, - onCleanup, - onMount, - startTransition, -} from "solid-js" +import { Component, createEffect, createMemo, For, Show, onCleanup, onMount, startTransition } from "solid-js" import { Tabs } from "@opencode-ai/ui/tabs" import { Icon } from "@opencode-ai/ui/icon" import { Menu } from "@opencode-ai/ui/menu" @@ -54,9 +44,7 @@ const sections = [ ], ] as const -export const SettingsScreen: Component<{ - defaultValue?: string -}> = (props) => { +export const SettingsScreen: Component = () => { const language = useLanguage() const platform = usePlatform() const dialog = useDialog() @@ -66,7 +54,6 @@ export const SettingsScreen: Component<{ const servers = useServers() const tabs = useTabs() const global = useGlobal() - const [tab, setTab] = createSignal(props.defaultValue ?? "general") let root: HTMLDivElement | undefined onMount(() => { @@ -75,10 +62,8 @@ export const SettingsScreen: Component<{ }) onCleanup(() => command.keybinds(true)) - createEffect(() => setTab(props.defaultValue ?? "general")) - const server = createMemo(() => { - const route = layout.route() + const route = surface.route() switch (route.type) { case "draft": { const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID) @@ -101,7 +86,7 @@ export const SettingsScreen: Component<{ const selected = global.settings.server.selected() const current = server() if (!selected || !current || ServerConnection.key(selected) !== ServerConnection.key(current)) return - const route = layout.route() + const route = surface.route() if (route.type === "draft") { const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID) return draft?.type === "draft" ? draft.directory : undefined @@ -112,7 +97,7 @@ export const SettingsScreen: Component<{ const showProviders = () => { dialog.close() - setTab("providers") + surface.open("providers") } return ( @@ -130,8 +115,8 @@ export const SettingsScreen: Component<{ void startTransition(() => setTab(value))} + value={surface.tab()} + onChange={(value) => void startTransition(() => surface.open(value))} class="settings" >
@@ -143,14 +128,18 @@ export const SettingsScreen: Component<{ {language.t( - sections.flat().find((section) => section.value === tab())?.label ?? "settings.tab.preferences", + sections.flat().find((section) => section.value === surface.tab())?.label ?? + "settings.tab.preferences", )} event.stopPropagation()}> - void startTransition(() => setTab(value))}> + void startTransition(() => surface.open(value))} + > {(group, index) => ( <> diff --git a/packages/app/src/settings/surface.tsx b/packages/app/src/settings/surface.tsx index cc24ee840f2..1b7d9021f1c 100644 --- a/packages/app/src/settings/surface.tsx +++ b/packages/app/src/settings/surface.tsx @@ -1,32 +1,52 @@ -import { useLocation } from "@solidjs/router" +import { useLocation, useNavigate } from "@solidjs/router" import { createEffect, on } from "solid-js" -import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" +import { useLayout, type LayoutRoute } from "@/shell/state/layout" +import { useCommand } from "@/shell/commands/command" export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = createSimpleContext({ name: "SettingsSurface", gate: false, init: () => { - const location = useLocation() - const [store, setStore] = createStore({ open: false, tab: "general" }) + const navigate = useNavigate() + const layout = useLayout() + const command = useCommand() + const location = useLocation<{ + settings?: { route: Exclude; tab: string } + }>() + const open = () => layout.route().type === "settings" + const source = () => location.state?.settings?.route ?? { type: "home" as const } let focus: HTMLElement | undefined - const close = () => { - if (!store.open) return - setStore("open", false) - if (focus?.isConnected) focus.focus({ preventScroll: true }) - focus = undefined - } - - createEffect(on(() => `${location.pathname}${location.search}`, close, { defer: true })) + createEffect( + on( + open, + (value) => { + if (value) return + if (focus?.isConnected) focus.focus({ preventScroll: true }) + focus = undefined + }, + { defer: true }, + ), + ) return { - store, + active: open, + route: source, + tab: () => location.state?.settings?.tab ?? "general", open(tab = "general") { - if (!store.open && document.activeElement instanceof HTMLElement) focus = document.activeElement - setStore({ open: true, tab }) + const route = layout.route() + if (route.type !== "settings") { + if (document.activeElement instanceof HTMLElement) focus = document.activeElement + } + navigate("/settings", { + replace: open(), + state: { settings: { route: route.type === "settings" ? source() : route, tab } }, + }) + }, + close() { + if (open()) command.trigger("common.goBack") }, - close, } }, }) diff --git a/packages/app/src/shell/routes/routes.tsx b/packages/app/src/shell/routes/routes.tsx index 3138b7ae513..c708914266d 100644 --- a/packages/app/src/shell/routes/routes.tsx +++ b/packages/app/src/shell/routes/routes.tsx @@ -13,6 +13,7 @@ import { requireServerKey } from "./session" export const File = lazy(() => import("@opencode-ai/session-ui/file").then((module) => ({ default: module.File }))) const loadSessionRoute = () => Promise.all([import("@/session/route"), File.preload()]).then(([module]) => module) const DraftRoute = lazy(() => import("@/new-session/route").then((module) => ({ default: module.DraftRoute }))) +const SettingsScreen = lazy(() => import("@/settings/shell").then((module) => ({ default: module.SettingsScreen }))) const TargetSessionRouteContent = lazy(() => loadSessionRoute().then((module) => ({ default: module.TargetSessionRouteContent })), ) @@ -20,6 +21,7 @@ const TargetSessionRouteContent = lazy(() => export function preloadRoute(url: string) { const pathname = url.split(/[?#]/, 1)[0] if (pathname === "/new-session") return DraftRoute.preload().then(() => undefined) + if (pathname === "/settings") return SettingsScreen.preload().then(() => undefined) if (/^\/server\/[^/]+\/session\/[^/]+$/.test(pathname)) return TargetSessionRouteContent.preload().then(() => undefined) return Promise.resolve() @@ -29,6 +31,7 @@ export function AppRoutes() { return ( + ( diff --git a/packages/app/src/shell/shell.tsx b/packages/app/src/shell/shell.tsx index c1f67cf6836..9a684177bc0 100644 --- a/packages/app/src/shell/shell.tsx +++ b/packages/app/src/shell/shell.tsx @@ -10,7 +10,6 @@ import { useSettingsSurface } from "@/settings/surface" import { useSettings } from "@/settings/model" const DebugBar = lazy(() => import("@/shell/debug/debug-bar").then((module) => ({ default: module.DebugBar }))) -const SettingsScreen = lazy(() => import("@/settings/shell").then((module) => ({ default: module.SettingsScreen }))) export default function Layout(props: ParentProps) { const platform = usePlatform() @@ -86,24 +85,14 @@ export default function Layout(props: ParentProps) { class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-content" style={{ "padding-top": bottomTitlebar() ? "env(safe-area-inset-top, 0px)" : "0px", - "padding-bottom": bottomTitlebar() || settings.store.open ? "0px" : "env(safe-area-inset-bottom, 0px)", + "padding-bottom": bottomTitlebar() || settings.active() ? "0px" : "env(safe-area-inset-bottom, 0px)", "--settings-bottom-inset": bottomTitlebar() ? "40px" : "env(safe-area-inset-bottom, 0px)", "--settings-top-inset": mobile() && !bottomTitlebar() ? "0px" : "var(--shell-top-inset, 8px)", }} > - diff --git a/packages/app/src/shell/state/layout.test.ts b/packages/app/src/shell/state/layout.test.ts index 9df49f8ee7c..2470b037e80 100644 --- a/packages/app/src/shell/state/layout.test.ts +++ b/packages/app/src/shell/state/layout.test.ts @@ -3,9 +3,13 @@ import { createRoot, createSignal } from "solid-js" import { Schema } from "effect" import { ServerConnection } from "@/runtime/server/registry" import { Persistence } from "@/runtime/persistence/schema" -import { initialLayout, layoutPersistence, layoutSchema } from "./layout" +import { currentRoute, initialLayout, layoutPersistence, layoutSchema } from "./layout" import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./helpers" +test("settings has its own layout route", () => { + expect(currentRoute("/settings", "")).toEqual({ type: "settings" }) +}) + describe("layout persistence", () => { const schema = Persistence.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local"))) const decode = Schema.decodeUnknownSync(schema) diff --git a/packages/app/src/shell/state/layout.tsx b/packages/app/src/shell/state/layout.tsx index 3e8a26293e4..9ad44297ac9 100644 --- a/packages/app/src/shell/state/layout.tsx +++ b/packages/app/src/shell/state/layout.tsx @@ -66,6 +66,7 @@ export type TabPanes = { export type LayoutRoute = | { type: "home" } + | { type: "settings" } | { type: "draft"; draftID: string } | { type: "session"; sessionId: string; server: ServerConnection.Key } @@ -104,6 +105,7 @@ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => { export const currentRoute = (pathname: string, search: string): LayoutRoute => { const parts = pathname.split("/").filter(Boolean) if (parts.length === 0) return { type: "home" } + if (parts[0] === "settings") return { type: "settings" } if (parts[0] === "new-session") { const draftID = new URLSearchParams(search).get("draftId") diff --git a/packages/app/src/shell/titlebar/history.test.ts b/packages/app/src/shell/titlebar/history.test.ts index 3754ae7c5f6..5196f95d258 100644 --- a/packages/app/src/shell/titlebar/history.test.ts +++ b/packages/app/src/shell/titlebar/history.test.ts @@ -8,56 +8,75 @@ function history(): TitlebarHistory { describe("titlebar history", () => { test("append and trim keeps max bounded", () => { let state = history() - state = applyPath(state, "/", 3) - state = applyPath(state, "/a", 3) - state = applyPath(state, "/b", 3) - state = applyPath(state, "/c", 3) + state = applyPath(state, { url: "/" }, 3) + state = applyPath(state, { url: "/a" }, 3) + state = applyPath(state, { url: "/b" }, 3) + state = applyPath(state, { url: "/c" }, 3) - expect(state.stack).toEqual(["/a", "/b", "/c"]) + expect(state.stack.map((entry) => entry.url)).toEqual(["/a", "/b", "/c"]) expect(state.stack.length).toBe(3) expect(state.index).toBe(2) }) test("back and forward indexes stay correct after trimming", () => { let state = history() - state = applyPath(state, "/", 3) - state = applyPath(state, "/a", 3) - state = applyPath(state, "/b", 3) - state = applyPath(state, "/c", 3) + state = applyPath(state, { url: "/" }, 3) + state = applyPath(state, { url: "/a" }, 3) + state = applyPath(state, { url: "/b" }, 3) + state = applyPath(state, { url: "/c" }, 3) - expect(state.stack).toEqual(["/a", "/b", "/c"]) + expect(state.stack.map((entry) => entry.url)).toEqual(["/a", "/b", "/c"]) expect(state.index).toBe(2) const back = backPath(state) - expect(back?.to).toBe("/b") + expect(back?.to.url).toBe("/b") expect(back?.state.index).toBe(1) const afterBack = applyPath(back!.state, back!.to, 3) - expect(afterBack.stack).toEqual(["/a", "/b", "/c"]) + expect(afterBack.stack.map((entry) => entry.url)).toEqual(["/a", "/b", "/c"]) expect(afterBack.index).toBe(1) const forward = forwardPath(afterBack) - expect(forward?.to).toBe("/c") + expect(forward?.to.url).toBe("/c") expect(forward?.state.index).toBe(2) const afterForward = applyPath(forward!.state, forward!.to, 3) - expect(afterForward.stack).toEqual(["/a", "/b", "/c"]) + expect(afterForward.stack.map((entry) => entry.url)).toEqual(["/a", "/b", "/c"]) expect(afterForward.index).toBe(2) }) test("action-driven navigation does not push duplicate history entries", () => { const state: TitlebarHistory = { - stack: ["/", "/a", "/b"], + stack: [{ url: "/" }, { url: "/a" }, { url: "/b" }], index: 2, action: undefined, } const back = backPath(state) - expect(back?.to).toBe("/a") + expect(back?.to.url).toBe("/a") const next = applyPath(back!.state, back!.to, 10) - expect(next.stack).toEqual(["/", "/a", "/b"]) + expect(next.stack.map((entry) => entry.url)).toEqual(["/", "/a", "/b"]) expect(next.index).toBe(1) expect(next.action).toBeUndefined() }) + + test("settings visits retain their own route state", () => { + const first = { url: "/settings", state: { settings: { type: "draft", draftID: "a" } } } + const second = { url: "/settings", state: { settings: { type: "draft", draftID: "b" } } } + const state = applyPath(applyPath(applyPath(history(), first), { url: "/b" }), second) + const back = backPath(state)! + const previous = backPath(applyPath(back.state, back.to))! + expect(previous.to).toEqual(first) + expect(forwardPath(applyPath(back.state, back.to))?.to).toEqual(second) + }) + + test("replacing settings state does not add a back navigation", () => { + const initial = applyPath(history(), { url: "/settings", state: { tab: "general" } }) + const updated = applyPath(initial, { url: "/settings", state: { tab: "models" } }) + expect(updated.stack).toHaveLength(2) + const back = backPath(updated)! + expect(back.to.url).toBe("/") + expect(forwardPath(applyPath(back.state, back.to))?.to.state).toEqual({ tab: "models" }) + }) }) diff --git a/packages/app/src/shell/titlebar/history.ts b/packages/app/src/shell/titlebar/history.ts index 44dbbfa3a49..6e9c68d85d4 100644 --- a/packages/app/src/shell/titlebar/history.ts +++ b/packages/app/src/shell/titlebar/history.ts @@ -2,22 +2,32 @@ export const MAX_TITLEBAR_HISTORY = 100 export type TitlebarAction = "back" | "forward" | undefined +export type HistoryLocation = { url: string; state?: unknown } + export type TitlebarHistory = { - stack: string[] + stack: HistoryLocation[] index: number action: TitlebarAction } -export function applyPath(state: TitlebarHistory, current: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory { +export function applyPath( + state: TitlebarHistory, + current: HistoryLocation, + max = MAX_TITLEBAR_HISTORY, +): TitlebarHistory { if (!state.stack.length) { - const stack = current === "/" ? ["/"] : ["/", current] + const stack = current.url === "/" ? [current] : [{ url: "/" }, current] return { stack, index: stack.length - 1, action: undefined } } const active = state.stack[state.index] - if (current === active) { - if (!state.action) return state - return { ...state, action: undefined } + if (current.url === active.url) { + if (!state.action && current.state === active.state) return state + return { + ...state, + stack: state.stack.map((entry, index) => (index === state.index ? current : entry)), + action: undefined, + } } if (state.action) return { ...state, action: undefined } @@ -25,13 +35,13 @@ export function applyPath(state: TitlebarHistory, current: string, max = MAX_TIT return pushPath(state, current, max) } -export function pushPath(state: TitlebarHistory, path: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory { +export function pushPath(state: TitlebarHistory, path: HistoryLocation, max = MAX_TITLEBAR_HISTORY): TitlebarHistory { const stack = state.stack.slice(0, state.index + 1).concat(path) const next = trimHistory(stack, stack.length - 1, max) return { ...state, ...next, action: undefined } } -export function trimHistory(stack: string[], index: number, max = MAX_TITLEBAR_HISTORY) { +export function trimHistory(stack: HistoryLocation[], index: number, max = MAX_TITLEBAR_HISTORY) { if (stack.length <= max) return { stack, index } const cut = stack.length - max return { diff --git a/packages/app/src/shell/titlebar/tab-nav.tsx b/packages/app/src/shell/titlebar/tab-nav.tsx index 2f420b39762..42486814b97 100644 --- a/packages/app/src/shell/titlebar/tab-nav.tsx +++ b/packages/app/src/shell/titlebar/tab-nav.tsx @@ -432,7 +432,20 @@ export function DraftTabItem(props: { class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]" > - + { ref = element props.onNavigate(tab, element) diff --git a/packages/app/src/shell/titlebar/titlebar.tsx b/packages/app/src/shell/titlebar/titlebar.tsx index 429990338ab..569c4b9aa49 100644 --- a/packages/app/src/shell/titlebar/titlebar.tsx +++ b/packages/app/src/shell/titlebar/titlebar.tsx @@ -1,5 +1,5 @@ import { createEffect, createMemo, createResource, Match, Show, Switch, untrack } from "solid-js" -import { createStore } from "solid-js/store" +import { createStore, unwrap } from "solid-js/store" import { Portal } from "solid-js/web" import { useLocation, useNavigate } from "@solidjs/router" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -13,7 +13,7 @@ import { useCommand } from "@/shell/commands/command" import { useLanguage } from "@/runtime/i18n/language" import { useSettings } from "@/settings/model" import { WindowsAppMenu } from "./windows-menu" -import { applyPath, backPath, forwardPath } from "./history" +import { applyPath, backPath, forwardPath, type HistoryLocation } from "./history" import { TitlebarTabStrip } from "@/shell/titlebar/tab-strip" import { makeEventListener } from "@solid-primitives/event-listener" import { createMediaQuery } from "@solid-primitives/media" @@ -75,7 +75,7 @@ export function Titlebar(props: { const windowsControlsWidth = () => `${windowsControlsBaseWidth / Math.max(titlebarZoom(), 1)}px` const [history, setHistory] = createStore({ - stack: [] as string[], + stack: [] as HistoryLocation[], index: 0, action: undefined as "back" | "forward" | undefined, }) @@ -83,7 +83,7 @@ export function Titlebar(props: { const path = () => `${location.pathname}${location.search}${location.hash}` createEffect(() => { - const current = path() + const current = { url: path(), state: location.state } untrack(() => { const next = applyPath(history, current) @@ -113,14 +113,14 @@ export function Titlebar(props: { const next = backPath(history) if (!next) return setHistory(next.state) - navigate(next.to) + navigate(next.to.url, { state: unwrap(next.to.state) }) } const forward = () => { const next = forwardPath(history) if (!next) return setHistory(next.state) - navigate(next.to) + navigate(next.to.url, { state: unwrap(next.to.state) }) } command.register(() => [ @@ -297,6 +297,7 @@ export function Titlebar(props: { void tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model) return } + case "settings": case "home": { const selection = layout.home.selection() const conn = @@ -432,8 +433,8 @@ export function Titlebar(props: { "md:pl-4": !macTrafficLights(), }} > - - + + @@ -624,10 +625,22 @@ export function Titlebar(props: { >
+ > +
+ +
+
{homeButton(true)} -