From a51a048797d083d368e55f76277055b7317663cf Mon Sep 17 00:00:00 2001 From: anduimagui Date: Sun, 1 Mar 2026 12:54:29 +0000 Subject: [PATCH 1/4] chore: add local PR template guard wrapper --- package.json | 3 ++- script/pr-create.ts | 63 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 script/pr-create.ts diff --git a/package.json b/package.json index 2e7c1172aa64..4823dd991ad2 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "prepare": "husky", "random": "echo 'Random script'", "hello": "echo 'Hello World!'", - "test": "echo 'do not run tests from root' && exit 1" + "test": "echo 'do not run tests from root' && exit 1", + "pr:create": "bun ./script/pr-create.ts" }, "workspaces": { "packages": [ diff --git a/script/pr-create.ts b/script/pr-create.ts new file mode 100644 index 000000000000..67a63be46990 --- /dev/null +++ b/script/pr-create.ts @@ -0,0 +1,63 @@ +#!/usr/bin/env bun + +import path from "node:path" + +const need = [ + "### Issue for this PR", + "### Type of change", + "### What does this PR do?", + "### How did you verify your code works?", + "### Screenshots / recordings", + "### Checklist", +] + +const help = () => { + console.log(`Usage: bun run pr:create -- [gh pr create args] + +Required: + --body-file Path to PR body markdown file + +Examples: + bun run pr:create -- --base dev --title "feat: add foo" --body-file /tmp/pr.md + bun run pr:create -- --base dev --head my-branch --body-file .github/pull_request_template.md +`) +} + +const fail = (msg: string) => { + console.error(msg) + process.exit(1) +} + +const args = Bun.argv.slice(2) +if (args.includes("--help") || args.includes("-h")) { + help() + process.exit(0) +} + +const bodyIndex = args.findIndex((x) => x === "--body-file" || x === "-F") +if (bodyIndex === -1) fail("Missing --body-file/-F. This wrapper validates PR template before creating PR.") + +const bodyArg = args[bodyIndex + 1] +if (!bodyArg) fail("Missing value for --body-file/-F.") + +const bodyPath = path.resolve(process.cwd(), bodyArg) +const bodyFile = Bun.file(bodyPath) +if (!(await bodyFile.exists())) fail(`PR body file not found: ${bodyArg}`) + +const body = await bodyFile.text() +for (const section of need) { + if (body.includes(section)) continue + fail(`Missing required section: ${section}`) +} + +const checked = /- \[x\] (Bug fix|New feature|Refactor \/ code improvement|Documentation)/.test(body) +if (!checked) fail("No checked 'Type of change' checkbox found.") + +const run = Bun.spawnSync(["gh", "pr", "create", ...args], { + stdout: "inherit", + stderr: "inherit", + stdin: "inherit", + env: process.env, +}) + +process.exit(run.exitCode) From 28481a26a8d6ed1dd09b7bb2cf53eaa8e3072690 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Thu, 5 Mar 2026 15:47:50 +0000 Subject: [PATCH 2/4] feat(session): add desktop unarchive and archive undo flow --- packages/app/src/i18n/en.ts | 3 + packages/app/src/pages/layout.tsx | 81 +++++++++++++++++-- .../src/pages/session/message-timeline.tsx | 29 +++++++ packages/desktop/src/i18n/en.ts | 2 + packages/desktop/src/menu.ts | 10 +++ .../opencode/src/server/routes/session.ts | 11 ++- packages/opencode/src/session/index.ts | 14 ++-- packages/sdk/js/src/v2/gen/sdk.gen.ts | 3 +- packages/sdk/js/src/v2/gen/types.gen.ts | 3 +- 9 files changed, 136 insertions(+), 20 deletions(-) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 7e95fd739df7..0ff17641e70a 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 f93d1f0691d2..27f25031fdd0 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -579,6 +579,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[] @@ -834,25 +842,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", () => { @@ -923,12 +977,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/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 0aa07bf7453b..608e35edd1b6 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -349,6 +349,8 @@ export function MessageTimeline(props: { const archiveSession = async (sessionID: string) => { const session = sync.session.get(sessionID) if (!session) return + const active = params.id === sessionID + const updated = session.time.updated ?? session.time.created const sessions = sync.data.session ?? [] const index = sessions.findIndex((s) => s.id === sessionID) @@ -364,6 +366,33 @@ export function MessageTimeline(props: { }), ) navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) + 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 sdk.client.session + .update({ sessionID, time: { archived: null, updated } }) + .then(() => { + if (!active) return + navigate(`/${params.dir}/session/${sessionID}`) + }) + .catch((err) => { + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(err), + }) + }) + }, + }, + { + label: language.t("common.dismiss"), + onClick: "dismiss", + }, + ], + }) }) .catch((err) => { showToast({ diff --git a/packages/desktop/src/i18n/en.ts b/packages/desktop/src/i18n/en.ts index f93fe58f77a2..f90ac932b805 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 de6a1d6a76c7..95d3427b5cc8 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 a391979520da..4b38dfb1dea6 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -269,7 +269,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(), }), @@ -282,8 +283,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 b117632051f7..a6496bd17ce7 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/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 49ebc847345d..d5195b8563eb 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -1400,7 +1400,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 69d10561090a..1e4114ff4576 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2877,7 +2877,8 @@ export type SessionUpdateData = { body?: { title?: string time?: { - archived?: number + archived?: number | null + updated?: number } } path: { From 4618bc5b784dd43aa5bc4465a9e0bf4ea4d990ec Mon Sep 17 00:00:00 2001 From: anduimagui Date: Sat, 7 Mar 2026 17:06:19 +0000 Subject: [PATCH 3/4] Revert "Merge origin/dev into opencode/lucky-eagle" This reverts commit b5a71ee16a61861ceb40d0d9ed416ea20594076d, reversing changes made to 921a95921e60a601c9704f1ced672e1fe6e314e9. --- package.json | 3 +-- script/pr-create.ts | 63 --------------------------------------------- 2 files changed, 1 insertion(+), 65 deletions(-) delete mode 100644 script/pr-create.ts diff --git a/package.json b/package.json index 3f60034bb2ae..36cf31d34683 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,7 @@ "prepare": "husky", "random": "echo 'Random script'", "hello": "echo 'Hello World!'", - "test": "echo 'do not run tests from root' && exit 1", - "pr:create": "bun ./script/pr-create.ts" + "test": "echo 'do not run tests from root' && exit 1" }, "workspaces": { "packages": [ diff --git a/script/pr-create.ts b/script/pr-create.ts deleted file mode 100644 index 67a63be46990..000000000000 --- a/script/pr-create.ts +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env bun - -import path from "node:path" - -const need = [ - "### Issue for this PR", - "### Type of change", - "### What does this PR do?", - "### How did you verify your code works?", - "### Screenshots / recordings", - "### Checklist", -] - -const help = () => { - console.log(`Usage: bun run pr:create -- [gh pr create args] - -Required: - --body-file Path to PR body markdown file - -Examples: - bun run pr:create -- --base dev --title "feat: add foo" --body-file /tmp/pr.md - bun run pr:create -- --base dev --head my-branch --body-file .github/pull_request_template.md -`) -} - -const fail = (msg: string) => { - console.error(msg) - process.exit(1) -} - -const args = Bun.argv.slice(2) -if (args.includes("--help") || args.includes("-h")) { - help() - process.exit(0) -} - -const bodyIndex = args.findIndex((x) => x === "--body-file" || x === "-F") -if (bodyIndex === -1) fail("Missing --body-file/-F. This wrapper validates PR template before creating PR.") - -const bodyArg = args[bodyIndex + 1] -if (!bodyArg) fail("Missing value for --body-file/-F.") - -const bodyPath = path.resolve(process.cwd(), bodyArg) -const bodyFile = Bun.file(bodyPath) -if (!(await bodyFile.exists())) fail(`PR body file not found: ${bodyArg}`) - -const body = await bodyFile.text() -for (const section of need) { - if (body.includes(section)) continue - fail(`Missing required section: ${section}`) -} - -const checked = /- \[x\] (Bug fix|New feature|Refactor \/ code improvement|Documentation)/.test(body) -if (!checked) fail("No checked 'Type of change' checkbox found.") - -const run = Bun.spawnSync(["gh", "pr", "create", ...args], { - stdout: "inherit", - stderr: "inherit", - stdin: "inherit", - env: process.env, -}) - -process.exit(run.exitCode) From ae71807beaca802fc5d19c0949a949ad8599d191 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Sat, 7 Mar 2026 20:01:36 +0000 Subject: [PATCH 4/4] Fix which module resolution for typecheck --- packages/opencode/src/util/which.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/util/which.ts b/packages/opencode/src/util/which.ts index 81da2572170f..1eea0ed3295f 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,