From 90a23bed1701d8e474107213e06a3a28d13e5c01 Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Fri, 6 Nov 2026 23:46:11 -0800 Subject: [PATCH 1/4] fix(mobile): count reconnect-queued sends as Working Outbox messages waiting on reconnect are committed work, so those threads belong in the Working shelf with a Working status, not Active/Ready. --- .../features/threads/thread-list-v2-items.tsx | 4 +- .../src/features/threads/threadListV2.test.ts | 45 +++++++++++++++++++ .../src/features/threads/threadListV2.ts | 21 ++++++--- .../features/threads/useThreadListV2.test.tsx | 7 ++- 4 files changed, 70 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index c9043a8d6c181..83ed1cc2addc0 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -736,7 +736,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const selected = props.selected === true; const rowAppearance = getThreadListV2RowAppearance(theme, sidebarPane, selected); - const status = resolveThreadListV2Status(thread); + const status = resolveThreadListV2Status(thread, { + hasQueuedMessages: props.hasQueuedMessages === true, + }); const isUnread = status === "ready" && threadHasUnseenCompletion(thread); const workingLabel = STATUS_LABEL_BY_STATUS[status]; const showsWoke = status === "ready" && props.wokeAt !== null; diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 786f1ce229113..46ca9a48ad30a 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -187,6 +187,26 @@ describe("resolveThreadListV2Status", () => { expect(resolveThreadListV2Status({ ...guest, hasPendingApprovals: true })).toBe("approval"); }); + it("treats an outbox pending message as Working, after attention requests", () => { + const thread = presentThreadShell( + environmentId, + decodeThreadShell(makeRawThreadShell({ status: "completed" })), + ); + expect(resolveThreadListV2Status(thread, { hasQueuedMessages: true })).toBe("working"); + expect( + resolveThreadListV2Status( + { ...thread, hasPendingUserInput: true }, + { hasQueuedMessages: true }, + ), + ).toBe("input"); + expect( + resolveThreadListV2Status( + { ...thread, pendingGuestMessageCount: 1 }, + { hasQueuedMessages: true }, + ), + ).toBe("request"); + }); + it.each([ { roster: "dev server", task: { kind: "command" }, status: "ready" }, { roster: "dev server watchdog", task: { kind: "monitor" }, status: "ready" }, @@ -1601,6 +1621,7 @@ describe("buildThreadListV2ListItems row-state stamps", () => { const settledQueued = byKey(queued).get(`v2-thread:${environmentId}:stamp-settled`)!; const settledPlain = byKey(plain).get(`v2-thread:${environmentId}:stamp-settled`)!; expect(readyQueued.type === "v2-thread" && readyQueued.hasQueuedMessages).toBe(true); + expect(readyQueued.type === "v2-thread" && readyQueued.timeLabel).toBe(""); expect(readyPlain.type === "v2-thread" && readyPlain.hasQueuedMessages).toBe(false); expect(threadListV2ListItemsAreEqual(readyQueued, readyPlain)).toBe(false); expect(threadListV2ListItemsAreEqual(settledQueued, settledPlain)).toBe(true); @@ -1825,4 +1846,28 @@ describe("queued messages keep a settled thread active", () => { ]); expect(layout.settledCount).toBe(1); }); + + it("counts a reconnect send as working when the Working shelf is on", () => { + const layout = buildThreadListV2Items({ + threads: [ + ...threads, + makeThread({ id: ThreadId.make("ready-queued"), title: "Ready with outbox" }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + queuedThreadKeys: new Set([...queuedThreadKeys, `${environmentId}:ready-queued`]), + workingShelfEnabled: true, + workingShelfExpanded: true, + }); + expect(layout.items.map((item) => [item.thread.id, item.variant] as const)).toEqual([ + ["active", "card"], + ["ready-queued", "card"], + ["settled-queued", "card"], + ["settled", "slim"], + ]); + expect(layout.workingCount).toBe(2); + expect(layout.workingShelfHeaderIndex).toBe(1); + expect(layout.settledCount).toBe(1); + }); }); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 3245c1c281e00..9fd12b94c1d9a 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -267,6 +267,7 @@ export function resolveThreadListV2Status( | "runtime" | "blockedOnThreadWait" >, + options?: { readonly hasQueuedMessages?: boolean }, ): ThreadListV2Status { if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") { return "approval"; @@ -287,6 +288,9 @@ export function resolveThreadListV2Status( ) { return "working"; } + if (options?.hasQueuedMessages === true) { + return "working"; + } if (thread.runtime?.status === "idle") { return "waiting"; } @@ -501,12 +505,13 @@ export function threadListV2ListItemsAreEqual( function resolveThreadListV2ItemTimeLabel( item: ThreadListV2Item, showSnoozeWakeLabel: boolean, + options?: { readonly hasQueuedMessages?: boolean }, ): string { const { thread, variant, snoozed } = item; if (showSnoozeWakeLabel) return ""; if ( variant === "card" && - (resolveThreadListV2Status(thread) !== "ready" || + (resolveThreadListV2Status(thread, options) !== "ready" || item.wokeAt !== null || threadHasUnseenCompletion(thread)) ) @@ -554,16 +559,19 @@ export function buildThreadListV2ListItems(input: { item.variant === "card" ? input.moveAvailability?.get(`${item.thread.environmentId}:${item.thread.id}`) : undefined; + const hasQueuedMessages = + queuedThreadKeys?.has(`${item.thread.environmentId}:${item.thread.id}`) === true; return { type: "v2-thread", key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`, item, snoozeWakeLabelText, - timeLabel: resolveThreadListV2ItemTimeLabel(item, snoozeWakeLabelText !== undefined), + timeLabel: resolveThreadListV2ItemTimeLabel(item, snoozeWakeLabelText !== undefined, { + hasQueuedMessages, + }), snoozePresetMinute, showTrailingDivider: false, - hasQueuedMessages: - queuedThreadKeys?.has(`${item.thread.environmentId}:${item.thread.id}`) === true, + hasQueuedMessages, canMoveUp: move?.canMoveUp === true, canMoveDown: move?.canMoveDown === true, }; @@ -752,7 +760,10 @@ export function buildThreadListV2Items(input: { settled.push(thread); } else if (thread.pinnedAt != null) { pinned.push(thread); - } else if (workingShelfEnabled && (input.isWorking ?? isThreadWorking)(thread)) { + } else if ( + workingShelfEnabled && + (hasQueuedMessages || (input.isWorking ?? isThreadWorking)(thread)) + ) { working.push(thread); } else { active.push(thread); diff --git a/apps/mobile/src/features/threads/useThreadListV2.test.tsx b/apps/mobile/src/features/threads/useThreadListV2.test.tsx index 7eac8955efa14..00e00623ed647 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.test.tsx +++ b/apps/mobile/src/features/threads/useThreadListV2.test.tsx @@ -110,10 +110,15 @@ it("reuses a large thread list across tab renders and updates when its inputs ch ).toBe(true); const queuedThreadKeys = new Set([`${environmentId}:thread-150`]); - await act(async () => root.render()); + await act(async () => + root.render( + , + ), + ); const queued = seen.at(-1)!; expect(queued.layout.items).toHaveLength(161); expect(queued.layout.settledCount).toBe(1049); + expect(queued.layout.workingCount).toBe(1); expect( queued.items.find((item) => item.type === "v2-thread" && item.item.thread.id === "thread-150"), ).toMatchObject({ hasQueuedMessages: true, item: { variant: "card" } }); From 7186bcf0622a3ec83e1377fb2f7abf2c2c4b6c27 Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Fri, 6 Nov 2026 23:58:54 -0800 Subject: [PATCH 2/4] fix(mobile): label reconnect-queued threads Pending They still count toward Working, but the row status should say Pending rather than Working while the outbox is waiting to send. --- .../src/features/threads/thread-list-v2-items.tsx | 10 +++++++++- apps/mobile/src/features/threads/threadListV2.test.ts | 4 ++-- apps/mobile/src/features/threads/threadListV2.ts | 3 ++- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 83ed1cc2addc0..54c84bf64482c 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -114,6 +114,12 @@ const STATUS_LABEL_BY_STATUS: Partial> = className: "text-adaptive-sky-600-400", iconTintClassName: "accent-adaptive-sky-600-400", }, + pending: { + label: "Pending", + icon: "tray.and.arrow.up", + className: "text-foreground-muted", + iconTintClassName: "accent-foreground-muted", + }, waiting: { label: "Waiting", icon: "circle.dashed.inset.filled", @@ -1166,7 +1172,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { {thread.title} - {props.hasQueuedMessages ? : null} + {props.hasQueuedMessages && status !== "pending" ? ( + + ) : null} {pinnedRow ? ( { expect(resolveThreadListV2Status({ ...guest, hasPendingApprovals: true })).toBe("approval"); }); - it("treats an outbox pending message as Working, after attention requests", () => { + it("treats an outbox pending message as Pending, after attention requests", () => { const thread = presentThreadShell( environmentId, decodeThreadShell(makeRawThreadShell({ status: "completed" })), ); - expect(resolveThreadListV2Status(thread, { hasQueuedMessages: true })).toBe("working"); + expect(resolveThreadListV2Status(thread, { hasQueuedMessages: true })).toBe("pending"); expect( resolveThreadListV2Status( { ...thread, hasPendingUserInput: true }, diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 9fd12b94c1d9a..74a4f9601d012 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -68,6 +68,7 @@ export type ThreadListV2Status = | "input" | "request" | "working" + | "pending" | "waiting" | "failed" | "limited" @@ -289,7 +290,7 @@ export function resolveThreadListV2Status( return "working"; } if (options?.hasQueuedMessages === true) { - return "working"; + return "pending"; } if (thread.runtime?.status === "idle") { return "waiting"; From 3e293d9e9c2b6bc0ccd9415c26042bf80ae50257 Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Sat, 7 Nov 2026 09:05:49 -0800 Subject: [PATCH 3/4] fix(mobile): keep queued attention requests out of Working A reconnect-queued send no longer overrides approval, input, or subagent attention, so those rows stay in Active when Working is collapsed. --- .../src/features/threads/threadListV2.test.ts | 28 +++++++++++++++++++ .../src/features/threads/threadListV2.ts | 8 +++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 310eb8c17d50e..d36b6e9a54530 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -1870,4 +1870,32 @@ describe("queued messages keep a settled thread active", () => { expect(layout.workingShelfHeaderIndex).toBe(1); expect(layout.settledCount).toBe(1); }); + + it("keeps a queued thread with an attention request in Active", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ + id: ThreadId.make("queued-approval"), + title: "Queued with approval", + hasPendingApprovals: true, + }), + makeThread({ id: ThreadId.make("ready-queued"), title: "Ready with outbox" }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + queuedThreadKeys: new Set([ + `${environmentId}:queued-approval`, + `${environmentId}:ready-queued`, + ]), + workingShelfEnabled: true, + workingShelfExpanded: true, + }); + expect(layout.items.map((item) => [item.thread.id, item.variant] as const)).toEqual([ + ["queued-approval", "card"], + ["ready-queued", "card"], + ]); + expect(layout.workingCount).toBe(1); + expect(layout.workingShelfHeaderIndex).toBe(1); + }); }); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 74a4f9601d012..ce8b278e82cc4 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -757,13 +757,19 @@ export function buildThreadListV2Items(input: { } const hasQueuedMessages = queuedThreadKeys?.has(`${thread.environmentId}:${thread.id}`) === true; + // Match isThreadWorking: attention stays in Active so a collapsed Working shelf + // cannot hide an approval, question, or subagent request behind a queued send. + const hasAttentionRequest = + thread.hasPendingApprovals || + thread.hasPendingUserInput || + thread.pendingSubagentRequest != null; if (supportsSettlement && thread.settledOverride === "settled" && !hasQueuedMessages) { settled.push(thread); } else if (thread.pinnedAt != null) { pinned.push(thread); } else if ( workingShelfEnabled && - (hasQueuedMessages || (input.isWorking ?? isThreadWorking)(thread)) + ((hasQueuedMessages && !hasAttentionRequest) || (input.isWorking ?? isThreadWorking)(thread)) ) { working.push(thread); } else { From e8cffe99151ead1a71522f2f8d3b174ae1a7360d Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Sun, 8 Nov 2026 09:33:11 -0800 Subject: [PATCH 4/4] fix(mobile): keep queued guest and plan reviews in Active A reconnect-queued send no longer shelves a guest request or an actionable proposed plan into Working, including when the runtime would otherwise count as working. --- .../src/features/threads/threadListV2.test.ts | 39 ++++++++++++++++--- .../src/features/threads/threadListV2.ts | 25 ++++++++++-- 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index d36b6e9a54530..34a67e09bbfc2 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -1872,6 +1872,14 @@ describe("queued messages keep a settled thread active", () => { }); it("keeps a queued thread with an attention request in Active", () => { + const running = { + status: "running" as const, + activeRunId: null, + providerInstanceId: ProviderInstanceId.make("codex"), + providerName: "Codex", + lastError: null, + updatedAt: NOW, + }; const layout = buildThreadListV2Items({ threads: [ makeThread({ @@ -1879,6 +1887,26 @@ describe("queued messages keep a settled thread active", () => { title: "Queued with approval", hasPendingApprovals: true, }), + makeThread({ + id: ThreadId.make("queued-guest"), + title: "Queued with guest request", + runtime: running, + pendingGuestMessageCount: 1, + }), + makeThread({ + id: ThreadId.make("queued-plan"), + title: "Queued with proposed plan", + interactionMode: "plan", + hasActionableProposedPlan: true, + latestRun: { + runId: RunId.make("queued-plan-run"), + status: "completed", + requestedAt: NOW, + startedAt: NOW, + completedAt: NOW, + assistantMessageId: null, + }, + }), makeThread({ id: ThreadId.make("ready-queued"), title: "Ready with outbox" }), ], environmentId: null, @@ -1886,16 +1914,17 @@ describe("queued messages keep a settled thread active", () => { now: NOW, queuedThreadKeys: new Set([ `${environmentId}:queued-approval`, + `${environmentId}:queued-guest`, + `${environmentId}:queued-plan`, `${environmentId}:ready-queued`, ]), workingShelfEnabled: true, workingShelfExpanded: true, }); - expect(layout.items.map((item) => [item.thread.id, item.variant] as const)).toEqual([ - ["queued-approval", "card"], - ["ready-queued", "card"], - ]); + const ids = layout.items.map((item) => item.thread.id); + expect(ids.slice(0, 3).sort()).toEqual(["queued-approval", "queued-guest", "queued-plan"]); + expect(ids.slice(3)).toEqual(["ready-queued"]); expect(layout.workingCount).toBe(1); - expect(layout.workingShelfHeaderIndex).toBe(1); + expect(layout.workingShelfHeaderIndex).toBe(3); }); }); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index ce8b278e82cc4..f07fdbe71dccb 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -757,19 +757,36 @@ export function buildThreadListV2Items(input: { } const hasQueuedMessages = queuedThreadKeys?.has(`${thread.environmentId}:${thread.id}`) === true; - // Match isThreadWorking: attention stays in Active so a collapsed Working shelf - // cannot hide an approval, question, or subagent request behind a queued send. + // Keep attention visible in Active when Working is collapsed. Guest requests are + // also blocked on both Working paths while a send is queued, since isThreadWorking + // can still be true for a running guest-request thread. + const hasGuestRequest = (thread.pendingGuestMessageCount ?? 0) > 0; const hasAttentionRequest = thread.hasPendingApprovals || thread.hasPendingUserInput || - thread.pendingSubagentRequest != null; + thread.pendingSubagentRequest != null || + hasGuestRequest; + const run = thread.latestRun; + const runSettled = + run !== null && + run.status !== "preparing" && + run.status !== "queued" && + run.status !== "starting" && + run.status !== "running" && + run.status !== "waiting" && + thread.runtime?.activeRunId !== run.runId; + const hasActionablePlanReview = + thread.interactionMode === "plan" && thread.hasActionableProposedPlan && runSettled; + const countsQueuedAsWorking = + hasQueuedMessages && !hasAttentionRequest && !hasActionablePlanReview; if (supportsSettlement && thread.settledOverride === "settled" && !hasQueuedMessages) { settled.push(thread); } else if (thread.pinnedAt != null) { pinned.push(thread); } else if ( workingShelfEnabled && - ((hasQueuedMessages && !hasAttentionRequest) || (input.isWorking ?? isThreadWorking)(thread)) + !(hasQueuedMessages && hasGuestRequest) && + (countsQueuedAsWorking || (input.isWorking ?? isThreadWorking)(thread)) ) { working.push(thread); } else {