mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
feat(desktop): add search to recently closed sessions (#55721)
This commit is contained in:
committed by
GitHub
Unverified
parent
a898dfa2ca
commit
ece6e3bfbb
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user