Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions packages/app-bundle/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
},
Expand Down Expand Up @@ -1151,7 +1153,7 @@
}
},
"counts": {
"overlay_total": 1080,
"overlay_total": 1082,
"deletions": 7,
"server_coupled": 0
},
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void>((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*\}/)
})
})
Original file line number Diff line number Diff line change
@@ -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<string>()

/** 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<void>): Promise<boolean> {
if (!tryEnterNewSessionSubmit(key)) return false
try {
await run()
} finally {
releaseNewSessionSubmit(key)
}
return true
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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({
Expand Down
Loading