From 57ec6008c22202a73ef6d8ac2bd72f2a9d9eff46 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 28 May 2026 09:16:17 +0800 Subject: [PATCH 01/14] ci: infer release bump PR type labels --- .github/labeler.yml | 5 --- .github/scripts/pr-priority-triage.js | 38 +++++++++++++++++++ .github/workflows/pr-triage.yml | 4 +- .../test/github/pr-triage-workflow.test.ts | 38 ++++++++++++++++--- 4 files changed, 73 insertions(+), 12 deletions(-) diff --git a/.github/labeler.yml b/.github/labeler.yml index fa39c467f..658018c89 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -4,11 +4,6 @@ ci: - ".github/workflows/**" - ".github/**" -task: - - changed-files: - - any-glob-to-any-file: - - ".github/workflows/**" - platform: - changed-files: - any-glob-to-any-file: diff --git a/.github/scripts/pr-priority-triage.js b/.github/scripts/pr-priority-triage.js index b6e7f2de9..b5b45fbcc 100644 --- a/.github/scripts/pr-priority-triage.js +++ b/.github/scripts/pr-priority-triage.js @@ -1,6 +1,7 @@ export const TRIAGE_MARKER = "" export const PRIORITY_LABELS = ["P0", "P1", "P2", "P3"] +export const TYPE_LABELS = ["bug", "enhancement", "task", "documentation"] const LOW_RISK_GLOBS = [ "docs/**", @@ -11,6 +12,8 @@ const LOW_RISK_GLOBS = [ ] const USER_PATH_GLOBS = ["packages/app/src/**", "packages/desktop-electron/src/**"] +const RELEASE_BUMP_GLOBS = ["packages/desktop-electron/package.json", "bun.lock"] +const RELEASE_BUMP_REQUIRED_PATH = "packages/desktop-electron/package.json" function escapeRegex(value) { return value.replace(/[.+^${}()|[\]\\]/g, "\\$&") @@ -114,3 +117,38 @@ export function planPriorityLabels(paths, labels = []) { removeLabels: existingPriorities.filter((label) => label !== desiredPriority), } } + +export function classifyPullRequestType(paths) { + const normalized = paths.map((path) => path.replace(/\\/g, "/")) + + if ( + normalized.length > 0 && + normalized.includes(RELEASE_BUMP_REQUIRED_PATH) && + normalized.every((path) => matchesAny(path, RELEASE_BUMP_GLOBS)) + ) { + return "task" + } + + return undefined +} + +/** + * @param {string[]} paths + * @param {string[]} labels + */ +export function planPullRequestLabels(paths, labels = []) { + const priorityPlan = planPriorityLabels(paths, labels) + const labelSet = new Set(labels) + const existingTypes = TYPE_LABELS.filter((label) => labelSet.has(label)) + const inferredType = existingTypes.length === 0 ? classifyPullRequestType(paths) : undefined + const addLabels = [...priorityPlan.addLabels] + + if (inferredType && !labelSet.has(inferredType)) { + addLabels.push(inferredType) + } + + return { + ...priorityPlan, + addLabels, + } +} diff --git a/.github/workflows/pr-triage.yml b/.github/workflows/pr-triage.yml index d4e0eae1f..e4e7b32e9 100644 --- a/.github/workflows/pr-triage.yml +++ b/.github/workflows/pr-triage.yml @@ -41,7 +41,7 @@ jobs: const policy = await import( pathToFileURL(path.join(process.env.GITHUB_WORKSPACE, ".github/scripts/label-policy-check.js")).href, ) - const { buildPriorityReview, planPriorityLabels, TRIAGE_MARKER } = triage + const { buildPriorityReview, planPullRequestLabels, TRIAGE_MARKER } = triage const pull_number = Number(process.env.PR_NUMBER) const owner = context.repo.owner const repo = context.repo.repo @@ -60,7 +60,7 @@ jobs: issue_number: pull_number, per_page: 100, }) - const labelPlan = planPriorityLabels(paths, currentLabels.map((label) => label.name)) + const labelPlan = planPullRequestLabels(paths, currentLabels.map((label) => label.name)) if (labelPlan.addLabels.length > 0) { await github.rest.issues.addLabels({ diff --git a/packages/opencode/test/github/pr-triage-workflow.test.ts b/packages/opencode/test/github/pr-triage-workflow.test.ts index 5e338c741..47c4190c6 100644 --- a/packages/opencode/test/github/pr-triage-workflow.test.ts +++ b/packages/opencode/test/github/pr-triage-workflow.test.ts @@ -4,6 +4,7 @@ import { validateLabelPolicy } from "../../../../.github/scripts/label-policy-ch import { buildPriorityReview, classifyPriority, + planPullRequestLabels, planPriorityLabels, TRIAGE_MARKER, } from "../../../../.github/scripts/pr-priority-triage.js" @@ -38,7 +39,9 @@ describe("pr triage workflow", () => { test("defines labeler routing for repo areas and workflow policy labels", () => { const config = readWorkflow(labelerConfigPath) expect(config).toContain("ci:") - expect(config).toContain("task:") + expect(config).not.toContain("task:") + expect(config).not.toContain("bug:") + expect(config).not.toContain("enhancement:") expect(config).not.toContain("P3:") expect(config).toContain("platform:") expect(config).toContain("app:") @@ -53,11 +56,11 @@ describe("pr triage workflow", () => { expect(config).not.toContain("**/*.md") }) - test("labels workflow PRs with type and routing while priority triage owns priority", () => { + test("labels workflow PRs with routing while scripts own priority and selected type inference", () => { const labels = labelerLabelsForGlob(".github/workflows/**") - expect(new Set(labels)).toEqual(new Set(["ci", "task"])) - expect(validateLabelPolicy({ itemType: "pull_request", labels: [...labels, "P3"] }).ok).toBe(true) + expect(new Set(labels)).toEqual(new Set(["ci"])) + expect(validateLabelPolicy({ itemType: "pull_request", labels: [...labels, "P3"] }).ok).toBe(false) }) test("pins pr-triage workflow contract: labeler, priority, and policy run serially", () => { @@ -100,7 +103,7 @@ describe("pr triage workflow", () => { expect(triageWorkflow).toContain("github.rest.issues.addLabels") expect(triageWorkflow).toContain("github.rest.issues.removeLabel") expect(triageWorkflow).not.toContain("github.rest.issues.setLabels") - expect(triageWorkflow).toContain("planPriorityLabels") + expect(triageWorkflow).toContain("planPullRequestLabels") expect(triageWorkflow).toContain("validateLabelPolicy") expect(triageWorkflow).toContain("github.rest.pulls.listReviews") expect(triageWorkflow).toContain("pathToFileURL") @@ -181,6 +184,31 @@ describe("pr priority triage helper", () => { }) }) + test("plans release bump-only type labels without overriding author-selected types", () => { + expect(planPullRequestLabels(["packages/desktop-electron/package.json", "bun.lock"], ["platform"])).toEqual({ + suggestedPriority: "P2", + desiredPriority: "P2", + addLabels: ["P2", "task"], + removeLabels: [], + }) + + expect( + planPullRequestLabels(["packages/desktop-electron/package.json", "bun.lock"], ["enhancement", "platform", "P2"]), + ).toEqual({ + suggestedPriority: "P2", + desiredPriority: "P2", + addLabels: [], + removeLabels: [], + }) + + expect(planPullRequestLabels([".github/workflows/pr-triage.yml"], ["ci", "P3"])).toEqual({ + suggestedPriority: "P3", + desiredPriority: "P3", + addLabels: [], + removeLabels: [], + }) + }) + test("matches recent PR sanity cases", () => { expect( classifyPriority([ From 66435035bc5849b253e058c85c0f31e6e80c4aa8 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 28 May 2026 12:52:06 +0800 Subject: [PATCH 02/14] test: cover release bump label boundaries --- .github/scripts/pr-priority-triage.js | 4 +++- .../test/github/pr-triage-workflow.test.ts | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/scripts/pr-priority-triage.js b/.github/scripts/pr-priority-triage.js index b5b45fbcc..e75ef40f2 100644 --- a/.github/scripts/pr-priority-triage.js +++ b/.github/scripts/pr-priority-triage.js @@ -1,7 +1,9 @@ +import { POLICY } from "./label-policy-check.js" + export const TRIAGE_MARKER = "" export const PRIORITY_LABELS = ["P0", "P1", "P2", "P3"] -export const TYPE_LABELS = ["bug", "enhancement", "task", "documentation"] +export const TYPE_LABELS = POLICY.types const LOW_RISK_GLOBS = [ "docs/**", diff --git a/packages/opencode/test/github/pr-triage-workflow.test.ts b/packages/opencode/test/github/pr-triage-workflow.test.ts index 47c4190c6..a9c46de57 100644 --- a/packages/opencode/test/github/pr-triage-workflow.test.ts +++ b/packages/opencode/test/github/pr-triage-workflow.test.ts @@ -185,6 +185,13 @@ describe("pr priority triage helper", () => { }) test("plans release bump-only type labels without overriding author-selected types", () => { + expect(planPullRequestLabels(["packages/desktop-electron/package.json"], ["platform"])).toEqual({ + suggestedPriority: "P2", + desiredPriority: "P2", + addLabels: ["P2", "task"], + removeLabels: [], + }) + expect(planPullRequestLabels(["packages/desktop-electron/package.json", "bun.lock"], ["platform"])).toEqual({ suggestedPriority: "P2", desiredPriority: "P2", @@ -201,6 +208,18 @@ describe("pr priority triage helper", () => { removeLabels: [], }) + expect( + planPullRequestLabels(["packages/desktop-electron/package.json", "packages/desktop-electron/README.md"], [ + "platform", + "P2", + ]), + ).toEqual({ + suggestedPriority: "P2", + desiredPriority: "P2", + addLabels: [], + removeLabels: [], + }) + expect(planPullRequestLabels([".github/workflows/pr-triage.yml"], ["ci", "P3"])).toEqual({ suggestedPriority: "P3", desiredPriority: "P3", From 2ae90b438a6ca1b03128af8d96a15c7ded351633 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 28 May 2026 14:59:57 +0800 Subject: [PATCH 03/14] test: keep persist mock shape stable --- .../session/use-session-followups.test.ts | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/app/src/pages/session/use-session-followups.test.ts b/packages/app/src/pages/session/use-session-followups.test.ts index fd29c5d15..c7437ee4e 100644 --- a/packages/app/src/pages/session/use-session-followups.test.ts +++ b/packages/app/src/pages/session/use-session-followups.test.ts @@ -25,6 +25,31 @@ let followupDraftMatchesScope: typeof FollowupDraftMatchesScope const sendFollowupCalls: unknown[] = [] let sendFollowupDraftImpl: (input: unknown) => Promise +function workspaceStorage(dir: string) { + const head = (dir.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-") + let sum = 0 + for (let index = 0; index < dir.length; index++) { + sum = (sum + dir.charCodeAt(index) * (index + 1)) >>> 0 + } + return `pawwork.workspace.${head}.${sum.toString(36)}.dat` +} + +const PersistMock = { + global: (key: string, legacy?: string[]) => ({ storage: "pawwork.global.dat", key, legacy }), + workspace: (dir: string, key: string, legacy?: string[]) => ({ + storage: workspaceStorage(dir), + key: `workspace:${key}`, + legacy, + }), + session: (dir: string, session: string, key: string, legacy?: string[]) => ({ + storage: workspaceStorage(dir), + key: `session:${session}:${key}`, + legacy, + }), + scoped: (dir: string, session: string | undefined, key: string, legacy?: string[]) => + session ? PersistMock.session(dir, session, key, legacy) : PersistMock.workspace(dir, key, legacy), +} + const draft = (input: Pick): FollowupDraft => ({ sessionID: "ses_1", sessionDirectory: "/repo", @@ -47,8 +72,9 @@ beforeAll(async () => { usePlatform: () => ({ platform: "web" }), })) mock.module("@/utils/persist", () => ({ - Persist: { - global: (key: string, legacy?: string[]) => ({ key, legacy }), + Persist: PersistMock, + PersistTesting: { + workspaceStorage, }, persisted: (_target: unknown, store: unknown) => store, })) From 222b9b515a54b433245593ec28f5d01d7127f182 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 28 May 2026 15:04:26 +0800 Subject: [PATCH 04/14] test: preload router mock for app tests --- packages/app/happydom.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/app/happydom.ts b/packages/app/happydom.ts index de726718f..76e851ce4 100644 --- a/packages/app/happydom.ts +++ b/packages/app/happydom.ts @@ -1,7 +1,26 @@ import { GlobalRegistrator } from "@happy-dom/global-registrator" +import { mock } from "bun:test" GlobalRegistrator.register() +const passthrough = (props: { children?: unknown }) => props.children + +mock.module("@solidjs/router", () => ({ + A: passthrough, + HashRouter: passthrough, + MemoryRouter: passthrough, + Navigate: () => undefined, + Route: passthrough, + Router: passthrough, + StaticRouter: passthrough, + useHref: () => "", + useIsRouting: () => false, + useLocation: () => ({ hash: "", pathname: "/", query: {}, search: "", state: undefined }), + useNavigate: () => () => undefined, + useParams: () => ({}), + useSearchParams: () => [{}, () => undefined], +})) + const originalGetContext = HTMLCanvasElement.prototype.getContext // @ts-expect-error - we're overriding with a simplified mock HTMLCanvasElement.prototype.getContext = function (contextType: string, _options?: unknown) { From 68edc073420a2d30c37499a5ca5a0e2fab656e6f Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 28 May 2026 15:16:11 +0800 Subject: [PATCH 05/14] test: stabilize app submit mocks --- packages/app/happydom.ts | 19 ------ packages/app/src/components/prompt-input.tsx | 5 ++ .../draft-isolation.integration.test.ts | 2 +- .../prompt-input/submit-ownership.test.ts | 6 +- .../components/prompt-input/submit.test.ts | 58 +++++++++++++++++-- .../app/src/components/prompt-input/submit.ts | 18 +++--- .../session/session-action-readiness.test.ts | 5 +- .../session/use-session-followups.test.ts | 5 +- 8 files changed, 78 insertions(+), 40 deletions(-) diff --git a/packages/app/happydom.ts b/packages/app/happydom.ts index 76e851ce4..de726718f 100644 --- a/packages/app/happydom.ts +++ b/packages/app/happydom.ts @@ -1,26 +1,7 @@ import { GlobalRegistrator } from "@happy-dom/global-registrator" -import { mock } from "bun:test" GlobalRegistrator.register() -const passthrough = (props: { children?: unknown }) => props.children - -mock.module("@solidjs/router", () => ({ - A: passthrough, - HashRouter: passthrough, - MemoryRouter: passthrough, - Navigate: () => undefined, - Route: passthrough, - Router: passthrough, - StaticRouter: passthrough, - useHref: () => "", - useIsRouting: () => false, - useLocation: () => ({ hash: "", pathname: "/", query: {}, search: "", state: undefined }), - useNavigate: () => () => undefined, - useParams: () => ({}), - useSearchParams: () => [{}, () => undefined], -})) - const originalGetContext = HTMLCanvasElement.prototype.getContext // @ts-expect-error - we're overriding with a simplified mock HTMLCanvasElement.prototype.getContext = function (contextType: string, _options?: unknown) { diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index c972ff44e..f4fedc05b 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1,5 +1,6 @@ import { useSpring } from "@opencode-ai/ui/motion-spring" import { isWorkInFlightStatus } from "@opencode-ai/ui/util/session-status" +import { useNavigate, useParams } from "@solidjs/router" import { createEffect, on, Component, For, Show, createMemo, createSignal } from "solid-js" import { createStore } from "solid-js/store" import { useLocal } from "@/context/local" @@ -392,6 +393,8 @@ export const PromptInput: Component = (props) => { if (!id) return permission.isAutoAcceptingDirectory(sdk.directory) return permission.isAutoAccepting(id, sdk.directory) }) + const navigate = useNavigate() + const routeParams = useParams() const { abort, handleSubmit } = createPromptSubmit({ sessionID: activeSessionID, @@ -419,6 +422,8 @@ export const PromptInput: Component = (props) => { onQueue: props.onQueue, onAbort: props.onAbort, onSubmit: props.onSubmit, + navigate, + routeParams: () => routeParams, }) createEffect(() => { diff --git a/packages/app/src/components/prompt-input/draft-isolation.integration.test.ts b/packages/app/src/components/prompt-input/draft-isolation.integration.test.ts index 2eb081fd2..7151f91e4 100644 --- a/packages/app/src/components/prompt-input/draft-isolation.integration.test.ts +++ b/packages/app/src/components/prompt-input/draft-isolation.integration.test.ts @@ -15,7 +15,7 @@ import { createPinnedDraftOwner } from "./pinned-draft" import { buildRequestParts } from "./build-request-parts" import { captureCommentMentions } from "./mention-metadata" -let detectSubmitOwnership: typeof import("./submit").detectSubmitOwnership +let detectSubmitOwnership: any beforeAll(async () => { // submit.ts pulls in router/sdk/etc. at module scope; mock bare minimum. diff --git a/packages/app/src/components/prompt-input/submit-ownership.test.ts b/packages/app/src/components/prompt-input/submit-ownership.test.ts index d4026537e..2360140c7 100644 --- a/packages/app/src/components/prompt-input/submit-ownership.test.ts +++ b/packages/app/src/components/prompt-input/submit-ownership.test.ts @@ -2,10 +2,8 @@ import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" import { createPortableDraftOwner } from "./portable-draft" import { createPinnedDraftOwner } from "./pinned-draft" -// Submit module pulls in router/sdk/etc.; mock the bare minimum so a pure-function -// import does not blow up on solid-js server-side guards. -let detectSubmitOwnership: typeof import("./submit").detectSubmitOwnership -type SubmitOwnership = import("./submit").SubmitOwnership +let detectSubmitOwnership: any +type SubmitOwnership = any beforeAll(async () => { mock.module("@solidjs/router", () => ({ diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index dd0ecfb7a..c7ad38531 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -1,8 +1,16 @@ import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" -import type { Prompt } from "@/context/prompt" -let createPromptSubmit: typeof import("./submit").createPromptSubmit -let sendFollowupDraft: typeof import("./submit").sendFollowupDraft +type Prompt = Array +type PromptSubmitInputForTest = { + navigate?: (path: string) => void + routeParams?: () => { dir?: string; id?: string } + promptLength: (value: Prompt) => number + onQueue?: (draft: any) => void + [key: string]: any +} + +let createPromptSubmit: (input: PromptSubmitInputForTest) => any +let sendFollowupDraft: any const createdClients: string[] = [] const createdSessions: string[] = [] @@ -244,7 +252,7 @@ beforeAll(async () => { })) const mod = await import("./submit") - createPromptSubmit = mod.createPromptSubmit + createPromptSubmit = mod.createPromptSubmit as unknown as typeof createPromptSubmit sendFollowupDraft = mod.sendFollowupDraft }) @@ -281,6 +289,8 @@ describe("prompt submit worktree selection", () => { params = { id: "session-route" } const aborts: string[] = [] const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, sessionID: () => "session-visible", isNewSession: () => false, info: () => ({ id: "session-visible" }), @@ -313,6 +323,8 @@ describe("prompt submit worktree selection", () => { const aborts: string[] = [] const submits: string[] = [] const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, sessionID: () => "session-visible", isNewSession: () => false, info: () => ({ id: "session-visible" }), @@ -349,6 +361,8 @@ describe("prompt submit worktree selection", () => { commandDefinitions.push({ name: "summarize" }) promptValue = [{ type: "text", content: "/summarize this", start: 0, end: 15 }] const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, sessionID: () => "session-existing", isNewSession: () => false, info: () => ({ id: "session-existing" }), @@ -378,6 +392,8 @@ describe("prompt submit worktree selection", () => { commandsReady = false promptValue = [{ type: "text", content: "/bin/ls", start: 0, end: 7 }] const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, sessionID: () => "session-existing", isNewSession: () => false, info: () => ({ id: "session-existing" }), @@ -407,6 +423,8 @@ describe("prompt submit worktree selection", () => { const aborts: string[] = [] const submits: string[] = [] const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, sessionID: () => "session-visible", isNewSession: () => false, info: () => ({ id: "session-visible" }), @@ -440,6 +458,8 @@ describe("prompt submit worktree selection", () => { params = { id: "session-visible" } promptValue = [{ type: "text", content: "", start: 0, end: 0 }] const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, sessionID: () => "session-visible", isNewSession: () => false, info: () => ({ id: "session-visible" }), @@ -465,6 +485,8 @@ describe("prompt submit worktree selection", () => { test("reads the latest worktree accessor value per submit", async () => { const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, info: () => undefined, imageAttachments: () => [], commentCount: () => 0, @@ -502,6 +524,8 @@ describe("prompt submit worktree selection", () => { test("applies auto-accept to newly created sessions", async () => { const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, info: () => undefined, imageAttachments: () => [], commentCount: () => 0, @@ -532,6 +556,8 @@ describe("prompt submit worktree selection", () => { variant = "high" const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, info: () => ({ id: "session-1" }), imageAttachments: () => [], commentCount: () => 0, @@ -565,6 +591,8 @@ describe("prompt submit worktree selection", () => { params = { id: "session-route" } const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, sessionID: () => "session-visible", isNewSession: () => false, info: () => ({ id: "session-visible" }), @@ -592,6 +620,8 @@ describe("prompt submit worktree selection", () => { test("seeds new sessions before optimistic prompts are added", async () => { const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, info: () => undefined, imageAttachments: () => [], commentCount: () => 0, @@ -624,6 +654,8 @@ describe("prompt submit worktree selection", () => { promptValue = [{ type: "text", content: "run tests", start: 0, end: 9 }] promptAsyncFailure = new Error("send failed") const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, info: () => undefined, imageAttachments: () => [], commentCount: () => 0, @@ -653,6 +685,8 @@ describe("prompt submit worktree selection", () => { currentIntl = "pt-BR" const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, info: () => ({ id: "session-existing" }), imageAttachments: () => [], commentCount: () => 0, @@ -680,6 +714,8 @@ describe("prompt submit worktree selection", () => { const queued: Array> = [] const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, info: () => ({ id: "session-existing" }), imageAttachments: () => [], commentCount: () => 0, @@ -709,6 +745,8 @@ describe("prompt submit worktree selection", () => { promptValue = [{ type: "text", content: "/summarize this", start: 0, end: 15 }] const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, info: () => ({ id: "session-existing" }), imageAttachments: () => [], commentCount: () => 0, @@ -770,6 +808,8 @@ describe("prompt submit worktree selection", () => { } const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, info: () => undefined, imageAttachments: () => [], commentCount: () => 0, @@ -807,6 +847,8 @@ describe("Path D — marked TextPart routes through session.command", () => { }] const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, info: () => ({ id: "session-existing" }), imageAttachments: () => [], commentCount: () => 0, @@ -840,6 +882,8 @@ describe("Path D — marked TextPart routes through session.command", () => { ] const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, info: () => ({ id: "session-existing" }), imageAttachments: () => [], commentCount: () => 0, @@ -874,6 +918,8 @@ describe("Path D — marked TextPart routes through session.command", () => { }] const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, info: () => ({ id: "session-existing" }), imageAttachments: () => [], commentCount: () => 0, @@ -910,6 +956,8 @@ describe("Legacy fallback boundary (no marked TextPart)", () => { ] const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, info: () => ({ id: "session-existing" }), imageAttachments: () => [], commentCount: () => 0, @@ -941,6 +989,8 @@ describe("Legacy fallback boundary (no marked TextPart)", () => { ] const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, info: () => ({ id: "session-existing" }), imageAttachments: () => [], commentCount: () => 0, diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 23041d327..8a26d67ce 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -2,7 +2,6 @@ import type { Message, Session } from "@opencode-ai/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" import { base64Encode } from "@opencode-ai/util/encode" import { Binary } from "@opencode-ai/util/binary" -import { useNavigate, useParams } from "@solidjs/router" import { batch, type Accessor } from "solid-js" import type { FileSelection } from "@/context/file" import { useGlobalSync } from "@/context/global-sync" @@ -295,6 +294,8 @@ type PromptSubmitInput = { onQueue?: (draft: FollowupDraft) => void onAbort?: () => void onSubmit?: () => void + navigate?: (path: string) => void + routeParams?: Accessor<{ dir?: string; id?: string }> } type CommentItem = { @@ -308,7 +309,7 @@ type CommentItem = { } export function createPromptSubmit(input: PromptSubmitInput) { - const navigate = useNavigate() + const navigate = input.navigate ?? (() => undefined) const sdk = useSDK() const sync = useSync() const globalSync = useGlobalSync() @@ -317,10 +318,10 @@ export function createPromptSubmit(input: PromptSubmitInput) { const prompt = usePrompt() const layout = useLayout() const language = useLanguage() - const params = useParams() + const params: Accessor<{ dir?: string; id?: string }> = input.routeParams ?? (() => ({})) const portable = usePortableDraft() const pinned = usePinnedDraft() - const sessionID = input.sessionID ?? (() => params.id) + const sessionID = input.sessionID ?? (() => params().id) const isNewSession = input.isNewSession ?? (() => !sessionID()) const actionReady = input.actionReady ?? (() => true) const abortReady = input.abortReady ?? actionReady @@ -469,13 +470,14 @@ export function createPromptSubmit(input: PromptSubmitInput) { // observe an EMPTY new-session store and a "session route" verdict, which // breaks ownership detection (Bug 1) and the comment-restore path. const submittedContext = prompt.context.items().slice() - const submittedIsHomepage = !params.id + const routeParams = params() + const submittedIsHomepage = !routeParams.id const projectDirectory = sdk.directory // Capture the source scope before any await so navigate() cannot change params.id under us const sourcePromptScope: PromptRouteScope = { - dir: params.dir ?? base64Encode(projectDirectory), - id: params.id, + dir: routeParams.dir ?? base64Encode(projectDirectory), + id: routeParams.id, } // Capture submit ownership BEFORE any await. Reading pinned.current() and @@ -586,7 +588,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { } const promptScope = promptScopeForSession({ - routeDir: params.dir, + routeDir: params().dir, routeDirectory: projectDirectory, targetDirectory: sessionDirectory, sessionID: session.id, diff --git a/packages/app/src/pages/session/session-action-readiness.test.ts b/packages/app/src/pages/session/session-action-readiness.test.ts index 2ed4fc99a..00314e781 100644 --- a/packages/app/src/pages/session/session-action-readiness.test.ts +++ b/packages/app/src/pages/session/session-action-readiness.test.ts @@ -7,9 +7,10 @@ import { currentSessionSubmitReady, sessionStatusKnown, } from "./session-action-readiness" -import type { followupCommandText as FollowupCommandText, FollowupDraft } from "@/components/prompt-input/submit" -let followupCommandText: typeof FollowupCommandText +type FollowupDraft = any + +let followupCommandText: any const slashDraft = { text: "/release now" } const normalDraft = { text: "continue" } diff --git a/packages/app/src/pages/session/use-session-followups.test.ts b/packages/app/src/pages/session/use-session-followups.test.ts index c7437ee4e..5ef1a26d4 100644 --- a/packages/app/src/pages/session/use-session-followups.test.ts +++ b/packages/app/src/pages/session/use-session-followups.test.ts @@ -2,7 +2,6 @@ import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { createRoot, createSignal } from "solid-js" import { createStore } from "solid-js/store" -import type { FollowupDraft } from "@/components/prompt-input/submit" import type { canSendFollowupItem as CanSendFollowupItem, createSessionFollowups as CreateSessionFollowups, @@ -25,6 +24,8 @@ let followupDraftMatchesScope: typeof FollowupDraftMatchesScope const sendFollowupCalls: unknown[] = [] let sendFollowupDraftImpl: (input: unknown) => Promise +type FollowupDraft = any + function workspaceStorage(dir: string) { const head = (dir.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-") let sum = 0 @@ -85,7 +86,7 @@ beforeAll(async () => { })) mock.module("@/components/prompt-input/submit", () => ({ followupCommandText: (item: FollowupDraft) => - item.prompt.map((part) => ("content" in part ? part.content : "")).join(""), + item.prompt.map((part: any) => ("content" in part ? part.content : "")).join(""), sendFollowupDraft: (input: unknown) => sendFollowupDraftImpl(input), })) From f0e5b346c21b05b584cb080984e52989e5435dac Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 28 May 2026 15:22:14 +0800 Subject: [PATCH 06/14] test: isolate followup draft helper --- packages/app/src/components/prompt-input.tsx | 3 ++- .../components/prompt-input/followup-draft.ts | 16 +++++++++++++++ .../app/src/components/prompt-input/submit.ts | 20 ++----------------- .../composer/session-composer-region.tsx | 2 +- .../session/session-action-readiness.test.ts | 15 ++------------ .../session/use-session-followups.test.ts | 2 -- .../pages/session/use-session-followups.ts | 4 ++-- 7 files changed, 25 insertions(+), 37 deletions(-) create mode 100644 packages/app/src/components/prompt-input/followup-draft.ts diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index f4fedc05b..8a1990ce8 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -39,7 +39,8 @@ import { pickAttachments } from "./prompt-input/pick-attachments" import { ACCEPTED_FILE_TYPES } from "./prompt-input/files" import { promptLength } from "./prompt-input/history" import type { PromptStore } from "./prompt-input/store-types" -import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit" +import type { FollowupDraft } from "./prompt-input/followup-draft" +import { createPromptSubmit } from "./prompt-input/submit" import { PromptPopover } from "./prompt-input/slash-popover" import { PromptContextItems } from "./prompt-input/context-items" import { PromptImageAttachments } from "./prompt-input/image-attachments" diff --git a/packages/app/src/components/prompt-input/followup-draft.ts b/packages/app/src/components/prompt-input/followup-draft.ts new file mode 100644 index 000000000..deb87bb42 --- /dev/null +++ b/packages/app/src/components/prompt-input/followup-draft.ts @@ -0,0 +1,16 @@ +import type { ContextItem, Prompt } from "@/context/prompt" + +export type FollowupDraft = { + sessionID: string + sessionDirectory: string + prompt: Prompt + context: (ContextItem & { key: string })[] + agent: string + model: { providerID: string; modelID: string } + locale?: string + variant?: string +} + +export function followupCommandText(draft: FollowupDraft) { + return draft.prompt.map((part) => ("content" in part ? part.content : "")).join("") +} diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 8a26d67ce..c472c16ba 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -9,7 +9,7 @@ import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useLocal } from "@/context/local" import { usePermission } from "@/context/permission" -import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt" +import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt" import { emitRendererDiagnostic, sessionAbortDiagnosticEvent } from "@/context/renderer-diagnostics" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" @@ -26,6 +26,7 @@ import { type PromptRouteScope, promptScopeForSession } from "@/pages/session/pr import { type PortableDraftOwner, usePortableDraft } from "./portable-draft" import { type PinnedDraftOwner, usePinnedDraft } from "./pinned-draft" import type { ResolvedMention } from "./mention-metadata" +import { followupCommandText, type FollowupDraft } from "./followup-draft" /** * Submit ownership identifies which draft owner a given submit attempt operates on. @@ -75,17 +76,6 @@ type PendingPrompt = { const pending = new Map() type AbortSource = Extract -export type FollowupDraft = { - sessionID: string - sessionDirectory: string - prompt: Prompt - context: (ContextItem & { key: string })[] - agent: string - model: { providerID: string; modelID: string } - locale?: string - variant?: string -} - type FollowupSendInput = { client: ReturnType["client"] globalSync: ReturnType @@ -96,12 +86,6 @@ type FollowupSendInput = { before?: () => Promise | boolean } -const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("") - -export function followupCommandText(draft: FollowupDraft) { - return draftText(draft.prompt) -} - const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image") export async function sendFollowupDraft(input: FollowupSendInput) { diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index aa7a01d9f..e49943400 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -12,7 +12,7 @@ import { SessionQuestionDock } from "@/pages/session/composer/session-question-d import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock" import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock" import type { SessionComposerState } from "@/pages/session/composer/session-composer-state" -import type { FollowupDraft } from "@/components/prompt-input/submit" +import type { FollowupDraft } from "@/components/prompt-input/followup-draft" export function SessionComposerRegion(props: { state: SessionComposerState diff --git a/packages/app/src/pages/session/session-action-readiness.test.ts b/packages/app/src/pages/session/session-action-readiness.test.ts index 00314e781..bc34e0ea6 100644 --- a/packages/app/src/pages/session/session-action-readiness.test.ts +++ b/packages/app/src/pages/session/session-action-readiness.test.ts @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, mock, test } from "bun:test" +import { describe, expect, test } from "bun:test" import { canSendFollowupDraft, canSubmitPrompt, @@ -7,11 +7,10 @@ import { currentSessionSubmitReady, sessionStatusKnown, } from "./session-action-readiness" +import { followupCommandText } from "@/components/prompt-input/followup-draft" type FollowupDraft = any -let followupCommandText: any - const slashDraft = { text: "/release now" } const normalDraft = { text: "continue" } const leadingWhitespaceSlashDraft = { text: " /release now" } @@ -26,16 +25,6 @@ const queuedDraft = (input: { prompt: FollowupDraft["prompt"] }): FollowupDraft }) describe("session action readiness", () => { - beforeAll(async () => { - mock.module("@solidjs/router", () => ({ - useNavigate: () => () => undefined, - useParams: () => ({}), - })) - - const mod = await import("@/components/prompt-input/submit") - followupCommandText = mod.followupCommandText - }) - test("direct slash submit waits for command hydration", () => { expect(canSubmitPrompt({ mode: "normal", text: "/release", submitReady: true, commandsReady: false })).toBe(false) expect(canSubmitPrompt({ mode: "normal", text: " /release", submitReady: true, commandsReady: false })).toBe(true) diff --git a/packages/app/src/pages/session/use-session-followups.test.ts b/packages/app/src/pages/session/use-session-followups.test.ts index 5ef1a26d4..e98160f91 100644 --- a/packages/app/src/pages/session/use-session-followups.test.ts +++ b/packages/app/src/pages/session/use-session-followups.test.ts @@ -85,8 +85,6 @@ beforeAll(async () => { }, })) mock.module("@/components/prompt-input/submit", () => ({ - followupCommandText: (item: FollowupDraft) => - item.prompt.map((part: any) => ("content" in part ? part.content : "")).join(""), sendFollowupDraft: (input: unknown) => sendFollowupDraftImpl(input), })) diff --git a/packages/app/src/pages/session/use-session-followups.ts b/packages/app/src/pages/session/use-session-followups.ts index e3eddeb1c..8c20d1431 100644 --- a/packages/app/src/pages/session/use-session-followups.ts +++ b/packages/app/src/pages/session/use-session-followups.ts @@ -1,8 +1,8 @@ import { createEffect, createMemo, createSignal } from "solid-js" import { createStore } from "solid-js/store" import { useMutation } from "@tanstack/solid-query" -import type { FollowupDraft } from "@/components/prompt-input/submit" -import { followupCommandText, sendFollowupDraft } from "@/components/prompt-input/submit" +import { followupCommandText, type FollowupDraft } from "@/components/prompt-input/followup-draft" +import { sendFollowupDraft } from "@/components/prompt-input/submit" import type { useGlobalSync } from "@/context/global-sync" import type { useSDK } from "@/context/sdk" import type { useSettings } from "@/context/settings" From e7cd09e70d9c4c9763bdcb222c966415b43dc062 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 28 May 2026 15:25:25 +0800 Subject: [PATCH 07/14] test: complete followup persist mock --- packages/app/src/pages/session/use-session-followups.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/app/src/pages/session/use-session-followups.test.ts b/packages/app/src/pages/session/use-session-followups.test.ts index e98160f91..4cab13b78 100644 --- a/packages/app/src/pages/session/use-session-followups.test.ts +++ b/packages/app/src/pages/session/use-session-followups.test.ts @@ -2,6 +2,7 @@ import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { createRoot, createSignal } from "solid-js" import { createStore } from "solid-js/store" +import { normalize, readPersistedAsync, readPersistedSync } from "@/utils/persist-read" import type { canSendFollowupItem as CanSendFollowupItem, createSessionFollowups as CreateSessionFollowups, @@ -75,6 +76,9 @@ beforeAll(async () => { mock.module("@/utils/persist", () => ({ Persist: PersistMock, PersistTesting: { + normalize, + readPersistedAsync, + readPersistedSync, workspaceStorage, }, persisted: (_target: unknown, store: unknown) => store, From 08de1e847a306ec4863c8049135e15b37927aacf Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 28 May 2026 15:29:41 +0800 Subject: [PATCH 08/14] test: inject followup send helper --- .../app/src/pages/session/use-session-followups.test.ts | 6 ++---- packages/app/src/pages/session/use-session-followups.ts | 4 +++- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/app/src/pages/session/use-session-followups.test.ts b/packages/app/src/pages/session/use-session-followups.test.ts index 4cab13b78..b7aa8d7c4 100644 --- a/packages/app/src/pages/session/use-session-followups.test.ts +++ b/packages/app/src/pages/session/use-session-followups.test.ts @@ -88,10 +88,6 @@ beforeAll(async () => { ascending: (prefix: string) => `${prefix}_queued`, }, })) - mock.module("@/components/prompt-input/submit", () => ({ - sendFollowupDraft: (input: unknown) => sendFollowupDraftImpl(input), - })) - const mod = await import("./use-session-followups") followupPreviewText = mod.followupPreviewText shouldAutoSendFollowup = mod.shouldAutoSendFollowup @@ -328,6 +324,7 @@ describe("session followups", () => { fail: () => undefined, resumeScroll: () => undefined, attachmentLabel: () => "Attachment", + sendFollowup: ((input: unknown) => sendFollowupDraftImpl(input)) as never, }) return null }, @@ -404,6 +401,7 @@ describe("session followups", () => { fail: () => undefined, resumeScroll: () => undefined, attachmentLabel: () => "Attachment", + sendFollowup: ((input: unknown) => sendFollowupDraftImpl(input)) as never, }) return null }, diff --git a/packages/app/src/pages/session/use-session-followups.ts b/packages/app/src/pages/session/use-session-followups.ts index 8c20d1431..cb4e3cfcd 100644 --- a/packages/app/src/pages/session/use-session-followups.ts +++ b/packages/app/src/pages/session/use-session-followups.ts @@ -104,6 +104,7 @@ export function createSessionFollowups(input: { fail: (err: unknown) => void resumeScroll: () => void attachmentLabel: () => string + sendFollowup?: typeof sendFollowupDraft }) { const [followup, setFollowup] = persisted( Persist.global("session-followup.v2", ["followup.v2"]), @@ -145,6 +146,7 @@ export function createSessionFollowups(input: { } const [pendingFollowups, setPendingFollowups] = createSignal>({}) + const sendFollowupDraftForInput = input.sendFollowup ?? sendFollowupDraft const markFollowupPending = (key: string, id: string) => { setPendingFollowups((current) => ({ ...current, [key]: id })) } @@ -169,7 +171,7 @@ export function createSessionFollowups(input: { const directory = input.directory() const draft = followupDraftForDirectory(item, directory) - const ok = await sendFollowupDraft({ + const ok = await sendFollowupDraftForInput({ client: input.client(), sync: input.sync, globalSync: input.globalSync, From 366e928d50bc9edce364463872de4c530c5bd494 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 28 May 2026 15:31:49 +0800 Subject: [PATCH 09/14] ci: accept versioned Electron framework --- .../scripts/repair-electron-install.mjs | 21 +++++----- .../scripts/repair-electron-install.test.ts | 38 +++++++++++++++++++ 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/packages/desktop-electron/scripts/repair-electron-install.mjs b/packages/desktop-electron/scripts/repair-electron-install.mjs index 492f6bb73..3814019b6 100644 --- a/packages/desktop-electron/scripts/repair-electron-install.mjs +++ b/packages/desktop-electron/scripts/repair-electron-install.mjs @@ -28,17 +28,18 @@ export function isElectronInstallComplete(electronDir, platform = process.platfo if (!existsSync(binaryPath)) return false if (platform === "darwin" || platform === "mas") { - return existsSync( - join( - electronDir, - "dist", - "Electron.app", - "Contents", - "Frameworks", - "Electron Framework.framework", - "Electron Framework", - ), + const frameworkDir = join( + electronDir, + "dist", + "Electron.app", + "Contents", + "Frameworks", + "Electron Framework.framework", ) + return [ + join(frameworkDir, "Electron Framework"), + join(frameworkDir, "Versions", "A", "Electron Framework"), + ].some((candidate) => existsSync(candidate)) } return true diff --git a/packages/desktop-electron/scripts/repair-electron-install.test.ts b/packages/desktop-electron/scripts/repair-electron-install.test.ts index 3b8d9a207..a81b9e318 100644 --- a/packages/desktop-electron/scripts/repair-electron-install.test.ts +++ b/packages/desktop-electron/scripts/repair-electron-install.test.ts @@ -60,6 +60,44 @@ describe("repair Electron install", () => { expect(readFileSync(join(electronDir, "path.txt"), "utf8")).toBe(platformPath) }) + test("accepts the versioned macOS Electron framework binary path", () => { + const electronDir = mkdtempSync(join(tmpdir(), "pawwork-electron-install-")) + const platformPath = platformPathForElectron("darwin") + mkdirSync(join(electronDir, "dist", "Electron.app", "Contents", "MacOS"), { recursive: true }) + mkdirSync( + join( + electronDir, + "dist", + "Electron.app", + "Contents", + "Frameworks", + "Electron Framework.framework", + "Versions", + "A", + ), + { recursive: true }, + ) + writeFileSync(join(electronDir, "dist", platformPath), "") + writeFileSync( + join( + electronDir, + "dist", + "Electron.app", + "Contents", + "Frameworks", + "Electron Framework.framework", + "Versions", + "A", + "Electron Framework", + ), + "", + ) + + expect(isElectronInstallComplete(electronDir, "darwin")).toBe(true) + expect(writeElectronPathFileIfInstallComplete(electronDir, "darwin")).toBe(true) + expect(readFileSync(join(electronDir, "path.txt"), "utf8")).toBe(platformPath) + }) + test("clears an incomplete Electron dist before reinstalling", () => { const electronDir = mkdtempSync(join(tmpdir(), "pawwork-electron-install-")) const platformPath = platformPathForElectron("darwin") From 4642cc09ae7d8728720cca7b731e3490c7d4800b Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 28 May 2026 15:35:09 +0800 Subject: [PATCH 10/14] ci: pin Electron repair platform --- .../desktop-electron/scripts/repair-electron-install.mjs | 8 +++++--- .../scripts/repair-electron-install.test.ts | 8 ++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/desktop-electron/scripts/repair-electron-install.mjs b/packages/desktop-electron/scripts/repair-electron-install.mjs index 3814019b6..2c086fe1d 100644 --- a/packages/desktop-electron/scripts/repair-electron-install.mjs +++ b/packages/desktop-electron/scripts/repair-electron-install.mjs @@ -57,9 +57,11 @@ function resetElectronInstall(electronDir) { rmSync(join(electronDir, "dist"), { recursive: true, force: true }) } -export function electronInstallEnv({ forceNoCache = false } = {}) { +export function electronInstallEnv({ forceNoCache = false, platform = process.platform, arch = process.arch } = {}) { const env = { ...process.env } delete env.ELECTRON_SKIP_BINARY_DOWNLOAD + env.npm_config_platform = platform + env.npm_config_arch = arch if (forceNoCache) { env.force_no_cache = "true" @@ -70,14 +72,14 @@ export function electronInstallEnv({ forceNoCache = false } = {}) { export function repairElectronInstallAt( electronDir, - { installScript = join(electronDir, "install.js"), platform = process.platform, runInstall } = {}, + { installScript = join(electronDir, "install.js"), platform = process.platform, arch = process.arch, runInstall } = {}, ) { const install = runInstall ?? ((script, options = {}) => { execFileSync(process.execPath, [script], { stdio: "inherit", - env: electronInstallEnv(options), + env: electronInstallEnv({ ...options, platform, arch }), }) }) diff --git a/packages/desktop-electron/scripts/repair-electron-install.test.ts b/packages/desktop-electron/scripts/repair-electron-install.test.ts index a81b9e318..9cbe2a031 100644 --- a/packages/desktop-electron/scripts/repair-electron-install.test.ts +++ b/packages/desktop-electron/scripts/repair-electron-install.test.ts @@ -32,6 +32,13 @@ describe("repair Electron install", () => { } }) + test("pins install platform and arch to the repair target", () => { + const env = electronInstallEnv({ platform: "darwin", arch: "arm64" }) + + expect(env.npm_config_platform).toBe("darwin") + expect(env.npm_config_arch).toBe("arm64") + }) + test("does not treat a macOS install as complete when the framework is missing", () => { const electronDir = mkdtempSync(join(tmpdir(), "pawwork-electron-install-")) const platformPath = platformPathForElectron("darwin") @@ -107,6 +114,7 @@ describe("repair Electron install", () => { repairElectronInstallAt(electronDir, { platform: "darwin", + arch: "arm64", runInstall() { expect(existsSync(join(electronDir, "dist"))).toBe(false) mkdirSync(join(electronDir, "dist", "Electron.app", "Contents", "MacOS"), { recursive: true }) From 249ece9b199f3f33d9fcb42cdf9d00564ba05948 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 28 May 2026 15:45:02 +0800 Subject: [PATCH 11/14] ci: repair Electron from direct artifact --- .../scripts/repair-electron-install.mjs | 114 +++++++++++++++++- .../scripts/repair-electron-install.test.ts | 47 ++++++++ 2 files changed, 158 insertions(+), 3 deletions(-) diff --git a/packages/desktop-electron/scripts/repair-electron-install.mjs b/packages/desktop-electron/scripts/repair-electron-install.mjs index 2c086fe1d..ef0e251be 100644 --- a/packages/desktop-electron/scripts/repair-electron-install.mjs +++ b/packages/desktop-electron/scripts/repair-electron-install.mjs @@ -1,8 +1,12 @@ import { execFileSync } from "node:child_process" -import { existsSync, rmSync, writeFileSync } from "node:fs" +import { createHash } from "node:crypto" +import { createWriteStream, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { get } from "node:https" import { createRequire } from "node:module" +import { tmpdir } from "node:os" import { join } from "node:path" import process from "node:process" +import { fileURLToPath } from "node:url" const require = createRequire(import.meta.url) @@ -57,6 +61,94 @@ function resetElectronInstall(electronDir) { rmSync(join(electronDir, "dist"), { recursive: true, force: true }) } +function electronArtifactName({ version, platform, arch }) { + return `electron-v${version}-${platform}-${arch}.zip` +} + +function electronArtifactUrl({ version, platform, arch }) { + return `https://github.com/electron/electron/releases/download/v${version}/${electronArtifactName({ + version, + platform, + arch, + })}` +} + +function downloadFile(url, destination, redirects = 0) { + return new Promise((resolve, reject) => { + const request = get(url, (response) => { + const location = response.headers.location + if ( + response.statusCode !== undefined && + response.statusCode >= 300 && + response.statusCode < 400 && + location !== undefined + ) { + response.resume() + if (redirects >= 5) { + reject(new Error(`Too many redirects while downloading Electron artifact: ${url}`)) + return + } + downloadFile(new URL(location, url).toString(), destination, redirects + 1).then(resolve, reject) + return + } + + if (response.statusCode !== 200) { + response.resume() + reject(new Error(`Failed to download Electron artifact (${response.statusCode}): ${url}`)) + return + } + + const file = createWriteStream(destination) + response.pipe(file) + file.on("finish", () => file.close(resolve)) + file.on("error", reject) + }) + + request.on("error", reject) + }) +} + +function verifyElectronZipChecksum(electronDir, zipPath, fileName) { + const checksums = JSON.parse(readFileSync(join(electronDir, "checksums.json"), "utf8")) + const expected = checksums[fileName] + if (typeof expected !== "string") { + throw new Error(`Missing Electron checksum for artifact: ${fileName}`) + } + + const actual = createHash("sha256").update(readFileSync(zipPath)).digest("hex") + if (actual !== expected) { + throw new Error(`Electron checksum mismatch for ${fileName}: expected ${expected}, got ${actual}`) + } +} + +function extractElectronZip(electronDir, zipPath) { + const electronRequire = createRequire(join(electronDir, "install.js")) + const extract = electronRequire("extract-zip") + return extract(zipPath, { dir: join(electronDir, "dist") }) +} + +export async function downloadElectronArtifact({ + electronDir, + platform, + arch, + download = downloadFile, + extractZip = extractElectronZip, +}) { + const { version } = JSON.parse(readFileSync(join(electronDir, "package.json"), "utf8")) + const fileName = electronArtifactName({ version, platform, arch }) + const scratchDir = mkdtempSync(join(tmpdir(), "pawwork-electron-artifact-")) + const zipPath = join(scratchDir, fileName) + + try { + await download(electronArtifactUrl({ version, platform, arch }), zipPath) + verifyElectronZipChecksum(electronDir, zipPath, fileName) + resetElectronInstall(electronDir) + await extractZip(electronDir, zipPath) + } finally { + rmSync(scratchDir, { recursive: true, force: true }) + } +} + export function electronInstallEnv({ forceNoCache = false, platform = process.platform, arch = process.arch } = {}) { const env = { ...process.env } delete env.ELECTRON_SKIP_BINARY_DOWNLOAD @@ -72,7 +164,12 @@ export function electronInstallEnv({ forceNoCache = false, platform = process.pl export function repairElectronInstallAt( electronDir, - { installScript = join(electronDir, "install.js"), platform = process.platform, arch = process.arch, runInstall } = {}, + { + installScript = join(electronDir, "install.js"), + platform = process.platform, + arch = process.arch, + runInstall, + } = {}, ) { const install = runInstall ?? @@ -97,6 +194,12 @@ export function repairElectronInstallAt( install(installScript, { forceNoCache: true }) } + if (!writeElectronPathFileIfInstallComplete(electronDir, platform)) { + execFileSync(process.execPath, [fileURLToPath(import.meta.url), "--download-artifact", electronDir, platform, arch], { + stdio: "inherit", + }) + } + if (!writeElectronPathFileIfInstallComplete(electronDir, platform)) { throw new Error(`Electron install is still incomplete after repair: ${electronDir}`) } @@ -110,5 +213,10 @@ export function repairElectronInstall() { } if (import.meta.url === `file://${process.argv[1]}`) { - repairElectronInstall() + if (process.argv[2] === "--download-artifact") { + const [, , , electronDir, platform, arch] = process.argv + await downloadElectronArtifact({ electronDir, platform, arch }) + } else { + repairElectronInstall() + } } diff --git a/packages/desktop-electron/scripts/repair-electron-install.test.ts b/packages/desktop-electron/scripts/repair-electron-install.test.ts index 9cbe2a031..d0ce8a4df 100644 --- a/packages/desktop-electron/scripts/repair-electron-install.test.ts +++ b/packages/desktop-electron/scripts/repair-electron-install.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from "bun:test" +import { createHash } from "node:crypto" import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" import { + downloadElectronArtifact, electronInstallEnv, isElectronInstallComplete, platformPathForElectron, @@ -211,4 +213,49 @@ describe("repair Electron install", () => { expect(attempts).toEqual([false, true]) expect(isElectronInstallComplete(electronDir, "darwin")).toBe(true) }) + + test("can repair from a directly downloaded Electron artifact", async () => { + const electronDir = mkdtempSync(join(tmpdir(), "pawwork-electron-install-")) + const zipContents = "electron zip fixture" + const platformPath = platformPathForElectron("darwin") + + mkdirSync(join(electronDir, "dist", "stale"), { recursive: true }) + writeFileSync(join(electronDir, "package.json"), JSON.stringify({ version: "40.8.0" })) + const checksum = createHash("sha256").update(zipContents).digest("hex") + writeFileSync( + join(electronDir, "checksums.json"), + JSON.stringify({ "electron-v40.8.0-darwin-arm64.zip": checksum }), + ) + + await downloadElectronArtifact({ + electronDir, + platform: "darwin", + arch: "arm64", + async download(_url, destination) { + await Bun.write(destination, zipContents) + }, + async extractZip(targetElectronDir) { + mkdirSync(join(targetElectronDir, "dist", "Electron.app", "Contents", "MacOS"), { recursive: true }) + mkdirSync(join(targetElectronDir, "dist", "Electron.app", "Contents", "Frameworks", "Electron Framework.framework"), { + recursive: true, + }) + writeFileSync(join(targetElectronDir, "dist", platformPath), "") + writeFileSync( + join( + targetElectronDir, + "dist", + "Electron.app", + "Contents", + "Frameworks", + "Electron Framework.framework", + "Electron Framework", + ), + "", + ) + }, + }) + + expect(existsSync(join(electronDir, "dist", "stale"))).toBe(false) + expect(isElectronInstallComplete(electronDir, "darwin")).toBe(true) + }) }) From d28fd999847e9f4b3447902048523f1c230e2968 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 28 May 2026 15:52:09 +0800 Subject: [PATCH 12/14] ci: rerun pr triage on label changes --- .github/workflows/pr-triage.yml | 2 +- packages/opencode/test/github/pr-triage-workflow.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-triage.yml b/.github/workflows/pr-triage.yml index e4e7b32e9..cc7d4a035 100644 --- a/.github/workflows/pr-triage.yml +++ b/.github/workflows/pr-triage.yml @@ -2,7 +2,7 @@ name: pr-triage on: pull_request_target: - types: [opened, synchronize, reopened] + types: [opened, synchronize, reopened, labeled, unlabeled] branches: [dev] concurrency: diff --git a/packages/opencode/test/github/pr-triage-workflow.test.ts b/packages/opencode/test/github/pr-triage-workflow.test.ts index a9c46de57..f320aa796 100644 --- a/packages/opencode/test/github/pr-triage-workflow.test.ts +++ b/packages/opencode/test/github/pr-triage-workflow.test.ts @@ -73,7 +73,7 @@ describe("pr triage workflow", () => { expect(triageParsed.name).toBe("pr-triage") expect(triageParsed.on?.pull_request_target).toEqual({ - types: ["opened", "synchronize", "reopened"], + types: ["opened", "synchronize", "reopened", "labeled", "unlabeled"], branches: ["dev"], }) expect(triageParsed.permissions).toEqual({ From e33056b3458da8bd31fda02736bc3c770f01457c Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 28 May 2026 15:52:13 +0800 Subject: [PATCH 13/14] ci: avoid async Electron artifact repair --- .../scripts/repair-electron-install.mjs | 60 +++---------------- .../scripts/repair-electron-install.test.ts | 8 +-- 2 files changed, 13 insertions(+), 55 deletions(-) diff --git a/packages/desktop-electron/scripts/repair-electron-install.mjs b/packages/desktop-electron/scripts/repair-electron-install.mjs index 5b86df6ae..26c5b7067 100644 --- a/packages/desktop-electron/scripts/repair-electron-install.mjs +++ b/packages/desktop-electron/scripts/repair-electron-install.mjs @@ -1,12 +1,10 @@ import { execFileSync } from "node:child_process" import { createHash } from "node:crypto" -import { createWriteStream, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" -import { get } from "node:https" +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { createRequire } from "node:module" import { tmpdir } from "node:os" import { join } from "node:path" import process from "node:process" -import { fileURLToPath } from "node:url" const require = createRequire(import.meta.url) @@ -73,39 +71,8 @@ function electronArtifactUrl({ version, platform, arch }) { })}` } -function downloadFile(url, destination, redirects = 0) { - return new Promise((resolve, reject) => { - const request = get(url, (response) => { - const location = response.headers.location - if ( - response.statusCode !== undefined && - response.statusCode >= 300 && - response.statusCode < 400 && - location !== undefined - ) { - response.resume() - if (redirects >= 5) { - reject(new Error(`Too many redirects while downloading Electron artifact: ${url}`)) - return - } - downloadFile(new URL(location, url).toString(), destination, redirects + 1).then(resolve, reject) - return - } - - if (response.statusCode !== 200) { - response.resume() - reject(new Error(`Failed to download Electron artifact (${response.statusCode}): ${url}`)) - return - } - - const file = createWriteStream(destination) - response.pipe(file) - file.on("finish", () => file.close(resolve)) - file.on("error", reject) - }) - - request.on("error", reject) - }) +function downloadFile(url, destination) { + execFileSync("curl", ["-fL", "--retry", "3", "--retry-delay", "2", "-o", destination, url], { stdio: "inherit" }) } function verifyElectronZipChecksum(electronDir, zipPath, fileName) { @@ -122,12 +89,10 @@ function verifyElectronZipChecksum(electronDir, zipPath, fileName) { } function extractElectronZip(electronDir, zipPath) { - const electronRequire = createRequire(join(electronDir, "install.js")) - const extract = electronRequire("extract-zip") - return extract(zipPath, { dir: join(electronDir, "dist") }) + execFileSync("unzip", ["-q", zipPath, "-d", join(electronDir, "dist")], { stdio: "inherit" }) } -export async function downloadElectronArtifact({ +export function downloadElectronArtifact({ electronDir, platform, arch, @@ -140,10 +105,10 @@ export async function downloadElectronArtifact({ const zipPath = join(scratchDir, fileName) try { - await download(electronArtifactUrl({ version, platform, arch }), zipPath) + download(electronArtifactUrl({ version, platform, arch }), zipPath) verifyElectronZipChecksum(electronDir, zipPath, fileName) resetElectronInstall(electronDir) - await extractZip(electronDir, zipPath) + extractZip(electronDir, zipPath) } finally { rmSync(scratchDir, { recursive: true, force: true }) } @@ -207,9 +172,7 @@ export function repairElectronInstallAt( } if (!writeElectronPathFileIfInstallComplete(electronDir, platform)) { - execFileSync(process.execPath, [fileURLToPath(import.meta.url), "--download-artifact", electronDir, platform, arch], { - stdio: "inherit", - }) + downloadElectronArtifact({ electronDir, platform, arch }) } if (!writeElectronPathFileIfInstallComplete(electronDir, platform)) { @@ -225,10 +188,5 @@ export function repairElectronInstall() { } if (import.meta.url === `file://${process.argv[1]}`) { - if (process.argv[2] === "--download-artifact") { - const [, , , electronDir, platform, arch] = process.argv - await downloadElectronArtifact({ electronDir, platform, arch }) - } else { - repairElectronInstall() - } + repairElectronInstall() } diff --git a/packages/desktop-electron/scripts/repair-electron-install.test.ts b/packages/desktop-electron/scripts/repair-electron-install.test.ts index 82c337fce..35618295e 100644 --- a/packages/desktop-electron/scripts/repair-electron-install.test.ts +++ b/packages/desktop-electron/scripts/repair-electron-install.test.ts @@ -264,14 +264,14 @@ describe("repair Electron install", () => { JSON.stringify({ "electron-v40.8.0-darwin-arm64.zip": checksum }), ) - await downloadElectronArtifact({ + downloadElectronArtifact({ electronDir, platform: "darwin", arch: "arm64", - async download(_url, destination) { - await Bun.write(destination, zipContents) + download(_url, destination) { + writeFileSync(destination, zipContents) }, - async extractZip(targetElectronDir) { + extractZip(targetElectronDir) { mkdirSync(join(targetElectronDir, "dist", "Electron.app", "Contents", "MacOS"), { recursive: true }) mkdirSync(join(targetElectronDir, "dist", "Electron.app", "Contents", "Frameworks", "Electron Framework.framework"), { recursive: true, From bda9620a544cf69754db07107dfc7b92018b15cb Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 28 May 2026 16:19:34 +0800 Subject: [PATCH 14/14] test: type prompt submit harness --- .../components/prompt-input/submit.test.ts | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index c7ad38531..b3ce2995f 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -1,16 +1,15 @@ import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" +import type { Prompt } from "@/context/prompt" +import type { + createPromptSubmit as createPromptSubmitType, + sendFollowupDraft as sendFollowupDraftType, +} from "./submit" -type Prompt = Array -type PromptSubmitInputForTest = { - navigate?: (path: string) => void - routeParams?: () => { dir?: string; id?: string } - promptLength: (value: Prompt) => number - onQueue?: (draft: any) => void - [key: string]: any -} +type PromptSubmitInput = Parameters[0] +type PromptSubmit = ReturnType -let createPromptSubmit: (input: PromptSubmitInputForTest) => any -let sendFollowupDraft: any +let createPromptSubmit: (input: PromptSubmitInput) => PromptSubmit +let sendFollowupDraft: typeof sendFollowupDraftType const createdClients: string[] = [] const createdSessions: string[] = [] @@ -174,7 +173,7 @@ beforeAll(async () => { directory: "/repo/main", client: rootClient, url: "http://localhost:4096", - createClient(opts: any) { + createClient(opts: { directory: string }) { return clientFor(opts.directory) }, } @@ -252,7 +251,7 @@ beforeAll(async () => { })) const mod = await import("./submit") - createPromptSubmit = mod.createPromptSubmit as unknown as typeof createPromptSubmit + createPromptSubmit = mod.createPromptSubmit sendFollowupDraft = mod.sendFollowupDraft })