Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions packages/app/e2e/regression/command-palette.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/ })
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/home/sessions/controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ export function createHomeSessionsController(home: HomeController) {
})
})

command.register("home.palette", () => [
command.register("palette", () => [
{
id: "command.palette",
title: language.t("command.palette"),
Expand Down
53 changes: 9 additions & 44 deletions packages/app/src/home/start/start.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,14 @@ 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"
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"
Expand All @@ -31,7 +29,8 @@ type Language = ReturnType<typeof useLanguage>
/** 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 (
<div
Expand All @@ -56,45 +55,6 @@ export function StartHome() {
)
}

/** Home's own palette lists commands and this computer's sessions; Settings opens from Home like from a session. */
function useStartCommands() {
const command = useCommand()
const dialog = useDialog()
const global = useGlobal()
const language = useLanguage()
const servers = useServers()
const tabs = useTabs()

useSettingsCommand()
command.register("home.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("./command-palette")
void dialog.show(() => (
<HomeCommandPalette
server={conn}
onSelectSession={(entry) => {
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()
Expand Down Expand Up @@ -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(/[/\\]+$/, "")
Expand Down Expand Up @@ -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"}`)
}
</span>
</Match>
</Switch>
Expand Down
4 changes: 3 additions & 1 deletion packages/app/src/new-session/commands.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -29,6 +29,8 @@ export function useNewSessionCommands(input: {
void dialog.show(() => <DialogCommandPalette />)
},
},
])
command.register("new-session", () => [
{
id: "input.focus",
title: language.t("command.input.focus"),
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/session/composer/region.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
38 changes: 37 additions & 1 deletion packages/app/src/shell/shell.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"
Expand All @@ -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<{
Expand Down Expand Up @@ -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(() => (
<HomeCommandPalette
server={conn}
onSelectSession={(entry) => {
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()

Expand Down
Loading