diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index b530aff532f..103d8d8f413 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -34,7 +34,7 @@ type Entry = { updated?: number } -type DialogSelectFileMode = "all" | "files" +type DialogSelectFileMode = "all" | "files" | "archived" const ENTRY_LIMIT = 5 const COMMON_COMMAND_IDS = [ @@ -99,11 +99,13 @@ const createSessionEntry = ( function createCommandEntries(props: { filesOnly: () => boolean + archivedOnly: () => boolean command: ReturnType language: ReturnType }) { const allowed = createMemo(() => { if (props.filesOnly()) return [] + if (props.archivedOnly()) return [] return props.command.options.filter( (option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open", ) @@ -174,10 +176,14 @@ function createSessionEntries(props: { token: number inflight: Promise | undefined cached: Entry[] | undefined + archivedInflight: Promise | undefined + archivedCached: Entry[] | undefined } = { token: 0, inflight: undefined, cached: undefined, + archivedInflight: undefined, + archivedCached: undefined, } const sessions = (text: string) => { @@ -250,7 +256,73 @@ function createSessionEntries(props: { return state.inflight } - return { sessions } + const archived = () => { + if (state.archivedCached) return state.archivedCached + if (state.archivedInflight) return state.archivedInflight + + const dirs = props.workspaces() + if (dirs.length === 0) return [] as Entry[] + + state.archivedInflight = Promise.all( + dirs.map((directory) => { + const description = props.label(directory) + return props.globalSDK.client.experimental.session + .list({ + directory, + roots: true, + archived: true, + limit: 200, + }) + .then((x) => + (x.data ?? []) + .filter((s) => !!s?.id) + .filter((s) => !!s.time?.archived) + .map((s) => ({ + id: s.id, + title: s.title ?? props.language.t("command.session.new"), + description, + directory, + archived: s.time?.archived, + updated: s.time?.updated, + })), + ) + .catch( + () => + [] as { + id: string + title: string + description: string + directory: string + archived?: number + updated?: number + }[], + ) + }), + ) + .then((results) => { + const seen = new Set() + const category = props.language.t("command.category.session") + const next = results + .flat() + .filter((item) => { + const key = `${item.directory}:${item.id}` + if (seen.has(key)) return false + seen.add(key) + return true + }) + .map((item) => createSessionEntry(item, category)) + state.archivedCached = next + return next + }) + .catch(() => [] as Entry[]) + .finally(() => { + state.archivedInflight = undefined + }) + + return state.archivedInflight + } + + return { sessions, archived } } export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) { @@ -264,12 +336,13 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil const globalSDK = useGlobalSDK() const globalSync = useGlobalSync() const filesOnly = () => props.mode === "files" + const archivedOnly = () => props.mode === "archived" const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey)) const view = createMemo(() => layout.view(sessionKey)) const state = { cleanup: undefined as (() => void) | void, committed: false } const [grouped, setGrouped] = createSignal(false) - const commandEntries = createCommandEntries({ filesOnly, command, language }) + const commandEntries = createCommandEntries({ filesOnly, archivedOnly, command, language }) const fileEntries = createFileEntries({ file, tabs, language }) const projectDirectory = createMemo(() => decode64(params.dir) ?? "") @@ -301,12 +374,16 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil return `${kind} : ${name || path}` } - const { sessions } = createSessionEntries({ workspaces, label, globalSDK, language }) + const sessions = createSessionEntries({ workspaces, label, globalSDK, language }) const items = async (text: string) => { const query = text.trim() setGrouped(query.length > 0) + if (archivedOnly()) { + return Promise.resolve(sessions.archived()) + } + if (!query && filesOnly()) { const loaded = file.tree.state("")?.loaded const pending = loaded ? Promise.resolve() : file.tree.list("") @@ -329,7 +406,10 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil return files.map((path) => createFileEntry(path, category)) } - const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))]) + const [files, nextSessions] = await Promise.all([ + file.searchFiles(query), + Promise.resolve(sessions.sessions(query)), + ]) const category = language.t("palette.group.files") const entries = files.map((path) => createFileEntry(path, category)) return [...commandEntries.list(), ...nextSessions, ...entries] @@ -384,7 +464,9 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil search={{ placeholder: filesOnly() ? language.t("session.header.searchFiles") - : language.t("palette.search.placeholder"), + : archivedOnly() + ? language.t("palette.search.archived.placeholder") + : language.t("palette.search.placeholder"), autofocus: true, hideIcon: true, }} @@ -437,6 +519,11 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil > {item.title} + + + {language.t("common.archived")} + + dialog.show(() => ), + }, { id: "workspace.new", title: language.t("workspace.new"), diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index a7f503d5a34..766d1de142f 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -261,7 +261,8 @@ export function MessageTimeline(props: { }) const titleValue = createMemo(() => info()?.title) const parentID = createMemo(() => info()?.parentID) - const showHeader = createMemo(() => !!(titleValue() || parentID())) + const archivedAt = createMemo(() => info()?.time?.archived) + const showHeader = createMemo(() => !!(titleValue() || parentID() || archivedAt())) const stageCfg = { init: 1, batch: 3 } const staging = createTimelineStaging({ sessionKey, @@ -382,6 +383,34 @@ export function MessageTimeline(props: { }) } + const unarchiveSession = async (sessionID: string) => { + const session = sync.session.get(sessionID) + if (!session) return + + await sdk.client.session + .update({ sessionID, time: { archived: null } }) + .then((res) => { + const next = res.data ?? { ...session, time: { ...session.time, archived: undefined } } + sync.set( + produce((draft) => { + const index = draft.session.findIndex((s) => s.id === sessionID) + if (index !== -1) { + draft.session[index] = next + return + } + const match = Binary.search(draft.session, sessionID, (s) => s.id) + draft.session.splice(match.index, 0, next) + }), + ) + }) + .catch((err) => { + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(err), + }) + }) + } + const deleteSession = async (sessionID: string) => { const session = sync.session.get(sessionID) if (!session) return false @@ -609,6 +638,11 @@ export function MessageTimeline(props: { /> + + + {language.t("common.archived")} + + {(id) => ( @@ -645,8 +679,12 @@ export function MessageTimeline(props: { > {language.t("common.rename")} - void archiveSession(id())}> - {language.t("common.archive")} + (archivedAt() ? void unarchiveSession(id()) : void archiveSession(id()))} + > + + {archivedAt() ? language.t("common.unarchive") : language.t("common.archive")} + 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 b117632051f..c246b15c659 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -395,7 +395,7 @@ export namespace Session { export const setArchived = fn( z.object({ sessionID: Identifier.schema("session"), - time: z.number().optional(), + time: z.number().nullable().optional(), }), async (input) => { return Database.use((db) => { diff --git a/packages/opencode/test/server/session-update.test.ts b/packages/opencode/test/server/session-update.test.ts new file mode 100644 index 00000000000..9a600b6bb86 --- /dev/null +++ b/packages/opencode/test/server/session-update.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test" +import { Instance } from "../../src/project/instance" +import { Server } from "../../src/server/server" +import { Session } from "../../src/session" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +describe("session.update endpoint", () => { + test("supports unarchive with null", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({ title: "archived-session" }) + await Session.setArchived({ sessionID: session.id, time: Date.now() }) + + const app = Server.App() + const response = await app.request(`/session/${session.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ time: { archived: null } }), + }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.time.archived).toBeUndefined() + + const updated = await Session.get(session.id) + expect(updated.time.archived).toBeUndefined() + }, + }) + }) +}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 1c1b31e46f0..df6e99ff191 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -1399,7 +1399,7 @@ export class Session2 extends HeyApiClient { workspace?: string title?: string time?: { - archived?: number + archived?: number | null } }, options?: Options, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index afb2224a751..a47d965b8e4 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2874,7 +2874,7 @@ export type SessionUpdateData = { body?: { title?: string time?: { - archived?: number + archived?: number | null } } path: {