diff --git a/packages/app/src/components/dialog-settings.tsx b/packages/app/src/components/dialog-settings.tsx index 83cea131f5db..f63e2c339aca 100644 --- a/packages/app/src/components/dialog-settings.tsx +++ b/packages/app/src/components/dialog-settings.tsx @@ -8,6 +8,7 @@ import { SettingsGeneral } from "./settings-general" import { SettingsKeybinds } from "./settings-keybinds" import { SettingsProviders } from "./settings-providers" import { SettingsModels } from "./settings-models" +import { SettingsArchive } from "./settings-archive" export const DialogSettings: Component = () => { const language = useLanguage() @@ -47,6 +48,16 @@ export const DialogSettings: Component = () => { + +
+ {language.t("settings.section.data")} +
+ + + {language.t("settings.archive.title")} + +
+
@@ -67,6 +78,9 @@ export const DialogSettings: Component = () => { + + + ) diff --git a/packages/app/src/components/settings-archive.tsx b/packages/app/src/components/settings-archive.tsx new file mode 100644 index 000000000000..28ace7bad9bf --- /dev/null +++ b/packages/app/src/components/settings-archive.tsx @@ -0,0 +1,188 @@ +import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" +import { RadioGroup } from "@opencode-ai/ui/radio-group" +import { getFilename } from "@opencode-ai/util/path" +import { Component, For, Show, createMemo, createResource, createSignal } from "solid-js" +import { useParams } from "@solidjs/router" +import { useGlobalSDK } from "@/context/global-sdk" +import { useGlobalSync } from "@/context/global-sync" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { getRelativeTime } from "@/utils/time" +import { decode64 } from "@/utils/base64" +import type { Session } from "@opencode-ai/sdk/v2/client" +import { SessionSkeleton } from "@/pages/layout/sidebar-items" + +type FilterScope = "all" | "current" + +type ScopeOption = { value: FilterScope; label: "settings.archive.scope.all" | "settings.archive.scope.current" } + +const scopeOptions: ScopeOption[] = [ + { value: "all", label: "settings.archive.scope.all" }, + { value: "current", label: "settings.archive.scope.current" }, +] + +export const SettingsArchive: Component = () => { + const language = useLanguage() + const globalSDK = useGlobalSDK() + const globalSync = useGlobalSync() + const layout = useLayout() + const params = useParams() + const [removedIds, setRemovedIds] = createSignal>(new Set()) + + const projects = createMemo(() => globalSync.data.project) + const layoutProjects = createMemo(() => layout.projects.list()) + const hasMultipleProjects = createMemo(() => projects().length > 1) + const homedir = createMemo(() => globalSync.data.path.home) + + const defaultScope = () => (hasMultipleProjects() ? "current" : "all") + const [filterScope, setFilterScope] = createSignal(defaultScope()) + + const currentDirectory = createMemo(() => decode64(params.dir) ?? "") + + const currentProject = createMemo(() => { + const dir = currentDirectory() + if (!dir) return null + return layoutProjects().find((p) => p.worktree === dir || p.sandboxes?.includes(dir)) ?? null + }) + + const filteredProjects = createMemo(() => { + if (filterScope() === "current" && currentProject()) { + return [currentProject()!] + } + return layoutProjects() + }) + + const getSessionLabel = (session: Session) => { + const directory = session.directory + const home = homedir() + const path = home ? directory.replace(home, "~") : directory + + if (filterScope() === "current" && currentProject()) { + const current = currentProject() + const kind = + current && directory === current.worktree + ? language.t("workspace.type.local") + : language.t("workspace.type.sandbox") + const [store] = globalSync.child(directory, { bootstrap: false }) + const name = store.vcs?.branch ?? getFilename(directory) + return `${kind} : ${name || path}` + } + + return path + } + + const [archivedSessions] = createResource( + () => ({ scope: filterScope(), projects: filteredProjects() }), + async ({ projects }) => { + const allSessions: Session[] = [] + for (const project of projects) { + const directories = [project.worktree, ...(project.sandboxes ?? [])] + for (const directory of directories) { + const result = await globalSDK.client.experimental.session.list({ directory, archived: true }) + const sessions = result.data ?? [] + for (const session of sessions) { + allSessions.push(session) + } + } + } + return allSessions.sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0)) + }, + { initialValue: [] }, + ) + + const displayedSessions = () => { + const sessions = archivedSessions() ?? [] + const removed = removedIds() + return sessions.filter((s) => !removed.has(s.id)) + } + + const currentScopeOption = () => scopeOptions.find((o) => o.value === filterScope()) + + const unarchiveSession = async (session: Session) => { + setRemovedIds((prev) => new Set(prev).add(session.id)) + await globalSDK.client.session.update({ + directory: session.directory, + sessionID: session.id, + time: { archived: null as any }, + }) + } + + const handleScopeChange = (option: ScopeOption | undefined) => { + if (!option) return + setRemovedIds(new Set()) + setFilterScope(option.value) + } + + return ( +
+
+
+

{language.t("settings.archive.title")}

+

{language.t("settings.archive.description")}

+
+
+ +
+ + o.value} + size="small" + label={(o) => language.t(o.label)} + onSelect={handleScopeChange} + /> + + + +
+ } + > + +
{language.t("settings.archive.none")}
+
+ } + > +
+ + {(session) => ( +
+
+
+ {session.title} + {getSessionLabel(session)} +
+
+
+ + {(updated) => ( + + {getRelativeTime(new Date(updated()).toISOString(), language.t)} + + )} + + +
+
+ )} +
+
+ + +
+ + ) +} diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 6c3f3bb55ef4..69959a74bc6a 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -752,6 +752,11 @@ export const dict = { "workspace.reset.archived.one": "ستتم أرشفة جلسة واحدة.", "workspace.reset.archived.many": "ستتم أرشفة {{count}} جلسات.", "workspace.reset.note": "سيؤدي هذا إلى إعادة تعيين مساحة العمل لتتطابق مع الفرع الافتراضي.", + "settings.archive.title": "الجلسات المؤرشفة", + "settings.archive.description": "استعادة الجلسات المؤرشفة لجعلها مرئية في الشريط الجانبي.", + "settings.archive.none": "لا توجد جلسات مؤرشفة.", + "settings.archive.scope.all": "جميع المشاريع", + "settings.archive.scope.current": "المشروع الحالي", "common.open": "فتح", "dialog.releaseNotes.action.getStarted": "البدء", "dialog.releaseNotes.action.next": "التالي", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 63880462a467..fc8ffb6d23b6 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -762,6 +762,11 @@ export const dict = { "workspace.reset.archived.one": "1 sessão será arquivada.", "workspace.reset.archived.many": "{{count}} sessões serão arquivadas.", "workspace.reset.note": "Isso redefinirá o espaço de trabalho para corresponder ao branch padrão.", + "settings.archive.title": "Sessões arquivadas", + "settings.archive.description": "Restaure sessões arquivadas para torná-las visíveis na barra lateral.", + "settings.archive.none": "Nenhuma sessão arquivada.", + "settings.archive.scope.all": "Todos os projetos", + "settings.archive.scope.current": "Projeto atual", "common.open": "Abrir", "dialog.releaseNotes.action.getStarted": "Começar", "dialog.releaseNotes.action.next": "Próximo", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 2b589eb35f62..e89324e79b98 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -838,6 +838,11 @@ export const dict = { "workspace.reset.archived.one": "1 sesija će biti arhivirana.", "workspace.reset.archived.many": "Biće arhivirano {{count}} sesija.", "workspace.reset.note": "Ovo će resetovati radni prostor da odgovara podrazumijevanoj grani.", + "settings.archive.title": "Arhivirane sesije", + "settings.archive.description": "Vrati arhivirane sesije da bi bile vidljive u bočnoj traci.", + "settings.archive.none": "Nema arhiviranih sesija.", + "settings.archive.scope.all": "Svi projekti", + "settings.archive.scope.current": "Trenutni projekt", "common.open": "Otvori", "dialog.releaseNotes.action.getStarted": "Započni", "dialog.releaseNotes.action.next": "Sljedeće", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index b096d87b4b7b..69319841f759 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -832,6 +832,11 @@ export const dict = { "workspace.reset.archived.one": "1 session vil blive arkiveret.", "workspace.reset.archived.many": "{{count}} sessioner vil blive arkiveret.", "workspace.reset.note": "Dette vil nulstille arbejdsområdet til at matche hovedgrenen.", + "settings.archive.title": "Arkiverede sessioner", + "settings.archive.description": "Gendan arkiverede sessioner for at gøre dem synlige i sidebjælken.", + "settings.archive.none": "Ingen arkiverede sessioner.", + "settings.archive.scope.all": "Alle projekter", + "settings.archive.scope.current": "Nuværende projekt", "common.open": "Åbn", "dialog.releaseNotes.action.getStarted": "Kom i gang", "dialog.releaseNotes.action.next": "Næste", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 6dc0b0497245..ffc43148afee 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -773,6 +773,12 @@ export const dict = { "workspace.reset.archived.one": "1 Sitzung wird archiviert.", "workspace.reset.archived.many": "{{count}} Sitzungen werden archiviert.", "workspace.reset.note": "Dadurch wird der Arbeitsbereich auf den Standard-Branch zurückgesetzt.", + + "settings.archive.title": "Archivierte Sitzungen", + "settings.archive.description": "Archivierte Sitzungen wiederherstellen, um sie in der Seitenleiste anzuzeigen.", + "settings.archive.none": "Keine archivierten Sitzungen.", + "settings.archive.scope.all": "Alle Projekte", + "settings.archive.scope.current": "Aktuelles Projekt", "common.open": "Öffnen", "dialog.releaseNotes.action.getStarted": "Loslegen", "dialog.releaseNotes.action.next": "Weiter", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 39317b8d657e..5b6721145ee1 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -639,6 +639,7 @@ export const dict = { "common.rename": "Rename", "common.reset": "Reset", "common.archive": "Archive", + "common.unarchive": "Unarchive", "common.delete": "Delete", "common.close": "Close", "common.edit": "Edit", @@ -708,6 +709,7 @@ export const dict = { "settings.section.desktop": "Desktop", "settings.section.server": "Server", + "settings.section.data": "Data", "settings.tab.general": "General", "settings.tab.shortcuts": "Shortcuts", "settings.desktop.section.wsl": "WSL", @@ -932,4 +934,10 @@ export const dict = { "workspace.reset.archived.one": "1 session will be archived.", "workspace.reset.archived.many": "{{count}} sessions will be archived.", "workspace.reset.note": "This will reset the workspace to match the default branch.", + + "settings.archive.title": "Archived Sessions", + "settings.archive.description": "Restore archived sessions to make them visible in the sidebar.", + "settings.archive.none": "No archived sessions.", + "settings.archive.scope.all": "All projects", + "settings.archive.scope.current": "Current project", } diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index c600232ef613..f46b2aa8c209 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -845,6 +845,12 @@ export const dict = { "workspace.reset.archived.one": "1 sesión será archivada.", "workspace.reset.archived.many": "{{count}} sesiones serán archivadas.", "workspace.reset.note": "Esto restablecerá el espacio de trabajo para coincidir con la rama predeterminada.", + + "settings.archive.title": "Sesiones archivadas", + "settings.archive.description": "Restaura las sesiones archivadas para hacerlas visibles en la barra lateral.", + "settings.archive.none": "No hay sesiones archivadas.", + "settings.archive.scope.all": "Todos los proyectos", + "settings.archive.scope.current": "Proyecto actual", "common.open": "Abrir", "dialog.releaseNotes.action.getStarted": "Comenzar", "dialog.releaseNotes.action.next": "Siguiente", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index a140c1e3a123..4deeb3ed2d5c 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -771,6 +771,11 @@ export const dict = { "workspace.reset.archived.one": "1 session sera archivée.", "workspace.reset.archived.many": "{{count}} sessions seront archivées.", "workspace.reset.note": "Cela réinitialisera l'espace de travail pour correspondre à la branche par défaut.", + "settings.archive.title": "Sessions archivées", + "settings.archive.description": "Restaurez les sessions archivées pour les rendre visibles dans la barre latérale.", + "settings.archive.none": "Aucune session archivée.", + "settings.archive.scope.all": "Tous les Projets", + "settings.archive.scope.current": "Projet actuel", "common.open": "Ouvrir", "dialog.releaseNotes.action.getStarted": "Commencer", "dialog.releaseNotes.action.next": "Suivant", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 3da1c4b43b58..bf91e7f2b43d 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -757,6 +757,12 @@ export const dict = { "workspace.reset.archived.one": "1つのセッションがアーカイブされます。", "workspace.reset.archived.many": "{{count}}個のセッションがアーカイブされます。", "workspace.reset.note": "これにより、ワークスペースはデフォルトブランチと一致するようにリセットされます。", + + "settings.archive.title": "アーカイブされたセッション", + "settings.archive.description": "アーカイブされたセッションを復元してサイドバーに表示します。", + "settings.archive.none": "アーカイブされたセッションはありません。", + "settings.archive.scope.all": "すべてのプロジェクト", + "settings.archive.scope.current": "現在のプロジェクト", "common.open": "開く", "dialog.releaseNotes.action.getStarted": "始める", "dialog.releaseNotes.action.next": "次へ", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 0f2f7647abf5..bb4c5045e2c0 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -756,6 +756,12 @@ export const dict = { "workspace.reset.archived.one": "1개의 세션이 보관됩니다.", "workspace.reset.archived.many": "{{count}}개의 세션이 보관됩니다.", "workspace.reset.note": "이 작업은 작업 공간을 기본 브랜치와 일치하도록 재설정합니다.", + + "settings.archive.title": "보관된 세션", + "settings.archive.description": "보관된 세션을 복원하여 사이드바에 표시합니다.", + "settings.archive.none": "보관된 세션이 없습니다.", + "settings.archive.scope.all": "모든 프로젝트", + "settings.archive.scope.current": "현재 프로젝트", "common.open": "열기", "dialog.releaseNotes.action.getStarted": "시작하기", "dialog.releaseNotes.action.next": "다음", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index a0a968179cd0..3a2642324019 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -839,6 +839,12 @@ export const dict = { "workspace.reset.archived.one": "1 sesjon vil bli arkivert.", "workspace.reset.archived.many": "{{count}} sesjoner vil bli arkivert.", "workspace.reset.note": "Dette vil tilbakestille arbeidsområdet til å samsvare med standardgrenen.", + + "settings.archive.title": "Arkiverte økter", + "settings.archive.description": "Gjenopprett arkiverte økter for å gjøre dem synlige i sidefeltet.", + "settings.archive.none": "Ingen arkiverte økter.", + "settings.archive.scope.all": "Alle prosjekter", + "settings.archive.scope.current": "Nåværende prosjekt", "common.open": "Åpne", "dialog.releaseNotes.action.getStarted": "Kom i gang", "dialog.releaseNotes.action.next": "Neste", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 88d209f11ff2..85238c5212ca 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -759,6 +759,11 @@ export const dict = { "workspace.reset.archived.one": "1 sesja zostanie zarchiwizowana.", "workspace.reset.archived.many": "{{count}} sesji zostanie zarchiwizowanych.", "workspace.reset.note": "To zresetuje przestrzeń roboczą, aby odpowiadała domyślnej gałęzi.", + "settings.archive.title": "Zarchiwizowane sesje", + "settings.archive.description": "Przywróć zarchiwizowane sesje, aby były widoczne na pasku bocznym.", + "settings.archive.none": "Brak zarchiwizowanych sesji.", + "settings.archive.scope.all": "Wszystkie projekty", + "settings.archive.scope.current": "Bieżący projekt", "common.open": "Otwórz", "dialog.releaseNotes.action.getStarted": "Rozpocznij", "dialog.releaseNotes.action.next": "Dalej", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 688289b7e812..63097ae52708 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -840,6 +840,11 @@ export const dict = { "workspace.reset.archived.none": "Активные сессии не будут архивированы.", "workspace.reset.archived.one": "1 сессия будет архивирована.", "workspace.reset.archived.many": "{{count}} сессий будет архивировано.", + "settings.archive.title": "Архивированные сессии", + "settings.archive.description": "Восстановите архивированные сессии, чтобы они отображались на боковой панели.", + "settings.archive.none": "Нет архивированных сессий.", + "settings.archive.scope.all": "Все проекты", + "settings.archive.scope.current": "Текущий проект", "workspace.reset.note": "Это сбросит рабочее пространство до соответствия ветке по умолчанию.", "common.open": "Открыть", "dialog.releaseNotes.action.getStarted": "Начать", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 5decf3adb531..7e3508ca1018 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -828,6 +828,12 @@ export const dict = { "workspace.reset.archived.one": "1 เซสชันจะถูกจัดเก็บ", "workspace.reset.archived.many": "{{count}} เซสชันจะถูกจัดเก็บ", "workspace.reset.note": "สิ่งนี้จะรีเซ็ตพื้นที่ทำงานให้ตรงกับสาขาเริ่มต้น", + + "settings.archive.title": "เซสชันที่จัดเก็บ", + "settings.archive.description": "กู้คืนเซสชันที่จัดเก็บเพื่อให้แสดงในแถบด้านข้าง", + "settings.archive.none": "ไม่มีเซสชันที่จัดเก็บ", + "settings.archive.scope.all": "โปรเจกต์ทั้งหมด", + "settings.archive.scope.current": "โปรเจกต์ปัจจุบัน", "common.open": "เปิด", "dialog.releaseNotes.action.getStarted": "เริ่มต้น", "dialog.releaseNotes.action.next": "ถัดไป", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 28231733eaba..08c44c3ad3d5 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -826,6 +826,12 @@ export const dict = { "workspace.reset.archived.one": "将归档 1 个会话。", "workspace.reset.archived.many": "将归档 {{count}} 个会话。", "workspace.reset.note": "这将把工作区重置为与默认分支一致。", + + "settings.archive.title": "归档会话", + "settings.archive.description": "恢复归档会话以使其在侧边栏中可见。", + "settings.archive.none": "没有归档会话。", + "settings.archive.scope.all": "所有项目", + "settings.archive.scope.current": "当前项目", "common.open": "打开", "dialog.releaseNotes.action.getStarted": "开始", "dialog.releaseNotes.action.next": "下一步", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 4abdf5db574d..c85ef5f9bd5f 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -822,6 +822,12 @@ export const dict = { "workspace.reset.archived.one": "將封存 1 個工作階段。", "workspace.reset.archived.many": "將封存 {{count}} 個工作階段。", "workspace.reset.note": "這將把工作區重設為與預設分支一致。", + + "settings.archive.title": "封存工作階段", + "settings.archive.description": "恢復封存的工作階段以使其在側邊欄中可見。", + "settings.archive.none": "沒有封存的工作階段。", + "settings.archive.scope.all": "所有專案", + "settings.archive.scope.current": "目前專案", "common.open": "打開", "dialog.releaseNotes.action.getStarted": "開始", "dialog.releaseNotes.action.next": "下一步", diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index d499e5a1ecf4..2b7ac534250f 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -266,13 +266,19 @@ export const SessionRoutes = lazy(() => sessionID: SessionID.zod, }), ), + validator( + "query", + z.object({ + directory: z.string().optional(), + }), + ), validator( "json", z.object({ title: z.string().optional(), time: z .object({ - archived: z.number().optional(), + archived: z.number().nullable().optional(), }) .optional(), }), diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 371091722e30..0a08a97df509 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -9,7 +9,7 @@ import { Config } from "../config/config" import { Flag } from "../flag/flag" import { Installation } from "../installation" -import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage/db" +import { Database, NotFoundError, eq, and, or, gte, isNull, isNotNull, desc, like, inArray, lt } from "../storage/db" import { SyncEvent } from "../sync" import type { SQL } from "../storage/db" import { SessionTable } from "./session.sql" @@ -321,7 +321,7 @@ export namespace Session { readonly share: (id: SessionID) => Effect.Effect<{ url: string }> readonly unshare: (id: SessionID) => Effect.Effect readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect - readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect + readonly setArchived: (input: { sessionID: SessionID; time?: number | null }) => Effect.Effect readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect readonly setRevert: (input: { sessionID: SessionID @@ -552,7 +552,7 @@ export namespace Session { yield* patch(input.sessionID, { title: input.title }) }) - const setArchived = Effect.fn("Session.setArchived")(function* (input: { sessionID: SessionID; time?: number }) { + const setArchived = Effect.fn("Session.setArchived")(function* (input: { sessionID: SessionID; time?: number | null }) { yield* patch(input.sessionID, { time: { archived: input.time } }) }) @@ -711,8 +711,9 @@ export namespace Session { runPromise((svc) => svc.setTitle(input)), ) - export const setArchived = fn(z.object({ sessionID: SessionID.zod, time: z.number().optional() }), (input) => - runPromise((svc) => svc.setArchived(input)), + export const setArchived = fn( + z.object({ sessionID: SessionID.zod, time: z.number().nullable().optional() }), + (input) => runPromise((svc) => svc.setArchived(input)), ) export const setPermission = fn(z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset }), (input) => @@ -806,6 +807,9 @@ export namespace Session { if (input?.search) { conditions.push(like(SessionTable.title, `%${input.search}%`)) } + if (input?.archived) { + conditions.push(isNotNull(SessionTable.time_archived)) + } if (!input?.archived) { conditions.push(isNull(SessionTable.time_archived)) } diff --git a/packages/opencode/test/server/session-archive.test.ts b/packages/opencode/test/server/session-archive.test.ts new file mode 100644 index 000000000000..b6575f80238b --- /dev/null +++ b/packages/opencode/test/server/session-archive.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test, beforeEach } from "bun:test" +import path from "path" +import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" +import { Log } from "../../src/util/log" + +const projectRoot = path.join(__dirname, "../..") +Log.init({ print: false }) + +describe("Session archive and unarchive", () => { + let testDir: string + + beforeEach(async () => { + testDir = path.join(projectRoot, "__archive_test_" + Date.now()) + }) + + test("can archive and unarchive a session", async () => { + await Instance.provide({ + directory: testDir, + fn: async () => { + // Create a new session + const session = await Session.create({ title: "Test Archive Session" }) + + // Verify session is not archived initially + expect(session.time?.archived).toBeUndefined() + + // Archive the session + await Session.setArchived({ sessionID: session.id, time: Date.now() }) + + // Get fresh session from database using list + const allSessions = [...Session.listGlobal({ archived: true })] + const archivedSession = allSessions.find(s => s.id === session.id) + + expect(archivedSession).toBeDefined() + expect(archivedSession?.time?.archived).toBeDefined() + expect(archivedSession!.time!.archived!).toBeGreaterThan(0) + + // Verify it's in archived list + expect(allSessions.some(s => s.id === session.id)).toBe(true) + + // Unarchive the session + await Session.setArchived({ sessionID: session.id, time: null }) + + // Get fresh session after unarchive + const unarchivedSessions = [...Session.listGlobal({ archived: true })] + const unarchivedSession = unarchivedSessions.find(s => s.id === session.id) + expect(unarchivedSession).toBeUndefined() + + // Verify it appears in normal (non-archived) list + const normalSessions = [...Session.listGlobal({ archived: false })] + const normalTestSession = normalSessions.find(s => s.id === session.id) + expect(normalTestSession).toBeDefined() + }, + }) + }) + + test("archived filter returns only archived sessions", async () => { + await Instance.provide({ + directory: testDir, + fn: async () => { + // Create two sessions + const session1 = await Session.create({ title: "Session One" }) + const session2 = await Session.create({ title: "Session Two" }) + + // Archive only session1 + await Session.setArchived({ sessionID: session1.id, time: Date.now() }) + + // Get archived sessions + const archivedSessions = [...Session.listGlobal({ archived: true })] + const archivedIds = archivedSessions.map(s => s.id) + + expect(archivedIds).toContain(session1.id) + expect(archivedIds).not.toContain(session2.id) + + // Get non-archived sessions + const activeSessions = [...Session.listGlobal({ archived: false })] + const activeIds = activeSessions.map(s => s.id) + + expect(activeIds).toContain(session2.id) + expect(activeIds).not.toContain(session1.id) + }, + }) + }) +}) \ No newline at end of file