#18365 · @anduimagui · opened Mar 20, 2026 at 8:25 AM UTC · last updated Mar 21, 2026 at 4:54 PM UTC

refactor(app): move command palette to dedicated module

apprefactor
37
+72724010 files

Score breakdown

Impact

7.0

Clarity

8.0

Urgency

2.0

Ease Of Review

3.0

Guidelines

7.0

Readiness

4.0

Size

0.0

Trust

6.0

Traction

0.0

Summary

This PR refactors the command palette logic by moving command registration, action helpers, and dialog openers from layout.tsx to a new layout/commands.tsx module. The goal is to improve maintainability, reduce merge conflicts, and simplify adding new commands. New tests have been added for the moved logic, but a keybinding change contradicts the claim of no UI impact.

Open in GitHub

Description

Issue for this PR

Closes #

Type of change

  • [ ] Bug fix
  • [x] New feature
  • [x] Refactor / code improvement
  • [ ] Documentation

What does this PR do?

This starts by moving the layout command registration block out of packages/app/src/pages/layout.tsx into packages/app/src/pages/layout/commands.tsx, then goes further by moving the command-owned action helpers and dialog openers into the same module.

That expanded scope means layout.tsx no longer carries the inline command wrappers for archiving the current session, creating or toggling workspaces, or opening the provider, server, and settings dialogs. The page now mostly passes the layout state and actions into the command module, which keeps command-related churn in one smaller file and reduces the chance of merge conflicts in a high-traffic page.

A good example of why this refactor matters is the new Edit project command added to the palette. Instead of threading command logic through multiple layout files, the new command can now be added and maintained in packages/app/src/pages/layout/commands.tsx, making it much easier to ship targeted palette actions for the current project.

I also added focused coverage in packages/app/src/pages/layout/commands.test.ts so future command changes have a small test seam around registration, workspace toggling, archive behavior, and dialog-triggering commands.

How did you verify your code works?

Ran bun typecheck in packages/app.

Ran bun test --preload ./happydom.ts ./src/pages/layout/commands.test.ts in packages/app.

Push verification also passed the repo pre-push bun turbo typecheck hook.

Screenshots / recordings

Not needed; this is a code organization change with no intended UI change.

Checklist

  • [x] I have tested my changes locally
  • [x] I have not included unrelated changes in this PR

Linked Issues

None.

Comments

No comments.

Changed Files

packages/app/e2e/actions.ts

+22
@@ -175,9 +175,9 @@ export async function runTerminal(page: Page, input: { cmd: string; token: strin
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
}
export async function openPalette(page: Page) {
export async function openCommandPalette(page: Page) {
await defocus(page)
await page.keyboard.press(`${modKey}+P`)
await page.keyboard.press(`${modKey}+Shift+P`)
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()

packages/app/e2e/app/palette.spec.ts

+213
@@ -1,11 +1,29 @@
import { test, expect } from "../fixtures"
import { openPalette } from "../actions"
import { openCommandPalette } from "../actions"
import { modKey } from "../utils"
test("search palette opens and closes", async ({ page, gotoSession }) => {
test("command palette opens with mod+shift+p and closes", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openPalette(page)
const dialog = await openCommandPalette(page)
await dialog.getByRole("textbox").first().fill("package.json")
await expect(dialog).toContainText("No results found")
await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0)
})
test("original mixed palette still opens with mod+p", async ({ page, gotoSession }) => {
await gotoSession()
await page.keyboard.press(`${modKey}+P`)
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first()
await input.fill("package.json")
await expect(dialog).not.toContainText("No results found")
await expect(dialog).toContainText("package.json")
})

packages/app/src/components/dialog-actions.tsx

+140
@@ -0,0 +1,14 @@
import type { JSX } from "solid-js"
import { DialogSelectProvider } from "@/components/dialog-select-provider"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { DialogSettings } from "@/components/dialog-settings"
type Dialog = {
show: (render: () => JSX.Element, onClose?: () => void) => void
}
export const showProviderDialog = (dialog: Dialog) => dialog.show(() => <DialogSelectProvider />)
export const showServerDialog = (dialog: Dialog) => dialog.show(() => <DialogSelectServer />)
export const showSettingsDialog = (dialog: Dialog) => dialog.show(() => <DialogSettings />)

packages/app/src/components/dialog-command-palette.tsx

+1160
@@ -0,0 +1,116 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Keybind } from "@opencode-ai/ui/keybind"
import { List } from "@opencode-ai/ui/list"
import { createMemo, createSignal, onCleanup, Show, type Accessor } from "solid-js"
import { formatKeybind, type CommandOption } from "@/context/command"
const ENTRY_LIMIT = 5
const COMMON_COMMAND_IDS = [
"session.new",
"workspace.new",
"session.previous",
"session.next",
"terminal.toggle",
"review.toggle",
] as const
type Entry = {
id: string
title: string
description?: string
category: string
keybind?: string
option: CommandOption
}
const entry = (option: CommandOption, category: string): Entry => ({
id: option.id,
title: option.title,
description: option.description,
category,
keybind: option.keybind,
option,
})
export function DialogCommandPalette(props: {
options: Accessor<CommandOption[]>
commands: string
placeholder: string
empty: string
loading: string
t: (key: string) => string
}) {
const dialog = useDialog()
const [grouped, setGrouped] = createSignal(false)

packages/app/src/components/dialog-select-file.tsx

+3030
@@ -99,36 +99,6 @@ const createSessionEntry = (
updated: input.updated,
})
function createCommandEntries(props: {
filesOnly: () => boolean
command: ReturnType<typeof useCommand>
language: ReturnType<typeof useLanguage>
}) {
const allowed = createMemo(() => {
if (props.filesOnly()) return []
return props.command.options.filter(
(option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open",
)
})
const list = createMemo(() => {
const category = props.language.t("palette.group.commands")
return allowed().map((option) => createCommandEntry(option, category))
})
const picks = createMemo(() => {
const all = allowed()
const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
const picked = all.filter((option) => order.has(option.id))
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
const category = props.language.t("palette.group.commands")
return sorted.map((option) => createCommandEntry(option, c

packages/app/src/context/command.tsx

+123
@@ -6,6 +6,7 @@ import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { dict as en } from "@/i18n/en"
import { Persist, persisted } from "@/utils/persist"
import { DialogCommandPalette } from "@/components/dialog-command-palette"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
@@ -350,9 +351,17 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
option?.onSelect?.(source)
}
const showPalette = () => {
run("file.open", "palette")
}
const showPalette = () =>
dialog.show(() => (
<DialogCommandPalette
options={options}
commands={language.t("palette.group.commands")}
placeholder={language.t("palette.command.placeholder")}
empty={language.t("palette.empty")}
loading={language.t("common.loading")}
t={language.t}
/>
))
const handleKeyDown = (event: KeyboardEvent) => {
if (suspended() || dialog.active) return

packages/app/src/i18n/en.ts

+10
@@ -91,6 +91,7 @@ export const dict = {
"command.session.unshare.description": "Stop sharing this session",
"palette.search.placeholder": "Search files, commands, and sessions",
"palette.command.placeholder": "Search commands",
"palette.empty": "No results found",
"palette.group.commands": "Commands",
"palette.group.files": "Files",

packages/app/src/pages/layout.tsx

+32202
@@ -57,10 +57,7 @@ import { setSessionHandoff } from "@/pages/session/handoff"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { DialogSelectProvider } from "@/components/dialog-select-provider"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { DialogSettings } from "@/components/dialog-settings"
import { useCommand, type CommandOption } from "@/context/command"
import { useCommand } from "@/context/command"
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogEditProject } from "@/components/dialog-edit-project"
@@ -76,6 +73,8 @@ import {
sortedRootSessions,
workspaceKey,
} from "./layout/helpers"
import { showProviderDialog, showSettingsDialog } from "@/components/dialog-actions"
import { registerLayoutCommands } from "./layout/commands"
import {
collectNewSessionDeepLinks,
collectOpenProjectDeepLinks,
@@ -1006,203 +1005,32 @@ export default function Layout(props: ParentProps) {
}
}
command.register("layout", () => {

packages/app/src/pages/layout/commands.test.ts

+2340
@@ -0,0 +1,234 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
import type { Session } from "@opencode-ai/sdk/v2/client"
import type { LocalProject } from "@/context/layout"
import type { Locale } from "@/context/language"
let registerLayoutCommands: typeof import("./commands").registerLayoutCommands
const toasts: Array<Record<string, unknown>> = []
const session = (id: string, directory: string) =>
({
id,
slug: id,
directory,
projectID: "p1",
title: "",
version: "v2",
parentID: undefined,
messageCount: 0,
permissions: { session: {}, share: {} },
time: { created: 0, updated: 0, archived: undefined },
}) as unknown as Session
const project = (vcs: LocalProject["vcs"] = "git") =>
({
id: "p1",
worktree: "/repo",
vcs,
expanded: true,
}) as LocalProject
function input() {
let cb = () => [] as Array<{ id: string; onSelect?: () => void; disabled?: boolean }>
const calls = {
chosen: 0,
edited: [] as LocalProject[],
project: [] as number[],
moved: [] as number[],
unseen: [] as number[],
archived: [] as Session[],
work

packages/app/src/pages/layout/commands.tsx

+2650
@@ -0,0 +1,265 @@
import type { Accessor, JSX } from "solid-js"
import { showToast } from "@opencode-ai/ui/toast"
import { showProviderDialog, showServerDialog, showSettingsDialog } from "@/components/dialog-actions"
import { type CommandOption } from "@/context/command"
import type { LocalProject } from "@/context/layout"
import type { Locale } from "@/context/language"
import type { Session } from "@opencode-ai/sdk/v2/client"
import type { ColorScheme } from "@opencode-ai/ui/theme"
type ThemeEntry = {
name?: string
}
type Input = {
command: {
register(key: string, cb: () => CommandOption[]): void
}
params: {
dir?: string
id?: string
}
dialog: {
show: (render: () => JSX.Element, onClose?: () => void) => void
}
language: {
t: (key: string, args?: Record<string, string | number | boolean>) => string
locales: readonly Locale[]
label: (locale: Locale) => string
}
layout: {
sidebar: {
toggle: () => void
workspaces: (directory: string) => Accessor<boolean>
toggleWorkspaces: (directory: string) => void
}
}
currentProject: Accessor<LocalProject | undefined>
currentSessio