diff --git a/packages/app/e2e/regression/tabs.spec.ts b/packages/app/e2e/regression/tabs.spec.ts index 6e7c3d4df5c..1f1c5d24d10 100644 --- a/packages/app/e2e/regression/tabs.spec.ts +++ b/packages/app/e2e/regression/tabs.spec.ts @@ -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", diff --git a/packages/app/src/home/sessions/controller.tsx b/packages/app/src/home/sessions/controller.tsx index 6fd287fd3df..7f8099e0d21 100644 --- a/packages/app/src/home/sessions/controller.tsx +++ b/packages/app/src/home/sessions/controller.tsx @@ -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 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) diff --git a/packages/app/src/home/sessions/queries.ts b/packages/app/src/home/sessions/queries.ts new file mode 100644 index 00000000000..c7247c2e482 --- /dev/null +++ b/packages/app/src/home/sessions/queries.ts @@ -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, + }) +} diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 301c1a6f5d4..4d995f42501 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -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", diff --git a/packages/app/src/shell/tabs/closed.ts b/packages/app/src/shell/tabs/closed.ts index d176df48f24..036e52c40ab 100644 --- a/packages/app/src/shell/tabs/closed.ts +++ b/packages/app/src/shell/tabs/closed.ts @@ -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. diff --git a/packages/app/src/shell/tabs/tabs.test.ts b/packages/app/src/shell/tabs/tabs.test.ts index c68fff1377f..3bbc5b21cf7 100644 --- a/packages/app/src/shell/tabs/tabs.test.ts +++ b/packages/app/src/shell/tabs/tabs.test.ts @@ -175,14 +175,14 @@ describe("closed tab stack", () => { }) test("caps the stack size", () => { - const stack = Array.from({ length: 30 }, (_, i) => i).reduce( + const stack = Array.from({ length: 105 }, (_, i) => i).reduce( (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", () => { diff --git a/packages/app/src/shell/titlebar/recently-closed-tabs-menu.css b/packages/app/src/shell/titlebar/recently-closed-tabs-menu.css new file mode 100644 index 00000000000..259916b5864 --- /dev/null +++ b/packages/app/src/shell/titlebar/recently-closed-tabs-menu.css @@ -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); +} diff --git a/packages/app/src/shell/titlebar/recently-closed-tabs-menu.tsx b/packages/app/src/shell/titlebar/recently-closed-tabs-menu.tsx index 2c7a548ed0a..f80419c59bd 100644 --- a/packages/app/src/shell/titlebar/recently-closed-tabs-menu.tsx +++ b/packages/app/src/shell/titlebar/recently-closed-tabs-menu.tsx @@ -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 ( - + setState({ open, query: "" })}> - - - {language.t("home.recentlyClosed")} - - {(entry) => ( - tabs.reopenClosedTab(entry.tab, { append: true })} - /> - )} - - + { + 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()) + }} + > + + + + {language.t("home.recentlyClosed")} + + {(entry) => ( + tabs.reopenClosedTab(entry.tab, { append: true })} + /> + )} + + + + + + {language.t("sidebar.project.recentSessions")} + + {(item) => ( + { + global.serverCtx(item.server)?.data.session.remember(item.session) + tabs.select(tabs.addSessionTab({ server: item.server, sessionId: item.session.id })) + }} + /> + )} + + + + +
+ {language.t(state.query.trim() ? "session.search.noResults" : "session.tabs.closed.empty")} +
+
) } -function RecentlyClosedTabItem(props: { entry: ClosedTab; onSelect: () => void }) { +function RecentlyClosedTabItem(props: { entry: Pick; onSelect: () => void }) { const global = useGlobal() const language = useLanguage() const [state, setState] = createStore({ truncated: false })