From 9361c84e3bab200e38e0fc198b2717215590d6bd Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 14:44:16 -0400 Subject: [PATCH 1/2] Fix sending messages during active turns --- .../web/src/components/ChatView.logic.test.ts | 57 ++++++++++++++++++- apps/web/src/components/ChatView.logic.ts | 13 +++++ apps/web/src/components/ChatView.tsx | 2 + 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 0a0103df183..bc7487cee29 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,4 +1,11 @@ -import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, TurnId } from "@t3tools/contracts"; +import { + EnvironmentId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import type { Thread } from "../types"; @@ -359,6 +366,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { localDispatch, phase: "ready", latestTurn: completedTurn, + latestUserMessageId: localDispatch.latestUserMessageId, session: readySession, hasPendingApproval: false, hasPendingUserInput: false, @@ -384,6 +392,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { localDispatch, phase: "ready", latestTurn: newerTurn, + latestUserMessageId: localDispatch.latestUserMessageId, session: { ...readySession, updatedAt: newerTurn.completedAt }, hasPendingApproval: false, hasPendingUserInput: false, @@ -410,6 +419,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { localDispatch, phase: "running", latestTurn: runningTurn, + latestUserMessageId: localDispatch.latestUserMessageId, session: { ...readySession, status: "running", @@ -425,6 +435,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { localDispatch, phase: "running", latestTurn: runningTurn, + latestUserMessageId: localDispatch.latestUserMessageId, session: { ...readySession, status: "running", @@ -437,12 +448,56 @@ describe("hasServerAcknowledgedLocalDispatch", () => { ).toBe(true); }); + it("acknowledges a steering message projected onto the current running turn", () => { + const runningTurn = { + ...completedTurn, + state: "running" as const, + completedAt: null, + }; + const runningSession = { + ...readySession, + status: "running" as const, + activeTurnId: runningTurn.turnId, + }; + const localDispatch = createLocalDispatchSnapshot( + makeThread({ + latestTurn: runningTurn, + session: runningSession, + messages: [ + { + id: MessageId.make("message-before-steer"), + role: "user", + text: "Initial prompt", + turnId: runningTurn.turnId, + createdAt: runningTurn.requestedAt, + updatedAt: runningTurn.requestedAt, + streaming: false, + }, + ], + }), + ); + + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: "running", + latestTurn: runningTurn, + latestUserMessageId: MessageId.make("message-steer"), + session: runningSession, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }), + ).toBe(true); + }); + it("acknowledges pending user interaction and errors immediately", () => { const localDispatch = createLocalDispatchSnapshot(makeThread()); const common = { localDispatch, phase: "ready" as const, latestTurn: null, + latestUserMessageId: localDispatch.latestUserMessageId, session: null, hasPendingApproval: false, hasPendingUserInput: false, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 705793ec77e..325f9afa90a 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -387,6 +387,7 @@ export async function waitForStartedServerThread( export interface LocalDispatchSnapshot { startedAt: string; preparingWorktree: boolean; + latestUserMessageId: ChatMessage["id"] | null; latestTurnTurnId: TurnId | null; latestTurnRequestedAt: string | null; latestTurnStartedAt: string | null; @@ -401,9 +402,11 @@ export function createLocalDispatchSnapshot( ): LocalDispatchSnapshot { const latestTurn = activeThread?.latestTurn ?? null; const session = activeThread?.session ?? null; + const latestUserMessage = activeThread?.messages.findLast((message) => message.role === "user"); return { startedAt: new Date().toISOString(), preparingWorktree: Boolean(options?.preparingWorktree), + latestUserMessageId: latestUserMessage?.id ?? null, latestTurnTurnId: latestTurn?.turnId ?? null, latestTurnRequestedAt: latestTurn?.requestedAt ?? null, latestTurnStartedAt: latestTurn?.startedAt ?? null, @@ -417,6 +420,7 @@ export function hasServerAcknowledgedLocalDispatch(input: { localDispatch: LocalDispatchSnapshot | null; phase: SessionPhase; latestTurn: Thread["latestTurn"] | null; + latestUserMessageId: ChatMessage["id"] | null; session: Thread["session"] | null; hasPendingApproval: boolean; hasPendingUserInput: boolean; @@ -431,6 +435,8 @@ export function hasServerAcknowledgedLocalDispatch(input: { const latestTurn = input.latestTurn ?? null; const session = input.session ?? null; + const latestUserMessageChanged = + input.localDispatch.latestUserMessageId !== input.latestUserMessageId; const latestTurnChanged = input.localDispatch.latestTurnTurnId !== (latestTurn?.turnId ?? null) || input.localDispatch.latestTurnRequestedAt !== (latestTurn?.requestedAt ?? null) || @@ -438,6 +444,13 @@ export function hasServerAcknowledgedLocalDispatch(input: { input.localDispatch.latestTurnCompletedAt !== (latestTurn?.completedAt ?? null); if (input.phase === "running") { + // Steering adds a user message to the current running turn without + // necessarily changing any of the turn timestamps. Treat that projected + // message as the server acknowledgment so the composer does not remain + // stuck in its local "Sending" state until the turn settles. + if (latestUserMessageChanged) { + return true; + } if (!latestTurnChanged) { return false; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f5ea5bb1eba..4f11b9ac646 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -379,6 +379,8 @@ function useLocalDispatchState(input: { localDispatch, phase: input.phase, latestTurn: input.activeLatestTurn, + latestUserMessageId: + input.activeThread?.messages.findLast((message) => message.role === "user")?.id ?? null, session: input.activeThread?.session ?? null, hasPendingApproval: input.activePendingApproval !== null, hasPendingUserInput: input.activePendingUserInput !== null, From e78403bb62fa853d37d1c32343b6b9a144d109e5 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 14:55:38 -0400 Subject: [PATCH 2/2] Fix dispatch acknowledgment memo dependencies --- apps/web/src/components/ChatView.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4f11b9ac646..a29752fbc97 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -368,6 +368,8 @@ function useLocalDispatchState(input: { threadError: string | null | undefined; }) { const [localDispatch, setLocalDispatch] = useState(null); + const latestUserMessageId = + input.activeThread?.messages.findLast((message) => message.role === "user")?.id ?? null; const resetLocalDispatch = useCallback(() => { setLocalDispatch(null); @@ -379,8 +381,7 @@ function useLocalDispatchState(input: { localDispatch, phase: input.phase, latestTurn: input.activeLatestTurn, - latestUserMessageId: - input.activeThread?.messages.findLast((message) => message.role === "user")?.id ?? null, + latestUserMessageId, session: input.activeThread?.session ?? null, hasPendingApproval: input.activePendingApproval !== null, hasPendingUserInput: input.activePendingUserInput !== null, @@ -393,6 +394,7 @@ function useLocalDispatchState(input: { input.activeThread?.session, input.phase, input.threadError, + latestUserMessageId, localDispatch, ], );