authorgravatar for jakeleventhal@me.comJake Leventhal <jakeleventhal@me.com> 2026-07-18 07:19:14-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-18 13:19:14+02:00
logd8b12ae801f2123e9be73d2f4139f8994e4d1d19
tree3c78c05698209f4b6bfdeba84ec171b292e23205
parent31caa5652b0d3d68660c914a85af5101c057f6a0
signature Signed by PGP key B5690EEEBB952194

Fix sending messages during active turns (#3919)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

3 files changed, 73 insertions(+), 1 deletions(-)

apps/web/src/components/ChatView.logic.test.ts+56-1
......@@ -1,4 +1,11 @@
1import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, TurnId } from "@t3tools/contracts";
1import {
2 EnvironmentId,
3 MessageId,
4 ProjectId,
5 ProviderInstanceId,
6 ThreadId,
7 TurnId,
8} from "@t3tools/contracts";
29import { describe, expect, it } from "vite-plus/test";
310
411import type { Thread } from "../types";
......@@ -359,6 +366,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
359366 localDispatch,
360367 phase: "ready",
361368 latestTurn: completedTurn,
369 latestUserMessageId: localDispatch.latestUserMessageId,
362370 session: readySession,
363371 hasPendingApproval: false,
364372 hasPendingUserInput: false,
......@@ -384,6 +392,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
384392 localDispatch,
385393 phase: "ready",
386394 latestTurn: newerTurn,
395 latestUserMessageId: localDispatch.latestUserMessageId,
387396 session: { ...readySession, updatedAt: newerTurn.completedAt },
388397 hasPendingApproval: false,
389398 hasPendingUserInput: false,
......@@ -410,6 +419,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
410419 localDispatch,
411420 phase: "running",
412421 latestTurn: runningTurn,
422 latestUserMessageId: localDispatch.latestUserMessageId,
413423 session: {
414424 ...readySession,
415425 status: "running",
......@@ -425,6 +435,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
425435 localDispatch,
426436 phase: "running",
427437 latestTurn: runningTurn,
438 latestUserMessageId: localDispatch.latestUserMessageId,
428439 session: {
429440 ...readySession,
430441 status: "running",
......@@ -437,12 +448,56 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
437448 ).toBe(true);
438449 });
439450
451 it("acknowledges a steering message projected onto the current running turn", () => {
452 const runningTurn = {
453 ...completedTurn,
454 state: "running" as const,
455 completedAt: null,
456 };
457 const runningSession = {
458 ...readySession,
459 status: "running" as const,
460 activeTurnId: runningTurn.turnId,
461 };
462 const localDispatch = createLocalDispatchSnapshot(
463 makeThread({
464 latestTurn: runningTurn,
465 session: runningSession,
466 messages: [
467 {
468 id: MessageId.make("message-before-steer"),
469 role: "user",
470 text: "Initial prompt",
471 turnId: runningTurn.turnId,
472 createdAt: runningTurn.requestedAt,
473 updatedAt: runningTurn.requestedAt,
474 streaming: false,
475 },
476 ],
477 }),
478 );
479
480 expect(
481 hasServerAcknowledgedLocalDispatch({
482 localDispatch,
483 phase: "running",
484 latestTurn: runningTurn,
485 latestUserMessageId: MessageId.make("message-steer"),
486 session: runningSession,
487 hasPendingApproval: false,
488 hasPendingUserInput: false,
489 threadError: null,
490 }),
491 ).toBe(true);
492 });
493
440494 it("acknowledges pending user interaction and errors immediately", () => {
441495 const localDispatch = createLocalDispatchSnapshot(makeThread());
442496 const common = {
443497 localDispatch,
444498 phase: "ready" as const,
445499 latestTurn: null,
500 latestUserMessageId: localDispatch.latestUserMessageId,
446501 session: null,
447502 hasPendingApproval: false,
448503 hasPendingUserInput: false,
apps/web/src/components/ChatView.logic.ts+13
......@@ -387,6 +387,7 @@ export async function waitForStartedServerThread(
387387export interface LocalDispatchSnapshot {
388388 startedAt: string;
389389 preparingWorktree: boolean;
390 latestUserMessageId: ChatMessage["id"] | null;
390391 latestTurnTurnId: TurnId | null;
391392 latestTurnRequestedAt: string | null;
392393 latestTurnStartedAt: string | null;
......@@ -401,9 +402,11 @@ export function createLocalDispatchSnapshot(
401402): LocalDispatchSnapshot {
402403 const latestTurn = activeThread?.latestTurn ?? null;
403404 const session = activeThread?.session ?? null;
405 const latestUserMessage = activeThread?.messages.findLast((message) => message.role === "user");
404406 return {
405407 startedAt: new Date().toISOString(),
406408 preparingWorktree: Boolean(options?.preparingWorktree),
409 latestUserMessageId: latestUserMessage?.id ?? null,
407410 latestTurnTurnId: latestTurn?.turnId ?? null,
408411 latestTurnRequestedAt: latestTurn?.requestedAt ?? null,
409412 latestTurnStartedAt: latestTurn?.startedAt ?? null,
......@@ -417,6 +420,7 @@ export function hasServerAcknowledgedLocalDispatch(input: {
417420 localDispatch: LocalDispatchSnapshot | null;
418421 phase: SessionPhase;
419422 latestTurn: Thread["latestTurn"] | null;
423 latestUserMessageId: ChatMessage["id"] | null;
420424 session: Thread["session"] | null;
421425 hasPendingApproval: boolean;
422426 hasPendingUserInput: boolean;
......@@ -431,6 +435,8 @@ export function hasServerAcknowledgedLocalDispatch(input: {
431435
432436 const latestTurn = input.latestTurn ?? null;
433437 const session = input.session ?? null;
438 const latestUserMessageChanged =
439 input.localDispatch.latestUserMessageId !== input.latestUserMessageId;
434440 const latestTurnChanged =
435441 input.localDispatch.latestTurnTurnId !== (latestTurn?.turnId ?? null) ||
436442 input.localDispatch.latestTurnRequestedAt !== (latestTurn?.requestedAt ?? null) ||
......@@ -438,6 +444,13 @@ export function hasServerAcknowledgedLocalDispatch(input: {
438444 input.localDispatch.latestTurnCompletedAt !== (latestTurn?.completedAt ?? null);
439445
440446 if (input.phase === "running") {
447 // Steering adds a user message to the current running turn without
448 // necessarily changing any of the turn timestamps. Treat that projected
449 // message as the server acknowledgment so the composer does not remain
450 // stuck in its local "Sending" state until the turn settles.
451 if (latestUserMessageChanged) {
452 return true;
453 }
441454 if (!latestTurnChanged) {
442455 return false;
443456 }
apps/web/src/components/ChatView.tsx+4
......@@ -452,6 +452,8 @@ function useLocalDispatchState(input: {
452452 threadError: string | null | undefined;
453453}) {
454454 const [localDispatch, setLocalDispatch] = useState<LocalDispatchSnapshot | null>(null);
455 const latestUserMessageId =
456 input.activeThread?.messages.findLast((message) => message.role === "user")?.id ?? null;
455457
456458 const resetLocalDispatch = useCallback(() => {
457459 setLocalDispatch(null);
......@@ -463,6 +465,7 @@ function useLocalDispatchState(input: {
463465 localDispatch,
464466 phase: input.phase,
465467 latestTurn: input.activeLatestTurn,
468 latestUserMessageId,
466469 session: input.activeThread?.session ?? null,
467470 hasPendingApproval: input.activePendingApproval !== null,
468471 hasPendingUserInput: input.activePendingUserInput !== null,
......@@ -475,6 +478,7 @@ function useLocalDispatchState(input: {
475478 input.activeThread?.session,
476479 input.phase,
477480 input.threadError,
481 latestUserMessageId,
478482 localDispatch,
479483 ],
480484 );