From 888c4cb50476aaecaad48e6a448759da3040ed2e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:00:53 -0500 Subject: [PATCH 01/12] feat(tui): exit subagent menu with up arrow (#36951) Co-authored-by: Aiden Cline --- .../src/cli/cmd/run/footer.command.tsx | 6 ++++ .../test/cli/run/footer.view.test.tsx | 36 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/packages/opencode/src/cli/cmd/run/footer.command.tsx b/packages/opencode/src/cli/cmd/run/footer.command.tsx index c97af0ce09..3176c1724f 100644 --- a/packages/opencode/src/cli/cmd/run/footer.command.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.command.tsx @@ -633,6 +633,12 @@ export function RunSubagentSelectBody(props: { return } + if (event.name.toLowerCase() === "up" && menu.selected() === 0) { + event.preventDefault() + props.onClose() + return + } + handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose }) }) diff --git a/packages/opencode/test/cli/run/footer.view.test.tsx b/packages/opencode/test/cli/run/footer.view.test.tsx index 2e9fd8ef17..d54e2e3fd6 100644 --- a/packages/opencode/test/cli/run/footer.view.test.tsx +++ b/packages/opencode/test/cli/run/footer.view.test.tsx @@ -642,6 +642,42 @@ test("direct subagent panel renders active subagents", async () => { } }) +test("direct subagent panel closes when moving up from the first item", async () => { + const [tabs] = createSignal([ + subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" }), + subagent({ sessionID: "s-2", label: "General", description: "Write migration plan" }), + ]) + const [current] = createSignal() + let closed = 0 + + const app = await testRender( + () => ( + + RUN_THEME_FALLBACK.footer} + tabs={tabs} + current={current} + onClose={() => closed++} + onSelect={() => {}} + /> + + ), + { width: 100, height: RUN_SUBAGENT_PANEL_ROWS }, + ) + + try { + await app.renderOnce() + app.mockInput.pressKey("ARROW_DOWN") + app.mockInput.pressKey("ARROW_UP") + expect(closed).toBe(0) + + app.mockInput.pressKey("ARROW_UP") + expect(closed).toBe(1) + } finally { + app.renderer.destroy() + } +}) + test("direct queued prompt panel renders pending prompt actions", async () => { const [prompts] = createSignal([ { messageID: "m-1", partID: "p-1", prompt: { text: "fix the auth test", parts: [] } }, From 4a760b5743496942fd821eeafaa7d648a5630973 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Thu, 16 Jul 2026 08:44:47 +0800 Subject: [PATCH 02/12] fix(app): show selector for custom agents (#37198) Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> --- packages/app/src/context/local-agent.test.ts | 29 +++++++++++++++++++ packages/app/src/context/local-agent.ts | 7 +++++ packages/app/src/context/local.tsx | 11 ++++--- .../composer/session-composer-controls.ts | 2 +- .../pages/session/use-composer-commands.tsx | 6 ++-- 5 files changed, 46 insertions(+), 9 deletions(-) create mode 100644 packages/app/src/context/local-agent.test.ts create mode 100644 packages/app/src/context/local-agent.ts diff --git a/packages/app/src/context/local-agent.test.ts b/packages/app/src/context/local-agent.test.ts new file mode 100644 index 0000000000..c7a95f9a5b --- /dev/null +++ b/packages/app/src/context/local-agent.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test" +import { hasCustomAgent, resolveAgent } from "./local-agent" + +describe("hasCustomAgent", () => { + test("detects explicitly custom agents", () => { + expect(hasCustomAgent([{ native: true }, { native: false }])).toBe(true) + }) + + test("ignores built-in and unclassified agents", () => { + expect(hasCustomAgent([{ native: true }, {}])).toBe(false) + }) +}) + +describe("resolveAgent", () => { + const agents = [{ name: "plan" }, { name: "build" }, { name: "custom" }] + + test("uses the requested available agent", () => { + expect(resolveAgent(agents, "custom")?.name).toBe("custom") + }) + + test("defaults to build", () => { + expect(resolveAgent(agents)?.name).toBe("build") + expect(resolveAgent(agents, "missing")?.name).toBe("build") + }) + + test("uses the first agent when build is unavailable", () => { + expect(resolveAgent([{ name: "custom" }], "missing")?.name).toBe("custom") + }) +}) diff --git a/packages/app/src/context/local-agent.ts b/packages/app/src/context/local-agent.ts new file mode 100644 index 0000000000..f5c76d1469 --- /dev/null +++ b/packages/app/src/context/local-agent.ts @@ -0,0 +1,7 @@ +export function hasCustomAgent(items: Array<{ native?: boolean }>) { + return items.some((item) => item.native === false) +} + +export function resolveAgent(items: T[], name?: string) { + return items.find((item) => item.name === name) ?? items.find((item) => item.name === "build") ?? items[0] +} diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index a12fd832fd..83db571385 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -4,8 +4,10 @@ import { useParams } from "@solidjs/router" import { batch, createEffect, createMemo, startTransition } from "solid-js" import { createStore } from "solid-js/store" import { useModels } from "@/context/models" +import { useSettings } from "@/context/settings" import { useProviders } from "@/hooks/use-providers" import { Persist, persisted } from "@/utils/persist" +import { hasCustomAgent, resolveAgent } from "./local-agent" import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant" import { useSDK } from "./sdk" import { useSync } from "./sync" @@ -62,9 +64,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const serverSDK = useServerSDK() const providers = useProviders(() => sdk().directory) const models = useModels() + const settings = useSettings() const id = createMemo(() => params.id || undefined) const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden)) + const agentsVisible = createMemo(() => settings.visibility.customAgents() || hasCustomAgent(list())) const connected = createMemo(() => new Set(providers.connected().map((item) => item.id))) const [saved, setSaved, , savedReady] = persisted( @@ -107,9 +111,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } const pickAgent = (name: string | undefined) => { - const items = list() - if (items.length === 0) return - return items.find((item) => item.name === name) ?? items[0] + return resolveAgent(list(), name) } createEffect(() => { @@ -180,8 +182,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const agent = { list, + visible: agentsVisible, current() { - return pickAgent(scope()?.agent ?? store.current) + return pickAgent(agentsVisible() ? (scope()?.agent ?? store.current) : "build") }, set(name: string | undefined) { const item = pickAgent(name) diff --git a/packages/app/src/pages/session/composer/session-composer-controls.ts b/packages/app/src/pages/session/composer/session-composer-controls.ts index 8d5d96719c..3ead9599bb 100644 --- a/packages/app/src/pages/session/composer/session-composer-controls.ts +++ b/packages/app/src/pages/session/composer/session-composer-controls.ts @@ -41,7 +41,7 @@ export function createPromptInputController(input: { options: local.agent.list().map((agent) => agent.name), current: local.agent.current()?.name ?? "", loading: agentsQuery.isLoading, - visible: settings.visibility.customAgents(), + visible: local.agent.visible(), select: local.agent.set, }, model: { diff --git a/packages/app/src/pages/session/use-composer-commands.tsx b/packages/app/src/pages/session/use-composer-commands.tsx index 9b75c7bf4c..e7e51489ee 100644 --- a/packages/app/src/pages/session/use-composer-commands.tsx +++ b/packages/app/src/pages/session/use-composer-commands.tsx @@ -1,7 +1,6 @@ import { useCommand, type CommandOption } from "@/context/command" import { useLanguage } from "@/context/language" import { useLocal, type ModelSelection } from "@/context/local" -import { useSettings } from "@/context/settings" import { useDialog } from "@opencode-ai/ui/context/dialog" import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom" import { useSessionLayout } from "./session-layout" @@ -19,7 +18,6 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => { const dialog = useDialog() const language = useLanguage() const local = useLocal() - const settings = useSettings() const { sessionKey } = useSessionLayout() const sessionOwnership = createSessionOwnership(sessionKey) const model = input.model ?? local.model @@ -70,7 +68,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => { description: language.t("command.agent.cycle.description"), keybind: "mod+.", slash: "agent", - disabled: !settings.visibility.customAgents(), + disabled: !local.agent.visible(), onSelect: () => local.agent.move(1), }), agentCommand({ @@ -78,7 +76,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => { title: language.t("command.agent.cycle.reverse"), description: language.t("command.agent.cycle.reverse.description"), keybind: "shift+mod+.", - disabled: !settings.visibility.customAgents(), + disabled: !local.agent.visible(), onSelect: () => local.agent.move(-1), }), ]) From 17544802c38a4d35834275526ccf38be1cdcfbf4 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:40:48 +0800 Subject: [PATCH 03/12] fix(app): remove inactive help button (#37217) Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> --- packages/app/src/components/help-button.tsx | 42 +-------------------- packages/app/src/pages/layout-new.tsx | 3 +- packages/app/src/pages/layout.tsx | 3 +- 3 files changed, 3 insertions(+), 45 deletions(-) diff --git a/packages/app/src/components/help-button.tsx b/packages/app/src/components/help-button.tsx index 40861676b9..23a955a837 100644 --- a/packages/app/src/components/help-button.tsx +++ b/packages/app/src/components/help-button.tsx @@ -8,49 +8,9 @@ import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4" import homeImage from "@/assets/help/home.png" import tabsImage from "@/assets/help/tabs.png" -const helpIcon = ( - -) - -const triggerClass = - "size-7 !rounded-full shrink-0 bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]" - // TODO: wire to changelog / seen-state when available const showPopover = () => true -export function HelpButton() { - const platform = usePlatform() - - return ( - { - event.preventDefault() - platform.openLink(event.currentTarget.href) - }} - > - {helpIcon} - - ) -} - // can remove this after the tabs rollout has been out for a while export function TabsInfoPopup() { const settings = useSettings() @@ -61,7 +21,7 @@ export function TabsInfoPopup() {
) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 8762d2f884..545eee827a 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -54,7 +54,7 @@ import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useCommand, type CommandOption } from "@/context/command" import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd" import { DebugBar } from "@/components/debug-bar" -import { HelpButton, TabsInfoPopup } from "@/components/help-button" +import { TabsInfoPopup } from "@/components/help-button" import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" import { useDirectoryPicker } from "@/components/directory-picker" import { ServerConnection, useServer } from "@/context/server" @@ -2396,7 +2396,6 @@ export default function LegacyLayout(props: ParentProps) { {import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && } - ) From 590a11ed9870fb0f64e8f99ccbaa8c9565b793fa Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:40:04 +0800 Subject: [PATCH 04/12] fix(desktop): include wslServers loading in ready state (#37218) --- packages/desktop/src/renderer/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index 2f14f43481..13c122cb2e 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -382,7 +382,8 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) { function App() { const wslServers = useWslServers() const ready = createMemo( - () => !defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading, + () => + !defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading && !wslServers.isLoading, ) const servers = createMemo(() => { const data = initializationData(sidecar) From 958e08e109b2adf263c78d4b6edbfa328c72ecb8 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:21:20 +0530 Subject: [PATCH 05/12] feat(app): cmd+k menu shows sessions on home page (#36686) Co-authored-by: Brendan Allan --- .../app/src/components/command-palette.ts | 224 +++++++----------- .../components/dialog-command-palette-v2.tsx | 164 +++++++++++-- .../app/src/components/settings-keybinds.tsx | 3 +- packages/app/src/context/command.test.ts | 16 +- packages/app/src/context/command.tsx | 14 +- packages/app/src/i18n/ar.ts | 1 + packages/app/src/i18n/br.ts | 1 + packages/app/src/i18n/bs.ts | 1 + packages/app/src/i18n/da.ts | 1 + packages/app/src/i18n/de.ts | 1 + packages/app/src/i18n/en.ts | 1 + packages/app/src/i18n/es.ts | 1 + packages/app/src/i18n/fr.ts | 1 + packages/app/src/i18n/ja.ts | 1 + packages/app/src/i18n/ko.ts | 1 + packages/app/src/i18n/no.ts | 1 + packages/app/src/i18n/pl.ts | 1 + packages/app/src/i18n/ru.ts | 1 + packages/app/src/i18n/th.ts | 1 + packages/app/src/i18n/tr.ts | 1 + packages/app/src/i18n/uk.ts | 1 + packages/app/src/i18n/zh.ts | 1 + packages/app/src/i18n/zht.ts | 1 + packages/app/src/pages/home.tsx | 28 +++ packages/app/src/pages/new-session.tsx | 11 + packages/app/src/pages/session.tsx | 10 + .../pages/session/use-session-commands.tsx | 2 +- .../app/test-browser/command-palette.test.ts | 76 ++++++ 28 files changed, 409 insertions(+), 157 deletions(-) create mode 100644 packages/app/test-browser/command-palette.test.ts diff --git a/packages/app/src/components/command-palette.ts b/packages/app/src/components/command-palette.ts index b61a61977e..95e03351ae 100644 --- a/packages/app/src/components/command-palette.ts +++ b/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 +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 | 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[] + onCleanup(() => abort?.abort()) + + return async (text: string): Promise => { + const search = text.trim() + if (!search) { + abort?.abort() + return [] } - if (state.cached) return state.cached - if (state.inflight) return state.inflight - - 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[]) - }), + abort?.abort() + const current = new AbortController() + abort = current + await new Promise((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() - 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, } } diff --git a/packages/app/src/components/dialog-command-palette-v2.tsx b/packages/app/src/components/dialog-command-palette-v2.tsx index b1cafe1e7a..dba0e214fd 100644 --- a/packages/app/src/components/dialog-command-palette-v2.tsx +++ b/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 ( + + ) +} + +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 ( + dialog.close()} + /> + ) +} + +function CommandPaletteView(props: { + placeholder: string + loadItems: (text: string) => CommandPaletteEntry[] | Promise + 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={} 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={
- {entries.loading ? palette.language.t("common.loading") : palette.language.t("palette.empty")} + {entries.loading ? language.t("common.loading") : language.t("palette.empty")}
} > @@ -132,9 +235,14 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v setActive(visibleEntries().findIndex((entry) => entry.id === item.id))} - onSelect={() => palette.select(item)} + onSelect={() => props.select(item)} /> )} @@ -153,13 +261,19 @@ function PaletteRow(props: { item: CommandPaletteEntry active: boolean language: ReturnType + 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 (