From cb427d08d3548f648c46d2b59cf8720d2318ef1f Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 22 Oct 2026 00:07:16 -0500 Subject: [PATCH 1/5] refactor(mobile): isolate acknowledged message reconciliation Extract the existing echo-only filter without changing its behavior. Co-Authored-By: GPT-6.1 Sol --- .../mobile/src/state/acknowledged-thread-messages.ts | 8 ++++++++ apps/mobile/src/state/use-thread-composer-state.ts | 12 ++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/state/acknowledged-thread-messages.ts b/apps/mobile/src/state/acknowledged-thread-messages.ts index 6dd9ca8c36fd4..7337ce0e25c0e 100644 --- a/apps/mobile/src/state/acknowledged-thread-messages.ts +++ b/apps/mobile/src/state/acknowledged-thread-messages.ts @@ -1,6 +1,14 @@ import { Atom } from "effect/reactivity"; import { appAtomRegistry } from "./atom-registry"; import type { QueuedThreadMessage } from "./thread-outbox-model"; +import type { MessageId } from "@t3tools/contracts"; + +export function unsettledAcknowledgedThreadMessages( + messages: ReadonlyArray, + echoedIds: ReadonlySet, +): ReadonlyArray { + return messages.filter((message) => !echoedIds.has(message.messageId)); +} // A command acknowledgment can precede its message in the subscribed timeline. // Keep the visible row until that projection arrives, independently of outbox cleanup. diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index ebdf9769d46fe..d9b69c45d7745 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -57,7 +57,10 @@ import { import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; import { buildThreadFeed, createThreadFeedCache } from "../lib/threadActivity"; -import { acknowledgedThreadMessagesAtom } from "./acknowledged-thread-messages"; +import { + acknowledgedThreadMessagesAtom, + unsettledAcknowledgedThreadMessages, +} from "./acknowledged-thread-messages"; import { appendPendingThreadMessages } from "../features/threads/pending-thread-feed"; import { threadAllowsProviderSwitch } from "./thread-provider-switching"; import { appAtomRegistry } from "../state/atom-registry"; @@ -281,9 +284,10 @@ export function useThreadComposerState() { if (acknowledgedMessages.some((message) => echoedIds.has(message.messageId))) { appAtomRegistry.set( acknowledgedThreadMessagesAtom, - appAtomRegistry - .get(acknowledgedThreadMessagesAtom) - .filter((message) => !echoedIds.has(message.messageId)), + unsettledAcknowledgedThreadMessages( + appAtomRegistry.get(acknowledgedThreadMessagesAtom), + echoedIds, + ), ); } }, [acknowledgedMessages, selectedThreadMessages]); From f8b4ea386e9313f98627cffb9c045b6dd0e2dace Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 22 Oct 2026 00:49:33 -0500 Subject: [PATCH 2/5] fix(mobile): stop pinning sent messages below newer history Use the send receipt and bounded snapshot sequences to retire temporary acknowledged rows whose echoes belong to unloaded history. Keep pending sends and acknowledgments without ordering evidence visible. Co-Authored-By: GPT-6.1 Sol --- .../acknowledged-thread-messages.test.ts | 58 +++++++++++++++++++ .../src/state/acknowledged-thread-messages.ts | 43 +++++++++++--- .../src/state/use-thread-composer-state.ts | 44 ++++++++++---- .../src/state/use-thread-outbox-drain.test.ts | 8 ++- .../src/state/use-thread-outbox-drain.ts | 11 +++- .../src/state/threadHistoryMerge.ts | 4 +- .../src/state/threads-sync.test.ts | 4 ++ packages/client-runtime/src/state/threads.ts | 3 + 8 files changed, 152 insertions(+), 23 deletions(-) create mode 100644 apps/mobile/src/state/acknowledged-thread-messages.test.ts diff --git a/apps/mobile/src/state/acknowledged-thread-messages.test.ts b/apps/mobile/src/state/acknowledged-thread-messages.test.ts new file mode 100644 index 0000000000000..129c65b0ab7f5 --- /dev/null +++ b/apps/mobile/src/state/acknowledged-thread-messages.test.ts @@ -0,0 +1,58 @@ +import { CommandId, EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { unsettledAcknowledgedThreadMessages } from "./acknowledged-thread-messages"; + +const environmentId = EnvironmentId.make("environment-1"); +const threadId = ThreadId.make("thread-1"); + +function acknowledged(messageId: string, acknowledgedSequence?: number) { + return { + environmentId, + threadId, + messageId: MessageId.make(messageId), + commandId: CommandId.make(`command-${messageId}`), + text: messageId, + attachments: [], + // Client time can be ahead of the server, or equal for different sends. + createdAt: "2099-01-01T00:00:00.000Z", + ...(acknowledgedSequence === undefined ? {} : { acknowledgedSequence }), + }; +} + +describe("acknowledged message reconciliation", () => { + it("settles an unloaded echo only after a bounded snapshot includes its receipt", () => { + const old = acknowledged("accepted-outside-window", 40); + const inFlight = acknowledged("accepted-after-snapshot", 51); + const unknown = acknowledged("no-ordering-evidence"); + const otherThread = { ...old, threadId: ThreadId.make("thread-2") }; + const otherEnvironment = { ...old, environmentId: EnvironmentId.make("environment-2") }; + const echoed = acknowledged("loaded-echo", 52); + const messages = [old, inFlight, unknown, otherThread, otherEnvironment, echoed]; + const echoes = new Set([echoed.messageId]); + const history = { environmentId, threadId, hasMoreHistory: true, snapshotSequence: 40 }; + + expect(unsettledAcknowledgedThreadMessages(messages, echoes, history)).toEqual([ + inFlight, + unknown, + otherThread, + otherEnvironment, + ]); + expect( + unsettledAcknowledgedThreadMessages(messages, echoes, { ...history, snapshotSequence: 39 }), + ).toEqual([old, inFlight, unknown, otherThread, otherEnvironment]); + }); + + it("keeps missing echoes without evidence of a bounded, committed history window", () => { + const message = acknowledged("still-waiting", 40); + const history = { environmentId, threadId, hasMoreHistory: false, snapshotSequence: 50 }; + expect(unsettledAcknowledgedThreadMessages([message], new Set(), history)).toEqual([message]); + expect( + unsettledAcknowledgedThreadMessages([message], new Set(), { + environmentId, + threadId, + hasMoreHistory: true, + }), + ).toEqual([message]); + }); +}); diff --git a/apps/mobile/src/state/acknowledged-thread-messages.ts b/apps/mobile/src/state/acknowledged-thread-messages.ts index 7337ce0e25c0e..7d967e6079e36 100644 --- a/apps/mobile/src/state/acknowledged-thread-messages.ts +++ b/apps/mobile/src/state/acknowledged-thread-messages.ts @@ -1,28 +1,57 @@ import { Atom } from "effect/reactivity"; import { appAtomRegistry } from "./atom-registry"; import type { QueuedThreadMessage } from "./thread-outbox-model"; -import type { MessageId } from "@t3tools/contracts"; +import type { MessageId, ScopedThreadRef } from "@t3tools/contracts"; + +interface AcknowledgedThreadMessage extends QueuedThreadMessage { + readonly acknowledgedSequence?: number; +} export function unsettledAcknowledgedThreadMessages( - messages: ReadonlyArray, + messages: ReadonlyArray, echoedIds: ReadonlySet, -): ReadonlyArray { - return messages.filter((message) => !echoedIds.has(message.messageId)); + history?: ScopedThreadRef & { + readonly hasMoreHistory: boolean; + readonly snapshotSequence?: number; + }, +): ReadonlyArray { + return messages.filter((message) => { + if ( + history !== undefined && + (message.environmentId !== history.environmentId || message.threadId !== history.threadId) + ) { + return true; + } + if (echoedIds.has(message.messageId)) return false; + // A bounded snapshot taken after the send committed can omit its echo. + // Client timestamps cannot establish this ordering, especially after offline sends. + return !( + history?.hasMoreHistory && + history.snapshotSequence !== undefined && + message.acknowledgedSequence !== undefined && + message.acknowledgedSequence <= history.snapshotSequence + ); + }); } // A command acknowledgment can precede its message in the subscribed timeline. // Keep the visible row until that projection arrives, independently of outbox cleanup. -export const acknowledgedThreadMessagesAtom = Atom.make>( +export const acknowledgedThreadMessagesAtom = Atom.make>( [], ).pipe(Atom.keepAlive); -export function retainAcknowledgedThreadMessage(message: QueuedThreadMessage) { +export function retainAcknowledgedThreadMessage( + message: QueuedThreadMessage, + acknowledgedSequence?: number, +) { const current = appAtomRegistry.get(acknowledgedThreadMessagesAtom); + const acknowledgedMessage = + acknowledgedSequence === undefined ? message : { ...message, acknowledgedSequence }; appAtomRegistry.set( acknowledgedThreadMessagesAtom, current.some((entry) => entry.messageId === message.messageId) ? current - : [...current, message], + : [...current, acknowledgedMessage], ); } diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index d9b69c45d7745..79ac2f61018b4 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -31,6 +31,7 @@ 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_THREAD_HISTORY_META, parseCodexFeedbackCommand, submitCodexFeedback, type CodexFeedbackSubmission, @@ -122,6 +123,7 @@ const EMPTY_QUEUE_WORKFLOW_ATOM = Atom.make(null).pipe( Atom.withLabel("mobile-thread-queue-workflow:empty"), ); const EMPTY_CHECKPOINT_SUMMARIES_ATOM = Atom.make>([]); +const EMPTY_THREAD_HISTORY_ATOM = Atom.make(EMPTY_THREAD_HISTORY_META); export function appendReviewCommentToDraft(input: { readonly environmentId: EnvironmentId; @@ -245,6 +247,16 @@ export function useThreadComposerState() { const selectedThreadMessages = selectedThreadProjection?.projection.messages; const selectedThreadAttempts = selectedThreadProjection?.projection.attempts; const selectedThreadNodes = selectedThreadProjection?.projection.nodes; + const selectedThreadHistory = useAtomValue( + selectedThreadDetailRef === null + ? EMPTY_THREAD_HISTORY_ATOM + : environmentThreadDetails.historyAtom(selectedThreadDetailRef), + ); + const echoedIds = new Set(selectedThreadMessages?.map((message) => message.id)); + const acknowledgmentHistory = + selectedThreadDetailRef === null + ? undefined + : { ...selectedThreadDetailRef, ...selectedThreadHistory }; const checkpointSummaries = useAtomValue( selectedThreadDetailRef === null ? EMPTY_CHECKPOINT_SUMMARIES_ATOM @@ -269,7 +281,11 @@ export function useThreadComposerState() { checkpointSummaries, cache: feedCache, }); - const pendingAcknowledgments = acknowledgedMessages.filter( + const pendingAcknowledgments = unsettledAcknowledgedThreadMessages( + acknowledgedMessages, + echoedIds, + acknowledgmentHistory, + ).filter( (message) => scopedThreadKey(message.environmentId, message.threadId) === selectedThreadKey && !selectedThreadQueuedMessages.some((queued) => queued.messageId === message.messageId), @@ -280,17 +296,23 @@ export function useThreadComposerState() { ); })(); useEffect(() => { - const echoedIds = new Set(selectedThreadMessages?.map((message) => message.id)); - if (acknowledgedMessages.some((message) => echoedIds.has(message.messageId))) { - appAtomRegistry.set( - acknowledgedThreadMessagesAtom, - unsettledAcknowledgedThreadMessages( - appAtomRegistry.get(acknowledgedThreadMessagesAtom), - echoedIds, - ), - ); + const current = appAtomRegistry.get(acknowledgedThreadMessagesAtom); + const unsettled = unsettledAcknowledgedThreadMessages( + current, + new Set(selectedThreadMessages?.map((message) => message.id)), + selectedThreadDetailRef === null + ? undefined + : { ...selectedThreadDetailRef, ...selectedThreadHistory }, + ); + if (unsettled.length !== current.length) { + appAtomRegistry.set(acknowledgedThreadMessagesAtom, unsettled); } - }, [acknowledgedMessages, selectedThreadMessages]); + }, [ + acknowledgedMessages, + selectedThreadMessages, + selectedThreadDetailRef, + selectedThreadHistory, + ]); const preferencesResult = useAtomValue(mobilePreferencesAtom); const followUpBehavior = AsyncResult.isSuccess(preferencesResult) diff --git a/apps/mobile/src/state/use-thread-outbox-drain.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.test.ts index f62686baf2363..083b45ea23b8c 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.test.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.test.ts @@ -434,10 +434,14 @@ describe("thread outbox drain delivery cleanup", () => { await harness.manager.enqueue(message); const deliveryRevision = harness.manager.revisionOf(message.messageId); - await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe("removed"); + await expect(completeQueuedMessageDelivery(message, deliveryRevision, 40)).resolves.toBe( + "removed", + ); expect(remainingMessages()).toEqual([]); - expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([message]); + expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([ + { ...message, acknowledgedSequence: 40 }, + ]); }); it("keeps a delivered message when its editor opens during storage removal", async () => { diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index a979632029461..61d5ccd0c9376 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -251,6 +251,7 @@ function isQueuedMessagePayloadCurrent( export async function completeQueuedMessageDelivery( queuedMessage: QueuedThreadMessage, deliveryRevision: number, + acknowledgedSequence?: number, ): Promise<"removed" | "edited" | "failed"> { clearThreadComposerError( scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId), @@ -269,7 +270,7 @@ export async function completeQueuedMessageDelivery( if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { return "edited"; } - retainAcknowledgedThreadMessage(queuedMessage); + retainAcknowledgedThreadMessage(queuedMessage, acknowledgedSequence); const removed = await removeThreadOutboxMessage( queuedMessage, deliveryRevision, @@ -881,7 +882,13 @@ export function useThreadOutboxDrain(): void { } acknowledgedExistingThreadMessageIdsRef.current.add(persistedMessage.messageId); const delivered = - (await completeQueuedMessageDelivery(persistedMessage, deliveryRevision)) === "removed"; + (await completeQueuedMessageDelivery( + persistedMessage, + deliveryRevision, + AsyncResult.isSuccess(deliveryResult) && "sequence" in deliveryResult.value + ? deliveryResult.value.sequence + : undefined, + )) === "removed"; if (delivered) { acknowledgedExistingThreadMessageIdsRef.current.delete(persistedMessage.messageId); } diff --git a/packages/client-runtime/src/state/threadHistoryMerge.ts b/packages/client-runtime/src/state/threadHistoryMerge.ts index 24840ee28420a..b653ecf50a591 100644 --- a/packages/client-runtime/src/state/threadHistoryMerge.ts +++ b/packages/client-runtime/src/state/threadHistoryMerge.ts @@ -5,6 +5,8 @@ import type { import { isOrchestrationV2TurnItemVisible } from "@t3tools/shared/orchestrationV2Timeline"; export type ThreadHistoryMeta = { + /** Server sequence of the snapshot that established this history window. */ + readonly snapshotSequence?: number; readonly historyCursor: string | null; readonly hasMoreHistory: boolean; readonly loading: boolean; @@ -119,12 +121,12 @@ export function applyHistoryPageMeta( }, ): ThreadHistoryMeta { return { + ...current, historyCursor: page.nextCursor, hasMoreHistory: page.hasMoreHistory, loading: false, error: null, expanded: true, - latestLocalTurnOrdinal: current.latestLocalTurnOrdinal, }; } diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index b87ab18a50ae7..404325037a51f 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -641,6 +641,7 @@ describe("EnvironmentThreads", () => { value.status === "live" && value.history.historyCursor === "socket-history-cursor", ); expect(state.history).toMatchObject({ + snapshotSequence: 14, historyCursor: "socket-history-cursor", hasMoreHistory: true, loading: false, @@ -665,6 +666,7 @@ describe("EnvironmentThreads", () => { Option.getOrThrow(expanded.data).turnItems.some((item) => item.id === olderItem.id), ).toBe(true); expect(expanded.history.hasMoreHistory).toBe(false); + expect(expanded.history.snapshotSequence).toBe(14); }), ); @@ -933,6 +935,7 @@ describe("EnvironmentThreads", () => { ); expect(state.history).toMatchObject({ + snapshotSequence: 11, historyCursor: "opaque-cursor", hasMoreHistory: true, loading: false, @@ -950,6 +953,7 @@ describe("EnvironmentThreads", () => { const harness = yield* makeHarness({ cached: BASE_PROJECTION }); const state = yield* awaitThreadState(harness.observed, (value) => Option.isSome(value.data)); expect(state.history).toMatchObject({ + snapshotSequence: CACHED_SNAPSHOT_SEQUENCE, historyCursor: null, hasMoreHistory: false, expanded: false, diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index fc8b8e5008133..b37e0fbc0ca7e 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -77,6 +77,7 @@ function historyMetaFromCachedSnapshot( const historyCursor = snapshot.historyCursor ?? null; const hasMoreHistory = snapshot.hasMoreHistory ?? false; return { + snapshotSequence: snapshot.snapshotSequence, historyCursor, hasMoreHistory, loading: false, @@ -571,6 +572,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make hasProgressiveHistory ? { history: { + snapshotSequence: item.snapshotSequence, historyCursor: item.historyCursor ?? null, hasMoreHistory: item.hasMoreHistory ?? false, loading: false, @@ -815,6 +817,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const history: ThreadHistoryMeta = httpResult.history !== undefined ? { + snapshotSequence: httpResult.snapshot.snapshotSequence, historyCursor: httpResult.history.historyCursor, hasMoreHistory: httpResult.history.hasMoreHistory, loading: false, From 9a96438cf4bb615a4f0a4adfc07dd28abca98fe7 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 22 Oct 2026 23:24:15 -0500 Subject: [PATCH 3/5] test(mobile): give thread mocks history and dispatch results The composer now reads the selected thread's history window, and queued delivery reads the dispatch sequence from startTurn. Two mobile test files mocked neither, so the composer threw and delivery read `sequence` from undefined. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/state/thread-task-permissions.test.ts | 1 + .../src/state/use-thread-outbox-drain.permissions.test.ts | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/state/thread-task-permissions.test.ts b/apps/mobile/src/state/thread-task-permissions.test.ts index eac2e40e9835e..0c819fb674c3c 100644 --- a/apps/mobile/src/state/thread-task-permissions.test.ts +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -112,6 +112,7 @@ vi.mock("./threads", async () => { environmentThreadDetails: { queueWorkflowAtom: () => Atom.make(null), checkpointSummariesAtom: () => Atom.make([]), + historyAtom: () => Atom.make({ hasMoreHistory: false }), }, threadEnvironment: { respondToApproval: state.approve, diff --git a/apps/mobile/src/state/use-thread-outbox-drain.permissions.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.permissions.test.ts index 3bdab5f7a6223..4a4269ecce389 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.permissions.test.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.permissions.test.ts @@ -184,6 +184,7 @@ beforeEach(() => { ]) { command.mockReset().mockResolvedValue(AsyncResult.success(undefined)); } + state.start.mockResolvedValue(AsyncResult.success({ sequence: 1 })); state.prepare.mockReset().mockImplementation(async ({ attachments }) => ({ status: "ready", attachments: [], @@ -342,7 +343,8 @@ describe("queued task operation access", () => { ); await state.manager.enqueue(queued); const started = Promise.withResolvers(); - const delivery = Promise.withResolvers>(); + const delivery = + Promise.withResolvers>(); state.start.mockImplementationOnce(() => { started.resolve(); return delivery.promise; @@ -352,7 +354,7 @@ describe("queued task operation access", () => { state.grantedEnvironments.delete("secondary"); delivery.resolve( accepted - ? AsyncResult.success(undefined) + ? AsyncResult.success({ sequence: 1 }) : AsyncResult.failure(Cause.fail(new Error("This connection cannot control this task."))), ); await drained; From 2d60f937e0a9d6255ed37d71f4bac63b39107566 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 10:53:04 +0000 Subject: [PATCH 4/5] fix(mobile): verify receipt reconciliation with the current composer --- .../src/state/thread-task-permissions.test.ts | 36 ++++++++++++++++++- .../src/state/use-thread-composer-state.ts | 1 + 2 files changed, 36 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 0165f58004d12..4783f4ec3effb 100644 --- a/apps/mobile/src/state/thread-task-permissions.test.ts +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -1,6 +1,9 @@ import { AuthOrchestrationOperateScope, DEFAULT_SERVER_SETTINGS, + CommandId, + EnvironmentId, + ThreadId, MessageId, ProjectId, ProviderDriverKind, @@ -32,6 +35,7 @@ const state = vi.hoisted(() => ({ }, serverConfig: null as ServerConfig | null, projection: null as OrchestrationV2ThreadProjection | null, + history: { hasMoreHistory: true, snapshotSequence: 39 }, pendingRequests: { approvals: [], userInputs: [] } as PendingThreadRequests, enqueue: vi.fn(async () => undefined), clearDraft: vi.fn(), @@ -94,6 +98,7 @@ vi.mock("./use-thread-selection", () => ({ useThreadSelection: () => ({ selectedThread: state.thread, selectedThreadCreation: null, + selectedThreadDetailRef: { environmentId: "secondary", threadId: "thread" }, selectedEnvironmentRuntime: { connectionState: state.connectionState, serverConfig: state.serverConfig, @@ -113,7 +118,7 @@ vi.mock("./threads", async () => { environmentThreadDetails: { queueWorkflowAtom: () => Atom.make(null), checkpointSummariesAtom: () => Atom.make([]), - historyAtom: () => Atom.make({ hasMoreHistory: false }), + historyAtom: () => Atom.make(state.history), }, threadEnvironment: { respondToApproval: state.approve, @@ -174,6 +179,9 @@ vi.mock("../lib/commandMetadata", () => ({ vi.mock("../lib/copyTextWithHaptic", () => ({ copyTextWithHaptic: vi.fn() })); vi.mock("./use-remote-environment-registry", () => ({ setPendingConnectionError: vi.fn() })); +import { appAtomRegistry } from "./atom-registry"; +import { acknowledgedThreadMessagesAtom } from "./acknowledged-thread-messages"; + import { useThreadComposerState } from "./use-thread-composer-state"; import { useSelectedThreadRequests } from "./use-selected-thread-requests"; @@ -182,6 +190,8 @@ beforeEach(() => { state.connectionState = "connected"; state.draft = { text: "Keep my draft", attachments: [], modelSelection: undefined }; state.projection = null; + state.history = { hasMoreHistory: true, snapshotSequence: 39 }; + appAtomRegistry.set(acknowledgedThreadMessagesAtom, []); state.serverConfig = { providers: [{ instanceId: "codex", driver: "codex", enabled: true }], environment: { capabilities: {} }, @@ -428,3 +438,27 @@ describe("mobile handoff preview and submission", () => { expect(state.clearDraft).toHaveBeenCalledTimes(1); }); }); + +it("reopening bounded history removes an accepted row only after its receipt", () => { + const message = { + environmentId: EnvironmentId.make("secondary"), + threadId: ThreadId.make("thread"), + messageId: MessageId.make("accepted-outside-window"), + commandId: CommandId.make("accepted-command"), + text: "old accepted message", + attachments: [], + createdAt: "2099-01-01T00:00:00.000Z", + acknowledgedSequence: 40, + }; + appAtomRegistry.set(acknowledgedThreadMessagesAtom, [message]); + state.projection = { ...v2Projection, messages: [] }; + expect(useThreadComposerState().selectedThreadFeed.map((entry) => entry.id)).toEqual([ + message.messageId, + ]); + state.history = { hasMoreHistory: true, snapshotSequence: 40 }; + expect(useThreadComposerState().selectedThreadFeed).toEqual([]); + state.history = { hasMoreHistory: false, snapshotSequence: 40 }; + expect(useThreadComposerState().selectedThreadFeed.map((entry) => entry.id)).toEqual([ + message.messageId, + ]); +}); diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 1363a0a096180..27131557540e4 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -289,6 +289,7 @@ export function useThreadComposerState() { ); })(); useEffect(() => { + if (acknowledgedMessages.length === 0) return; const current = appAtomRegistry.get(acknowledgedThreadMessagesAtom); const unsettled = unsettledAcknowledgedThreadMessages( current, From aa84d791f2991826016408c913acf848c7f55d6d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 27 Oct 2026 01:42:08 +0000 Subject: [PATCH 5/5] fix(mobile): preserve reconciliation imports after main sync --- apps/mobile/src/state/acknowledged-thread-messages.test.ts | 4 +++- apps/mobile/src/state/use-thread-composer-state.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/state/acknowledged-thread-messages.test.ts b/apps/mobile/src/state/acknowledged-thread-messages.test.ts index 129c65b0ab7f5..7dadf26312410 100644 --- a/apps/mobile/src/state/acknowledged-thread-messages.test.ts +++ b/apps/mobile/src/state/acknowledged-thread-messages.test.ts @@ -1,8 +1,10 @@ import { CommandId, EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { unsettledAcknowledgedThreadMessages } from "./acknowledged-thread-messages"; +vi.mock("./threads", () => ({ environmentThreadDetails: {} })); + const environmentId = EnvironmentId.make("environment-1"); const threadId = ThreadId.make("thread-1"); diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 1ab03e58e4b56..ce236d2746af6 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -13,7 +13,7 @@ import { deriveThreadRuntime, threadRuntimeHasInterruptibleRun, } from "@t3tools/client-runtime/state/thread-execution"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { CommandId,