From fd647c8d9fd1d42fff22340d4e8c3b7f5af0b5fd Mon Sep 17 00:00:00 2001 From: Andrew Forster Date: Sun, 4 Oct 2026 05:17:51 -0700 Subject: [PATCH 01/21] fix(queue): hold queued messages while editing --- .../features/threads/ThreadQueueControl.tsx | 47 ++- apps/mobile/src/state/queued-run-edit.test.ts | 2 + apps/mobile/src/state/queued-run-edit.ts | 16 +- .../src/state/use-thread-composer-state.ts | 31 +- .../src/orchestration-v2/Orchestrator.ts | 94 +++++- .../orchestration-v2/QueuedRunEditing.test.ts | 314 ++++++++++++++++++ .../testkit/OrchestratorScenario.ts | 2 + apps/web/src/components/ChatView.tsx | 105 ++++-- .../src/components/chat/QueuedRunsControl.tsx | 8 +- docs/user/composer.md | 9 +- .../src/operations/commands.test.ts | 39 +++ .../client-runtime/src/operations/commands.ts | 38 +++ .../src/state/threadCommands.ts | 16 + packages/contracts/src/orchestrationV2.ts | 16 + 14 files changed, 678 insertions(+), 59 deletions(-) create mode 100644 apps/server/src/orchestration-v2/QueuedRunEditing.test.ts diff --git a/apps/mobile/src/features/threads/ThreadQueueControl.tsx b/apps/mobile/src/features/threads/ThreadQueueControl.tsx index 5aa56b3d2723..e78c1985bcfe 100644 --- a/apps/mobile/src/features/threads/ThreadQueueControl.tsx +++ b/apps/mobile/src/features/threads/ThreadQueueControl.tsx @@ -4,7 +4,7 @@ import type { ChatAttachment, EnvironmentId, RunId, ThreadId } from "@t3tools/co import { Image } from "expo-image"; import * as Haptics from "expo-haptics"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { Animated, Platform, Pressable, ScrollView, View } from "react-native"; +import { Alert, Animated, Platform, Pressable, ScrollView, View } from "react-native"; import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler"; import ReanimatedSwipeable, { type SwipeableMethods, @@ -61,6 +61,7 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { const reorder = useAtomCommand(threadEnvironment.reorderQueuedRun, "reorder queued message"); const promote = useAtomCommand(threadEnvironment.promoteQueuedRun, "promote queued message"); const cancel = useAtomCommand(threadEnvironment.cancelQueuedRun, "remove queued message"); + const beginEdit = useAtomCommand(threadEnvironment.beginQueuedRunEdit, "edit queued message"); const resume = useAtomCommand(threadEnvironment.resumeThreadQueue, "resume queue"); const [resuming, setResuming] = useState(false); const [busyRunId, setBusyRunId] = useState(null); @@ -126,16 +127,38 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { return; } if (action === "edit") { + if (editing !== null) { + if (editing.runId !== runId) Alert.alert("Save or cancel your current queued edit first."); + navigation.goBack(); + return; + } const entry = queuedRuns[index]!; - void Haptics.selectionAsync(); - beginQueuedRunEdit(threadKey, { - runId, - messageId: entry.messageId, - originalText: entry.text, - existingAttachments: entry.attachments, - ...(entry.context ? { context: entry.context } : {}), - }); - navigation.goBack(); + busyRef.current = true; + setBusyRunId(runId); + try { + const result = await beginEdit({ + ...target, + input: { + threadId: target.threadId, + runId, + previousEditId: entry.run.queueEditId ?? null, + }, + }); + if (result._tag !== "Success") return; + void Haptics.selectionAsync(); + beginQueuedRunEdit(threadKey, { + editId: result.value, + runId, + messageId: entry.messageId, + originalText: entry.text, + existingAttachments: entry.attachments, + ...(entry.context ? { context: entry.context } : {}), + }); + navigation.goBack(); + } finally { + busyRef.current = false; + setBusyRunId(null); + } return; } if (action !== "steer" && action !== "remove") return; @@ -381,7 +404,7 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { > {title} - {controls.isEditing ? ( + {controls.isEditing || run.queueEditId != null ? ( Editing @@ -390,7 +413,7 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { void act(run.id, "steer")} className="h-8 shrink-0 justify-center rounded-full bg-primary px-3 active:opacity-70 disabled:opacity-40" > diff --git a/apps/mobile/src/state/queued-run-edit.test.ts b/apps/mobile/src/state/queued-run-edit.test.ts index 06f9f40f1ac0..64f489a57883 100644 --- a/apps/mobile/src/state/queued-run-edit.test.ts +++ b/apps/mobile/src/state/queued-run-edit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { ChatAttachmentId, + CommandId, ComposerContextId, MessageId, RunId, @@ -22,6 +23,7 @@ const image = (id: string): ChatAttachment => ({ function edit(attachments: ReadonlyArray): QueuedRunEdit { return { + editId: CommandId.make("edit-1"), runId: RunId.make("run-1"), messageId: MessageId.make("message-1"), originalText: "original", diff --git a/apps/mobile/src/state/queued-run-edit.ts b/apps/mobile/src/state/queued-run-edit.ts index f3b29d83b155..c8f3e433af95 100644 --- a/apps/mobile/src/state/queued-run-edit.ts +++ b/apps/mobile/src/state/queued-run-edit.ts @@ -9,11 +9,11 @@ * working, while the user's draft is never touched and is waiting unchanged * when the edit is cancelled. * - * The record below is in-memory only: an edit that does not survive a restart - * is better than a composer that reopens pointed at a run the server may have - * already started. + * Editor metadata stays in memory. The server retains the edit hold across + * restarts; reopening the queued message claims it again before editing. */ import type { + CommandId, ChatAttachment, MessageId, OrchestrationMessageContext, @@ -28,11 +28,13 @@ import { appAtomRegistry } from "./atom-registry"; import { queuedEditDraftKey } from "./queued-edit-draft-key"; import { clearComposerDraft, + getComposerDraftSnapshot, setComposerDraftContext, setComposerDraftText, } from "./use-composer-drafts"; export interface QueuedRunEdit { + readonly editId: CommandId; readonly runId: RunId; readonly messageId: MessageId; readonly originalText: string; @@ -77,9 +79,11 @@ export function beginQueuedRunEdit(threadKey: string, edit: QueuedRunEdit): void clearComposerDraft(queuedEditDraftKey(threadKey, previous.runId)); } const draftKey = queuedEditDraftKey(threadKey, edit.runId); - clearComposerDraft(draftKey); - setComposerDraftText(draftKey, edit.originalText); - setComposerDraftContext(draftKey, edit.context); + const draft = getComposerDraftSnapshot(draftKey); + if (draft.text.length === 0 && draft.attachments.length === 0) { + setComposerDraftText(draftKey, edit.originalText); + setComposerDraftContext(draftKey, edit.context); + } setQueuedRunEdit(threadKey, edit); } diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 1f9eed3d675c..df6a75f368f7 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -195,6 +195,7 @@ export function useThreadComposerState() { label: "edit queued message", reportFailure: false, }); + const cancelQueuedEdit = useAtomCommand(threadEnvironment.cancelQueuedRunEdit); const [isSavingQueuedEdit, setIsSavingQueuedEdit] = useState(false); const savingQueuedEditRef = useRef(false); const pastedTextFileNamesRef = useRef<{ threadKey: string | null; names: Set }>({ @@ -432,7 +433,7 @@ export function useThreadComposerState() { if (selectedThreadKey === null || editedRunId === null || selectedThreadRuns === undefined) { return; } - if (savingQueuedEditRef.current) return; + if (isSavingQueuedEdit || savingQueuedEditRef.current) return; const stillQueued = selectedThreadRuns.some( (run) => run.id === editedRunId && run.status === "queued", ); @@ -457,17 +458,34 @@ export function useThreadComposerState() { ? "That message already started. Your edit is back in the composer." : "That message already started, so the edit was discarded.", ); - }, [editedRunId, selectedThreadKey, selectedThreadRuns]); + }, [editedRunId, isSavingQueuedEdit, selectedThreadKey, selectedThreadRuns]); const activeThreadBusy = threadRuntimeIsActive(selectedThreadRuntime); const interruptibleRunId = threadRuntimeHasInterruptibleRun(selectedThreadRuntime) ? (selectedThreadRuntime?.activeRunId ?? null) : null; - const cancelQueuedRunEdit = useCallback(() => { - if (selectedThreadKey === null || savingQueuedEditRef.current) return; - endQueuedRunEdit(selectedThreadKey); - }, [selectedThreadKey]); + const cancelQueuedRunEdit = useCallback(async () => { + if (selectedThreadKey === null || !selectedThreadShell || savingQueuedEditRef.current) return; + const edit = getQueuedRunEdit(selectedThreadKey); + if (edit === null) return; + savingQueuedEditRef.current = true; + setIsSavingQueuedEdit(true); + try { + const currentRun = selectedThreadRuns?.find((run) => run.id === edit.runId); + const result = + currentRun?.queueEditId != null && currentRun.queueEditId !== edit.editId + ? null + : await cancelQueuedEdit({ + environmentId: selectedThreadShell.environmentId, + input: { threadId: selectedThreadShell.id, runId: edit.runId, editId: edit.editId }, + }); + if (result === null || result._tag === "Success") endQueuedRunEdit(selectedThreadKey); + } finally { + savingQueuedEditRef.current = false; + setIsSavingQueuedEdit(false); + } + }, [cancelQueuedEdit, selectedThreadKey, selectedThreadRuns, selectedThreadShell]); const onRemoveQueuedEditAttachment = useCallback( (attachmentId: string) => { @@ -521,6 +539,7 @@ export function useThreadComposerState() { input: { threadId: thread.id, runId: edit.runId, + editId: edit.editId, text, edit: { messageId: edit.messageId, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index abdfc29579cd..7dce09f20f13 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -413,6 +413,8 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "queued-run.reorder": case "queued-run.cancel": case "queued-run.edit": + case "queued-run.edit.begin": + case "queued-run.edit.cancel": case "runtime-request.respond": case "thread.user-input.dismiss": case "checkpoint.rollback": @@ -1235,7 +1237,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return; } const queuedRun = nextQueuedRun(projection); - if (queuedRun === undefined) { + if (queuedRun === undefined || queuedRun.queueEditId != null) { return; } // A provider that just failed will likely fail the next message too. @@ -7108,11 +7110,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); } const queuedRun = projection.runs.find((candidate) => candidate.id === command.queuedRunId); - if (queuedRun === undefined || queuedRun.status !== "queued") { + if ( + queuedRun === undefined || + queuedRun.status !== "queued" || + queuedRun.queueEditId != null + ) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, - cause: `Queued run ${command.queuedRunId} is not queued.`, + cause: `Queued run ${command.queuedRunId} is not queued or is being edited.`, }); } const queuedRootNode = @@ -7399,6 +7405,55 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } }); + /** Persist edits under the dispatch lock so completion cannot send the old message. */ + const dispatchQueuedRunEditState = Effect.fn("Orchestrator.dispatchQueuedRunEditState")( + function* ( + command: Extract< + OrchestrationV2Command, + { readonly type: "queued-run.edit.begin" | "queued-run.edit.cancel" } + >, + events: Ref.Ref>, + ) { + const projection = yield* projectionStore + .getThreadRecords(command.threadId, ["runs", "messages"], { messageRoles: ["user"] }) + .pipe( + Effect.mapError(() => new OrchestratorProjectionError({ threadId: command.threadId })), + ); + const run = projection.runs.find((candidate) => candidate.id === command.runId); + const message = projection.messages.find((candidate) => candidate.id === run?.userMessageId); + const expectedEditId = + command.type === "queued-run.edit.begin" ? command.previousEditId : command.editId; + if ( + run === undefined || + run.status !== "queued" || + message === undefined || + message.delegatedCompletion !== undefined || + message.notification !== undefined || + (run.queueEditId ?? null) !== expectedEditId + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "The queued message changed. Reopen it before editing.", + }); + } + yield* emit( + events, + command, + )({ + type: "run.updated", + threadId: command.threadId, + runId: run.id, + providerInstanceId: run.providerInstanceId, + occurredAt: yield* DateTime.now, + payload: { + ...run, + queueEditId: command.type === "queued-run.edit.begin" ? command.commandId : null, + }, + }); + }, + ); + const dispatchQueuedRunEdit = ( command: Extract, events: Ref.Ref>, @@ -7430,6 +7485,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const queuedMessage = projection.messages.find( (candidate) => candidate.id === queuedRun.userMessageId, ); + if ((queuedRun.queueEditId ?? null) !== (command.editId ?? null)) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "The queued message is being edited elsewhere. Reopen it before saving.", + }); + } if (queuedMessage === undefined) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, @@ -7456,6 +7518,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const emitEvent = emit(events, command); const editedAttachments = command.attachments === undefined ? {} : { attachments: command.attachments }; + if (command.editId !== undefined) { + yield* emitEvent({ + type: "run.updated", + threadId: command.threadId, + runId: queuedRun.id, + providerInstanceId: queuedRun.providerInstanceId, + occurredAt: now, + payload: { ...queuedRun, queueEditId: null }, + }); + } yield* emitEvent({ type: "message.updated", threadId: command.threadId, @@ -9640,6 +9712,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "queued-run.edit": yield* dispatchQueuedRunEdit(command, events); break; + case "queued-run.edit.begin": + case "queued-run.edit.cancel": + yield* dispatchQueuedRunEditState(command, events); + break; case "checkpoint.rollback": yield* dispatchCheckpointRollback(command, events, effects); break; @@ -9731,7 +9807,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); - if (command.type === "queue.resume") { + if ( + command.type === "queue.resume" || + command.type === "queued-run.edit.cancel" || + (command.type === "queued-run.edit" && command.editId !== undefined) + ) { yield* mapDispatchError(command)(startNextQueuedRun(command.threadId)); } return { @@ -9849,7 +9929,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio detail: committed.receipt.error ?? "Previously rejected.", }); } - if (command.type === "queue.resume") { + if ( + command.type === "queue.resume" || + command.type === "queued-run.edit.cancel" || + (command.type === "queued-run.edit" && command.editId !== undefined) + ) { yield* mapDispatchError(command)(startNextQueuedRun(command.threadId)); } if (command.type === "notification.delivery.accept") { diff --git a/apps/server/src/orchestration-v2/QueuedRunEditing.test.ts b/apps/server/src/orchestration-v2/QueuedRunEditing.test.ts new file mode 100644 index 000000000000..02e5249bb309 --- /dev/null +++ b/apps/server/src/orchestration-v2/QueuedRunEditing.test.ts @@ -0,0 +1,314 @@ +import { assert, it } from "@effect/vitest"; +import { + CommandId, + EventId, + MessageId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; + +import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import * as EventSink from "./EventSink.ts"; +import * as Orchestrator from "./Orchestrator.ts"; +import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; +import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; +import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts"; + +const modelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "test" }; +const adapter = { + instanceId: modelSelection.instanceId, + driver: ProviderDriverKind.make("codex"), + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), + openSession: () => Effect.die("provider execution is disabled in queue editing tests"), +} as ProviderAdapterV2Shape; +const layer = makeOrchestratorV2ReplayLayerWithRegistry( + { name: "queued-run-editing" }, + ProviderAdapterRegistry.makeLayer([adapter]), + { runEffectWorker: false }, +); + +const createQueue = Effect.fnUntraced(function* (name: string) { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const sink = yield* EventSink.EventSinkV2; + const threadId = ThreadId.make(`thread:${name}`); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${name}:create`), + threadId, + projectId: ProjectId.make(`project:${name}`), + title: name, + modelSelection, + createdBy: "user", + creationSource: "web", + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + for (const [index, text] of ["Active", "Original", "Later"].entries()) { + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${name}:message:${index}`), + threadId, + messageId: MessageId.make(`${name}:message:${index}`), + text, + attachments: [], + createdBy: "user", + creationSource: "web", + modelSelection, + dispatchMode: { type: index === 0 ? "start_immediately" : "queue_after_active" }, + }); + } + const projection = yield* orchestrator.getThreadProjection(threadId); + const [active, queued, later] = projection.runs; + assert.isDefined(active); + assert.isDefined(queued); + assert.isDefined(later); + const editId = CommandId.make(`${name}:begin`); + const begin = { + type: "queued-run.edit.begin" as const, + commandId: editId, + threadId, + runId: queued.id, + previousEditId: null, + }; + const complete = Effect.gen(function* () { + const now = yield* DateTime.now; + yield* sink.write({ + events: [ + { + id: EventId.make(`${name}:complete`), + type: "run.updated", + threadId, + runId: active.id, + providerInstanceId: active.providerInstanceId, + occurredAt: now, + payload: { ...active, status: "completed", completedAt: now }, + }, + ], + }); + yield* orchestrator.resumeQueuedRuns; + }); + return { orchestrator, threadId, active, queued, later, editId, begin, complete }; +}); + +it.layer(layer)("queued message editing", (it) => { + it.effect.each(["save", "cancel"] as const)( + "%s releases an edit only after completion has left the original queued", + (action) => + Effect.gen(function* () { + const { orchestrator, threadId, queued, later, editId, begin, complete } = + yield* createQueue(action); + yield* orchestrator.dispatch(begin); + yield* orchestrator.dispatch(begin); + yield* complete; + const held = yield* orchestrator.getThreadProjection(threadId); + assert.equal(held.runs.find((run) => run.id === queued.id)?.status, "queued"); + assert.equal(held.runs.find((run) => run.id === later.id)?.status, "queued"); + assert.isFalse(held.turnItems.some((item) => item.runId === queued.id)); + + const finish = + action === "save" + ? { + type: "queued-run.edit" as const, + commandId: CommandId.make(`${action}:finish`), + threadId, + runId: queued.id, + editId, + text: "Edited", + } + : { + type: "queued-run.edit.cancel" as const, + commandId: CommandId.make(`${action}:finish`), + threadId, + runId: queued.id, + editId, + }; + yield* orchestrator.dispatch(finish); + yield* orchestrator.dispatch(finish); + const after = yield* orchestrator.getThreadProjection(threadId); + assert.equal(after.runs.find((run) => run.id === queued.id)?.status, "starting"); + assert.equal(after.runs.find((run) => run.id === later.id)?.status, "queued"); + const items = after.turnItems.filter( + (item) => item.type === "user_message" && item.runId === queued.id, + ); + assert.equal(items.length, 1); + const item = items[0]; + assert.isDefined(item); + assert.equal(item.type, "user_message"); + if (item.type !== "user_message") return assert.fail("Expected the queued user message"); + assert.equal(item.text, action === "save" ? "Edited" : "Original"); + assert.equal(after.runs.length, 3); + }), + ); + + it.effect("rejects editing after dispatch has already won", () => + Effect.gen(function* () { + const { orchestrator, threadId, queued, begin, complete } = + yield* createQueue("already-started"); + yield* complete; + assert.isTrue(Exit.isFailure(yield* Effect.exit(orchestrator.dispatch(begin)))); + const after = yield* orchestrator.getThreadProjection(threadId); + assert.equal(after.runs.find((run) => run.id === queued.id)?.status, "starting"); + assert.equal( + after.messages.find((message) => message.id === queued.userMessageId)?.text, + "Original", + ); + }), + ); + + it.effect("holding a later message does not block an earlier queued message", () => + Effect.gen(function* () { + const { orchestrator, threadId, queued, later, begin, complete } = + yield* createQueue("later-edit"); + yield* orchestrator.dispatch({ ...begin, runId: later.id }); + yield* complete; + const after = yield* orchestrator.getThreadProjection(threadId); + assert.equal(after.runs.find((run) => run.id === queued.id)?.status, "starting"); + assert.equal(after.runs.find((run) => run.id === later.id)?.status, "queued"); + }), + ); + + it.effect("steering cannot bypass an active edit", () => + Effect.gen(function* () { + const { orchestrator, threadId, active, queued, begin } = yield* createQueue("held-steer"); + yield* orchestrator.dispatch(begin); + assert.isTrue( + Exit.isFailure( + yield* Effect.exit( + orchestrator.dispatch({ + type: "queued-message.promote-to-steer", + commandId: CommandId.make("held-steer:promote"), + threadId, + queuedRunId: queued.id, + targetRunId: active.id, + }), + ), + ), + ); + const after = yield* orchestrator.getThreadProjection(threadId); + assert.equal(after.runs.find((run) => run.id === queued.id)?.status, "queued"); + }), + ); + + it.effect("resume and rejected edits cannot release an active edit", () => + Effect.gen(function* () { + const { orchestrator, threadId, queued, editId, begin, complete } = + yield* createQueue("rejections"); + yield* orchestrator.dispatch(begin); + yield* complete; + yield* orchestrator.dispatch({ + type: "queue.resume", + commandId: CommandId.make("rejections:resume"), + threadId, + }); + for (const [index, extra] of [ + { text: "", editId }, + { text: "Overwritten" }, + { text: "Stale", editId: CommandId.make("wrong") }, + ].entries()) { + assert.isTrue( + Exit.isFailure( + yield* Effect.exit( + orchestrator.dispatch({ + type: "queued-run.edit", + commandId: CommandId.make(`rejections:edit:${index}`), + threadId, + runId: queued.id, + ...extra, + }), + ), + ), + ); + } + const after = yield* orchestrator.getThreadProjection(threadId); + assert.equal(after.runs.find((run) => run.id === queued.id)?.queueEditId, editId); + assert.equal(after.runs.find((run) => run.id === queued.id)?.status, "queued"); + assert.equal( + after.messages.find((message) => message.id === queued.userMessageId)?.text, + "Original", + ); + }), + ); + + it.effect.each(["save", "cancel"] as const)("%s preserves a pre-existing queue pause", (action) => + Effect.gen(function* () { + const { orchestrator, threadId, active, queued, editId, begin } = yield* createQueue( + `paused-${action}`, + ); + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make(`paused-${action}:stop`), + threadId, + runId: active.id, + holdQueue: true, + }); + yield* orchestrator.dispatch(begin); + yield* orchestrator.dispatch( + action === "save" + ? { + type: "queued-run.edit", + commandId: CommandId.make(`paused-${action}:finish`), + threadId, + runId: queued.id, + editId, + text: "Edited", + } + : { + type: "queued-run.edit.cancel", + commandId: CommandId.make(`paused-${action}:finish`), + threadId, + runId: queued.id, + editId, + }, + ); + const after = yield* orchestrator.getThreadProjection(threadId); + const run = after.runs.find((candidate) => candidate.id === queued.id); + assert.equal(run?.status, "queued"); + assert.isTrue(run?.queueHeld); + assert.isNull(run?.queueEditId); + }), + ); + + it.effect("reopening an edit invalidates stale saves and cancels", () => + Effect.gen(function* () { + const { orchestrator, threadId, queued, editId, begin, complete } = + yield* createQueue("reopen"); + yield* orchestrator.dispatch(begin); + const reopenedId = CommandId.make("reopen:new-editor"); + yield* orchestrator.dispatch({ ...begin, commandId: reopenedId, previousEditId: editId }); + assert.isTrue( + Exit.isFailure( + yield* Effect.exit( + orchestrator.dispatch({ + type: "queued-run.edit.cancel", + commandId: CommandId.make("reopen:stale-cancel"), + threadId, + runId: queued.id, + editId, + }), + ), + ), + ); + yield* complete; + const held = yield* orchestrator.getThreadProjection(threadId); + assert.equal(held.runs.find((run) => run.id === queued.id)?.status, "queued"); + yield* orchestrator.dispatch({ + type: "queued-run.edit", + commandId: CommandId.make("reopen:save"), + threadId, + runId: queued.id, + editId: reopenedId, + text: "Recovered", + }); + const after = yield* orchestrator.getThreadProjection(threadId); + assert.equal(after.runs.find((run) => run.id === queued.id)?.status, "starting"); + }), + ); +}); diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts index 0bb2d5053b4b..5f11d7b06de8 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -172,6 +172,8 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray(null); const queuedEditSaveInFlightRef = useRef(false); const [isSavingQueuedEdit, setIsSavingQueuedEdit] = useState(false); @@ -4539,28 +4542,54 @@ export default function ChatView(props: ChatViewProps) { })); }, [editingQueuedRun, queuedEditImageResources, queuedEditImageUrls]); const beginEditingQueuedRun = useCallback( - (request: EditQueuedRunRequest) => { - if (!activeThread) return; - if (editingQueuedRun !== null && editingQueuedRun.runId !== request.runId) { - clearComposerDraftContent(queuedEditDraftTargetFor(editingQueuedRun.runId)); + async (request: EditQueuedRunRequest) => { + if (!activeThread || queuedEditSaveInFlightRef.current) return; + if (editingQueuedRun !== null) { + if (editingQueuedRun.runId !== request.runId) { + toastManager.add({ + type: "info", + title: "Save or cancel your current queued edit first.", + }); + } + scheduleComposerFocus(); + return; + } + queuedEditSaveInFlightRef.current = true; + try { + const result = await beginQueuedRunEditCommand({ + environmentId: activeThread.environmentId, + input: { + threadId: activeThread.id, + runId: request.runId, + previousEditId: + serverProjection?.runs.find((run) => run.id === request.runId)?.queueEditId ?? null, + }, + }); + if (result._tag !== "Success") return; + const target = queuedEditDraftTargetFor(request.runId); + if ( + !composerDraftHasUserContent(useComposerDraftStore.getState().getComposerDraft(target)) + ) { + setComposerDraftPrompt(target, request.text); + } + setEditingQueuedRun({ + editId: result.value, + threadId: activeThread.id, + runId: request.runId, + messageId: request.messageId, + originalText: request.text, + existingAttachments: request.attachments, + context: serverProjection?.messages.find((message) => message.id === request.messageId) + ?.context, + }); + scheduleComposerFocus(); + } finally { + queuedEditSaveInFlightRef.current = false; } - const target = queuedEditDraftTargetFor(request.runId); - clearComposerDraftContent(target); - setComposerDraftPrompt(target, request.text); - setEditingQueuedRun({ - threadId: activeThread.id, - runId: request.runId, - messageId: request.messageId, - originalText: request.text, - existingAttachments: request.attachments, - context: serverProjection?.messages.find((message) => message.id === request.messageId) - ?.context, - }); - scheduleComposerFocus(); }, [ activeThread, - clearComposerDraftContent, + beginQueuedRunEditCommand, editingQueuedRun, queuedEditDraftTargetFor, serverProjection, @@ -4568,12 +4597,35 @@ export default function ChatView(props: ChatViewProps) { setComposerDraftPrompt, ], ); - const cancelEditingQueuedRun = useCallback(() => { - if (editingQueuedRun === null) return; - clearComposerDraftContent(queuedEditDraftTargetFor(editingQueuedRun.runId)); - setEditingQueuedRun(null); - scheduleComposerFocus(); + const cancelEditingQueuedRun = useCallback(async () => { + if (editingQueuedRun === null || queuedEditSaveInFlightRef.current) return; + queuedEditSaveInFlightRef.current = true; + setIsSavingQueuedEdit(true); + try { + const currentRun = serverProjection?.runs.find((run) => run.id === editingQueuedRun.runId); + const result = + currentRun?.queueEditId != null && currentRun.queueEditId !== editingQueuedRun.editId + ? null + : await cancelQueuedRunEditCommand({ + environmentId, + input: { + threadId: editingQueuedRun.threadId, + runId: editingQueuedRun.runId, + editId: editingQueuedRun.editId, + }, + }); + if (result !== null && result._tag !== "Success") return; + clearComposerDraftContent(queuedEditDraftTargetFor(editingQueuedRun.runId)); + setEditingQueuedRun(null); + scheduleComposerFocus(); + } finally { + queuedEditSaveInFlightRef.current = false; + setIsSavingQueuedEdit(false); + } }, [ + cancelQueuedRunEditCommand, + environmentId, + serverProjection, clearComposerDraftContent, editingQueuedRun, queuedEditDraftTargetFor, @@ -4600,9 +4652,10 @@ export default function ChatView(props: ChatViewProps) { setEditingQueuedRun(null); return; } - if (serverProjection === null) return; + if (serverProjection === null || isSavingQueuedEdit || queuedEditSaveInFlightRef.current) + return; const run = serverProjection.runs.find((candidate) => candidate.id === editingQueuedRun.runId); - if (run !== undefined && run.status === "queued") return; + if (run?.status === "queued") return; const recovery = recoverQueuedMessageEdit({ editTarget: queuedEditDraftTargetFor(editingQueuedRun.runId), threadTarget: baseComposerDraftTarget, @@ -4630,6 +4683,7 @@ export default function ChatView(props: ChatViewProps) { activeThread?.id, baseComposerDraftTarget, editingQueuedRun, + isSavingQueuedEdit, queuedEditDraftTargetFor, serverProjection, ]); @@ -8552,6 +8606,7 @@ export default function ChatView(props: ChatViewProps) { input: { threadId: editingQueuedRun.threadId, runId: editingQueuedRun.runId, + editId: editingQueuedRun.editId, text: editText.length === 0 ? ATTACHMENT_ONLY_BOOTSTRAP_PROMPT : editText, edit: { messageId: editingQueuedRun.messageId, diff --git a/apps/web/src/components/chat/QueuedRunsControl.tsx b/apps/web/src/components/chat/QueuedRunsControl.tsx index 453c99ab4689..68bc039ec739 100644 --- a/apps/web/src/components/chat/QueuedRunsControl.tsx +++ b/apps/web/src/components/chat/QueuedRunsControl.tsx @@ -135,6 +135,7 @@ export function QueuedRunsControl({ key: run.id, runId: run.id, messageId: run.userMessageId, + heldForEdit: run.queueEditId != null, serverIndex, text, attachments, @@ -151,6 +152,7 @@ export function QueuedRunsControl({ key: message.id, runId: null, messageId: null, + heldForEdit: false, serverIndex: null, text: message.text, attachments: [] as ReadonlyArray, @@ -207,7 +209,7 @@ export function QueuedRunsControl({ useImperativeHandle(ref, () => ({ steerNext(repeat) { const next = queued[0]; - if (!next || !workflow?.canPromoteToSteer) return false; + if (!next || next.run.queueEditId != null || !workflow?.canPromoteToSteer) return false; if (!repeat && busyRunId === null) void steer(next.run.id); return true; }, @@ -402,6 +404,7 @@ export function QueuedRunsControl({ {previewText} + {item.heldForEdit ? Editing : null} {isEditing ? ( @@ -450,7 +453,8 @@ export function QueuedRunsControl({ disabled={ item.runId === null || busyRunId !== null || - !workflow?.canPromoteToSteer + !workflow?.canPromoteToSteer || + item.heldForEdit } onClick={() => { if (item.runId !== null) { diff --git a/docs/user/composer.md b/docs/user/composer.md index 8cb8d7805ac9..79f44349357a 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -167,11 +167,14 @@ If the server restarts, saved queued messages keep their order and are held. Pre sheet on mobile, to continue. You can edit, reorder, or remove held messages without starting them. The pencil on a queued row opens that message in the composer for editing. The original message -stays in the queue until you save, and its row is highlighted while you edit. The message's +stays in its queue position and cannot start while you edit, even if the current turn finishes. +Its row is highlighted while you edit. The message's attachments appear above the text with a remove control, and new images can be added the usual way. The checkmark saves the queued message in place; **Cancel** on its row leaves it unchanged. Whatever -you had typed in the composer before starting the edit is restored afterwards. If the queued -message starts or is removed while you are editing, the edit ends: changed content moves into the +you had typed in the composer before starting the edit is restored afterwards. Saving or cancelling +releases the edit hold, but does not resume a queue paused by Stop or a server restart. If you close +the app during an edit, reopen the message with its pencil to finish or cancel it. +If the message is removed while you are editing, the edit ends: changed content moves into the composer when it is empty, and is discarded otherwise. ## Commands and skills diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index a9833f7499d7..5a32fc52a4db 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -42,6 +42,8 @@ import { createProject, dismissThreadUserInput, editQueuedRun, + beginQueuedRunEdit, + cancelQueuedRunEdit, forkThreadFromRun, interruptThreadTurn, mergeThreadBack, @@ -666,6 +668,43 @@ describe("V2 environment commands", () => { }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), ); + it.effect("keeps the accepted edit identifier on save and cancel", () => + Effect.gen(function* () { + const commands: OrchestrationV2Command[] = []; + const supervisor = yield* makeSupervisor({ commands, projects: [] }); + const provide = Effect.provideService( + EnvironmentSupervisor.EnvironmentSupervisor, + supervisor, + ); + const runId = RunId.make("editing-run"); + const editId = yield* beginQueuedRunEdit({ + commandId: CommandId.make("begin-edit"), + threadId: v2ThreadId, + runId, + previousEditId: null, + }).pipe(provide); + yield* editQueuedRun({ + commandId: CommandId.make("save-edit"), + threadId: v2ThreadId, + runId, + editId, + text: "Revised", + }).pipe(provide); + yield* cancelQueuedRunEdit({ + commandId: CommandId.make("cancel-edit"), + threadId: v2ThreadId, + runId, + editId, + }).pipe(provide); + expect(editId).toBe("begin-edit"); + expect(commands).toMatchObject([ + { type: "queued-run.edit.begin", commandId: editId, previousEditId: null }, + { type: "queued-run.edit", editId, text: "Revised" }, + { type: "queued-run.edit.cancel", editId }, + ]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); + it.effect("delegates model selection to the server without fetching the full projection", () => Effect.gen(function* () { const commands: OrchestrationV2Command[] = []; diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index bedf552f9b20..eab860430322 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -242,6 +242,7 @@ export interface RetryWorkspacePreparationInput extends ThreadCommandInput { } export interface EditQueuedRunInput extends ThreadCommandInput { + readonly editId?: CommandId; readonly runId: RunId; readonly text: string; /** @@ -1010,6 +1011,7 @@ export const editQueuedRun = Effect.fn("EnvironmentCommands.editQueuedRun")(func threadId: input.threadId, runId: input.runId, text: input.text, + ...(input.editId === undefined ? {} : { editId: input.editId }), ...(attachments === undefined ? {} : { attachments }), ...(input.edit?.context && attachments ? { @@ -1023,6 +1025,42 @@ export const editQueuedRun = Effect.fn("EnvironmentCommands.editQueuedRun")(func }); }); +export interface BeginQueuedRunEditInput extends ThreadCommandInput { + readonly runId: RunId; + readonly previousEditId: CommandId | null; +} + +export interface CancelQueuedRunEditInput extends ThreadCommandInput { + readonly runId: RunId; + readonly editId: CommandId; +} + +export const beginQueuedRunEdit = Effect.fn("EnvironmentCommands.beginQueuedRunEdit")(function* ( + input: BeginQueuedRunEditInput, +) { + const commandId = yield* allocateCommandId(input); + yield* dispatch({ + type: "queued-run.edit.begin", + commandId, + threadId: input.threadId, + runId: input.runId, + previousEditId: input.previousEditId, + }); + return commandId; +}); + +export const cancelQueuedRunEdit = Effect.fn("EnvironmentCommands.cancelQueuedRunEdit")(function* ( + input: CancelQueuedRunEditInput, +) { + return yield* dispatch({ + type: "queued-run.edit.cancel", + commandId: yield* allocateCommandId(input), + threadId: input.threadId, + runId: input.runId, + editId: input.editId, + }); +}); + export type LinkThreadPullRequestInput = Omit< Extract, "type" | "commandId" diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index ed8cc1ec97cc..5d495f14521c 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -25,6 +25,8 @@ import { type CreateThreadInput, type DeleteThreadInput, type EditQueuedRunInput, + type BeginQueuedRunEditInput, + type CancelQueuedRunEditInput, type InterruptThreadTurnInput, type MarkThreadUnreadInput, type ForkThreadFromRunInput, @@ -59,6 +61,8 @@ import { createThread, deleteThread, editQueuedRun, + beginQueuedRunEdit, + cancelQueuedRunEdit, interruptThreadTurn, forkThreadFromRun, markThreadUnread, @@ -371,6 +375,18 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + beginQueuedRunEdit: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:begin-queued-run-edit", + execute: (input: BeginQueuedRunEditInput) => beginQueuedRunEdit(input), + scheduler, + concurrency, + }), + cancelQueuedRunEdit: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:cancel-queued-run-edit", + execute: (input: CancelQueuedRunEditInput) => cancelQueuedRunEdit(input), + scheduler, + concurrency, + }), loadEarlierHistory: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:load-earlier-history", execute: (input: LoadEarlierThreadHistoryInput) => diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 220fec2b15fb..1def26f13674 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -545,6 +545,7 @@ export const OrchestrationV2Run = Schema.Struct({ queuePosition: Schema.optional(Schema.NullOr(PositiveInt)), /** Restart recovery holds the queue until the user explicitly resumes it. */ queueHeld: Schema.optional(Schema.Boolean), + queueEditId: Schema.optional(Schema.NullOr(CommandId)), requestedAt: Schema.DateTimeUtc, startedAt: Schema.NullOr(Schema.DateTimeUtc), completedAt: Schema.NullOr(Schema.DateTimeUtc), @@ -2807,6 +2808,20 @@ export const OrchestrationV2Command = Schema.Union([ threadId: ThreadId, runId: RunId, }), + Schema.Struct({ + type: Schema.Literal("queued-run.edit.begin"), + commandId: CommandId, + threadId: ThreadId, + runId: RunId, + previousEditId: Schema.NullOr(CommandId), + }), + Schema.Struct({ + type: Schema.Literal("queued-run.edit.cancel"), + commandId: CommandId, + threadId: ThreadId, + runId: RunId, + editId: CommandId, + }), Schema.Struct({ type: Schema.Literal("queued-run.edit"), context: Schema.optional(OrchestrationMessageContext), @@ -2814,6 +2829,7 @@ export const OrchestrationV2Command = Schema.Union([ threadId: ThreadId, runId: RunId, text: Schema.String, + editId: Schema.optional(CommandId), // Full replacement list. Absent = leave the message's attachments as-is, // so pre-attachment clients editing text keep the original attachments. attachments: Schema.optional(Schema.Array(ChatAttachment)), From 120d40fccc75bfc5bc75d12da09bbe8e91d6f2b6 Mon Sep 17 00:00:00 2001 From: Andrew Forster Date: Sun, 4 Oct 2026 07:16:19 -0700 Subject: [PATCH 02/21] fix(queue): exclude edit-held messages from active status --- .../orchestration-v2/ProjectionStore.test.ts | 70 ++++++++++++------- .../src/orchestration-v2/ProjectionStore.ts | 12 +++- .../src/state/threadExecution.test.ts | 31 ++++++++ .../shared/src/orchestrationV2ThreadError.ts | 13 +++- 4 files changed, 99 insertions(+), 27 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 3bb7617eef0f..bdf2ce08e487 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -1970,32 +1970,52 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => { // A held queue waits for the user, so both shells present the run before // it rather than reporting queued work. - yield* projectionStore.apply({ - id: EventId.make("event:clock:newer:held"), - type: "run.updated", - threadId, - occurredAt: later, - payload: { - ...run, - id: RunId.make("run:clock:newer"), - ordinal: 2, - status: "queued", - queueHeld: true, - requestedAt: later, - startedAt: null, - completedAt: null, - }, - }); - const heldProjection = yield* projectionStore.getThreadProjection(threadId); - const heldSqlShell = (yield* projectionStore.getShellSnapshot()).threads.find( - (row) => row.id === threadId, - )!; - for (const heldShell of [ - heldSqlShell, - ProjectionStore.threadShellFromProjection(heldProjection), + for (const hold of [ + { queueHeld: true }, + { queueHeld: false, queueEditId: CommandId.make("clock-edit") }, ]) { - assert.equal(heldShell.latestRunId, runId); - assert.equal(heldShell.status, "completed"); + yield* projectionStore.apply({ + id: EventId.make(`event:clock:newer:held:${hold.queueHeld}`), + type: "run.updated", + threadId, + occurredAt: later, + payload: { + ...run, + id: RunId.make("run:clock:newer"), + ordinal: 2, + status: "queued", + ...hold, + requestedAt: later, + startedAt: null, + completedAt: null, + }, + }); + yield* projectionStore.apply({ + id: EventId.make(`event:clock:behind:${hold.queueHeld}`), + type: "run.updated", + threadId, + occurredAt: later, + payload: { + ...run, + id: RunId.make("run:clock:behind"), + ordinal: 3, + status: "queued", + queueHeld: hold.queueHeld, + startedAt: null, + completedAt: null, + }, + }); + const heldProjection = yield* projectionStore.getThreadProjection(threadId); + const heldSqlShell = (yield* projectionStore.getShellSnapshot()).threads.find( + (row) => row.id === threadId, + )!; + for (const heldShell of [ + heldSqlShell, + ProjectionStore.threadShellFromProjection(heldProjection), + ]) { + assert.equal(heldShell.latestRunId, runId); + assert.equal(heldShell.status, "completed"); + } } // With only held runs, nothing has executed: both shells read idle. diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index a6be3079946f..1e089d09c854 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -4945,7 +4945,17 @@ export const layer: Layer.Layer = WHERE candidate.thread_id = t.thread_id AND NOT ( candidate.status = 'queued' - AND json_extract(candidate.payload_json, '$.queueHeld') IS 1 + AND ( + json_extract(candidate.payload_json, '$.queueHeld') IS 1 + OR EXISTS ( + SELECT 1 FROM orchestration_v2_projection_runs edited + WHERE edited.thread_id = candidate.thread_id + AND edited.status = 'queued' + AND json_extract(edited.payload_json, '$.queueEditId') IS NOT NULL + AND COALESCE(json_extract(edited.payload_json, '$.queuePosition'), edited.ordinal) + <= COALESCE(json_extract(candidate.payload_json, '$.queuePosition'), candidate.ordinal) + ) + ) ) ORDER BY candidate.ordinal DESC, candidate.run_id DESC LIMIT 1 diff --git a/packages/client-runtime/src/state/threadExecution.test.ts b/packages/client-runtime/src/state/threadExecution.test.ts index 6ee64d2eaf66..f765888ca5dd 100644 --- a/packages/client-runtime/src/state/threadExecution.test.ts +++ b/packages/client-runtime/src/state/threadExecution.test.ts @@ -1,4 +1,5 @@ import { + CommandId, TurnItemId, NodeId, MessageId, @@ -216,6 +217,36 @@ describe("thread execution presentation", () => { expect(deriveThreadRuntime(onlyHeld)).toBeNull(); }); + it("settles behind an edit hold without hiding a genuinely executing run", () => { + const completed = { ...run("done", 1, "completed"), completedAt: now }; + const edited = { ...run("edited", 2, "queued"), queueEditId: CommandId.make("edit") }; + const later = run("later", 3, "queued"); + const projection = { ...v2Projection, runs: [completed, edited, later], updatedAt: now }; + expect(deriveLatestThreadRun(projection)?.runId).toBe(completed.id); + expect(deriveThreadRuntime(projection)).toMatchObject({ + status: "completed", + activeRunId: null, + }); + expect( + deriveThreadRuntime({ + ...projection, + runs: [{ ...completed, status: "running" }, edited, later], + }), + ).toMatchObject({ status: "running", activeRunId: completed.id }); + expect( + deriveThreadRuntime({ + ...projection, + runs: [completed, { ...edited, queueEditId: null }, later], + }), + ).toMatchObject({ status: "queued" }); + expect( + deriveThreadRuntime({ + ...projection, + runs: [completed, { ...edited, queuePosition: 4 }, later], + }), + ).toMatchObject({ status: "queued" }); + }); + it("does not expose a queued-only run as interruptible", () => { const queuedRun = run("run-queued", 1, "queued"); const projection = { ...v2Projection, runs: [queuedRun], updatedAt: now }; diff --git a/packages/shared/src/orchestrationV2ThreadError.ts b/packages/shared/src/orchestrationV2ThreadError.ts index ca8dcceaa912..5b40f210c75e 100644 --- a/packages/shared/src/orchestrationV2ThreadError.ts +++ b/packages/shared/src/orchestrationV2ThreadError.ts @@ -115,9 +115,20 @@ export function usageLimitRunPresentedAsLatest( export function latestUnheldRun( runs: ReadonlyArray, ): OrchestrationV2Run | null { + const editPosition = runs.reduce( + (position, run) => + run.status === "queued" && run.queueEditId != null + ? Math.min(position, run.queuePosition ?? run.ordinal) + : position, + Infinity, + ); let latest: OrchestrationV2Run | null = null; for (const run of runs) { - if (run.status === "queued" && run.queueHeld === true) continue; + if ( + run.status === "queued" && + (run.queueHeld === true || (run.queuePosition ?? run.ordinal) >= editPosition) + ) + continue; if (latest === null || run.ordinal > latest.ordinal) latest = run; } return latest; From 559eff3ffa7f46bed45390479f2e7dbcd9eb919f Mon Sep 17 00:00:00 2001 From: Andrew Forster Date: Tue, 6 Oct 2026 17:18:30 -0700 Subject: [PATCH 03/21] test(queue): use current test layer helpers --- apps/server/src/orchestration-v2/QueuedRunEditing.test.ts | 2 +- packages/client-runtime/src/operations/commands.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/QueuedRunEditing.test.ts b/apps/server/src/orchestration-v2/QueuedRunEditing.test.ts index 02e5249bb309..f738aed0a250 100644 --- a/apps/server/src/orchestration-v2/QueuedRunEditing.test.ts +++ b/apps/server/src/orchestration-v2/QueuedRunEditing.test.ts @@ -29,7 +29,7 @@ const adapter = { } as ProviderAdapterV2Shape; const layer = makeOrchestratorV2ReplayLayerWithRegistry( { name: "queued-run-editing" }, - ProviderAdapterRegistry.makeLayer([adapter]), + ProviderAdapterRegistry.layerFromAdapters([adapter]), { runEffectWorker: false }, ); diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 5c4127e75986..271fc6a78f3a 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -756,7 +756,7 @@ describe("V2 environment commands", () => { { type: "queued-run.edit", editId, text: "Revised" }, { type: "queued-run.edit.cancel", editId }, ]); - }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + }).pipe(Effect.provide(layerTestCrypto)), ); it.effect("delegates model selection to the server without fetching the full projection", () => From 7e2d557417e17ee905004da80518913fa0333bb3 Mon Sep 17 00:00:00 2001 From: Andrew Forster Date: Tue, 6 Oct 2026 17:21:22 -0700 Subject: [PATCH 04/21] test(queue): update replay harness reference --- apps/server/src/orchestration-v2/QueuedRunEditing.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/QueuedRunEditing.test.ts b/apps/server/src/orchestration-v2/QueuedRunEditing.test.ts index f738aed0a250..d470303242c9 100644 --- a/apps/server/src/orchestration-v2/QueuedRunEditing.test.ts +++ b/apps/server/src/orchestration-v2/QueuedRunEditing.test.ts @@ -17,7 +17,7 @@ import * as EventSink from "./EventSink.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; -import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts"; +import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; const modelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "test" }; const adapter = { @@ -27,7 +27,7 @@ const adapter = { planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), openSession: () => Effect.die("provider execution is disabled in queue editing tests"), } as ProviderAdapterV2Shape; -const layer = makeOrchestratorV2ReplayLayerWithRegistry( +const layer = ProviderReplayHarness.layerWithRegistry( { name: "queued-run-editing" }, ProviderAdapterRegistry.layerFromAdapters([adapter]), { runEffectWorker: false }, From cb149ace72669e47e6de281d767c897aed2e8949 Mon Sep 17 00:00:00 2001 From: Andrew Forster Date: Wed, 7 Oct 2026 15:29:41 -0700 Subject: [PATCH 05/21] fix(queue): recover editors when their edit hold ends --- .../src/state/use-thread-composer-state.ts | 20 ++++++++++--------- apps/web/src/components/ChatView.tsx | 12 +++++------ 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 5733a959af82..4e045ff25c4d 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -427,20 +427,22 @@ export function useThreadComposerState() { [selectedThreadProjection], ); - // The run can start, or be cancelled from another client, while its message - // is open in the composer. Leave edit mode rather than saving into a run the + // Another client can start or cancel the run, or end or take over its edit, + // while its message is open. Leave edit mode rather than saving into a run the // server will refuse, and keep whatever was typed if there is room for it. const selectedThreadRuns = selectedThreadProjection?.projection.runs; const editedRunId = queuedRunEdit?.runId ?? null; + const editedRunEditId = queuedRunEdit?.editId ?? null; useEffect(() => { if (selectedThreadKey === null || editedRunId === null || selectedThreadRuns === undefined) { return; } if (isSavingQueuedEdit || savingQueuedEditRef.current) return; - const stillQueued = selectedThreadRuns.some( - (run) => run.id === editedRunId && run.status === "queued", + const stillOwned = selectedThreadRuns.some( + (run) => + run.id === editedRunId && run.status === "queued" && run.queueEditId === editedRunEditId, ); - if (stillQueued) return; + if (stillOwned) return; const editDraftKey = queuedEditDraftKey(selectedThreadKey, editedRunId); const editDraft = getComposerDraftSnapshot(editDraftKey); const threadDraft = getComposerDraftSnapshot(selectedThreadKey); @@ -459,10 +461,10 @@ export function useThreadComposerState() { setThreadComposerError( selectedThreadKey, keepable - ? "That message already started. Your edit is back in the composer." - : "That message already started, so the edit was discarded.", + ? "That queued message edit ended. Your edit is back in the composer." + : "That queued message edit ended, so the edit was discarded.", ); - }, [editedRunId, isSavingQueuedEdit, selectedThreadKey, selectedThreadRuns]); + }, [editedRunId, editedRunEditId, isSavingQueuedEdit, selectedThreadKey, selectedThreadRuns]); const activeThreadBusy = threadRuntimeIsActive(selectedThreadRuntime); const interruptibleRunId = threadRuntimeHasInterruptibleRun(selectedThreadRuntime) @@ -478,7 +480,7 @@ export function useThreadComposerState() { try { const currentRun = selectedThreadRuns?.find((run) => run.id === edit.runId); const result = - currentRun?.queueEditId != null && currentRun.queueEditId !== edit.editId + currentRun !== undefined && currentRun.queueEditId !== edit.editId ? null : await cancelQueuedEdit({ environmentId: selectedThreadShell.environmentId, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4048ed609c40..858a40bd4d58 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4723,7 +4723,7 @@ export default function ChatView(props: ChatViewProps) { try { const currentRun = serverProjection?.runs.find((run) => run.id === editingQueuedRun.runId); const result = - currentRun?.queueEditId != null && currentRun.queueEditId !== editingQueuedRun.editId + currentRun !== undefined && currentRun.queueEditId !== editingQueuedRun.editId ? null : await cancelQueuedRunEditCommand({ environmentId, @@ -4762,8 +4762,8 @@ export default function ChatView(props: ChatViewProps) { }, ); }, []); - // Exit edit mode when the edited run leaves the queue (it started, or was - // cancelled from another client). A dirty edit moves into the thread's own + // Exit edit mode when the run leaves the queue or another client ends or + // takes over its edit. A dirty edit moves into the thread's own // draft when that draft is empty; otherwise it is dropped with a toast. useEffect(() => { if (editingQueuedRun === null) return; @@ -4774,7 +4774,7 @@ export default function ChatView(props: ChatViewProps) { if (serverProjection === null || isSavingQueuedEdit || queuedEditSaveInFlightRef.current) return; const run = serverProjection.runs.find((candidate) => candidate.id === editingQueuedRun.runId); - if (run?.status === "queued") return; + if (run?.status === "queued" && run.queueEditId === editingQueuedRun.editId) return; const recovery = recoverQueuedMessageEdit({ editTarget: queuedEditDraftTargetFor(editingQueuedRun.runId), threadTarget: baseComposerDraftTarget, @@ -4784,7 +4784,7 @@ export default function ChatView(props: ChatViewProps) { toastManager.add( stackedThreadToast({ type: "info", - title: "Queued message is no longer queued", + title: "Queued message edit ended", description: "Your unsaved edit was kept in the composer.", }), ); @@ -4792,7 +4792,7 @@ export default function ChatView(props: ChatViewProps) { toastManager.add( stackedThreadToast({ type: "warning", - title: "Queued message is no longer queued", + title: "Queued message edit ended", description: "Your unsaved edit was discarded.", }), ); From bacd935e34d34ad546026874581c5aeb7b3b8ff9 Mon Sep 17 00:00:00 2001 From: Andrew Forster Date: Sat, 10 Oct 2026 16:10:54 -0700 Subject: [PATCH 06/21] fix(queue): wait for edit acquisition before reconciling ownership --- .../features/threads/ThreadQueueControl.tsx | 2 +- apps/mobile/src/state/queued-run-edit.test.ts | 1 + apps/mobile/src/state/queued-run-edit.ts | 1 + .../src/state/use-thread-composer-state.ts | 29 ++++--- .../orchestration-v2/QueuedRunEditing.test.ts | 4 +- apps/web/src/components/ChatView.tsx | 20 +++-- apps/web/src/state/entities.ts | 8 ++ .../src/operations/commands.test.ts | 3 +- .../client-runtime/src/operations/commands.ts | 4 +- .../src/state/threadDetail.test.ts | 2 +- .../client-runtime/src/state/threadState.ts | 15 +++- .../src/state/threads-sync.test.ts | 85 +++++++++++++++++++ packages/client-runtime/src/state/threads.ts | 13 +-- 13 files changed, 153 insertions(+), 34 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadQueueControl.tsx b/apps/mobile/src/features/threads/ThreadQueueControl.tsx index e78c1985bcfe..3ffe18f4040b 100644 --- a/apps/mobile/src/features/threads/ThreadQueueControl.tsx +++ b/apps/mobile/src/features/threads/ThreadQueueControl.tsx @@ -147,7 +147,7 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { if (result._tag !== "Success") return; void Haptics.selectionAsync(); beginQueuedRunEdit(threadKey, { - editId: result.value, + ...result.value, runId, messageId: entry.messageId, originalText: entry.text, diff --git a/apps/mobile/src/state/queued-run-edit.test.ts b/apps/mobile/src/state/queued-run-edit.test.ts index 64f489a57883..ad00ff3afdaa 100644 --- a/apps/mobile/src/state/queued-run-edit.test.ts +++ b/apps/mobile/src/state/queued-run-edit.test.ts @@ -24,6 +24,7 @@ const image = (id: string): ChatAttachment => ({ function edit(attachments: ReadonlyArray): QueuedRunEdit { return { editId: CommandId.make("edit-1"), + sequence: 1, runId: RunId.make("run-1"), messageId: MessageId.make("message-1"), originalText: "original", diff --git a/apps/mobile/src/state/queued-run-edit.ts b/apps/mobile/src/state/queued-run-edit.ts index fdf9710df787..d4976f72b302 100644 --- a/apps/mobile/src/state/queued-run-edit.ts +++ b/apps/mobile/src/state/queued-run-edit.ts @@ -34,6 +34,7 @@ import { } from "./use-composer-drafts"; export interface QueuedRunEdit { + readonly sequence: number; readonly editId: CommandId; readonly runId: RunId; readonly messageId: MessageId; diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 4e045ff25c4d..e5a8e014644a 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -29,7 +29,9 @@ import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; import { nextPastedTextFileName, pastedTextDisposition } from "@t3tools/client-runtime/text-paste"; import { + EMPTY_ENVIRONMENT_THREAD_STATE, parseCodexFeedbackCommand, + queuedRunEditOwnership, submitCodexFeedback, type CodexFeedbackSubmission, } from "@t3tools/client-runtime/state/threads"; @@ -112,6 +114,7 @@ import { useAtomCommand } from "./use-atom-command"; const EMPTY_QUEUE_WORKFLOW_ATOM = Atom.make(null).pipe( Atom.withLabel("mobile-thread-queue-workflow:empty"), ); +const EMPTY_THREAD_STATE_ATOM = Atom.make(EMPTY_ENVIRONMENT_THREAD_STATE); export function appendReviewCommentToDraft(input: { readonly environmentId: EnvironmentId; @@ -183,6 +186,14 @@ export function useThreadComposerState() { selectedEnvironmentRuntime, } = useThreadSelection(); const selectedThreadProjection = useSelectedThreadProjection(); + const selectedThreadState = useAtomValue( + selectedThreadShell === null + ? EMPTY_THREAD_STATE_ATOM + : environmentThreadDetails.stateAtom({ + environmentId: selectedThreadShell.environmentId, + threadId: selectedThreadShell.id, + }), + ); const selectedThreadVisibleTurnItems = useSelectedThreadVisibleTurnItems(); const composerDrafts = useAtomValue(composerDraftsAtom); const acknowledgedMessages = useAtomValue(acknowledgedThreadMessagesAtom); @@ -430,19 +441,14 @@ export function useThreadComposerState() { // Another client can start or cancel the run, or end or take over its edit, // while its message is open. Leave edit mode rather than saving into a run the // server will refuse, and keep whatever was typed if there is room for it. - const selectedThreadRuns = selectedThreadProjection?.projection.runs; const editedRunId = queuedRunEdit?.runId ?? null; - const editedRunEditId = queuedRunEdit?.editId ?? null; + const editOwnership = + queuedRunEdit === null ? null : queuedRunEditOwnership(selectedThreadState, queuedRunEdit); useEffect(() => { - if (selectedThreadKey === null || editedRunId === null || selectedThreadRuns === undefined) { + if (selectedThreadKey === null || editedRunId === null || editOwnership !== "lost") { return; } if (isSavingQueuedEdit || savingQueuedEditRef.current) return; - const stillOwned = selectedThreadRuns.some( - (run) => - run.id === editedRunId && run.status === "queued" && run.queueEditId === editedRunEditId, - ); - if (stillOwned) return; const editDraftKey = queuedEditDraftKey(selectedThreadKey, editedRunId); const editDraft = getComposerDraftSnapshot(editDraftKey); const threadDraft = getComposerDraftSnapshot(selectedThreadKey); @@ -464,7 +470,7 @@ export function useThreadComposerState() { ? "That queued message edit ended. Your edit is back in the composer." : "That queued message edit ended, so the edit was discarded.", ); - }, [editedRunId, editedRunEditId, isSavingQueuedEdit, selectedThreadKey, selectedThreadRuns]); + }, [editedRunId, editOwnership, isSavingQueuedEdit, selectedThreadKey]); const activeThreadBusy = threadRuntimeIsActive(selectedThreadRuntime); const interruptibleRunId = threadRuntimeHasInterruptibleRun(selectedThreadRuntime) @@ -478,9 +484,8 @@ export function useThreadComposerState() { savingQueuedEditRef.current = true; setIsSavingQueuedEdit(true); try { - const currentRun = selectedThreadRuns?.find((run) => run.id === edit.runId); const result = - currentRun !== undefined && currentRun.queueEditId !== edit.editId + queuedRunEditOwnership(selectedThreadState, edit) === "lost" ? null : await cancelQueuedEdit({ environmentId: selectedThreadShell.environmentId, @@ -491,7 +496,7 @@ export function useThreadComposerState() { savingQueuedEditRef.current = false; setIsSavingQueuedEdit(false); } - }, [cancelQueuedEdit, selectedThreadKey, selectedThreadRuns, selectedThreadShell]); + }, [cancelQueuedEdit, selectedThreadKey, selectedThreadState, selectedThreadShell]); const onRemoveQueuedEditAttachment = useCallback( (attachmentId: string) => { diff --git a/apps/server/src/orchestration-v2/QueuedRunEditing.test.ts b/apps/server/src/orchestration-v2/QueuedRunEditing.test.ts index d470303242c9..238b3354f73f 100644 --- a/apps/server/src/orchestration-v2/QueuedRunEditing.test.ts +++ b/apps/server/src/orchestration-v2/QueuedRunEditing.test.ts @@ -11,12 +11,12 @@ import { import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import type { ProviderAdapterV2 } from "@t3tools/provider-core/server/ProviderAdapter"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as EventSink from "./EventSink.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; -import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; const modelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "test" }; @@ -26,7 +26,7 @@ const adapter = { getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), openSession: () => Effect.die("provider execution is disabled in queue editing tests"), -} as ProviderAdapterV2Shape; +} satisfies ProviderAdapterV2["Service"]; const layer = ProviderReplayHarness.layerWithRegistry( { name: "queued-run-editing" }, ProviderAdapterRegistry.layerFromAdapters([adapter]), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 935d5eb3ab04..1f93723f1566 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,4 +1,5 @@ import { ThreadFind, ThreadFindCanvas, type ThreadFindControls } from "./chat/ThreadFindProvider"; +import * as Option from "effect/Option"; import { THREAD_FIND_BAR_RESERVED_HEIGHT } from "./chat/ThreadFindBar"; import { usageLimitRecoveryBannerItem } from "./chat/UsageLimitRecoveryBanner"; import { @@ -110,6 +111,7 @@ import { threadSupportsProviderHandoff } from "@t3tools/client-runtime/state/thr import { codexFeedbackMessage, parseCodexFeedbackCommand, + queuedRunEditOwnership, shouldShowLoadEarlierControl, submitCodexFeedback, type CodexFeedbackSubmission, @@ -424,7 +426,7 @@ import { useEnvironmentSupportsServerBrowser, useProject, useProjects, - useThreadProjection, + useThreadState, useThreadStatus, useThreadHistory, useThreadShell, @@ -1678,6 +1680,7 @@ export default function ChatView(props: ChatViewProps) { // keeps (removal is client state until save). const [editingQueuedRun, setEditingQueuedRun] = useState<{ readonly editId: CommandId; + readonly sequence: number; readonly threadId: ThreadId; readonly runId: RunId; readonly messageId: MessageId; @@ -1707,8 +1710,8 @@ export default function ChatView(props: ChatViewProps) { shellExists: serverThread !== null, waitForShell: draftThread !== null, }); - const serverThreadProjection = useThreadProjection(routeThreadDetailRef); - const serverProjection = serverThreadProjection?.projection ?? null; + const serverThreadState = useThreadState(routeThreadDetailRef); + const serverProjection = Option.getOrNull(serverThreadState.data); const reportedModelSelection = serverProjection ? deriveReportedModelSelection(serverProjection) : null; @@ -4701,7 +4704,7 @@ export default function ChatView(props: ChatViewProps) { setComposerDraftPrompt(target, request.text); } setEditingQueuedRun({ - editId: result.value, + ...result.value, threadId: activeThread.id, runId: request.runId, messageId: request.messageId, @@ -4730,9 +4733,8 @@ export default function ChatView(props: ChatViewProps) { queuedEditSaveInFlightRef.current = true; setIsSavingQueuedEdit(true); try { - const currentRun = serverProjection?.runs.find((run) => run.id === editingQueuedRun.runId); const result = - currentRun !== undefined && currentRun.queueEditId !== editingQueuedRun.editId + queuedRunEditOwnership(serverThreadState, editingQueuedRun) === "lost" ? null : await cancelQueuedRunEditCommand({ environmentId, @@ -4753,7 +4755,7 @@ export default function ChatView(props: ChatViewProps) { }, [ cancelQueuedRunEditCommand, environmentId, - serverProjection, + serverThreadState, clearComposerDraftContent, editingQueuedRun, queuedEditDraftTargetFor, @@ -4782,8 +4784,7 @@ export default function ChatView(props: ChatViewProps) { } if (serverProjection === null || isSavingQueuedEdit || queuedEditSaveInFlightRef.current) return; - const run = serverProjection.runs.find((candidate) => candidate.id === editingQueuedRun.runId); - if (run?.status === "queued" && run.queueEditId === editingQueuedRun.editId) return; + if (queuedRunEditOwnership(serverThreadState, editingQueuedRun) !== "lost") return; const recovery = recoverQueuedMessageEdit({ editTarget: queuedEditDraftTargetFor(editingQueuedRun.runId), threadTarget: baseComposerDraftTarget, @@ -4814,6 +4815,7 @@ export default function ChatView(props: ChatViewProps) { isSavingQueuedEdit, queuedEditDraftTargetFor, serverProjection, + serverThreadState, ]); const addTerminalContextToDraft = useCallback( (selection: TerminalContextSelection) => { diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index ebf62f01be6f..229737ea217d 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -6,6 +6,7 @@ import type { } from "@t3tools/client-runtime/state/shell"; import { EMPTY_THREAD_HISTORY_META, + EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadStatus, type ThreadHistoryMeta, } from "@t3tools/client-runtime/state/threads"; @@ -40,6 +41,7 @@ const EMPTY_THREAD_SHELL_ATOM = Atom.make(null).p const EMPTY_THREAD_PROJECTION_ATOM = Atom.make(null).pipe( Atom.withLabel("web-thread-projection:empty"), ); +const EMPTY_THREAD_STATE_ATOM = Atom.make(EMPTY_ENVIRONMENT_THREAD_STATE); const EMPTY_THREAD_STATUS_ATOM = Atom.make("empty").pipe( Atom.withLabel("web-thread-status:empty"), ); @@ -129,6 +131,12 @@ export function useThreadProjection(ref: ScopedThreadRef | null): EnvironmentThr ); } +export function useThreadState(ref: ScopedThreadRef | null) { + return useAtomValue( + ref === null ? EMPTY_THREAD_STATE_ATOM : environmentThreadDetails.stateAtom(ref), + ); +} + export function useThreadStatus(ref: ScopedThreadRef | null): EnvironmentThreadStatus { return useAtomValue( ref === null ? EMPTY_THREAD_STATUS_ATOM : environmentThreadDetails.statusAtom(ref), diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index e3f628b766f2..e962b35cef0c 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -732,7 +732,7 @@ describe("V2 environment commands", () => { supervisor, ); const runId = RunId.make("editing-run"); - const editId = yield* beginQueuedRunEdit({ + const { editId, sequence } = yield* beginQueuedRunEdit({ commandId: CommandId.make("begin-edit"), threadId: v2ThreadId, runId, @@ -752,6 +752,7 @@ describe("V2 environment commands", () => { editId, }).pipe(provide); expect(editId).toBe("begin-edit"); + expect(sequence).toBe(1); expect(commands).toMatchObject([ { type: "queued-run.edit.begin", commandId: editId, previousEditId: null }, { type: "queued-run.edit", editId, text: "Revised" }, diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index 053829d020c4..ad4195aea7c8 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -1075,14 +1075,14 @@ export const beginQueuedRunEdit = Effect.fn("EnvironmentCommands.beginQueuedRunE input: BeginQueuedRunEditInput, ) { const commandId = yield* allocateCommandId(input); - yield* dispatch({ + const { sequence } = yield* dispatch({ type: "queued-run.edit.begin", commandId, threadId: input.threadId, runId: input.runId, previousEditId: input.previousEditId, }); - return commandId; + return { editId: commandId, sequence }; }); export const cancelQueuedRunEdit = Effect.fn("EnvironmentCommands.cancelQueuedRunEdit")(function* ( diff --git a/packages/client-runtime/src/state/threadDetail.test.ts b/packages/client-runtime/src/state/threadDetail.test.ts index 10ea41f4f3ad..ff01c75aecbb 100644 --- a/packages/client-runtime/src/state/threadDetail.test.ts +++ b/packages/client-runtime/src/state/threadDetail.test.ts @@ -28,7 +28,7 @@ function threadState( partial: Pick & Partial>, ): EnvironmentThreadState { - return { ...partial, history: partial.history ?? EMPTY_THREAD_HISTORY_META }; + return { sequence: 0, ...partial, history: partial.history ?? EMPTY_THREAD_HISTORY_META }; } describe("createEnvironmentThreadDetailAtoms", () => { diff --git a/packages/client-runtime/src/state/threadState.ts b/packages/client-runtime/src/state/threadState.ts index d534f50ce916..73435d47a8c8 100644 --- a/packages/client-runtime/src/state/threadState.ts +++ b/packages/client-runtime/src/state/threadState.ts @@ -1,4 +1,4 @@ -import type { OrchestrationV2ThreadProjection } from "@t3tools/contracts"; +import type { CommandId, RunId, OrchestrationV2ThreadProjection } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { EMPTY_THREAD_HISTORY_META, type ThreadHistoryMeta } from "./threadHistoryMerge.ts"; @@ -6,6 +6,7 @@ import { EMPTY_THREAD_HISTORY_META, type ThreadHistoryMeta } from "./threadHisto export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted"; export interface EnvironmentThreadState { + readonly sequence: number; readonly data: Option.Option; readonly status: EnvironmentThreadStatus; readonly error: Option.Option; @@ -18,8 +19,20 @@ export interface EnvironmentThreadState { } export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = { + sequence: 0, data: Option.none(), status: "empty", error: Option.none(), history: EMPTY_THREAD_HISTORY_META, }; + +/** Command replies can arrive before their subscription events. Only a projection + * at or beyond the acquisition receipt can prove that an editor lost its hold. */ +export function queuedRunEditOwnership( + state: EnvironmentThreadState, + edit: { readonly runId: RunId; readonly editId: CommandId; readonly sequence: number }, +): "pending" | "owned" | "lost" { + if (state.sequence < edit.sequence) return "pending"; + const run = Option.getOrNull(state.data)?.runs.find((run) => run.id === edit.runId); + return run?.status === "queued" && run.queueEditId === edit.editId ? "owned" : "lost"; +} diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index b78377b2b1ec..b6086efc441f 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -1,4 +1,6 @@ import { + CommandId, + RunId, EnvironmentId, EventId, MessageId, @@ -41,6 +43,7 @@ import * as ThreadHistoryController from "./threadHistoryController.ts"; import { EMPTY_ENVIRONMENT_THREAD_STATE, makeEnvironmentThreadState, + queuedRunEditOwnership, type EnvironmentThreadState, type ThreadSnapshotLoadResult, } from "./threads.ts"; @@ -320,6 +323,88 @@ const deleted = (sequence = 3): OrchestrationV2ThreadStreamItem => { }; describe("EnvironmentThreads", () => { + it.effect.each([null, CommandId.make("previous-editor")])( + "keeps an acquired editor open while the projection still has edit ID %s", + (previousEditId) => + Effect.gen(function* () { + const run = { + id: RunId.make("queued-edit-run"), + threadId: THREAD_ID, + ordinal: 1, + providerInstanceId: BASE_PROJECTION.thread.providerInstanceId, + modelSelection: BASE_PROJECTION.thread.modelSelection, + providerThreadId: null, + userMessageId: MessageId.make("queued-edit-message"), + rootNodeId: null, + activeAttemptId: null, + status: "queued" as const, + queueEditId: previousEditId, + requestedAt: BASE_PROJECTION.updatedAt, + startedAt: null, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + }; + const h = yield* makeHarness({ cached: { ...BASE_PROJECTION, runs: [run] } }); + const before = yield* awaitThreadState(h.observed, (value) => value.status === "live"); + const edit = { runId: run.id, editId: CommandId.make("accepted-editor"), sequence: 10 }; + expect(queuedRunEditOwnership(before, edit)).toBe("pending"); + + yield* Queue.offer(h.inputs, titleUpdated("An earlier buffered update", 9)); + const buffered = yield* awaitThreadState(h.observed, (value) => value.sequence === 9); + expect(queuedRunEditOwnership(buffered, edit)).toBe("pending"); + + yield* Queue.offer(h.inputs, { + kind: "event", + sequence: edit.sequence, + event: { + id: EventId.make("edit-acquired"), + type: "run.updated", + threadId: THREAD_ID, + occurredAt: BASE_PROJECTION.updatedAt, + payload: { ...run, queueEditId: edit.editId }, + }, + }); + const acquired = yield* awaitThreadState(h.observed, (value) => value.sequence === 10); + expect(queuedRunEditOwnership(acquired, edit)).toBe("owned"); + + for (const [index, queueEditId] of [null, CommandId.make("another-editor")].entries()) { + yield* Queue.offer(h.inputs, { + kind: "event", + sequence: 11 + index, + event: { + id: EventId.make(`edit-lost-${index}`), + type: "run.updated", + threadId: THREAD_ID, + occurredAt: BASE_PROJECTION.updatedAt, + payload: { ...run, queueEditId }, + }, + }); + const lost = yield* awaitThreadState( + h.observed, + (value) => value.sequence === 11 + index, + ); + expect(queuedRunEditOwnership(lost, edit)).toBe("lost"); + } + }), + ); + + it.effect("detects a superseded edit even when a snapshot skips the acquired token", () => + Effect.gen(function* () { + const h = yield* makeHarness({ cached: BASE_PROJECTION }); + const before = yield* awaitThreadState(h.observed, (value) => value.status === "live"); + const edit = { + runId: RunId.make("removed-run"), + editId: CommandId.make("edit"), + sequence: 10, + }; + expect(queuedRunEditOwnership(before, edit)).toBe("pending"); + yield* Queue.offer(h.inputs, snapshot(BASE_PROJECTION, 12)); + const after = yield* awaitThreadState(h.observed, (value) => value.sequence === 12); + expect(queuedRunEditOwnership(after, edit)).toBe("lost"); + }), + ); + it.effect("loads the server thread when its local cache read fails", () => Effect.gen(function* () { const h = yield* makeHarness({ diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index a6e31cf40c4a..e4d6a589bbc1 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -203,9 +203,13 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ) : Option.none(); const cachedThread = Option.map(cached, (snapshot) => snapshot.projection); + const initialSequence = + retained?.sequence ?? + Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }); const initialState: EnvironmentThreadState = retained ? cachedThreadState(retained.state) : { + sequence: initialSequence, data: cachedThread, status: statusWithoutLiveData(cachedThread), error: Option.none(), @@ -221,9 +225,6 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const acceptsBoundedSocketSnapshots = yield* Ref.make(canLoadHistory); // Seed the resume cursor from the cached snapshot so a warm cache can catch up // via `afterSequence` instead of re-downloading the full thread body. - const initialSequence = - retained?.sequence ?? - Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }); const lastSequence = yield* SubscriptionRef.make(initialSequence); let committed: ThreadResumeSnapshot = { state: initialState, @@ -367,6 +368,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make }, ) { const waiting = yield* Ref.get(awaitingCompletion); + const sequence = yield* SubscriptionRef.get(lastSequence); // Atomic with concurrent history meta updates: never get-then-set the whole // state when only the projection changes. Bounded installs pass history so // projection + cursor persist together in one enqueue. @@ -379,6 +381,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make : previous.history; return { ...previous, + sequence, data: Option.some(thread), // Buffered values from a failed attempt can arrive after its error. status: Option.isSome(previous.error) @@ -416,6 +419,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* () { yield* Ref.set(awaitingCompletion, false); yield* SubscriptionRef.set(state, { + sequence: yield* SubscriptionRef.get(lastSequence), data: Option.none(), status: "deleted", error: Option.none(), @@ -467,7 +471,6 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make } if (sequence === appliedSequence) return; yield* SubscriptionRef.set(lastSequence, sequence); - if (fresh.length === 0) return; const waiting = yield* Ref.get(awaitingCompletion); // Apply against the latest projection/history in one update so a concurrent @@ -546,7 +549,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make if (applied._tag !== "noop") result = applied; if (applied._tag === "delete") break; } - return [result, current]; + return [result, { ...current, sequence }]; }, ); From f8c9469439d4528224ec5ab2b224ce8919aac18b Mon Sep 17 00:00:00 2001 From: Andrew Forster Date: Sat, 10 Oct 2026 16:14:23 -0700 Subject: [PATCH 07/21] test(mobile): provide thread state in composer permission tests --- apps/mobile/src/state/thread-task-permissions.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/state/thread-task-permissions.test.ts b/apps/mobile/src/state/thread-task-permissions.test.ts index 2fc2fecacc9e..63d59f039cc4 100644 --- a/apps/mobile/src/state/thread-task-permissions.test.ts +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -85,8 +85,12 @@ vi.mock("./use-thread-detail", () => ({ vi.mock("./use-atom-command", () => ({ useAtomCommand: (command: A) => command })); vi.mock("./threads", async () => { const { Atom } = await import("effect/reactivity"); + const { EMPTY_ENVIRONMENT_THREAD_STATE } = await import("@t3tools/client-runtime/state/threads"); return { - environmentThreadDetails: { queueWorkflowAtom: () => Atom.make(null) }, + environmentThreadDetails: { + queueWorkflowAtom: () => Atom.make(null), + stateAtom: () => Atom.make(EMPTY_ENVIRONMENT_THREAD_STATE), + }, threadEnvironment: { respondToApproval: state.approve, respondToUserInput: state.answer, From 80f07caaf3eada15ec7de5154b04204a593e41f0 Mon Sep 17 00:00:00 2001 From: Andrew Forster Date: Mon, 12 Oct 2026 04:21:11 -0700 Subject: [PATCH 08/21] fix(queue): preserve failure pauses while editing --- .../features/threads/ThreadQueueControl.tsx | 8 +- .../src/orchestration-v2/Orchestrator.ts | 7 +- .../orchestration-v2/QueuedRunEditing.test.ts | 210 ++++++++++-------- .../QueuedRunEditing.testkit.ts | 101 +++++++++ 4 files changed, 230 insertions(+), 96 deletions(-) create mode 100644 apps/server/src/orchestration-v2/QueuedRunEditing.testkit.ts diff --git a/apps/mobile/src/features/threads/ThreadQueueControl.tsx b/apps/mobile/src/features/threads/ThreadQueueControl.tsx index 3ffe18f4040b..68d44d5b00da 100644 --- a/apps/mobile/src/features/threads/ThreadQueueControl.tsx +++ b/apps/mobile/src/features/threads/ThreadQueueControl.tsx @@ -168,7 +168,11 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { try { if (action === "remove") { await cancel(buildCancelQueuedRunCommand({ ...target, runId })); - } else if (workflow?.activeRun && workflow.canPromoteToSteer) { + } else if ( + workflow?.activeRun && + workflow.canPromoteToSteer && + queuedRuns[index]!.run.queueEditId == null + ) { await promote({ ...target, input: { @@ -243,7 +247,7 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { : 0; const controls = resolveThreadQueueRowControls({ busy: busyRunId !== null || draggedRunId !== null, - canPromoteToSteer: workflow?.canPromoteToSteer ?? false, + canPromoteToSteer: workflow?.canPromoteToSteer === true && run.queueEditId == null, canReorder: workflow?.canReorder ?? false, index, isEditing: editing?.runId === run.id, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index b2f868963ed5..99a046402bd8 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1302,7 +1302,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return; } const queuedRun = nextQueuedRun(projection); - if (queuedRun === undefined || queuedRun.queueEditId != null) { + if (queuedRun === undefined) { return; } // A provider that just failed will likely fail the next message too. @@ -1341,6 +1341,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); return; } + if (queuedRun.queueEditId != null) return; const rootNodeId = queuedRun.rootNodeId; const attemptId = queuedRun.activeAttemptId; const providerThreadId = queuedRun.providerThreadId; @@ -7882,7 +7883,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const projection = yield* projectionStore .getThreadRecords(command.threadId, ["runs", "messages"], { messageRoles: ["user"] }) .pipe( - Effect.mapError(() => new OrchestratorProjectionError({ threadId: command.threadId })), + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), ); const run = projection.runs.find((candidate) => candidate.id === command.runId); const message = projection.messages.find((candidate) => candidate.id === run?.userMessageId); diff --git a/apps/server/src/orchestration-v2/QueuedRunEditing.test.ts b/apps/server/src/orchestration-v2/QueuedRunEditing.test.ts index 238b3354f73f..283731586a80 100644 --- a/apps/server/src/orchestration-v2/QueuedRunEditing.test.ts +++ b/apps/server/src/orchestration-v2/QueuedRunEditing.test.ts @@ -1,104 +1,130 @@ import { assert, it } from "@effect/vitest"; -import { - CommandId, - EventId, - MessageId, - ProjectId, - ProviderDriverKind, - ProviderInstanceId, - ThreadId, -} from "@t3tools/contracts"; +import { CommandId, EventId, RunId, TurnItemId } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; -import type { ProviderAdapterV2 } from "@t3tools/provider-core/server/ProviderAdapter"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; -import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as EventSink from "./EventSink.ts"; -import * as Orchestrator from "./Orchestrator.ts"; -import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; -import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; -const modelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "test" }; -const adapter = { - instanceId: modelSelection.instanceId, - driver: ProviderDriverKind.make("codex"), - getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), - planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), - openSession: () => Effect.die("provider execution is disabled in queue editing tests"), -} satisfies ProviderAdapterV2["Service"]; -const layer = ProviderReplayHarness.layerWithRegistry( - { name: "queued-run-editing" }, - ProviderAdapterRegistry.layerFromAdapters([adapter]), - { runEffectWorker: false }, -); - -const createQueue = Effect.fnUntraced(function* (name: string) { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const sink = yield* EventSink.EventSinkV2; - const threadId = ThreadId.make(`thread:${name}`); - yield* orchestrator.dispatch({ - type: "thread.create", - commandId: CommandId.make(`${name}:create`), - threadId, - projectId: ProjectId.make(`project:${name}`), - title: name, - modelSelection, - createdBy: "user", - creationSource: "web", - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: process.cwd(), - }); - for (const [index, text] of ["Active", "Original", "Later"].entries()) { - yield* orchestrator.dispatch({ - type: "message.dispatch", - commandId: CommandId.make(`${name}:message:${index}`), - threadId, - messageId: MessageId.make(`${name}:message:${index}`), - text, - attachments: [], - createdBy: "user", - creationSource: "web", - modelSelection, - dispatchMode: { type: index === 0 ? "start_immediately" : "queue_after_active" }, - }); - } - const projection = yield* orchestrator.getThreadProjection(threadId); - const [active, queued, later] = projection.runs; - assert.isDefined(active); - assert.isDefined(queued); - assert.isDefined(later); - const editId = CommandId.make(`${name}:begin`); - const begin = { - type: "queued-run.edit.begin" as const, - commandId: editId, - threadId, - runId: queued.id, - previousEditId: null, - }; - const complete = Effect.gen(function* () { - const now = yield* DateTime.now; - yield* sink.write({ - events: [ - { - id: EventId.make(`${name}:complete`), - type: "run.updated", - threadId, - runId: active.id, - providerInstanceId: active.providerInstanceId, - occurredAt: now, - payload: { ...active, status: "completed", completedAt: now }, - }, - ], - }); - yield* orchestrator.resumeQueuedRuns; - }); - return { orchestrator, threadId, active, queued, later, editId, begin, complete }; -}); +import { createQueue, layer } from "./QueuedRunEditing.testkit.ts"; it.layer(layer)("queued message editing", (it) => { + it.effect.each([ + { reason: "provider_error", action: "save" }, + { reason: "provider_error", action: "cancel" }, + { reason: "missing_worktree", action: "save" }, + { reason: "missing_worktree", action: "cancel" }, + ] as const)( + "$action preserves the queue pause after $reason during editing", + ({ reason, action }) => + Effect.gen(function* () { + const name = `${reason}-${action}`; + const { orchestrator, threadId, active, queued, later, editId, begin } = yield* createQueue( + name, + null, + ); + const sink = yield* EventSink.EventSinkV2; + yield* orchestrator.dispatch(begin); + const heldRunIds = yield* Queue.unbounded(); + const afterSequence = yield* orchestrator.getThreadEventSequence(threadId); + yield* sink.stream({ threadId, afterSequence }).pipe( + Stream.runForEach((stored) => + stored.event.type === "run.updated" && stored.event.payload.queueHeld === true + ? Queue.offer(heldRunIds, stored.event.payload.id) + : Effect.void, + ), + Effect.forkScoped, + ); + yield* Effect.yieldNow; + const now = yield* DateTime.now; + yield* sink.write({ + events: [ + ...(reason === "provider_error" + ? [ + { + id: EventId.make(`${name}:error`), + type: "turn-item.updated" as const, + threadId, + runId: active.id, + nodeId: active.rootNodeId!, + providerInstanceId: active.providerInstanceId, + occurredAt: now, + payload: { + id: TurnItemId.make(`${name}:error`), + type: "error" as const, + threadId, + runId: active.id, + nodeId: active.rootNodeId!, + providerThreadId: active.providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 2, + status: "failed" as const, + title: "Provider failure", + startedAt: now, + completedAt: now, + updatedAt: now, + failure: { + class: "provider_error" as const, + message: "Provider failed.", + code: "provider_failed", + retryable: null, + }, + }, + }, + ] + : []), + { + id: EventId.make(`${name}:terminal`), + type: "run.updated", + threadId, + runId: active.id, + providerInstanceId: active.providerInstanceId, + occurredAt: now, + payload: { + ...active, + status: reason === "provider_error" ? "failed" : "interrupted", + completedAt: now, + ...(reason === "missing_worktree" + ? { workspacePreparation: { type: "worktree" as const, baseRef: "main" } } + : {}), + }, + }, + ], + }); + assert.equal(yield* Queue.take(heldRunIds), queued.id); + yield* orchestrator.dispatch( + action === "save" + ? { + type: "queued-run.edit", + commandId: CommandId.make(`${name}:finish`), + threadId, + runId: queued.id, + editId, + text: "Edited", + } + : { + type: "queued-run.edit.cancel", + commandId: CommandId.make(`${name}:finish`), + threadId, + runId: queued.id, + editId, + }, + ); + const after = yield* orchestrator.getThreadProjection(threadId); + for (const id of [queued.id, later.id]) { + const run = after.runs.find((candidate) => candidate.id === id); + assert.equal(run?.status, "queued"); + assert.isTrue(run?.queueHeld); + assert.isFalse(after.turnItems.some((item) => item.runId === id)); + } + assert.isNull(after.runs.find((run) => run.id === queued.id)?.queueEditId); + }), + ); + it.effect.each(["save", "cancel"] as const)( "%s releases an edit only after completion has left the original queued", (action) => diff --git a/apps/server/src/orchestration-v2/QueuedRunEditing.testkit.ts b/apps/server/src/orchestration-v2/QueuedRunEditing.testkit.ts new file mode 100644 index 000000000000..4d5fb16e1d9c --- /dev/null +++ b/apps/server/src/orchestration-v2/QueuedRunEditing.testkit.ts @@ -0,0 +1,101 @@ +import { assert } from "@effect/vitest"; +import { + CommandId, + EventId, + MessageId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import type { ProviderAdapterV2 } from "@t3tools/provider-core/server/ProviderAdapter"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; + +import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import * as EventSink from "./EventSink.ts"; +import * as Orchestrator from "./Orchestrator.ts"; +import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; +import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; + +const modelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "test" }; +const adapter = { + instanceId: modelSelection.instanceId, + driver: ProviderDriverKind.make("codex"), + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), + openSession: () => Effect.die("provider execution is disabled in queue editing tests"), +} satisfies ProviderAdapterV2["Service"]; +export const layer = ProviderReplayHarness.layerWithRegistry( + { name: "queued-run-editing" }, + ProviderAdapterRegistry.layerFromAdapters([adapter]), + { runEffectWorker: false }, +); + +export const createQueue = Effect.fnUntraced(function* ( + name: string, + worktreePath: string | null = process.cwd(), +) { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const sink = yield* EventSink.EventSinkV2; + const threadId = ThreadId.make(`thread:${name}`); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${name}:create`), + threadId, + projectId: ProjectId.make(`project:${name}`), + title: name, + modelSelection, + createdBy: "user", + creationSource: "web", + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath, + }); + for (const [index, text] of ["Active", "Original", "Later"].entries()) { + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${name}:message:${index}`), + threadId, + messageId: MessageId.make(`${name}:message:${index}`), + text, + attachments: [], + createdBy: "user", + creationSource: "web", + modelSelection, + dispatchMode: { type: index === 0 ? "start_immediately" : "queue_after_active" }, + }); + } + const projection = yield* orchestrator.getThreadProjection(threadId); + const [active, queued, later] = projection.runs; + assert.isDefined(active); + assert.isDefined(queued); + assert.isDefined(later); + const editId = CommandId.make(`${name}:begin`); + const begin = { + type: "queued-run.edit.begin" as const, + commandId: editId, + threadId, + runId: queued.id, + previousEditId: null, + }; + const complete = Effect.gen(function* () { + const now = yield* DateTime.now; + yield* sink.write({ + events: [ + { + id: EventId.make(`${name}:complete`), + type: "run.updated", + threadId, + runId: active.id, + providerInstanceId: active.providerInstanceId, + occurredAt: now, + payload: { ...active, status: "completed", completedAt: now }, + }, + ], + }); + yield* orchestrator.resumeQueuedRuns; + }); + return { orchestrator, threadId, active, queued, later, editId, begin, complete }; +}); From f262509ed4971b9097907faebc0f0b07d4aa3682 Mon Sep 17 00:00:00 2001 From: GLDRoger <130458023+GLDRoger@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:21:32 +0530 Subject: [PATCH 09/21] fix(server): preserve queue pauses after edit release and reorder Edit release could start a queued run before the terminal listener paused it, and reordering ahead of an edit-held head could leave eligible work stranded. Derive automatic queue eligibility from the latest executed run in durable projection state, reuse the existing failure and worktree checks, and re-evaluate reordered queues on commit and replay. Keep explicit queue.resume as the user override. Add deterministic listener-gated regressions for provider failures, missing worktrees, successful completion, validation failures, and resume behavior. Model: GPT-6.1 Sol. Harness: T3 Code (Codex). --- .../src/orchestration-v2/Orchestrator.ts | 44 ++-- .../orchestration-v2/QueuedRunEditing.test.ts | 228 +++++++++++++----- .../QueuedRunEditing.testkit.ts | 117 ++++++++- 3 files changed, 290 insertions(+), 99 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 6abf3f18b593..3c1005ba81eb 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1274,10 +1274,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ]); }); - const startNextQueuedRun = ( - threadId: ThreadId, - options?: { readonly failedRunId?: RunId; readonly endedRunId?: RunId }, - ) => + const startNextQueuedRun = (threadId: ThreadId, options?: { readonly resume?: boolean }) => Effect.gen(function* () { // Every terminal run checks the queue. Only a deliverable queued run // needs the transcript for provider handoff and legacy import context. @@ -1314,14 +1311,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // and a message queued for another provider is how users recover. // A message queued behind a worktree that was cancelled or never created // would run in the project checkout instead, so it waits for the user. - const failedRun = latestExecutedRun(projection.runs); + // Commands can make the queue eligible before the terminal listener + // handles the last run. Only an explicit Resume bypasses its pause. + const latestRun = latestExecutedRun(projection.runs); const failureClass = - failedRun?.id === options?.failedRunId - ? latestRootProviderFailure(failedRun, projection.turnItems)?.class + options?.resume !== true + ? latestRootProviderFailure(latestRun, projection.turnItems)?.class : undefined; const worktreeMissing = - failedRun?.id === options?.endedRunId && - failedRun?.workspacePreparation?.type === "worktree" && + options?.resume !== true && + latestRun?.workspacePreparation?.type === "worktree" && projection.thread.worktreePath === null; // A project can turn a provider off while a message waits in the queue, // including between a run ending and this check, or while the queue is @@ -1348,7 +1347,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio instanceDisabledForProject || (failureClass !== undefined && failureClass !== "validation_error" && - failedRun?.providerInstanceId === queuedRun.providerInstanceId) + latestRun?.providerInstanceId === queuedRun.providerInstanceId) ) { const now = yield* DateTime.now; yield* writeSystemEvents( @@ -11074,10 +11073,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); if ( command.type === "queue.resume" || + command.type === "queued-run.reorder" || command.type === "queued-run.edit.cancel" || (command.type === "queued-run.edit" && command.editId !== undefined) ) { - yield* mapDispatchError(command)(startNextQueuedRun(command.threadId)); + yield* mapDispatchError(command)( + startNextQueuedRun(command.threadId, { resume: command.type === "queue.resume" }), + ); } return { sequence: receipt.resultSequence, @@ -11216,10 +11218,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } if ( command.type === "queue.resume" || + command.type === "queued-run.reorder" || command.type === "queued-run.edit.cancel" || (command.type === "queued-run.edit" && command.editId !== undefined) ) { - yield* mapDispatchError(command)(startNextQueuedRun(command.threadId)); + yield* mapDispatchError(command)( + startNextQueuedRun(command.threadId, { resume: command.type === "queue.resume" }), + ); } if (command.type === "notification.delivery.accept") { yield* mapDispatchError(command)(offerDelegatedCompletionDeliveries(command.threadId)); @@ -11272,20 +11277,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); } yield* threadDispatch - .withLock( - threadId, - startNextQueuedRun( - threadId, - stored.event.type === "run.updated" - ? { - endedRunId: stored.event.payload.id, - ...(stored.event.payload.status === "failed" - ? { failedRunId: stored.event.payload.id } - : {}), - } - : undefined, - ), - ) + .withLock(threadId, startNextQueuedRun(threadId)) .pipe( Effect.catchCause((cause) => Effect.logWarning("Failed to start the next queued V2 run", { threadId, cause }), diff --git a/apps/server/src/orchestration-v2/QueuedRunEditing.test.ts b/apps/server/src/orchestration-v2/QueuedRunEditing.test.ts index 283731586a80..3e022dd5ee9f 100644 --- a/apps/server/src/orchestration-v2/QueuedRunEditing.test.ts +++ b/apps/server/src/orchestration-v2/QueuedRunEditing.test.ts @@ -1,6 +1,5 @@ import { assert, it } from "@effect/vitest"; -import { CommandId, EventId, RunId, TurnItemId } from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; +import { CommandId, RunId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Queue from "effect/Queue"; @@ -8,7 +7,7 @@ import * as Stream from "effect/Stream"; import * as EventSink from "./EventSink.ts"; -import { createQueue, layer } from "./QueuedRunEditing.testkit.ts"; +import { createQueue, layer, pauseTerminalListener } from "./QueuedRunEditing.testkit.ts"; it.layer(layer)("queued message editing", (it) => { it.effect.each([ @@ -21,10 +20,8 @@ it.layer(layer)("queued message editing", (it) => { ({ reason, action }) => Effect.gen(function* () { const name = `${reason}-${action}`; - const { orchestrator, threadId, active, queued, later, editId, begin } = yield* createQueue( - name, - null, - ); + const { orchestrator, threadId, queued, later, editId, begin, endActiveRun } = + yield* createQueue(name, null); const sink = yield* EventSink.EventSinkV2; yield* orchestrator.dispatch(begin); const heldRunIds = yield* Queue.unbounded(); @@ -38,63 +35,7 @@ it.layer(layer)("queued message editing", (it) => { Effect.forkScoped, ); yield* Effect.yieldNow; - const now = yield* DateTime.now; - yield* sink.write({ - events: [ - ...(reason === "provider_error" - ? [ - { - id: EventId.make(`${name}:error`), - type: "turn-item.updated" as const, - threadId, - runId: active.id, - nodeId: active.rootNodeId!, - providerInstanceId: active.providerInstanceId, - occurredAt: now, - payload: { - id: TurnItemId.make(`${name}:error`), - type: "error" as const, - threadId, - runId: active.id, - nodeId: active.rootNodeId!, - providerThreadId: active.providerThreadId, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: 2, - status: "failed" as const, - title: "Provider failure", - startedAt: now, - completedAt: now, - updatedAt: now, - failure: { - class: "provider_error" as const, - message: "Provider failed.", - code: "provider_failed", - retryable: null, - }, - }, - }, - ] - : []), - { - id: EventId.make(`${name}:terminal`), - type: "run.updated", - threadId, - runId: active.id, - providerInstanceId: active.providerInstanceId, - occurredAt: now, - payload: { - ...active, - status: reason === "provider_error" ? "failed" : "interrupted", - completedAt: now, - ...(reason === "missing_worktree" - ? { workspacePreparation: { type: "worktree" as const, baseRef: "main" } } - : {}), - }, - }, - ], - }); + yield* endActiveRun(reason); assert.equal(yield* Queue.take(heldRunIds), queued.id); yield* orchestrator.dispatch( action === "save" @@ -125,6 +66,165 @@ it.layer(layer)("queued message editing", (it) => { }), ); + it.effect.each([ + { reason: "provider_error", action: "save" }, + { reason: "provider_error", action: "cancel" }, + { reason: "provider_error", action: "reorder" }, + { reason: "missing_worktree", action: "save" }, + { reason: "missing_worktree", action: "cancel" }, + { reason: "missing_worktree", action: "reorder" }, + ] as const)( + "$action holds the queue after $reason before the terminal listener handles it", + ({ reason, action }) => + Effect.gen(function* () { + const name = `before-listener-${reason}-${action}`; + const { orchestrator, threadId, queued, later, editId, begin, endActiveRun } = + yield* createQueue(name, null); + yield* orchestrator.dispatch(begin); + const listener = yield* pauseTerminalListener(threadId); + yield* endActiveRun(reason); + yield* listener.entered; + const before = yield* orchestrator.getThreadProjection(threadId); + assert.isFalse(before.runs.some((run) => run.queueHeld === true)); + const finish = + action === "save" + ? { + type: "queued-run.edit" as const, + commandId: CommandId.make(`${name}:finish`), + threadId, + runId: queued.id, + editId, + text: "Edited", + } + : action === "cancel" + ? { + type: "queued-run.edit.cancel" as const, + commandId: CommandId.make(`${name}:finish`), + threadId, + runId: queued.id, + editId, + } + : { + type: "queued-run.reorder" as const, + commandId: CommandId.make(`${name}:finish`), + threadId, + runId: later.id, + beforeRunId: queued.id, + }; + yield* orchestrator.dispatch(finish); + yield* orchestrator.dispatch(finish); + const after = yield* orchestrator.getThreadProjection(threadId); + for (const id of [queued.id, later.id]) { + const run = after.runs.find((candidate) => candidate.id === id); + assert.equal(run?.status, "queued"); + assert.isTrue(run?.queueHeld); + assert.isFalse(after.turnItems.some((item) => item.runId === id)); + } + assert.equal( + after.runs.find((run) => run.id === queued.id)?.queueEditId, + action === "reorder" ? editId : null, + ); + if (action === "reorder") { + assert.equal(after.runs.find((run) => run.id === later.id)?.queuePosition, 1); + } + yield* orchestrator.dispatch({ + type: "queue.resume", + commandId: CommandId.make(`${name}:resume`), + threadId, + }); + const resumed = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + resumed.runs.find((run) => run.id === (action === "reorder" ? later.id : queued.id)) + ?.status, + "starting", + "An explicit resume still overrides the terminal pause", + ); + yield* listener.release; + }), + ); + + it.effect( + "reordering ahead of an edit-held head starts the eligible message after completion", + () => + Effect.gen(function* () { + const { orchestrator, threadId, queued, later, editId, begin, endActiveRun } = + yield* createQueue("reorder-completed"); + yield* orchestrator.dispatch(begin); + const listener = yield* pauseTerminalListener(threadId); + yield* endActiveRun("completed"); + yield* listener.entered; + yield* listener.release; + yield* listener.handled; + const before = yield* orchestrator.getThreadProjection(threadId); + assert.equal(before.runs.find((run) => run.id === queued.id)?.status, "queued"); + assert.equal(before.runs.find((run) => run.id === later.id)?.status, "queued"); + assert.isFalse(before.runs.some((run) => run.queueHeld === true)); + const reorder = { + type: "queued-run.reorder" as const, + commandId: CommandId.make("reorder-completed:reorder"), + threadId, + runId: later.id, + beforeRunId: queued.id, + }; + yield* orchestrator.dispatch(reorder); + yield* orchestrator.dispatch(reorder); + const after = yield* orchestrator.getThreadProjection(threadId); + assert.equal(after.runs.find((run) => run.id === later.id)?.status, "starting"); + assert.equal(after.runs.find((run) => run.id === queued.id)?.status, "queued"); + assert.equal(after.runs.find((run) => run.id === queued.id)?.queueEditId, editId); + assert.isFalse(after.turnItems.some((item) => item.runId === queued.id)); + assert.equal(after.turnItems.filter((item) => item.runId === later.id).length, 1); + }), + ); + + it.effect.each(["save", "cancel", "reorder"] as const)( + "%s does not pause the queue after a message-local validation failure", + (action) => + Effect.gen(function* () { + const name = `validation-${action}`; + const { orchestrator, threadId, queued, later, editId, begin, endActiveRun } = + yield* createQueue(name); + yield* orchestrator.dispatch(begin); + const listener = yield* pauseTerminalListener(threadId); + yield* endActiveRun("validation_error"); + yield* listener.entered; + yield* orchestrator.dispatch( + action === "save" + ? { + type: "queued-run.edit", + commandId: CommandId.make(`${name}:finish`), + threadId, + runId: queued.id, + editId, + text: "Edited", + } + : action === "cancel" + ? { + type: "queued-run.edit.cancel", + commandId: CommandId.make(`${name}:finish`), + threadId, + runId: queued.id, + editId, + } + : { + type: "queued-run.reorder", + commandId: CommandId.make(`${name}:finish`), + threadId, + runId: later.id, + beforeRunId: queued.id, + }, + ); + const after = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + after.runs.find((run) => run.id === (action === "reorder" ? later.id : queued.id)) + ?.status, + "starting", + ); + assert.isFalse(after.runs.some((run) => run.queueHeld === true)); + yield* listener.release; + }), + ); + it.effect.each(["save", "cancel"] as const)( "%s releases an edit only after completion has left the original queued", (action) => diff --git a/apps/server/src/orchestration-v2/QueuedRunEditing.testkit.ts b/apps/server/src/orchestration-v2/QueuedRunEditing.testkit.ts index 4d5fb16e1d9c..d3a7462a4bb1 100644 --- a/apps/server/src/orchestration-v2/QueuedRunEditing.testkit.ts +++ b/apps/server/src/orchestration-v2/QueuedRunEditing.testkit.ts @@ -7,15 +7,20 @@ import { ProviderDriverKind, ProviderInstanceId, ThreadId, + TurnItemId, } from "@t3tools/contracts"; import type { ProviderAdapterV2 } from "@t3tools/provider-core/server/ProviderAdapter"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { vi } from "vite-plus/test"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as EventSink from "./EventSink.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; +import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; const modelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "test" }; @@ -26,12 +31,56 @@ const adapter = { planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), openSession: () => Effect.die("provider execution is disabled in queue editing tests"), } satisfies ProviderAdapterV2["Service"]; -export const layer = ProviderReplayHarness.layerWithRegistry( - { name: "queued-run-editing" }, - ProviderAdapterRegistry.layerFromAdapters([adapter]), - { runEffectWorker: false }, +export const layer = Layer.merge( + ProviderReplayHarness.layerWithRegistry( + { name: "queued-run-editing" }, + ProviderAdapterRegistry.layerFromAdapters([adapter]), + { runEffectWorker: false }, + ), + ThreadCommandExecutor.layer, ); +// Park the terminal listener before it takes the thread lock, while allowing +// subsequent user commands through the real executor. +export const pauseTerminalListener = Effect.fnUntraced(function* (threadId: ThreadId) { + const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor; + const entered = yield* Deferred.make(); + const released = yield* Deferred.make(); + const handled = yield* Deferred.make(); + const withLock = executor.withLock; + let paused = false; + let finalized = false; + const intercept: typeof withLock = (key, effect) => { + if (key !== threadId) return withLock(key, effect); + if (paused) { + return finalized + ? withLock(key, effect).pipe(Effect.tap(() => Deferred.succeed(handled, undefined))) + : withLock(key, effect); + } + paused = true; + return Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(released)), + Effect.andThen(withLock(key, effect)), + Effect.tap(() => + Effect.sync(() => { + finalized = true; + }), + ), + ); + }; + const spy = vi.spyOn(executor, "withLock").mockImplementation(intercept); + yield* Effect.addFinalizer(() => + Effect.sync(() => spy.mockRestore()).pipe( + Effect.andThen(Deferred.succeed(released, undefined)), + ), + ); + return { + entered: Deferred.await(entered), + release: Deferred.succeed(released, undefined), + handled: Deferred.await(handled), + }; +}); + export const createQueue = Effect.fnUntraced(function* ( name: string, worktreePath: string | null = process.cwd(), @@ -80,22 +129,72 @@ export const createQueue = Effect.fnUntraced(function* ( runId: queued.id, previousEditId: null, }; - const complete = Effect.gen(function* () { + const endActiveRun = Effect.fnUntraced(function* ( + reason: "completed" | "provider_error" | "validation_error" | "missing_worktree", + ) { const now = yield* DateTime.now; yield* sink.write({ events: [ + ...(reason === "provider_error" || reason === "validation_error" + ? [ + { + id: EventId.make(`${name}:error`), + type: "turn-item.updated" as const, + threadId, + runId: active.id, + nodeId: active.rootNodeId!, + providerInstanceId: active.providerInstanceId, + occurredAt: now, + payload: { + id: TurnItemId.make(`${name}:error`), + type: "error" as const, + threadId, + runId: active.id, + nodeId: active.rootNodeId!, + providerThreadId: active.providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 2, + status: "failed" as const, + title: "Provider failure", + startedAt: now, + completedAt: now, + updatedAt: now, + failure: { + class: reason, + message: "Provider failed.", + code: "provider_failed", + retryable: null, + }, + }, + }, + ] + : []), { - id: EventId.make(`${name}:complete`), + id: EventId.make(`${name}:terminal`), type: "run.updated", threadId, runId: active.id, providerInstanceId: active.providerInstanceId, occurredAt: now, - payload: { ...active, status: "completed", completedAt: now }, + payload: { + ...active, + status: + reason === "completed" + ? "completed" + : reason === "missing_worktree" + ? "interrupted" + : "failed", + completedAt: now, + ...(reason === "missing_worktree" + ? { workspacePreparation: { type: "worktree" as const, baseRef: "main" } } + : {}), + }, }, ], }); - yield* orchestrator.resumeQueuedRuns; }); - return { orchestrator, threadId, active, queued, later, editId, begin, complete }; + const complete = endActiveRun("completed").pipe(Effect.andThen(orchestrator.resumeQueuedRuns)); + return { orchestrator, threadId, active, queued, later, editId, begin, complete, endActiveRun }; }); From f2b0c4d62f231e15cbb0f63f352c552e57e049e7 Mon Sep 17 00:00:00 2001 From: Andrew Forster Date: Wed, 14 Oct 2026 04:30:45 -0700 Subject: [PATCH 10/21] fix(queue): require protocol 3 for edit holds --- .../client-runtime/src/connection/compatibility.test.ts | 6 ++++++ packages/client-runtime/src/connection/resolver.test.ts | 5 ++--- packages/contracts/src/environment.ts | 4 ++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/client-runtime/src/connection/compatibility.test.ts b/packages/client-runtime/src/connection/compatibility.test.ts index 3f9dc8f99245..4f002ae6bd57 100644 --- a/packages/client-runtime/src/connection/compatibility.test.ts +++ b/packages/client-runtime/src/connection/compatibility.test.ts @@ -20,6 +20,12 @@ const descriptor = (orchestrationProtocolVersion?: number): ExecutionEnvironment }); describe("orchestration protocol compatibility", () => { + it("requires an update for protocol 2 servers without queued-edit holds", () => { + const error = orchestrationProtocolCompatibilityError(descriptor(2)); + expect(error).toMatchObject({ reason: "unsupported" }); + expect(error?.message).toContain("This client requires a newer server"); + }); + it("accepts the current protocol and announces it without disturbing socket credentials", () => { expect( orchestrationProtocolCompatibilityError(descriptor(ORCHESTRATION_PROTOCOL_VERSION)), diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index b99b686fd48f..4e683c86b42f 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -239,8 +239,7 @@ describe("ConnectionResolver", () => { environmentId: ENVIRONMENT_ID, label: "Primary", httpBaseUrl: "http://127.0.0.1:3777", - socketUrl: - "ws://127.0.0.1:3777/ws?clientSurface=web&clientDeviceType=desktop&connectionMethod=direct&orchestrationProtocol=2", + socketUrl: `ws://127.0.0.1:3777/ws?clientSurface=web&clientDeviceType=desktop&connectionMethod=direct&orchestrationProtocol=${ORCHESTRATION_PROTOCOL_VERSION}`, httpAuthorization: null, target, }); @@ -278,7 +277,7 @@ describe("ConnectionResolver", () => { }); expect(yield* broker.prepare(catalogEntry(target))).toMatchObject({ - socketUrl: "ws://127.0.0.1:3777/ws?wsTicket=desktop&orchestrationProtocol=2", + socketUrl: `ws://127.0.0.1:3777/ws?wsTicket=desktop&orchestrationProtocol=${ORCHESTRATION_PROTOCOL_VERSION}`, httpAuthorization: { _tag: "Bearer", token: "desktop-bearer" }, target, }); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index a4763076639b..f0ded6a87a69 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -10,8 +10,8 @@ import { } from "./baseSchemas.ts"; /** Wire version for orchestration snapshots, streams, commands, and RPC payloads. */ -export const ORCHESTRATION_PROTOCOL_VERSION = 2; -export const ORCHESTRATION_PROTOCOL_VERSION_TEXT = "2"; +export const ORCHESTRATION_PROTOCOL_VERSION = 3; +export const ORCHESTRATION_PROTOCOL_VERSION_TEXT = "3"; export const ORCHESTRATION_PROTOCOL_QUERY_PARAM = "orchestrationProtocol"; export const ORCHESTRATION_PROTOCOL_HEADER = "x-t3-orchestration-protocol"; From 9e13fb8e7cfece1468cc9b051c4f3c21f454315c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 05:55:40 +0000 Subject: [PATCH 11/21] fix(queue): hold queued messages during composer edits --- .../features/threads/ThreadDetailScreen.tsx | 27 ++ .../features/threads/ThreadQueueControl.tsx | 161 ++++++- .../features/threads/ThreadRouteScreen.tsx | 3 + apps/mobile/src/lib/attachmentUpload.ts | 2 +- apps/mobile/src/lib/composer-image-schema.ts | 3 +- .../src/lib/composerContextClipboard.ts | 2 +- apps/mobile/src/state/queued-run-edit.test.ts | 176 ++++++- apps/mobile/src/state/queued-run-edit.ts | 214 +++++++-- .../src/state/use-composer-drafts.test.ts | 17 + apps/mobile/src/state/use-composer-drafts.ts | 19 +- .../src/state/use-thread-composer-state.ts | 103 ++-- .../src/environment/ServerEnvironment.test.ts | 1 + .../src/environment/ServerEnvironment.ts | 1 + .../src/orchestration-v2/Orchestrator.ts | 102 +++- .../src/orchestration-v2/ProjectionStore.ts | 10 +- .../src/orchestration-v2/runtimeLayer.test.ts | 446 ++++++++++++++++++ .../testkit/OrchestratorScenario.ts | 2 + apps/web/src/components/ChatView.tsx | 25 + .../src/components/chat/QueuedRunsControl.tsx | 31 +- .../components/chat/queuedMessageEdit.test.ts | 162 ++++++- .../src/components/chat/queuedMessageEdit.ts | 149 +++++- .../src/components/chat/useQueuedRunEdit.ts | 248 +++++++--- apps/web/src/composerDraftStore.ts | 25 +- docs/user/composer.md | 12 + packages/client-runtime/package.json | 4 + .../src/connection/resolver.test.ts | 5 +- .../src/operations/commands.test.ts | 42 ++ .../client-runtime/src/operations/commands.ts | 43 ++ .../src/state/queuedRunEditDraft.ts | 32 ++ .../src/state/threadCommands.ts | 18 + .../src/state/threadExecution.test.ts | 25 + .../client-runtime/src/state/threadState.ts | 1 + .../src/state/threads-sync.test.ts | 78 +++ packages/client-runtime/src/state/threads.ts | 28 +- packages/contracts/src/environment.ts | 1 + packages/contracts/src/orchestrationV2.ts | 16 + .../shared/src/orchestrationV2ThreadError.ts | 13 +- 37 files changed, 2020 insertions(+), 227 deletions(-) create mode 100644 packages/client-runtime/src/state/queuedRunEditDraft.ts diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 681bd852934f..d9e22715c7ae 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,5 +1,6 @@ import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult } from "effect/reactivity"; +import { Button } from "../../components/ui/Button"; import * as Dialog from "../../components/ui/Dialog"; import { useAtomValue } from "@effect/atom-react"; import { useChildThreadInputs, useThreadReportedModelSelection } from "../../state/entities"; @@ -193,6 +194,9 @@ export interface ThreadDetailScreenProps { readonly activeThreadBusy: boolean; readonly canStopThread: boolean; readonly queuedRunEdit: QueuedRunEdit | null; + readonly savedQueuedEditDrafts: ReadonlyArray; + readonly recoveryDraftKey: string | null; + readonly onSelectRecoveryDraft: (key: string | null) => void; readonly composerDraftKey: string | null; readonly followUpBehavior: FollowUpBehavior; readonly canSteerActiveTurn: boolean; @@ -1149,6 +1153,29 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai }} /> + {props.savedQueuedEditDrafts.length > 0 ? ( + + {props.recoveryDraftKey !== null ? ( + + ) : null} + {queuedRunEdit.savedEditDrafts.map((target, index) => ( + + ))} + + ) : null} diff --git a/apps/web/src/components/chat/QueuedRunsControl.tsx b/apps/web/src/components/chat/QueuedRunsControl.tsx index 2cef09911f34..b2e343a64da0 100644 --- a/apps/web/src/components/chat/QueuedRunsControl.tsx +++ b/apps/web/src/components/chat/QueuedRunsControl.tsx @@ -130,6 +130,7 @@ export function QueuedRunsControl({ name: attachment.name, url: queuedImageUrlById.get(attachment.id) ?? null, })), + held: run.queueEditId != null, pending: false, })), ...optimisticQueued.map((message) => ({ @@ -146,6 +147,7 @@ export function QueuedRunsControl({ name: attachment.name, url: attachment.previewUrl ?? null, })), + held: false, pending: true, })), ]; @@ -170,7 +172,13 @@ export function QueuedRunsControl({ }; const steer = async (queuedRunId: RunId) => { - if (activeRun === null || !workflow?.canPromoteToSteer || steerInFlightRef.current) return; + if ( + activeRun === null || + !workflow?.canPromoteToSteer || + steerInFlightRef.current || + queued.find(({ run }) => run.id === queuedRunId)?.run.queueEditId != null + ) + return; steerInFlightRef.current = true; setBusyRunId(queuedRunId); await promote({ @@ -184,7 +192,7 @@ export function QueuedRunsControl({ useImperativeHandle(ref, () => ({ steerNext(repeat) { const next = queued[0]; - if (!next || !workflow?.canPromoteToSteer) return false; + if (!next || !workflow?.canPromoteToSteer || next.run.queueEditId != null) return false; if (!repeat && busyRunId === null) void steer(next.run.id); return true; }, @@ -338,6 +346,11 @@ export function QueuedRunsControl({ ) : null} + {item.held && !isEditing ? ( + + Editing elsewhere + + ) : null} {isEditing ? Editing queued message: : null} {item.pending ? ( { if (item.runId !== null && item.messageId !== null) { @@ -413,7 +430,9 @@ export function QueuedRunsControl({ - {`Edit in the composer${item.serverIndex === queued.length - 1 && props.editShortcutLabel ? ` (${props.editShortcutLabel})` : ""}`} + {item.held + ? "Take over editing, then Save or Cancel to release the message." + : `Edit in the composer${item.serverIndex === queued.length - 1 && props.editShortcutLabel ? ` (${props.editShortcutLabel})` : ""}`} @@ -424,7 +443,9 @@ export function QueuedRunsControl({ disabled={ item.runId === null || busyRunId !== null || - !workflow?.canPromoteToSteer + !workflow?.canPromoteToSteer || + queued.find(({ run }) => run.id === item.runId)?.run.queueEditId != + null } onClick={() => { if (item.runId !== null) { diff --git a/apps/web/src/components/chat/queuedMessageEdit.test.ts b/apps/web/src/components/chat/queuedMessageEdit.test.ts index 50e9e469c17b..a3ecac391d02 100644 --- a/apps/web/src/components/chat/queuedMessageEdit.test.ts +++ b/apps/web/src/components/chat/queuedMessageEdit.test.ts @@ -1,17 +1,42 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { CommandId, EnvironmentId, MessageId, RunId, ThreadId } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { beforeEach, describe, expect, it } from "vite-plus/test"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +vi.hoisted(() => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }; + vi.stubGlobal("localStorage", storage); + vi.stubGlobal("window", { localStorage: storage }); +}); +afterAll(() => vi.unstubAllGlobals()); import { DraftId, useComposerDraftStore, + partializeComposerDraftStoreState, + hydrateImagesFromPersisted, type ComposerFileAttachment, type ComposerImageAttachment, } from "../../composerDraftStore"; -import { prepareQueuedEditAttachments, recoverQueuedMessageEdit } from "./queuedMessageEdit"; +import { + prepareQueuedEditAttachments, + prepareQueuedMessageEditDraft, + recoverQueuedMessageEdit, + releaseQueuedMessageEditDraft, +} from "./queuedMessageEdit"; const environmentId = EnvironmentId.make("remote-environment"); const threadTarget = scopeThreadRef(environmentId, ThreadId.make("thread:edit")); const editTarget = DraftId.make("queued-edit:test"); +const edit = { + editId: CommandId.make("edit-1"), + sequence: 1, + runId: RunId.make("run-1"), + messageId: MessageId.make("message-1"), +}; + const file: ComposerFileAttachment = { type: "file", id: "file:report", @@ -116,9 +141,136 @@ describe("queued message file edits", () => { store.setPrompt(threadTarget, "Separate draft"); store.addFiles(editTarget, [file]); expect(recoverQueuedMessageEdit({ editTarget, threadTarget, originalText: "" })).toBe( - "discarded", + "retained", ); expect(store.getComposerDraft(threadTarget)?.prompt).toBe("Separate draft"); - expect(store.getComposerDraft(editTarget)).toBeNull(); + expect(store.getComposerDraft(editTarget)?.files).toEqual([file]); + }); + + it("restores ownership and dirty content through draft hydration, then releases locally", async () => { + const store = useComposerDraftStore.getState(); + await prepareQueuedMessageEditDraft({ + editTarget, + edit, + environmentId, + text: "Original", + attachments: [], + readImage: async () => { + throw new Error("unused"); + }, + }); + store.setPrompt(editTarget, ""); + store.setPrompt(threadTarget, "Separate draft"); + const persisted = JSON.parse( + JSON.stringify(partializeComposerDraftStoreState(useComposerDraftStore.getState())), + ); + useComposerDraftStore.setState({ draftsByThreadKey: {} }); + const merge = useComposerDraftStore.persist.getOptions().merge!; + useComposerDraftStore.setState(merge(persisted, useComposerDraftStore.getState())); + expect(store.getComposerDraft(editTarget)?.queuedEdit?.ownership).toEqual({ + editId: edit.editId, + sequence: 1, + }); + expect(store.getComposerDraft(editTarget)?.prompt).toBe(""); + expect(store.getComposerDraft(threadTarget)?.prompt).toBe("Separate draft"); + releaseQueuedMessageEditDraft(editTarget); + expect(store.getComposerDraft(editTarget)?.queuedEdit?.ownership).toBeUndefined(); }); + + it.each(["text", "attachments"])( + "retains an older dirty edit separately when server %s changes", + async (change) => { + const store = useComposerDraftStore.getState(); + const prepare = (text: string, attachments: (typeof uploadedFile)[]) => + prepareQueuedMessageEditDraft({ + editTarget, + edit, + environmentId, + text, + attachments, + readImage: async () => { + throw new Error("unused"); + }, + }); + await prepare("Original", []); + store.setPrompt(editTarget, "My older edit"); + releaseQueuedMessageEditDraft(editTarget); + await prepare("Original", []); + expect(store.getComposerDraft(editTarget)?.prompt).toBe("My older edit"); + releaseQueuedMessageEditDraft(editTarget); + await prepare( + change === "text" ? "New server text" : "Original", + change === "attachments" ? [uploadedFile] : [], + ); + const fresh = store.getComposerDraft(editTarget)!; + expect(fresh.prompt).toContain(change === "text" ? "New server text" : "Original"); + expect(fresh.files.map((file) => file.id)).toEqual( + change === "attachments" ? [uploadedFile.id] : [], + ); + const retained = Object.entries(useComposerDraftStore.getState().draftsByThreadKey).find( + ([key]) => key.startsWith(`${editTarget}:saved:`), + )?.[1]; + expect(retained?.prompt).toBe("My older edit"); + expect(retained?.queuedEdit?.ownership).toBeUndefined(); + }, + ); + + it.each(["attachment removal", "text edit"])( + "recovers the selected server attachments after %s and restart", + async (change) => { + const store = useComposerDraftStore.getState(); + const first = { + type: "image" as const, + id: "saved:first", + name: "first.png", + mimeType: "image/png", + sizeBytes: 5, + }; + const second = { ...first, id: "saved:second", name: "second.png" }; + const savedFile = { ...uploadedFile, id: "saved:file" }; + const attachments = [first, second, savedFile]; + await prepareQueuedMessageEditDraft({ + editTarget, + edit, + environmentId, + text: "Original message", + attachments, + readImage: async (attachment) => ({ + file: new File(["image"], attachment.name, { type: attachment.mimeType }), + dataUrl: "data:image/png;base64,aW1hZ2U=", + }), + }); + const originalText = store.getComposerDraft(editTarget)!.prompt; + store.setPrompt(threadTarget, "Unrelated main draft"); + if (change === "attachment removal") { + store.removeImage(editTarget, first.id); + expect(store.getComposerDraft(editTarget)?.prompt).toBe(originalText); + } else store.setPrompt(editTarget, `${originalText} edited`); + expect( + recoverQueuedMessageEdit({ + editTarget, + threadTarget, + originalText, + originalAttachmentIds: attachments.map((attachment) => attachment.id), + }), + ).toBe("retained"); + const persisted = JSON.parse( + JSON.stringify(partializeComposerDraftStoreState(useComposerDraftStore.getState())), + ); + expect( + persisted.draftsByThreadKey["queued-edit:test"].attachments.map( + (attachment: { id: string }) => attachment.id, + ), + ).toEqual(change === "attachment removal" ? [second.id] : [first.id, second.id]); + expect( + hydrateImagesFromPersisted(persisted.draftsByThreadKey["queued-edit:test"].attachments).map( + (attachment) => attachment.id, + ), + ).toEqual(change === "attachment removal" ? [second.id] : [first.id, second.id]); + expect(persisted.draftsByThreadKey["queued-edit:test"].files).toEqual([ + expect.objectContaining({ attachmentId: savedFile.id, environmentId }), + ]); + expect(store.getComposerDraft(threadTarget)?.prompt).toBe("Unrelated main draft"); + }, + ); }); diff --git a/apps/web/src/components/chat/queuedMessageEdit.ts b/apps/web/src/components/chat/queuedMessageEdit.ts index 238534d91de4..744ce55a83a3 100644 --- a/apps/web/src/components/chat/queuedMessageEdit.ts +++ b/apps/web/src/components/chat/queuedMessageEdit.ts @@ -1,28 +1,161 @@ -import type { ChatAttachment } from "@t3tools/contracts"; +import { randomUUID } from "../../lib/utils"; +import { + queuedEditDraftMatchesSource, + type QueuedRunEditDraft, +} from "@t3tools/client-runtime/state/queued-run-edit-draft"; +import type { + ChatAttachment, + ChatImageAttachment, + ChatFileAttachment, + EnvironmentId, +} from "@t3tools/contracts"; import { composerDraftHasUserContent, + resolveComposerDraftKey, useComposerDraftStore, type ComposerFileAttachment, type ComposerImageAttachment, type ComposerThreadTarget, } from "../../composerDraftStore"; +export async function prepareQueuedMessageEditDraft(input: { + readonly editTarget: ComposerThreadTarget; + readonly edit: Pick & + NonNullable; + readonly environmentId: EnvironmentId; + readonly text: string; + readonly attachments: ReadonlyArray; + readonly readImage: (attachment: ChatAttachment) => Promise<{ file: File; dataUrl: string }>; +}) { + const store = useComposerDraftStore.getState(); + const existing = store.getComposerDraft(input.editTarget); + const key = resolveComposerDraftKey(store, input.editTarget); + if (key === null) throw new Error("Queued edit draft unavailable."); + const matches = queuedEditDraftMatchesSource(existing?.queuedEdit, input.text, input.attachments); + if (existing !== null && !matches) { + const { queuedEdit, ...content } = existing; + const retained = queuedEdit + ? { ...content, queuedEdit: { ...queuedEdit, ownership: undefined } } + : content; + const next = { ...store.draftsByThreadKey, [`${key}:saved:${randomUUID()}`]: retained }; + delete next[key]; + useComposerDraftStore.setState({ draftsByThreadKey: next }); + } + if (matches && existing?.queuedEdit) { + const queuedEdit = existing.queuedEdit; + useComposerDraftStore.setState((state) => ({ + draftsByThreadKey: { + ...state.draftsByThreadKey, + [key]: { + ...existing, + queuedEdit: { + ...queuedEdit, + ownership: { editId: input.edit.editId, sequence: input.edit.sequence }, + }, + }, + }, + })); + return; + } + if ( + input.attachments.some( + (attachment) => attachment.type !== "image" && attachment.type !== "file", + ) + ) + throw new Error("This attachment cannot be edited."); + const images = await Promise.all( + input.attachments + .filter((attachment): attachment is ChatImageAttachment => attachment.type === "image") + .map(async (image) => { + const { file, dataUrl } = await input.readImage(image); + return { ...image, file, previewUrl: dataUrl, dataUrl }; + }), + ); + store.setPrompt(input.editTarget, input.text); + store.addImages(input.editTarget, images, { allowDuplicates: true }); + await store.syncPersistedAttachments(input.editTarget, images); + store.addFiles( + input.editTarget, + input.attachments + .filter((attachment): attachment is ChatFileAttachment => attachment.type === "file") + .map((file) => ({ + ...file, + file: null, + uploadedAttachmentId: file.id, + uploadEnvironmentId: input.environmentId, + })), + { allowDuplicates: true, appendReference: true }, + ); + useComposerDraftStore.setState((state) => { + const draft = state.draftsByThreadKey[key]!; + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [key]: { + ...draft, + queuedEdit: { + runId: input.edit.runId, + messageId: input.edit.messageId, + sourceText: input.text, + originalText: draft.prompt, + originalAttachmentIds: input.attachments.map((attachment) => attachment.id), + ...(input.edit.context ? { context: input.edit.context } : {}), + ownership: { editId: input.edit.editId, sequence: input.edit.sequence }, + }, + }, + }, + }; + }); +} + +export function releaseQueuedMessageEditDraft(editTarget: ComposerThreadTarget) { + const store = useComposerDraftStore.getState(); + const key = resolveComposerDraftKey(store, editTarget); + if (key === null) return; + const draft = store.draftsByThreadKey[key]; + if (!draft?.queuedEdit) return; + useComposerDraftStore.setState({ + draftsByThreadKey: { + ...store.draftsByThreadKey, + [key]: { ...draft, queuedEdit: { ...draft.queuedEdit, ownership: undefined } }, + }, + }); +} + export function recoverQueuedMessageEdit(input: { readonly editTarget: ComposerThreadTarget; readonly threadTarget: ComposerThreadTarget; readonly originalText: string; -}): "kept" | "discarded" | "clean" { + readonly originalAttachmentIds?: ReadonlyArray; +}): "kept" | "retained" | "clean" { + releaseQueuedMessageEditDraft(input.editTarget); const store = useComposerDraftStore.getState(); const draft = store.getComposerDraft(input.editTarget); const dirty = draft !== null && - (draft.prompt !== input.originalText || draft.images.length > 0 || draft.files.length > 0); - if (dirty && !composerDraftHasUserContent(store.getComposerDraft(input.threadTarget))) { - store.moveComposerPromptAndImages(input.editTarget, input.threadTarget); - return "kept"; + (draft.prompt !== input.originalText || + [...draft.images, ...draft.files].length !== (input.originalAttachmentIds?.length ?? 0) || + [...draft.images, ...draft.files].some( + (attachment) => !input.originalAttachmentIds?.includes(attachment.id), + ) || + draft.terminalContexts.length > 0 || + draft.reviewComments.length > 0 || + draft.previewAnnotations.length > 0 || + draft.threadContexts.length > 0); + if (!dirty || draft === null) { + store.clearComposerContent(input.editTarget); + return "clean"; } - store.clearComposerContent(input.editTarget); - return dirty ? "discarded" : "clean"; + const fromKey = resolveComposerDraftKey(store, input.editTarget); + const toKey = resolveComposerDraftKey(store, input.threadTarget); + if (fromKey === null || toKey === null || fromKey === toKey) return "kept"; + const destination = store.getComposerDraft(input.threadTarget); + if (composerDraftHasUserContent(destination)) return "retained"; + const { queuedEdit: _queuedEdit, ...recovered } = draft; + const next = { ...store.draftsByThreadKey, [toKey]: recovered }; + delete next[fromKey]; + useComposerDraftStore.setState({ draftsByThreadKey: next }); + return "kept"; } export async function prepareQueuedEditAttachments(input: { diff --git a/apps/web/src/components/chat/useQueuedRunEdit.ts b/apps/web/src/components/chat/useQueuedRunEdit.ts index 0d5b87a9398f..4f1a0cd95d18 100644 --- a/apps/web/src/components/chat/useQueuedRunEdit.ts +++ b/apps/web/src/components/chat/useQueuedRunEdit.ts @@ -1,6 +1,7 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, type ChatAttachment, + type CommandId, type EnvironmentId, type MessageId, type OrchestrationMessageContext, @@ -11,10 +12,17 @@ import { type ThreadContextRecord, type ThreadId, } from "@t3tools/contracts"; +import { queuedRunEditOwnership } from "@t3tools/client-runtime/state/threads"; +import { readLocalApi } from "../../localApi"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; +import { useShallow } from "zustand/react/shallow"; import { useEffect, useRef, useState, type RefObject } from "react"; import { useAssetUrls } from "../../assets/assetUrls"; +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { executeAtomQuery, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { assetEnvironment } from "../../state/assets"; +import { readPreparedConnection } from "../../state/session"; import { DraftId, useComposerDraftStore, @@ -35,19 +43,26 @@ import type { TerminalContextDraft } from "../../lib/terminalContext"; import type { ReviewCommentContext } from "../../reviewCommentContext"; import { appAtomRegistry } from "../../rpc/atomRegistry"; import { environmentServerConfigsAtom } from "../../state/server"; -import { threadEnvironment } from "../../state/threads"; +import { threadEnvironment, useEnvironmentThread } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; import { stackedThreadToast, toastManager } from "../ui/toast"; import type { ChatComposerHandle } from "./ChatComposer"; import { fileAttachmentCapabilityBlockReason } from "./composerAttachmentFiles"; -import { prepareQueuedEditAttachments, recoverQueuedMessageEdit } from "./queuedMessageEdit"; +import { + prepareQueuedEditAttachments, + prepareQueuedMessageEditDraft, + recoverQueuedMessageEdit, +} from "./queuedMessageEdit"; import type { EditQueuedRunRequest } from "./QueuedRunsControl"; interface EditingQueuedRun { + readonly editId: CommandId | null; + readonly sequence: number; readonly threadId: ThreadId; readonly runId: RunId; readonly messageId: MessageId; readonly originalText: string; + readonly originalAttachmentIds: ReadonlyArray; readonly existingAttachments: ReadonlyArray; readonly context?: OrchestrationMessageContext | undefined; } @@ -131,6 +146,7 @@ async function submitQueuedRunEdit(input: { input: { threadId: editing.threadId, runId: editing.runId, + editId: editing.editId, text: editText.length === 0 ? ATTACHMENT_ONLY_BOOTSTRAP_PROMPT : editText, edit: { messageId: editing.messageId, @@ -161,8 +177,8 @@ function useEditQueuedRunCommand() { /** * Queued-message edit mode. While active, the composer is retargeted to a * per-run edit draft so the user's in-progress draft on the thread survives - * untouched; `existingAttachments` tracks which stored attachments the edit - * keeps (removal is client state until save). + * untouched. Stored attachments join that draft so its current selection can + * be recovered through the same paths as newly attached files. */ export function useQueuedRunEdit(input: { readonly environmentId: EnvironmentId; @@ -184,15 +200,45 @@ export function useQueuedRunEdit(input: { composerRef, } = input; const editQueuedRunCommand = useEditQueuedRunCommand(); - const [editing, setEditing] = useState(null); + const acquireEdit = useAtomCommand(threadEnvironment.beginQueuedRunEdit); + const releaseEdit = useAtomCommand(threadEnvironment.cancelQueuedRunEdit, { + reportFailure: false, + }); + const threadState = useEnvironmentThread(environmentId, activeThreadId); + const beginInFlight = useRef(false); + const recoveryPrefix = `queued-edit:${scopedThreadKey(routeThreadRef)}:`; + const activeEdit = useComposerDraftStore( + (state) => + Object.entries(state.draftsByThreadKey).find( + ([key, draft]) => + key.startsWith(recoveryPrefix) && draft.queuedEdit?.ownership !== undefined, + )?.[1].queuedEdit ?? null, + ); + const editing: EditingQueuedRun | null = + activeEdit?.ownership && activeThreadId !== null + ? { + ...activeEdit, + ...activeEdit.ownership, + threadId: activeThreadId, + existingAttachments: [], + } + : null; const saveInFlightRef = useRef(false); const [isSaving, setIsSaving] = useState(false); - // Navigating to another thread ends the edit; its draft stays for later. - if (editing !== null && activeThreadId !== editing.threadId) { - setEditing(null); - } + const savedEditDrafts = useComposerDraftStore( + useShallow((state) => + Object.keys(state.draftsByThreadKey).filter((key) => key.startsWith(recoveryPrefix)), + ), + ); + const [recoveryTarget, setRecoveryTarget] = useState(null); + const selectedRecoveryTarget = + recoveryTarget !== null && savedEditDrafts.includes(recoveryTarget) + ? DraftId.make(recoveryTarget) + : null; const draftTarget = - editing === null ? threadDraftTarget : queuedEditDraftTarget(routeThreadRef, editing.runId); + editing !== null + ? queuedEditDraftTarget(routeThreadRef, editing.runId) + : (selectedRecoveryTarget ?? threadDraftTarget); const imageResources = (editing?.existingAttachments ?? []) .filter((attachment) => attachment.type === "image") @@ -217,71 +263,141 @@ export function useQueuedRunEdit(input: { .getState() .clearComposerContent(queuedEditDraftTarget(routeThreadRef, runId)); - const beginEdit = useStableCallback((request: EditQueuedRunRequest) => { - if (activeThreadId === null) return; - if (editing !== null && editing.runId !== request.runId) clearEditDraft(editing.runId); - const target = queuedEditDraftTarget(routeThreadRef, request.runId); - const store = useComposerDraftStore.getState(); - store.clearComposerContent(target); - store.setPrompt(target, request.text); - setEditing({ - threadId: activeThreadId, - runId: request.runId, - messageId: request.messageId, - originalText: request.text, - existingAttachments: request.attachments, - context: serverProjection?.messages.find((message) => message.id === request.messageId) - ?.context, - }); - focusComposerSoon(); + const beginEdit = useStableCallback(async (request: EditQueuedRunRequest) => { + if (activeThreadId === null || beginInFlight.current || saveInFlightRef.current) return; + beginInFlight.current = true; + try { + const run = serverProjection?.runs.find((run) => run.id === request.runId); + const previousEditId = run?.queueEditId ?? null; + if (editing?.runId === request.runId && editing.editId === previousEditId) { + focusComposerSoon(); + return; + } + if (previousEditId !== null) { + const confirmed = await readLocalApi()?.dialogs.confirm( + "This message is being edited elsewhere. Take over editing? The other editor will keep their unsaved changes.", + ); + if (!confirmed) return; + } + if (editing !== null) { + const released = await releaseEdit({ + environmentId, + input: { threadId: editing.threadId, runId: editing.runId, editId: editing.editId }, + }); + if (released._tag !== "Success") return; + recoverQueuedMessageEdit({ + editTarget: queuedEditDraftTarget(routeThreadRef, editing.runId), + threadTarget: threadDraftTarget, + originalText: editing.originalText, + originalAttachmentIds: editing.originalAttachmentIds, + }); + } + const acquired = await acquireEdit({ + environmentId, + input: { threadId: activeThreadId, runId: request.runId, previousEditId }, + }); + if (acquired._tag !== "Success") return; + const target = queuedEditDraftTarget(routeThreadRef, request.runId); + try { + await prepareQueuedMessageEditDraft({ + editTarget: target, + edit: { + ...acquired.value, + runId: request.runId, + messageId: request.messageId, + context: serverProjection?.messages.find((message) => message.id === request.messageId) + ?.context, + }, + environmentId, + text: request.text, + attachments: request.attachments, + readImage: async (attachment) => { + const connection = readPreparedConnection(environmentId); + if (!connection) throw new Error("Reconnect before editing attachments."); + const result = await executeAtomQuery( + appAtomRegistry, + assetEnvironment.createUrl({ + environmentId, + input: { resource: { _tag: "attachment", attachmentId: attachment.id } }, + }), + { refresh: true, reportFailure: false }, + ); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl); + if (url === null) throw new Error("Attachment URL unavailable."); + const response = await fetch(url, { signal: AbortSignal.timeout(30_000) }); + if (!response.ok) throw new Error(`Could not restore attachment: ${attachment.name}`); + const file = new File([await response.blob()], attachment.name, { + type: attachment.mimeType, + }); + return { file, dataUrl: await readFileAsDataUrl(file) }; + }, + }); + } catch (error) { + await releaseEdit({ + environmentId, + input: { threadId: activeThreadId, runId: request.runId, editId: acquired.value.editId }, + }); + toastManager.add({ + type: "error", + title: "Could not open queued edit", + description: error instanceof Error ? error.message : "Could not restore attachments.", + }); + return; + } + focusComposerSoon(); + } finally { + beginInFlight.current = false; + } }); - const cancelEdit = useStableCallback(() => { - if (editing === null) return; + const cancelEdit = useStableCallback(async () => { + if (editing === null || saveInFlightRef.current) return; + saveInFlightRef.current = true; + setIsSaving(true); + const released = await releaseEdit({ + environmentId, + input: { threadId: editing.threadId, runId: editing.runId, editId: editing.editId }, + }); + saveInFlightRef.current = false; + setIsSaving(false); + if (released._tag !== "Success") return; clearEditDraft(editing.runId); - setEditing(null); focusComposerSoon(); }); - const removeEditingAttachment = useStableCallback((attachmentId: string) => { - setEditing((current) => - current === null - ? current - : { - ...current, - existingAttachments: current.existingAttachments.filter( - (attachment) => attachment.id !== attachmentId, - ), - }, - ); - }); + const removeEditingAttachment = (attachmentId: string) => { + const store = useComposerDraftStore.getState(); + store.removeImage(draftTarget, attachmentId); + store.removeFile(draftTarget, attachmentId); + }; - // Exit edit mode when the edited run leaves the queue (it started, or was - // cancelled from another client). A dirty edit moves into the thread's own - // draft when that draft is empty; otherwise it is dropped with a toast. useEffect(() => { - if (editing === null || activeThreadId !== editing.threadId) return; - if (serverProjection === null) return; - const run = serverProjection.runs.find((candidate) => candidate.id === editing.runId); - if (run !== undefined && run.status === "queued") return; + if ( + editing === null || + activeThreadId !== editing.threadId || + saveInFlightRef.current || + isSaving + ) + return; + if (queuedRunEditOwnership(threadState, editing) !== "lost") return; const recovery = recoverQueuedMessageEdit({ editTarget: queuedEditDraftTarget(routeThreadRef, editing.runId), threadTarget: threadDraftTarget, originalText: editing.originalText, + originalAttachmentIds: editing.originalAttachmentIds, }); - if (recovery !== "clean") { - toastManager.add( - stackedThreadToast({ - type: recovery === "kept" ? "info" : "warning", - title: "Queued message is no longer queued", - description: - recovery === "kept" - ? "Your unsaved edit was kept in the composer." - : "Your unsaved edit was discarded.", - }), - ); - } - // oxlint-disable-next-line react/set-state-in-effect -- Leaving edit mode follows the draft recovery and toast above, which must run once after commit. - setEditing(null); - }, [activeThreadId, editing, routeThreadRef, serverProjection, threadDraftTarget]); + toastManager.add( + stackedThreadToast({ + type: "info", + title: "Queued message edit ended", + description: + recovery === "kept" + ? "Your unsaved changes are in the composer." + : recovery === "retained" + ? "Open Saved queued edit above the composer to recover your changes." + : "The message left the queue or another editor took over.", + }), + ); + }, [activeThreadId, editing, routeThreadRef, threadState, threadDraftTarget, isSaving]); /** * Saves the composer content into the queued message being edited. Reports @@ -328,11 +444,13 @@ export function useQueuedRunEdit(input: { promptRef.current = ""; clearEditDraft(editing.runId); composerRef.current?.resetCursorState(); - setEditing(null); focusComposerSoon(); }; return { + savedEditDrafts: editing === null ? savedEditDrafts : [], + recoveryTarget: selectedRecoveryTarget, + selectRecoveryTarget: setRecoveryTarget, editingRunId: editing?.runId ?? null, isEditing: editing !== null, isSaving, diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 76b86ae4cd46..e0363d1a19e5 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1,3 +1,4 @@ +import { QueuedRunEditDraft } from "@t3tools/client-runtime/state/queued-run-edit-draft"; import { stripInlineContextReferences } from "./lib/composerContextReferences"; import { elementContextToPreviewAnnotation } from "./lib/elementContext"; import { @@ -82,6 +83,7 @@ import { import { UnifiedSettings } from "@t3tools/contracts/settings"; const isElementContextDetails = Schema.is(ElementContextDetails); +const isQueuedRunEditDraft = Schema.is(QueuedRunEditDraft); import { ReviewCommentContextSchema, type ReviewCommentContext } from "./reviewCommentContext"; const isRuntimeMode = Schema.is(RuntimeMode); const isProviderDriverKind = Schema.is(ProviderDriverKind); @@ -224,6 +226,7 @@ const PersistedTerminalContextDraft = Schema.Struct({ type PersistedTerminalContextDraft = typeof PersistedTerminalContextDraft.Type; const PersistedComposerThreadDraftState = Schema.Struct({ + queuedEdit: Schema.optionalKey(QueuedRunEditDraft), prompt: Schema.String, attachments: Schema.Array(PersistedComposerImageAttachment), files: Schema.optionalKey(Schema.Array(PersistedComposerDraftFileAttachment)), @@ -349,6 +352,7 @@ export type ComposerContextInsertionHandler = ( const contextInsertionHandlers = new Map(); export interface ComposerThreadDraftState { + queuedEdit?: QueuedRunEditDraft | undefined; prompt: string; images: ComposerImageAttachment[]; files: ComposerFileAttachment[]; @@ -867,7 +871,8 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { Object.keys(draft.modelSelectionByProvider).length === 0 && draft.activeProvider === null && draft.runtimeMode === null && - draft.interactionMode === null + draft.interactionMode === null && + draft.queuedEdit === undefined ); } @@ -1891,7 +1896,8 @@ function normalizePersistedDraftsByThreadId( threadContexts.length === 0 && !hasModelData && !runtimeMode && - !interactionMode + !interactionMode && + !draftCandidate.queuedEdit ) { continue; } @@ -1908,6 +1914,13 @@ function normalizePersistedDraftsByThreadId( : threadKeyOrId; })(); nextDraftsByThreadKey[normalizedThreadKey] = { + ...(isQueuedRunEditDraft(draftCandidate.queuedEdit) + ? { + queuedEdit: structuredClone( + draftCandidate.queuedEdit, + ) as DeepMutable, + } + : {}), prompt, attachments, ...(files.length > 0 ? { files } : {}), @@ -2019,11 +2032,15 @@ export function partializeComposerDraftStoreState( draft.threadContexts.length === 0 && !hasModelData && draft.runtimeMode === null && - draft.interactionMode === null + draft.interactionMode === null && + !draft.queuedEdit ) { continue; } const persistedDraft: DeepMutable = { + ...(draft.queuedEdit + ? { queuedEdit: structuredClone(draft.queuedEdit) as DeepMutable } + : {}), prompt: draft.prompt, attachments: draft.persistedAttachments, ...(draft.files.length > 0 @@ -2327,6 +2344,7 @@ function toHydratedThreadDraft( })) ?? []; return { + ...(persistedDraft.queuedEdit ? { queuedEdit: persistedDraft.queuedEdit } : {}), prompt: ensureInlineContextReferences(persistedDraft.prompt, [ ...(persistedDraft.reviewComments ?? []).map(reviewCommentContextReference), ...(persistedDraft.previewAnnotations ?? []).map(previewAnnotationContextReference), @@ -3940,6 +3958,7 @@ const composerDraftStore = create()( } const nextDraft: ComposerThreadDraftState = { ...current, + queuedEdit: undefined, prompt: "", images: [], files: [], diff --git a/docs/user/composer.md b/docs/user/composer.md index 5c5335350e5e..cb97d31718ef 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -72,6 +72,18 @@ Press `Option+Up` on macOS or `Alt+Up` on Windows and Linux with the cursor at t start of the composer to edit the most recently queued message. Change `thread.editQueuedMessage` to use another shortcut. +Editing holds that message and the messages behind it until you Save or Cancel; +the active turn continues. The hold survives disconnects, restarts, and resuming +the queue. Return to the thread after navigating away or restarting the client to +continue your edit. To edit a message held on another device, choose Edit and +confirm takeover. +If another editor takes over or the message is removed, unsaved changes stay in +the composer or remain available as a saved queued edit above it. If another device +saves a newer version, reopening Edit starts from that version and keeps the older +draft under Saved queued edit. Failure and workspace pauses remain separate: finish +the edit, then resume a paused queue. Older servers allow local editing but cannot +hold the queue while you edit. + Mobile has the same choice under **Settings → Follow-ups**. While a turn is running the send button shows which action it will take. Long-press it to use the other action for a single message, or hold `Cmd` while sending from a hardware diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 9eed1acbb883..5877e83a89f0 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -267,6 +267,10 @@ "types": "./src/state/terminal.ts", "default": "./src/state/terminal.ts" }, + "./state/queued-run-edit-draft": { + "types": "./src/state/queuedRunEditDraft.ts", + "default": "./src/state/queuedRunEditDraft.ts" + }, "./state/threads": { "types": "./src/state/threads.ts", "default": "./src/state/threads.ts" diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index 253ba85b367c..de33eee87760 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -239,8 +239,7 @@ describe("ConnectionResolver", () => { environmentId: ENVIRONMENT_ID, label: "Primary", httpBaseUrl: "http://127.0.0.1:3777", - socketUrl: - "ws://127.0.0.1:3777/ws?clientSurface=web&clientDeviceType=desktop&connectionMethod=direct&orchestrationProtocol=2", + socketUrl: `ws://127.0.0.1:3777/ws?clientSurface=web&clientDeviceType=desktop&connectionMethod=direct&orchestrationProtocol=${ORCHESTRATION_PROTOCOL_VERSION}`, httpAuthorization: null, target, }); @@ -278,7 +277,7 @@ describe("ConnectionResolver", () => { }); expect(yield* broker.prepare(catalogEntry(target))).toMatchObject({ - socketUrl: "ws://127.0.0.1:3777/ws?wsTicket=desktop&orchestrationProtocol=2", + socketUrl: `ws://127.0.0.1:3777/ws?wsTicket=desktop&orchestrationProtocol=${ORCHESTRATION_PROTOCOL_VERSION}`, httpAuthorization: { _tag: "Bearer", token: "desktop-bearer" }, target, }); diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index a02554bb0fb7..619e28c62911 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -43,6 +43,8 @@ import { createProject, dismissThreadUserInput, editQueuedRun, + beginQueuedRunEdit, + cancelQueuedRunEdit, forkThreadFromRun, interruptThreadTurn, mergeThreadBack, @@ -79,6 +81,7 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct readonly projection?: OrchestrationV2ThreadProjection; readonly projectionRequests?: ThreadId[]; readonly advertiseServerResolvedCommandContext?: boolean; + readonly queuedRunEditHold?: boolean; }) { const client = { [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command: OrchestrationV2Command) => @@ -126,6 +129,9 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct environment: { capabilities: { repositoryIdentity: true, + ...(input.queuedRunEditHold === undefined + ? {} + : { queuedRunEditHold: input.queuedRunEditHold }), ...(input.advertiseServerResolvedCommandContext === false ? {} : { serverResolvedCommandContext: true }), @@ -720,6 +726,42 @@ describe("V2 environment commands", () => { }).pipe(Effect.provide(layerTestCrypto)), ); + it.effect.each([false, true])( + "negotiates queued edit holds with advertised capability %s", + (supported) => + Effect.gen(function* () { + const commands: OrchestrationV2Command[] = []; + const supervisor = yield* makeSupervisor({ + commands, + projects: [], + ...(supported ? { queuedRunEditHold: true } : {}), + }); + const provide = Effect.provideService( + EnvironmentSupervisor.EnvironmentSupervisor, + supervisor, + ); + const target = { threadId: v2ThreadId, runId: RunId.make("queued-run") }; + const acquired = yield* beginQueuedRunEdit({ + ...target, + commandId: CommandId.make("acquire"), + previousEditId: null, + }).pipe(provide); + expect(acquired).toEqual( + supported ? { editId: "acquire", sequence: 1 } : { editId: null, sequence: 0 }, + ); + yield* editQueuedRun({ ...target, editId: acquired.editId, text: "Saved" }).pipe(provide); + yield* cancelQueuedRunEdit({ ...target, editId: acquired.editId }).pipe(provide); + expect(commands.map((command) => command.type)).toEqual( + supported + ? ["queued-run.edit.begin", "queued-run.edit", "queued-run.edit.cancel"] + : ["queued-run.edit"], + ); + const saved = commands.find((command) => command.type === "queued-run.edit"); + if (supported) expect(saved).toHaveProperty("editId", acquired.editId); + else expect(saved).not.toHaveProperty("editId"); + }).pipe(Effect.provide(layerTestCrypto)), + ); + it.effect("delegates model selection to the server without fetching the full projection", () => Effect.gen(function* () { const commands: OrchestrationV2Command[] = []; diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index b16a2f5b4895..d7660cd32f12 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -237,7 +237,18 @@ export interface RetryWorkspacePreparationInput extends ThreadCommandInput { readonly runId: RunId; } +export interface BeginQueuedRunEditInput extends ThreadCommandInput { + readonly runId: RunId; + readonly previousEditId: CommandId | null; +} + +export interface CancelQueuedRunEditInput extends ThreadCommandInput { + readonly runId: RunId; + readonly editId: CommandId | null; +} + export interface EditQueuedRunInput extends ThreadCommandInput { + readonly editId?: CommandId | null; readonly runId: RunId; readonly text: string; /** @@ -1020,6 +1031,37 @@ export const retryWorkspacePreparation = Effect.fn("EnvironmentCommands.retryWor }, ); +export const beginQueuedRunEdit = Effect.fn("EnvironmentCommands.beginQueuedRunEdit")(function* ( + input: BeginQueuedRunEditInput, +) { + const config = yield* getInitialServerConfig(); + if (config.environment.capabilities.queuedRunEditHold !== true) { + return { editId: null, sequence: 0 }; + } + const editId = yield* allocateCommandId(input); + const receipt = yield* dispatch({ + type: "queued-run.edit.begin", + commandId: editId, + threadId: input.threadId, + runId: input.runId, + previousEditId: input.previousEditId, + }); + return { editId, sequence: receipt.sequence }; +}); + +export const cancelQueuedRunEdit = Effect.fn("EnvironmentCommands.cancelQueuedRunEdit")(function* ( + input: CancelQueuedRunEditInput, +) { + if (input.editId === null) return { sequence: 0 }; + return yield* dispatch({ + type: "queued-run.edit.cancel", + commandId: yield* allocateCommandId(input), + threadId: input.threadId, + runId: input.runId, + editId: input.editId, + }); +}); + export const editQueuedRun = Effect.fn("EnvironmentCommands.editQueuedRun")(function* ( input: EditQueuedRunInput, ) { @@ -1032,6 +1074,7 @@ export const editQueuedRun = Effect.fn("EnvironmentCommands.editQueuedRun")(func commandId: yield* allocateCommandId(input), threadId: input.threadId, runId: input.runId, + ...(input.editId == null ? {} : { editId: input.editId }), text: input.text, ...(attachments === undefined ? {} : { attachments }), ...(input.edit?.context && attachments diff --git a/packages/client-runtime/src/state/queuedRunEditDraft.ts b/packages/client-runtime/src/state/queuedRunEditDraft.ts new file mode 100644 index 000000000000..7d9e2ffbe291 --- /dev/null +++ b/packages/client-runtime/src/state/queuedRunEditDraft.ts @@ -0,0 +1,32 @@ +import { CommandId, MessageId, OrchestrationMessageContext, RunId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +export const QueuedRunEditDraft = Schema.Struct({ + runId: RunId, + messageId: MessageId, + // File references can normalize composer text; compare newer saves against the server text. + sourceText: Schema.String, + originalText: Schema.String, + originalAttachmentIds: Schema.Array(Schema.String), + context: Schema.optional(OrchestrationMessageContext), + ownership: Schema.optional( + Schema.Struct({ + editId: Schema.NullOr(CommandId), + sequence: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + }), + ), +}); +export type QueuedRunEditDraft = typeof QueuedRunEditDraft.Type; + +export function queuedEditDraftMatchesSource( + draft: QueuedRunEditDraft | undefined, + text: string, + attachments: ReadonlyArray<{ readonly id: string }>, +): boolean { + return ( + draft !== undefined && + draft.sourceText === text && + draft.originalAttachmentIds.length === attachments.length && + draft.originalAttachmentIds.every((id, index) => id === attachments[index]?.id) + ); +} diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index c0b996004679..28daf3b4699e 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -25,6 +25,8 @@ import { type CreateThreadInput, type DeleteThreadInput, type EditQueuedRunInput, + type BeginQueuedRunEditInput, + type CancelQueuedRunEditInput, type InterruptThreadTurnInput, type MarkThreadUnreadInput, type ForkThreadFromRunInput, @@ -59,6 +61,8 @@ import { createThread, deleteThread, editQueuedRun, + beginQueuedRunEdit, + cancelQueuedRunEdit, interruptThreadTurn, forkThreadFromRun, markThreadUnread, @@ -106,6 +110,8 @@ export type { CreateThreadInput, DeleteThreadInput, EditQueuedRunInput, + BeginQueuedRunEditInput, + CancelQueuedRunEditInput, InterruptThreadTurnInput, MarkThreadUnreadInput, ForkThreadFromRunInput, @@ -367,6 +373,18 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + beginQueuedRunEdit: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:begin-queued-run-edit", + execute: (input: BeginQueuedRunEditInput) => beginQueuedRunEdit(input), + scheduler, + concurrency, + }), + cancelQueuedRunEdit: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:cancel-queued-run-edit", + execute: (input: CancelQueuedRunEditInput) => cancelQueuedRunEdit(input), + scheduler, + concurrency, + }), editQueuedRun: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:edit-queued-run", execute: (input: EditQueuedRunInput) => editQueuedRun(input), diff --git a/packages/client-runtime/src/state/threadExecution.test.ts b/packages/client-runtime/src/state/threadExecution.test.ts index 231f200f3753..d5023a5d8ff0 100644 --- a/packages/client-runtime/src/state/threadExecution.test.ts +++ b/packages/client-runtime/src/state/threadExecution.test.ts @@ -1,4 +1,5 @@ import { + CommandId, TurnItemId, NodeId, MessageId, @@ -56,6 +57,30 @@ function run(id: string, ordinal: number, status: OrchestrationV2RunStatus) { } describe("thread execution presentation", () => { + it("shows a completed thread as idle behind an edit hold while preserving active work", () => { + const completed = { ...run("done", 1, "completed"), completedAt: now }; + const edited = { ...run("edited", 2, "queued"), queueEditId: CommandId.make("edit") }; + const later = run("later", 3, "queued"); + const projection = { ...v2Projection, runs: [completed, edited, later], updatedAt: now }; + expect(deriveLatestThreadRun(projection)?.runId).toBe(completed.id); + expect(deriveThreadRuntime(projection)).toMatchObject({ + status: "completed", + activeRunId: null, + }); + expect( + deriveThreadRuntime({ + ...projection, + runs: [{ ...completed, status: "running" }, edited, later], + }), + ).toMatchObject({ status: "running", activeRunId: completed.id }); + expect( + deriveThreadRuntime({ + ...projection, + runs: [completed, { ...edited, queuePosition: 4 }, later], + }), + ).toMatchObject({ status: "queued" }); + }); + it("derives the current root failure without inheriting errors from children or previous runs", () => { const failed = { ...run("limited", 1, "failed"), rootNodeId: NodeId.make("root") }; const item = { diff --git a/packages/client-runtime/src/state/threadState.ts b/packages/client-runtime/src/state/threadState.ts index 67b227089ab1..8197c2015442 100644 --- a/packages/client-runtime/src/state/threadState.ts +++ b/packages/client-runtime/src/state/threadState.ts @@ -6,6 +6,7 @@ import { EMPTY_THREAD_HISTORY_META, type ThreadHistoryMeta } from "./threadHisto export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted"; export interface EnvironmentThreadState { + readonly sequence?: number; readonly data: Option.Option; readonly status: EnvironmentThreadStatus; readonly error: Option.Option; diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 61c7f01c9b73..7c26f1a8c62e 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -1,4 +1,6 @@ import { + CommandId, + RunId, EnvironmentId, EventId, MessageId, @@ -41,6 +43,7 @@ import * as ThreadHistoryController from "./threadHistoryController.ts"; import { EMPTY_ENVIRONMENT_THREAD_STATE, makeEnvironmentThreadState, + queuedRunEditOwnership, type EnvironmentThreadState, type ThreadSnapshotLoadResult, } from "./threads.ts"; @@ -320,6 +323,81 @@ const deleted = (sequence = 3): OrchestrationV2ThreadStreamItem => { }; describe("EnvironmentThreads", () => { + it.effect( + "keeps edit ownership pending until the receipt sequence and detects a later takeover", + () => + Effect.gen(function* () { + const run = { + id: RunId.make("queued-edit-run"), + threadId: THREAD_ID, + ordinal: 1, + providerInstanceId: BASE_PROJECTION.thread.providerInstanceId, + modelSelection: BASE_PROJECTION.thread.modelSelection, + providerThreadId: null, + userMessageId: MessageId.make("queued-edit-message"), + rootNodeId: null, + activeAttemptId: null, + status: "queued" as const, + queueEditId: CommandId.make("previous-editor"), + requestedAt: BASE_PROJECTION.updatedAt, + startedAt: null, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + }; + const h = yield* makeHarness({ cached: { ...BASE_PROJECTION, runs: [run] } }); + const before = yield* awaitThreadState(h.observed, (value) => value.status === "live"); + const edit = { runId: run.id, editId: CommandId.make("accepted-editor"), sequence: 10 }; + expect(queuedRunEditOwnership(before, edit)).toBe("pending"); + + yield* Queue.offer(h.inputs, titleUpdated("Buffered before acquisition", 9)); + const buffered = yield* awaitThreadState(h.observed, (value) => value.sequence === 9); + expect(queuedRunEditOwnership(buffered, edit)).toBe("pending"); + + yield* Queue.offer(h.inputs, { + kind: "event", + sequence: edit.sequence, + event: { + id: EventId.make("edit-acquired"), + type: "run.updated", + threadId: THREAD_ID, + occurredAt: BASE_PROJECTION.updatedAt, + payload: { ...run, queueEditId: edit.editId }, + }, + }); + const acquired = yield* awaitThreadState(h.observed, (value) => value.sequence === 10); + expect(queuedRunEditOwnership(acquired, edit)).toBe("owned"); + expect(queuedRunEditOwnership({ ...acquired, status: "cached" }, edit)).toBe("pending"); + const legacy = { ...edit, editId: null, sequence: 0 }; + expect( + queuedRunEditOwnership( + { + ...acquired, + data: Option.some({ ...BASE_PROJECTION, runs: [{ ...run, queueEditId: undefined }] }), + }, + legacy, + ), + ).toBe("owned"); + expect(queuedRunEditOwnership(acquired, legacy)).toBe("lost"); + + const reopened = { ...edit, editId: CommandId.make("reopened-editor"), sequence: 11 }; + expect(queuedRunEditOwnership(acquired, reopened)).toBe("pending"); + yield* Queue.offer( + h.inputs, + snapshot( + { + ...BASE_PROJECTION, + runs: [{ ...run, queueEditId: CommandId.make("another-device") }], + }, + 12, + ), + ); + const superseded = yield* awaitThreadState(h.observed, (value) => value.sequence === 12); + expect(queuedRunEditOwnership(superseded, edit)).toBe("lost"); + expect(queuedRunEditOwnership(superseded, reopened)).toBe("lost"); + }), + ); + it.effect("loads the server thread when its local cache read fails", () => Effect.gen(function* () { const h = yield* makeHarness({ diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index fc8b8e500813..cdb1ced5c99f 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -1,5 +1,7 @@ import { ORCHESTRATION_V2_WS_METHODS, + type CommandId, + type RunId, type EnvironmentId as EnvironmentIdType, type OrchestrationV2ThreadDetailSnapshot, type OrchestrationV2ThreadProjection, @@ -51,6 +53,19 @@ import { type EnvironmentThreadStatus, } from "./threadState.ts"; +export function queuedRunEditOwnership( + state: EnvironmentThreadState, + edit: { readonly runId: RunId; readonly editId: CommandId | null; readonly sequence: number }, +): "pending" | "owned" | "lost" { + if ( + (state.status !== "live" && state.status !== "deleted") || + (state.sequence ?? 0) < edit.sequence + ) + return "pending"; + const run = Option.getOrNull(state.data)?.runs.find((run) => run.id === edit.runId); + return run?.status === "queued" && (run.queueEditId ?? null) === edit.editId ? "owned" : "lost"; +} + function statusWithoutLiveData( data: Option.Option, ): EnvironmentThreadStatus { @@ -200,6 +215,10 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const initialState: EnvironmentThreadState = retained ? cachedThreadState(retained.state) : { + sequence: Option.match(cached, { + onNone: () => 0, + onSome: (snapshot) => snapshot.snapshotSequence, + }), data: cachedThread, status: statusWithoutLiveData(cachedThread), error: Option.none(), @@ -356,6 +375,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make // Atomic with concurrent history meta updates: never get-then-set the whole // state when only the projection changes. Bounded installs pass history so // projection + cursor persist together in one enqueue. + const sequence = yield* SubscriptionRef.get(lastSequence); const next = yield* SubscriptionRef.updateAndGet(state, (previous) => { const history = options?.history !== undefined @@ -365,6 +385,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make : previous.history; return { ...previous, + sequence, data: Option.some(thread), status: Option.isSome(previous.error) ? ("cached" as const) @@ -401,6 +422,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* () { yield* Ref.set(awaitingCompletion, false); yield* SubscriptionRef.set(state, { + sequence: yield* SubscriptionRef.get(lastSequence), data: Option.none(), status: "deleted", error: Option.none(), @@ -450,7 +472,10 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make } if (sequence === appliedSequence) return; yield* SubscriptionRef.set(lastSequence, sequence); - if (fresh.length === 0) return; + if (fresh.length === 0) { + yield* SubscriptionRef.update(state, (current) => ({ ...current, sequence })); + return; + } const waiting = yield* Ref.get(awaitingCompletion); // Apply against the latest projection/history in one update so a concurrent @@ -507,6 +532,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const updated: EnvironmentThreadState = { ...current, + sequence, data: Option.some(next), status: waiting ? "synchronizing" : "live", error: Option.none(), diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index b78cf0f99971..e6153fa3632b 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -87,6 +87,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ the links as literal text -- so a client must serialize context the legacy way for them. */ inlineMessageContext: Schema.optionalKey(Schema.Boolean), requiredWorktreeBootstrap: Schema.optionalKey(Schema.Boolean), + queuedRunEditHold: Schema.optionalKey(Schema.Boolean), threadSettlement: Schema.optionalKey(Schema.Boolean), threadAutoSettlement: Schema.optionalKey(Schema.Boolean), storageCleanup: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 94a9eca331d1..1e3799c53eab 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -530,6 +530,7 @@ export const OrchestrationV2Run = Schema.Struct({ status: OrchestrationV2RunStatus, queuePosition: Schema.optional(Schema.NullOr(PositiveInt)), queueHeld: Schema.optional(Schema.Boolean), + queueEditId: Schema.optional(Schema.NullOr(CommandId)), requestedAt: Schema.DateTimeUtc, startedAt: Schema.NullOr(Schema.DateTimeUtc), completedAt: Schema.NullOr(Schema.DateTimeUtc), @@ -2871,8 +2872,23 @@ export const OrchestrationV2Command = Schema.Union([ threadId: ThreadId, runId: RunId, }), + Schema.Struct({ + type: Schema.Literal("queued-run.edit.begin"), + commandId: CommandId, + threadId: ThreadId, + runId: RunId, + previousEditId: Schema.NullOr(CommandId), + }), + Schema.Struct({ + type: Schema.Literal("queued-run.edit.cancel"), + commandId: CommandId, + threadId: ThreadId, + runId: RunId, + editId: CommandId, + }), Schema.Struct({ type: Schema.Literal("queued-run.edit"), + editId: Schema.optional(CommandId), context: Schema.optional(OrchestrationMessageContext), commandId: CommandId, threadId: ThreadId, diff --git a/packages/shared/src/orchestrationV2ThreadError.ts b/packages/shared/src/orchestrationV2ThreadError.ts index c1e92cc13352..ff9eb94ac7c8 100644 --- a/packages/shared/src/orchestrationV2ThreadError.ts +++ b/packages/shared/src/orchestrationV2ThreadError.ts @@ -104,8 +104,19 @@ export function latestUnheldRun( runs: ReadonlyArray, ): OrchestrationV2Run | null { let latest: OrchestrationV2Run | null = null; + const editPosition = runs.reduce( + (position, run) => + run.status === "queued" && run.queueEditId != null + ? Math.min(position, run.queuePosition ?? run.ordinal) + : position, + Number.POSITIVE_INFINITY, + ); for (const run of runs) { - if (run.status === "queued" && run.queueHeld === true) continue; + if ( + run.status === "queued" && + (run.queueHeld === true || (run.queuePosition ?? run.ordinal) >= editPosition) + ) + continue; if (latest === null || run.ordinal > latest.ordinal) latest = run; } return latest; From 5a1225ef69c011a5745fe0bd6be47460f768e4fb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 06:20:51 +0000 Subject: [PATCH 12/21] fix(queue): preserve attachment references across edit recovery --- .../features/threads/ComposerQueuedEdit.tsx | 87 +------- .../src/features/threads/ThreadComposer.tsx | 23 +-- .../features/threads/ThreadDetailScreen.tsx | 3 - .../features/threads/ThreadQueueControl.tsx | 7 +- .../features/threads/ThreadRouteScreen.tsx | 1 - apps/mobile/src/state/queued-run-edit.test.ts | 137 +++++++------ apps/mobile/src/state/queued-run-edit.ts | 31 +-- .../src/state/use-composer-drafts.test.ts | 17 -- .../src/state/use-thread-composer-state.ts | 15 +- apps/web/src/components/ChatView.tsx | 5 +- apps/web/src/components/chat/ChatComposer.tsx | 47 +---- .../components/chat/queuedMessageEdit.test.ts | 194 +++++++++++++++--- .../src/components/chat/queuedMessageEdit.ts | 115 +++++++++-- .../src/components/chat/useQueuedRunEdit.ts | 98 ++------- apps/web/src/composerDraftStore.ts | 20 ++ .../src/connection/resolver.test.ts | 5 +- .../src/state/queuedRunEditDraft.ts | 12 ++ 17 files changed, 409 insertions(+), 408 deletions(-) diff --git a/apps/mobile/src/features/threads/ComposerQueuedEdit.tsx b/apps/mobile/src/features/threads/ComposerQueuedEdit.tsx index 6ebf85b87895..d44df80bdc6f 100644 --- a/apps/mobile/src/features/threads/ComposerQueuedEdit.tsx +++ b/apps/mobile/src/features/threads/ComposerQueuedEdit.tsx @@ -1,11 +1,7 @@ -import type { ChatAttachment, EnvironmentId } from "@t3tools/contracts"; -import { Image } from "expo-image"; -import { Pressable, ScrollView, View } from "react-native"; +import { Pressable, View } from "react-native"; import { AppText as Text } from "../../components/ui/AppText"; import { SymbolView } from "../../components/ui/AppSymbol"; -import { useAssetUrl } from "../../state/assets"; -import { assetImageSource } from "../../lib/assetImageSource"; export function ComposerQueuedEditBanner(props: { readonly saving: boolean; @@ -30,84 +26,3 @@ export function ComposerQueuedEditBanner(props: { ); } - -export function ComposerQueuedEditAttachments(props: { - readonly environmentId: EnvironmentId; - readonly attachments: ReadonlyArray; - readonly disabled: boolean; - readonly onRemove: (attachmentId: string) => void; -}) { - if (props.attachments.length === 0) return null; - return ( - - {props.attachments.map((attachment) => ( - - ))} - - ); -} - -function QueuedEditAttachmentChip(props: { - readonly environmentId: EnvironmentId; - readonly attachment: ChatAttachment; - readonly disabled: boolean; - readonly onRemove: (attachmentId: string) => void; -}) { - const { attachment } = props; - const isImage = attachment.mimeType.startsWith("image/"); - const resource = { - _tag: "attachment", - attachmentId: attachment.id, - fileName: attachment.name, - mimeType: attachment.mimeType, - disposition: "inline", - } as const; - const url = useAssetUrl(props.environmentId, isImage ? resource : null); - - return ( - - {isImage && url !== null ? ( - - ) : ( - - - - )} - - {attachment.name} - - props.onRemove(attachment.id)} - className="active:opacity-70 disabled:opacity-40" - > - - - - ); -} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 2a95b632587a..a89c8571936d 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -10,7 +10,6 @@ import { pastedTextDisposition, replaceTextSelection } from "@t3tools/client-run import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, - type ChatAttachment, type EnvironmentId, type MessageId, type ModelSelection, @@ -99,7 +98,6 @@ import { } from "./composerSendPresentation"; import { ComposerCommandPopover } from "./ComposerCommandPopover"; import { ComposerPopoverAnchor } from "./ComposerPopoverAnchor"; -import { ComposerQueuedEditAttachments } from "./ComposerQueuedEdit"; import { useComposerCommandMenu } from "./use-composer-command-menu"; import { ComposerDictationCancelAction, @@ -148,9 +146,7 @@ export interface ThreadComposerProps { readonly sendBlockedReason?: string | null; readonly draftKey?: string; readonly queuedEdit?: { - readonly existingAttachments: ReadonlyArray; readonly saving: boolean; - readonly onRemoveExistingAttachment: (attachmentId: string) => void; } | null; readonly followUpBehavior: FollowUpBehavior; readonly canSteerActiveTurn: boolean; @@ -351,10 +347,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps const composerDraftKey = props.draftKey ?? composerOwnerKey; const { text: draftMessage, attachments: draftAttachments } = useComposerDraft(composerDraftKey); const queuedEdit = props.queuedEdit ?? null; - const hasContent = - draftMessage.trim().length > 0 || - draftAttachments.length > 0 || - (queuedEdit?.existingAttachments.length ?? 0) > 0; + const hasContent = draftMessage.trim().length > 0 || draftAttachments.length > 0; const stripAttachments = composerStripAttachments(draftAttachments); const showStopAction = !hasContent && props.canStopThread && queuedEdit === null; @@ -743,20 +736,6 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps onPickFiles={props.onPickDraftFiles} /> ) : null} - {isExpanded && queuedEdit !== null && queuedEdit.existingAttachments.length > 0 ? ( - - - - ) : null} {isExpanded && stripAttachments.length > 0 ? ( void; - readonly onRemoveQueuedEditAttachment: (attachmentId: string) => void; readonly environmentId: EnvironmentId; readonly projectWorkspaceRoot: string | null; readonly threadCwd: string | null; @@ -1357,9 +1356,7 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai props.queuedRunEdit === null ? null : { - existingAttachments: props.queuedRunEdit.existingAttachments, saving: props.isSavingQueuedEdit, - onRemoveExistingAttachment: props.onRemoveQueuedEditAttachment, } } bottomInset={composerBottomInset} diff --git a/apps/mobile/src/features/threads/ThreadQueueControl.tsx b/apps/mobile/src/features/threads/ThreadQueueControl.tsx index 31e00fc8d666..a5741d30c0e0 100644 --- a/apps/mobile/src/features/threads/ThreadQueueControl.tsx +++ b/apps/mobile/src/features/threads/ThreadQueueControl.tsx @@ -87,6 +87,9 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { const promote = useAtomCommand(threadEnvironment.promoteQueuedRun, "promote queued message"); const acquireEdit = useAtomCommand(threadEnvironment.beginQueuedRunEdit, "edit queued message"); const releaseEdit = useAtomCommand(threadEnvironment.cancelQueuedRunEdit, "finish queued edit"); + const releaseFailedPreparation = useAtomCommand(threadEnvironment.cancelQueuedRunEdit, { + reportFailure: false, + }); const cancel = useAtomCommand(threadEnvironment.cancelQueuedRun, "remove queued message"); const resume = useAtomCommand(threadEnvironment.resumeThreadQueue, "resume queue"); const [resuming, setResuming] = useState(false); @@ -234,7 +237,7 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { }), ); } catch (error) { - await releaseEdit({ + await releaseFailedPreparation({ environmentId: target.environmentId, input: { threadId: target.threadId, runId, editId: acquired.value.editId }, }); @@ -252,7 +255,7 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { runId, messageId: entry.messageId, originalText: entry.text, - existingAttachments: entry.attachments, + originalAttachmentIds: entry.attachments.map((attachment) => attachment.id), ...(entry.context ? { context: entry.context } : {}), }, attachments, diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 10ce7c8a9fa1..34c9cbe07922 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -941,7 +941,6 @@ function ThreadRouteContent( canSteerActiveTurn={composer.canSteerActiveTurn} isSavingQueuedEdit={composer.isSavingQueuedEdit} onCancelQueuedRunEdit={composer.cancelQueuedRunEdit} - onRemoveQueuedEditAttachment={composer.onRemoveQueuedEditAttachment} environmentId={selectedThread.environmentId} projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null} threadCwd={selectedThreadCwd} diff --git a/apps/mobile/src/state/queued-run-edit.test.ts b/apps/mobile/src/state/queued-run-edit.test.ts index a22c3ec59833..bd29a24bcdb5 100644 --- a/apps/mobile/src/state/queued-run-edit.test.ts +++ b/apps/mobile/src/state/queued-run-edit.test.ts @@ -1,3 +1,7 @@ +import { + collectComposerContextReferences, + formatComposerContextReference, +} from "@t3tools/shared/composerContextReferences"; import { describe, expect, it, vi } from "vite-plus/test"; vi.mock("../lib/uuid", () => ({ uuidv4: () => "saved-edit" })); import { @@ -8,7 +12,6 @@ import { MessageId, RunId, type ChatAttachment, - type OrchestrationMessageContext, } from "@t3tools/contracts"; import { composerDraftEnvironmentId } from "../lib/composerAttachmentUploadQueue"; @@ -28,11 +31,12 @@ import { decodePersistedComposerState, getComposerDraftSnapshot, replaceComposerDraftAttachments, + removeComposerDraftAttachment, setComposerDraftText, } from "./use-composer-drafts"; -const image = (id: string): ChatAttachment => ({ - type: "image", +const image = (id: string) => ({ + type: "image" as const, id: ChatAttachmentId.make(id), name: `${id}.png`, mimeType: "image/png", @@ -46,23 +50,7 @@ function edit(attachments: ReadonlyArray): QueuedRunEdit { runId: RunId.make("run-1"), messageId: MessageId.make("message-1"), originalText: "original", - existingAttachments: attachments, - }; -} - -function context(attachmentIds: ReadonlyArray): OrchestrationMessageContext { - return { - version: 1, - records: attachmentIds.map((attachmentId, index) => ({ - version: 1, - kind: "image", - contextId: ComposerContextId.make(`ctx-${index}`), - label: `${attachmentId}.png`, - attachmentId, - name: `${attachmentId}.png`, - mimeType: "image/png", - sizeBytes: 10, - })), + originalAttachmentIds: attachments.map((attachment) => attachment.id), }; } @@ -172,7 +160,7 @@ describe("resolveQueuedEditPayload", () => { beginQueuedRunEdit( threadKey, editing, - editing.existingAttachments.map((attachment) => ({ + [image("first"), image("second")].map((attachment) => ({ type: "image" as const, id: attachment.id, name: attachment.name, @@ -228,51 +216,66 @@ describe("resolveQueuedEditPayload", () => { }, ); - it("resends the kept server attachments ahead of the newly uploaded ones", () => { - const payload = resolveQueuedEditPayload({ - edit: edit([image("kept")]), - draftContext: undefined, - draftAttachments: [{ id: "draft-1" }], - uploaded: [image("uploaded")], - }); - - expect(payload.attachments.map((attachment) => attachment.id)).toEqual(["kept", "uploaded"]); - }); - - it("rebinds context records from the draft's ids to the uploaded ones", () => { - const payload = resolveQueuedEditPayload({ - edit: edit([]), - draftContext: context(["draft-1"]), - draftAttachments: [{ id: "draft-1" }], - uploaded: [image("uploaded")], - }); - - expect(payload.context?.records).toEqual([ - expect.objectContaining({ attachmentId: "uploaded" }), - ]); - }); - - it("drops the record of an attachment the user removed", () => { - const payload = resolveQueuedEditPayload({ - edit: edit([image("kept")]), - draftContext: context(["kept", "gone"]), - draftAttachments: [], - uploaded: [], - }); - - expect(payload.attachments.map((attachment) => attachment.id)).toEqual(["kept"]); - expect(payload.context?.records).toEqual([expect.objectContaining({ attachmentId: "kept" })]); - }); - - it("returns no context when every record lost its attachment", () => { - const payload = resolveQueuedEditPayload({ - edit: edit([]), - draftContext: context(["gone"]), - draftAttachments: [], - uploaded: [], - }); - - expect(payload.attachments).toEqual([]); - expect(payload.context).toBeUndefined(); + it("keeps distinct stored context identities on save and removes references with their attachments", () => { + vi.useFakeTimers(); + try { + const threadKey = "env:binding"; + const draftKey = queuedEditDraftKey(threadKey, "run-1"); + const saved = [ + image("server-image"), + { ...image("server-file"), type: "file" as const, mimeType: "application/pdf" }, + ]; + const records = saved.map((attachment, index) => ({ + version: 1 as const, + contextId: ComposerContextId.make(`local-context-${index}`), + ...(attachment.type === "file" ? { kind: "file" as const } : { kind: "image" as const }), + label: attachment.name, + attachmentId: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + })); + const originalText = records.map(formatComposerContextReference).join(" "); + appAtomRegistry.set(composerDraftsAtom, {}); + beginQueuedRunEdit( + threadKey, + { ...edit(saved), originalText, context: { version: 1, records } }, + saved.map((attachment) => ({ + ...attachment, + ...(attachment.type === "file" + ? { type: "file" as const, fileUri: "file:///report.pdf" } + : { + type: "image" as const, + previewUri: "data:image/png;base64,YWJj", + dataUrl: "data:image/png;base64,YWJj", + }), + })), + ); + const draft = getComposerDraftSnapshot(draftKey); + expect(collectComposerContextReferences(draft.text)).toHaveLength(2); + const kept = resolveQueuedEditPayload({ + draftContext: draft.context, + draftAttachments: draft.attachments, + uploaded: saved, + }); + expect(kept.attachments).toEqual(saved); + expect(kept.context?.records.map((record) => record.contextId)).toEqual( + records.map((record) => record.contextId), + ); + for (const attachment of saved) removeComposerDraftAttachment(draftKey, attachment.id); + const removed = getComposerDraftSnapshot(draftKey); + expect(collectComposerContextReferences(removed.text)).toEqual([]); + expect( + resolveQueuedEditPayload({ + draftContext: removed.context, + draftAttachments: removed.attachments, + uploaded: [], + }), + ).toEqual({ attachments: [], context: undefined }); + } finally { + appAtomRegistry.set(composerDraftsAtom, {}); + vi.clearAllTimers(); + vi.useRealTimers(); + } }); }); diff --git a/apps/mobile/src/state/queued-run-edit.ts b/apps/mobile/src/state/queued-run-edit.ts index 2801364694d7..ccc8b0aeb537 100644 --- a/apps/mobile/src/state/queued-run-edit.ts +++ b/apps/mobile/src/state/queued-run-edit.ts @@ -27,7 +27,6 @@ import { getComposerDraftSnapshot, composerDraftsAtom, appendComposerDraftAttachments, - removeComposerDraftAttachment, } from "./use-composer-drafts"; export interface QueuedRunEdit { @@ -36,8 +35,7 @@ export interface QueuedRunEdit { readonly runId: RunId; readonly messageId: MessageId; readonly originalText: string; - readonly originalAttachmentIds?: ReadonlyArray; - readonly existingAttachments: ReadonlyArray; + readonly originalAttachmentIds: ReadonlyArray; readonly context?: OrchestrationMessageContext; } @@ -98,7 +96,7 @@ function activeEditDraft( } function editFromDraft(draft: QueuedRunEditDraft | null): QueuedRunEdit | null { - return draft?.ownership ? { ...draft, ...draft.ownership, existingAttachments: [] } : null; + return draft?.ownership ? { ...draft, ...draft.ownership } : null; } export function getQueuedRunEdit(threadKey: string | null): QueuedRunEdit | null { @@ -132,7 +130,7 @@ export function canReuseQueuedRunEditDraft( threadKey: string, runId: RunId, text: string, - attachments: ReadonlyArray, + attachments: ReadonlyArray<{ readonly id: string }>, ) { return queuedEditDraftMatchesSource( getComposerDraftSnapshot(queuedEditDraftKey(threadKey, runId)).queuedEdit, @@ -157,7 +155,7 @@ export function beginQueuedRunEdit( threadKey, edit.runId, edit.originalText, - edit.existingAttachments, + edit.originalAttachmentIds.map((id) => ({ id })), ); if (existing && !reuse) { updateComposerDrafts((current) => { @@ -172,15 +170,7 @@ export function beginQueuedRunEdit( if (!reuse) { setComposerDraftText(draftKey, edit.originalText); setComposerDraftContext(draftKey, edit.context); - appendComposerDraftAttachments( - draftKey, - attachments.filter((attachment) => attachment.type === "image"), - ); - appendComposerDraftAttachments( - draftKey, - attachments.filter((attachment) => attachment.type === "file"), - { appendReference: true }, - ); + appendComposerDraftAttachments(draftKey, attachments); } updateComposerDrafts((current) => { const draft = current[draftKey] ?? { text: "", attachments: [] }; @@ -193,7 +183,7 @@ export function beginQueuedRunEdit( messageId: edit.messageId, sourceText: edit.originalText, originalText: reuse ? (existing?.queuedEdit?.originalText ?? draft.text) : draft.text, - originalAttachmentIds: edit.existingAttachments.map((attachment) => attachment.id), + originalAttachmentIds: edit.originalAttachmentIds, ...(edit.context ? { context: edit.context } : {}), ownership: { editId: edit.editId, sequence: edit.sequence }, }, @@ -216,12 +206,6 @@ export function recoverQueuedRunEdit(threadKey: string): boolean { return dirty; } -export function removeQueuedRunEditAttachment(threadKey: string, attachmentId: string): void { - const edit = getQueuedRunEdit(threadKey); - if (edit === null) return; - removeComposerDraftAttachment(queuedEditDraftKey(threadKey, edit.runId), attachmentId); -} - export function endQueuedRunEdit( threadKey: string, options?: { readonly deferAttachmentCleanup?: boolean }, @@ -236,7 +220,6 @@ export function endQueuedRunEdit( * are resent alongside the newly uploaded ones, in composer order. */ export function resolveQueuedEditPayload(input: { - readonly edit: QueuedRunEdit; readonly draftContext: OrchestrationMessageContext | undefined; readonly draftAttachments: ReadonlyArray<{ readonly id: string }>; readonly uploaded: ReadonlyArray; @@ -244,7 +227,7 @@ export function resolveQueuedEditPayload(input: { readonly attachments: ReadonlyArray; readonly context: OrchestrationMessageContext | undefined; } { - const attachments = [...input.edit.existingAttachments, ...input.uploaded]; + const attachments = input.uploaded; const rebound = uploadedComposerContext( input.draftContext, input.draftAttachments, diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 6a43067d3df5..8d48fa129c7c 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -240,23 +240,6 @@ function contextDraft(start: number, count: number): ComposerDraft { } describe("mobile composer drafts", () => { - it("retains a queued edit across restart beside an occupied main draft", () => { - const threadKey = "environment:thread"; - const editKey = `${threadKey}~queued-edit~run`; - const main = { text: "Unrelated draft", attachments: [] }; - const edit = { text: "Unsaved queued edit", attachments: [] }; - const restored = decodePersistedComposerState( - JSON.parse( - JSON.stringify({ - schemaVersion: 1, - drafts: { [threadKey]: main, [editKey]: edit }, - }), - ), - ); - expect(restored.drafts[threadKey]).toEqual(main); - expect(restored.drafts[editKey]).toEqual(edit); - }); - it("restores a PR checkout source after restarting the composer", () => { const draft: ComposerDraft = { text: "Address feedback", diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index f0038d4ec2dc..b38088597ea8 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -104,7 +104,6 @@ import { getQueuedEditRecovery, getQueuedRunEdit, queuedEditDraftKey, - removeQueuedRunEditAttachment, resolveQueuedEditPayload, useQueuedRunEdit, } from "./queued-run-edit"; @@ -207,7 +206,7 @@ export function useThreadComposerState() { reportFailure: false, }); const releaseQueuedEdit = useAtomCommand(threadEnvironment.cancelQueuedRunEdit, { - reportFailure: false, + label: "cancel queued edit", }); const editThreadState = useEnvironmentThread( selectedThreadShell?.environmentId ?? null, @@ -428,11 +427,6 @@ export function useThreadComposerState() { if (result._tag === "Success") endQueuedRunEdit(selectedThreadKey); }; - const onRemoveQueuedEditAttachment = (attachmentId: string) => { - if (selectedThreadKey === null) return; - removeQueuedRunEditAttachment(selectedThreadKey, attachmentId); - }; - const saveQueuedRunEdit = async () => { const thread = selectedThreadShell; if (!thread || savingQueuedEditRef.current) return; @@ -446,10 +440,7 @@ export function useThreadComposerState() { Dialog.alert("Add a message", "A queued message cannot be left empty."); return; } - if ( - edit.existingAttachments.length + draft.attachments.length > - PROVIDER_SEND_TURN_MAX_ATTACHMENTS - ) { + if (draft.attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { Dialog.alert( "Too many attachments", `Remove attachments until there are at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}.`, @@ -482,7 +473,6 @@ export function useThreadComposerState() { return; } const payload = resolveQueuedEditPayload({ - edit, draftContext: draft.context, draftAttachments: draft.attachments, uploaded: prepared.value.attachments, @@ -988,7 +978,6 @@ export function useThreadComposerState() { }, isSavingQueuedEdit, cancelQueuedRunEdit, - onRemoveQueuedEditAttachment, modelSelection, pendingHandoff, canSwitchThreadProvider, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index bcf7cb890512..0f28385f8ab5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8354,10 +8354,7 @@ export default function ChatView(props: ChatViewProps) { setThreadError={setThreadError} onExpandImage={onExpandTimelineImageStable} onFileOpen={openFileAttachmentStable} - editingQueuedAttachments={queuedRunEdit.editingAttachments} - onRemoveEditingQueuedAttachment={ - queuedRunEdit.removeEditingAttachment - } + isEditingQueuedMessage={queuedRunEdit.isEditing} /> )} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index ebc30a4a1e70..bb3e0e32ca8f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -24,7 +24,6 @@ import { import type { KeybindingCommand, AssistantCitation, - ChatAttachment as ContractChatAttachment, ChatFileAttachment, EnvironmentId, ModelSelection, @@ -1602,11 +1601,7 @@ export interface ChatComposerProps { onPageScrollRelease: () => void; queuedRunsControl?: ReactNode; - editingQueuedAttachments: ReadonlyArray<{ - readonly attachment: ContractChatAttachment; - readonly url: string | null; - }> | null; - onRemoveEditingQueuedAttachment: (attachmentId: string) => void; + isEditingQueuedMessage: boolean; onCompactContext: () => void; onSend: ( @@ -1988,8 +1983,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setThreadError, onExpandImage, onFileOpen, - editingQueuedAttachments, - onRemoveEditingQueuedAttachment, + isEditingQueuedMessage, } = props; const primaryEnvironmentId = usePrimaryEnvironmentId(); const composerDraftTargetKey = composerTargetKey(composerDraftTarget); @@ -2017,7 +2011,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const shownSyncPhase = useDelayedStatus(composerDraftTargetKey, props.threadSyncPhase); const activeTasksProgress = shownSyncPhase === null ? props.activeTasksProgress : null; const activeTaskSteps = shownSyncPhase === null ? props.activeTaskSteps : null; - const isEditingQueuedMessage = editingQueuedAttachments !== null; const composerDraft = useComposerThreadDraft(composerDraftTarget); const composerDraftTargetKeyRef = useRef(""); useLayoutEffect(() => { @@ -6640,42 +6633,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) )} - {!isComposerCollapsedMobile && - !isComposerApprovalState && - pendingUserInputs.length === 0 && - editingQueuedAttachments !== null && - editingQueuedAttachments.length > 0 && ( -
- {editingQueuedAttachments.map(({ attachment, url }) => ( -
- {attachment.type === "image" && url ? ( - {attachment.name} - ) : ( -
- {attachment.name} -
- )} - -
- ))} -
- )} - {!isComposerCollapsedMobile && !isComposerApprovalState && (uncommittedSnapShotIds.length > 0 || diff --git a/apps/web/src/components/chat/queuedMessageEdit.test.ts b/apps/web/src/components/chat/queuedMessageEdit.test.ts index a3ecac391d02..e45ec38e3ae7 100644 --- a/apps/web/src/components/chat/queuedMessageEdit.test.ts +++ b/apps/web/src/components/chat/queuedMessageEdit.test.ts @@ -1,4 +1,15 @@ -import { CommandId, EnvironmentId, MessageId, RunId, ThreadId } from "@t3tools/contracts"; +import { + collectComposerContextReferences, + formatComposerContextReference, +} from "@t3tools/shared/composerContextReferences"; +import { + CommandId, + ComposerContextId, + EnvironmentId, + MessageId, + RunId, + ThreadId, +} from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { afterAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; vi.hoisted(() => { @@ -21,7 +32,7 @@ import { type ComposerImageAttachment, } from "../../composerDraftStore"; import { - prepareQueuedEditAttachments, + prepareQueuedEditPayload, prepareQueuedMessageEditDraft, recoverQueuedMessageEdit, releaseQueuedMessageEditDraft, @@ -54,6 +65,15 @@ const image: ComposerImageAttachment = { file: new File(["image"], "screen.png", { type: "image/png" }), previewUrl: "blob:screen", }; +const emptyContent = { + images: [], + files: [], + terminalContexts: [], + reviewComments: [], + previewAnnotations: [], + threadContexts: [], +}; +const payloadEdit = { ...edit, sourceText: "", originalText: "", originalAttachmentIds: [] }; const uploadedFile = { type: "file" as const, id: "upload:report", @@ -71,40 +91,26 @@ describe("queued message file edits", () => { { images: [], label: "file-only" }, { images: [image], label: "mixed" }, ])("preserves a generic file in a $label save", async ({ images }) => { - const attachments = await prepareQueuedEditAttachments({ - existingAttachments: [], - images, - files: [file], + const attachments = await prepareQueuedEditPayload({ + edit: payloadEdit, + content: { ...emptyContent, images, files: [file] }, uploadFiles: async () => [uploadedFile], readImage: async () => "data:image/png;base64,aW1hZ2U=", }); - expect(attachments.at(-1)).toEqual(uploadedFile); - expect(attachments.length).toBe(images.length + 1); + expect(attachments.attachments.at(-1)).toEqual(uploadedFile); + expect(attachments.attachments.length).toBe(images.length + 1); if (images.length > 0) - expect(attachments[0]).toMatchObject({ + expect(attachments.attachments[0]).toMatchObject({ type: "image", dataUrl: "data:image/png;base64,aW1hZ2U=", }); }); - it("retains saved attachments alongside newly uploaded files", async () => { - const saved = { ...uploadedFile, id: "saved:earlier" }; - const attachments = await prepareQueuedEditAttachments({ - existingAttachments: [saved], - images: [], - files: [file], - uploadFiles: async () => [uploadedFile], - readImage: async () => "unused", - }); - expect(attachments).toEqual([saved, uploadedFile]); - }); - it("fails a save instead of dropping a file whose upload is missing", async () => { await expect( - prepareQueuedEditAttachments({ - existingAttachments: [], - images: [image], - files: [file], + prepareQueuedEditPayload({ + edit: payloadEdit, + content: { ...emptyContent, images: [image], files: [file] }, uploadFiles: async () => [], readImage: async () => "image", }), @@ -273,4 +279,142 @@ describe("queued message file edits", () => { expect(store.getComposerDraft(threadTarget)?.prompt).toBe("Unrelated main draft"); }, ); + it("saves bound existing image and file references, then removes them without orphaning text", async () => { + const store = useComposerDraftStore.getState(); + const savedImage = { + type: "image" as const, + id: "server-image", + name: "screen.png", + mimeType: "image/png", + sizeBytes: 5, + }; + const savedFile = { ...uploadedFile, id: "server-file" }; + const attachments = [savedFile, savedImage]; + const records = attachments.map((attachment, index) => ({ + version: 1 as const, + ...(attachment.type === "file" ? { kind: "file" as const } : { kind: "image" as const }), + contextId: ComposerContextId.make(`local-context-${index}`), + label: attachment.name, + attachmentId: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + })); + const text = records.map(formatComposerContextReference).join(" "); + await prepareQueuedMessageEditDraft({ + editTarget, + edit: { ...edit, context: { version: 1, records } }, + environmentId, + text, + attachments, + readImage: async () => ({ file: image.file, dataUrl: "data:image/png;base64,aW1hZ2U=" }), + }); + const save = () => { + const draft = store.getComposerDraft(editTarget)!; + return prepareQueuedEditPayload({ + edit: draft.queuedEdit!, + content: draft, + readImage: async () => "data:image/png;base64,aW1hZ2U=", + uploadFiles: async (files) => + files.map((file) => ({ ...savedFile, id: file.uploadedAttachmentId! })), + }); + }; + store.setPrompt(editTarget, store.getComposerDraft(editTarget)!.prompt + " edited"); + const kept = await save(); + expect(kept.attachments.map((attachment) => attachment.id)).toEqual([ + savedFile.id, + savedImage.id, + ]); + const refs = collectComposerContextReferences(store.getComposerDraft(editTarget)!.prompt); + expect(refs).toHaveLength(2); + for (const ref of refs) { + expect( + kept.context.records.find((record) => record.contextId === ref.contextId), + ).toMatchObject({ + attachmentId: ref.kind === "image" ? savedImage.id : savedFile.id, + }); + } + const savedText = store.getComposerDraft(editTarget)!.prompt; + store.clearComposerContent(editTarget); + await prepareQueuedMessageEditDraft({ + editTarget, + edit: { ...edit, context: kept.context }, + environmentId, + text: savedText, + attachments: kept.attachments, + readImage: async () => ({ file: image.file, dataUrl: "data:image/png;base64,aW1hZ2U=" }), + }); + store.removeImage(editTarget, savedImage.id); + store.removeFile(editTarget, savedFile.id); + const persisted = JSON.parse( + JSON.stringify(partializeComposerDraftStoreState(useComposerDraftStore.getState())), + ); + const merge = useComposerDraftStore.persist.getOptions().merge!; + useComposerDraftStore.setState(merge(persisted, useComposerDraftStore.getState())); + const removed = await save(); + expect(removed.attachments).toEqual([]); + expect(removed.context.records).toEqual([]); + expect(collectComposerContextReferences(store.getComposerDraft(editTarget)!.prompt)).toEqual( + [], + ); + }); + + it("rejects an image lost during persistence but saves an intentional removal after hydration", async () => { + const store = useComposerDraftStore.getState(); + const saved = { + type: "image" as const, + id: "server-image", + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + }; + const prepare = () => + prepareQueuedMessageEditDraft({ + editTarget, + edit, + environmentId, + text: "Original", + attachments: [saved], + readImage: async () => ({ file: image.file, dataUrl: "data:image/png;base64,aW1hZ2U=" }), + }); + await prepare(); + store.setPrompt(threadTarget, "Occupied draft"); + // Simulate the quota verifier omitting the image while retaining owned edit metadata. + const persisted = JSON.parse( + JSON.stringify(partializeComposerDraftStoreState(useComposerDraftStore.getState())), + ); + persisted.draftsByThreadKey[editTarget].attachments = []; + const merge = useComposerDraftStore.persist.getOptions().merge!; + useComposerDraftStore.setState(merge(persisted, useComposerDraftStore.getState())); + const save = () => { + const draft = store.getComposerDraft(editTarget)!; + return prepareQueuedEditPayload({ + edit: draft.queuedEdit!, + content: draft, + readImage: async () => "unused", + uploadFiles: async () => [], + }); + }; + await expect(save()).rejects.toThrow("attachment could not be restored"); + expect( + recoverQueuedMessageEdit({ + editTarget, + threadTarget, + originalText: "Original", + originalAttachmentIds: [saved.id], + }), + ).toBe("retained"); + expect(store.getComposerDraft(threadTarget)?.prompt).toBe("Occupied draft"); + // Missing content cannot be reused as an intentional selection when reopening. + await prepare(); + expect(store.getComposerDraft(editTarget)?.images.map((image) => image.id)).toEqual([saved.id]); + store.removeImage(editTarget, saved.id); + const removed = JSON.parse( + JSON.stringify(partializeComposerDraftStoreState(useComposerDraftStore.getState())), + ); + useComposerDraftStore.setState(merge(removed, useComposerDraftStore.getState())); + expect((await save()).attachments).toEqual([]); + await prepare(); + expect(store.getComposerDraft(editTarget)?.images).toEqual([]); + }); }); diff --git a/apps/web/src/components/chat/queuedMessageEdit.ts b/apps/web/src/components/chat/queuedMessageEdit.ts index 744ce55a83a3..adbfdf09d4ae 100644 --- a/apps/web/src/components/chat/queuedMessageEdit.ts +++ b/apps/web/src/components/chat/queuedMessageEdit.ts @@ -1,6 +1,13 @@ +import { + replaceComposerContextReferences, + formatComposerContextReference, + toKindScopedComposerContextId, +} from "@t3tools/shared/composerContextReferences"; +import { buildMessageContext } from "../../lib/composerContextRecords"; import { randomUUID } from "../../lib/utils"; import { queuedEditDraftMatchesSource, + queuedEditDraftHasMissingAttachments, type QueuedRunEditDraft, } from "@t3tools/client-runtime/state/queued-run-edit-draft"; import type { @@ -14,8 +21,8 @@ import { resolveComposerDraftKey, useComposerDraftStore, type ComposerFileAttachment, - type ComposerImageAttachment, type ComposerThreadTarget, + type ComposerThreadDraftState, } from "../../composerDraftStore"; export async function prepareQueuedMessageEditDraft(input: { @@ -31,7 +38,48 @@ export async function prepareQueuedMessageEditDraft(input: { const existing = store.getComposerDraft(input.editTarget); const key = resolveComposerDraftKey(store, input.editTarget); if (key === null) throw new Error("Queued edit draft unavailable."); - const matches = queuedEditDraftMatchesSource(existing?.queuedEdit, input.text, input.attachments); + const contextIds = new Map( + (input.edit.context?.records ?? []).flatMap((record) => + "attachmentId" in record && (record.kind === "image" || record.kind === "file") + ? [ + [ + record.contextId, + toKindScopedComposerContextId(record.kind, record.attachmentId), + ] as const, + ] + : [], + ), + ); + const text = replaceComposerContextReferences(input.text, (ref) => { + const contextId = contextIds.get(ref.contextId); + return contextId ? formatComposerContextReference({ ...ref, contextId }) : ref.source; + }); + const context = input.edit.context && { + version: 1 as const, + records: input.edit.context.records.map((record) => ({ + ...record, + contextId: contextIds.get(record.contextId) ?? record.contextId, + ...("screenshotContextId" in record && record.screenshotContextId + ? { + screenshotContextId: + contextIds.get(record.screenshotContextId) ?? record.screenshotContextId, + } + : {}), + })), + }; + const matches = + queuedEditDraftMatchesSource(existing?.queuedEdit, input.text, input.attachments) && + existing?.queuedEdit !== undefined && + !queuedEditDraftHasMissingAttachments(existing.queuedEdit, [ + ...existing.images, + ...existing.files, + ]) && + existing.queuedEdit.context?.records.every( + (record) => + !("attachmentId" in record) || + (record.kind !== "image" && record.kind !== "file") || + record.contextId === toKindScopedComposerContextId(record.kind, record.attachmentId), + ) !== false; if (existing !== null && !matches) { const { queuedEdit, ...content } = existing; const retained = queuedEdit @@ -71,7 +119,7 @@ export async function prepareQueuedMessageEditDraft(input: { return { ...image, file, previewUrl: dataUrl, dataUrl }; }), ); - store.setPrompt(input.editTarget, input.text); + store.setPrompt(input.editTarget, text); store.addImages(input.editTarget, images, { allowDuplicates: true }); await store.syncPersistedAttachments(input.editTarget, images); store.addFiles( @@ -84,7 +132,7 @@ export async function prepareQueuedMessageEditDraft(input: { uploadedAttachmentId: file.id, uploadEnvironmentId: input.environmentId, })), - { allowDuplicates: true, appendReference: true }, + { allowDuplicates: true }, ); useComposerDraftStore.setState((state) => { const draft = state.draftsByThreadKey[key]!; @@ -99,7 +147,7 @@ export async function prepareQueuedMessageEditDraft(input: { sourceText: input.text, originalText: draft.prompt, originalAttachmentIds: input.attachments.map((attachment) => attachment.id), - ...(input.edit.context ? { context: input.edit.context } : {}), + ...(context ? { context } : {}), ownership: { editId: input.edit.editId, sequence: input.edit.sequence }, }, }, @@ -158,20 +206,35 @@ export function recoverQueuedMessageEdit(input: { return "kept"; } -export async function prepareQueuedEditAttachments(input: { - readonly existingAttachments: ReadonlyArray; - readonly images: ReadonlyArray; - readonly files: ReadonlyArray; +export async function prepareQueuedEditPayload(input: { + readonly edit: QueuedRunEditDraft; + readonly content: { + readonly [ + Key in + | "images" + | "files" + | "terminalContexts" + | "reviewComments" + | "previewAnnotations" + | "threadContexts" + ]: Readonly; + }; readonly uploadFiles: ( files: ReadonlyArray, ) => Promise>; readonly readImage: (file: File) => Promise; }) { - const files = input.files.length === 0 ? [] : await input.uploadFiles(input.files); - if (files.length !== input.files.length) + const { edit, content } = input; + const drafts = [...content.images, ...content.files]; + if (queuedEditDraftHasMissingAttachments(edit, drafts)) + throw new Error( + "An attachment could not be restored. Cancel and reopen the edit to restore it before saving.", + ); + const files = content.files.length === 0 ? [] : await input.uploadFiles(content.files); + if (files.length !== content.files.length) throw new Error("Retry or remove failed uploads before saving."); const images = await Promise.all( - input.images.map(async (image) => ({ + content.images.map(async (image) => ({ type: "image" as const, id: image.id, name: image.name, @@ -181,5 +244,31 @@ export async function prepareQueuedEditAttachments(input: { ...(image.source ? { source: image.source } : {}), })), ); - return [...input.existingAttachments, ...images, ...files]; + const uploaded = [...images, ...files]; + const originalOrder = new Map(edit.originalAttachmentIds.map((id, index) => [id, index])); + const attachments = [...uploaded].sort( + (left, right) => + (originalOrder.get(left.id) ?? originalOrder.size) - + (originalOrder.get(right.id) ?? originalOrder.size), + ); + const records = [ + ...(edit.context?.records ?? []).filter((record) => !("attachmentId" in record)), + ...(buildMessageContext({ + ...content, + terminalContexts: content.terminalContexts.filter( + (context) => context.text.trim().length > 0, + ), + attachments: drafts.map((attachment, index) => ({ + attachment, + attachmentId: uploaded[index]!.id, + })), + })?.records ?? []), + ]; + return { + attachments, + context: { + version: 1 as const, + records: [...new Map(records.map((record) => [record.contextId, record])).values()], + }, + }; } diff --git a/apps/web/src/components/chat/useQueuedRunEdit.ts b/apps/web/src/components/chat/useQueuedRunEdit.ts index 4f1a0cd95d18..6e91b6ea091a 100644 --- a/apps/web/src/components/chat/useQueuedRunEdit.ts +++ b/apps/web/src/components/chat/useQueuedRunEdit.ts @@ -1,15 +1,12 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, - type ChatAttachment, type CommandId, type EnvironmentId, - type MessageId, - type OrchestrationMessageContext, type OrchestrationV2ThreadProjection, type PreviewAnnotationPayload, - type RunId, type ScopedThreadRef, type ThreadContextRecord, + type RunId, type ThreadId, } from "@t3tools/contracts"; import { queuedRunEditOwnership } from "@t3tools/client-runtime/state/threads"; @@ -18,7 +15,6 @@ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; import { useShallow } from "zustand/react/shallow"; import { useEffect, useRef, useState, type RefObject } from "react"; -import { useAssetUrls } from "../../assets/assetUrls"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import { executeAtomQuery, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { assetEnvironment } from "../../state/assets"; @@ -37,7 +33,6 @@ import { releaseDraftAttachments, startAttachmentUpload, } from "../../lib/attachmentUploadQueue"; -import { buildMessageContext } from "../../lib/composerContextRecords"; import { readFileAsDataUrl } from "../../lib/imageCompression"; import type { TerminalContextDraft } from "../../lib/terminalContext"; import type { ReviewCommentContext } from "../../reviewCommentContext"; @@ -49,22 +44,18 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; import type { ChatComposerHandle } from "./ChatComposer"; import { fileAttachmentCapabilityBlockReason } from "./composerAttachmentFiles"; import { - prepareQueuedEditAttachments, + prepareQueuedEditPayload, prepareQueuedMessageEditDraft, recoverQueuedMessageEdit, } from "./queuedMessageEdit"; import type { EditQueuedRunRequest } from "./QueuedRunsControl"; -interface EditingQueuedRun { +import type { QueuedRunEditDraft } from "@t3tools/client-runtime/state/queued-run-edit-draft"; + +interface EditingQueuedRun extends QueuedRunEditDraft { readonly editId: CommandId | null; readonly sequence: number; readonly threadId: ThreadId; - readonly runId: RunId; - readonly messageId: MessageId; - readonly originalText: string; - readonly originalAttachmentIds: ReadonlyArray; - readonly existingAttachments: ReadonlyArray; - readonly context?: OrchestrationMessageContext | undefined; } /** What the composer holds when the user saves a queued-message edit. */ @@ -93,7 +84,6 @@ async function submitQueuedRunEdit(input: { }): Promise { const { environmentId, draftTarget, editing, content } = input; const editText = content.prompt.trim(); - const newAttachments = [...content.images, ...content.files]; const uploadFiles = async (files: ReadonlyArray) => { const validateFiles = () => { const config = appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId) ?? null; @@ -115,32 +105,12 @@ async function submitQueuedRunEdit(input: { return uploaded; }; try { - const uploads = await prepareQueuedEditAttachments({ - existingAttachments: editing.existingAttachments, - images: content.images, - files: content.files, + const payload = await prepareQueuedEditPayload({ + edit: editing, + content, readImage: readFileAsDataUrl, uploadFiles, }); - // Kept records for removed attachments would point at nothing. - const keptRecords = (editing.context?.records ?? []).filter( - (record) => - !("attachmentId" in record) || - editing.existingAttachments.some((attachment) => attachment.id === record.attachmentId), - ); - const newRecords = - buildMessageContext({ - terminalContexts: content.terminalContexts.filter( - (context) => context.text.trim().length > 0, - ), - reviewComments: content.reviewComments, - previewAnnotations: content.previewAnnotations, - threadContexts: content.threadContexts, - attachments: newAttachments.map((attachment, index) => ({ - attachment, - attachmentId: uploads[editing.existingAttachments.length + index]?.id ?? attachment.id, - })), - })?.records ?? []; const result = await input.editQueuedRun({ environmentId, input: { @@ -150,17 +120,7 @@ async function submitQueuedRunEdit(input: { text: editText.length === 0 ? ATTACHMENT_ONLY_BOOTSTRAP_PROMPT : editText, edit: { messageId: editing.messageId, - attachments: uploads, - context: { - version: 1, - records: [ - ...new Map( - [...keptRecords, ...newRecords].map( - (record) => [record.contextId, record] as const, - ), - ).values(), - ], - }, + ...payload, }, }, }); @@ -201,7 +161,8 @@ export function useQueuedRunEdit(input: { } = input; const editQueuedRunCommand = useEditQueuedRunCommand(); const acquireEdit = useAtomCommand(threadEnvironment.beginQueuedRunEdit); - const releaseEdit = useAtomCommand(threadEnvironment.cancelQueuedRunEdit, { + const releaseEdit = useAtomCommand(threadEnvironment.cancelQueuedRunEdit); + const releaseFailedPreparation = useAtomCommand(threadEnvironment.cancelQueuedRunEdit, { reportFailure: false, }); const threadState = useEnvironmentThread(environmentId, activeThreadId); @@ -220,7 +181,6 @@ export function useQueuedRunEdit(input: { ...activeEdit, ...activeEdit.ownership, threadId: activeThreadId, - existingAttachments: [], } : null; const saveInFlightRef = useRef(false); @@ -240,21 +200,6 @@ export function useQueuedRunEdit(input: { ? queuedEditDraftTarget(routeThreadRef, editing.runId) : (selectedRecoveryTarget ?? threadDraftTarget); - const imageResources = (editing?.existingAttachments ?? []) - .filter((attachment) => attachment.type === "image") - .map((attachment) => ({ _tag: "attachment" as const, attachmentId: attachment.id })); - const imageUrls = useAssetUrls(environmentId, imageResources); - const urlByAttachmentId = new Map( - imageResources.map((resource, index) => [resource.attachmentId, imageUrls[index] ?? null]), - ); - const editingAttachments = - editing === null - ? null - : editing.existingAttachments.map((attachment) => ({ - attachment, - url: urlByAttachmentId.get(attachment.id) ?? null, - })); - const focusComposerSoon = () => { window.requestAnimationFrame(() => composerRef.current?.focusAtEnd()); }; @@ -334,7 +279,7 @@ export function useQueuedRunEdit(input: { }, }); } catch (error) { - await releaseEdit({ + await releaseFailedPreparation({ environmentId, input: { threadId: activeThreadId, runId: request.runId, editId: acquired.value.editId }, }); @@ -364,12 +309,6 @@ export function useQueuedRunEdit(input: { clearEditDraft(editing.runId); focusComposerSoon(); }); - const removeEditingAttachment = (attachmentId: string) => { - const store = useComposerDraftStore.getState(); - store.removeImage(draftTarget, attachmentId); - store.removeFile(draftTarget, attachmentId); - }; - useEffect(() => { if ( editing === null || @@ -410,21 +349,14 @@ export function useQueuedRunEdit(input: { ) => { if (editing === null || saveInFlightRef.current) return; const newAttachments = [...content.images, ...content.files]; - if ( - editing.existingAttachments.length + newAttachments.length > - PROVIDER_SEND_TURN_MAX_ATTACHMENTS - ) { + if (newAttachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { setThreadError( editing.threadId, `A message can have at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments.`, ); return; } - if ( - content.prompt.trim().length === 0 && - editing.existingAttachments.length === 0 && - newAttachments.length === 0 - ) { + if (content.prompt.trim().length === 0 && newAttachments.length === 0) { return; } saveInFlightRef.current = true; @@ -455,10 +387,8 @@ export function useQueuedRunEdit(input: { isEditing: editing !== null, isSaving, draftTarget, - editingAttachments, beginEdit, cancelEdit, - removeEditingAttachment, saveEdit, }; } diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index e0363d1a19e5..a7ffa9d61762 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -3279,6 +3279,16 @@ const composerDraftStore = create()( } const nextDraft: ComposerThreadDraftState = { ...current, + ...(current.queuedEdit && current.queuedEdit.originalAttachmentIds.includes(imageId) + ? { + queuedEdit: { + ...current.queuedEdit, + removedAttachmentIds: [ + ...new Set([...(current.queuedEdit.removedAttachmentIds ?? []), imageId]), + ], + }, + } + : {}), prompt: removeInlineContextReference( current.prompt, toKindScopedComposerContextId("image", imageId), @@ -3391,6 +3401,16 @@ const composerDraftStore = create()( } const nextDraft = { ...current, + ...(current.queuedEdit && current.queuedEdit.originalAttachmentIds.includes(fileId) + ? { + queuedEdit: { + ...current.queuedEdit, + removedAttachmentIds: [ + ...new Set([...(current.queuedEdit.removedAttachmentIds ?? []), fileId]), + ], + }, + } + : {}), prompt: removeInlineContextReference( current.prompt, toKindScopedComposerContextId("file", fileId), diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index de33eee87760..253ba85b367c 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -239,7 +239,8 @@ describe("ConnectionResolver", () => { environmentId: ENVIRONMENT_ID, label: "Primary", httpBaseUrl: "http://127.0.0.1:3777", - socketUrl: `ws://127.0.0.1:3777/ws?clientSurface=web&clientDeviceType=desktop&connectionMethod=direct&orchestrationProtocol=${ORCHESTRATION_PROTOCOL_VERSION}`, + socketUrl: + "ws://127.0.0.1:3777/ws?clientSurface=web&clientDeviceType=desktop&connectionMethod=direct&orchestrationProtocol=2", httpAuthorization: null, target, }); @@ -277,7 +278,7 @@ describe("ConnectionResolver", () => { }); expect(yield* broker.prepare(catalogEntry(target))).toMatchObject({ - socketUrl: `ws://127.0.0.1:3777/ws?wsTicket=desktop&orchestrationProtocol=${ORCHESTRATION_PROTOCOL_VERSION}`, + socketUrl: "ws://127.0.0.1:3777/ws?wsTicket=desktop&orchestrationProtocol=2", httpAuthorization: { _tag: "Bearer", token: "desktop-bearer" }, target, }); diff --git a/packages/client-runtime/src/state/queuedRunEditDraft.ts b/packages/client-runtime/src/state/queuedRunEditDraft.ts index 7d9e2ffbe291..e770d7726c66 100644 --- a/packages/client-runtime/src/state/queuedRunEditDraft.ts +++ b/packages/client-runtime/src/state/queuedRunEditDraft.ts @@ -8,6 +8,7 @@ export const QueuedRunEditDraft = Schema.Struct({ sourceText: Schema.String, originalText: Schema.String, originalAttachmentIds: Schema.Array(Schema.String), + removedAttachmentIds: Schema.optional(Schema.Array(Schema.String)), context: Schema.optional(OrchestrationMessageContext), ownership: Schema.optional( Schema.Struct({ @@ -18,6 +19,17 @@ export const QueuedRunEditDraft = Schema.Struct({ }); export type QueuedRunEditDraft = typeof QueuedRunEditDraft.Type; +export function queuedEditDraftHasMissingAttachments( + draft: QueuedRunEditDraft, + attachments: ReadonlyArray<{ readonly id: string }>, +): boolean { + return draft.originalAttachmentIds.some( + (id) => + !draft.removedAttachmentIds?.includes(id) && + !attachments.some((attachment) => attachment.id === id), + ); +} + export function queuedEditDraftMatchesSource( draft: QueuedRunEditDraft | undefined, text: string, From 455cd83c8df92f2f5aefe272e5949f6f1f0645c8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 06:46:41 +0000 Subject: [PATCH 13/21] fix(queue): retain edit ownership through attachment failures --- .../features/threads/ThreadQueueControl.tsx | 49 ++++++------- .../mobile/src/state/queued-edit-draft-key.ts | 4 - apps/mobile/src/state/queued-run-edit.test.ts | 58 ++++++++++++++- apps/mobile/src/state/queued-run-edit.ts | 60 +++++++++++---- apps/mobile/src/state/use-composer-drafts.ts | 23 +++++- .../src/state/use-thread-composer-state.ts | 11 +++ .../components/chat/queuedMessageEdit.test.ts | 73 ++++++++++++++++++- .../src/components/chat/queuedMessageEdit.ts | 56 ++++++++------ .../src/components/chat/useQueuedRunEdit.ts | 3 +- apps/web/src/composerDraftStore.ts | 9 ++- 10 files changed, 267 insertions(+), 79 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadQueueControl.tsx b/apps/mobile/src/features/threads/ThreadQueueControl.tsx index a5741d30c0e0..20e97e453bd7 100644 --- a/apps/mobile/src/features/threads/ThreadQueueControl.tsx +++ b/apps/mobile/src/features/threads/ThreadQueueControl.tsx @@ -35,8 +35,8 @@ import { NativeStackScreenOptions, nativeHeaderScrollEdgeEffects } from "../../n import { useAssetUrl } from "../../state/assets"; import { assetImageSource } from "../../lib/assetImageSource"; import { - beginQueuedRunEdit, - canReuseQueuedRunEditDraft, + prepareQueuedRunEditDraft, + endQueuedRunEdit, useQueuedRunEdit, } from "../../state/queued-run-edit"; import { importAttachment } from "../../lib/composerContextClipboard"; @@ -186,17 +186,25 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { input: { threadId: target.threadId, runId, previousEditId }, }); if (acquired._tag !== "Success") return; - let attachments; try { - if ( - entry.attachments.some( - (attachment) => attachment.type !== "image" && attachment.type !== "file", - ) - ) - throw new Error("This attachment cannot be edited."); - attachments = canReuseQueuedRunEditDraft(threadKey, runId, entry.text, entry.attachments) - ? [] - : await Promise.all( + await prepareQueuedRunEditDraft( + threadKey, + { + ...acquired.value, + runId, + messageId: entry.messageId, + originalText: entry.text, + originalAttachmentIds: entry.attachments.map((attachment) => attachment.id), + ...(entry.context ? { context: entry.context } : {}), + }, + async () => { + if ( + entry.attachments.some( + (attachment) => attachment.type !== "image" && attachment.type !== "file", + ) + ) + throw new Error("This attachment cannot be edited."); + return await Promise.all( entry.attachments .filter( (attachment): attachment is ChatImageAttachment | ChatFileAttachment => @@ -236,11 +244,14 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { return restored; }), ); + }, + ); } catch (error) { - await releaseFailedPreparation({ + const released = await releaseFailedPreparation({ environmentId: target.environmentId, input: { threadId: target.threadId, runId, editId: acquired.value.editId }, }); + if (released._tag === "Success") endQueuedRunEdit(threadKey); Dialog.alert( "Could not open queued edit", error instanceof Error ? error.message : "Could not restore attachments.", @@ -248,18 +259,6 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { return; } void Haptics.selectionAsync(); - beginQueuedRunEdit( - threadKey, - { - ...acquired.value, - runId, - messageId: entry.messageId, - originalText: entry.text, - originalAttachmentIds: entry.attachments.map((attachment) => attachment.id), - ...(entry.context ? { context: entry.context } : {}), - }, - attachments, - ); navigation.goBack(); } finally { busyRef.current = false; diff --git a/apps/mobile/src/state/queued-edit-draft-key.ts b/apps/mobile/src/state/queued-edit-draft-key.ts index 519c4177e4de..6e02b27ddf45 100644 --- a/apps/mobile/src/state/queued-edit-draft-key.ts +++ b/apps/mobile/src/state/queued-edit-draft-key.ts @@ -9,7 +9,3 @@ const QUEUED_EDIT_DRAFT_MARKER = "~queued-edit~"; export function queuedEditDraftKey(threadKey: string, runId: string): string { return `${threadKey}${QUEUED_EDIT_DRAFT_MARKER}${runId}`; } - -export function isQueuedEditDraftKey(draftKey: string): boolean { - return draftKey.includes(QUEUED_EDIT_DRAFT_MARKER); -} diff --git a/apps/mobile/src/state/queued-run-edit.test.ts b/apps/mobile/src/state/queued-run-edit.test.ts index bd29a24bcdb5..52e6baddd32f 100644 --- a/apps/mobile/src/state/queued-run-edit.test.ts +++ b/apps/mobile/src/state/queued-run-edit.test.ts @@ -15,9 +15,11 @@ import { } from "@t3tools/contracts"; import { composerDraftEnvironmentId } from "../lib/composerAttachmentUploadQueue"; -import { isQueuedEditDraftKey, queuedEditDraftKey } from "./queued-edit-draft-key"; +import { queuedEditDraftKey } from "./queued-edit-draft-key"; import { beginQueuedRunEdit, + prepareQueuedRunEditDraft, + canReuseQueuedRunEditDraft, recoverQueuedRunEdit, getQueuedRunEdit, forgetQueuedRunEdit, @@ -58,8 +60,6 @@ describe("queued edit draft key", () => { it("keeps the environment readable so attachment uploads still resolve it", () => { const key = queuedEditDraftKey("env-1:thread-9", "run-3"); - expect(isQueuedEditDraftKey(key)).toBe(true); - expect(isQueuedEditDraftKey("env-1:thread-9")).toBe(false); expect(composerDraftEnvironmentId(key, [])).toBe("env-1"); }); }); @@ -85,6 +85,10 @@ describe("resolveQueuedEditPayload", () => { expect(getQueuedRunEdit(first)?.editId).toBe("edit-1"); expect(getQueuedRunEdit(second)?.editId).toBe("edit-2"); expect(getComposerDraftSnapshot(queuedEditDraftKey(first, "run-1")).text).toBe(""); + recoverQueuedRunEdit(first); + expect( + appAtomRegistry.get(composerDraftsAtom)[queuedEditDraftKey(first, "run-1")], + ).toBeUndefined(); endQueuedRunEdit(first); expect(getQueuedRunEdit(first)).toBeNull(); expect(getQueuedRunEdit(second)?.editId).toBe("edit-2"); @@ -95,6 +99,40 @@ describe("resolveQueuedEditPayload", () => { } }); + it("keeps a failed attachment preparation owned across hydration until Cancel", async () => { + vi.useFakeTimers(); + try { + const threadKey = "env:failed-open"; + appAtomRegistry.set(composerDraftsAtom, {}); + const editing = edit([image("original-image")]); + await expect( + prepareQueuedRunEditDraft(threadKey, editing, async () => { + throw new Error("offline"); + }), + ).rejects.toThrow("offline"); + const restored = decodePersistedComposerState( + JSON.parse( + JSON.stringify({ + schemaVersion: 1, + drafts: appAtomRegistry.get(composerDraftsAtom), + }), + ), + ); + appAtomRegistry.set(composerDraftsAtom, restored.drafts); + expect(getQueuedRunEdit(threadKey)?.editId).toBe(editing.editId); + expect( + getComposerDraftSnapshot(queuedEditDraftKey(threadKey, editing.runId)).queuedEdit + ?.originalAttachmentIds, + ).toEqual(["original-image"]); + endQueuedRunEdit(threadKey); + expect(getQueuedRunEdit(threadKey)).toBeNull(); + } finally { + appAtomRegistry.set(composerDraftsAtom, {}); + vi.clearAllTimers(); + vi.useRealTimers(); + } + }); + it.each(["text", "attachments"])( "keeps a retained edit separate from newer server %s", (change) => { @@ -139,6 +177,11 @@ describe("resolveQueuedEditPayload", () => { )?.[1]; expect(retained?.text).toBe("My older edit"); expect(retained?.queuedEdit?.ownership).toBeUndefined(); + const retainedKey = Object.keys(appAtomRegistry.get(composerDraftsAtom)).find((key) => + key.includes("~saved~"), + )!; + setComposerDraftText(retainedKey, ""); + expect(appAtomRegistry.get(composerDraftsAtom)[retainedKey]).toBeUndefined(); } finally { appAtomRegistry.set(composerDraftsAtom, {}); vi.clearAllTimers(); @@ -195,6 +238,15 @@ describe("resolveQueuedEditPayload", () => { ), ); expect(restored.drafts[threadKey]).toEqual(main); + appAtomRegistry.set(composerDraftsAtom, restored.drafts); + expect( + canReuseQueuedRunEditDraft( + threadKey, + editing.runId, + editing.originalText, + editing.originalAttachmentIds.map((id) => ({ id })), + ), + ).toBe(true); expect(restored.drafts[draftKey]?.attachments.map((attachment) => attachment.id)).toEqual( change === "attachment removal" ? ["second"] : ["first", "second"], ); diff --git a/apps/mobile/src/state/queued-run-edit.ts b/apps/mobile/src/state/queued-run-edit.ts index ccc8b0aeb537..a73d283303d8 100644 --- a/apps/mobile/src/state/queued-run-edit.ts +++ b/apps/mobile/src/state/queued-run-edit.ts @@ -1,5 +1,6 @@ import { queuedEditDraftMatchesSource, + queuedEditDraftHasMissingAttachments, type QueuedRunEditDraft, } from "@t3tools/client-runtime/state/queued-run-edit-draft"; import { uuidv4 } from "../lib/uuid"; @@ -26,6 +27,7 @@ import { setComposerDraftText, getComposerDraftSnapshot, composerDraftsAtom, + isComposerDraftEmpty, appendComposerDraftAttachments, } from "./use-composer-drafts"; @@ -39,7 +41,7 @@ export interface QueuedRunEdit { readonly context?: OrchestrationMessageContext; } -export { isQueuedEditDraftKey, queuedEditDraftKey } from "./queued-edit-draft-key"; +export { queuedEditDraftKey } from "./queued-edit-draft-key"; const queuedEditRecoverySelectionAtom = Atom.make>>({}).pipe( Atom.keepAlive, @@ -115,15 +117,16 @@ export function useQueuedRunEdit(threadKey: string | null): QueuedRunEdit | null } export function forgetQueuedRunEdit(threadKey: string): void { - updateComposerDrafts((current) => - Object.fromEntries( - Object.entries(current).map(([key, draft]) => - key.startsWith(`${threadKey}~queued-edit~`) && draft.queuedEdit?.ownership !== undefined - ? [key, { ...draft, queuedEdit: { ...draft.queuedEdit, ownership: undefined } }] - : [key, draft], - ), - ), - ); + updateComposerDrafts((current) => { + const next = { ...current }; + for (const [key, draft] of Object.entries(current)) { + if (!key.startsWith(threadKey + "~queued-edit~") || !draft.queuedEdit?.ownership) continue; + const retained = { ...draft, queuedEdit: { ...draft.queuedEdit, ownership: undefined } }; + if (isComposerDraftEmpty(retained)) delete next[key]; + else next[key] = retained; + } + return next; + }); } export function canReuseQueuedRunEditDraft( @@ -132,10 +135,11 @@ export function canReuseQueuedRunEditDraft( text: string, attachments: ReadonlyArray<{ readonly id: string }>, ) { - return queuedEditDraftMatchesSource( - getComposerDraftSnapshot(queuedEditDraftKey(threadKey, runId)).queuedEdit, - text, - attachments, + const draft = getComposerDraftSnapshot(queuedEditDraftKey(threadKey, runId)); + return ( + queuedEditDraftMatchesSource(draft.queuedEdit, text, attachments) && + draft.queuedEdit !== undefined && + !queuedEditDraftHasMissingAttachments(draft.queuedEdit, draft.attachments) ); } @@ -162,7 +166,8 @@ export function beginQueuedRunEdit( const retained = existing.queuedEdit ? { ...existing, queuedEdit: { ...existing.queuedEdit, ownership: undefined } } : existing; - const next = { ...current, [`${draftKey}~saved~${uuidv4()}`]: retained }; + const next = { ...current }; + if (!isComposerDraftEmpty(retained)) next[`${draftKey}~saved~${uuidv4()}`] = retained; delete next[draftKey]; return next; }); @@ -184,6 +189,9 @@ export function beginQueuedRunEdit( sourceText: edit.originalText, originalText: reuse ? (existing?.queuedEdit?.originalText ?? draft.text) : draft.text, originalAttachmentIds: edit.originalAttachmentIds, + ...(reuse && existing?.queuedEdit?.removedAttachmentIds + ? { removedAttachmentIds: existing.queuedEdit.removedAttachmentIds } + : {}), ...(edit.context ? { context: edit.context } : {}), ownership: { editId: edit.editId, sequence: edit.sequence }, }, @@ -192,6 +200,28 @@ export function beginQueuedRunEdit( }); } +export async function prepareQueuedRunEditDraft( + threadKey: string, + edit: QueuedRunEdit, + readAttachments: () => Promise>, +): Promise { + const reuse = canReuseQueuedRunEditDraft( + threadKey, + edit.runId, + edit.originalText, + edit.originalAttachmentIds.map((id) => ({ id })), + ); + beginQueuedRunEdit(threadKey, edit, []); + if (reuse) return; + const draftKey = queuedEditDraftKey(threadKey, edit.runId); + const ownership = getComposerDraftSnapshot(draftKey).queuedEdit?.ownership; + const attachments = await readAttachments(); + if (getComposerDraftSnapshot(draftKey).queuedEdit?.ownership !== ownership) return; + appendComposerDraftAttachments(draftKey, attachments, { + allowOverflow: true, + }); +} + export function recoverQueuedRunEdit(threadKey: string): boolean { const edit = getQueuedRunEdit(threadKey); if (edit === null) return false; diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index d73728644e12..690e8ed105e4 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -559,7 +559,7 @@ function isEmptyDraft(draft: ComposerDraft): boolean { draft.runtimeMode === undefined && draft.interactionMode === undefined && draft.workspaceSelection === undefined && - draft.queuedEdit === undefined + draft.queuedEdit?.ownership === undefined ); } @@ -568,6 +568,25 @@ function withComposerDraft( draftKey: string, draft: ComposerDraft, ): Record { + const removedOriginalIds = + current[draftKey]?.attachments + .filter( + (attachment) => + draft.queuedEdit?.originalAttachmentIds.includes(attachment.id) && + !draft.attachments.some((retained) => retained.id === attachment.id), + ) + .map((attachment) => attachment.id) ?? []; + if (draft.queuedEdit && removedOriginalIds.length > 0) { + draft = { + ...draft, + queuedEdit: { + ...draft.queuedEdit, + removedAttachmentIds: [ + ...new Set([...(draft.queuedEdit.removedAttachmentIds ?? []), ...removedOriginalIds]), + ], + }, + }; + } if (isEmptyDraft(draft) && draft.project === undefined) { const next = { ...current }; delete next[draftKey]; @@ -626,7 +645,7 @@ export function decodePersistedComposerState(value: unknown): { draft.runtimeMode === undefined && draft.interactionMode === undefined && draft.workspaceSelection === undefined && - draft.queuedEdit === undefined + draft.queuedEdit?.ownership === undefined ? { ...draft, modelSelection: undefined } : draft, now, diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index b38088597ea8..6f9d69ec7391 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -1,3 +1,4 @@ +import { queuedEditDraftHasMissingAttachments } from "@t3tools/client-runtime/state/queued-run-edit-draft"; import * as Dialog from "../components/ui/Dialog"; import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; import { readEnvironmentScope } from "./session"; @@ -434,6 +435,16 @@ export function useThreadComposerState() { const edit = getQueuedRunEdit(threadKey); if (edit === null) return; const draft = getComposerDraftSnapshot(queuedEditDraftKey(threadKey, edit.runId)); + if ( + draft.queuedEdit && + queuedEditDraftHasMissingAttachments(draft.queuedEdit, draft.attachments) + ) { + Dialog.alert( + "Could not save the queued message", + "An attachment could not be restored. Cancel and reopen the edit to restore it before saving.", + ); + return; + } const text = draft.text.trim(); if (text.length === 0) { // The server rejects an empty queued message, attachments or not. diff --git a/apps/web/src/components/chat/queuedMessageEdit.test.ts b/apps/web/src/components/chat/queuedMessageEdit.test.ts index e45ec38e3ae7..3555b2fce503 100644 --- a/apps/web/src/components/chat/queuedMessageEdit.test.ts +++ b/apps/web/src/components/chat/queuedMessageEdit.test.ts @@ -180,7 +180,62 @@ describe("queued message file edits", () => { expect(store.getComposerDraft(editTarget)?.prompt).toBe(""); expect(store.getComposerDraft(threadTarget)?.prompt).toBe("Separate draft"); releaseQueuedMessageEditDraft(editTarget); - expect(store.getComposerDraft(editTarget)?.queuedEdit?.ownership).toBeUndefined(); + expect(store.getComposerDraft(editTarget)).toBeNull(); + expect( + partializeComposerDraftStoreState(useComposerDraftStore.getState()).draftsByThreadKey[ + editTarget + ], + ).toBeUndefined(); + }); + + it("retains the acquired hold through attachment failure and reload until Cancel succeeds", async () => { + const store = useComposerDraftStore.getState(); + await expect( + prepareQueuedMessageEditDraft({ + editTarget, + edit, + environmentId, + text: "Original", + attachments: [ + { + type: "image", + id: image.id, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + }, + ], + readImage: async () => { + throw new Error("offline"); + }, + }), + ).rejects.toThrow("offline"); + // Cleanup cannot reach the server while offline, so ownership must survive reload. + const persisted = JSON.parse( + JSON.stringify(partializeComposerDraftStoreState(useComposerDraftStore.getState())), + ); + useComposerDraftStore.setState({ draftsByThreadKey: {} }); + useComposerDraftStore.setState( + useComposerDraftStore.persist.getOptions().merge!( + persisted, + useComposerDraftStore.getState(), + ), + ); + const draft = store.getComposerDraft(editTarget)!; + expect(draft.queuedEdit?.ownership).toEqual({ editId: edit.editId, sequence: edit.sequence }); + await expect( + prepareQueuedEditPayload({ + edit: draft.queuedEdit!, + content: draft, + readImage: async () => { + throw new Error("unused"); + }, + uploadFiles: async () => [], + }), + ).rejects.toThrow("attachment could not be restored"); + // The normal Cancel path clears the draft only after the token's release succeeds. + store.clearComposerContent(editTarget); + expect(store.getComposerDraft(editTarget)).toBeNull(); }); it.each(["text", "attachments"])( @@ -218,6 +273,11 @@ describe("queued message file edits", () => { )?.[1]; expect(retained?.prompt).toBe("My older edit"); expect(retained?.queuedEdit?.ownership).toBeUndefined(); + const retainedKey = Object.keys(useComposerDraftStore.getState().draftsByThreadKey).find( + (key) => key.startsWith(editTarget + ":saved:"), + )!; + store.setPrompt(DraftId.make(retainedKey), ""); + expect(store.getComposerDraft(DraftId.make(retainedKey))).toBeNull(); }, ); @@ -287,6 +347,12 @@ describe("queued message file edits", () => { name: "screen.png", mimeType: "image/png", sizeBytes: 5, + source: { + kind: "snap-shot" as const, + capturedAt: "2026-10-19T00:00:00.000Z", + appName: "Browser", + windowTitle: "Test", + }, }; const savedFile = { ...uploadedFile, id: "server-file" }; const attachments = [savedFile, savedImage]; @@ -309,18 +375,21 @@ describe("queued message file edits", () => { attachments, readImage: async () => ({ file: image.file, dataUrl: "data:image/png;base64,aW1hZ2U=" }), }); + const readImage = vi.fn(async () => "data:image/png;base64,aW1hZ2U="); const save = () => { const draft = store.getComposerDraft(editTarget)!; return prepareQueuedEditPayload({ edit: draft.queuedEdit!, content: draft, - readImage: async () => "data:image/png;base64,aW1hZ2U=", + readImage, uploadFiles: async (files) => files.map((file) => ({ ...savedFile, id: file.uploadedAttachmentId! })), }); }; store.setPrompt(editTarget, store.getComposerDraft(editTarget)!.prompt + " edited"); const kept = await save(); + expect(readImage).not.toHaveBeenCalled(); + expect(kept.attachments).toEqual(attachments); expect(kept.attachments.map((attachment) => attachment.id)).toEqual([ savedFile.id, savedImage.id, diff --git a/apps/web/src/components/chat/queuedMessageEdit.ts b/apps/web/src/components/chat/queuedMessageEdit.ts index adbfdf09d4ae..600517dcc49a 100644 --- a/apps/web/src/components/chat/queuedMessageEdit.ts +++ b/apps/web/src/components/chat/queuedMessageEdit.ts @@ -18,6 +18,7 @@ import type { } from "@t3tools/contracts"; import { composerDraftHasUserContent, + createEmptyThreadDraft, resolveComposerDraftKey, useComposerDraftStore, type ComposerFileAttachment, @@ -85,7 +86,8 @@ export async function prepareQueuedMessageEditDraft(input: { const retained = queuedEdit ? { ...content, queuedEdit: { ...queuedEdit, ownership: undefined } } : content; - const next = { ...store.draftsByThreadKey, [`${key}:saved:${randomUUID()}`]: retained }; + const next = { ...store.draftsByThreadKey }; + if (composerDraftHasUserContent(retained)) next[`${key}:saved:${randomUUID()}`] = retained; delete next[key]; useComposerDraftStore.setState({ draftsByThreadKey: next }); } @@ -105,6 +107,30 @@ export async function prepareQueuedMessageEditDraft(input: { })); return; } + useComposerDraftStore.setState((state) => { + const draft = state.draftsByThreadKey[key] ?? createEmptyThreadDraft(); + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [key]: { + ...draft, + prompt: text, + queuedEdit: { + runId: input.edit.runId, + messageId: input.edit.messageId, + sourceText: input.text, + originalText: text, + originalAttachmentIds: input.attachments.map((attachment) => attachment.id), + ...(context ? { context } : {}), + ownership: { editId: input.edit.editId, sequence: input.edit.sequence }, + }, + }, + }, + }; + }); + const ownership = store.getComposerDraft(input.editTarget)?.queuedEdit?.ownership; + const ownsDraft = () => + store.getComposerDraft(input.editTarget)?.queuedEdit?.ownership === ownership; if ( input.attachments.some( (attachment) => attachment.type !== "image" && attachment.type !== "file", @@ -119,9 +145,10 @@ export async function prepareQueuedMessageEditDraft(input: { return { ...image, file, previewUrl: dataUrl, dataUrl }; }), ); - store.setPrompt(input.editTarget, text); + if (!ownsDraft()) return; store.addImages(input.editTarget, images, { allowDuplicates: true }); await store.syncPersistedAttachments(input.editTarget, images); + if (!ownsDraft()) return; store.addFiles( input.editTarget, input.attachments @@ -134,26 +161,6 @@ export async function prepareQueuedMessageEditDraft(input: { })), { allowDuplicates: true }, ); - useComposerDraftStore.setState((state) => { - const draft = state.draftsByThreadKey[key]!; - return { - draftsByThreadKey: { - ...state.draftsByThreadKey, - [key]: { - ...draft, - queuedEdit: { - runId: input.edit.runId, - messageId: input.edit.messageId, - sourceText: input.text, - originalText: draft.prompt, - originalAttachmentIds: input.attachments.map((attachment) => attachment.id), - ...(context ? { context } : {}), - ownership: { editId: input.edit.editId, sequence: input.edit.sequence }, - }, - }, - }, - }; - }); } export function releaseQueuedMessageEditDraft(editTarget: ComposerThreadTarget) { @@ -168,6 +175,7 @@ export function releaseQueuedMessageEditDraft(editTarget: ComposerThreadTarget) [key]: { ...draft, queuedEdit: { ...draft.queuedEdit, ownership: undefined } }, }, }); + store.setPrompt(editTarget, draft.prompt); } export function recoverQueuedMessageEdit(input: { @@ -240,7 +248,9 @@ export async function prepareQueuedEditPayload(input: { name: image.name, mimeType: image.mimeType, sizeBytes: image.sizeBytes, - dataUrl: await input.readImage(image.file), + ...(!edit.originalAttachmentIds.includes(image.id) + ? { dataUrl: await input.readImage(image.file) } + : {}), ...(image.source ? { source: image.source } : {}), })), ); diff --git a/apps/web/src/components/chat/useQueuedRunEdit.ts b/apps/web/src/components/chat/useQueuedRunEdit.ts index 6e91b6ea091a..73d6d1a061b3 100644 --- a/apps/web/src/components/chat/useQueuedRunEdit.ts +++ b/apps/web/src/components/chat/useQueuedRunEdit.ts @@ -279,10 +279,11 @@ export function useQueuedRunEdit(input: { }, }); } catch (error) { - await releaseFailedPreparation({ + const released = await releaseFailedPreparation({ environmentId, input: { threadId: activeThreadId, runId: request.runId, editId: acquired.value.editId }, }); + if (released._tag === "Success") clearEditDraft(request.runId); toastManager.add({ type: "error", title: "Could not open queued edit", diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index a7ffa9d61762..0e24078d3d7f 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -762,7 +762,7 @@ const EMPTY_THREAD_DRAFT = Object.freeze({ interactionMode: null, }); -function createEmptyThreadDraft(): ComposerThreadDraftState { +export function createEmptyThreadDraft(): ComposerThreadDraftState { return { prompt: "", images: [], @@ -872,7 +872,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { draft.activeProvider === null && draft.runtimeMode === null && draft.interactionMode === null && - draft.queuedEdit === undefined + draft.queuedEdit?.ownership === undefined ); } @@ -1897,7 +1897,8 @@ function normalizePersistedDraftsByThreadId( !hasModelData && !runtimeMode && !interactionMode && - !draftCandidate.queuedEdit + (!isQueuedRunEditDraft(draftCandidate.queuedEdit) || + draftCandidate.queuedEdit.ownership === undefined) ) { continue; } @@ -2033,7 +2034,7 @@ export function partializeComposerDraftStoreState( !hasModelData && draft.runtimeMode === null && draft.interactionMode === null && - !draft.queuedEdit + draft.queuedEdit?.ownership === undefined ) { continue; } From 65da2bdc40be04035b9feb0768e918ba2213b8cf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 07:06:41 +0000 Subject: [PATCH 14/21] fix(queue): preserve drafts during stale edit cleanup --- .../features/threads/ThreadQueueControl.tsx | 126 +++++++++--------- apps/mobile/src/state/queued-run-edit.test.ts | 37 ++++- apps/mobile/src/state/queued-run-edit.ts | 15 ++- .../src/state/use-composer-drafts.test.ts | 17 +++ apps/mobile/src/state/use-composer-drafts.ts | 7 +- .../components/chat/queuedMessageEdit.test.ts | 75 ++++++++++- .../src/components/chat/queuedMessageEdit.ts | 12 +- .../src/components/chat/useQueuedRunEdit.ts | 79 ++++++----- 8 files changed, 260 insertions(+), 108 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadQueueControl.tsx b/apps/mobile/src/features/threads/ThreadQueueControl.tsx index 3da7fc14ae3b..a82ca4dfc4cf 100644 --- a/apps/mobile/src/features/threads/ThreadQueueControl.tsx +++ b/apps/mobile/src/features/threads/ThreadQueueControl.tsx @@ -36,9 +36,10 @@ import { useAssetUrl } from "../../state/assets"; import { assetImageSource } from "../../lib/assetImageSource"; import { prepareQueuedRunEditDraft, - endQueuedRunEdit, + queuedEditDraftKey, useQueuedRunEdit, } from "../../state/queued-run-edit"; +import { getComposerDraftSnapshot } from "../../state/use-composer-drafts"; import { importAttachment } from "../../lib/composerContextClipboard"; import { recoverQueuedRunEdit } from "../../state/queued-run-edit"; import { environmentThreadDetails, threadEnvironment } from "../../state/threads"; @@ -186,72 +187,77 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { input: { threadId: target.threadId, runId, previousEditId }, }); if (acquired._tag !== "Success") return; - try { - await prepareQueuedRunEditDraft( - threadKey, - { - ...acquired.value, - runId, - messageId: entry.messageId, - originalText: entry.text, - originalAttachmentIds: entry.attachments.map((attachment) => attachment.id), - ...(entry.context ? { context: entry.context } : {}), - }, - async () => { - if ( - entry.attachments.some( - (attachment) => attachment.type !== "image" && attachment.type !== "file", - ) + const preparation = prepareQueuedRunEditDraft( + threadKey, + { + ...acquired.value, + runId, + messageId: entry.messageId, + originalText: entry.text, + originalAttachmentIds: entry.attachments.map((attachment) => attachment.id), + ...(entry.context ? { context: entry.context } : {}), + }, + async () => { + if ( + entry.attachments.some( + (attachment) => attachment.type !== "image" && attachment.type !== "file", ) - throw new Error("This attachment cannot be edited."); - return await Promise.all( - entry.attachments - .filter( - (attachment): attachment is ChatImageAttachment | ChatFileAttachment => - attachment.type === "image" || attachment.type === "file", - ) - .map(async (attachment) => { - const local = await importAttachment( - { - version: 1, - kind: attachment.type, - contextId: ComposerContextId.make(attachment.id), - label: attachment.name, - attachmentId: attachment.id, - name: attachment.name, - mimeType: attachment.mimeType, - sizeBytes: attachment.sizeBytes, - }, - target.environmentId, - new AbortController().signal, - ); - const restored = { - ...local, - id: attachment.id, - uploadedAttachmentId: attachment.id, - uploadEnvironmentId: target.environmentId, + ) + throw new Error("This attachment cannot be edited."); + return await Promise.all( + entry.attachments + .filter( + (attachment): attachment is ChatImageAttachment | ChatFileAttachment => + attachment.type === "image" || attachment.type === "file", + ) + .map(async (attachment) => { + const local = await importAttachment( + { + version: 1, + kind: attachment.type, + contextId: ComposerContextId.make(attachment.id), + label: attachment.name, + attachmentId: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + }, + target.environmentId, + new AbortController().signal, + ); + const restored = { + ...local, + id: attachment.id, + uploadedAttachmentId: attachment.id, + uploadEnvironmentId: target.environmentId, + }; + if (restored.type === "image" && attachment.type === "image") + return { + ...restored, + ...(attachment.source ? { source: attachment.source } : {}), }; - if (restored.type === "image" && attachment.type === "image") - return { - ...restored, - ...(attachment.source ? { source: attachment.source } : {}), - }; - if (restored.type === "file" && attachment.type === "file") - return { - ...restored, - ...(attachment.source ? { source: attachment.source } : {}), - }; - return restored; - }), - ); - }, - ); + if (restored.type === "file" && attachment.type === "file") + return { + ...restored, + ...(attachment.source ? { source: attachment.source } : {}), + }; + return restored; + }), + ); + }, + ); + const preparedEdit = getComposerDraftSnapshot( + queuedEditDraftKey(threadKey, runId), + ).queuedEdit; + try { + await preparation; } catch (error) { const released = await releaseFailedPreparation({ environmentId: target.environmentId, input: { threadId: target.threadId, runId, editId: acquired.value.editId }, }); - if (released._tag === "Success") endQueuedRunEdit(threadKey); + if (released._tag === "Success" && preparedEdit?.ownership) + recoverQueuedRunEdit(threadKey, preparedEdit); Dialog.alert( "Could not open queued edit", error instanceof Error ? error.message : "Could not restore attachments.", diff --git a/apps/mobile/src/state/queued-run-edit.test.ts b/apps/mobile/src/state/queued-run-edit.test.ts index 52e6baddd32f..eaeab284275a 100644 --- a/apps/mobile/src/state/queued-run-edit.test.ts +++ b/apps/mobile/src/state/queued-run-edit.test.ts @@ -99,7 +99,7 @@ describe("resolveQueuedEditPayload", () => { } }); - it("keeps a failed attachment preparation owned across hydration until Cancel", async () => { + it("keeps failed preparation owned across hydration and drops untouched content after release", async () => { vi.useFakeTimers(); try { const threadKey = "env:failed-open"; @@ -124,8 +124,41 @@ describe("resolveQueuedEditPayload", () => { getComposerDraftSnapshot(queuedEditDraftKey(threadKey, editing.runId)).queuedEdit ?.originalAttachmentIds, ).toEqual(["original-image"]); - endQueuedRunEdit(threadKey); + expect(recoverQueuedRunEdit(threadKey)).toBe(false); expect(getQueuedRunEdit(threadKey)).toBeNull(); + expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({}); + } finally { + appAtomRegistry.set(composerDraftsAtom, {}); + vi.clearAllTimers(); + vi.useRealTimers(); + } + }); + + it("keeps a replacement fallback edit when an old attachment import rejects", async () => { + vi.useFakeTimers(); + try { + const threadKey = "env:replaced-import"; + appAtomRegistry.set(composerDraftsAtom, {}); + const editing = { ...edit([image("original-image")]), editId: null }; + const read = Promise.withResolvers(); + const preparation = prepareQueuedRunEditDraft(threadKey, editing, () => read.promise); + const preparedEdit = getComposerDraftSnapshot( + queuedEditDraftKey(threadKey, editing.runId), + ).queuedEdit!; + endQueuedRunEdit(threadKey); + const replacement = { ...editing, originalAttachmentIds: [] }; + beginQueuedRunEdit(threadKey, replacement, []); + const replacementKey = queuedEditDraftKey(threadKey, replacement.runId); + setComposerDraftText(replacementKey, "Keep replacement edit"); + const draft = getComposerDraftSnapshot(replacementKey); + read.reject(new Error("offline")); + await expect(preparation).rejects.toThrow("offline"); + recoverQueuedRunEdit(threadKey, preparedEdit); + expect(getQueuedRunEdit(threadKey)?.runId).toBe(replacement.runId); + expect(getComposerDraftSnapshot(replacementKey)).toEqual(draft); + expect(getComposerDraftSnapshot(replacementKey).queuedEdit?.ownership).toBe( + draft.queuedEdit?.ownership, + ); } finally { appAtomRegistry.set(composerDraftsAtom, {}); vi.clearAllTimers(); diff --git a/apps/mobile/src/state/queued-run-edit.ts b/apps/mobile/src/state/queued-run-edit.ts index a73d283303d8..5770a61393db 100644 --- a/apps/mobile/src/state/queued-run-edit.ts +++ b/apps/mobile/src/state/queued-run-edit.ts @@ -222,14 +222,23 @@ export async function prepareQueuedRunEditDraft( }); } -export function recoverQueuedRunEdit(threadKey: string): boolean { - const edit = getQueuedRunEdit(threadKey); +export function recoverQueuedRunEdit( + threadKey: string, + expectedEdit?: Pick, +): boolean { + const currentEdit = activeEditDraft(appAtomRegistry.get(composerDraftsAtom), threadKey); + if ( + expectedEdit && + (currentEdit?.runId !== expectedEdit.runId || currentEdit?.ownership !== expectedEdit.ownership) + ) + return false; + const edit = editFromDraft(currentEdit); if (edit === null) return false; const draft = getComposerDraftSnapshot(queuedEditDraftKey(threadKey, edit.runId)); const ids = edit.originalAttachmentIds ?? []; const dirty = draft.text !== edit.originalText || - draft.attachments.length !== ids.length || + (draft.queuedEdit?.removedAttachmentIds?.length ?? 0) > 0 || draft.attachments.some((attachment) => !ids.includes(attachment.id)); if (dirty) forgetQueuedRunEdit(threadKey); else endQueuedRunEdit(threadKey); diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 8d48fa129c7c..ca9463078eec 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -4,6 +4,7 @@ import { ComposerContextId, EnvironmentId, MessageId, + RunId, ProjectId, ProviderInstanceId, ThreadId, @@ -703,6 +704,21 @@ describe("mobile composer drafts", () => { fileUri: `file:///notes-${index}.txt`, })); appendComposerDraftAttachments(key, files, { appendReference: true }); + const original = getComposerDraftSnapshot(key); + appAtomRegistry.set(composerDraftsAtom, { + ...appAtomRegistry.get(composerDraftsAtom), + [key]: { + ...original, + queuedEdit: { + runId: RunId.make("run-1"), + messageId: MessageId.make("message-1"), + sourceText: original.text, + originalText: original.text, + originalAttachmentIds: files.map((file) => file.id), + ownership: { editId: null, sequence: 0 }, + }, + }, + }); const firstLink = "[notes-0.txt](t3-context://v1/file/file-0)"; const insertion = captureComposerDraftInsertion(key, { start: 0, end: firstLink.length }); expect(countComposerDraftAttachmentsAfterSelection(key, insertion)).toBe(99); @@ -748,6 +764,7 @@ describe("mobile composer drafts", () => { } const draft = getComposerDraftSnapshot(key); expect(draft.attachments.map((file) => file.id)).not.toContain("file-0"); + expect(draft.queuedEdit?.removedAttachmentIds).toEqual(["file-0"]); expect(draft.attachments.slice(0, 99)).toEqual(files.slice(1)); expect(draft.context?.records.some((record) => record.contextId === "file-0")).toBe(false); await cleanup.promise; diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 690e8ed105e4..71f0c90705e4 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -279,7 +279,7 @@ export function insertComposerDraftContext( if (!nextDraft) return current; inserted = true; removed = draft.attachments.filter((attachment) => !nextDraft.attachments.includes(attachment)); - return { ...current, [draftKey]: nextDraft }; + return withComposerDraft(current, draftKey, nextDraft); }); scheduleUnusedComposerAttachmentCleanup(inserted ? removed : (content.attachments ?? [])); return inserted; @@ -1366,10 +1366,7 @@ export function appendComposerDraftAttachments( (attachment) => !draft.attachments.includes(attachment), ); } - return { - ...current, - [draftKey]: draft, - }; + return withComposerDraft(current, draftKey, draft); }); scheduleUnusedComposerAttachmentCleanup([...rejected, ...removed]); return rejected.length; diff --git a/apps/web/src/components/chat/queuedMessageEdit.test.ts b/apps/web/src/components/chat/queuedMessageEdit.test.ts index 3555b2fce503..ddadb6457415 100644 --- a/apps/web/src/components/chat/queuedMessageEdit.test.ts +++ b/apps/web/src/components/chat/queuedMessageEdit.test.ts @@ -238,6 +238,78 @@ describe("queued message file edits", () => { expect(store.getComposerDraft(editTarget)).toBeNull(); }); + it("recovers text typed while failed image preparation was pending after release", async () => { + const store = useComposerDraftStore.getState(); + let rejectFetch!: (error: Error) => void; + const fetch = new Promise((_resolve, reject) => { + rejectFetch = reject; + }); + const preparation = prepareQueuedMessageEditDraft({ + editTarget, + edit, + environmentId, + text: "Original", + attachments: [image], + readImage: () => fetch, + }); + const preparedEdit = store.getComposerDraft(editTarget)!.queuedEdit!; + store.setPrompt(editTarget, "Keep what I typed during loading"); + rejectFetch(new Error("offline")); + await expect(preparation).rejects.toThrow("offline"); + expect( + recoverQueuedMessageEdit({ + editTarget, + threadTarget, + originalText: preparedEdit.originalText, + originalAttachmentIds: preparedEdit.originalAttachmentIds, + expectedEdit: preparedEdit, + }), + ).toBe("kept"); + expect(store.getComposerDraft(threadTarget)?.prompt).toBe("Keep what I typed during loading"); + expect(store.getComposerDraft(editTarget)).toBeNull(); + }); + + it("leaves a replacement fallback edit intact after an old preparation fails", async () => { + const store = useComposerDraftStore.getState(); + let rejectFetch!: (error: Error) => void; + const fetch = new Promise((_resolve, reject) => { + rejectFetch = reject; + }); + const preparation = prepareQueuedMessageEditDraft({ + editTarget, + edit: { ...edit, editId: null }, + environmentId, + text: "Original", + attachments: [image], + readImage: () => fetch, + }); + const preparedEdit = store.getComposerDraft(editTarget)!.queuedEdit!; + store.clearComposerContent(editTarget); + await prepareQueuedMessageEditDraft({ + editTarget, + edit: { ...edit, editId: null }, + environmentId, + text: "Original", + attachments: [], + readImage: async () => { + throw new Error("unused"); + }, + }); + store.setPrompt(editTarget, "Replacement edit"); + const replacement = store.getComposerDraft(editTarget); + rejectFetch(new Error("offline")); + await expect(preparation).rejects.toThrow("offline"); + recoverQueuedMessageEdit({ + editTarget, + threadTarget, + originalText: preparedEdit.originalText, + originalAttachmentIds: preparedEdit.originalAttachmentIds, + expectedEdit: preparedEdit, + }); + expect(store.getComposerDraft(editTarget)).toBe(replacement); + expect(store.getComposerDraft(threadTarget)).toBeNull(); + }); + it.each(["text", "attachments"])( "retains an older dirty edit separately when server %s changes", async (change) => { @@ -472,7 +544,8 @@ describe("queued message file edits", () => { originalText: "Original", originalAttachmentIds: [saved.id], }), - ).toBe("retained"); + ).toBe("clean"); + expect(store.getComposerDraft(editTarget)).toBeNull(); expect(store.getComposerDraft(threadTarget)?.prompt).toBe("Occupied draft"); // Missing content cannot be reused as an intentional selection when reopening. await prepare(); diff --git a/apps/web/src/components/chat/queuedMessageEdit.ts b/apps/web/src/components/chat/queuedMessageEdit.ts index 600517dcc49a..84c6651970d6 100644 --- a/apps/web/src/components/chat/queuedMessageEdit.ts +++ b/apps/web/src/components/chat/queuedMessageEdit.ts @@ -183,14 +183,22 @@ export function recoverQueuedMessageEdit(input: { readonly threadTarget: ComposerThreadTarget; readonly originalText: string; readonly originalAttachmentIds?: ReadonlyArray; + readonly expectedEdit?: Pick; }): "kept" | "retained" | "clean" { - releaseQueuedMessageEditDraft(input.editTarget); const store = useComposerDraftStore.getState(); + const currentEdit = store.getComposerDraft(input.editTarget)?.queuedEdit; + if ( + input.expectedEdit && + (currentEdit?.runId !== input.expectedEdit.runId || + currentEdit?.ownership !== input.expectedEdit.ownership) + ) + return "clean"; + releaseQueuedMessageEditDraft(input.editTarget); const draft = store.getComposerDraft(input.editTarget); const dirty = draft !== null && (draft.prompt !== input.originalText || - [...draft.images, ...draft.files].length !== (input.originalAttachmentIds?.length ?? 0) || + (draft.queuedEdit?.removedAttachmentIds?.length ?? 0) > 0 || [...draft.images, ...draft.files].some( (attachment) => !input.originalAttachmentIds?.includes(attachment.id), ) || diff --git a/apps/web/src/components/chat/useQueuedRunEdit.ts b/apps/web/src/components/chat/useQueuedRunEdit.ts index 73d6d1a061b3..a7dbee21a5d1 100644 --- a/apps/web/src/components/chat/useQueuedRunEdit.ts +++ b/apps/web/src/components/chat/useQueuedRunEdit.ts @@ -243,47 +243,56 @@ export function useQueuedRunEdit(input: { }); if (acquired._tag !== "Success") return; const target = queuedEditDraftTarget(routeThreadRef, request.runId); + const preparation = prepareQueuedMessageEditDraft({ + editTarget: target, + edit: { + ...acquired.value, + runId: request.runId, + messageId: request.messageId, + context: serverProjection?.messages.find((message) => message.id === request.messageId) + ?.context, + }, + environmentId, + text: request.text, + attachments: request.attachments, + readImage: async (attachment) => { + const connection = readPreparedConnection(environmentId); + if (!connection) throw new Error("Reconnect before editing attachments."); + const result = await executeAtomQuery( + appAtomRegistry, + assetEnvironment.createUrl({ + environmentId, + input: { resource: { _tag: "attachment", attachmentId: attachment.id } }, + }), + { refresh: true, reportFailure: false }, + ); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl); + if (url === null) throw new Error("Attachment URL unavailable."); + const response = await fetch(url, { signal: AbortSignal.timeout(30_000) }); + if (!response.ok) throw new Error(`Could not restore attachment: ${attachment.name}`); + const file = new File([await response.blob()], attachment.name, { + type: attachment.mimeType, + }); + return { file, dataUrl: await readFileAsDataUrl(file) }; + }, + }); + const preparedEdit = useComposerDraftStore.getState().getComposerDraft(target)?.queuedEdit; try { - await prepareQueuedMessageEditDraft({ - editTarget: target, - edit: { - ...acquired.value, - runId: request.runId, - messageId: request.messageId, - context: serverProjection?.messages.find((message) => message.id === request.messageId) - ?.context, - }, - environmentId, - text: request.text, - attachments: request.attachments, - readImage: async (attachment) => { - const connection = readPreparedConnection(environmentId); - if (!connection) throw new Error("Reconnect before editing attachments."); - const result = await executeAtomQuery( - appAtomRegistry, - assetEnvironment.createUrl({ - environmentId, - input: { resource: { _tag: "attachment", attachmentId: attachment.id } }, - }), - { refresh: true, reportFailure: false }, - ); - if (result._tag === "Failure") throw squashAtomCommandFailure(result); - const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl); - if (url === null) throw new Error("Attachment URL unavailable."); - const response = await fetch(url, { signal: AbortSignal.timeout(30_000) }); - if (!response.ok) throw new Error(`Could not restore attachment: ${attachment.name}`); - const file = new File([await response.blob()], attachment.name, { - type: attachment.mimeType, - }); - return { file, dataUrl: await readFileAsDataUrl(file) }; - }, - }); + await preparation; } catch (error) { const released = await releaseFailedPreparation({ environmentId, input: { threadId: activeThreadId, runId: request.runId, editId: acquired.value.editId }, }); - if (released._tag === "Success") clearEditDraft(request.runId); + if (released._tag === "Success" && preparedEdit?.ownership) + recoverQueuedMessageEdit({ + editTarget: target, + threadTarget: threadDraftTarget, + originalText: preparedEdit.originalText, + originalAttachmentIds: preparedEdit.originalAttachmentIds, + expectedEdit: preparedEdit, + }); toastManager.add({ type: "error", title: "Could not open queued edit", From d8e9db9f0de1a239c8118ff1aba4ef215e418054 Mon Sep 17 00:00:00 2001 From: Andrew Forster Date: Sun, 25 Oct 2026 22:35:59 -0700 Subject: [PATCH 15/21] Preserve queued edit saves and recovery across navigation --- .../chat/ComposerAttachmentStrip.tsx | 41 ------- .../chat/useQueuedRunEdit.browser.test.tsx | 21 +++- .../src/components/chat/useQueuedRunEdit.ts | 105 ++++++++++++++---- 3 files changed, 101 insertions(+), 66 deletions(-) diff --git a/apps/web/src/components/chat/ComposerAttachmentStrip.tsx b/apps/web/src/components/chat/ComposerAttachmentStrip.tsx index 1b2f572dfbd2..079a35276861 100644 --- a/apps/web/src/components/chat/ComposerAttachmentStrip.tsx +++ b/apps/web/src/components/chat/ComposerAttachmentStrip.tsx @@ -1,5 +1,4 @@ import type { - ChatAttachment, ChatFileAttachment, EnvironmentId, ScopedThreadRef, @@ -627,46 +626,6 @@ export function ComposerRestingImagePreviews({ ); } -/** Attachments of the queued message being edited; they are already uploaded. */ -export function ComposerQueuedAttachmentRow({ - attachments, - onRemove, -}: { - readonly attachments: ReadonlyArray<{ - readonly attachment: ChatAttachment; - readonly url: string | null; - }>; - readonly onRemove: (attachmentId: string) => void; -}) { - return ( -
- {attachments.map(({ attachment, url }) => ( -
- {attachment.type === "image" && url ? ( - {attachment.name} - ) : ( -
- {attachment.name} -
- )} - -
- ))} -
- ); -} - export function ComposerAttachButton({ addComposerAttachments, focusComposer, diff --git a/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx b/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx index 08e919cb152e..e2aa2dc4725e 100644 --- a/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx +++ b/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx @@ -22,6 +22,8 @@ interface RunStatus { const mocks = await vi.hoisted(async () => { const { Atom } = await import("effect/reactivity"); return { + acquireEdit: vi.fn(async () => ({ _tag: "Success", value: { editId: null, sequence: 0 } })), + releaseEdit: vi.fn(async () => ({ _tag: "Success" })), editQueuedRun: vi.fn<(value: unknown) => Promise<{ readonly _tag: "Success" | "Failure" }>>(), // The projection's runs per thread, written by each test to stand in for the server. threadRuns: Atom.family((_key: string) => @@ -31,18 +33,35 @@ const mocks = await vi.hoisted(async () => { }); vi.mock("../../state/use-atom-command", () => ({ - useAtomCommand: () => mocks.editQueuedRun, + useAtomCommand: (command: unknown) => + command === "begin" + ? mocks.acquireEdit + : command === "cancel" + ? mocks.releaseEdit + : mocks.editQueuedRun, })); const runsKey = (ref: ScopedThreadRef) => `${ref.environmentId}:${ref.threadId}`; vi.mock("../../state/threads", async () => { const { Atom } = await import("effect/reactivity"); + const { useAtomValue } = await import("@effect/atom-react"); + const Option = await import("effect/Option"); const key = (ref: ScopedThreadRef) => `${ref.environmentId}:${ref.threadId}`; return { + useEnvironmentThread: (environmentId: string, threadId: string) => { + const runs = useAtomValue(mocks.threadRuns(`${environmentId}:${threadId}`)); + return { + status: "live", + sequence: 0, + data: runs === null ? Option.none() : Option.some({ runs }), + }; + }, threadEnvironment: { snapshotAtom: () => Atom.make(null), editQueuedRun: {}, + beginQueuedRunEdit: "begin", + cancelQueuedRunEdit: "cancel", }, environmentThreadDetails: { statusAtom: () => Atom.make("ready"), diff --git a/apps/web/src/components/chat/useQueuedRunEdit.ts b/apps/web/src/components/chat/useQueuedRunEdit.ts index 23f8c9b63837..6d936775f614 100644 --- a/apps/web/src/components/chat/useQueuedRunEdit.ts +++ b/apps/web/src/components/chat/useQueuedRunEdit.ts @@ -1,4 +1,10 @@ import type { ThreadStructure } from "@t3tools/client-runtime/state/shell"; +import { useAtomValue } from "@effect/atom-react"; +import { + makeQueuedRunEditStore, + makeQueuedRunsAtom, +} from "@t3tools/client-runtime/queued-run-edit"; +import { Atom } from "effect/reactivity"; import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, type CommandId, @@ -38,7 +44,11 @@ import type { TerminalContextDraft } from "../../lib/terminalContext"; import type { ReviewCommentContext } from "../../reviewCommentContext"; import { appAtomRegistry } from "../../rpc/atomRegistry"; import { environmentServerConfigsAtom } from "../../state/server"; -import { threadEnvironment, useEnvironmentThread } from "../../state/threads"; +import { + environmentThreadDetails, + threadEnvironment, + useEnvironmentThread, +} from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; import { stackedThreadToast, toastManager } from "../ui/toast"; import type { ChatComposerHandle } from "./ChatComposer"; @@ -58,6 +68,40 @@ interface EditingQueuedRun extends QueuedRunEditDraft { readonly threadId: ThreadId; } +const queuedRunEdits = makeQueuedRunEditStore({ + registry: appAtomRegistry, + label: "web-queued-run-edits", +}); + +function watchQueuedEdit( + routeThreadRef: ScopedThreadRef, + threadTarget: ComposerThreadTarget, + edit: EditingQueuedRun, +) { + const key = scopedThreadKey(routeThreadRef); + if (queuedRunEdits.get(key)?.ownership === edit.ownership) return; + queuedRunEdits.begin(key, edit, { + runs: makeQueuedRunsAtom({ + threadId: edit.threadId, + snapshot: threadEnvironment.snapshotAtom(routeThreadRef.environmentId), + detailStatus: environmentThreadDetails.statusAtom(routeThreadRef), + runs: Atom.make( + (get) => + get(environmentThreadDetails.quietThreadAtom(routeThreadRef))?.projection.runs ?? null, + ), + }), + onRunLeftQueue: (_, endedEdit) => { + recoverQueuedMessageEdit({ + editTarget: queuedEditDraftTarget(routeThreadRef, endedEdit.runId), + threadTarget, + originalText: endedEdit.originalText, + originalAttachmentIds: endedEdit.originalAttachmentIds, + expectedEdit: endedEdit, + }); + }, + }); +} + /** What the composer holds when the user saves a queued-message edit. */ export interface QueuedRunEditContent { readonly prompt: string; @@ -183,8 +227,8 @@ export function useQueuedRunEdit(input: { threadId: activeThreadId, } : null; - const saveInFlightRef = useRef(false); - const [isSaving, setIsSaving] = useState(false); + const editKey = scopedThreadKey(routeThreadRef); + const isSaving = useAtomValue(queuedRunEdits.savingAtom)[editKey] === true; const savedEditDrafts = useComposerDraftStore( useShallow((state) => Object.keys(state.draftsByThreadKey).filter((key) => key.startsWith(recoveryPrefix)), @@ -209,7 +253,12 @@ export function useQueuedRunEdit(input: { .clearComposerContent(queuedEditDraftTarget(routeThreadRef, runId)); const beginEdit = useStableCallback(async (request: EditQueuedRunRequest) => { - if (activeThreadId === null || beginInFlight.current || saveInFlightRef.current) return; + if ( + activeThreadId === null || + beginInFlight.current || + appAtomRegistry.get(queuedRunEdits.savingAtom)[editKey] + ) + return; beginInFlight.current = true; const openEdit = async () => { try { @@ -319,28 +368,30 @@ export function useQueuedRunEdit(input: { }); }); const cancelEdit = useStableCallback(async () => { - if (editing === null || saveInFlightRef.current) return; - saveInFlightRef.current = true; - setIsSaving(true); + if (editing === null) return; + watchQueuedEdit(routeThreadRef, threadDraftTarget, editing); + const finishCancel = queuedRunEdits.startSave(editKey); + if (finishCancel === null) return; + const cancelledDraft = useComposerDraftStore.getState().getComposerDraft(draftTarget); const released = await releaseEdit({ environmentId, input: { threadId: editing.threadId, runId: editing.runId, editId: editing.editId }, }); - saveInFlightRef.current = false; - setIsSaving(false); - if (released._tag !== "Success") return; + const current = finishCancel(released._tag === "Success"); + if ( + released._tag !== "Success" || + !current || + useComposerDraftStore.getState().getComposerDraft(draftTarget) !== cancelledDraft + ) + return; clearEditDraft(editing.runId); focusComposerSoon(); }); useEffect(() => { - if ( - editing === null || - activeThreadId !== editing.threadId || - saveInFlightRef.current || - isSaving - ) - return; + if (editing === null || activeThreadId !== editing.threadId || isSaving) return; + watchQueuedEdit(routeThreadRef, threadDraftTarget, editing); if (queuedRunEditOwnership(threadState, editing) !== "lost") return; + queuedRunEdits.end(editKey); const recovery = recoverQueuedMessageEdit({ editTarget: queuedEditDraftTarget(routeThreadRef, editing.runId), threadTarget: threadDraftTarget, @@ -359,7 +410,7 @@ export function useQueuedRunEdit(input: { : "The message left the queue or another editor took over.", }), ); - }, [activeThreadId, editing, routeThreadRef, threadState, threadDraftTarget, isSaving]); + }, [activeThreadId, editing, routeThreadRef, threadState, threadDraftTarget, isSaving, editKey]); /** * Saves the composer content into the queued message being edited. Reports @@ -370,7 +421,7 @@ export function useQueuedRunEdit(input: { content: QueuedRunEditContent, setThreadError: (threadId: ThreadId, error: string | null) => void, ) => { - if (editing === null || saveInFlightRef.current) return; + if (editing === null || isSaving) return; const newAttachments = [...content.images, ...content.files]; if (newAttachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { setThreadError( @@ -382,8 +433,10 @@ export function useQueuedRunEdit(input: { if (content.prompt.trim().length === 0 && newAttachments.length === 0) { return; } - saveInFlightRef.current = true; - setIsSaving(true); + watchQueuedEdit(routeThreadRef, threadDraftTarget, editing); + const finishSave = queuedRunEdits.startSave(editKey); + if (finishSave === null) return; + const savedDraft = useComposerDraftStore.getState().getComposerDraft(draftTarget); const error = await submitQueuedRunEdit({ environmentId, draftTarget, @@ -391,10 +444,14 @@ export function useQueuedRunEdit(input: { content, editQueuedRun: editQueuedRunCommand, }); - saveInFlightRef.current = false; - setIsSaving(false); setThreadError(editing.threadId, error); - if (error !== null) return; + const current = finishSave(error === null); + if ( + error !== null || + !current || + useComposerDraftStore.getState().getComposerDraft(draftTarget) !== savedDraft + ) + return; releaseDraftAttachments(newAttachments); promptRef.current = ""; clearEditDraft(editing.runId); From 766a98bb15dbff4e51cc7e2f2b3fdaa2f4a3e595 Mon Sep 17 00:00:00 2001 From: Andrew Forster Date: Tue, 27 Oct 2026 18:43:46 -0700 Subject: [PATCH 16/21] fix(queue): preserve save validation after main merge --- .../mobile/src/state/thread-task-permissions.test.ts | 1 + apps/server/src/orchestration-v2/Orchestrator.ts | 12 +++++++----- apps/web/src/components/chat/useQueuedRunEdit.ts | 1 + 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/state/thread-task-permissions.test.ts b/apps/mobile/src/state/thread-task-permissions.test.ts index 6dff3cbf95ba..52e2e70e9f83 100644 --- a/apps/mobile/src/state/thread-task-permissions.test.ts +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -74,6 +74,7 @@ vi.mock("../lib/attachmentUpload", () => ({ prepareTurnAttachments: vi.fn() })); vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid" })); vi.mock("./queued-run-edit", () => ({ useQueuedRunEdit: () => null, + useQueuedRunEditSaving: () => false, getQueuedRunEdit: () => null, useSavedQueuedEditDrafts: () => [], useQueuedEditRecovery: () => null, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 681ca1906b5e..4b7a87567a22 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -9394,11 +9394,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: "A guest's message cannot be edited. Cancel it instead.", }); } - yield* ensureGoalCommandSupported( - command, - { text: command.text, attachments: command.attachments ?? queuedMessage.attachments }, - queuedRun.providerInstanceId, - ); + if (command.type === "queued-run.edit") { + yield* ensureGoalCommandSupported( + command, + { text: command.text, attachments: command.attachments ?? queuedMessage.attachments }, + queuedRun.providerInstanceId, + ); + } const queuedTurnItem = projection.turnItems.find( (candidate) => candidate.type === "user_message" && candidate.messageId === queuedMessage.id, ); diff --git a/apps/web/src/components/chat/useQueuedRunEdit.ts b/apps/web/src/components/chat/useQueuedRunEdit.ts index a5319f6160b1..6c5331486fc2 100644 --- a/apps/web/src/components/chat/useQueuedRunEdit.ts +++ b/apps/web/src/components/chat/useQueuedRunEdit.ts @@ -389,6 +389,7 @@ export function useQueuedRunEdit(input: { clearEditDraft(editing.runId); focusComposerSoon(); }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- keeps the edit projection watch and recovery toast in sync with server ownership after navigation useEffect(() => { if (editing === null || activeThreadId !== editing.threadId || isSaving) return; watchQueuedEdit(routeThreadRef, threadDraftTarget, editing); From 28d046a8526389d361911e00154080b44020ce12 Mon Sep 17 00:00:00 2001 From: Andrew Forster Date: Fri, 6 Nov 2026 16:39:22 -0800 Subject: [PATCH 17/21] test(queue): use current random and environment fixtures --- apps/mobile/src/state/queued-run-edit.test.ts | 2 +- packages/client-runtime/src/operations/commands.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/state/queued-run-edit.test.ts b/apps/mobile/src/state/queued-run-edit.test.ts index dfd1fa1d0fa3..0f7f100907a1 100644 --- a/apps/mobile/src/state/queued-run-edit.test.ts +++ b/apps/mobile/src/state/queued-run-edit.test.ts @@ -82,7 +82,7 @@ describe("queued edit draft key", () => { it("keeps the environment readable so attachment uploads still resolve it", () => { const key = queuedEditDraftKey("env-1:thread-9", "run-3"); - expect(composerDraftEnvironmentId(key, [])).toBe("env-1"); + expect(composerDraftEnvironmentId(key, [EnvironmentId.make("env-1")], [])).toBe("env-1"); }); }); diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index b31a849cef0a..9a4124d432bb 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -171,7 +171,7 @@ describe("V2 environment commands", () => { const saved = commands.find((command) => command.type === "queued-run.edit"); if (supported) expect(saved).toHaveProperty("editId", acquired.editId); else expect(saved).not.toHaveProperty("editId"); - }).pipe(Effect.provide(layerTestCrypto)), + }).pipe(Effect.provide(layerTestRandom)), ); it.effect("distinguishes sending a selected queued message from resuming queue order", () => Effect.gen(function* () { From a05d5c5f7b722fe078f491f5ad0a7c6d2bc9d00a Mon Sep 17 00:00:00 2001 From: Andrew Forster Date: Fri, 6 Nov 2026 16:43:02 -0800 Subject: [PATCH 18/21] test(queue): rebuild held projections with the replay service --- apps/server/src/orchestration-v2/runtimeLayer.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index f753fd5441aa..8d2011ede384 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -9030,7 +9030,7 @@ it.layer(layerTest)("queued edit holds", (it) => { q.active, ); assert.equal(holding.runs.find((run) => run.id === q.queued.id)?.queueEditId, q.editId); - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + const maintenance = yield* ProjectionReplay.ProjectionReplay; assert.isTrue((yield* maintenance.rebuild).valid); assert.equal( (yield* q.orchestrator.getThreadProjection(q.threadId)).runs.find( From 434bf3fc8b89aeafaf1690c21a88efd4000bcdc3 Mon Sep 17 00:00:00 2001 From: Andrew Forster Date: Tue, 10 Nov 2026 17:41:52 -0800 Subject: [PATCH 19/21] Fix queued-edit pause and recovery review findings --- .../features/threads/ThreadQueueControl.tsx | 3 +- apps/mobile/src/state/queued-run-edit.test.ts | 55 ++++++++++++++++ apps/mobile/src/state/queued-run-edit.ts | 33 ++++++++++ .../src/state/thread-task-permissions.test.ts | 36 ++++++++-- .../src/state/use-thread-composer-state.ts | 15 +++-- .../src/orchestration-v2/Orchestrator.ts | 9 ++- .../src/orchestration-v2/runtimeLayer.test.ts | 66 +++++++++++++++++-- .../components/chat/queuedMessageEdit.test.ts | 28 ++++++++ apps/web/src/composerDraftStore.ts | 17 +++++ docs/user/composer.md | 3 +- 10 files changed, 245 insertions(+), 20 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadQueueControl.tsx b/apps/mobile/src/features/threads/ThreadQueueControl.tsx index bd0401266110..ff5c43e9b619 100644 --- a/apps/mobile/src/features/threads/ThreadQueueControl.tsx +++ b/apps/mobile/src/features/threads/ThreadQueueControl.tsx @@ -41,6 +41,7 @@ import { serverEnvironment } from "../../state/server"; import { assetImageSource } from "../../lib/assetImageSource"; import { prepareQueuedRunEditDraft, + restoreQueuedEditAttachments, queuedEditDraftKey, recoverQueuedRunEdit, watchQueuedRunEdit, @@ -225,7 +226,7 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { ) ) throw new Error("This attachment cannot be edited."); - return await Promise.all( + return await restoreQueuedEditAttachments( entry.attachments .filter( (attachment): attachment is ChatImageAttachment | ChatFileAttachment => diff --git a/apps/mobile/src/state/queued-run-edit.test.ts b/apps/mobile/src/state/queued-run-edit.test.ts index 0f7f100907a1..8ecf21a7e25e 100644 --- a/apps/mobile/src/state/queued-run-edit.test.ts +++ b/apps/mobile/src/state/queued-run-edit.test.ts @@ -17,6 +17,7 @@ import { ComposerContextId, MessageId, RunId, + ProviderInstanceId, type ChatAttachment, } from "@t3tools/contracts"; @@ -28,6 +29,8 @@ import { watchQueuedRunEdit, startQueuedRunEditSave, prepareQueuedRunEditDraft, + restoreQueuedEditAttachments, + getQueuedRecoverySendDraft, canReuseQueuedRunEditDraft, recoverQueuedRunEdit, getQueuedRunEdit, @@ -44,7 +47,9 @@ import { replaceComposerDraftAttachments, removeComposerDraftAttachment, setComposerDraftText, + updateComposerDraftSettings, } from "./use-composer-drafts"; +import * as composerImages from "../lib/composerImages"; vi.mock("expo-file-system", () => ({ Directory: class { @@ -487,3 +492,53 @@ describe("activeComposerDraftKey", () => { expect(activeComposerDraftKey(threadKey, editKey, null)).toBe(threadKey); }); }); + +describe("queued edit recovery", () => { + it("sends recovered content with the settings displayed by the thread controls", () => { + const threadKey = "env:recovery-settings"; + const recoveryKey = queuedEditDraftKey(threadKey, "run-1"); + setComposerDraftText(threadKey, "Unrelated draft"); + setComposerDraftText(recoveryKey, "Recovered message"); + const modelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "selected-model", + }; + updateComposerDraftSettings(threadKey, { + modelSelection, + runtimeMode: "approval-required", + interactionMode: "plan", + }); + expect(getQueuedRecoverySendDraft(threadKey, recoveryKey)).toMatchObject({ + text: "Recovered message", + modelSelection, + runtimeMode: "approval-required", + interactionMode: "plan", + }); + updateComposerDraftSettings(threadKey, { modelSelection: undefined }); + expect(getQueuedRecoverySendDraft(threadKey, recoveryKey).modelSelection).toBeUndefined(); + expect(getComposerDraftSnapshot(threadKey).text).toBe("Unrelated draft"); + }); + + it("waits for late imports and removes their local files when another import fails", async () => { + const cleanup = vi + .spyOn(composerImages, "removePersistedComposerAttachmentFile") + .mockResolvedValue(); + try { + const late = Promise.withResolvers(); + const error = new Error("Download failed"); + const result = restoreQueuedEditAttachments([Promise.reject(error), late.promise]); + const rejected = expect(result).rejects.toBe(error); + await Promise.resolve(); + expect(cleanup).not.toHaveBeenCalled(); + late.resolve({ + ...image("late"), + previewUri: "file:///late.png", + fileUri: "file:///late.png", + }); + await rejected; + expect(cleanup).toHaveBeenCalledExactlyOnceWith("file:///late.png"); + } finally { + cleanup.mockRestore(); + } + }); +}); diff --git a/apps/mobile/src/state/queued-run-edit.ts b/apps/mobile/src/state/queued-run-edit.ts index 3c1cb4c414f0..b18f45732721 100644 --- a/apps/mobile/src/state/queued-run-edit.ts +++ b/apps/mobile/src/state/queued-run-edit.ts @@ -23,6 +23,7 @@ import { import { uploadedComposerContext } from "../lib/composerContext"; import type { DraftComposerAttachment } from "../lib/composerImages"; +import { removePersistedComposerAttachmentFile } from "../lib/composerImages"; import { appAtomRegistry } from "./atom-registry"; import { abortComposerDraftOperations } from "./composerDraftOperations"; import { queuedEditDraftKey } from "./queued-edit-draft-key"; @@ -50,6 +51,38 @@ export interface QueuedRunEdit { export { queuedEditDraftKey } from "./queued-edit-draft-key"; +export function getQueuedRecoverySendDraft(threadKey: string, draftKey: string) { + const content = getComposerDraftSnapshot(draftKey); + const settings = getComposerDraftSnapshot(threadKey); + return { + ...content, + modelSelection: settings.modelSelection, + runtimeMode: settings.runtimeMode, + interactionMode: settings.interactionMode, + }; +} + +export async function restoreQueuedEditAttachments( + imports: ReadonlyArray>, +): Promise> { + const results = await Promise.allSettled(imports); + const restored = results.flatMap((result) => + result.status === "fulfilled" ? [result.value] : [], + ); + const failure = results.find((result) => result.status === "rejected"); + if (failure) { + await Promise.allSettled( + restored.map((attachment) => + attachment.fileUri + ? removePersistedComposerAttachmentFile(attachment.fileUri) + : Promise.resolve(), + ), + ); + throw failure.reason; + } + return restored; +} + const queuedRunEdits = makeQueuedRunEditStore({ registry: appAtomRegistry, label: "mobile-queued-run-edits", diff --git a/apps/mobile/src/state/thread-task-permissions.test.ts b/apps/mobile/src/state/thread-task-permissions.test.ts index f5e36674e38a..147bf070c242 100644 --- a/apps/mobile/src/state/thread-task-permissions.test.ts +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -27,6 +27,7 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const state = vi.hoisted(() => ({ grantedEnvironments: new Set(), connectionState: "connected", + recoveryText: null as string | null, draft: { text: "Keep my draft", attachments: [] as DraftComposerImageAttachment[], @@ -83,13 +84,15 @@ vi.mock("@t3tools/client-runtime/state/context-window", async (importOriginal) = })); vi.mock("../lib/attachmentUpload", () => ({ prepareTurnAttachments: vi.fn() })); vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid" })); -vi.mock("./queued-run-edit", () => ({ +vi.mock("./queued-run-edit", async (importOriginal) => ({ + getQueuedRecoverySendDraft: (await importOriginal()) + .getQueuedRecoverySendDraft, useQueuedRunEdit: () => null, useQueuedRunEditSaving: () => false, getQueuedRunEdit: () => null, useSavedQueuedEditDrafts: () => [], - useQueuedEditRecovery: () => null, - getQueuedEditRecovery: () => null, + useQueuedEditRecovery: () => (state.recoveryText === null ? null : "secondary:thread:recovery"), + getQueuedEditRecovery: () => (state.recoveryText === null ? null : "secondary:thread:recovery"), })); vi.mock("./preferences", async () => { const { Atom, AsyncResult } = await import("effect/reactivity"); @@ -157,7 +160,10 @@ vi.mock("./use-composer-drafts", async () => { return { composerDraftsAtom: Atom.make({}), composerContextImportsAtom: Atom.make({}), - getComposerDraftSnapshot: () => state.draft, + getComposerDraftSnapshot: (key: string) => + key === "secondary:thread:recovery" + ? { ...state.draft, text: state.recoveryText } + : state.draft, isFreshSessionPending: () => state.freshPending, setFreshSessionPending: state.setFreshPending, clearComposerDraftContent: state.clearDraft, @@ -201,6 +207,7 @@ const NO_SIDE_CHATS = { startSideChat: async () => false }; beforeEach(() => { state.grantedEnvironments = new Set(["primary"]); state.connectionState = "connected"; + state.recoveryText = null; state.draft = { text: "Keep my draft", attachments: [], @@ -233,6 +240,27 @@ beforeEach(() => { }); describe("mobile task permissions", () => { + it.each(["/clear", "/side explain this", "approval"])( + "clears only the selected recovery draft for %s", + async (text) => { + state.grantedEnvironments.add("secondary"); + state.recoveryText = text; + state.thread.latestRun = { runId: "run" }; + state.serverConfig = { + ...state.serverConfig, + environment: { capabilities: { freshContext: true, sideChats: true } }, + } as ServerConfig; + const composer = useThreadComposerState({ startSideChat: async () => true }); + await composer.onSendMessage( + undefined, + text === "approval" ? async () => "responded" : undefined, + ); + expect(state.clearDraft).toHaveBeenCalledExactlyOnceWith("secondary:thread:recovery"); + expect(state.draft.text).toBe("Keep my draft"); + expect(state.enqueue).not.toHaveBeenCalled(); + }, + ); + it("consumes an approval reply without queueing a parent message", async () => { state.grantedEnvironments.add("secondary"); const composer = useThreadComposerState(NO_SIDE_CHATS); diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index a5e6bf719a30..16d7ead66eaf 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -100,6 +100,7 @@ import { environmentThreadDetails, useEnvironmentThread } from "./threads"; import { endQueuedRunEdit, recoverQueuedRunEdit, + getQueuedRecoverySendDraft, useSavedQueuedEditDrafts, useQueuedEditRecovery, selectQueuedEditRecovery, @@ -594,7 +595,7 @@ export function useThreadComposerState(input: { const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); const draftKey = activeComposerDraftKey(selectedThreadShell); - const draft = getComposerDraftSnapshot(draftKey); + const draft = getQueuedRecoverySendDraft(threadKey, draftKey); if (appAtomRegistry.get(composerContextImportsAtom)[draftKey]) return null; const thread = selectedThreadShell; const text = draft.text.trim(); @@ -654,14 +655,14 @@ export function useThreadComposerState(input: { startingSideChatRef.current = false; }); // Clear only what was sent; the draft may have changed while the side chat started. - const current = getComposerDraftSnapshot(threadKey); + const current = getComposerDraftSnapshot(draftKey); if ( started && current.text === draft.text && current.attachments.length === 0 && (current.context?.records.length ?? 0) === 0 ) { - clearComposerDraftContent(threadKey); + clearComposerDraftContent(draftKey); } } return null; @@ -676,7 +677,7 @@ export function useThreadComposerState(input: { isClearComposerCommand(text) ) { setFreshSessionPending(threadKey, true); - clearComposerDraftContent(threadKey); + clearComposerDraftContent(draftKey); return null; } // Recovery preserves over-cap attachments; require trimming or outbox delivery would reject indefinitely. @@ -749,8 +750,8 @@ export function useThreadComposerState(input: { if (!response) return null; // A context paste still landing, or any draft or settings change while approvals were // declined, means this snapshot is no longer what the user sent. - if (appAtomRegistry.get(composerContextImportsAtom)[threadKey]) return null; - const current = getComposerDraftSnapshot(threadKey); + if (appAtomRegistry.get(composerContextImportsAtom)[draftKey]) return null; + const current = getQueuedRecoverySendDraft(threadKey, draftKey); if ( current.text !== draft.text || JSON.stringify(current.context) !== JSON.stringify(draft.context) || @@ -762,7 +763,7 @@ export function useThreadComposerState(input: { ) return null; if (response === "responded") { - clearComposerDraftContent(threadKey); + clearComposerDraftContent(draftKey); return null; } } diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 6b3e773fca0d..ea8e9a82970c 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -6356,7 +6356,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const editingQueueRun = dispatchMode.type === "queue_after_active" && projection.runs.some((run) => run.status === "queued" && run.queueEditId != null) - ? nextQueuedRun(projection) + ? queuedRunsInDeliveryOrder(projection)[0] : undefined; const queueBehindRun = activeRun ?? worktreeMissingRun ?? editingQueueRun; const pendingMergeBackTransfers = pendingMergeBackTransfersForThread(projection); @@ -13774,6 +13774,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? undefined : projection.runs.find((run) => run.id === selectedRunId && run.status === "queued"); if (selectedRunId !== undefined) { + if (selectedRun?.queueEditId != null) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "Finish editing this queued message before sending it now.", + }); + } const selectedMessage = projection.messages.find( (message) => message.id === selectedRun?.userMessageId, ); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 7fa01c7849f7..76ef801101a4 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -9332,6 +9332,42 @@ const pauseQueueTerminalListener = Effect.fnUntraced(function* (threadId: Thread }); it.layer(layerTest)("queued edit holds", (it) => { + it.effect("queues new messages behind an edit even when Stop held the entire queue", () => + Effect.gen(function* () { + const q = yield* editableQueue("send-after-stop"); + yield* q.orchestrator.dispatch(q.begin); + yield* q.orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make(`${q.threadId}:stop`), + threadId: q.threadId, + runId: q.active.id, + holdQueue: true, + }); + yield* q.orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${q.threadId}:new-message`), + threadId: q.threadId, + messageId: MessageId.make(`${q.threadId}:new-message`), + text: "Wait behind the edit", + attachments: [], + createdBy: "user", + creationSource: "web", + modelSelection, + dispatchMode: { type: "queue_after_active" }, + }); + const after = yield* q.orchestrator.getThreadProjection(q.threadId); + assert.deepEqual( + after.runs.slice(1).map((run) => run.status), + ["queued", "queued", "queued"], + ); + assert.isAbove(after.runs.at(-1)!.queuePosition!, q.later.queuePosition ?? q.later.ordinal); + yield* q.orchestrator.dispatch(q.finish("save")); + const resumed = yield* q.orchestrator.getThreadProjection(q.threadId); + assert.equal(resumed.runs.find((run) => run.id === q.queued.id)?.status, "starting"); + assert.equal(resumed.runs.at(-1)?.status, "queued"); + }), + ); + it.effect("lets an explicit Resume release a missing-worktree pause", () => Effect.gen(function* () { const q = yield* editableQueue("resume-missing-worktree", null); @@ -9385,12 +9421,18 @@ it.layer(layerTest)("queued edit holds", (it) => { commandId: CommandId.make(`${q.threadId}:resume-while-editing`), threadId: q.threadId, }); - yield* q.orchestrator.dispatch({ - type: "queued-run.start", - commandId: CommandId.make(`${q.threadId}:send-now-while-editing`), - threadId: q.threadId, - runId: q.queued.id, - }); + assert.isTrue( + Exit.isFailure( + yield* Effect.exit( + q.orchestrator.dispatch({ + type: "queued-run.start", + commandId: CommandId.make(`${q.threadId}:send-now-while-editing`), + threadId: q.threadId, + runId: q.queued.id, + }), + ), + ), + ); const held = yield* q.orchestrator.getThreadProjection(q.threadId); const shell = (yield* q.orchestrator.getShellSnapshot()).threads.find( (thread) => thread.id === q.threadId, @@ -9599,6 +9641,18 @@ it.layer(layerTest)("queued edit holds", (it) => { const recovery = yield* ProviderRuntimeRecoveryService.ProviderRuntimeRecoveryService; yield* recovery.reconcile("startup"); } + assert.isTrue( + Exit.isFailure( + yield* Effect.exit( + q.orchestrator.dispatch({ + type: "queued-run.start", + commandId: CommandId.make(`${q.threadId}:stale-send-now`), + threadId: q.threadId, + runId: q.queued.id, + }), + ), + ), + ); assert.equal( (yield* q.orchestrator.getThreadProjection(q.threadId)).runs.find( (run) => run.id === q.queued.id, diff --git a/apps/web/src/components/chat/queuedMessageEdit.test.ts b/apps/web/src/components/chat/queuedMessageEdit.test.ts index 2a23659df371..cbea9e83bdd3 100644 --- a/apps/web/src/components/chat/queuedMessageEdit.test.ts +++ b/apps/web/src/components/chat/queuedMessageEdit.test.ts @@ -85,6 +85,34 @@ const uploadedFile = { }; describe("queued message file edits", () => { + it("records stashed attachments as intentional removals", async () => { + const store = useComposerDraftStore.getState(); + await prepareQueuedMessageEditDraft({ + editTarget, + edit, + environmentId, + text: "Original message", + attachments: [uploadedFile], + readImage: async () => { + throw new Error("Unexpected image"); + }, + }); + store.clearComposerPromptAndImages(editTarget); + store.setPrompt(editTarget, "Replacement text"); + const draft = store.getComposerDraft(editTarget)!; + expect(draft.queuedEdit?.removedAttachmentIds).toEqual([uploadedFile.id]); + await expect( + prepareQueuedEditPayload({ + edit: draft.queuedEdit!, + content: draft, + readImage: async () => { + throw new Error("Unexpected image"); + }, + uploadFiles: async () => [], + }), + ).resolves.toMatchObject({ attachments: [] }); + }); + beforeEach(() => { useComposerDraftStore.setState({ draftsByThreadKey: {}, draftThreadsByThreadKey: {} }); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 5a5f0bf7349f..d3aadfc546a1 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -4137,6 +4137,23 @@ const composerDraftStore = create()( ...current.environmentContexts.map(environmentContextReference), ...current.previewAnnotations.map(previewAnnotationContextReference), ]), + ...(current.queuedEdit + ? { + queuedEdit: { + ...current.queuedEdit, + removedAttachmentIds: [ + ...new Set([ + ...(current.queuedEdit.removedAttachmentIds ?? []), + ...[...current.images, ...current.files] + .filter((attachment) => + current.queuedEdit?.originalAttachmentIds.includes(attachment.id), + ) + .map((attachment) => attachment.id), + ]), + ], + }, + } + : {}), images: [], files: [], nonPersistedImageIds: [], diff --git a/docs/user/composer.md b/docs/user/composer.md index c68a39daa21d..d87f4c8f8ccc 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -267,7 +267,8 @@ shortcut described above. On web and desktop, queued messages appear at the end of the thread as dashed messages that have not been sent yet. On mobile they appear above the composer. Each shows its text and any attached images. **Send now** sends a message right away, into the running turn if there is one. You can -also edit or remove it. +also edit or remove it. Finish an open edit with Save or Cancel before using **Send now**; +trying to send an edit-held message does not release a Stop pause. If you stop the agent or the server restarts, queued messages keep their order and are held. Press **Resume** in an empty composer to continue. **Resume** also continues a stopped From 6a7263e8b3abe2f7068b23960a5637fb76f71e09 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 06:58:05 +0000 Subject: [PATCH 20/21] fix(server): only queue new messages behind an edit that is next in line A send while a later queued run was edit-held parked the new message behind the unheld first run, leaving the thread idle after Stop. AI-assisted; verified with the tests and tools listed above. --- .../src/orchestration-v2/Orchestrator.ts | 6 ++-- .../src/orchestration-v2/runtimeLayer.test.ts | 31 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 72a97e5b7c5b..a2e8dd51ecb2 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -6386,11 +6386,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio projection.thread.worktreePath === null ? latestRun : undefined; - const editingQueueRun = - dispatchMode.type === "queue_after_active" && - projection.runs.some((run) => run.status === "queued" && run.queueEditId != null) + const nextQueuedRun = + dispatchMode.type === "queue_after_active" ? queuedRunsInDeliveryOrder(projection)[0] : undefined; + const editingQueueRun = nextQueuedRun?.queueEditId != null ? nextQueuedRun : undefined; const queueBehindRun = activeRun ?? worktreeMissingRun ?? editingQueueRun; const pendingMergeBackTransfers = pendingMergeBackTransfersForThread(projection); const startsOrQueuesRun = diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 76ef801101a4..88de4b8cb44f 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -9368,6 +9368,37 @@ it.layer(layerTest)("queued edit holds", (it) => { }), ); + it.effect("starts a new message after Stop when the edit is not first in line", () => + Effect.gen(function* () { + const q = yield* editableQueue("send-after-stop-later-edit"); + yield* q.orchestrator.dispatch({ ...q.begin, runId: q.later.id }); + yield* q.orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make(`${q.threadId}:stop`), + threadId: q.threadId, + runId: q.active.id, + holdQueue: true, + }); + yield* q.orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${q.threadId}:new-message`), + threadId: q.threadId, + messageId: MessageId.make(`${q.threadId}:new-message`), + text: "Start now", + attachments: [], + createdBy: "user", + creationSource: "web", + modelSelection, + dispatchMode: { type: "queue_after_active" }, + }); + const after = yield* q.orchestrator.getThreadProjection(q.threadId); + assert.deepEqual( + after.runs.slice(1).map((run) => run.status), + ["queued", "queued", "starting"], + ); + }), + ); + it.effect("lets an explicit Resume release a missing-worktree pause", () => Effect.gen(function* () { const q = yield* editableQueue("resume-missing-worktree", null); From 002830fb047091a4327baf726b3b4ce3bb387e67 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 07:18:35 +0000 Subject: [PATCH 21/21] fix(server): a message sent after a provider failure survives saving a queued edit Saving or cancelling a queued edit re-derived the provider-failure pause from the last executed run, re-holding a queue the user had already released by sending a message. The pause now skips when a client message was created after the failed run ended. The missing-worktree pause is unchanged because sends do not release it while worktree setup is pending. AI-assisted; verified with the tests and tools listed above. --- .../src/orchestration-v2/Orchestrator.ts | 12 ++++++++ .../src/orchestration-v2/runtimeLayer.test.ts | 28 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index a2e8dd51ecb2..f1097ae95e99 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1936,6 +1936,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const worktreeMissing = failedRun?.workspacePreparation?.type === "worktree" && projection.thread.worktreePath === null; + // A client message sent after the failure already released its pause. + const failedAt = failedRun?.completedAt ?? null; + const sentByUserAfterFailure = + failedAt !== null && + projection.messages.some( + (message) => + message.createdBy === "user" && + (message.creationSource === "web" || message.creationSource === "mobile") && + message.scheduledTaskId === undefined && + DateTime.isGreaterThan(message.createdAt, failedAt), + ); const settings = yield* serverSettings.getSettings.pipe(Effect.orDie); const projectOverride = settings.projectSettingsOverrides[projection.thread.projectId]?.providerInstances?.[ @@ -1955,6 +1966,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio (worktreeMissing && options?.userResumed !== true) || instanceDisabledForProject || (options?.userResumed !== true && + !sentByUserAfterFailure && failureClass !== undefined && failureClass !== "validation_error" && failedRun?.providerInstanceId === queuedRun.providerInstanceId) diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 88de4b8cb44f..ad2a3d56178b 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -9399,6 +9399,34 @@ it.layer(layerTest)("queued edit holds", (it) => { }), ); + it.effect("saving an edit after a send does not re-hold a released failure pause", () => + Effect.gen(function* () { + const q = yield* editableQueue("send-after-failure"); + yield* q.orchestrator.dispatch(q.begin); + yield* endQueueActiveRun(q.active, "provider_error"); + yield* q.orchestrator.resumeQueuedRuns; + yield* TestClock.adjust("1 second"); + yield* q.orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${q.threadId}:new-message`), + threadId: q.threadId, + messageId: MessageId.make(`${q.threadId}:new-message`), + text: "Send after failure", + attachments: [], + createdBy: "user", + creationSource: "web", + modelSelection, + dispatchMode: { type: "queue_after_active" }, + }); + yield* q.orchestrator.dispatch(q.finish("save")); + const after = yield* q.orchestrator.getThreadProjection(q.threadId); + assert.deepEqual( + after.runs.slice(1).map((run) => run.status), + ["starting", "queued", "queued"], + ); + }), + ); + it.effect("lets an explicit Resume release a missing-worktree pause", () => Effect.gen(function* () { const q = yield* editableQueue("resume-missing-worktree", null);