Compare commits

...

1 Commits

Author SHA1 Message Date
Brendonovich 346701899d feat(app): reveal archived sessions in search 2026-08-25 09:27:03 +00:00
10 changed files with 159 additions and 41 deletions
@@ -0,0 +1,52 @@
import { expect, test } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
test("keeps archived sessions off Home until search includes them", async ({ page }) => {
const archivedTitle = "Archived search result"
const archived = {
...fixture.sessions.find((session) => session.id === fixture.targetID)!,
id: "ses_archived_search",
title: archivedTitle,
directory: "/removed/worktree",
time: { created: 1, updated: 2, archived: 3 },
}
await mockOpenCodeServer(page, {
sessions: [...fixture.sessions, archived],
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
}, fixture.directory)
await page.goto("/")
await expect(page.getByRole("button", { name: archivedTitle })).toHaveCount(0)
const search = page.getByRole("textbox", { name: "Search sessions", exact: true })
await search.fill("Archived search")
await expect(page.getByRole("option", { name: new RegExp(archivedTitle) })).toHaveCount(0)
const toggle = page.locator('[data-component="switch"]').filter({ hasText: "Include archived" })
const includeArchived = toggle.getByRole("switch", { name: "Include archived" })
await expect(includeArchived).not.toBeChecked()
await toggle.locator('[data-slot="switch-control"]').click()
await expect(includeArchived).toBeChecked()
await expect(page.getByRole("option", { name: new RegExp(`${archivedTitle}.*Archived`) })).toBeVisible()
await toggle.locator('[data-slot="switch-control"]').click()
await expect(page.getByRole("option", { name: new RegExp(archivedTitle) })).toHaveCount(0)
await expect(search).toBeVisible()
await toggle.locator('[data-slot="switch-control"]').click()
await page.getByRole("option", { name: new RegExp(`${archivedTitle}.*Archived`) }).click()
await expect(page).toHaveURL(/\/session\/ses_archived_search$/)
})
+49 -21
View File
@@ -13,7 +13,7 @@ import type { LocalProject } from "@/shell/state/layout"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection } from "@/runtime/server/registry"
import { sessionHasOpenTab, useTabs } from "@/shell/tabs/tabs"
import { errorMessage } from "@/shell/layout/helpers"
import { compareSessionTime, errorMessage } from "@/shell/layout/helpers"
import { useSessionTabAvatarState } from "@/shell/layout/project-avatar-state"
import { removedSessionIDs } from "@/session/session-domain"
import { pathKey } from "@/workspaces/path-key"
@@ -71,22 +71,47 @@ export function createHomeSessionsController(home: HomeController) {
const conn = home.server.focused()
if (!ctx || !conn) return []
const server = ServerConnection.key(conn)
return retainHomeSessions(
mergeHomeSessionIndex(sessionLoad.data?.() ?? [], ctx.data.session.list()).filter(
(session) => !removed.keys.includes(`${server}\0${session.id}`),
),
HOME_SESSION_LIMIT,
Date.now(),
return mergeHomeSessionIndex(sessionLoad.data?.() ?? [], ctx.data.session.list()).filter(
(session) => !removed.keys.includes(`${server}\0${session.id}`),
)
})
const allRecords = createMemo(() =>
const activeSessions = createMemo(() =>
retainHomeSessions(
indexedSessions().filter((session) => typeof session.time.archived !== "number"),
HOME_SESSION_LIMIT,
Date.now(),
),
)
const archivedSessions = createMemo(() =>
retainHomeSessions(
indexedSessions().filter((session) => typeof session.time.archived === "number"),
HOME_SESSION_LIMIT,
Date.now(),
),
)
const activeRecords = createMemo(() =>
buildHomeSessionRecords({
sessions: indexedSessions,
sessions: activeSessions,
projectDirectories,
projects: home.project.list,
}),
)
const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT))
const archivedRecords = createMemo(() =>
buildHomeSessionRecords({
sessions: () => {
const project = home.project.selected()
if (!project) return archivedSessions()
return archivedSessions().filter((session) => session.projectID === project.id)
},
// Archived workspaces may already be removed from the project's directory inventory.
projectDirectories: () => undefined,
projects: home.project.list,
}),
)
const searchRecords = createMemo(() =>
[...activeRecords(), ...archivedRecords()].toSorted((a, b) => compareSessionTime(a.session, b.session)),
)
const records = createMemo(() => activeRecords().slice(0, HOME_SESSION_LIMIT))
const groups = createMemo(() => groupSessions(records(), language))
const prefetched = new Set<string>()
@@ -257,33 +282,36 @@ export function createHomeSessionsController(home: HomeController) {
records,
groups,
loading: () => sessionLoad.isLoading,
searchRecords: allRecords,
searchRecords: (includeArchived = false) => (includeArchived ? searchRecords() : activeRecords()),
},
session: {
showProjectName: () => !home.project.selected(),
server: () => home.selection.value().server,
canCreate: () => !!home.project.newSession(),
lookup: async (sessionID: string) => {
lookup: async (sessionID: string, includeArchived = false) => {
const ctx = home.server.focusedContext()
if (!ctx) return
const result = await ctx.sdk.api.session.get({ sessionID })
if (result.time.archived) return
if (result.time.archived && !includeArchived) return
const project = home.project.selected()
if (result.time.archived && project && result.projectID !== project.id) return
return buildHomeSessionRecords({
sessions: () => [result],
projectDirectories,
projectDirectories: result.time.archived ? () => undefined : projectDirectories,
projects: home.project.list,
})[0]
},
create: home.project.openNewSession,
open: (session: SessionInfo, options?: OpenSessionOptions) => {
const directoryKey = pathKey(session.location.directory)
const project = home.project
.list()
.find(
(item) =>
pathKey(item.worktree) === directoryKey ||
item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
)
const project =
home.project
.list()
.find(
(item) =>
pathKey(item.worktree) === directoryKey ||
item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
) ?? home.project.list().find((item) => item.id === session.projectID)
const conn = home.server.focused()
if (!conn) return
const connKey = ServerConnection.key(conn)
+2 -2
View File
@@ -27,14 +27,14 @@ describe("Home session index", () => {
expect(calls.every((call) => call.parentID === null)).toBe(true)
})
test("keeps only visible roots", () => {
test("keeps active and archived roots", () => {
expect(
parseHomeSessionIndex([
session("root"),
session("child", { parentID: "root" }),
session("archived", { time: { created: 1, updated: 1, archived: 2 } }),
]).map((item) => item.id),
).toEqual(["root"])
).toEqual(["root", "archived"])
})
test("preserves the per-directory retention limit", () => {
+2 -2
View File
@@ -35,9 +35,9 @@ export async function loadHomeSessionIndex(
}
}
// Keep this filter for locally known sessions merged into the fetched index.
// Keep this filter for locally known child sessions merged into the fetched index.
export function parseHomeSessionIndex(sessions: SessionInfo[]) {
return sessions.filter((session) => !session.parentID && typeof session.time.archived !== "number")
return sessions.filter((session) => !session.parentID)
}
export function mergeHomeSessionIndex(fetched: SessionInfo[], known: SessionInfo[]) {
@@ -36,4 +36,14 @@ describe("buildHomeSessionRecords", () => {
expect(records.map((record) => record.session.id)).toEqual(["a"])
})
test("maps a removed workspace back to its project", () => {
const records = buildHomeSessionRecords({
sessions: () => [session("archived", "/removed/worktree", "project-a")],
projectDirectories: () => undefined,
projects: () => [opened],
})
expect(records[0]?.project).toBe(opened)
})
})
+6 -5
View File
@@ -28,11 +28,12 @@ export function buildHomeSessionRecords(input: {
.find(
(item) =>
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
) ?? {
id: session.projectID,
worktree: session.location.directory,
expanded: false,
}
) ??
input.projects().find((item) => item.id === session.projectID) ?? {
id: session.projectID,
worktree: session.location.directory,
expanded: false,
}
return { session, project, projectName: displayName(project) }
})
}
@@ -22,6 +22,7 @@ export function HomeSessions(props: {
searchResults={props.search.result.list()}
searchActive={props.search.result.active()}
searchNoResultsLabel={props.search.result.noResultsLabel()}
searchIncludeArchived={props.search.query.includeArchived()}
titleOpacity={props.scroll.header.titleOpacity}
isOpenTab={props.sessions.tab.isOpen}
onCreateSession={props.sessions.session.create}
@@ -41,6 +42,7 @@ export function HomeSessions(props: {
onSearchFocus={props.search.query.focus}
onSearchInput={props.search.query.input}
onSearchClose={props.search.query.close}
onSearchIncludeArchived={props.search.query.setIncludeArchived}
onSearchMove={props.search.result.move}
onSearchSelectActive={props.search.result.selectActive}
onSearchHighlight={props.search.result.highlight}
+9 -3
View File
@@ -21,6 +21,7 @@ export function createHomeSessionSearchController(home: HomeController, sessions
highlighted: "",
exact: undefined as HomeSessionRecord | undefined,
lookingUp: false,
includeArchived: false,
})
let lookup = 0
let root: HTMLDivElement | undefined
@@ -31,7 +32,7 @@ export function createHomeSessionSearchController(home: HomeController, sessions
const value = query().toLowerCase()
if (!value) return []
const records = sessions.data
.searchRecords()
.searchRecords(state.includeArchived)
.filter((record) => `${sessionLabel(record.session)} ${record.projectName}`.toLowerCase().includes(value))
if (!state.exact || records.some((record) => record.session.id === state.exact?.session.id)) return records
return [state.exact, ...records]
@@ -82,13 +83,13 @@ export function createHomeSessionSearchController(home: HomeController, sessions
setState({ value: "", focused: false, exact: undefined, lookingUp: false })
}
function update(value: string) {
function update(value: string, includeArchived = state.includeArchived) {
const current = ++lookup
const sessionID = value.trim()
setState({ value, highlighted: "", exact: undefined, lookingUp: false })
if (!looksLikeSessionID(sessionID)) return
setState("lookingUp", true)
void sessions.session.lookup(sessionID).then(
void sessions.session.lookup(sessionID, includeArchived).then(
(record) => {
if (current !== lookup) return
setState({ exact: record, lookingUp: false })
@@ -113,6 +114,11 @@ export function createHomeSessionSearchController(home: HomeController, sessions
focus,
input: update,
close,
includeArchived: () => state.includeArchived,
setIncludeArchived: (value: boolean) => {
setState("includeArchived", value)
update(state.value, value)
},
},
result: {
loading: () => sessions.data.loading() || state.lookingUp,
+25 -8
View File
@@ -10,6 +10,7 @@ import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Menu } from "@opencode-ai/ui/menu"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Switch } from "@opencode-ai/ui/switch"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection } from "@/runtime/server/registry"
import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar"
@@ -54,6 +55,7 @@ export type HomeSessionsViewProps = {
searchResults: HomeSessionRecord[]
searchActive: string
searchNoResultsLabel: string
searchIncludeArchived: boolean
titleOpacity: (id: HomeSessionGroup["id"]) => number
isOpenTab: (record: HomeSessionRecord) => boolean
onCreateSession: () => void
@@ -73,6 +75,7 @@ export type HomeSessionsViewProps = {
onSearchFocus: () => void
onSearchInput: (value: string) => void
onSearchClose: () => void
onSearchIncludeArchived: (value: boolean) => void
onSearchMove: (delta: number) => void
onSearchSelectActive: () => void
onSearchHighlight: (record: HomeSessionRecord) => void
@@ -238,6 +241,17 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
style={{ top: "-6px", left: "-6px", width: "calc(100% + 12px)" }}
>
<div class="flex flex-col pt-9">
<div
class={`
my-1.5 flex items-center justify-between gap-3 pl-[18px] pr-4
text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]
`}
>
<span>{props.language.t("home.sessions.search.sessions")}</span>
<Switch checked={props.searchIncludeArchived} onChange={props.onSearchIncludeArchived}>
{props.language.t("home.sessions.search.includeArchived")}
</Switch>
</div>
<div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4">
<Show
when={!props.searchLoading}
@@ -261,14 +275,6 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
}
>
<div class="flex flex-col">
<p
class={`
my-1.5 pl-[18px] pr-6 text-[13px] leading-4 tracking-[-0.04px]
text-v2-text-text-muted [font-weight:440]
`}
>
{props.language.t("home.sessions.search.sessions")}
</p>
<ScrollView class="max-h-80" viewportRef={props.onSetSearchList}>
<div class="flex flex-col gap-px pb-2">
<For each={props.searchResults}>
@@ -409,6 +415,17 @@ function HomeSessionSearchResultRow(
<HomeSessionProjectName name={props.record.projectName} search />
</Show>
</div>
<Show when={props.record.session.time.archived}>
<span
class={`
flex shrink-0 items-center gap-1 text-[11px] leading-4 tracking-[0.05px]
text-v2-text-text-faint [font-weight:440]
`}
>
<Icon name="archive" size="small" />
{props.language.t("home.sessions.search.archived")}
</span>
</Show>
</button>
)
}
+2
View File
@@ -647,6 +647,8 @@ export const dict = {
"home.sessions.search.placeholder.scoped": "Search sessions in {{scope}}",
"home.sessions.search.sessions": "Sessions",
"home.sessions.search.noResults": "No sessions found for {{query}}",
"home.sessions.search.includeArchived": "Include archived",
"home.sessions.search.archived": "Archived",
"home.sessions.empty": "Nothing here yet",
"home.sessions.empty.description": "Create a session to get started",
"home.sessions.group.today": "Today",