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 @@...@@ -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";
2import { describe, expect, it } from "vite-plus/test";9import { describe, expect, it } from "vite-plus/test";
310
4import type { Thread } from "../types";11import type { Thread } from "../types";
...@@ -359,6 +366,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {...@@ -359,6 +366,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
359 localDispatch,366 localDispatch,
360 phase: "ready",367 phase: "ready",
361 latestTurn: completedTurn,368 latestTurn: completedTurn,
369 latestUserMessageId: localDispatch.latestUserMessageId,
362 session: readySession,370 session: readySession,
363 hasPendingApproval: false,371 hasPendingApproval: false,
364 hasPendingUserInput: false,372 hasPendingUserInput: false,
...@@ -384,6 +392,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {...@@ -384,6 +392,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
384 localDispatch,392 localDispatch,
385 phase: "ready",393 phase: "ready",
386 latestTurn: newerTurn,394 latestTurn: newerTurn,
395 latestUserMessageId: localDispatch.latestUserMessageId,
387 session: { ...readySession, updatedAt: newerTurn.completedAt },396 session: { ...readySession, updatedAt: newerTurn.completedAt },
388 hasPendingApproval: false,397 hasPendingApproval: false,
389 hasPendingUserInput: false,398 hasPendingUserInput: false,
...@@ -410,6 +419,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {...@@ -410,6 +419,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
410 localDispatch,419 localDispatch,
411 phase: "running",420 phase: "running",
412 latestTurn: runningTurn,421 latestTurn: runningTurn,
422 latestUserMessageId: localDispatch.latestUserMessageId,
413 session: {423 session: {
414 ...readySession,424 ...readySession,
415 status: "running",425 status: "running",
...@@ -425,6 +435,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {...@@ -425,6 +435,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
425 localDispatch,435 localDispatch,
426 phase: "running",436 phase: "running",
427 latestTurn: runningTurn,437 latestTurn: runningTurn,
438 latestUserMessageId: localDispatch.latestUserMessageId,
428 session: {439 session: {
429 ...readySession,440 ...readySession,
430 status: "running",441 status: "running",
...@@ -437,12 +448,56 @@ describe("hasServerAcknowledgedLocalDispatch", () => {...@@ -437,12 +448,56 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
437 ).toBe(true);448 ).toBe(true);
438 });449 });
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
440 it("acknowledges pending user interaction and errors immediately", () => {494 it("acknowledges pending user interaction and errors immediately", () => {
441 const localDispatch = createLocalDispatchSnapshot(makeThread());495 const localDispatch = createLocalDispatchSnapshot(makeThread());
442 const common = {496 const common = {
443 localDispatch,497 localDispatch,
444 phase: "ready" as const,498 phase: "ready" as const,
445 latestTurn: null,499 latestTurn: null,
500 latestUserMessageId: localDispatch.latestUserMessageId,
446 session: null,501 session: null,
447 hasPendingApproval: false,502 hasPendingApproval: false,
448 hasPendingUserInput: false,503 hasPendingUserInput: false,
apps/web/src/components/ChatView.logic.ts+13
...@@ -387,6 +387,7 @@ export async function waitForStartedServerThread(...@@ -387,6 +387,7 @@ export async function waitForStartedServerThread(
387export interface LocalDispatchSnapshot {387export interface LocalDispatchSnapshot {
388 startedAt: string;388 startedAt: string;
389 preparingWorktree: boolean;389 preparingWorktree: boolean;
390 latestUserMessageId: ChatMessage["id"] | null;
390 latestTurnTurnId: TurnId | null;391 latestTurnTurnId: TurnId | null;
391 latestTurnRequestedAt: string | null;392 latestTurnRequestedAt: string | null;
392 latestTurnStartedAt: string | null;393 latestTurnStartedAt: string | null;
...@@ -401,9 +402,11 @@ export function createLocalDispatchSnapshot(...@@ -401,9 +402,11 @@ export function createLocalDispatchSnapshot(
401): LocalDispatchSnapshot {402): LocalDispatchSnapshot {
402 const latestTurn = activeThread?.latestTurn ?? null;403 const latestTurn = activeThread?.latestTurn ?? null;
403 const session = activeThread?.session ?? null;404 const session = activeThread?.session ?? null;
405 const latestUserMessage = activeThread?.messages.findLast((message) => message.role === "user");
404 return {406 return {
405 startedAt: new Date().toISOString(),407 startedAt: new Date().toISOString(),
406 preparingWorktree: Boolean(options?.preparingWorktree),408 preparingWorktree: Boolean(options?.preparingWorktree),
409 latestUserMessageId: latestUserMessage?.id ?? null,
407 latestTurnTurnId: latestTurn?.turnId ?? null,410 latestTurnTurnId: latestTurn?.turnId ?? null,
408 latestTurnRequestedAt: latestTurn?.requestedAt ?? null,411 latestTurnRequestedAt: latestTurn?.requestedAt ?? null,
409 latestTurnStartedAt: latestTurn?.startedAt ?? null,412 latestTurnStartedAt: latestTurn?.startedAt ?? null,
...@@ -417,6 +420,7 @@ export function hasServerAcknowledgedLocalDispatch(input: {...@@ -417,6 +420,7 @@ export function hasServerAcknowledgedLocalDispatch(input: {
417 localDispatch: LocalDispatchSnapshot | null;420 localDispatch: LocalDispatchSnapshot | null;
418 phase: SessionPhase;421 phase: SessionPhase;
419 latestTurn: Thread["latestTurn"] | null;422 latestTurn: Thread["latestTurn"] | null;
423 latestUserMessageId: ChatMessage["id"] | null;
420 session: Thread["session"] | null;424 session: Thread["session"] | null;
421 hasPendingApproval: boolean;425 hasPendingApproval: boolean;
422 hasPendingUserInput: boolean;426 hasPendingUserInput: boolean;
...@@ -431,6 +435,8 @@ export function hasServerAcknowledgedLocalDispatch(input: {...@@ -431,6 +435,8 @@ export function hasServerAcknowledgedLocalDispatch(input: {
431435
432 const latestTurn = input.latestTurn ?? null;436 const latestTurn = input.latestTurn ?? null;
433 const session = input.session ?? null;437 const session = input.session ?? null;
438 const latestUserMessageChanged =
439 input.localDispatch.latestUserMessageId !== input.latestUserMessageId;
434 const latestTurnChanged =440 const latestTurnChanged =
435 input.localDispatch.latestTurnTurnId !== (latestTurn?.turnId ?? null) ||441 input.localDispatch.latestTurnTurnId !== (latestTurn?.turnId ?? null) ||
436 input.localDispatch.latestTurnRequestedAt !== (latestTurn?.requestedAt ?? null) ||442 input.localDispatch.latestTurnRequestedAt !== (latestTurn?.requestedAt ?? null) ||
...@@ -438,6 +444,13 @@ export function hasServerAcknowledgedLocalDispatch(input: {...@@ -438,6 +444,13 @@ export function hasServerAcknowledgedLocalDispatch(input: {
438 input.localDispatch.latestTurnCompletedAt !== (latestTurn?.completedAt ?? null);444 input.localDispatch.latestTurnCompletedAt !== (latestTurn?.completedAt ?? null);
439445
440 if (input.phase === "running") {446 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 }
441 if (!latestTurnChanged) {454 if (!latestTurnChanged) {
442 return false;455 return false;
443 }456 }
apps/web/src/components/ChatView.tsx+4
...@@ -452,6 +452,8 @@ function useLocalDispatchState(input: {...@@ -452,6 +452,8 @@ function useLocalDispatchState(input: {
452 threadError: string | null | undefined;452 threadError: string | null | undefined;
453}) {453}) {
454 const [localDispatch, setLocalDispatch] = useState<LocalDispatchSnapshot | null>(null);454 const [localDispatch, setLocalDispatch] = useState<LocalDispatchSnapshot | null>(null);
455 const latestUserMessageId =
456 input.activeThread?.messages.findLast((message) => message.role === "user")?.id ?? null;
455457
456 const resetLocalDispatch = useCallback(() => {458 const resetLocalDispatch = useCallback(() => {
457 setLocalDispatch(null);459 setLocalDispatch(null);
...@@ -463,6 +465,7 @@ function useLocalDispatchState(input: {...@@ -463,6 +465,7 @@ function useLocalDispatchState(input: {
463 localDispatch,465 localDispatch,
464 phase: input.phase,466 phase: input.phase,
465 latestTurn: input.activeLatestTurn,467 latestTurn: input.activeLatestTurn,
468 latestUserMessageId,
466 session: input.activeThread?.session ?? null,469 session: input.activeThread?.session ?? null,
467 hasPendingApproval: input.activePendingApproval !== null,470 hasPendingApproval: input.activePendingApproval !== null,
468 hasPendingUserInput: input.activePendingUserInput !== null,471 hasPendingUserInput: input.activePendingUserInput !== null,
...@@ -475,6 +478,7 @@ function useLocalDispatchState(input: {...@@ -475,6 +478,7 @@ function useLocalDispatchState(input: {
475 input.activeThread?.session,478 input.activeThread?.session,
476 input.phase,479 input.phase,
477 input.threadError,480 input.threadError,
481 latestUserMessageId,
478 localDispatch,482 localDispatch,
479 ],483 ],
480 );484 );