mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-06 19:34:44 +00:00
feat(app): route settings and refine shell styling (#46984)
This commit is contained in:
parent
bf6ec61a74
commit
4f6060ad94
20 changed files with 226 additions and 114 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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<void>()
|
||||
const sessions = Promise.withResolvers<void>()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export function NewSessionView(props: {
|
|||
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
|
||||
<div
|
||||
data-component="new-session"
|
||||
class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-deep"
|
||||
class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
|
||||
>
|
||||
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<{
|
|||
<Tabs
|
||||
orientation="vertical"
|
||||
variant="settings"
|
||||
value={tab()}
|
||||
onChange={(value) => void startTransition(() => setTab(value))}
|
||||
value={surface.tab()}
|
||||
onChange={(value) => void startTransition(() => surface.open(value))}
|
||||
class="settings"
|
||||
>
|
||||
<div class="settings-mobile-nav">
|
||||
|
|
@ -143,14 +128,18 @@ export const SettingsScreen: Component<{
|
|||
<Menu.Trigger as={Button} size="normal" variant="outline" class="settings-mobile-menu-trigger">
|
||||
<span>
|
||||
{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",
|
||||
)}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="settings-mobile-menu" onEscapeKeyDown={(event) => event.stopPropagation()}>
|
||||
<Menu.RadioGroup value={tab()} onChange={(value) => void startTransition(() => setTab(value))}>
|
||||
<Menu.RadioGroup
|
||||
value={surface.tab()}
|
||||
onChange={(value) => void startTransition(() => surface.open(value))}
|
||||
>
|
||||
<For each={sections}>
|
||||
{(group, index) => (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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<LayoutRoute, { type: "settings" }>; 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,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Route component={AppLayout}>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/settings" component={SettingsScreen} />
|
||||
<Route
|
||||
path="/server/:serverKey/session/:id"
|
||||
component={() => (
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="flex size-full min-h-0 min-w-0 flex-col"
|
||||
hidden={settings.store.open}
|
||||
inert={settings.store.open}
|
||||
aria-hidden={settings.store.open}
|
||||
>
|
||||
<div class="flex size-full min-h-0 min-w-0 flex-col">
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</div>
|
||||
<Show when={settings.store.open}>
|
||||
<Suspense>
|
||||
<SettingsScreen defaultValue={settings.store.tab} />
|
||||
</Suspense>
|
||||
</Show>
|
||||
</main>
|
||||
</div>
|
||||
<Show when={import.meta.env.DEV && state.debugTools}>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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]"
|
||||
>
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<Icon name="edit" />
|
||||
<svg
|
||||
class="text-v2-icon-icon-muted group-data-[active='true']:text-v2-icon-icon-base"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M9.00002 13.5H14M2.60419 10.9167V13.3958H5.08335L13.3959 5.08333L10.9167 2.60416L2.60419 10.9167Z"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span
|
||||
data-titlebar-tab-title
|
||||
|
|
|
|||
|
|
@ -377,7 +377,7 @@ export function TitlebarTabStrip(props: {
|
|||
index={visibleIndex()}
|
||||
active={props.currentTab === tab}
|
||||
orientation={vertical() ? "vertical" : "horizontal"}
|
||||
title={language.t("command.session.new")}
|
||||
title={language.t("session.tab.session")}
|
||||
onNavigate={(element) => {
|
||||
ref = element
|
||||
props.onNavigate(tab, element)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}}
|
||||
>
|
||||
<Show when={!mobile() && !props.verticalTabs}>
|
||||
<ChannelIndicator debugTools={props.debugTools} />
|
||||
<Show when={!mobile() && (!props.verticalTabs || windows())}>
|
||||
<ChannelIndicator debugTools={props.debugTools} height={windows() ? minHeight() : undefined} />
|
||||
</Show>
|
||||
<Show when={windows() || linux()}>
|
||||
<WindowsAppMenu command={command} platform={platform} />
|
||||
|
|
@ -624,10 +625,22 @@ export function Titlebar(props: {
|
|||
>
|
||||
<Show when={macVerticalTabs()}>
|
||||
<div
|
||||
class="relative w-full shrink-0"
|
||||
class="relative mb-2 w-full shrink-0"
|
||||
style={{ height: `${macTrafficLightsTopClearance / zoom()}px` }}
|
||||
data-tauri-drag-region
|
||||
></div>
|
||||
>
|
||||
<div
|
||||
class="absolute -top-0.5 bottom-0.5 flex items-center"
|
||||
style={{
|
||||
// Native traffic lights stay on the physical left; subtract the sidebar padding.
|
||||
left: macTrafficLights()
|
||||
? `calc(${macTrafficLightsBaseWidth / zoom()}px - 0.625rem)`
|
||||
: "0px",
|
||||
}}
|
||||
>
|
||||
<ChannelIndicator debugTools={props.debugTools} />
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
{homeButton(true)}
|
||||
<button
|
||||
|
|
@ -640,7 +653,7 @@ export function Titlebar(props: {
|
|||
<Icon name="plus" />
|
||||
{language.t("command.session.new")}
|
||||
</button>
|
||||
<div class="my-1 h-px w-full shrink-0 bg-v2-border-border-muted" aria-hidden="true" />
|
||||
<div class="h-4 w-full shrink-0" aria-hidden="true" />
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-1">
|
||||
<TitlebarTabStrip
|
||||
orientation="vertical"
|
||||
|
|
@ -657,13 +670,14 @@ export function Titlebar(props: {
|
|||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||
/>
|
||||
</div>
|
||||
<div data-slot="vertical-tabs-footer" class="relative mt-auto h-9 w-full shrink-0">
|
||||
<div class="absolute bottom-0 left-0 flex h-9 items-center">
|
||||
<div
|
||||
data-slot="vertical-tabs-footer"
|
||||
class="mt-auto flex h-9 w-full shrink-0 items-center gap-1.5"
|
||||
>
|
||||
<TitlebarRightMount />
|
||||
<Show when={!macVerticalTabs() && !windows()}>
|
||||
<ChannelIndicator debugTools={props.debugTools} />
|
||||
</div>
|
||||
<div class="absolute bottom-0 right-0 flex h-9 items-center">
|
||||
<TitlebarRightMount />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
|
|
@ -739,13 +753,19 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
|
|||
)
|
||||
}
|
||||
|
||||
function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void } }) {
|
||||
function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void }; height?: string }) {
|
||||
const platform = usePlatform()
|
||||
const style = () => ({
|
||||
height: props.height,
|
||||
"font-size": platform.platform === "desktop" && platform.os === "macos" ? "9px" : "10px",
|
||||
})
|
||||
const channel = import.meta.env.VITE_OPENCODE_CHANNEL
|
||||
if (channel === "dev" && props.debugTools) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono cursor-pointer"
|
||||
class="inline-flex h-4 shrink-0 items-center bg-icon-interactive-base text-[#FFF] leading-4 font-medium px-1.5 rounded-full uppercase font-mono cursor-pointer [app-region:no-drag]"
|
||||
style={style()}
|
||||
onClick={props.debugTools.toggle}
|
||||
aria-label="Toggle debug tools"
|
||||
aria-pressed={props.debugTools.visible}
|
||||
|
|
@ -759,7 +779,10 @@ function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () =
|
|||
return (
|
||||
<Show when={label}>
|
||||
{(value) => (
|
||||
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
|
||||
<div
|
||||
class="inline-flex h-4 shrink-0 items-center bg-icon-interactive-base text-[#FFF] leading-4 font-medium px-1.5 rounded-full uppercase font-mono"
|
||||
style={style()}
|
||||
>
|
||||
{value()}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export function Wordmark(props: Pick<ComponentProps<"svg">, "class">) {
|
|||
fill="none"
|
||||
classList={{ [props.class ?? ""]: !!props.class }}
|
||||
>
|
||||
<g opacity="0.6">
|
||||
<g opacity="0.6" class="[[data-color-scheme=dark]_&]:opacity-100">
|
||||
<g mask={`url(#${mask})`}>
|
||||
<g opacity="0.16">
|
||||
<path
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue