mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 20:21:39 +03:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user