diff --git a/src/features/chat/application/threads/thread-management-actions.ts b/src/features/chat/application/threads/thread-management-actions.ts index 59c6694d..925add85 100644 --- a/src/features/chat/application/threads/thread-management-actions.ts +++ b/src/features/chat/application/threads/thread-management-actions.ts @@ -44,6 +44,7 @@ export interface ThreadManagementActions { interface ThreadManagementPanelScope { targetThreadId: string; initialActiveThreadId: string | null; + initialTurnLifecycle: ChatState["turn"]["lifecycle"]; } export function createThreadManagementActions(host: ThreadManagementActionsHost): ThreadManagementActions { @@ -240,11 +241,13 @@ function captureThreadManagementPanelScope(host: ThreadManagementActionsHost, ta return { targetThreadId, initialActiveThreadId: threadManagementState(host).activeThread.id, + initialTurnLifecycle: threadManagementState(host).turn.lifecycle, }; } function threadManagementScopeStillTargetsPanel(host: ThreadManagementActionsHost, scope: ThreadManagementPanelScope): boolean { - return threadManagementState(host).activeThread.id === scope.targetThreadId; + const state = threadManagementState(host); + return state.activeThread.id === scope.targetThreadId && state.turn.lifecycle === scope.initialTurnLifecycle; } function threadManagementScopeStillTargetsOriginalPanel(host: ThreadManagementActionsHost, scope: ThreadManagementPanelScope): boolean { diff --git a/src/features/chat/application/turns/turn-submission-actions.ts b/src/features/chat/application/turns/turn-submission-actions.ts index 32311589..5702079a 100644 --- a/src/features/chat/application/turns/turn-submission-actions.ts +++ b/src/features/chat/application/turns/turn-submission-actions.ts @@ -52,8 +52,17 @@ export interface TurnSubmissionRequest { } export function createTurnSubmissionActions(host: TurnSubmissionActionsHost): TurnSubmissionActions { + let submissionInFlight = false; return { - sendTurnText: (request) => sendTurnText(host, host.localItemIds, request), + sendTurnText: async (request) => { + if (submissionInFlight) return false; + submissionInFlight = true; + try { + return await sendTurnText(host, host.localItemIds, request); + } finally { + submissionInFlight = false; + } + }, }; } diff --git a/tests/features/chat/application/threads/thread-management-actions.test.ts b/tests/features/chat/application/threads/thread-management-actions.test.ts index 10f32efb..3ae375b3 100644 --- a/tests/features/chat/application/threads/thread-management-actions.test.ts +++ b/tests/features/chat/application/threads/thread-management-actions.test.ts @@ -450,6 +450,38 @@ describe("thread management actions", () => { expect(host.refreshAfterThreadMutation).not.toHaveBeenCalled(); }); + it("ignores rollback responses after a new turn starts in the same thread", async () => { + const rollback = deferred(); + const host = hostMock({ items: turnItems() }); + host.threadTransport.rollbackThread.mockReturnValue(rollback.promise); + host.stateStore.dispatch({ + type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, + thread: panelThread("source"), + cwd: "/vault", + model: null, + reasoningEffort: null, + serviceTier: null, + approvalsReviewer: null, + }); + host.stateStore.dispatch({ type: "thread-stream/items-replaced", items: turnItems(), historyCursor: null, loadingHistory: false }); + const controller = threadManagementActions(host); + + const pendingRollback = controller.rollbackThread("source"); + await waitForAsyncWork(() => expect(host.threadTransport.rollbackThread).toHaveBeenCalledOnce()); + host.stateStore.dispatch({ type: "turn/started", threadId: "source", turnId: "new-turn" }); + rollback.resolve(rollbackSnapshot()); + await pendingRollback; + + expect(host.stateStore.getState().turn.lifecycle).toEqual({ kind: "running", turnId: "new-turn" }); + expect(host.setComposerText).not.toHaveBeenCalled(); + }); + it("ignores rollback when the transport has no result", async () => { const host = hostMock({ items: turnItems(), diff --git a/tests/features/chat/application/turns/turn-submission-actions.test.ts b/tests/features/chat/application/turns/turn-submission-actions.test.ts index 4ebb3093..9967cd2c 100644 --- a/tests/features/chat/application/turns/turn-submission-actions.test.ts +++ b/tests/features/chat/application/turns/turn-submission-actions.test.ts @@ -10,6 +10,7 @@ import { createTurnSubmissionActions, type TurnSubmissionActionsHost, } from "../../../../../src/features/chat/application/turns/turn-submission-actions"; +import { deferred } from "../../../../support/async"; import { chatStateThreadStreamItems } from "../../support/thread-stream"; const textInput = (text: string): CodexInput => [{ type: "text", text }]; @@ -290,6 +291,23 @@ describe("TurnSubmissionActions", () => { expect(startTurn).not.toHaveBeenCalled(); }); + it("rejects a second submission while the first submission is still preparing", async () => { + const settings = deferred(); + const { host, startTurn, stateStore } = createHost({ applyPendingThreadSettings: vi.fn(() => settings.promise) }); + resumeThread(stateStore); + const actions = createTurnSubmissionActions(host); + + const first = actions.sendTurnText({ text: "first" }); + await Promise.resolve(); + const second = actions.sendTurnText({ text: "second" }); + settings.resolve(true); + + await expect(first).resolves.toBe(true); + await expect(second).resolves.toBe(false); + expect(startTurn).toHaveBeenCalledOnce(); + expect(startTurn).toHaveBeenCalledWith(expect.objectContaining({ input: textInput("first") })); + }); + it("keeps local user ids distinct when submissions share the same timestamp", async () => { const now = vi.spyOn(Date, "now").mockReturnValue(1234); try {