1
0
Эх сурвалжийг харах

feat(app): cmd+k menu shows sessions on home page (#36686)

Co-authored-by: Brendan Allan <git@brendonovich.dev>
Aarav Sareen 4 долоо хоног өмнө
parent
commit
958e08e109

+ 89 - 135
packages/app/src/components/command-palette.ts

@@ -1,17 +1,18 @@
-import { base64Encode } from "@opencode-ai/core/util/encode"
 import { getFilename } from "@opencode-ai/core/util/path"
+import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { useNavigate } from "@solidjs/router"
 import { createMemo, onCleanup } from "solid-js"
-import { useCommand, type CommandOption } from "@/context/command"
+import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command"
 import { useFile } from "@/context/file"
+import { useGlobal } from "@/context/global"
 import { useLanguage } from "@/context/language"
-import { useLayout } from "@/context/layout"
-import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
-import { useServerSync } from "@/context/server-sync"
+import { useLayout, type LocalProject } from "@/context/layout"
+import { ServerConnection } from "@/context/server"
+import { useServerSDK } from "@/context/server-sdk"
+import { useTabs } from "@/context/tabs"
+import { displayName, projectForSession } from "@/pages/layout/helpers"
 import { createSessionTabs } from "@/pages/session/helpers"
 import { useSessionLayout } from "@/pages/session/session-layout"
-import { decode64 } from "@/utils/base64"
 
 export type CommandPaletteEntry = {
   id: string
@@ -24,6 +25,8 @@ export type CommandPaletteEntry = {
   path?: string
   directory?: string
   sessionID?: string
+  server?: ServerConnection.Key
+  project?: LocalProject
   archived?: number
   updated?: number
 }
@@ -75,28 +78,25 @@ export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => vo
 
 export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) {
   const command = useCommand()
+  const global = useGlobal()
   const language = useLanguage()
-  const layout = useLayout()
   const file = useFile()
   const dialog = useDialog()
-  const navigate = useNavigate()
   const serverSDK = useServerSDK()()
-  const serverSync = useServerSync()
-  const { params, tabs } = useSessionLayout()
+  const serverCtx = global.ensureServerCtx(serverSDK.server)
+  const appTabs = useTabs()
+  const { tabs: sessionTabs } = useSessionLayout()
   const openFile = createCommandPaletteFileOpener(props.onOpenFile)
   const state = { cleanup: undefined as (() => void) | void, committed: false }
   const filesOnly = () => props.filesOnly?.() ?? false
 
   const allowedCommands = createMemo(() => {
     if (filesOnly()) return []
-    return command.options.filter(
-      (option) =>
-        !option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
-    )
+    return commandPaletteOptions(command.options)
   })
   const commandEntries = createMemo(() => {
     const category = language.t("palette.group.commands")
-    return allowedCommands().map((option) => createCommandEntry(option, category))
+    return allowedCommands().map((option) => createCommandPaletteCommandEntry(option, category))
   })
   const preferredCommandEntries = createMemo(() => {
     const all = allowedCommands()
@@ -105,11 +105,11 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
     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 = language.t("palette.group.commands")
-    return sorted.map((option) => createCommandEntry(option, category))
+    return sorted.map((option) => createCommandPaletteCommandEntry(option, category))
   })
 
   const tabState = createSessionTabs({
-    tabs,
+    tabs: sessionTabs,
     pathFromTab: file.pathFromTab,
     normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
   })
@@ -140,36 +140,12 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
       .map((path) => createCommandPaletteFileEntry(path, category))
   })
 
-  const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
-  const project = createMemo(() => {
-    const directory = projectDirectory()
-    if (!directory) return undefined
-    return layout.projects.list().find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
-  })
-  const workspaces = createMemo(() => {
-    const directory = projectDirectory()
-    const current = project()
-    if (!current) return directory ? [directory] : []
-    const dirs = [current.worktree, ...(current.sandboxes ?? [])]
-    if (directory && !dirs.includes(directory)) return [...dirs, directory]
-    return dirs
-  })
-  const homedir = createMemo(() => serverSync().data.path.home)
-  const sessions = createSessionEntries({
-    workspaces,
-    label: (directory) => {
-      const current = project()
-      const kind =
-        current && directory === current.worktree
-          ? language.t("workspace.type.local")
-          : language.t("workspace.type.sandbox")
-      const [store] = serverSync().child(directory, { bootstrap: false })
-      const home = homedir()
-      const path = home ? directory.replace(home, "~") : directory
-      const name = store.vcs?.branch ?? getFilename(directory)
-      return `${kind} : ${name || path}`
-    },
-    load: (directory) => serverSDK.client.session.list({ directory, roots: true }),
+  const sessions = createServerSessionEntries({
+    server: ServerConnection.key(serverSDK.server),
+    opened: serverCtx.projects.list,
+    stored: () => serverCtx.sync.data.project,
+    load: (search, signal) =>
+      serverSDK.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
     untitled: () => language.t("command.session.new"),
     category: () => language.t("command.category.session"),
   })
@@ -191,8 +167,17 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
       return
     }
     if (item.type === "session") {
-      if (!item.directory || !item.sessionID) return
-      navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
+      if (!item.sessionID || !item.server) return
+      const directory = item.project?.worktree ?? item.directory
+      if (directory) {
+        serverCtx.projects.open(directory)
+        serverCtx.projects.touch(directory)
+      }
+      const tab = appTabs.addSessionTab({
+        server: item.server,
+        sessionId: item.sessionID,
+      })
+      appTabs.select(tab)
       return
     }
     if (!item.path) return
@@ -218,7 +203,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
   }
 }
 
-function createCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
+export function createCommandPaletteCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
   return {
     id: "command:" + option.id,
     type: "command",
@@ -230,96 +215,65 @@ function createCommandEntry(option: CommandOption, category: string): CommandPal
   }
 }
 
-function createSessionEntries(props: {
-  workspaces: () => string[]
-  label: (directory: string) => string
-  load: (directory: string) => ReturnType<ServerSDK["client"]["session"]["list"]>
+export function createServerSessionEntries(props: {
+  server: ServerConnection.Key
+  opened: () => LocalProject[]
+  stored: () => Project[]
+  load: (search: string, signal: AbortSignal) => Promise<{ data?: GlobalSession[] }>
   untitled: () => string
   category: () => string
 }) {
-  const state: {
-    token: number
-    inflight: Promise<CommandPaletteEntry[]> | undefined
-    cached: CommandPaletteEntry[] | undefined
-  } = { token: 0, inflight: undefined, cached: undefined }
+  let abort: AbortController | undefined
 
-  return (text: string) => {
-    if (!text.trim()) {
-      state.token += 1
-      state.inflight = undefined
-      state.cached = undefined
-      return [] as CommandPaletteEntry[]
-    }
-    if (state.cached) return state.cached
-    if (state.inflight) return state.inflight
+  onCleanup(() => abort?.abort())
 
-    const current = state.token
-    const dirs = props.workspaces()
-    if (dirs.length === 0) return [] as CommandPaletteEntry[]
-
-    state.inflight = Promise.all(
-      dirs.map((directory) => {
-        const description = props.label(directory)
-        return props
-          .load(directory)
-          .then((result) =>
-            (result.data ?? [])
-              .filter((session) => !!session?.id)
-              .map((session) => ({
-                id: session.id,
-                title: session.title ?? props.untitled(),
-                description,
-                directory,
-                archived: session.time?.archived,
-                updated: session.time?.updated,
-              })),
-          )
-          .catch(() => [] as SessionEntryInput[])
-      }),
+  return async (text: string): Promise<CommandPaletteEntry[]> => {
+    const search = text.trim()
+    if (!search) {
+      abort?.abort()
+      return []
+    }
+    abort?.abort()
+    const current = new AbortController()
+    abort = current
+    await new Promise<void>((resolve) => {
+      const timer = setTimeout(resolve, 100)
+      current.signal.addEventListener(
+        "abort",
+        () => {
+          clearTimeout(timer)
+          resolve()
+        },
+        { once: true },
+      )
+    })
+    if (current.signal.aborted) return []
+    const opened = props.opened()
+    const openedByID = new Map(
+      opened.flatMap((project) => (project.id ? [[project.id, project] as const] : [])),
     )
-      .then((results) => {
-        if (state.token !== current) return [] as CommandPaletteEntry[]
-        const seen = new Set<string>()
-        const next = results
-          .flat()
-          .filter((item) => {
-            const key = `${item.directory}:${item.id}`
-            if (seen.has(key)) return false
-            seen.add(key)
-            return true
-          })
-          .map((item) => createSessionEntry(item, props.category()))
-        state.cached = next
-        return next
-      })
+    const stored = props.stored().map((project) => ({ ...project, expanded: false }))
+    const storedByID = new Map(stored.map((project) => [project.id, project] as const))
+    return props
+      .load(search, current.signal)
+      .then((result) =>
+        (result.data ?? []).filter((session) => !session.time.archived).map((session) => {
+          const project =
+            projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID)
+          return {
+            id: `session:${props.server}:${session.id}`,
+            type: "session" as const,
+            title: session.title || props.untitled(),
+            description: project ? displayName(project) : session.project?.name || getFilename(session.directory),
+            category: props.category(),
+            directory: session.directory,
+            sessionID: session.id,
+            server: props.server,
+            project,
+            updated: session.time.updated,
+          }
+        }),
+      )
       .catch(() => [] as CommandPaletteEntry[])
-      .finally(() => {
-        state.inflight = undefined
-      })
-
-    return state.inflight
-  }
-}
-
-type SessionEntryInput = {
-  directory: string
-  id: string
-  title: string
-  description: string
-  archived?: number
-  updated?: number
-}
-
-function createSessionEntry(input: SessionEntryInput, category: string): CommandPaletteEntry {
-  return {
-    id: `session:${input.directory}:${input.id}`,
-    type: "session",
-    title: input.title,
-    description: input.description,
-    category,
-    directory: input.directory,
-    sessionID: input.id,
-    archived: input.archived,
-    updated: input.updated,
   }
 }

+ 148 - 16
packages/app/src/components/dialog-command-palette-v2.tsx

@@ -5,13 +5,20 @@ import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2"
 import { Icon } from "@opencode-ai/ui/v2/icon"
 import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
 import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
-import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
-import { formatKeybindParts } from "@/context/command"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
+import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command"
+import { useGlobal } from "@/context/global"
 import { useLanguage } from "@/context/language"
+import { ServerConnection } from "@/context/server"
+import { useTabs } from "@/context/tabs"
+import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
 import { getRelativeTime } from "@/utils/time"
 import {
+  createCommandPaletteCommandEntry,
   createCommandPaletteFileEntry,
   createCommandPaletteModel,
+  createServerSessionEntries,
   uniqueCommandPaletteEntries,
   type CommandPaletteEntry,
 } from "./command-palette"
@@ -30,9 +37,6 @@ function matchesEntry(entry: CommandPaletteEntry, query: string) {
 
 export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
   const palette = createCommandPaletteModel(props)
-  const [query, setQuery] = createSignal("")
-  const [active, setActive] = createSignal(0)
-
   const loadItems = async (text: string) => {
     const q = text.trim()
     if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
@@ -41,16 +45,115 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
     const category = palette.language.t("palette.group.files")
     return [
       ...palette.commandEntries().filter((entry) => matchesEntry(entry, q)),
-      ...nextSessions.filter((entry) => matchesEntry(entry, q)),
+      ...nextSessions,
       ...files.map((path) => createCommandPaletteFileEntry(path, category)),
     ]
   }
 
-  const [entries] = createResource(query, loadItems, { initialValue: [] as CommandPaletteEntry[] })
+  return (
+    <CommandPaletteView
+      placeholder={palette.language.t("palette.search.placeholder")}
+      loadItems={loadItems}
+      highlight={palette.highlight}
+      select={palette.select}
+      close={palette.close}
+    />
+  )
+}
+
+export function DialogHomeCommandPaletteV2(props: {
+  server: ServerConnection.Any
+  onSelectSession: (entry: CommandPaletteEntry) => void
+}) {
+  const command = useCommand()
+  const dialog = useDialog()
+  const global = useGlobal()
+  const language = useLanguage()
+  const serverCtx = global.ensureServerCtx(props.server)
+  const state = { cleanup: undefined as (() => void) | void, committed: false }
+  const commandEntries = createMemo(() => {
+    const category = language.t("palette.group.commands")
+    return commandPaletteOptions(command.options).map((option) =>
+      createCommandPaletteCommandEntry(option, category),
+    )
+  })
+  const sessions = createServerSessionEntries({
+    server: ServerConnection.key(props.server),
+    opened: serverCtx.projects.list,
+    stored: () => serverCtx.sync.data.project,
+    load: (search, signal) =>
+      serverCtx.sdk.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
+    untitled: () => language.t("command.session.new"),
+    category: () => language.t("command.category.session"),
+  })
+
+  const highlight = (item: CommandPaletteEntry | undefined) => {
+    state.cleanup?.()
+    state.cleanup = undefined
+    if (item?.type !== "command") return
+    state.cleanup = item.option?.onHighlight?.()
+  }
+  const select = (item: CommandPaletteEntry | undefined) => {
+    if (!item) return
+    state.committed = true
+    state.cleanup = undefined
+    dialog.close()
+    if (item.type === "command") {
+      item.option?.onSelect?.("palette")
+      return
+    }
+    if (item.type === "session") props.onSelectSession(item)
+  }
+  const loadItems = async (text: string) => {
+    const query = text.trim()
+    if (!query) return commandEntries().slice(0, 5)
+    return [
+      ...commandEntries().filter((entry) => matchesEntry(entry, query)),
+      ...(await sessions(query)),
+    ]
+  }
+
+  onCleanup(() => {
+    if (state.committed) return
+    state.cleanup?.()
+  })
+
+  return (
+    <CommandPaletteView
+      placeholder={language.t("palette.search.placeholder.home")}
+      loadItems={loadItems}
+      highlight={highlight}
+      select={select}
+      close={() => dialog.close()}
+    />
+  )
+}
+
+function CommandPaletteView(props: {
+  placeholder: string
+  loadItems: (text: string) => CommandPaletteEntry[] | Promise<CommandPaletteEntry[]>
+  highlight: (item: CommandPaletteEntry | undefined) => void
+  select: (item: CommandPaletteEntry | undefined) => void
+  close: () => void
+}) {
+  const language = useLanguage()
+  const tabs = useTabs()
+  const [query, setQuery] = createSignal("")
+  const [active, setActive] = createSignal(0)
+
+  const [entries] = createResource(query, props.loadItems, { initialValue: [] as CommandPaletteEntry[] })
   // Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
   const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
   const groupedEntries = createMemo(() => groups(visibleEntries()))
   const activeEntry = createMemo(() => visibleEntries()[active()])
+  const openSessions = createMemo(
+    () =>
+      new Set(
+        tabs.store.flatMap((tab) =>
+          tab.type === "session" ? [`${tab.server}\0${tab.sessionId}`] : [],
+        ),
+      ),
+  )
 
   createEffect(() => {
     query()
@@ -59,7 +162,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
   })
 
   createEffect(() => {
-    palette.highlight(activeEntry())
+    props.highlight(activeEntry())
   })
 
   let resultsRef: HTMLDivElement | undefined
@@ -86,12 +189,12 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
     }
     if (event.key === "Enter") {
       event.preventDefault()
-      palette.select(activeEntry())
+      props.select(activeEntry())
       return
     }
     if (event.key === "Escape") {
       event.preventDefault()
-      palette.close()
+      props.close()
     }
   }
 
@@ -105,7 +208,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
             autocomplete="off"
             spellcheck={false}
             appearance="large"
-            placeholder={palette.language.t("palette.search.placeholder")}
+            placeholder={props.placeholder}
             leadingIcon={<Icon name="magnifying-glass" />}
             onInput={(event) => setQuery(event.currentTarget.value)}
             onKeyDown={handleKeyDown}
@@ -117,7 +220,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
               when={visibleEntries().length > 0}
               fallback={
                 <div class="command-palette-v2-state">
-                  {entries.loading ? palette.language.t("common.loading") : palette.language.t("palette.empty")}
+                  {entries.loading ? language.t("common.loading") : language.t("palette.empty")}
                 </div>
               }
             >
@@ -132,9 +235,14 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
                         <PaletteRow
                           item={item}
                           active={activeEntry()?.id === item.id}
-                          language={palette.language}
+                          language={language}
+                          sessionOpen={
+                            item.server && item.sessionID
+                              ? openSessions().has(`${item.server}\0${item.sessionID}`)
+                              : false
+                          }
                           onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
-                          onSelect={() => palette.select(item)}
+                          onSelect={() => props.select(item)}
                         />
                       )}
                     </For>
@@ -153,13 +261,19 @@ function PaletteRow(props: {
   item: CommandPaletteEntry
   active: boolean
   language: ReturnType<typeof useLanguage>
+  sessionOpen: boolean
   onActive: () => void
   onSelect: () => void
 }) {
+  const session = () =>
+    props.item.server && props.item.directory && props.item.sessionID
+      ? { server: props.item.server, directory: props.item.directory, sessionID: props.item.sessionID }
+      : undefined
+
   return (
     <button
       type="button"
-      class="command-palette-v2-row"
+      class="command-palette-v2-row group"
       role="option"
       aria-selected={props.active}
       data-active={props.active ? "" : undefined}
@@ -197,7 +311,25 @@ function PaletteRow(props: {
         </Match>
         <Match when={props.item.type === "session"}>
           <div class="command-palette-v2-row-main">
-            <Icon name="status" class="command-palette-v2-row-icon" />
+            <div class="relative shrink-0">
+              <Show when={props.sessionOpen}>
+                <span
+                  aria-hidden="true"
+                  class="pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
+                  style={{ right: "calc(100% + 4px)" }}
+                />
+              </Show>
+              <Show when={session()}>
+                {(session) => (
+                  <SessionTabAvatar
+                    project={props.item.project}
+                    directory={session().directory}
+                    sessionId={session().sessionID}
+                    server={session().server}
+                  />
+                )}
+              </Show>
+            </div>
             <div class="command-palette-v2-row-text">
               <span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
                 {props.item.title}

+ 1 - 2
packages/app/src/components/settings-keybinds.tsx

@@ -10,7 +10,7 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
 import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
 import { showToast } from "@/utils/toast"
 import fuzzysort from "fuzzysort"
-import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
+import { DEFAULT_PALETTE_KEYBIND, formatKeybind, parseKeybind, useCommand } from "@/context/command"
 import { useLanguage } from "@/context/language"
 import { useSettings } from "@/context/settings"
 import { SettingsList } from "./settings-list"
@@ -20,7 +20,6 @@ const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({
 
 const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
 const PALETTE_ID = "command.palette"
-const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
 
 type KeybindGroup = "General" | "Session" | "Navigation" | "Model and agent" | "Terminal" | "Prompt"
 

+ 15 - 1
packages/app/src/context/command.test.ts

@@ -1,5 +1,19 @@
 import { describe, expect, test } from "bun:test"
-import { resolveKeybindOption, upsertCommandRegistration } from "./command"
+import { commandPaletteOptions, resolveKeybindOption, upsertCommandRegistration, type CommandOption } from "./command"
+
+const paletteOptions: CommandOption[] = [
+  { id: "settings.open", title: "Open settings" },
+  { id: "session.undo", title: "Undo" },
+  { id: "file.open", title: "Open file" },
+  { id: "hidden", title: "Hidden", hidden: true },
+  { id: "disabled", title: "Disabled", disabled: true },
+]
+
+describe("commandPaletteOptions", () => {
+  test("keeps visible enabled commands", () => {
+    expect(commandPaletteOptions(paletteOptions).map((option) => option.id)).toEqual(["settings.open", "session.undo"])
+  })
+})
 
 describe("upsertCommandRegistration", () => {
   test("replaces keyed registrations", () => {

+ 12 - 2
packages/app/src/context/command.tsx

@@ -11,7 +11,7 @@ import { Persist, persisted } from "@/utils/persist"
 const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
 
 const PALETTE_ID = "command.palette"
-const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
+export const DEFAULT_PALETTE_KEYBIND = "mod+k,mod+shift+p"
 const SUGGESTED_PREFIX = "suggested."
 const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
 
@@ -87,6 +87,16 @@ export interface CommandOption {
   onHighlight?: () => (() => void) | void
 }
 
+export function commandPaletteOptions(options: CommandOption[]) {
+  return options.filter(
+    (option) =>
+      !option.disabled &&
+      !option.hidden &&
+      !option.id.startsWith(SUGGESTED_PREFIX) &&
+      option.id !== "file.open",
+  )
+}
+
 export function resolveKeybindOption(candidates: CommandOption[] | undefined, event: KeyboardEvent) {
   return candidates?.find((option) => option.when?.(event)) ?? candidates?.find((option) => !option.when)
 }
@@ -375,7 +385,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
     }
 
     const showPalette = () => {
-      run("file.open", "palette")
+      run(PALETTE_ID, "palette")
     }
 
     const handleKeyDown = (event: KeyboardEvent) => {

+ 1 - 0
packages/app/src/i18n/ar.ts

@@ -86,6 +86,7 @@ export const dict = {
   "command.session.unshare": "إلغاء مشاركة الجلسة",
   "command.session.unshare.description": "إيقاف مشاركة هذه الجلسة",
   "palette.search.placeholder": "البحث في الملفات والأوامر والجلسات",
+  "palette.search.placeholder.home": "البحث في الأوامر والجلسات",
   "palette.empty": "لا توجد نتائج",
   "palette.group.commands": "الأوامر",
   "palette.group.files": "الملفات",

+ 1 - 0
packages/app/src/i18n/br.ts

@@ -86,6 +86,7 @@ export const dict = {
   "command.session.unshare": "Parar de compartilhar sessão",
   "command.session.unshare.description": "Parar de compartilhar esta sessão",
   "palette.search.placeholder": "Buscar arquivos, comandos e sessões",
+  "palette.search.placeholder.home": "Buscar comandos e sessões",
   "palette.empty": "Nenhum resultado encontrado",
   "palette.group.commands": "Comandos",
   "palette.group.files": "Arquivos",

+ 1 - 0
packages/app/src/i18n/bs.ts

@@ -93,6 +93,7 @@ export const dict = {
   "command.session.unshare.description": "Zaustavi dijeljenje ove sesije",
 
   "palette.search.placeholder": "Pretraži datoteke, komande i sesije",
+  "palette.search.placeholder.home": "Pretraži komande i sesije",
   "palette.empty": "Nema rezultata",
   "palette.group.commands": "Komande",
   "palette.group.files": "Datoteke",

+ 1 - 0
packages/app/src/i18n/da.ts

@@ -93,6 +93,7 @@ export const dict = {
   "command.session.unshare.description": "Stop med at dele denne session",
 
   "palette.search.placeholder": "Søg i filer, kommandoer og sessioner",
+  "palette.search.placeholder.home": "Søg i kommandoer og sessioner",
   "palette.empty": "Ingen resultater fundet",
   "palette.group.commands": "Kommandoer",
   "palette.group.files": "Filer",

+ 1 - 0
packages/app/src/i18n/de.ts

@@ -90,6 +90,7 @@ export const dict = {
   "command.session.unshare": "Teilen der Sitzung aufheben",
   "command.session.unshare.description": "Teilen dieser Sitzung beenden",
   "palette.search.placeholder": "Dateien, Befehle und Sitzungen durchsuchen",
+  "palette.search.placeholder.home": "Befehle und Sitzungen durchsuchen",
   "palette.empty": "Keine Ergebnisse gefunden",
   "palette.group.commands": "Befehle",
   "palette.group.files": "Dateien",

+ 1 - 0
packages/app/src/i18n/en.ts

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

+ 1 - 0
packages/app/src/i18n/es.ts

@@ -93,6 +93,7 @@ export const dict = {
   "command.session.unshare.description": "Dejar de compartir esta sesión",
 
   "palette.search.placeholder": "Buscar archivos, comandos y sesiones",
+  "palette.search.placeholder.home": "Buscar comandos y sesiones",
   "palette.empty": "No se encontraron resultados",
   "palette.group.commands": "Comandos",
   "palette.group.files": "Archivos",

+ 1 - 0
packages/app/src/i18n/fr.ts

@@ -86,6 +86,7 @@ export const dict = {
   "command.session.unshare": "Ne plus partager la session",
   "command.session.unshare.description": "Arrêter de partager cette session",
   "palette.search.placeholder": "Rechercher des fichiers, des commandes et des sessions",
+  "palette.search.placeholder.home": "Rechercher des commandes et des sessions",
   "palette.empty": "Aucun résultat trouvé",
   "palette.group.commands": "Commandes",
   "palette.group.files": "Fichiers",

+ 1 - 0
packages/app/src/i18n/ja.ts

@@ -86,6 +86,7 @@ export const dict = {
   "command.session.unshare": "セッションの共有を停止",
   "command.session.unshare.description": "このセッションの共有を停止",
   "palette.search.placeholder": "ファイル、コマンド、セッションを検索",
+  "palette.search.placeholder.home": "コマンドとセッションを検索",
   "palette.empty": "結果が見つかりません",
   "palette.group.commands": "コマンド",
   "palette.group.files": "ファイル",

+ 1 - 0
packages/app/src/i18n/ko.ts

@@ -82,6 +82,7 @@ export const dict = {
   "command.session.unshare": "세션 공유 중지",
   "command.session.unshare.description": "이 세션 공유 중지",
   "palette.search.placeholder": "파일, 명령어 및 세션 검색",
+  "palette.search.placeholder.home": "명령어 및 세션 검색",
   "palette.empty": "결과 없음",
   "palette.group.commands": "명령어",
   "palette.group.files": "파일",

+ 1 - 0
packages/app/src/i18n/no.ts

@@ -92,6 +92,7 @@ export const dict = {
   "command.session.unshare.description": "Slutt å dele denne sesjonen",
 
   "palette.search.placeholder": "Søk i filer, kommandoer og sesjoner",
+  "palette.search.placeholder.home": "Søk i kommandoer og sesjoner",
   "palette.empty": "Ingen resultater funnet",
   "palette.group.commands": "Kommandoer",
   "palette.group.files": "Filer",

+ 1 - 0
packages/app/src/i18n/pl.ts

@@ -86,6 +86,7 @@ export const dict = {
   "command.session.unshare": "Przestań udostępniać sesję",
   "command.session.unshare.description": "Zatrzymaj udostępnianie tej sesji",
   "palette.search.placeholder": "Szukaj plików, poleceń i sesji",
+  "palette.search.placeholder.home": "Szukaj poleceń i sesji",
   "palette.empty": "Brak wyników",
   "palette.group.commands": "Polecenia",
   "palette.group.files": "Pliki",

+ 1 - 0
packages/app/src/i18n/ru.ts

@@ -93,6 +93,7 @@ export const dict = {
   "command.session.unshare.description": "Прекратить публикацию сессии",
 
   "palette.search.placeholder": "Поиск файлов, команд и сессий",
+  "palette.search.placeholder.home": "Поиск команд и сессий",
   "palette.empty": "Ничего не найдено",
   "palette.group.commands": "Команды",
   "palette.group.files": "Файлы",

+ 1 - 0
packages/app/src/i18n/th.ts

@@ -93,6 +93,7 @@ export const dict = {
   "command.session.unshare.description": "หยุดการแชร์เซสชันนี้",
 
   "palette.search.placeholder": "ค้นหาไฟล์ คำสั่ง และเซสชัน",
+  "palette.search.placeholder.home": "ค้นหาคำสั่งและเซสชัน",
   "palette.empty": "ไม่พบผลลัพธ์",
   "palette.group.commands": "คำสั่ง",
   "palette.group.files": "ไฟล์",

+ 1 - 0
packages/app/src/i18n/tr.ts

@@ -97,6 +97,7 @@ export const dict = {
   "command.session.unshare.description": "Bu oturumun paylaşımını durdur",
 
   "palette.search.placeholder": "Dosya, komut ve oturum ara",
+  "palette.search.placeholder.home": "Komut ve oturum ara",
   "palette.empty": "Sonuç bulunamadı",
   "palette.group.commands": "Komutlar",
   "palette.group.files": "Dosyalar",

+ 1 - 0
packages/app/src/i18n/uk.ts

@@ -93,6 +93,7 @@ export const dict = {
   "command.session.unshare.description": "Припинити поширення цієї сесії",
 
   "palette.search.placeholder": "Пошук файлів, команд і сесій",
+  "palette.search.placeholder.home": "Пошук команд і сесій",
   "palette.empty": "Результатів не знайдено",
   "palette.group.commands": "Команди",
   "palette.group.files": "Файли",

+ 1 - 0
packages/app/src/i18n/zh.ts

@@ -120,6 +120,7 @@ export const dict = {
   "command.session.unshare.description": "停止分享此会话",
 
   "palette.search.placeholder": "搜索文件、命令和会话",
+  "palette.search.placeholder.home": "搜索命令和会话",
   "palette.empty": "未找到结果",
   "palette.group.commands": "命令",
   "palette.group.files": "文件",

+ 1 - 0
packages/app/src/i18n/zht.ts

@@ -97,6 +97,7 @@ export const dict = {
   "command.session.unshare.description": "停止分享此工作階段",
 
   "palette.search.placeholder": "搜尋檔案、命令和工作階段",
+  "palette.search.placeholder.home": "搜尋命令和工作階段",
   "palette.empty": "找不到結果",
   "palette.group.commands": "命令",
   "palette.group.files": "檔案",

+ 28 - 0
packages/app/src/pages/home.tsx

@@ -431,6 +431,34 @@ export function NewHome() {
   }
 
   command.register("home", () => [
+    {
+      id: "command.palette",
+      title: language.t("command.palette"),
+      hidden: true,
+      onSelect: async () => {
+        const conn = focusedServer()
+        if (!conn) return
+        const ctx = global.ensureServerCtx(conn)
+        const { DialogHomeCommandPaletteV2 } = await import("@/components/dialog-command-palette-v2")
+        void dialog.show(() => (
+          <DialogHomeCommandPaletteV2
+            server={conn}
+            onSelectSession={(entry) => {
+              if (!entry.sessionID || !entry.directory || !entry.server) return
+              const sessionID = entry.sessionID
+              const server = entry.server
+              const directory = entry.project?.worktree ?? entry.directory
+              ctx.projects.open(directory)
+              ctx.projects.touch(directory)
+              void startTransition(() => {
+                const tab = tabs.addSessionTab({ server, sessionId: sessionID })
+                tabs.select(tab)
+              })
+            }}
+          />
+        ))
+      },
+    },
     {
       id: "home.sessions.search.focus",
       title: searchPlaceholder(),

+ 11 - 0
packages/app/src/pages/new-session.tsx

@@ -3,6 +3,7 @@ import { createStore } from "solid-js/store"
 import { Portal } from "solid-js/web"
 import { useSearchParams } from "@solidjs/router"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
 import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
 import { NewSessionDesignView } from "@/components/session"
@@ -51,6 +52,7 @@ export default function NewSessionPage() {
   const comments = useComments()
   const language = useLanguage()
   const settings = useSettings()
+  const dialog = useDialog()
   const command = useCommand()
   const providers = useProviders(() => sdk().directory)
   const openProviderSettings = useSettingsDialog("providers")
@@ -77,6 +79,15 @@ export default function NewSessionPage() {
   })
 
   command.register("new-session", () => [
+    {
+      id: "command.palette",
+      title: language.t("command.palette"),
+      hidden: true,
+      onSelect: async () => {
+        const { DialogSelectFile } = await import("@/components/dialog-select-file")
+        void dialog.show(() => <DialogSelectFile />)
+      },
+    },
     {
       id: "input.focus",
       title: language.t("command.input.focus"),

+ 10 - 0
packages/app/src/pages/session.tsx

@@ -41,6 +41,7 @@ import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/r
 import { NewSessionView, SessionHeader } from "@/components/session"
 import { ErrorPage } from "@/pages/error"
 import { CommentsProvider, useComments } from "@/context/comments"
+import { useCommand } from "@/context/command"
 import { DirectoryDataProvider } from "@/pages/directory-layout"
 import { useServerSync } from "@/context/server-sync"
 import { useLanguage } from "@/context/language"
@@ -362,6 +363,7 @@ export default function Page() {
   const platform = usePlatform()
   const prompt = usePrompt()
   const comments = useComments()
+  const command = useCommand()
   const terminal = useTerminal()
   const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
   const location = useLocation()
@@ -1135,6 +1137,14 @@ export default function Page() {
     review: reviewTab,
     fileBrowser: () => newSessionDesign() && isDesktop() && !!params.id,
   })
+  command.register("session-palette", () => [
+    {
+      id: "command.palette",
+      title: language.t("command.palette"),
+      hidden: true,
+      onSelect: () => command.trigger("file.open", "palette"),
+    },
+  ])
 
   const openReviewFile = createOpenReviewFile({
     showAllFiles,

+ 1 - 1
packages/app/src/pages/session/use-session-commands.tsx

@@ -468,7 +468,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
         id: "file.open",
         title: language.t("command.file.open"),
         description: language.t("palette.search.placeholder"),
-        keybind: "mod+k,mod+p",
+        keybind: "mod+p",
         slash: "open",
         onSelect: openFile,
       }),

+ 76 - 0
packages/app/test-browser/command-palette.test.ts

@@ -0,0 +1,76 @@
+import { describe, expect, test } from "bun:test"
+import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client"
+import { createRoot } from "solid-js"
+import { createServerSessionEntries } from "@/components/command-palette"
+import type { LocalProject } from "@/context/layout"
+import { ServerConnection } from "@/context/server"
+import { getProjectAvatarSource } from "@/pages/layout/helpers"
+
+const stored: Project = {
+  id: "project-1",
+  name: "Palette project",
+  worktree: "/workspace/project",
+  sandboxes: [],
+  time: { created: 1, updated: 1 },
+}
+
+const session: GlobalSession = {
+  id: "session-1",
+  slug: "session-1",
+  projectID: stored.id,
+  directory: stored.worktree,
+  title: "Palette session",
+  version: "1",
+  time: { created: 1, updated: 2 },
+  project: { id: stored.id, name: stored.name, worktree: stored.worktree },
+}
+
+describe("command palette sessions", () => {
+  test("uses the home project avatar and cancels superseded searches", async () => {
+    const server = ServerConnection.Key.make("selected-server")
+    const opened: LocalProject = {
+      ...stored,
+      icon: { override: "home-project-avatar" },
+      expanded: true,
+    }
+    const searches: string[] = []
+    const result = await new Promise<Awaited<ReturnType<ReturnType<typeof createServerSessionEntries>>>>(
+      (resolve, reject) => {
+        createRoot((dispose) => {
+          const search = createServerSessionEntries({
+            server,
+            opened: () => [opened],
+            stored: () => [{ ...stored, icon: { url: "stored-project-avatar" } }],
+            load: async (text) => {
+              searches.push(text)
+              return {
+                data: [session, { ...session, id: "archived-session", time: { ...session.time, archived: 3 } }],
+              }
+            },
+            untitled: () => "Untitled",
+            category: () => "Sessions",
+          })
+          const first = search("palette")
+          const second = search("palette session")
+          Promise.all([first, second])
+            .then(([cancelled, entries]) => {
+              expect(cancelled).toEqual([])
+              resolve(entries)
+            })
+            .catch(reject)
+            .finally(dispose)
+        })
+      },
+    )
+
+    expect(searches).toEqual(["palette session"])
+    expect(result).toHaveLength(1)
+    expect(getProjectAvatarSource(result[0]?.project?.id, result[0]?.project?.icon)).toBe("home-project-avatar")
+    expect(result[0]).toMatchObject({
+      server,
+      sessionID: session.id,
+      description: stored.name,
+      project: { id: stored.id, icon: opened.icon },
+    })
+  })
+})