Skip to content

Commit 0ab2ad8

Browse files
fix(web): an unanswered first-message launch keeps the next message in view
A launch that misses its deadline may already have created the thread and promoted the draft. Restoring the failed draft un-promoted it and showed the first message in place of the one typed after it. The launch deadline now fails with ThreadLaunchUnansweredError, and that failure leaves the draft promoted. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 11d4928 commit 0ab2ad8

3 files changed

Lines changed: 30 additions & 17 deletions

File tree

‎apps/web/src/components/chat/useComposerSend.ts‎

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,10 @@ import {
3232
ProviderInteractionMode,
3333
RuntimeMode,
3434
} from "@t3tools/contracts";
35-
import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations";
35+
import {
36+
deriveThreadTitleSeed,
37+
ThreadLaunchUnansweredError,
38+
} from "@t3tools/client-runtime/operations";
3639
import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors";
3740
import {
3841
declinePendingApprovals,
@@ -51,6 +54,7 @@ import {
5154
import { createModelSelection } from "@t3tools/shared/model";
5255
import { type SetStateAction, useEffect, useLayoutEffect, useRef, useState } from "react";
5356
import { flushSync } from "react-dom";
57+
import * as Schema from "effect/Schema";
5458
import { useNavigate } from "@tanstack/react-router";
5559
import {
5660
isAtomCommandInterrupted,
@@ -264,6 +268,8 @@ interface ComposerSendInput {
264268
readonly linkCheckoutPullRequest: (threadRef: ScopedThreadRef, url: string) => void;
265269
}
266270

271+
const isThreadLaunchUnanswered = Schema.is(ThreadLaunchUnansweredError);
272+
267273
const notifyQueuedSubmitNotSent = () =>
268274
toastManager.add(
269275
stackedThreadToast({
@@ -1419,14 +1425,16 @@ export function useComposerSend(input: ComposerSendInput) {
14191425
}
14201426

14211427
if (failure !== null) {
1428+
const failureError = squashAtomCommandFailure(failure);
1429+
// An unanswered launch may still create the thread, so the draft stays promoted to it.
14221430
if (isLocalDraftThread && draftId && draftThread) {
1423-
restoreFailedDraftThread(
1424-
draftId,
1425-
draftThread,
1426-
wasBootstrapThreadDeleted(squashAtomCommandFailure(failure))
1427-
? newThreadId()
1428-
: threadIdForSend,
1429-
);
1431+
if (!isThreadLaunchUnanswered(failureError)) {
1432+
restoreFailedDraftThread(
1433+
draftId,
1434+
draftThread,
1435+
wasBootstrapThreadDeleted(failureError) ? newThreadId() : threadIdForSend,
1436+
);
1437+
}
14301438
clearBackgroundDraftSubmissionByRef(scopeThreadRef(environmentId, threadIdForSend));
14311439
}
14321440
// Only an acknowledged send replaces its optimistic row, so a failed one leaves here.
@@ -1481,10 +1489,9 @@ export function useComposerSend(input: ComposerSendInput) {
14811489
}
14821490
}
14831491
if (!isAtomCommandInterrupted(failure)) {
1484-
const error = squashAtomCommandFailure(failure);
14851492
setThreadError(
14861493
threadIdForSend,
1487-
error instanceof Error ? error.message : "Failed to send message.",
1494+
failureError instanceof Error ? failureError.message : "Failed to send message.",
14881495
);
14891496
if (
14901497
draftId &&
@@ -1497,7 +1504,8 @@ export function useComposerSend(input: ComposerSendInput) {
14971504
submissionIntent === "background"
14981505
? "Background task failed"
14991506
: "Could not start thread",
1500-
description: error instanceof Error ? error.message : "Failed to send message.",
1507+
description:
1508+
failureError instanceof Error ? failureError.message : "Failed to send message.",
15011509
actionProps: {
15021510
children: "Open draft",
15031511
onClick: () => {

‎packages/client-runtime/src/operations/commands.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -505,7 +505,7 @@ describe("V2 environment commands", () => {
505505
expect(sendFiber.pollUnsafe()).toBeUndefined();
506506
yield* TestClock.adjust("1 second");
507507

508-
expect((yield* Fiber.join(sendFiber))._tag).toBe("EnvironmentRpcUnavailableError");
508+
expect((yield* Fiber.join(sendFiber))._tag).toBe("ThreadLaunchUnansweredError");
509509
// The server commits a launch in several steps, so the late request keeps running.
510510
expect(interrupted).toBe(false);
511511
}).pipe(Effect.provide(layerTestRandom)),

‎packages/client-runtime/src/operations/commands.ts‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import * as Effect from "effect/Effect";
3636
import * as Exit from "effect/Exit";
3737
import * as Fiber from "effect/Fiber";
3838
import * as Option from "effect/Option";
39+
import * as Schema from "effect/Schema";
3940
import * as Stream from "effect/Stream";
4041
import * as SubscriptionRef from "effect/SubscriptionRef";
4142

@@ -350,14 +351,18 @@ const dispatchMessage = Effect.fn("EnvironmentCommands.dispatchMessage")(functio
350351
}
351352
});
352353

354+
export class ThreadLaunchUnansweredError extends Schema.TaggedError<ThreadLaunchUnansweredError>()(
355+
"ThreadLaunchUnansweredError",
356+
{ message: Schema.String },
357+
) {}
358+
353359
// Interrupting a launch would cancel the server handler mid-launch, so the deadline bounds only
354-
// the wait and the launch keeps running; the error says the message may still arrive.
360+
// the wait and the launch keeps running; the server may still create the thread.
355361
const THREAD_LAUNCH_TIMEOUT = "2 minutes";
356362

357363
const launchThread = Effect.fn("EnvironmentCommands.launchThread")(function* (
358364
input: EnvironmentRpcInput<typeof ORCHESTRATION_V2_WS_METHODS.launchThread>,
359365
) {
360-
const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor;
361366
const answer = yield* Effect.forkDetach(
362367
request(ORCHESTRATION_V2_WS_METHODS.launchThread, input),
363368
{
@@ -369,9 +374,9 @@ const launchThread = Effect.fn("EnvironmentCommands.launchThread")(function* (
369374
duration: THREAD_LAUNCH_TIMEOUT,
370375
orElse: () =>
371376
Effect.fail(
372-
new EnvironmentRpcUnavailableError({
373-
environmentId: supervisor.target.environmentId,
374-
message: `${supervisor.target.label} did not confirm the message within 2 minutes. It may still arrive; check the thread before sending it again.`,
377+
new ThreadLaunchUnansweredError({
378+
message:
379+
"The server did not confirm this message. It may still arrive; check the thread before sending it again.",
375380
}),
376381
),
377382
}),

0 commit comments

Comments
 (0)