From 168da881013612f0ab0a5c55b448b78b463d4f8a Mon Sep 17 00:00:00 2001 From: tris203 Date: Thu, 15 Oct 2026 15:33:39 +0000 Subject: [PATCH 1/3] fix(mobile): a steered queued message no longer returns to the composer --- apps/mobile/src/state/queued-run-edit.test.ts | 17 ++++++++++++++++- apps/mobile/src/state/queued-run-edit.ts | 8 ++++++++ .../src/state/use-thread-composer-state.ts | 8 ++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/state/queued-run-edit.test.ts b/apps/mobile/src/state/queued-run-edit.test.ts index 67e456369b890..1af11a78d82b0 100644 --- a/apps/mobile/src/state/queued-run-edit.test.ts +++ b/apps/mobile/src/state/queued-run-edit.test.ts @@ -10,7 +10,11 @@ import { import { composerDraftEnvironmentId } from "../lib/composerAttachmentUploadQueue"; import { isQueuedEditDraftKey, queuedEditDraftKey } from "./queued-edit-draft-key"; -import { resolveQueuedEditPayload, type QueuedRunEdit } from "./queued-run-edit"; +import { + queuedRunEditIsDirty, + resolveQueuedEditPayload, + type QueuedRunEdit, +} from "./queued-run-edit"; const image = (id: string): ChatAttachment => ({ type: "image", @@ -104,3 +108,14 @@ describe("resolveQueuedEditPayload", () => { expect(payload.context).toBeUndefined(); }); }); + +describe("queuedRunEditIsDirty", () => { + it("treats an untouched edit as clean so a steered message is not restored", () => { + expect(queuedRunEditIsDirty(edit([]), { text: "original", attachments: [] })).toBe(false); + }); + + it("treats changed text or a new attachment as an edit worth keeping", () => { + expect(queuedRunEditIsDirty(edit([]), { text: "revised", attachments: [] })).toBe(true); + expect(queuedRunEditIsDirty(edit([]), { text: "original", attachments: [{}] })).toBe(true); + }); +}); diff --git a/apps/mobile/src/state/queued-run-edit.ts b/apps/mobile/src/state/queued-run-edit.ts index 6eac91a5adfc6..c2eaf14f27b2e 100644 --- a/apps/mobile/src/state/queued-run-edit.ts +++ b/apps/mobile/src/state/queued-run-edit.ts @@ -83,6 +83,14 @@ export function removeQueuedRunEditAttachment(threadKey: string, attachmentId: s }); } +/** Whether the edit draft holds anything the queued message does not already have. */ +export function queuedRunEditIsDirty( + edit: QueuedRunEdit, + draft: { readonly text: string; readonly attachments: ReadonlyArray }, +): boolean { + return draft.text !== edit.originalText || draft.attachments.length > 0; +} + export function endQueuedRunEdit( threadKey: string, options?: { readonly deferAttachmentCleanup?: boolean }, diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 438b419672bc0..8718cdb022014 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -96,6 +96,7 @@ import { environmentThreadDetails } from "./threads"; import { endQueuedRunEdit, getQueuedRunEdit, + queuedRunEditIsDirty, queuedEditDraftKey, removeQueuedRunEditAttachment, resolveQueuedEditPayload, @@ -434,8 +435,15 @@ export function useThreadComposerState() { (run) => run.id === editedRunId && run.status === "queued", ); if (stillQueued) return; + const edit = getQueuedRunEdit(selectedThreadKey); const editDraftKey = queuedEditDraftKey(selectedThreadKey, editedRunId); const editDraft = getComposerDraftSnapshot(editDraftKey); + // An untouched edit is just the message that was sent (or steered), so + // there is nothing to recover and nothing to tell the user. + if (edit === null || !queuedRunEditIsDirty(edit, editDraft)) { + endQueuedRunEdit(selectedThreadKey); + return; + } const threadDraft = getComposerDraftSnapshot(selectedThreadKey); const keepable = editDraft.text.trim().length > 0 && From 29768d8fe86abd96d5c6f897bf1a8360a4fdfba1 Mon Sep 17 00:00:00 2001 From: tris203 Date: Thu, 15 Oct 2026 15:36:38 +0000 Subject: [PATCH 2/3] test(mobile): cover queued edit recovery when its run leaves the queue --- apps/mobile/src/state/queued-run-edit.test.ts | 40 ++++++++++++++--- apps/mobile/src/state/queued-run-edit.ts | 45 +++++++++++++++---- .../src/state/use-thread-composer-state.ts | 37 ++++----------- 3 files changed, 79 insertions(+), 43 deletions(-) diff --git a/apps/mobile/src/state/queued-run-edit.test.ts b/apps/mobile/src/state/queued-run-edit.test.ts index 1af11a78d82b0..aed74d5a7fa4c 100644 --- a/apps/mobile/src/state/queued-run-edit.test.ts +++ b/apps/mobile/src/state/queued-run-edit.test.ts @@ -11,10 +11,13 @@ import { import { composerDraftEnvironmentId } from "../lib/composerAttachmentUploadQueue"; import { isQueuedEditDraftKey, queuedEditDraftKey } from "./queued-edit-draft-key"; import { - queuedRunEditIsDirty, + beginQueuedRunEdit, + getQueuedRunEdit, + recoverQueuedRunEdit, resolveQueuedEditPayload, type QueuedRunEdit, } from "./queued-run-edit"; +import { getComposerDraftSnapshot, setComposerDraftText } from "./use-composer-drafts"; const image = (id: string): ChatAttachment => ({ type: "image", @@ -109,13 +112,36 @@ describe("resolveQueuedEditPayload", () => { }); }); -describe("queuedRunEditIsDirty", () => { - it("treats an untouched edit as clean so a steered message is not restored", () => { - expect(queuedRunEditIsDirty(edit([]), { text: "original", attachments: [] })).toBe(false); +describe("recoverQueuedRunEdit", () => { + const threadKey = "env:thread-recover"; + const runId = RunId.make("run-1"); + const editKey = queuedEditDraftKey(threadKey, runId); + + it("drops an untouched edit without restoring the sent message", () => { + beginQueuedRunEdit(threadKey, edit([])); + + expect(recoverQueuedRunEdit(threadKey, runId)).toBe("clean"); + expect(getQueuedRunEdit(threadKey)).toBeNull(); + expect(getComposerDraftSnapshot(threadKey).text).toBe(""); + expect(getComposerDraftSnapshot(editKey).text).toBe(""); + }); + + it("discards a changed edit when the thread draft already has content", () => { + setComposerDraftText(threadKey, "unsent draft"); + beginQueuedRunEdit(threadKey, edit([])); + setComposerDraftText(editKey, "revised"); + + expect(recoverQueuedRunEdit(threadKey, runId)).toBe("discarded"); + expect(getQueuedRunEdit(threadKey)).toBeNull(); + expect(getComposerDraftSnapshot(threadKey).text).toBe("unsent draft"); + setComposerDraftText(threadKey, ""); }); - it("treats changed text or a new attachment as an edit worth keeping", () => { - expect(queuedRunEditIsDirty(edit([]), { text: "revised", attachments: [] })).toBe(true); - expect(queuedRunEditIsDirty(edit([]), { text: "original", attachments: [{}] })).toBe(true); + it("ignores a run the composer is no longer editing", () => { + beginQueuedRunEdit(threadKey, edit([])); + + expect(recoverQueuedRunEdit(threadKey, RunId.make("run-2"))).toBeNull(); + expect(getQueuedRunEdit(threadKey)?.runId).toBe(runId); + expect(recoverQueuedRunEdit(threadKey, runId)).toBe("clean"); }); }); diff --git a/apps/mobile/src/state/queued-run-edit.ts b/apps/mobile/src/state/queued-run-edit.ts index c2eaf14f27b2e..107e2066e998a 100644 --- a/apps/mobile/src/state/queued-run-edit.ts +++ b/apps/mobile/src/state/queued-run-edit.ts @@ -19,6 +19,8 @@ import { appAtomRegistry } from "./atom-registry"; import { queuedEditDraftKey } from "./queued-edit-draft-key"; import { clearComposerDraft, + getComposerDraftSnapshot, + mergeComposerDraftContent, setComposerDraftContext, setComposerDraftText, } from "./use-composer-drafts"; @@ -83,14 +85,6 @@ export function removeQueuedRunEditAttachment(threadKey: string, attachmentId: s }); } -/** Whether the edit draft holds anything the queued message does not already have. */ -export function queuedRunEditIsDirty( - edit: QueuedRunEdit, - draft: { readonly text: string; readonly attachments: ReadonlyArray }, -): boolean { - return draft.text !== edit.originalText || draft.attachments.length > 0; -} - export function endQueuedRunEdit( threadKey: string, options?: { readonly deferAttachmentCleanup?: boolean }, @@ -101,6 +95,41 @@ export function endQueuedRunEdit( setQueuedRunEdit(threadKey, null); } +/** + * Leaves edit mode once the edited run is no longer queued (it started, was + * steered, or was removed elsewhere). A changed edit moves into the thread's + * own draft when that draft is empty and is otherwise discarded; an untouched + * edit is just the message that was sent, so it is dropped as "clean". + * Returns null when the composer is not editing `runId`. + */ +export function recoverQueuedRunEdit( + threadKey: string, + runId: RunId, +): "kept" | "discarded" | "clean" | null { + const edit = getQueuedRunEdit(threadKey); + if (edit === null || edit.runId !== runId) return null; + const editDraft = getComposerDraftSnapshot(queuedEditDraftKey(threadKey, runId)); + const dirty = editDraft.text !== edit.originalText || editDraft.attachments.length > 0; + if (!dirty) { + endQueuedRunEdit(threadKey); + return "clean"; + } + const threadDraft = getComposerDraftSnapshot(threadKey); + const keepable = + editDraft.text.trim().length > 0 && + threadDraft.text.trim().length === 0 && + threadDraft.attachments.length === 0; + if (keepable) { + void mergeComposerDraftContent(threadKey, { + text: editDraft.text, + attachments: editDraft.attachments, + ...(editDraft.context ? { context: editDraft.context } : {}), + }); + } + endQueuedRunEdit(threadKey, { deferAttachmentCleanup: keepable }); + return keepable ? "kept" : "discarded"; +} + /** * Attachments replace the message's list wholesale, so kept server attachments * are resent alongside the newly uploaded ones, in composer order. diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 8718cdb022014..8049349446bd5 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -96,7 +96,7 @@ import { environmentThreadDetails } from "./threads"; import { endQueuedRunEdit, getQueuedRunEdit, - queuedRunEditIsDirty, + recoverQueuedRunEdit, queuedEditDraftKey, removeQueuedRunEditAttachment, resolveQueuedEditPayload, @@ -435,34 +435,15 @@ export function useThreadComposerState() { (run) => run.id === editedRunId && run.status === "queued", ); if (stillQueued) return; - const edit = getQueuedRunEdit(selectedThreadKey); - const editDraftKey = queuedEditDraftKey(selectedThreadKey, editedRunId); - const editDraft = getComposerDraftSnapshot(editDraftKey); - // An untouched edit is just the message that was sent (or steered), so - // there is nothing to recover and nothing to tell the user. - if (edit === null || !queuedRunEditIsDirty(edit, editDraft)) { - endQueuedRunEdit(selectedThreadKey); - return; - } - const threadDraft = getComposerDraftSnapshot(selectedThreadKey); - const keepable = - editDraft.text.trim().length > 0 && - threadDraft.text.trim().length === 0 && - threadDraft.attachments.length === 0; - if (keepable) { - void mergeComposerDraftContent(selectedThreadKey, { - text: editDraft.text, - attachments: editDraft.attachments, - ...(editDraft.context ? { context: editDraft.context } : {}), - }); + const recovery = recoverQueuedRunEdit(selectedThreadKey, editedRunId); + if (recovery === "kept" || recovery === "discarded") { + setThreadComposerError( + selectedThreadKey, + recovery === "kept" + ? "That message already started. Your edit is back in the composer." + : "That message already started, so the edit was discarded.", + ); } - endQueuedRunEdit(selectedThreadKey, { deferAttachmentCleanup: keepable }); - setThreadComposerError( - selectedThreadKey, - keepable - ? "That message already started. Your edit is back in the composer." - : "That message already started, so the edit was discarded.", - ); }, [editedRunId, selectedThreadKey, selectedThreadRuns]); const activeThreadBusy = threadRuntimeIsActive(selectedThreadRuntime); From ea5e01bcbb34b7a78b72d28d2ca65ffc1a503813 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 02:59:50 +0000 Subject: [PATCH 3/3] fix(mobile): verify queued edit recovery without restoring sent text --- apps/mobile/src/state/queued-run-edit.test.ts | 69 +++++++++++++++---- apps/mobile/src/state/queued-run-edit.ts | 8 +-- 2 files changed, 56 insertions(+), 21 deletions(-) diff --git a/apps/mobile/src/state/queued-run-edit.test.ts b/apps/mobile/src/state/queued-run-edit.test.ts index aed74d5a7fa4c..fe50dbf497728 100644 --- a/apps/mobile/src/state/queued-run-edit.test.ts +++ b/apps/mobile/src/state/queued-run-edit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { ChatAttachmentId, ComposerContextId, @@ -12,12 +12,32 @@ import { composerDraftEnvironmentId } from "../lib/composerAttachmentUploadQueue import { isQueuedEditDraftKey, queuedEditDraftKey } from "./queued-edit-draft-key"; import { beginQueuedRunEdit, + endQueuedRunEdit, getQueuedRunEdit, recoverQueuedRunEdit, resolveQueuedEditPayload, type QueuedRunEdit, } from "./queued-run-edit"; -import { getComposerDraftSnapshot, setComposerDraftText } from "./use-composer-drafts"; +import { + clearComposerDraft, + flushComposerDrafts, + getComposerDraftSnapshot, + setComposerDraftText, + waitForComposerDraftsLoaded, +} from "./use-composer-drafts"; + +vi.mock("expo-file-system", () => ({ + Directory: class { + create() {} + }, + File: class { + exists = false; + create() {} + write() {} + moveSync() {} + }, + Paths: { document: { uri: "file:///documents" } }, +})); const image = (id: string): ChatAttachment => ({ type: "image", @@ -117,31 +137,52 @@ describe("recoverQueuedRunEdit", () => { const runId = RunId.make("run-1"); const editKey = queuedEditDraftKey(threadKey, runId); - it("drops an untouched edit without restoring the sent message", () => { + beforeEach(async () => { + await waitForComposerDraftsLoaded(); + }); + + afterEach(async () => { + endQueuedRunEdit(threadKey); + clearComposerDraft(threadKey); + await flushComposerDrafts(); + }); + + it("drops an untouched edit without restoring the sent message", async () => { beginQueuedRunEdit(threadKey, edit([])); - expect(recoverQueuedRunEdit(threadKey, runId)).toBe("clean"); - expect(getQueuedRunEdit(threadKey)).toBeNull(); + const recovery = recoverQueuedRunEdit(threadKey, runId); + await flushComposerDrafts(); + expect(getComposerDraftSnapshot(threadKey).text).toBe(""); expect(getComposerDraftSnapshot(editKey).text).toBe(""); + expect(getQueuedRunEdit(threadKey)).toBeNull(); + expect(recovery).toBe("clean"); }); - it("discards a changed edit when the thread draft already has content", () => { - setComposerDraftText(threadKey, "unsent draft"); + it("recovers a changed edit into an empty thread draft", async () => { beginQueuedRunEdit(threadKey, edit([])); setComposerDraftText(editKey, "revised"); - expect(recoverQueuedRunEdit(threadKey, runId)).toBe("discarded"); + const recovery = recoverQueuedRunEdit(threadKey, runId); + await flushComposerDrafts(); + + expect(getComposerDraftSnapshot(threadKey).text).toBe("revised"); + expect(getComposerDraftSnapshot(editKey).text).toBe(""); expect(getQueuedRunEdit(threadKey)).toBeNull(); - expect(getComposerDraftSnapshot(threadKey).text).toBe("unsent draft"); - setComposerDraftText(threadKey, ""); + expect(recovery).toBe("kept"); }); - it("ignores a run the composer is no longer editing", () => { + it("preserves an occupied thread draft when discarding a changed edit", async () => { + setComposerDraftText(threadKey, "unsent draft"); beginQueuedRunEdit(threadKey, edit([])); + setComposerDraftText(editKey, "revised"); - expect(recoverQueuedRunEdit(threadKey, RunId.make("run-2"))).toBeNull(); - expect(getQueuedRunEdit(threadKey)?.runId).toBe(runId); - expect(recoverQueuedRunEdit(threadKey, runId)).toBe("clean"); + const recovery = recoverQueuedRunEdit(threadKey, runId); + await flushComposerDrafts(); + + expect(getComposerDraftSnapshot(threadKey).text).toBe("unsent draft"); + expect(getComposerDraftSnapshot(editKey).text).toBe(""); + expect(getQueuedRunEdit(threadKey)).toBeNull(); + expect(recovery).toBe("discarded"); }); }); diff --git a/apps/mobile/src/state/queued-run-edit.ts b/apps/mobile/src/state/queued-run-edit.ts index 107e2066e998a..b0c325d401aaf 100644 --- a/apps/mobile/src/state/queued-run-edit.ts +++ b/apps/mobile/src/state/queued-run-edit.ts @@ -95,13 +95,6 @@ export function endQueuedRunEdit( setQueuedRunEdit(threadKey, null); } -/** - * Leaves edit mode once the edited run is no longer queued (it started, was - * steered, or was removed elsewhere). A changed edit moves into the thread's - * own draft when that draft is empty and is otherwise discarded; an untouched - * edit is just the message that was sent, so it is dropped as "clean". - * Returns null when the composer is not editing `runId`. - */ export function recoverQueuedRunEdit( threadKey: string, runId: RunId, @@ -111,6 +104,7 @@ export function recoverQueuedRunEdit( const editDraft = getComposerDraftSnapshot(queuedEditDraftKey(threadKey, runId)); const dirty = editDraft.text !== edit.originalText || editDraft.attachments.length > 0; if (!dirty) { + // Untouched text was already sent; restoring it would duplicate the message. endQueuedRunEdit(threadKey); return "clean"; }