diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 6c5da0db20991..6bcc3146525fe 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -1,4 +1,4 @@ -import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors"; +import { isRetryableDeliveryFailure } from "@t3tools/client-runtime/errors"; import { clampFileAttachmentUploadBytes, fileAttachmentTooLargeMessage, @@ -241,34 +241,7 @@ export function isQueuedThreadCreationSendable(message: QueuedThreadMessage): bo return message.creation.workspaceMode !== "worktree" || Boolean(message.creation.branch); } -function errorMessage(error: unknown): string | null { - if (error instanceof Error) { - return error.message; - } - if (typeof error === "object" && error !== null && "message" in error) { - return typeof error.message === "string" ? error.message : null; - } - return typeof error === "string" ? error : null; -} - -/** Dispatch/auth rejections invalidate payloads; other typed failures may be disconnected transports and must retry. */ -export function shouldRetryThreadOutboxDelivery(error: unknown): boolean { - if (typeof error === "object" && error !== null && "_tag" in error) { - switch (error._tag) { - case "OrchestrationDispatchCommandError": - case "EnvironmentAuthorizationError": - return false; - case "ConnectionTransientError": - case "RpcClientError": - case "EnvironmentRpcUnavailableError": - case "EnvironmentNotRegisteredError": - return true; - default: - break; - } - } - return isTransportConnectionErrorMessage(errorMessage(error)); -} +export const shouldRetryThreadOutboxDelivery = isRetryableDeliveryFailure; export type ThreadOutboxCommandStage = "settings-sync" | "start-turn"; export type ThreadOutboxFailureAction = "retry" | "restore"; diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index bef8f7ab405f1..68a877f6b1ba9 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -93,7 +93,7 @@ import { } from "../lib/composerContextRecords"; import type { ComposerDispatchMode } from "@t3tools/client-runtime/state/composer-dispatch"; import type { DraftThreadEnvMode } from "../composerDraftStore"; -import { collapseExpandedComposerCursor, type ComposerSubmissionIntent } from "../composer-logic"; +import type { ComposerSubmissionIntent } from "../composer-logic"; import type { ReviewCommentContext } from "../reviewCommentContext"; import { derivePhase, type TimelineEntry } from "../session-logic"; import type { PreviewMiniPlayerSource } from "../previewMiniPlayerStore"; @@ -707,6 +707,12 @@ export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[ return previewUrls; } +export function revokeUserMessagePreviewUrls(message: ChatMessage): void { + for (const previewUrl of collectUserMessageBlobPreviewUrls(message)) { + URL.revokeObjectURL(previewUrl); + } +} + export { readFileAsDataUrl } from "../lib/imageCompression"; export { resolveSendEnvMode } from "@t3tools/shared/projectSettings"; @@ -1166,42 +1172,6 @@ export function shouldRefocusComposerOnWindowFocus( ); } -export interface PlanFollowUpComposerSnapshot { - readonly prompt: string; - readonly terminalContexts: ReadonlyArray; - readonly reviewComments: ReadonlyArray; - readonly previewAnnotations: ReadonlyArray; - readonly threadContexts: ReadonlyArray; - readonly environmentContexts: ReadonlyArray; -} - -export function restorePlanFollowUpComposer(input: { - readonly snapshot: PlanFollowUpComposerSnapshot; - readonly writePrompt: (prompt: string) => void; - readonly writeTerminalContexts: (contexts: ReadonlyArray) => void; - readonly writeReviewComments: (comments: ReadonlyArray) => void; - readonly writePreviewAnnotations: (annotations: ReadonlyArray) => void; - readonly writeThreadContexts: (records: ReadonlyArray) => void; - readonly writeEnvironmentContexts: (records: ReadonlyArray) => void; - readonly resetCursor: (options: { - cursor: number; - prompt: string; - detectTrigger: boolean; - }) => void; -}): void { - input.writePrompt(input.snapshot.prompt); - input.writeTerminalContexts(input.snapshot.terminalContexts); - input.writeReviewComments(input.snapshot.reviewComments); - input.writePreviewAnnotations(input.snapshot.previewAnnotations); - input.writeThreadContexts(input.snapshot.threadContexts); - input.writeEnvironmentContexts(input.snapshot.environmentContexts); - input.resetCursor({ - cursor: collapseExpandedComposerCursor(input.snapshot.prompt, input.snapshot.prompt.length), - prompt: input.snapshot.prompt, - detectTrigger: true, - }); -} - /** Includes the annotation screenshot only within the attachment cap; exceeding it rejects the whole turn. */ export function mergeDirectPreviewAnnotation(input: { readonly images: ReadonlyArray; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cf48ddcb842dd..b535cdd7bdbe4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -305,6 +305,8 @@ import { QueuedRunsControl, type QueuedRunsControlHandle } from "./chat/QueuedRu import { useThreadLinkedPullRequest } from "./pullRequest/useThreadLinkedPullRequest"; import { useProactivePanels } from "../hooks/useProactivePanels"; import { useIncrementalTimelineEntries } from "../hooks/useIncrementalTimelineEntries"; +import { usePendingTurnDeliveries } from "../state/turnDeliveries"; +import { useUnsentMessages } from "../state/unsentMessages"; import { useRightPanelSurfaceClose } from "../hooks/useRightPanelSurfaceClose"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { modelFallbackBannerItem } from "./chat/ComposerUsageLimits"; @@ -653,6 +655,25 @@ export default function ChatView(props: ChatViewProps) { setHeldOptimisticUserMessages((state) => applyOptimisticUserMessagesAction(state, threadId, action), ); + // Sends waiting on a reconnect live outside this view, so they stay visible across thread switches. + const pendingTurnDeliveries = usePendingTurnDeliveries(environmentId, threadId); + const unsentMessageRecords = useUnsentMessages(environmentId, threadId); + const detachedUserMessages = [ + ...pendingTurnDeliveries.map((delivery) => delivery.display), + ...unsentMessageRecords.map((record) => record.display), + ]; + const displayedOptimisticMessages = + detachedUserMessages.length === 0 + ? optimisticUserMessages + : [ + ...optimisticUserMessages, + ...detachedUserMessages.filter( + (display, index) => + !serverAcknowledgedUserMessageIds.has(display.id) && + !optimisticUserMessages.some((message) => message.id === display.id) && + detachedUserMessages.findIndex((other) => other.id === display.id) === index, + ), + ]; // Draft ids survive promotion and expire on navigation, keeping the pending resend scoped correctly. const [workLocallyResendDraftId, setWorkLocallyResendDraftId] = useState(null); const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState< @@ -1498,7 +1519,7 @@ export default function ChatView(props: ChatViewProps) { ], ); const timelineEntriesInput = { - optimisticMessages: optimisticUserMessages, + optimisticMessages: displayedOptimisticMessages, anchoredMessages: anchoredTimelineMessages, attachmentUrlById: timelineAttachmentUrlById, ...(serverProjection === null @@ -1519,7 +1540,7 @@ export default function ChatView(props: ChatViewProps) { ...timelineEntriesInput, visibleTurnItems: serverTimelineRows, }); - const draftTimelineEntries = optimisticUserMessages.map( + const draftTimelineEntries = displayedOptimisticMessages.map( (message) => ({ id: message.id, @@ -2939,7 +2960,7 @@ export default function ChatView(props: ChatViewProps) { })} environmentId={activeThread.environmentId} threadId={activeThread.id} - optimisticMessages={optimisticUserMessages} + optimisticMessages={displayedOptimisticMessages} editingRunId={queuedRunEdit.editingRunId} editBlockedReason={queuedRunEditBlockedReason({ openQuestionCount: pendingUserInputs.length, diff --git a/apps/web/src/components/chat/QueuedRunsControl.browser.test.tsx b/apps/web/src/components/chat/QueuedRunsControl.browser.test.tsx index 7e25e84135ba5..66499e23d402c 100644 --- a/apps/web/src/components/chat/QueuedRunsControl.browser.test.tsx +++ b/apps/web/src/components/chat/QueuedRunsControl.browser.test.tsx @@ -1,4 +1,10 @@ -import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; +import { + CommandId, + EnvironmentId, + MessageId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; import * as DateTime from "effect/DateTime"; import type { ComponentProps } from "react"; @@ -41,12 +47,44 @@ vi.mock(import("../../assets/assetUrls"), async (importOriginal) => ({ })); import "~/index.css"; +vi.mock(import("../../state/turnDeliveries"), async () => { + const { createTurnDeliveries } = await import("@t3tools/client-runtime/state/turn-delivery"); + const { useSyncExternalStore } = await import("react"); + const Cause = await import("effect/Cause"); + const { AsyncResult } = await import("effect/reactivity"); + const turnDeliveries = createTurnDeliveries({ + send: async () => + AsyncResult.failure(Cause.fail({ _tag: "RpcClientError", message: "SocketCloseError" })), + // A thread that never resyncs keeps a lost send waiting. + observeThread: (_environmentId, _threadId, listener) => { + listener({ status: "live" } as never); + return () => undefined; + }, + observeShell: () => () => undefined, + releaseDisplay: () => undefined, + }); + return { + turnDeliveries, + usePendingTurnDelivery: (environmentId: string, messageId: string) => + useSyncExternalStore(turnDeliveries.subscribe, turnDeliveries.getSnapshot).find( + (delivery) => delivery.environmentId === environmentId && delivery.messageId === messageId, + ) ?? null, + usePendingTurnDeliveries: (environmentId: string, threadId: string) => + useSyncExternalStore(turnDeliveries.subscribe, turnDeliveries.getSnapshot).filter( + (delivery) => delivery.environmentId === environmentId && delivery.threadId === threadId, + ), + } as never; +}); + import { render as renderUi } from "../../../test/render"; +import { turnDeliveries } from "../../state/turnDeliveries"; import { QueuedRunsControl } from "./QueuedRunsControl"; function setup() { state.projection = null; state.workflow = null; + for (const delivery of turnDeliveries.getSnapshot()) turnDeliveries.cancel(delivery.key); + expect(turnDeliveries.getSnapshot()).toEqual([]); } const baseProps = { @@ -203,3 +241,34 @@ it("stays inside the timeline scroller instead of growing the chat column", asyn const column = screen.getByTestId("column").element(); expect(column.scrollHeight).toBe(column.clientHeight); }); + +it("cancels a queued send that is waiting on a reconnect", async () => { + setup(); + state.projection = { projection: { messages: [] } }; + state.workflow = { activeRun: { id: "run:active" }, canPromoteToSteer: true, queuedRuns: [] }; + const message = { + id: MessageId.make("message:queued"), + inputIntent: "queued_turn" as const, + text: "Run the tests next", + attachments: [], + }; + const outcome = await turnDeliveries.deliver( + baseProps.environmentId, + { + commandId: CommandId.make("command:queued"), + threadId: baseProps.threadId, + message: { messageId: message.id, role: "user", text: message.text, attachments: [] }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + }, + () => message as never, + ); + expect(outcome.type).toBe("waiting"); + await using _screen = await renderQueue({ optimisticMessages: [message] }); + + await page.getByRole("button", { name: "Cancel waiting queued message", exact: true }).click(); + + expect(outcome.type === "waiting" && (await outcome.settled)).toEqual({ type: "cancelled" }); + expect(turnDeliveries.getSnapshot()).toEqual([]); +}); diff --git a/apps/web/src/components/chat/QueuedRunsControl.tsx b/apps/web/src/components/chat/QueuedRunsControl.tsx index ca98e919ff93d..a0be8612cd964 100644 --- a/apps/web/src/components/chat/QueuedRunsControl.tsx +++ b/apps/web/src/components/chat/QueuedRunsControl.tsx @@ -28,6 +28,7 @@ import { useId, useImperativeHandle, useRef, useState, type ReactNode, type Ref import { useAssetUrls } from "../../assets/assetUrls"; import { threadEnvironment } from "../../state/threads"; +import { turnDeliveries, usePendingTurnDeliveries } from "../../state/turnDeliveries"; import { useQuietThreadShell, useThreadProjection } from "../../state/entities"; import { useAtomCommand } from "../../state/use-atom-command"; import { isImageAttachment, type ChatMessage } from "../../types"; @@ -99,6 +100,7 @@ export function QueuedRunsControl({ const promote = useAtomCommand(threadEnvironment.promoteQueuedRun); const resume = useAtomCommand(threadEnvironment.resumeThreadQueue); const cancel = useAtomCommand(threadEnvironment.cancelQueuedRun); + const pendingDeliveries = usePendingTurnDeliveries(props.environmentId, props.threadId); const [showAll, setShowAll] = useState(false); const queueListId = useId(); const [busyRunId, setBusyRunId] = useState(null); @@ -142,6 +144,7 @@ export function QueuedRunsControl({ url: queuedImageUrlById.get(attachment.id) ?? null, })), pending: false, + waitingDeliveryKey: null, })), ...optimisticQueued.map((message) => ({ key: message.id, @@ -160,6 +163,10 @@ export function QueuedRunsControl({ url: attachment.previewUrl ?? null, })), pending: true, + waitingDeliveryKey: + pendingDeliveries.find( + (delivery) => delivery.messageId === message.id && delivery.state === "waiting", + )?.key ?? null, })), ]; @@ -293,7 +300,7 @@ export function QueuedRunsControl({ {item.pending ? ( - Saving + {item.waitingDeliveryKey === null ? "Saving" : "Waiting to send"} ) : null} {isEditing ? ( @@ -348,17 +355,32 @@ export function QueuedRunsControl({