diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 97a572f1cf2..3218b3821be 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -31,6 +31,7 @@ export const dict = { "command.session.previous.unseen": "Previous unread session", "command.session.next.unseen": "Next unread session", "command.session.archive": "Archive session", + "command.session.unarchive": "Unarchive session", "command.palette": "Command palette", @@ -440,6 +441,8 @@ export const dict = { "toast.session.unshare.success.description": "Session unshared successfully!", "toast.session.unshare.failed.title": "Failed to unshare session", "toast.session.unshare.failed.description": "An error occurred while unsharing the session", + "toast.session.archive.success.title": "Session archived", + "toast.session.archive.success.description": "Use Undo to restore it.", "toast.session.listFailed.title": "Failed to load sessions for {{project}}", "toast.project.reloadFailed.title": "Failed to reload {{project}}", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 70114623e33..9b1814ce41d 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -643,6 +643,14 @@ export default function Layout(props: ParentProps) { return result }) + const selectedSession = createMemo(() => { + if (!params.dir || !params.id) return + const directory = decode64(params.dir) + if (!directory) return + const [store] = globalSync.child(directory, { bootstrap: false }) + return store.session.find((s) => s.id === params.id) + }) + type PrefetchQueue = { inflight: Set pending: string[] @@ -898,25 +906,71 @@ export default function Layout(props: ParentProps) { const sessions = store.session ?? [] const index = sessions.findIndex((s) => s.id === session.id) const nextSession = sessions[index + 1] ?? sessions[index - 1] + const active = session.id === params.id - await globalSDK.client.session.update({ - directory: session.directory, - sessionID: session.id, - time: { archived: Date.now() }, - }) + const archived = await globalSDK.client.session + .update({ + directory: session.directory, + sessionID: session.id, + time: { archived: Date.now() }, + }) + .then(() => true) + .catch((err) => { + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(err, language.t("common.requestFailed")), + }) + return false + }) + if (!archived) return setStore( produce((draft) => { const match = Binary.search(draft.session, session.id, (s) => s.id) if (match.found) draft.session.splice(match.index, 1) }), ) - if (session.id === params.id) { + if (active) { if (nextSession) { navigate(`/${params.dir}/session/${nextSession.id}`) } else { navigate(`/${params.dir}/session`) } } + + showToast({ + title: language.t("toast.session.archive.success.title"), + description: language.t("toast.session.archive.success.description"), + actions: [ + { + label: language.t("command.session.undo"), + onClick: () => { + void unarchiveSession(session, session.time.updated ?? session.time.created) + .then(() => { + if (!active) return + navigate(`/${base64Encode(session.directory)}/session/${session.id}`) + }) + .catch((err) => { + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(err, language.t("common.requestFailed")), + }) + }) + }, + }, + { + label: language.t("common.dismiss"), + onClick: "dismiss", + }, + ], + }) + } + + async function unarchiveSession(session: Session, updated?: number) { + await globalSDK.client.session.update({ + directory: session.directory, + sessionID: session.id, + time: { archived: null, updated }, + }) } command.register("layout", () => { @@ -987,12 +1041,23 @@ export default function Layout(props: ParentProps) { title: language.t("command.session.archive"), category: language.t("command.category.session"), keybind: "mod+shift+backspace", - disabled: !params.dir || !params.id, + disabled: !selectedSession() || !!selectedSession()?.time?.archived, onSelect: () => { - const session = currentSessions().find((s) => s.id === params.id) + const session = selectedSession() if (session) archiveSession(session) }, }, + { + id: "session.unarchive", + title: language.t("command.session.unarchive"), + category: language.t("command.category.session"), + keybind: "mod+shift+u", + disabled: !selectedSession()?.time?.archived, + onSelect: () => { + const session = selectedSession() + if (session) unarchiveSession(session, session.time.updated ?? session.time.created) + }, + }, { id: "workspace.new", title: language.t("workspace.new"), diff --git a/packages/desktop/src/i18n/en.ts b/packages/desktop/src/i18n/en.ts index f93fe58f77a..f90ac932b80 100644 --- a/packages/desktop/src/i18n/en.ts +++ b/packages/desktop/src/i18n/en.ts @@ -17,6 +17,8 @@ export const dict = { "desktop.menu.view.forward": "Forward", "desktop.menu.view.previousSession": "Previous Session", "desktop.menu.view.nextSession": "Next Session", + "desktop.menu.view.archiveSession": "Archive Session", + "desktop.menu.view.unarchiveSession": "Unarchive Session", "desktop.menu.help.documentation": "OpenCode Documentation", "desktop.menu.help.supportForum": "Support Forum", "desktop.menu.help.shareFeedback": "Share Feedback", diff --git a/packages/desktop/src/menu.ts b/packages/desktop/src/menu.ts index de6a1d6a76c..95d3427b5cc 100644 --- a/packages/desktop/src/menu.ts +++ b/packages/desktop/src/menu.ts @@ -149,6 +149,16 @@ export async function createMenu(trigger: (id: string) => void) { text: t("desktop.menu.view.nextSession"), accelerator: "Option+ArrowDown", }), + await MenuItem.new({ + action: () => trigger("session.archive"), + text: t("desktop.menu.view.archiveSession"), + accelerator: "Cmd+Shift+Backspace", + }), + await MenuItem.new({ + action: () => trigger("session.unarchive"), + text: t("desktop.menu.view.unarchiveSession"), + accelerator: "Cmd+Shift+U", + }), await PredefinedMenuItem.new({ item: "Separator", }), diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 12938aeaba0..f8c237f1fea 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -267,7 +267,8 @@ export const SessionRoutes = lazy(() => title: z.string().optional(), time: z .object({ - archived: z.number().optional(), + archived: z.number().nullable().optional(), + updated: z.number().optional(), }) .optional(), }), @@ -280,8 +281,12 @@ export const SessionRoutes = lazy(() => if (updates.title !== undefined) { session = await Session.setTitle({ sessionID, title: updates.title }) } - if (updates.time?.archived !== undefined) { - session = await Session.setArchived({ sessionID, time: updates.time.archived }) + if (updates.time && "archived" in updates.time) { + session = await Session.setArchived({ + sessionID, + time: updates.time.archived ?? null, + updated: updates.time.updated, + }) } return c.json(session) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index b117632051f..a6496bd17ce 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -395,16 +395,16 @@ export namespace Session { export const setArchived = fn( z.object({ sessionID: Identifier.schema("session"), - time: z.number().optional(), + time: z.number().nullable().optional(), + updated: z.number().optional(), }), async (input) => { return Database.use((db) => { - const row = db - .update(SessionTable) - .set({ time_archived: input.time }) - .where(eq(SessionTable.id, input.sessionID)) - .returning() - .get() + const set = + input.updated === undefined + ? { time_archived: input.time } + : { time_archived: input.time, time_updated: input.updated } + const row = db.update(SessionTable).set(set).where(eq(SessionTable.id, input.sessionID)).returning().get() if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` }) const info = fromRow(row) Database.effect(() => Bus.publish(Event.Updated, { info })) diff --git a/packages/opencode/src/util/which.ts b/packages/opencode/src/util/which.ts index 81da2572170..1eea0ed3295 100644 --- a/packages/opencode/src/util/which.ts +++ b/packages/opencode/src/util/which.ts @@ -1,7 +1,12 @@ -import whichPkg from "which" +import { createRequire } from "node:module" + +const req = createRequire(import.meta.url) +const mod = req("which") as { + sync: (cmd: string, opts: { nothrow: boolean; path?: string; pathExt?: string }) => string | null +} export function which(cmd: string, env?: NodeJS.ProcessEnv) { - const result = whichPkg.sync(cmd, { + const result = mod.sync(cmd, { nothrow: true, path: env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path, pathExt: env?.PATHEXT ?? env?.PathExt ?? process.env.PATHEXT ?? process.env.PathExt, diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 22dcfec3553..d7520d6a170 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -1430,7 +1430,8 @@ export class Session2 extends HeyApiClient { workspace?: string title?: string time?: { - archived?: number + archived?: number | null + updated?: number } }, 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 71e075b3916..fd12e46647c 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2893,7 +2893,8 @@ export type SessionUpdateData = { body?: { title?: string time?: { - archived?: number + archived?: number | null + updated?: number } } path: {