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