Skip to content

Commit 16f2b05

Browse files
Apply PR #15250: feat(app): view archived sessions & unarchive
2 parents b93482c + fc156ef commit 16f2b05

5 files changed

Lines changed: 224 additions & 5 deletions

File tree

packages/app/src/components/dialog-settings.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { SettingsGeneral } from "./settings-general"
88
import { SettingsKeybinds } from "./settings-keybinds"
99
import { SettingsProviders } from "./settings-providers"
1010
import { SettingsModels } from "./settings-models"
11+
import { SettingsArchive } from "./settings-archive"
1112

1213
export const DialogSettings: Component = () => {
1314
const language = useLanguage()
@@ -47,6 +48,16 @@ export const DialogSettings: Component = () => {
4748
</Tabs.Trigger>
4849
</div>
4950
</div>
51+
52+
<div class="flex flex-col gap-1.5">
53+
<Tabs.SectionTitle>{language.t("settings.section.data")}</Tabs.SectionTitle>
54+
<div class="flex flex-col gap-1.5 w-full">
55+
<Tabs.Trigger value="archive">
56+
<Icon name="archive" />
57+
{language.t("settings.archive.title")}
58+
</Tabs.Trigger>
59+
</div>
60+
</div>
5061
</div>
5162
</div>
5263
<div class="flex flex-col gap-1 pl-1 py-1 text-12-medium text-text-weak">
@@ -67,6 +78,9 @@ export const DialogSettings: Component = () => {
6778
<Tabs.Content value="models" class="no-scrollbar">
6879
<SettingsModels />
6980
</Tabs.Content>
81+
<Tabs.Content value="archive" class="no-scrollbar">
82+
<SettingsArchive />
83+
</Tabs.Content>
7084
</Tabs>
7185
</Dialog>
7286
)
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { Button } from "@opencode-ai/ui/button"
2+
import { Icon } from "@opencode-ai/ui/icon"
3+
import { RadioGroup } from "@opencode-ai/ui/radio-group"
4+
import { getFilename } from "@opencode-ai/util/path"
5+
import { Component, For, Show, createMemo, createResource, createSignal } from "solid-js"
6+
import { useParams } from "@solidjs/router"
7+
import { useGlobalSDK } from "@/context/global-sdk"
8+
import { useGlobalSync } from "@/context/global-sync"
9+
import { useLanguage } from "@/context/language"
10+
import { useLayout } from "@/context/layout"
11+
import { getRelativeTime } from "@/utils/time"
12+
import { decode64 } from "@/utils/base64"
13+
import type { Session } from "@opencode-ai/sdk/v2/client"
14+
import { SessionSkeleton } from "@/pages/layout/sidebar-items"
15+
16+
type FilterScope = "all" | "current"
17+
18+
type ScopeOption = { value: FilterScope; label: "settings.archive.scope.all" | "settings.archive.scope.current" }
19+
20+
const scopeOptions: ScopeOption[] = [
21+
{ value: "all", label: "settings.archive.scope.all" },
22+
{ value: "current", label: "settings.archive.scope.current" },
23+
]
24+
25+
export const SettingsArchive: Component = () => {
26+
const language = useLanguage()
27+
const globalSDK = useGlobalSDK()
28+
const globalSync = useGlobalSync()
29+
const layout = useLayout()
30+
const params = useParams()
31+
const [removedIds, setRemovedIds] = createSignal<Set<string>>(new Set())
32+
33+
const projects = createMemo(() => globalSync.data.project)
34+
const layoutProjects = createMemo(() => layout.projects.list())
35+
const hasMultipleProjects = createMemo(() => projects().length > 1)
36+
const homedir = createMemo(() => globalSync.data.path.home)
37+
38+
const defaultScope = () => (hasMultipleProjects() ? "current" : "all")
39+
const [filterScope, setFilterScope] = createSignal<FilterScope>(defaultScope())
40+
41+
const currentDirectory = createMemo(() => decode64(params.dir) ?? "")
42+
43+
const currentProject = createMemo(() => {
44+
const dir = currentDirectory()
45+
if (!dir) return null
46+
return layoutProjects().find((p) => p.worktree === dir || p.sandboxes?.includes(dir)) ?? null
47+
})
48+
49+
const filteredProjects = createMemo(() => {
50+
if (filterScope() === "current" && currentProject()) {
51+
return [currentProject()!]
52+
}
53+
return layoutProjects()
54+
})
55+
56+
const getSessionLabel = (session: Session) => {
57+
const directory = session.directory
58+
const home = homedir()
59+
const path = home ? directory.replace(home, "~") : directory
60+
61+
if (filterScope() === "current" && currentProject()) {
62+
const current = currentProject()
63+
const kind =
64+
current && directory === current.worktree
65+
? language.t("workspace.type.local")
66+
: language.t("workspace.type.sandbox")
67+
const [store] = globalSync.child(directory, { bootstrap: false })
68+
const name = store.vcs?.branch ?? getFilename(directory)
69+
return `${kind} : ${name || path}`
70+
}
71+
72+
return path
73+
}
74+
75+
const [archivedSessions] = createResource(
76+
() => ({ scope: filterScope(), projects: filteredProjects() }),
77+
async ({ projects }) => {
78+
const allSessions: Session[] = []
79+
for (const project of projects) {
80+
const directories = [project.worktree, ...(project.sandboxes ?? [])]
81+
for (const directory of directories) {
82+
const result = await globalSDK.client.experimental.session.list({ directory, archived: true })
83+
const sessions = result.data ?? []
84+
for (const session of sessions) {
85+
allSessions.push(session)
86+
}
87+
}
88+
}
89+
return allSessions.sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0))
90+
},
91+
{ initialValue: [] },
92+
)
93+
94+
const displayedSessions = () => {
95+
const sessions = archivedSessions() ?? []
96+
const removed = removedIds()
97+
return sessions.filter((s) => !removed.has(s.id))
98+
}
99+
100+
const currentScopeOption = () => scopeOptions.find((o) => o.value === filterScope())
101+
102+
const unarchiveSession = async (session: Session) => {
103+
setRemovedIds((prev) => new Set(prev).add(session.id))
104+
await globalSDK.client.session.update({
105+
directory: session.directory,
106+
sessionID: session.id,
107+
time: { archived: null as any },
108+
})
109+
}
110+
111+
const handleScopeChange = (option: ScopeOption | undefined) => {
112+
if (!option) return
113+
setRemovedIds(new Set<string>())
114+
setFilterScope(option.value)
115+
}
116+
117+
return (
118+
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
119+
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
120+
<div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
121+
<h2 class="text-16-medium text-text-strong">{language.t("settings.archive.title")}</h2>
122+
<p class="text-14-regular text-text-weak">{language.t("settings.archive.description")}</p>
123+
</div>
124+
</div>
125+
126+
<div class="flex flex-col gap-4 max-w-[720px]">
127+
<Show when={hasMultipleProjects()}>
128+
<RadioGroup
129+
options={scopeOptions}
130+
current={currentScopeOption() ?? undefined}
131+
value={(o) => o.value}
132+
size="small"
133+
label={(o) => language.t(o.label)}
134+
onSelect={handleScopeChange}
135+
/>
136+
</Show>
137+
<Show
138+
when={!archivedSessions.loading}
139+
fallback={
140+
<div class="min-h-[700px]">
141+
<SessionSkeleton count={4} />
142+
</div>
143+
}
144+
>
145+
<Show
146+
when={displayedSessions().length}
147+
fallback={
148+
<div class="min-h-[700px]">
149+
<div class="text-14-regular text-text-weak">{language.t("settings.archive.none")}</div>
150+
</div>
151+
}
152+
>
153+
<div class="min-h-[700px] flex flex-col gap-2">
154+
<For each={displayedSessions()}>
155+
{(session) => (
156+
<div class="flex items-center justify-between gap-4 px-3 py-1 rounded-md hover:bg-surface-raised-base-hover">
157+
<div class="flex items-center gap-x-3 grow min-w-0">
158+
<div class="flex items-center gap-2 min-w-0">
159+
<span class="text-14-regular text-text-strong truncate">{session.title}</span>
160+
<span class="text-14-regular text-text-weak truncate">{getSessionLabel(session)}</span>
161+
</div>
162+
</div>
163+
<div class="flex items-center gap-4 shrink-0">
164+
<Show when={session.time?.updated}>
165+
{(updated) => (
166+
<span class="text-12-regular text-text-weak whitespace-nowrap">
167+
{getRelativeTime(new Date(updated()).toISOString())}
168+
</span>
169+
)}
170+
</Show>
171+
<Button
172+
size="normal"
173+
variant="secondary"
174+
onClick={() => unarchiveSession(session)}
175+
>
176+
{language.t("common.unarchive")}
177+
</Button>
178+
</div>
179+
</div>
180+
)}
181+
</For>
182+
</div>
183+
</Show>
184+
</Show>
185+
</div>
186+
</div>
187+
)
188+
}

packages/app/src/i18n/en.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,7 @@ export const dict = {
585585
"common.rename": "Rename",
586586
"common.reset": "Reset",
587587
"common.archive": "Archive",
588+
"common.unarchive": "Unarchive",
588589
"common.delete": "Delete",
589590
"common.close": "Close",
590591
"common.edit": "Edit",
@@ -613,6 +614,7 @@ export const dict = {
613614

614615
"settings.section.desktop": "Desktop",
615616
"settings.section.server": "Server",
617+
"settings.section.data": "Data",
616618
"settings.tab.general": "General",
617619
"settings.tab.shortcuts": "Shortcuts",
618620
"settings.desktop.section.wsl": "WSL",
@@ -844,4 +846,10 @@ export const dict = {
844846
"workspace.reset.archived.one": "1 session will be archived.",
845847
"workspace.reset.archived.many": "{{count}} sessions will be archived.",
846848
"workspace.reset.note": "This will reset the workspace to match the default branch.",
849+
850+
"settings.archive.title": "Archived Sessions",
851+
"settings.archive.description": "Restore archived sessions to make them visible in the sidebar.",
852+
"settings.archive.none": "No archived sessions.",
853+
"settings.archive.scope.all": "All projects",
854+
"settings.archive.scope.current": "Current project",
847855
}

packages/opencode/src/server/routes/session.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -263,13 +263,19 @@ export const SessionRoutes = lazy(() =>
263263
sessionID: z.string(),
264264
}),
265265
),
266+
validator(
267+
"query",
268+
z.object({
269+
directory: z.string().optional(),
270+
}),
271+
),
266272
validator(
267273
"json",
268274
z.object({
269275
title: z.string().optional(),
270276
time: z
271277
.object({
272-
archived: z.number().optional(),
278+
archived: z.number().nullable().optional(),
273279
})
274280
.optional(),
275281
}),
@@ -282,8 +288,8 @@ export const SessionRoutes = lazy(() =>
282288
if (updates.title !== undefined) {
283289
session = await Session.setTitle({ sessionID, title: updates.title })
284290
}
285-
if (updates.time?.archived !== undefined) {
286-
session = await Session.setArchived({ sessionID, time: updates.time.archived })
291+
if (updates.time !== undefined && "archived" in updates.time) {
292+
session = await Session.setArchived({ sessionID, time: updates.time.archived ?? undefined })
287293
}
288294

289295
return c.json(session)

packages/opencode/src/session/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { Flag } from "../flag/flag"
1010
import { Identifier } from "../id/id"
1111
import { Installation } from "../installation"
1212

13-
import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like, inArray, lt } from "../storage/db"
13+
import { Database, NotFoundError, eq, and, or, gte, isNull, isNotNull, desc, like, inArray, lt } from "../storage/db"
1414
import type { SQL } from "../storage/db"
1515
import { SessionTable, MessageTable, PartTable } from "./session.sql"
1616
import { ProjectTable } from "../project/project.sql"
@@ -396,7 +396,7 @@ export namespace Session {
396396
return Database.use((db) => {
397397
const row = db
398398
.update(SessionTable)
399-
.set({ time_archived: input.time })
399+
.set({ time_archived: input.time ?? null })
400400
.where(eq(SessionTable.id, input.sessionID))
401401
.returning()
402402
.get()
@@ -590,6 +590,9 @@ export namespace Session {
590590
if (input?.search) {
591591
conditions.push(like(SessionTable.title, `%${input.search}%`))
592592
}
593+
if (input?.archived) {
594+
conditions.push(isNotNull(SessionTable.time_archived))
595+
}
593596
if (!input?.archived) {
594597
conditions.push(isNull(SessionTable.time_archived))
595598
}

0 commit comments

Comments
 (0)