feat(desktop): add search to recently closed sessions (#55721)

This commit is contained in:
usrnk1
2026-10-22 10:31:12 +02:00
committed by GitHub
Unverified
parent a898dfa2ca
commit ece6e3bfbb
8 changed files with 301 additions and 44 deletions
+99
View File
@@ -27,6 +27,105 @@ const c = { id: "ses_tab_c", title: "Tab C session" }
test.use({ serviceWorkers: "block" })
test("recently closed shows ten sessions and searches older closures in close order", async ({ page }) => {
const sessions = Array.from({ length: 100 }, (_, index) => ({
id: `ses_closed_${index}`,
title: `Closed session ${index}`,
}))
await mockWorkspace(page, {
name: "ClosedTabs",
sessions,
seed: {
servers: [SERVER],
tabs: [sessions[0]!.id],
storage: {
"opencode.window.browser.dat:tabs.closed": sessions.slice(1).map((session, index) => ({
tab: { type: "session", server: SERVER, sessionId: session.id },
index,
info: { title: session.title, directory: "C:/OpenCode/ClosedTabs", prompted: true },
})),
},
},
})
await page.goto(sessionHref(sessions[0]!.id))
await expect(page.locator("[data-titlebar-tab-title]")).toHaveText("Closed session 0")
const trigger = page.getByRole("button", { name: "New session", exact: true })
await trigger.click({ button: "right" })
const menu = page.getByRole("menu")
const items = menu.getByRole("menuitem")
const search = menu.getByRole("searchbox", { name: "Search recently closed" })
await expect(search).toBeFocused()
await expect(items).toHaveText(Array.from({ length: 10 }, (_, index) => `CClosed session ${99 - index}`))
await search.pressSequentially("SESSION 2")
await expect(search).toHaveValue("SESSION 2")
await expect(items).toHaveText(Array.from({ length: 10 }, (_, index) => `CClosed session ${29 - index}`))
await search.fill("not found")
await expect(menu.getByRole("status")).toHaveText("No matches")
await expect(items).toHaveCount(0)
await menu.getByRole("button", { name: "Clear", exact: true }).click()
await expect(search).toHaveValue("")
await expect(search).toBeFocused()
await expect(items).toHaveCount(10)
await search.fill("session 1")
await expect(items).toHaveText(Array.from({ length: 10 }, (_, index) => `CClosed session ${19 - index}`))
await search.press("ArrowDown")
await expect(menu.getByRole("menuitem", { name: "C Closed session 19", exact: true })).toBeFocused()
await page.keyboard.press("Enter")
await expectPath(page, sessionHref("ses_closed_19"))
await expect(menu).toBeHidden()
await trigger.click({ button: "right" })
await expect(search).toHaveValue("")
await expect(items).toHaveCount(10)
await search.press("Escape")
await expect(menu).toBeHidden()
})
test("recently closed fills remaining slots in Home order without open or duplicate sessions", async ({ page }) => {
const sessions = Array.from({ length: 12 }, (_, index) => ({
id: `ses_other_${index}`,
title: `Other session ${index}`,
time: { created: 1700000000000, updated: 1700000000000 + (12 - index) * 1000 },
}))
await mockWorkspace(page, {
name: "OtherTabs",
sessions: [
...sessions,
{ id: "ses_other_archived", title: "Archived session", time: { created: Date.now(), archived: Date.now() } },
{ id: "ses_other_child", title: "Child session", parentID: sessions[0]!.id },
],
seed: {
servers: [SERVER],
tabs: [sessions[0]!.id, sessions[2]!.id],
storage: {
"opencode.window.browser.dat:tabs.closed": sessions.slice(10).map((session, index) => ({
tab: { type: "session", server: SERVER, sessionId: session.id },
index,
info: { title: session.title, directory: "C:/OpenCode/OtherTabs", prompted: true },
})),
},
},
})
await page.goto(sessionHref(sessions[0]!.id))
await expect(page.locator("[data-titlebar-tab-title]")).toHaveText(["Other session 0", "Other session 2"])
const trigger = page.getByRole("button", { name: "New session", exact: true })
await trigger.click({ button: "right" })
const menu = page.getByRole("menu")
const items = menu.getByRole("menuitem")
await expect(items).toHaveText([11, 10, 1, 3, 4, 5, 6, 7, 8, 9].map((index) => `OOther session ${index}`))
await expect(menu.getByRole("group", { name: "Recently closed", exact: true }).getByRole("menuitem")).toHaveText([
"OOther session 11",
"OOther session 10",
])
await expect(menu.getByRole("group", { name: "Recent sessions", exact: true }).getByRole("menuitem")).toHaveCount(8)
await menu.getByRole("menuitem", { name: "O Other session 1", exact: true }).click()
await expectPath(page, sessionHref("ses_other_1"))
await expect(menu).toBeHidden()
await trigger.click({ button: "right" })
await expect(items).toHaveText([11, 10, 3, 4, 5, 6, 7, 8, 9].map((index) => `OOther session ${index}`))
await menu.getByRole("searchbox", { name: "Search recently closed" }).fill("session 9")
await expect(items).toHaveText(["OOther session 9"])
})
test("tab strip keeps draft tabs as wide as session tabs and navigates on mouse down", async ({ page }) => {
const workspace = await mockWorkspace(page, {
name: "Tabs",
+6 -22
View File
@@ -2,16 +2,11 @@ import type { SessionInfo } from "@opencode/client/promise"
import { useDialog } from "@opencode/ui/context/dialog"
import { Button } from "@opencode/ui/button"
import { DialogFooter, DialogHeader, DialogTitleGroup, Dialog } from "@opencode/ui/dialog"
import { skipToken, useQuery, useQueryClient } from "@tanstack/solid-query"
import { useQuery, useQueryClient } from "@tanstack/solid-query"
import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js"
import { notifySessionTabsRemoved } from "@/shell/titlebar/session-events"
import { useCommand } from "@/shell/commands/command"
import {
HOME_SESSION_LIMIT,
loadHomeSessionIndex,
mergeHomeSessionIndex,
retainHomeSessions,
} from "@/home/sessions/index"
import { HOME_SESSION_LIMIT, mergeHomeSessionIndex, retainHomeSessions } from "@/home/sessions/index"
import type { LocalProject } from "@/shell/state/layout"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection } from "@/runtime/server/registry"
@@ -25,14 +20,12 @@ import { usePlatform } from "@/runtime/platform/platform"
import { sessionLabel, sessionTitle } from "@/session/title"
import { showToast } from "@/shell/notifications/toast"
import { archiveHomeSession } from "./archive"
import { homeSessionsQuery } from "./queries"
import type { HomeController } from "../model"
import { buildHomeSessionRecords, homeProjectForSession, homeSessionLocation, type HomeSessionRecord } from "./records"
export type { HomeSessionRecord } from "./records"
// Keep the immutable result opaque so Solid Query does not recursively unwrap every session on mount.
const selectSessions = (sessions: SessionInfo[]) => () => sessions
export type HomeSessionGroup = {
id: "today" | "yesterday" | "older"
title: string
@@ -63,18 +56,8 @@ export function createHomeSessionsController(home: HomeController) {
const conn = home.server.focused()
return {
queryKey: ["home-sessions", conn] as const,
...homeSessionsQuery(conn, ctx),
enabled: !!ctx && ctx.sdk.connection.status() === "connected",
queryFn: ctx
? ({ signal }) => loadHomeSessionIndex((input, options) => ctx.sdk.api.session.list(input, options), signal)
: skipToken,
retry: false,
staleTime: 30_000,
// Returning to Home shows the last list and refreshes it, instead of loading again once the cache expires.
gcTime: Infinity,
refetchOnMount: true,
refetchOnReconnect: true,
select: selectSessions,
}
})
@@ -411,7 +394,8 @@ function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof
const now = new Date()
const today = localDay(now)
const yesterday = localDay(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
const day = (record: HomeSessionRecord) => localDay(new Date(record.session.time.updated ?? record.session.time.created))
const day = (record: HomeSessionRecord) =>
localDay(new Date(record.session.time.updated ?? record.session.time.created))
const todaySessions = records.filter((record) => day(record) === today)
const yesterdaySessions = records.filter((record) => day(record) === yesterday)
const olderSessions = records.filter((record) => day(record) !== today && day(record) !== yesterday)
+24
View File
@@ -0,0 +1,24 @@
import type { SessionInfo } from "@opencode/client/promise"
import { queryOptions, skipToken } from "@tanstack/solid-query"
import type { ServerConnection } from "@/runtime/server/registry"
import type { ServerCtx } from "@/runtime/server/runtime"
import { loadHomeSessionIndex } from "./index"
// Keep the immutable result opaque so Solid Query does not recursively unwrap every session on mount.
const selectSessions = (sessions: SessionInfo[]) => () => sessions
export function homeSessionsQuery(conn: ServerConnection.Any | undefined, ctx: ServerCtx | undefined) {
return queryOptions({
queryKey: ["home-sessions", conn] as const,
queryFn: ctx
? ({ signal }) => loadHomeSessionIndex((input, options) => ctx.sdk.api.session.list(input, options), signal)
: skipToken,
retry: false,
staleTime: 30_000,
// Returning to Home shows the last list and refreshes it, instead of loading again once the cache expires.
gcTime: Infinity,
refetchOnMount: true,
refetchOnReconnect: true,
select: selectSessions,
})
}
+2
View File
@@ -528,6 +528,8 @@ export const dict = {
"home.project.missing.title": "Project folder not found",
"home.project.missing.description": "The folder for {{name}} no longer exists.",
"home.recentlyClosed": "Recently closed",
"session.tabs.closed.search": "Search recently closed",
"session.tabs.closed.empty": "No recently closed sessions",
"home.server.collapse": "Collapse server projects",
"home.server.expand": "Expand server projects",
"home.sessions.search.placeholder": "Search sessions",
+1 -1
View File
@@ -3,7 +3,7 @@ import type { TabStorage } from "./schema"
export type ClosedTab = typeof TabStorage.ClosedTab.Type
const CLOSED_TAB_LIMIT = 25
const CLOSED_TAB_LIMIT = 100
// Only session tabs are recorded; closing a draft tab deletes its persisted
// state, so a reopened draft would come back empty anyway.
+3 -3
View File
@@ -175,14 +175,14 @@ describe("closed tab stack", () => {
})
test("caps the stack size", () => {
const stack = Array.from({ length: 30 }, (_, i) => i).reduce<ClosedTab[]>(
const stack = Array.from({ length: 105 }, (_, i) => i).reduce<ClosedTab[]>(
(acc, i) => pushClosedTab(acc, sessionTab(`s${i}`), i),
[],
)
expect(stack).toHaveLength(25)
expect(stack).toHaveLength(100)
expect(stack[0]?.tab.sessionId).toBe("s5")
expect(stack.at(-1)?.tab.sessionId).toBe("s29")
expect(stack.at(-1)?.tab.sessionId).toBe("s104")
})
test("keeps only the newest close record for a session", () => {
@@ -0,0 +1,25 @@
.recently-closed-search > [data-component="text-input-v2"] {
width: 100%;
background: transparent;
box-shadow: none;
}
.recently-closed-search > [data-component="text-input-v2"]:is(:hover, :focus-within):not([data-disabled]),
.recently-closed-search > [data-component="text-input-v2"]:has(input:not(:placeholder-shown)):not([data-disabled]) {
background: var(--v2-overlay-simple-overlay-hover);
}
.recently-closed-search[data-empty="true"] [data-slot="text-input-v2-icon-button"] {
visibility: hidden;
}
.recently-closed-search [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
color: var(--v2-icon-icon-faint);
cursor: default;
}
.recently-closed-search
[data-slot="text-input-v2-icon-button"][data-variant="clear"]:is(:hover, :active, :focus-visible):not(:disabled) {
background: transparent;
color: var(--v2-icon-icon-base);
}
@@ -1,17 +1,23 @@
import { createMemo, For, Show, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useQueries } from "@tanstack/solid-query"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Menu } from "@opencode/ui/menu"
import { TextInput } from "@opencode/ui/text-input"
import { Tooltip } from "@opencode/ui/tooltip"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection } from "@/runtime/server/registry"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar"
import { compareSessionTime } from "@/shell/layout/helpers"
import { mergeHomeSessionIndex } from "@/home/sessions/index"
import { homeSessionsQuery } from "@/home/sessions/queries"
import { listClosedTabs, type ClosedTab } from "@/shell/tabs/closed"
import { useTabs } from "@/shell/tabs/tabs"
import { sessionIDHasOpenTab, useTabs } from "@/shell/tabs/tabs"
import { sessionTabTitle } from "./tab-title"
import "./recently-closed-tabs-menu.css"
export function RecentlyClosedTabsMenu(props: {
onNewTab: () => void
@@ -21,13 +27,66 @@ export function RecentlyClosedTabsMenu(props: {
}) {
const language = useLanguage()
const tabs = useTabs()
const global = useGlobal()
const [state, setState] = createStore({ query: "", open: false })
let search: HTMLInputElement | undefined
const recent = createMemo(() =>
listClosedTabs(tabs.closed, tabs.store).filter((entry) => entry.info?.prompted === true),
)
const recent = createMemo(() => {
const query = state.query.trim().toLocaleLowerCase()
return listClosedTabs(tabs.closed, tabs.store)
.filter((entry) => entry.info?.prompted === true)
.filter((entry) => {
if (!query) return true
const title = global.serverCtx(entry.tab.server)?.data.session.get(entry.tab.sessionId)?.title
return sessionTabTitle(title ?? entry.info?.title, language.t("session.tab.session"))
.toLocaleLowerCase()
.includes(query)
})
.slice(0, 10)
})
const servers = createMemo(() => global.servers.list())
const sessions = useQueries(() => ({
queries: servers().map((conn) => {
const ctx = global.serverCtx(ServerConnection.key(conn))
return {
...homeSessionsQuery(conn, ctx),
enabled: state.open && recent().length < 10 && ctx?.sdk.connection.status() === "connected",
}
}),
}))
const other = createMemo(() => {
if (!state.open || recent().length === 10) return []
const query = state.query.trim().toLocaleLowerCase()
return servers()
.flatMap((conn, index) => {
const server = ServerConnection.key(conn)
const ctx = global.serverCtx(server)
if (!ctx) return []
return ctx.data.session
.apply(mergeHomeSessionIndex(sessions[index]?.data?.() ?? [], ctx.data.session.list()))
.filter((session) => !sessionIDHasOpenTab(tabs.store, server, session.id))
.filter(
(session) => !recent().some((entry) => entry.tab.server === server && entry.tab.sessionId === session.id),
)
.filter((session) =>
sessionTabTitle(session.title, language.t("session.tab.session")).toLocaleLowerCase().includes(query),
)
.map((session) => ({ server, session }))
})
.sort((a, b) => compareSessionTime(a.session, b.session))
.slice(0, 10 - recent().length)
})
return (
<Menu.Context modal={false}>
<Menu.Context modal={false} onOpenChange={(open) => setState({ open, query: "" })}>
<Show
when={props.vertical}
fallback={
@@ -65,25 +124,89 @@ export function RecentlyClosedTabsMenu(props: {
</Menu.Context.Trigger>
</Show>
<Menu.Context.Portal>
<Menu.Context.Content class="max-h-[66.667dvh] w-56 max-w-56 overflow-y-auto">
<Menu.Group>
<Menu.GroupLabel>{language.t("home.recentlyClosed")}</Menu.GroupLabel>
<For each={recent()}>
{(entry) => (
<RecentlyClosedTabItem
entry={entry}
onSelect={() => tabs.reopenClosedTab(entry.tab, { append: true })}
/>
)}
</For>
</Menu.Group>
<Menu.Context.Content
class="max-h-[66.667dvh] w-56 max-w-56 overflow-y-auto"
onOpenAutoFocus={(event) => {
event.preventDefault()
}}
onFocusIn={(event) => {
if (event.target !== event.currentTarget) return
// The menu defers its own autofocus; redirect only container focus, not item navigation.
queueMicrotask(() => search?.focus())
}}
>
<div class="recently-closed-search mx-1 mb-1" data-empty={!state.query}>
<TextInput
ref={search}
type="search"
dir="auto"
leadingIcon={<Icon name="magnifying-glass" size="small" />}
showClearButton
clearIcon="circle-xmark"
onClearClick={() => {
setState("query", "")
search?.focus()
}}
aria-label={language.t("session.tabs.closed.search")}
placeholder={language.t("session.tabs.closed.search")}
spellcheck={false}
autocomplete="off"
value={state.query}
onFocusIn={(event) => event.stopPropagation()}
onInput={(event) => setState("query", event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Escape" || event.key === "ArrowDown" || event.key === "ArrowUp") return
event.stopPropagation()
}}
/>
</div>
<Show when={recent().length}>
<Menu.Group>
<Menu.GroupLabel>{language.t("home.recentlyClosed")}</Menu.GroupLabel>
<For each={recent()}>
{(entry) => (
<RecentlyClosedTabItem
entry={entry}
onSelect={() => tabs.reopenClosedTab(entry.tab, { append: true })}
/>
)}
</For>
</Menu.Group>
</Show>
<Show when={other().length}>
<Menu.Group>
<Menu.GroupLabel>{language.t("sidebar.project.recentSessions")}</Menu.GroupLabel>
<For each={other()}>
{(item) => (
<RecentlyClosedTabItem
entry={{
tab: { type: "session", server: item.server, sessionId: item.session.id },
info: { title: item.session.title, directory: item.session.location.directory },
}}
onSelect={() => {
global.serverCtx(item.server)?.data.session.remember(item.session)
tabs.select(tabs.addSessionTab({ server: item.server, sessionId: item.session.id }))
}}
/>
)}
</For>
</Menu.Group>
</Show>
<Show when={!recent().length && !other().length}>
<div
role="status"
class="px-3 py-2 text-[13px] leading-[var(--line-height-compact)] text-v2-text-text-faint"
>
{language.t(state.query.trim() ? "session.search.noResults" : "session.tabs.closed.empty")}
</div>
</Show>
</Menu.Context.Content>
</Menu.Context.Portal>
</Menu.Context>
)
}
function RecentlyClosedTabItem(props: { entry: ClosedTab; onSelect: () => void }) {
function RecentlyClosedTabItem(props: { entry: Pick<ClosedTab, "tab" | "info">; onSelect: () => void }) {
const global = useGlobal()
const language = useLanguage()
const [state, setState] = createStore({ truncated: false })