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

Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
Aarav Sareen 2026-07-16 15:21:20 +05:30 committed by GitHub
commit 958e08e109
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 409 additions and 157 deletions

View file

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

View file

@ -5,13 +5,20 @@ import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon } from "@opencode-ai/ui/v2/icon" import { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { formatKeybindParts } from "@/context/command" 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 { 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 { getRelativeTime } from "@/utils/time"
import { import {
createCommandPaletteCommandEntry,
createCommandPaletteFileEntry, createCommandPaletteFileEntry,
createCommandPaletteModel, createCommandPaletteModel,
createServerSessionEntries,
uniqueCommandPaletteEntries, uniqueCommandPaletteEntries,
type CommandPaletteEntry, type CommandPaletteEntry,
} from "./command-palette" } from "./command-palette"
@ -30,9 +37,6 @@ function matchesEntry(entry: CommandPaletteEntry, query: string) {
export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) { export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
const palette = createCommandPaletteModel(props) const palette = createCommandPaletteModel(props)
const [query, setQuery] = createSignal("")
const [active, setActive] = createSignal(0)
const loadItems = async (text: string) => { const loadItems = async (text: string) => {
const q = text.trim() const q = text.trim()
if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()] 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") const category = palette.language.t("palette.group.files")
return [ return [
...palette.commandEntries().filter((entry) => matchesEntry(entry, q)), ...palette.commandEntries().filter((entry) => matchesEntry(entry, q)),
...nextSessions.filter((entry) => matchesEntry(entry, q)), ...nextSessions,
...files.map((path) => createCommandPaletteFileEntry(path, category)), ...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. // Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? [])) const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
const groupedEntries = createMemo(() => groups(visibleEntries())) const groupedEntries = createMemo(() => groups(visibleEntries()))
const activeEntry = createMemo(() => visibleEntries()[active()]) const activeEntry = createMemo(() => visibleEntries()[active()])
const openSessions = createMemo(
() =>
new Set(
tabs.store.flatMap((tab) =>
tab.type === "session" ? [`${tab.server}\0${tab.sessionId}`] : [],
),
),
)
createEffect(() => { createEffect(() => {
query() query()
@ -59,7 +162,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
}) })
createEffect(() => { createEffect(() => {
palette.highlight(activeEntry()) props.highlight(activeEntry())
}) })
let resultsRef: HTMLDivElement | undefined let resultsRef: HTMLDivElement | undefined
@ -86,12 +189,12 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
} }
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault() event.preventDefault()
palette.select(activeEntry()) props.select(activeEntry())
return return
} }
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault() event.preventDefault()
palette.close() props.close()
} }
} }
@ -105,7 +208,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
autocomplete="off" autocomplete="off"
spellcheck={false} spellcheck={false}
appearance="large" appearance="large"
placeholder={palette.language.t("palette.search.placeholder")} placeholder={props.placeholder}
leadingIcon={<Icon name="magnifying-glass" />} leadingIcon={<Icon name="magnifying-glass" />}
onInput={(event) => setQuery(event.currentTarget.value)} onInput={(event) => setQuery(event.currentTarget.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
@ -117,7 +220,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
when={visibleEntries().length > 0} when={visibleEntries().length > 0}
fallback={ fallback={
<div class="command-palette-v2-state"> <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> </div>
} }
> >
@ -132,9 +235,14 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
<PaletteRow <PaletteRow
item={item} item={item}
active={activeEntry()?.id === item.id} 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))} onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
onSelect={() => palette.select(item)} onSelect={() => props.select(item)}
/> />
)} )}
</For> </For>
@ -153,13 +261,19 @@ function PaletteRow(props: {
item: CommandPaletteEntry item: CommandPaletteEntry
active: boolean active: boolean
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
sessionOpen: boolean
onActive: () => void onActive: () => void
onSelect: () => 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 ( return (
<button <button
type="button" type="button"
class="command-palette-v2-row" class="command-palette-v2-row group"
role="option" role="option"
aria-selected={props.active} aria-selected={props.active}
data-active={props.active ? "" : undefined} data-active={props.active ? "" : undefined}
@ -197,7 +311,25 @@ function PaletteRow(props: {
</Match> </Match>
<Match when={props.item.type === "session"}> <Match when={props.item.type === "session"}>
<div class="command-palette-v2-row-main"> <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"> <div class="command-palette-v2-row-text">
<span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}> <span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
{props.item.title} {props.item.title}

View file

@ -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 { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import fuzzysort from "fuzzysort" 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 { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { SettingsList } from "./settings-list" 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 IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const PALETTE_ID = "command.palette" const PALETTE_ID = "command.palette"
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
type KeybindGroup = "General" | "Session" | "Navigation" | "Model and agent" | "Terminal" | "Prompt" type KeybindGroup = "General" | "Session" | "Navigation" | "Model and agent" | "Terminal" | "Prompt"

View file

@ -1,5 +1,19 @@
import { describe, expect, test } from "bun:test" 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", () => { describe("upsertCommandRegistration", () => {
test("replaces keyed registrations", () => { test("replaces keyed registrations", () => {

View file

@ -11,7 +11,7 @@ import { Persist, persisted } from "@/utils/persist"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const PALETTE_ID = "command.palette" 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 SUGGESTED_PREFIX = "suggested."
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"]) const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
@ -87,6 +87,16 @@ export interface CommandOption {
onHighlight?: () => (() => void) | void 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) { export function resolveKeybindOption(candidates: CommandOption[] | undefined, event: KeyboardEvent) {
return candidates?.find((option) => option.when?.(event)) ?? candidates?.find((option) => !option.when) 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 = () => { const showPalette = () => {
run("file.open", "palette") run(PALETTE_ID, "palette")
} }
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -86,6 +86,7 @@ export const dict = {
"command.session.unshare": "Ne plus partager la session", "command.session.unshare": "Ne plus partager la session",
"command.session.unshare.description": "Arrêter de partager cette 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": "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.empty": "Aucun résultat trouvé",
"palette.group.commands": "Commandes", "palette.group.commands": "Commandes",
"palette.group.files": "Fichiers", "palette.group.files": "Fichiers",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -431,6 +431,34 @@ export function NewHome() {
} }
command.register("home", () => [ 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", id: "home.sessions.search.focus",
title: searchPlaceholder(), title: searchPlaceholder(),

View file

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

View file

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

View file

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

View file

@ -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 },
})
})
})