From 8577466adf9e18caafdc55a8aef96ecb1d7a1971 Mon Sep 17 00:00:00 2001 From: Jackson Turner Date: Sun, 8 Nov 2026 10:22:22 -0500 Subject: [PATCH] =?UTF-8?q?fix(app):=20one=20new-session=20submit=20per=20?= =?UTF-8?q?draft=20at=20a=20time=20=E2=80=94=20no=20duplicate=20sessions?= =?UTF-8?q?=20on=20a=20second=20Enter=20(#2157)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/app-bundle/manifest.json | 12 ++- .../prompt-input/new-session-gate.test.ts | 86 +++++++++++++++++++ .../prompt-input/new-session-gate.ts | 36 ++++++++ .../app/src/components/prompt-input/submit.ts | 13 +++ 4 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 packages/app-bundle/overlay/packages/app/src/components/prompt-input/new-session-gate.test.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/components/prompt-input/new-session-gate.ts diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 723500b31..f61f9eff2 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -778,12 +778,14 @@ "packages/app/src/components/prompt-input/harness.test.ts": "35739529b06829dd9fa169209c54ae668e0fc06d6968b00cfce638a221ce1361", "packages/app/src/components/prompt-input/harness.ts": "2a9406b14da9de68a172e61b6ddb4e49442a90f770b1b88669761f2ec201f9ab", "packages/app/src/components/prompt-input/image-attachments.tsx": "aa451ed6aa61a68ba0e8dacd8c45137f84c62566797a5242b75a89487ef0e9bf", + "packages/app/src/components/prompt-input/new-session-gate.test.ts": "b6fa61dacd53134a31238b9f9cf0bc55907286920a469e1f06a50c6642a4d677", + "packages/app/src/components/prompt-input/new-session-gate.ts": "ae49769be6c64f2cb242ccd25b9db0b818ac943a18313537bb8fba30e6408254", "packages/app/src/components/prompt-input/placeholder.test.ts": "d3cee5e2c758faf5fda626e270adad81129622bbc2bf2c335c2bffe1544165c3", "packages/app/src/components/prompt-input/placeholder.ts": "6b2af23a0588e7970cd0dd059fc60c3671c907a76ba3db660483a932f1dce79d", "packages/app/src/components/prompt-input/slash-popover.tsx": "a1800173edebcd419802796470f7270acaf0ef689a72f1b81a1cedfd8968337d", "packages/app/src/components/prompt-input/submit-concise.test.ts": "5d76bad06ced24bda9e5be84f6a01577145bcf2ad6dfa55d2941344da2a3d935", "packages/app/src/components/prompt-input/submit-stream-gap.test.ts": "351f0f5a8cf7aff55c859cb82fbca388ef848831256b80f74ee52ab9cf5d07c6", - "packages/app/src/components/prompt-input/submit.ts": "42c2177bc7f36355d65cd363e5ea2780d16c960991b5347a8ceadaccf11b8b65", + "packages/app/src/components/prompt-input/submit.ts": "abae05ffc6793faf2edc0e21d9e9f20e8c97edc332027426833216206c5ca564", "packages/app/src/components/server/server-row.tsx": "caf3179cf0b34b00fcab6ed57a324ff1f90f571f604216c49164dc54bd9f12ab", "packages/app/src/components/session/context-warning-banner.tsx": "c4ceb3f9589ebd21cc66e21cef209552fc5fcff6cd825da5069c2259c8d33e50", "packages/app/src/components/session/index.ts": "f0d58fa3a336639566ec51a10b692649707f91a7b780c0b5c4dcf18642d030e8", @@ -1102,10 +1104,10 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-11-07T23:24:13.583Z", + "extracted_at": "2026-11-08T15:21:50.674Z", "per_package": { "packages/app": { - "A": 312, + "A": 314, "M": 166, "D": 0 }, @@ -1151,7 +1153,7 @@ } }, "counts": { - "overlay_total": 1080, + "overlay_total": 1082, "deletions": 7, "server_coupled": 0 }, @@ -1926,6 +1928,8 @@ "packages/app/src/components/prompt-input/harness.test.ts": "A", "packages/app/src/components/prompt-input/harness.ts": "A", "packages/app/src/components/prompt-input/image-attachments.tsx": "M", + "packages/app/src/components/prompt-input/new-session-gate.test.ts": "A", + "packages/app/src/components/prompt-input/new-session-gate.ts": "A", "packages/app/src/components/prompt-input/placeholder.test.ts": "M", "packages/app/src/components/prompt-input/placeholder.ts": "M", "packages/app/src/components/prompt-input/slash-popover.tsx": "M", diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/new-session-gate.test.ts b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/new-session-gate.test.ts new file mode 100644 index 000000000..daea424f1 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/new-session-gate.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" +import { releaseNewSessionSubmit, tryEnterNewSessionSubmit, withNewSessionGate } from "./new-session-gate" + +// #2157: pressing Enter again while a new chat was still starting created a +// second session with the same first message. The gate admits one new-session +// submit per draft at a time. + +const deferred = () => { + let resolve!: () => void + let reject!: (err: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +describe("withNewSessionGate", () => { + test("a second submit from the same draft while the first is in flight does not run", async () => { + const creates: string[] = [] + const slowCreate = deferred() + const first = withNewSessionGate("draft-a", async () => { + creates.push("first") + await slowCreate.promise + }) + // the user presses Enter again (and again) while the create is slow + expect(await withNewSessionGate("draft-a", async () => void creates.push("second"))).toBe(false) + expect(await withNewSessionGate("draft-a", async () => void creates.push("third"))).toBe(false) + slowCreate.resolve() + expect(await first).toBe(true) + expect(creates).toEqual(["first"]) + }) + + test("after the first settles the draft can submit again", async () => { + await withNewSessionGate("draft-b", async () => {}) + let ran = false + expect(await withNewSessionGate("draft-b", async () => void (ran = true))).toBe(true) + expect(ran).toBe(true) + }) + + test("a failed create releases the gate, so the next submit retries", async () => { + await expect( + withNewSessionGate("draft-c", async () => { + throw new Error("create failed") + }), + ).rejects.toThrow("create failed") + let retried = false + expect(await withNewSessionGate("draft-c", async () => void (retried = true))).toBe(true) + expect(retried).toBe(true) + }) + + test("two different drafts can start sessions at the same time", async () => { + const a = deferred() + const first = withNewSessionGate("pane-1", () => a.promise) + let ran = false + expect(await withNewSessionGate("pane-2", async () => void (ran = true))).toBe(true) + expect(ran).toBe(true) + a.resolve() + expect(await first).toBe(true) + }) + + test("tryEnter/release pair directly", () => { + expect(tryEnterNewSessionSubmit("raw")).toBe(true) + expect(tryEnterNewSessionSubmit("raw")).toBe(false) + releaseNewSessionSubmit("raw") + expect(tryEnterNewSessionSubmit("raw")).toBe(true) + releaseNewSessionSubmit("raw") + }) +}) + +// Wiring pin (the controller needs the whole provider tree, so it is pinned by +// source): a draft submit goes through the gate, keyed by scope + directory + +// draft id; a submit in an existing session bypasses it. +describe("handleSubmit wiring", () => { + const submit = readFileSync(join(import.meta.dir, "submit.ts"), "utf8") + test("new-chat submits run under the gate; existing-session submits do not", () => { + expect(submit).toContain("if (params.id) return submitPrompt(event)") + expect(submit).toContain('ScopedKey.from(sdk().scope, sdk().directory, "new-session", search.draftId ?? "")') + expect(submit).toContain("await withNewSessionGate(key, () => submitPrompt(event))") + }) + test("handleSubmit is the only exported submit entry point", () => { + expect(submit).toMatch(/return \{\s*abort,\s*handleSubmit,\s*\}/) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/new-session-gate.ts b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/new-session-gate.ts new file mode 100644 index 000000000..18ede7f46 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/new-session-gate.ts @@ -0,0 +1,36 @@ +// #2157: the new-chat submit gate. Starting a new chat creates the session at +// submit, but the composer keeps its text until the create, the navigation +// transition and the first send have all finished — seconds on a busy engine. +// Without a gate, pressing Enter again in that window created a second session +// and sent it the same text (one duplicate tab per press). The gate admits one +// new-session submit per draft at a time; it is keyed by the draft so two +// different drafts (two panes) can still start sessions concurrently, and it +// is module-level so a composer remount mid-create cannot reset it. The +// caller supplies the key (submit.ts: ScopedKey of scope, directory, draft id). + +const inFlight = new Set() + +/** Admit a new-session submit from this draft, or refuse it while one is in + * flight. Every `true` must be paired with exactly one `releaseNewSessionSubmit`. */ +export function tryEnterNewSessionSubmit(key: string): boolean { + if (inFlight.has(key)) return false + inFlight.add(key) + return true +} + +export function releaseNewSessionSubmit(key: string): void { + inFlight.delete(key) +} + +/** Run one new-session submit under the gate: a concurrent call from the same + * draft resolves to `false` without running; the gate is released however + * `run` settles (sent, failed create, thrown error), so the next submit retries. */ +export async function withNewSessionGate(key: string, run: () => Promise): Promise { + if (!tryEnterNewSessionSubmit(key)) return false + try { + await run() + } finally { + releaseNewSessionSubmit(key) + } + return true +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit.ts b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit.ts index 8e0e00e60..130b41cd9 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit.ts @@ -4,6 +4,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { Binary } from "@opencode-ai/core/util/binary" import { useNavigate, useParams, useSearchParams } from "@solidjs/router" import { batch, startTransition, type Accessor } from "solid-js" +import { withNewSessionGate } from "./new-session-gate" import { useTabs } from "@/context/tabs" import { useServerSync, type ServerSync } from "@/context/server-sync" import { useServer } from "@/context/server" @@ -359,8 +360,20 @@ export function createPromptSubmit(input: PromptSubmitInput) { }) } + // #2157: a new chat admits one submit at a time per draft — the composer + // keeps its text until create + navigation + first send finish (seconds on a + // busy engine), and a second Enter in that window used to create a second + // session with the same first message. Follow-ups in an existing session + // are not gated (they queue as designed). const handleSubmit = async (event: Event) => { event.preventDefault() + if (params.id) return submitPrompt(event) + const key = ScopedKey.from(sdk().scope, sdk().directory, "new-session", search.draftId ?? "") + await withNewSessionGate(key, () => submitPrompt(event)) + } + + const submitPrompt = async (event: Event) => { + event.preventDefault() const target = prompt.capture() const submission = createPromptSubmissionState({