feat(app): add session search to the titlebar

Searches sessions across online servers, recent first. The button sits next to Home in top tabs and as a row in side tabs and the inbox. The command has no default keybind and is bindable in Shortcuts.
This commit is contained in:
Dax Raad
2026-10-19 10:52:26 -04:00
Unverified
parent 22ad26f24c
commit 6d0c0a3637
6 changed files with 188 additions and 11 deletions
@@ -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<void>()
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
+12 -7
View File
@@ -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
}
+2
View File
@@ -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",
+2 -1
View File
@@ -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)
}
}
@@ -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 (
<CommandPaletteView
placeholder={language.t("home.sessions.search.placeholder")}
items={() => []}
sources={[sessions]}
highlight={() => {}}
select={select}
close={() => dialog.close()}
/>
)
}
+61 -1
View File
@@ -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 } }) {
</Show>
)
const searchButton = (vertical = false) => (
<Show
when={vertical}
fallback={
<Tooltip
placement="bottom"
value={
<>
{language.t("command.session.search")}
<Keybind keys={command.keybindParts("session.search")} variant="neutral" />
</>
}
class="shrink-0"
>
<IconButton
type="button"
variant="ghost-muted"
size="large"
class="!w-9 shrink-0"
icon={<Icon name="magnifying-glass" />}
onClick={() => command.trigger("session.search")}
aria-label={language.t("command.session.search")}
/>
</Tooltip>
}
>
<button
type="button"
data-titlebar-tab-action
data-action="vertical-tabs-search"
class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:text-v2-text-text-base"
onClick={() => command.trigger("session.search")}
>
<Icon name="magnifying-glass" class="shrink-0" />
<span class="min-w-0 truncate">{language.t("command.session.search")}</span>
<span
class="ms-auto hidden min-w-0 truncate text-v2-text-text-faint group-hover:block group-focus-visible:block"
aria-hidden="true"
>
<bdi dir="ltr">{command.keybind("session.search")}</bdi>
</span>
</button>
</Show>
)
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(() => <DialogSessionSearch />)
},
},
])
command.register("tabs", () => {
@@ -546,7 +602,10 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
<Show when={windows() && props.verticalTabs}>
<SidebarToggle />
</Show>
<Show when={!mobile() && !props.verticalTabs}>{homeButton()}</Show>
<Show when={!mobile() && !props.verticalTabs}>
{homeButton()}
{searchButton()}
</Show>
<Show
when={!mobile()}
@@ -760,6 +819,7 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
</div>
</Show>
<Show when={!homeInSidebarHeader()}>{homeButton(true)}</Show>
{searchButton(true)}
<RecentlyClosedTabsMenu
vertical
onNewTab={openNewTab}