diff --git a/packages/app/e2e/regression/command-palette.spec.ts b/packages/app/e2e/regression/command-palette.spec.ts index 77be5062485..c9533407586 100644 --- a/packages/app/e2e/regression/command-palette.spec.ts +++ b/packages/app/e2e/regression/command-palette.spec.ts @@ -1,5 +1,10 @@ import { expect, test } from "@playwright/test" -import { captureConsoleWarnings, openCommandPalette, paletteSession } from "../utils/command-palette" +import { + captureConsoleWarnings, + openCommandPalette, + openPaletteWorkspace, + paletteSession, +} from "../utils/command-palette" test.use({ serviceWorkers: "block", permissions: ["clipboard-read", "clipboard-write"] }) @@ -48,6 +53,25 @@ test("lists recent sessions before a query is entered", async ({ page }) => { await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible() }) +test("titlebar search lists recent sessions and opens the typed match", async ({ page }) => { + await openPaletteWorkspace(page, true) + await page.getByRole("banner").getByRole("button", { name: "Search sessions", exact: true }).click() + const dialog = page.getByRole("dialog") + const input = dialog.getByRole("textbox") + await expect(input).toBeFocused() + await expect(input).toHaveAttribute("placeholder", "Search sessions") + await expect(dialog.getByText("Recent sessions", { exact: true })).toBeVisible() + await expect(dialog.getByRole("option", { name: /Palette fixture session/ })).toHaveAttribute("aria-selected", "true") + // Session search lists sessions only, never commands. + await input.fill("settings") + await expect(dialog.getByText("No results found", { exact: true })).toBeVisible() + await input.fill("Palette fixture") + await expect(dialog.getByRole("option")).toHaveCount(1) + await input.press("Enter") + await expect(dialog).toHaveCount(0) + await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible() +}) + test("reopens from its shortcut as soon as it closes", async ({ page }) => { const { dialog, input } = await openCommandPalette(page) await input.press("Escape") @@ -138,7 +162,14 @@ test("keeps a keyboard-selected file when session results arrive later", async ( const { dialog, input } = await openCommandPalette(page) const sessions = Promise.withResolvers() await page.route("**/api/fs/find?*", (route) => - route.fulfill({ json: { data: [{ path: "README.txt", type: "file" }, { path: "README.md", type: "file" }] } }), + route.fulfill({ + json: { + data: [ + { path: "README.txt", type: "file" }, + { path: "README.md", type: "file" }, + ], + }, + }), ) await page.route("**/api/session?*", async (route) => { await sessions.promise diff --git a/packages/app/e2e/utils/command-palette.ts b/packages/app/e2e/utils/command-palette.ts index 92630566576..83ca7403c5d 100644 --- a/packages/app/e2e/utils/command-palette.ts +++ b/packages/app/e2e/utils/command-palette.ts @@ -26,6 +26,17 @@ export function captureConsoleWarnings(page: Page) { } export async function openCommandPalette(page: Page, home = false) { + const mock = await openPaletteWorkspace(page, home) + await page.keyboard.press("ControlOrMeta+Shift+P") + const dialog = page.getByRole("dialog") + const input = dialog.getByRole("textbox") + await expect(input).toBeFocused() + await expect(dialog.getByRole("option")).not.toHaveCount(0) + + return { dialog, input, push: mock.push } +} + +export async function openPaletteWorkspace(page: Page, home = false) { const mock = await mockOpenCodeServer(page, { directory: paletteSession.directory, project: project({ id: paletteSession.projectID, directory: paletteSession.directory, name: "command-palette" }), @@ -47,11 +58,5 @@ export async function openCommandPalette(page: Page, home = false) { await expect(page.locator('[data-component="composer-editor"]')).toBeEditable({ timeout: APP_READY_TIMEOUT }) } - await page.keyboard.press("ControlOrMeta+Shift+P") - const dialog = page.getByRole("dialog") - const input = dialog.getByRole("textbox") - await expect(input).toBeFocused() - await expect(dialog.getByRole("option")).not.toHaveCount(0) - - return { dialog, input, push: mock.push } + return mock } diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index df60d4ce141..2321a6176f4 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -42,6 +42,7 @@ export const dict = { "command.palette": "Command palette", "command.session.new": "New session", + "command.session.search": "Search sessions", "command.session.previous": "Previous session", "command.session.next": "Next session", "command.session.previous.unseen": "Previous unread session", @@ -93,6 +94,7 @@ export const dict = { "palette.group.commands": "Commands", "palette.group.files": "Files", "palette.group.recentSessions": "Recent sessions", + "palette.session.location": "{{project}} ยท {{server}}", "dialog.provider.search.placeholder": "Search providers", "dialog.provider.empty": "No providers found", diff --git a/packages/app/src/shell/commands/palette.ts b/packages/app/src/shell/commands/palette.ts index 676166a2e8b..65edd9a892c 100644 --- a/packages/app/src/shell/commands/palette.ts +++ b/packages/app/src/shell/commands/palette.ts @@ -229,6 +229,7 @@ export function createServerSessionEntries(props: { untitled: () => string category: () => string recentCategory: () => string + recentLimit?: number }) { let abort: AbortController | undefined @@ -294,6 +295,6 @@ export function createServerSessionEntries(props: { ), ) - return search ? entries : entries.slice(0, ENTRY_LIMIT) + return search ? entries : entries.slice(0, props.recentLimit ?? ENTRY_LIMIT) } } diff --git a/packages/app/src/shell/commands/session-search.tsx b/packages/app/src/shell/commands/session-search.tsx new file mode 100644 index 00000000000..4c98bba8cda --- /dev/null +++ b/packages/app/src/shell/commands/session-search.tsx @@ -0,0 +1,78 @@ +import { useDialog } from "@opencode/ui/context/dialog" +import { startTransition } from "solid-js" +import { useGlobal } from "@/runtime/server/runtime" +import { useLanguage } from "@/runtime/i18n/language" +import { ServerConnection, serverName, useServers } from "@/runtime/server/registry" +import { useTabs } from "@/shell/tabs/tabs" +import { createServerSessionEntries, type CommandPaletteEntry } from "./palette" +import { CommandPaletteView } from "./dialog" + +const RECENT_LIMIT = 10 + +export function DialogSessionSearch() { + const dialog = useDialog() + const global = useGlobal() + const language = useLanguage() + const tabs = useTabs() + const servers = useServers().visible.filter( + (conn) => global.servers.health[ServerConnection.key(conn)]?.healthy !== false, + ) + + const loaders = servers.map((conn) => { + const ctx = global.ensureServerCtx(conn) + const load = createServerSessionEntries({ + server: ServerConnection.key(conn), + projects: ctx.projects.list, + load: (search, signal) => ctx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }), + get: (sessionID, signal) => ctx.sdk.api.session.get({ sessionID }, { signal }), + untitled: () => language.t("command.session.new"), + category: () => language.t("command.category.session"), + recentCategory: () => language.t("palette.group.recentSessions"), + recentLimit: RECENT_LIMIT, + }) + + if (servers.length < 2) return load + + return (query: string) => + load(query).then((entries) => + entries.map((entry) => ({ + ...entry, + description: language.t("palette.session.location", { + project: entry.description ?? "", + server: serverName(conn), + }), + })), + ) + }) + + const sessions = async (query: string) => { + const entries = (await Promise.all(loaders.map((load) => load(query)))) + .flat() + .sort((a, b) => (b.updated ?? 0) - (a.updated ?? 0)) + + return query ? entries : entries.slice(0, RECENT_LIMIT) + } + + const select = (item: CommandPaletteEntry | undefined) => { + if (!item?.sessionID || !item.server) return + const sessionId = item.sessionID + const server = item.server + const directory = item.project?.worktree ?? item.directory + const conn = servers.find((conn) => ServerConnection.key(conn) === server) + dialog.close() + + if (conn && directory) global.ensureServerCtx(conn).projects.touch(directory) + void startTransition(() => tabs.select(tabs.addSessionTab({ server, sessionId }))) + } + + return ( + []} + sources={[sessions]} + highlight={() => {}} + select={select} + close={() => dialog.close()} + /> + ) +} diff --git a/packages/app/src/shell/titlebar/titlebar.tsx b/packages/app/src/shell/titlebar/titlebar.tsx index 1635bc25374..601b21dfdba 100644 --- a/packages/app/src/shell/titlebar/titlebar.tsx +++ b/packages/app/src/shell/titlebar/titlebar.tsx @@ -2,6 +2,7 @@ import { createEffect, createMemo, createResource, Match, Show, Switch, untrack import { createStore, unwrap } from "solid-js/store" import { Dynamic, Portal } from "solid-js/web" import { useLocation, useNavigate } from "@solidjs/router" +import { useDialog } from "@opencode/ui/context/dialog" import { IconButton } from "@opencode/ui/icon-button" import { Icon } from "@opencode/ui/icon" import { Keybind } from "@opencode/ui/keybind" @@ -62,6 +63,7 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) { const language = useLanguage() const settings = useSettings() const openSettings = useSettingsDialog() + const dialog = useDialog() const navigate = useNavigate() const location = useLocation() const mobile = createMediaQuery("(max-width: 767px)") @@ -448,6 +450,51 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) { ) + const searchButton = (vertical = false) => ( + + {language.t("command.session.search")} + + + } + class="shrink-0" + > + } + onClick={() => command.trigger("session.search")} + aria-label={language.t("command.session.search")} + /> + + } + > + + + ) + command.register("titlebar-home", () => [ { id: "home.toggle", @@ -460,6 +507,15 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) { toggleHome() }, }, + { + id: "session.search", + title: language.t("command.session.search"), + category: language.t("command.category.session"), + onSelect: async () => { + const { DialogSessionSearch } = await import("@/shell/commands/session-search") + void dialog.show(() => ) + }, + }, ]) command.register("tabs", () => { @@ -546,7 +602,10 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) { - {homeButton()} + + {homeButton()} + {searchButton()} + {homeButton(true)} + {searchButton(true)}