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..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", @@ -736,7 +742,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; @@ -1164,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 Pending, after attention requests", () => { + const thread = presentThreadShell( + environmentId, + decodeThreadShell(makeRawThreadShell({ status: "completed" })), + ); + expect(resolveThreadListV2Status(thread, { hasQueuedMessages: true })).toBe("pending"); + 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,85 @@ 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); + }); + + 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({ + id: ThreadId.make("queued-approval"), + 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, + searchQuery: "", + now: NOW, + queuedThreadKeys: new Set([ + `${environmentId}:queued-approval`, + `${environmentId}:queued-guest`, + `${environmentId}:queued-plan`, + `${environmentId}:ready-queued`, + ]), + workingShelfEnabled: true, + workingShelfExpanded: true, + }); + 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(3); + }); }); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 3245c1c281e00..f07fdbe71dccb 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" @@ -267,6 +268,7 @@ export function resolveThreadListV2Status( | "runtime" | "blockedOnThreadWait" >, + options?: { readonly hasQueuedMessages?: boolean }, ): ThreadListV2Status { if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") { return "approval"; @@ -287,6 +289,9 @@ export function resolveThreadListV2Status( ) { return "working"; } + if (options?.hasQueuedMessages === true) { + return "pending"; + } if (thread.runtime?.status === "idle") { return "waiting"; } @@ -501,12 +506,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 +560,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, }; @@ -748,11 +757,37 @@ export function buildThreadListV2Items(input: { } const hasQueuedMessages = queuedThreadKeys?.has(`${thread.environmentId}:${thread.id}`) === true; + // 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 || + 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 && (input.isWorking ?? isThreadWorking)(thread)) { + } else if ( + workingShelfEnabled && + !(hasQueuedMessages && hasGuestRequest) && + (countsQueuedAsWorking || (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" } });