diff --git a/packages/app/e2e/regression/command-palette.spec.ts b/packages/app/e2e/regression/command-palette.spec.ts index 7ca01380ad91..de7684b75f86 100644 --- a/packages/app/e2e/regression/command-palette.spec.ts +++ b/packages/app/e2e/regression/command-palette.spec.ts @@ -79,6 +79,27 @@ test("finds a setting and opens Settings with its row in view and highlighted", await expect(row).toBeInViewport() }) +test("opens from the Settings page and finds another setting", async ({ page }) => { + await openPaletteWorkspace(page, true) + await page.keyboard.press("ControlOrMeta+,") + await expect(page).toHaveURL("/settings") + const screen = page.getByTestId("settings-screen") + await expect(screen.getByRole("tab", { name: "Preferences", exact: true })).toBeVisible() + + await page.keyboard.press("ControlOrMeta+K") + const dialog = page.getByRole("dialog") + const input = dialog.getByRole("combobox") + await expect(input).toBeFocused() + await input.fill("theme") + await expect(dialog.getByRole("option", { name: /^Theme/ })).toHaveAttribute("aria-selected", "true") + await input.press("Enter") + await expect(dialog).toHaveCount(0) + await expect(page).toHaveURL("/settings?tab=appearance") + const row = screen.locator('[data-search-target="row"]') + await expect(row).toContainText("Theme") + await expect(row).toBeInViewport() +}) + test("lists recent sessions before a query is entered", async ({ page }) => { const { dialog } = await openCommandPalette(page, true) const session = dialog.getByRole("option", { name: /Palette fixture session/ }) diff --git a/packages/app/src/home/sessions/controller.tsx b/packages/app/src/home/sessions/controller.tsx index 13b86703be40..6fd287fd3dfa 100644 --- a/packages/app/src/home/sessions/controller.tsx +++ b/packages/app/src/home/sessions/controller.tsx @@ -150,7 +150,7 @@ export function createHomeSessionsController(home: HomeController) { }) }) - command.register("home.palette", () => [ + command.register("palette", () => [ { id: "command.palette", title: language.t("command.palette"), diff --git a/packages/app/src/home/start/start.tsx b/packages/app/src/home/start/start.tsx index 5a3a02b1c3b2..15b23e579bb2 100644 --- a/packages/app/src/home/start/start.tsx +++ b/packages/app/src/home/start/start.tsx @@ -11,8 +11,7 @@ import { } from "@opencode/ui/project-avatar" import { TextShimmer } from "@opencode/ui/text-shimmer" import { Tooltip } from "@opencode/ui/tooltip" -import { useDialog } from "@opencode/ui/context/dialog" -import { createEffect, createMemo, For, Match, Show, startTransition, Switch, type JSX } from "solid-js" +import { createEffect, createMemo, For, Match, Show, Switch, type JSX } from "solid-js" import { useLanguage } from "@/runtime/i18n/language" import { useGlobal } from "@/runtime/server/runtime" import { ServerConnection, useServers } from "@/runtime/server/registry" @@ -20,7 +19,6 @@ import { useSettingsCommand } from "@/settings/command" import { useSettingsSurface } from "@/settings/surface" import { usePlatform } from "@/runtime/platform/platform" import { useCommand } from "@/shell/commands/command" -import { useTabs } from "@/shell/tabs/tabs" import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar" import { sessionTabTitle } from "@/shell/titlebar/tab-title" import type { InboxEntry } from "@/shell/titlebar/inbox-model" @@ -31,7 +29,8 @@ type Language = ReturnType /** Home: a greeting, every session sorted by what it needs from you, and recent projects beside them. */ export function StartHome() { const model = createStartModel() - useStartCommands() + // Settings opens from Home like from a session; the palette is the shell's. + useSettingsCommand() return (
[ - { - id: "command.palette", - title: language.t("command.palette"), - hidden: true, - onSelect: async () => { - const conn = servers.visible.find((item) => servers.current(item)) ?? servers.visible[0] - - if (!conn) return - const { HomeCommandPalette } = await import("./command-palette") - void dialog.show(() => ( - { - if (!entry.sessionID || !entry.directory || !entry.server) return - const sessionID = entry.sessionID - const server = entry.server - global.ensureServerCtx(conn).projects.touch(entry.project?.worktree ?? entry.directory) - void startTransition(() => { - tabs.select(tabs.addSessionTab({ server, sessionId: sessionID })) - }) - }} - /> - )) - }, - }, - ]) -} - function StartGreeting(props: { model: StartModel }) { const language = useLanguage() const command = useCommand() @@ -336,12 +296,14 @@ function StartProjects(props: { model: StartModel }) { function ProjectList(props: { model: StartModel; server: StartServer }) { const language = useLanguage() + // Projects that share a name, like a repository and its worktrees, are told apart by their parent folder. const repeated = createMemo(() => { const names = props.server.projects.map((item) => displayName(item.project)) return new Set(names.filter((name, index) => names.indexOf(name) !== index)) }) + const parent = (worktree: string) => worktree .replace(/[/\\]+$/, "") @@ -596,7 +558,10 @@ function Status(props: { model: StartModel; entry: InboxEntry }) { "text-v2-text-text-muted": status() === "done", }} > - {language.t(`sidebar.inbox.status.${status() as "approval" | "input" | "failed" | "done"}`)} + { + // SAFETY: working and ready matched above, so the remaining statuses each have a label. + language.t(`sidebar.inbox.status.${status() as "approval" | "input" | "failed" | "done"}`) + } diff --git a/packages/app/src/new-session/commands.tsx b/packages/app/src/new-session/commands.tsx index 6b1655dcfd14..868174b60eb5 100644 --- a/packages/app/src/new-session/commands.tsx +++ b/packages/app/src/new-session/commands.tsx @@ -19,7 +19,7 @@ export function useNewSessionCommands(input: { const language = useLanguage() useSettingsCommand() - command.register("new-session", () => [ + command.register("palette", () => [ { id: "command.palette", title: language.t("command.palette"), @@ -29,6 +29,8 @@ export function useNewSessionCommands(input: { void dialog.show(() => ) }, }, + ]) + command.register("new-session", () => [ { id: "input.focus", title: language.t("command.input.focus"), diff --git a/packages/app/src/session/composer/region.tsx b/packages/app/src/session/composer/region.tsx index 6f59b1465f47..00d424256763 100644 --- a/packages/app/src/session/composer/region.tsx +++ b/packages/app/src/session/composer/region.tsx @@ -237,7 +237,7 @@ export function createActiveSessionRegion(input: { return model ? { id: model.id, providerID: model.provider.id, variant: selection.variant.current() } : undefined }, }) - command.register("session-palette", () => [ + command.register("palette", () => [ { id: "command.palette", title: language.t("command.palette"), diff --git a/packages/app/src/shell/shell.tsx b/packages/app/src/shell/shell.tsx index e164716c2dea..8d92f5337e6b 100644 --- a/packages/app/src/shell/shell.tsx +++ b/packages/app/src/shell/shell.tsx @@ -1,7 +1,8 @@ -import { Show, Suspense, type ParentProps } from "solid-js" +import { Show, startTransition, Suspense, type ParentProps } from "solid-js" import { createStore } from "solid-js/store" import { createMediaQuery } from "@solid-primitives/media" import { ResizeHandle } from "@opencode/ui/resize-handle" +import { useDialog } from "@opencode/ui/context/dialog" import { Titlebar } from "@/shell/titlebar/titlebar" import { usePlatform } from "@/runtime/platform/platform" import { ToastRegion } from "@/shell/notifications/toast" @@ -13,6 +14,9 @@ import { ExtensionServerCover } from "@/runtime/extension/server-shell" import { ExtensionSlot } from "@/runtime/extension/render" import { useCommand } from "@/shell/commands/command" import { useLanguage } from "@/runtime/i18n/language" +import { useGlobal } from "@/runtime/server/runtime" +import { useServers } from "@/runtime/server/registry" +import { useTabs } from "@/shell/tabs/tabs" import { CollapsedSidebarToggle, measureSidebarToggles, paintCollapsedSlots } from "@/shell/sidebar-toggle" import { createPanelMotion, guardClosingFocus, replayPanelToggle } from "@/shell/panel-motion" import { windowDragEnabled } from "@/session/header/drag-area" @@ -25,6 +29,10 @@ export default function Layout(props: ParentProps) { const preferences = useSettings() const command = useCommand() const language = useLanguage() + const dialog = useDialog() + const global = useGlobal() + const servers = useServers() + const tabs = useTabs() const mobile = createMediaQuery("(max-width: 767px)") const [state, setState] = createStore<{ @@ -80,6 +88,34 @@ export default function Layout(props: ParentProps) { ] : [], ) + // Every surface has a palette: routes with their own register under the same key, and the newest wins. + command.register("palette", () => [ + { + id: "command.palette", + title: language.t("command.palette"), + hidden: true, + onSelect: async () => { + const conn = servers.visible.find((item) => servers.current(item)) ?? servers.visible[0] + + if (!conn) return + const { HomeCommandPalette } = await import("@/home/start/command-palette") + void dialog.show(() => ( + { + if (!entry.sessionID || !entry.directory || !entry.server) return + const sessionID = entry.sessionID + const server = entry.server + global.ensureServerCtx(conn).projects.touch(entry.project?.worktree ?? entry.directory) + void startTransition(() => { + tabs.select(tabs.addSessionTab({ server, sessionId: sessionID })) + }) + }} + /> + )) + }, + }, + ]) const bottomTitlebar = () => mobile() && preferences.general.mobileTitlebarPosition() === "bottom" const dragInset = () => windowDragEnabled(platform) && !mobile()