From 10055de665038710e01b125c4d4a5a80e489f36c Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Wed, 4 Nov 2026 17:49:24 -0800 Subject: [PATCH 01/15] feat(mobile): Mail-style thread list rows on iOS and Android Title-first cards with muted project context, shared shelf chrome for snoozed/settled, and a clear wake countdown so the inbox reads more like email. Co-authored-by: Cursor --- apps/mobile/src/features/home/HomeScreen.tsx | 2 +- .../threads/ThreadNavigationSidebar.tsx | 4 +- .../features/threads/thread-list-v2-items.tsx | 624 ++++++++---------- .../thread-list-v2-row-appearance.android.ts | 3 +- .../threads/thread-list-v2-row-appearance.ts | 4 +- .../src/features/threads/threadListV2.test.ts | 118 +++- .../src/features/threads/threadListV2.ts | 46 +- 7 files changed, 430 insertions(+), 371 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 4ed447eb69f23..38e8153bd6ee9 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -139,7 +139,7 @@ interface HomeScreenProps { readonly onNewThreadInProject: (project: EnvironmentProject) => void; } -const ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT = 72; +const ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT = 112; // Rows away from the viewport skip their swipe actions (see // swipe-row-activation), so render further ahead: a fast fling then reaches // rows that are already built instead of rows still being rebuilt. diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index dce389c836669..a68e3f6ca1bed 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -586,7 +586,7 @@ function ThreadNavigationSidebarPane(props: ThreadNavigationSidebarProps) { itemLayoutAnimation={THREAD_SIDEBAR_ROW_LAYOUT} data={listItems} drawDistance={500} - estimatedItemSize={64} + estimatedItemSize={104} extraData={listExtraData} getItemType={(item) => item.type} itemsAreEqual={sidebarItemsAreEqual} @@ -641,7 +641,7 @@ function ThreadNavigationSidebarPane(props: ThreadNavigationSidebarProps) { itemLayoutAnimation={THREAD_SIDEBAR_ROW_LAYOUT} data={listItems} drawDistance={500} - estimatedItemSize={64} + estimatedItemSize={104} extraData={listExtraData} getItemType={(item) => item.type} itemsAreEqual={sidebarItemsAreEqual} 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 77250f85a74ce..359768433b9f6 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -3,6 +3,7 @@ import type { ThreadRowProviderInstance } from "./thread-provider-instance"; import { THREAD_LIST_V2_MONO_FONT as MONO_FONT, THREAD_LIST_V2_ROW_CONTENT_CLASS_NAME, + THREAD_LIST_V2_ROW_DIVIDER_INSET_CLASS_NAME, THREAD_LIST_V2_ROW_DIVIDERS, selectedThreadRowColors, getThreadListV2NewBranchMenuTitle, @@ -40,7 +41,6 @@ import { AppText as Text } from "../../components/ui/AppText"; import { Menu } from "../../components/ui/Menu"; import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { ProjectFavicon } from "../../components/ProjectFavicon"; -import { ProviderIcon, ProviderInstanceIcon } from "../../components/ProviderIcon"; import { useNowAfter } from "../../lib/clock"; import { cn } from "../../lib/cn"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; @@ -66,12 +66,12 @@ import { canTurnThreadIntoProject, turnThreadIntoProject } from "./turnThreadInt import { threadCanArchive } from "../home/threadArchive"; import { THREAD_LIST_V2_SETTLED_PAGE_COUNT, + resolveThreadListMessagePreview, resolveThreadListV2SnoozeGateExpiryMs, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozePresets, threadHasUnseenCompletion, resolveThreadListV2Status, - resolveThreadListV2ProviderDrivers, resolveThreadListV2SwipeActions, layoutThreadListV2RowMenuActions, type ThreadListV2Status, @@ -84,8 +84,7 @@ import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs"; import { pullRequestStatus } from "../pull-requests/pullRequestStatus"; interface StatusLabel { - // Null when the icon and timer carry the status on their own. - readonly label: string | null; + readonly label: string; readonly icon: AppSymbolName; readonly className: string; readonly iconTintClassName: string; @@ -110,13 +109,13 @@ const STATUS_LABEL_BY_STATUS: Partial> = iconTintClassName: "accent-adaptive-fuchsia-600-300", }, working: { - label: null, + label: "Working", icon: "circle.dashed", className: "text-adaptive-sky-600-400", iconTintClassName: "accent-adaptive-sky-600-400", }, waiting: { - label: null, + label: "Waiting", icon: "circle.dashed.inset.filled", className: "text-foreground-muted", iconTintClassName: "accent-foreground-muted", @@ -697,9 +696,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { }) { const { width: windowWidth, fontScale } = useWindowDimensions(); const titleTypography = useScaledTextRole("body"); - const metadataTypography = useScaledTextRole("label"); - const projectIconSize = titleTypography.fontSize * 1.25 * fontScale; - const machineIconSize = metadataTypography.fontSize * fontScale; + const projectIconSize = Math.round(36 * fontScale); // Android's nonlinear system text scaling can differ from the fontScale estimate. const [androidTitleLineHeight, setTitleLineHeight] = useState( titleTypography.lineHeight * fontScale, @@ -708,7 +705,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { Platform.OS === "android" ? androidTitleLineHeight : titleTypography.lineHeight * fontScale; const { thread, - variant, onSelectThread, onOpenThreadPullRequests, onDeleteThread, @@ -730,19 +726,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const dormant = useSwipeRowDormant(props.activationKey); const selectThread = () => onSelectThread(thread); - const { providerDrivers, providerIconUrl } = useMemo(() => { - const provider = props.providers?.find( - (candidate) => - candidate.instanceId === - (thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId), - ); - return { - providerDrivers: resolveThreadListV2ProviderDrivers(thread, props.providers), - providerIconUrl: provider?.iconUrl, - }; - }, [thread, props.providers]); - - const providerInstance = props.providerInstance; const pr = useThreadPr(thread); const prBadgeLabel = pr ? resolveThreadPrBadgeAction(pr).accessibilityLabel : null; const handleOpenPullRequests = useCallback(() => { @@ -764,8 +747,22 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { : workingLabel) ?? (isUnread ? DONE_STATUS_LABEL : undefined); const recede = shouldRecedeThreadRow({ status, selected }); const isRegeneratingTitle = thread.titleRegeneration != null; - const rowAccessibilityLabel = `${thread.title}${props.hasQueuedMessages ? ", messages queued to send" : ""}${isRegeneratingTitle ? ", regenerating title" : ""}`; - const timeLabel = props.timeLabel; + const messagePreview = resolveThreadListMessagePreview(thread); + const contextLabel = props.project?.title ?? props.environmentLabel ?? null; + const wakeLabel = + snoozedRow && props.snoozeWakeLabelText !== undefined ? props.snoozeWakeLabelText : null; + const timeLabel = wakeLabel ?? props.timeLabel; + const rowAccessibilityLabel = [ + thread.title, + contextLabel, + isUnread ? "unread" : null, + snoozedRow ? "snoozed" : null, + wakeLabel != null ? `wakes in ${wakeLabel}` : null, + props.hasQueuedMessages ? "messages queued to send" : null, + isRegeneratingTitle ? "regenerating title" : null, + ] + .filter(Boolean) + .join(", "); const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); const handleAcknowledgeWoke = useCallback(() => { @@ -816,7 +813,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const handleMoveDown = useCallback(() => onMoveThread?.(thread, "down"), [onMoveThread, thread]); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); - const canUnsettle = variant === "slim"; + const canUnsettle = thread.settledOverride === "settled"; // The gate's only time-dependent edge is the queued-turn grace window, so its // expiry is known without a clock: evaluate at the message's own timestamp. // A gate held by runtime status has no expiry, and no clock tick opens it. @@ -827,7 +824,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const snoozeGateNowMs = useNowAfter(snoozeGateExpiryMs === null ? null : snoozeGateExpiryMs + 1); const snoozeGateNow = new Date(snoozeGateNowMs).toISOString(); const swipeActions = resolveThreadListV2SwipeActions({ - variant, + settled: canUnsettle, settlementSupported: props.settlementSupported, snoozeSupported: props.snoozeSupported, snoozable: canSnooze(thread, { now: snoozeGateNow }), @@ -892,12 +889,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { subactions: [ { id: "auto-settle:enabled", - title: "Follow settings", + title: "Enabled", state: thread.autoSettleDisabledAt == null ? "on" : "off", }, { id: "auto-settle:disabled", - title: "Never", + title: "Disabled", state: thread.autoSettleDisabledAt == null ? "off" : "on", }, ], @@ -1142,249 +1139,274 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ? `Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.` : `Opens the thread. Swipe left for ${primaryAction.label.toLowerCase()} and snooze actions.`; - const titleTextInset = props.project ? projectIconSize + 8 : 0; - const cardContent = ( - <> - - {props.project ? ( - - - - ) : null} + const runtimeError = + (status === "failed" || status === "limited") && thread.runtime?.lastError + ? thread.runtime.lastError + : null; + const trailingStatus = ( + + {props.hasQueuedMessages ? : null} + {pinnedRow ? ( + + ) : null} + {showsWoke ? ( + undefined} + style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} + > + + + Woke + + + ) : wakeLabel != null ? ( + + + + {wakeLabel} + + + ) : statusLabel ? ( + + + + {statusLabel.label} + {status === "working" ? ( + + ) : status === "waiting" ? ( + + ) : null} + + + ) : ( { - const firstLine = nativeEvent.lines[0]; - if (firstLine && Math.abs(firstLine.height - titleLineHeight) > 0.5) { - setTitleLineHeight(firstLine.height); - } - } - : undefined - } > - {thread.title} + {timeLabel} - - {props.hasQueuedMessages ? : null} - {pinnedRow ? ( + )} + {pr ? ( + undefined} + style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} + > + + + ) : null} + + + ); + const cardContent = ( + + + {isUnread ? : null} + + + {props.project ? ( + + ) : ( + - ) : null} - {showsWoke ? ( - undefined} - style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} - > - - - Woke - - - ) : statusLabel ? ( - - - - {statusLabel.label} - {status === "working" ? ( - - ) : status === "waiting" ? ( - - ) : null} - - - ) : ( - - {timeLabel} - - )} - + + )} - {props.searchMatch ? ( - + + + { + const firstLine = nativeEvent.lines[0]; + if (firstLine && Math.abs(firstLine.height - titleLineHeight) > 0.5) { + setTitleLineHeight(firstLine.height); + } + } + : undefined + } + > + {thread.title} + + {trailingStatus} + + {contextLabel != null ? ( + + {contextLabel} + + ) : null} + {props.searchMatch ? ( - - ) : null} - - {(status === "failed" || status === "limited") && thread.runtime?.lastError ? ( + ) : runtimeError ? ( - {thread.runtime.lastError} + {runtimeError} - ) : props.environmentLabel ? ( - - {props.environmentMachine ? ( - + {messagePreview.speaker === "you" ? ( + ) : null} - {props.environmentLabel} + {messagePreview.text} - ) : ( - - )} - {pr ? ( - undefined} - style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} - > - - - {pr.label} - - - ) : null} - {providerInstance ? ( - - {providerDrivers.slice(0, -1).map( - (driver, index) => ( - /* oxlint-disable react/no-array-index-key -- Provider history can repeat a driver; these decorative icons have no local state. */ - - - ) /* oxlint-enable react/no-array-index-key */, - )} - - ) : null} - + ); const fadedCardContent = recede ? ( @@ -1393,115 +1415,41 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { cardContent ); - const rowContent = (close: () => void) => - variant === "card" ? ( - { - if (event.nativeEvent.actionName === "openPullRequests") handleOpenPullRequests(); - if (event.nativeEvent.actionName === "dismissWoke") handleAcknowledgeWoke(); - }} - onPress={() => { - close(); - selectThread(); - }} - style={rowAppearance.cardStyle} - > - {sidebarPane ? ( - fadedCardContent - ) : ( - - {fadedCardContent} - {THREAD_LIST_V2_ROW_DIVIDERS && props.showTrailingDivider !== false ? ( - - ) : null} - - )} - - ) : ( - { - close(); - selectThread(); - }} - style={rowAppearance.style} - > - - {props.project ? ( - - - + const rowContent = (close: () => void) => ( + { + if (event.nativeEvent.actionName === "openPullRequests") handleOpenPullRequests(); + if (event.nativeEvent.actionName === "dismissWoke") handleAcknowledgeWoke(); + }} + onPress={() => { + close(); + selectThread(); + }} + style={rowAppearance.cardStyle} + > + {sidebarPane ? ( + fadedCardContent + ) : ( + + {fadedCardContent} + {THREAD_LIST_V2_ROW_DIVIDERS && props.showTrailingDivider !== false ? ( + ) : null} - - - {thread.title} - - {props.searchMatch ? ( - - ) : null} - - {props.hasQueuedMessages ? : null} - - {snoozedRow && props.snoozeWakeLabelText !== undefined - ? props.snoozeWakeLabelText - : timeLabel} - - - ); + )} + + ); if (!canOperateThread) return {rowContent(() => {})}; @@ -1515,7 +1463,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { dormant={dormant} threadKey={`${thread.environmentId}:${thread.id}`} backgroundColor={rowAppearance.swipeBackgroundColor} - compactActions={variant === "slim"} + compactActions={false} containerStyle={rowAppearance.swipeContainerStyle} enableTrackpadSwipe fullSwipeAction="primary" @@ -1525,7 +1473,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onSwipeableWillOpen={props.onSwipeableWillOpen} primaryAction={primaryAction} secondaryAction={secondaryAction} - resetKey={`${thread.environmentId}:${thread.id}:${variant}:${snoozedRow}:${thread.settledAt}:${thread.unsettledAt}:${thread.snoozedUntil}:${thread.pinnedAt}`} + resetKey={`${thread.environmentId}:${thread.id}:${snoozedRow}:${thread.settledAt}:${thread.unsettledAt}:${thread.snoozedUntil}:${thread.pinnedAt}`} simultaneousWith={props.simultaneousSwipeGesture} threadTitle={thread.title} > diff --git a/apps/mobile/src/features/threads/thread-list-v2-row-appearance.android.ts b/apps/mobile/src/features/threads/thread-list-v2-row-appearance.android.ts index c63d327764cb6..b07b63a82baef 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-row-appearance.android.ts +++ b/apps/mobile/src/features/threads/thread-list-v2-row-appearance.android.ts @@ -2,8 +2,9 @@ import type { ViewStyle } from "react-native"; import type { MobileThemeVariables } from "../../lib/mobileTheme"; export const THREAD_LIST_V2_MONO_FONT = "monospace"; -export const THREAD_LIST_V2_ROW_CONTENT_CLASS_NAME = "px-3 py-2.5"; +export const THREAD_LIST_V2_ROW_CONTENT_CLASS_NAME = "px-3 py-3"; export const THREAD_LIST_V2_ROW_DIVIDERS = false; +export const THREAD_LIST_V2_ROW_DIVIDER_INSET_CLASS_NAME = "ml-[68px]"; export const THREAD_LIST_V2_SIDEBAR_ROW_STYLE = { borderRadius: 20, paddingHorizontal: 12, diff --git a/apps/mobile/src/features/threads/thread-list-v2-row-appearance.ts b/apps/mobile/src/features/threads/thread-list-v2-row-appearance.ts index 96bf3d43682b4..2bebe3007a13b 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-row-appearance.ts +++ b/apps/mobile/src/features/threads/thread-list-v2-row-appearance.ts @@ -2,8 +2,10 @@ import type { ViewStyle } from "react-native"; import type { MobileThemeVariables } from "../../lib/mobileTheme"; export const THREAD_LIST_V2_MONO_FONT = "Menlo"; -export const THREAD_LIST_V2_ROW_CONTENT_CLASS_NAME = "px-5 py-2.5"; +export const THREAD_LIST_V2_ROW_CONTENT_CLASS_NAME = "px-4 py-3"; export const THREAD_LIST_V2_ROW_DIVIDERS = true; +/** Inset hairlines past the unread-dot + avatar column (Mail-style). */ +export const THREAD_LIST_V2_ROW_DIVIDER_INSET_CLASS_NAME = "ml-[68px]"; export const THREAD_LIST_V2_SIDEBAR_ROW_STYLE = { borderRadius: 12, paddingHorizontal: 12, diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 786f1ce229113..66de89bbc08aa 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -40,6 +40,7 @@ import { resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SwipeActions, resolveThreadListV2SnoozePresets, + resolveThreadListMessagePreview, resolveThreadListV2Status, resolveThreadListV2WokeAt, sortThreadsForListV2, @@ -243,6 +244,107 @@ describe("resolveThreadListV2SwipeActions", () => { }); }); +describe("resolveThreadListMessagePreview", () => { + it("collapses whitespace and maps roles for the Mail-style preview line", () => { + const assistant = presentThreadShell( + environmentId, + makeRawThreadShell({ + latestVisibleMessage: { + id: MessageId.make("msg-assistant"), + role: "assistant", + text: " Shipped\nthe fix ", + updatedAt: DateTime.makeUnsafe(NOW), + }, + }), + ); + const user = presentThreadShell( + environmentId, + makeRawThreadShell({ + latestVisibleMessage: { + id: MessageId.make("msg-user"), + role: "user", + text: "Please fix it", + updatedAt: DateTime.makeUnsafe(NOW), + }, + }), + ); + + expect(resolveThreadListMessagePreview(assistant)).toEqual({ + speaker: "agent", + text: "Shipped the fix", + }); + expect(resolveThreadListMessagePreview(user)).toEqual({ + speaker: "you", + text: "Please fix it", + }); + expect( + resolveThreadListMessagePreview( + presentThreadShell(environmentId, makeRawThreadShell({ latestVisibleMessage: null })), + ), + ).toBeNull(); + }); +}); + +describe("unread ready rows keep a date stamp", () => { + it("stamps a relative time instead of clearing the trailing slot for unseen completions", () => { + const completedAt = "2026-06-01T23:55:00.000Z"; + const thread = presentThreadShell( + environmentId, + makeRawThreadShell({ + id: ThreadId.make("unread-ready"), + title: "Unread ready", + status: "completed", + latestRunId: RunId.make("run-unread"), + latestRunCompletedAt: DateTime.makeUnsafe(completedAt), + latestUserMessageAt: DateTime.makeUnsafe("2026-06-01T23:50:00.000Z"), + lastVisitedAt: DateTime.makeUnsafe("2026-06-01T23:40:00.000Z"), + }), + ); + expect(threadHasUnseenCompletion(thread)).toBe(true); + expect(resolveThreadListV2Status(thread)).toBe("ready"); + + const [row] = buildThreadListV2ListItems({ + items: buildThreadListV2Items({ + threads: [thread], + environmentId: null, + searchQuery: "", + now: NOW, + }).items, + pendingTasks: [], + }); + expect(row?.type === "v2-thread" && row.timeLabel.length > 0).toBe(true); + }); +}); + +describe("queued messages keep a settled thread active", () => { + const threads = [ + makeThread({ id: ThreadId.make("active"), title: "Active" }), + makeThread({ id: ThreadId.make("settled"), title: "Settled", settledOverride: "settled" }), + makeThread({ + id: ThreadId.make("settled-queued"), + title: "Settled with outbox", + settledOverride: "settled", + }), + ]; + const queuedThreadKeys = new Set([`${environmentId}:settled-queued`]); + + it("lists the thread in the active block instead of the settled shelf", () => { + const layout = buildThreadListV2Items({ + threads, + environmentId: null, + searchQuery: "", + now: NOW, + queuedThreadKeys, + }); + expect(layout.items.map((item) => [item.thread.id, item.variant] as const)).toEqual([ + ["active", "card"], + ["settled-queued", "card"], + ["settled", "card"], + ]); + expect(layout.settledCount).toBe(1); + }); +}); + describe("search lists ranked matches", () => { const matchKey = (id: string) => threadSearchMatchKey({ environmentId, threadId: ThreadId.make(id) }); @@ -262,7 +364,7 @@ describe("search lists ranked matches", () => { }); // The exact title leads even from a collapsed Settled shelf, and keeps its look. expect(layout.items.map((item) => [item.thread.id, item.variant])).toEqual([ - ["exact", "slim"], + ["exact", "card"], ["notes", "card"], ["mentioned", "card"], ]); @@ -492,7 +594,7 @@ describe("buildThreadListV2Items", () => { expect(layout.snoozedCount).toBe(0); }); - it("partitions settled threads into a slim shelf", () => { + it("partitions settled threads into a settled shelf", () => { const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), @@ -516,8 +618,8 @@ describe("buildThreadListV2Items", () => { expect(layout.items.map((item) => [item.thread.id, item.variant])).toEqual([ ["active", "card"], - ["settled", "slim"], - ["settled-2", "slim"], + ["settled", "card"], + ["settled-2", "card"], ]); expect(layout.items.map((item) => item.isLast)).toEqual([false, false, true]); expect(layout.settledCount).toBe(2); @@ -543,7 +645,7 @@ describe("buildThreadListV2Items", () => { expect(items.map((item) => [item.thread.id, item.variant])).toEqual([ ["match", "card"], - ["settled", "slim"], + ["settled", "card"], ]); }); @@ -619,7 +721,9 @@ describe("buildThreadListV2Items settled paging", () => { }); expect(layout.hiddenSettledCount).toBe(2); - expect(layout.items.filter((item) => item.variant === "slim")).toHaveLength(2); + expect(layout.items.filter((item) => item.thread.settledOverride === "settled")).toHaveLength( + 2, + ); expect(layout.items.map((item) => item.thread.id)).toEqual([ "active", "settled-3", @@ -696,7 +800,7 @@ describe("buildThreadListV2Items settled sort reuse", () => { searchQuery: "", now: NOW, }) - .items.filter((item) => item.variant === "slim") + .items.filter((item) => item.thread.settledOverride === "settled") .map((item) => `${item.thread.environmentId}:${item.thread.id}`); } diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 3245c1c281e00..03332c6d8cfb2 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -113,7 +113,7 @@ export function resolveThreadListV2SnoozeMenuSelection(input: { } export function resolveThreadListV2SwipeActions(input: { - readonly variant: "card" | "slim"; + readonly settled: boolean; readonly settlementSupported: boolean; readonly snoozeSupported: boolean; readonly snoozable: boolean; @@ -125,11 +125,7 @@ export function resolveThreadListV2SwipeActions(input: { if (input.snoozed === true) { return { primary: "unsnooze", secondary: null }; } - const primary = input.settlementSupported - ? input.variant === "slim" - ? "unsettle" - : "settle" - : "archive"; + const primary = input.settlementSupported ? (input.settled ? "unsettle" : "settle") : "archive"; return { primary, secondary: input.snoozeSupported && input.snoozable ? "snooze" : null, @@ -243,6 +239,19 @@ export function threadHasUnseenCompletion( return completedAtMs > lastVisitedAtMs; } +export function resolveThreadListMessagePreview( + thread: Pick, +): { readonly speaker: "you" | "agent"; readonly text: string } | null { + const message = thread.source.latestVisibleMessage; + if (message == null) return null; + const text = message.text.replace(/\s+/g, " ").trim(); + if (text.length === 0) return null; + return { + speaker: message.role === "user" ? "you" : "agent", + text, + }; +} + export function resolveThreadListV2WokeAt( thread: EnvironmentThreadShell, options: { readonly now: string }, @@ -502,17 +511,12 @@ function resolveThreadListV2ItemTimeLabel( item: ThreadListV2Item, showSnoozeWakeLabel: boolean, ): string { - const { thread, variant, snoozed } = item; + const { thread, snoozed } = item; if (showSnoozeWakeLabel) return ""; - if ( - variant === "card" && - (resolveThreadListV2Status(thread) !== "ready" || - item.wokeAt !== null || - threadHasUnseenCompletion(thread)) - ) - return ""; + // Status badges and Woke own the trailing slot; unread-ready rows still keep a date. + if (resolveThreadListV2Status(thread) !== "ready" || item.wokeAt !== null) return ""; const settledTimestamp = - variant === "slim" && !snoozed ? resolveSettledThreadTimestamp(thread) : null; + !snoozed && thread.settledOverride === "settled" ? resolveSettledThreadTimestamp(thread) : null; return relativeTime( settledTimestamp ?? resolveThreadActivityTimestamp(thread) ?? thread.createdAt, ); @@ -551,7 +555,7 @@ export function buildThreadListV2ListItems(input: { ? input.snoozeLabelNow : undefined; const move = - item.variant === "card" + !item.snoozed && item.thread.settledOverride !== "settled" ? input.moveAvailability?.get(`${item.thread.environmentId}:${item.thread.id}`) : undefined; return { @@ -763,7 +767,7 @@ export function buildThreadListV2Items(input: { // Searching lists every match by rank across shelves, as the palette and // the web sidebar do, then threads listed for their messages in the // server's order. Each row keeps its shelf's look. - const slim = new Set([...snoozed, ...settled]); + const shelfThreads = new Set([...snoozed, ...settled]); const snoozedThreads = new Set(snoozed); const pinnedThreads = new Set(pinned); const threadByKey = new Map( @@ -780,10 +784,10 @@ export function buildThreadListV2Items(input: { return { items: results.map((thread, index) => ({ thread, - variant: slim.has(thread) ? "slim" : "card", + variant: "card" as const, snoozed: snoozedThreads.has(thread), pinned: pinnedThreads.has(thread), - wokeAt: slim.has(thread) ? null : resolveThreadListV2WokeAt(thread, { now }), + wokeAt: shelfThreads.has(thread) ? null : resolveThreadListV2WokeAt(thread, { now }), isLast: index === results.length - 1, })), hiddenSettledCount: 0, @@ -885,7 +889,7 @@ export function buildThreadListV2Items(input: { for (const thread of visibleSnoozed) { items.push({ thread, - variant: "slim", + variant: "card", snoozed: true, pinned: false, wokeAt: null, @@ -896,7 +900,7 @@ export function buildThreadListV2Items(input: { for (const thread of visibleSettled) { items.push({ thread, - variant: "slim", + variant: "card", snoozed: false, pinned: false, wokeAt: null, From bd581976710ce8b8513829834b4a06d6a25e0580 Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Wed, 4 Nov 2026 20:01:48 -0800 Subject: [PATCH 02/15] fix(mobile): restore provider icons on Mail-style thread rows The rewrite dropped the model/provider stack from the trailing slot. Co-authored-by: Cursor --- .../features/threads/thread-list-v2-items.tsx | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) 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 359768433b9f6..8a77f673b59e0 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -41,6 +41,7 @@ import { AppText as Text } from "../../components/ui/AppText"; import { Menu } from "../../components/ui/Menu"; import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { ProjectFavicon } from "../../components/ProjectFavicon"; +import { ProviderIcon, ProviderInstanceIcon } from "../../components/ProviderIcon"; import { useNowAfter } from "../../lib/clock"; import { cn } from "../../lib/cn"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; @@ -67,6 +68,7 @@ import { threadCanArchive } from "../home/threadArchive"; import { THREAD_LIST_V2_SETTLED_PAGE_COUNT, resolveThreadListMessagePreview, + resolveThreadListV2ProviderDrivers, resolveThreadListV2SnoozeGateExpiryMs, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozePresets, @@ -726,6 +728,18 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const dormant = useSwipeRowDormant(props.activationKey); const selectThread = () => onSelectThread(thread); + const { providerDrivers, providerIconUrl } = useMemo(() => { + const provider = props.providers?.find( + (candidate) => + candidate.instanceId === + (thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId), + ); + return { + providerDrivers: resolveThreadListV2ProviderDrivers(thread, props.providers), + providerIconUrl: provider?.iconUrl, + }; + }, [thread, props.providers]); + const providerInstance = props.providerInstance; const pr = useThreadPr(thread); const prBadgeLabel = pr ? resolveThreadPrBadgeAction(pr).accessibilityLabel : null; const handleOpenPullRequests = useCallback(() => { @@ -755,9 +769,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const rowAccessibilityLabel = [ thread.title, contextLabel, + statusLabel?.label ?? null, + showsWoke ? "woke" : null, isUnread ? "unread" : null, snoozedRow ? "snoozed" : null, wakeLabel != null ? `wakes in ${wakeLabel}` : null, + wakeLabel == null && !showsWoke && statusLabel === undefined && props.timeLabel + ? props.timeLabel + : null, + providerInstance?.displayName ?? null, props.hasQueuedMessages ? "messages queued to send" : null, isRegeneratingTitle ? "regenerating title" : null, ] @@ -1275,6 +1295,29 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { /> ) : null} + {providerInstance ? ( + + {providerDrivers.slice(0, -1).map( + (driver, index) => ( + /* oxlint-disable react/no-array-index-key -- Provider history can repeat a driver; these decorative icons have no local state. */ + + + ) /* oxlint-enable react/no-array-index-key */, + )} + + + ) : null} Date: Wed, 4 Nov 2026 20:08:05 -0800 Subject: [PATCH 03/15] fix(mobile): put provider icons under the thread row chevron Keeps the title row for time/status and uses the space under the arrow for the model indicator. Co-authored-by: Cursor --- .../features/threads/thread-list-v2-items.tsx | 382 +++++++++--------- 1 file changed, 193 insertions(+), 189 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 8a77f673b59e0..563da6ae51dfe 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -1163,138 +1163,152 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { (status === "failed" || status === "limited") && thread.runtime?.lastError ? thread.runtime.lastError : null; - const trailingStatus = ( - - {props.hasQueuedMessages ? : null} - {pinnedRow ? ( - - ) : null} - {showsWoke ? ( - undefined} - style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} - > + const trailingMeta = ( + + + {props.hasQueuedMessages ? : null} + {pinnedRow ? ( - undefined} + style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} > - Woke - - - ) : wakeLabel != null ? ( - - + + + Woke + + + ) : wakeLabel != null ? ( + + + + {wakeLabel} + + + ) : statusLabel ? ( + + + + {statusLabel.label} + {status === "working" ? ( + + ) : status === "waiting" ? ( + + ) : null} + + + ) : ( - {wakeLabel} + {timeLabel} - - ) : statusLabel ? ( - - - undefined} + style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} > - {statusLabel.label} - {status === "working" ? ( - - ) : status === "waiting" ? ( - - ) : null} - - - ) : ( - + + ) : null} + - {timeLabel} - - )} - {pr ? ( - undefined} - style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} - > - - - ) : null} + ? selectedThreadRowColors.mutedIconTintClassName + : rowAppearance.tertiaryIconTintClassName + } + type="monochrome" + /> + {providerInstance ? ( {providerDrivers.slice(0, -1).map( @@ -1318,16 +1332,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { /> ) : null} - ); const cardContent = ( @@ -1359,11 +1363,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { )} - - + + {thread.title} - {trailingStatus} - - {contextLabel != null ? ( - - {contextLabel} - - ) : null} - {props.searchMatch ? ( - - ) : runtimeError ? ( - - {runtimeError} - - ) : messagePreview ? ( - - {messagePreview.speaker === "you" ? ( - - ) : null} + {contextLabel != null ? ( + {contextLabel} + + ) : null} + {props.searchMatch ? ( + + ) : runtimeError ? ( + - {messagePreview.text} + {runtimeError} - - ) : null} + ) : messagePreview ? ( + + {messagePreview.speaker === "you" ? ( + + ) : null} + + {messagePreview.text} + + + ) : null} + + {trailingMeta} ); From d04a407a85be9323870a19cb0d5095cb760a4b64 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 5 Nov 2026 20:46:03 +0000 Subject: [PATCH 04/15] fix(mobile): start thread row dividers at the text column --- apps/mobile/src/features/threads/thread-list-v2-items.tsx | 5 +++-- .../threads/thread-list-v2-row-appearance.android.ts | 2 +- .../src/features/threads/thread-list-v2-row-appearance.ts | 4 ++-- 3 files changed, 6 insertions(+), 5 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 563da6ae51dfe..7b91383588acb 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -3,7 +3,7 @@ import type { ThreadRowProviderInstance } from "./thread-provider-instance"; import { THREAD_LIST_V2_MONO_FONT as MONO_FONT, THREAD_LIST_V2_ROW_CONTENT_CLASS_NAME, - THREAD_LIST_V2_ROW_DIVIDER_INSET_CLASS_NAME, + THREAD_LIST_V2_ROW_DIVIDER_INSET, THREAD_LIST_V2_ROW_DIVIDERS, selectedThreadRowColors, getThreadListV2NewBranchMenuTitle, @@ -1490,7 +1490,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { {fadedCardContent} {THREAD_LIST_V2_ROW_DIVIDERS && props.showTrailingDivider !== false ? ( ) : null} diff --git a/apps/mobile/src/features/threads/thread-list-v2-row-appearance.android.ts b/apps/mobile/src/features/threads/thread-list-v2-row-appearance.android.ts index b07b63a82baef..f47df5be02f96 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-row-appearance.android.ts +++ b/apps/mobile/src/features/threads/thread-list-v2-row-appearance.android.ts @@ -4,7 +4,7 @@ import type { MobileThemeVariables } from "../../lib/mobileTheme"; export const THREAD_LIST_V2_MONO_FONT = "monospace"; export const THREAD_LIST_V2_ROW_CONTENT_CLASS_NAME = "px-3 py-3"; export const THREAD_LIST_V2_ROW_DIVIDERS = false; -export const THREAD_LIST_V2_ROW_DIVIDER_INSET_CLASS_NAME = "ml-[68px]"; +export const THREAD_LIST_V2_ROW_DIVIDER_INSET = 40; export const THREAD_LIST_V2_SIDEBAR_ROW_STYLE = { borderRadius: 20, paddingHorizontal: 12, diff --git a/apps/mobile/src/features/threads/thread-list-v2-row-appearance.ts b/apps/mobile/src/features/threads/thread-list-v2-row-appearance.ts index 2bebe3007a13b..1ff93ceef0517 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-row-appearance.ts +++ b/apps/mobile/src/features/threads/thread-list-v2-row-appearance.ts @@ -4,8 +4,8 @@ import type { MobileThemeVariables } from "../../lib/mobileTheme"; export const THREAD_LIST_V2_MONO_FONT = "Menlo"; export const THREAD_LIST_V2_ROW_CONTENT_CLASS_NAME = "px-4 py-3"; export const THREAD_LIST_V2_ROW_DIVIDERS = true; -/** Inset hairlines past the unread-dot + avatar column (Mail-style). */ -export const THREAD_LIST_V2_ROW_DIVIDER_INSET_CLASS_NAME = "ml-[68px]"; +/** Row padding + unread-dot column + gaps; the divider adds the avatar size to start at the text. */ +export const THREAD_LIST_V2_ROW_DIVIDER_INSET = 44; export const THREAD_LIST_V2_SIDEBAR_ROW_STYLE = { borderRadius: 12, paddingHorizontal: 12, From ac8b5bb273a9ca00208cbaba3f49b39a35d3ae9d Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Sat, 7 Nov 2026 12:17:52 -0800 Subject: [PATCH 05/15] fix(mobile): make thread shelves look like Mail date groups Drop the hairline divider and uppercase tone so Working/Snoozed/Settled read as Mail-style section labels with a trailing count and chevron. --- .../features/threads/thread-list-v2-items.tsx | 46 +++++++------------ 1 file changed, 17 insertions(+), 29 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 7b91383588acb..fb07f57c1b66b 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -188,8 +188,8 @@ const LEGACY_MENU_ACTIONS: MenuAction[] = [ function ThreadListV2Section(props: { readonly label: string; + readonly count?: number; readonly pane?: "screen" | "sidebar"; - readonly tone?: "default" | "snoozed"; readonly disclosure?: { readonly expanded: boolean; readonly disabled?: boolean; @@ -198,43 +198,31 @@ function ThreadListV2Section(props: { readonly accessibilityHint: string; }; }) { - const snoozed = props.tone === "snoozed"; const sidebarPane = props.pane === "sidebar"; + const showCount = props.count !== undefined && props.disclosure?.expanded !== true; + const labelClassName = cn( + "text-[15px] font-t3-semibold", + sidebarPane ? "text-drawer-foreground" : "text-foreground", + ); + const mutedClassName = sidebarPane ? "text-drawer-foreground-muted" : "text-foreground-tertiary"; + const chevronTint = sidebarPane ? "accent-drawer-foreground-muted" : "accent-foreground-muted"; const className = cn( - "mb-1.5 mt-4 flex-row items-center gap-2.5", + "mb-1 mt-5 flex-row items-center gap-2", props.pane === "sidebar" ? "px-3" : "px-5", ); const content = ( <> - + {props.label} - + {showCount ? ( + {props.count} + ) : null} {props.disclosure ? ( @@ -286,9 +274,9 @@ function ThreadListV2ShelfHeader( const label = SHELF_LABEL[props.kind]; return ( Date: Sat, 7 Nov 2026 13:02:11 -0800 Subject: [PATCH 06/15] fix(mobile): Mail-like empty state when there are no threads Add a large muted icon and a New thread CTA so a blank inbox feels like Mail, not a dead end. Soften the zero-project and zero-thread copy too. --- apps/mobile/src/components/ui/EmptyState.tsx | 13 ++++++++ apps/mobile/src/features/home/HomeScreen.tsx | 31 +++++++++++++------- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/components/ui/EmptyState.tsx b/apps/mobile/src/components/ui/EmptyState.tsx index 74e7b31575904..cb4a40fc8748a 100644 --- a/apps/mobile/src/components/ui/EmptyState.tsx +++ b/apps/mobile/src/components/ui/EmptyState.tsx @@ -2,19 +2,31 @@ import { View } from "react-native"; import type { ReactNode } from "react"; import { AppText as Text } from "./AppText"; +import { SymbolView, type AppSymbolName } from "./AppSymbol"; import { Button } from "./Button"; export function EmptyState(props: { readonly title: string; readonly detail: string; + readonly icon?: AppSymbolName; readonly actionLabel?: string; readonly onAction?: () => void; readonly action?: ReactNode; readonly variant?: "card" | "plain"; }) { + const icon = props.icon ? ( + + ) : null; + if (props.variant === "plain") { return ( + {icon ? {icon} : null} {props.title} @@ -37,6 +49,7 @@ export function EmptyState(props: { return ( + {icon ? {icon} : null} {props.title} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 38e8153bd6ee9..8c82b03d0d006 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -199,15 +199,15 @@ function deriveEmptyState(props: { if (props.projectCount === 0 && catalogState.hasLoadedShellSnapshot) { return { - title: "No projects found", - detail: "The connected environment did not report any projects.", + title: "No Projects", + detail: "This environment has not reported any projects yet.", loading: false, }; } return { - title: "No threads yet", - detail: "Create a task to start a new coding runtime in one of your connected projects.", + title: "No Threads", + detail: "Start a new thread to begin working in one of your projects.", loading: false, }; } @@ -674,6 +674,8 @@ export function HomeScreen(props: HomeScreenProps) { }); if (!hasAnyThreads) { + const needsEnvironment = !props.catalogState.hasReadyEnvironment; + const showStartAction = !emptyState.loading; return ( @@ -692,22 +694,31 @@ export function HomeScreen(props: HomeScreenProps) { > ) : undefined } From 2204b3c2638a25f1c7d16c657f17f4a0d76fcf92 Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Sat, 7 Nov 2026 13:09:41 -0800 Subject: [PATCH 07/15] fix(mobile): celebrate inbox zero when nothing needs a response Show an All Caught Up placeholder above Working/Snoozed/Settled when the attention inbox is clear, so clearing every thread feels intentional. --- apps/mobile/src/features/home/HomeScreen.tsx | 4 ++ .../threads/ThreadNavigationSidebar.tsx | 3 ++ .../features/threads/thread-list-v2-items.tsx | 35 ++++++++++++++++ .../src/features/threads/threadListV2.test.ts | 40 +++++++++++++++++++ .../src/features/threads/threadListV2.ts | 23 +++++++++++ .../src/features/threads/useThreadListV2.ts | 1 + 6 files changed, 106 insertions(+) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 8c82b03d0d006..0eb4feb18cc79 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -62,6 +62,7 @@ import { threadListEnvironmentsAtom } from "../../state/presentation"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; import { + ThreadListV2CaughtUpPlaceholder, ThreadListV2PendingRow, ThreadListV2Row, ThreadListV2SettledShelfHeader, @@ -543,6 +544,9 @@ export function HomeScreen(props: HomeScreenProps) { /> ); } + if (item.type === "v2-caught-up") { + return ; + } if (item.type === "v2-working-shelf") { return ( ); } + case "v2-caught-up": + return ; case "v2-working-shelf": return ( ; }); +export const ThreadListV2CaughtUpPlaceholder = memo( + function ThreadListV2CaughtUpPlaceholder(props: { readonly pane?: "screen" | "sidebar" }) { + const sidebarPane = props.pane === "sidebar"; + return ( + + + + All Caught Up + + + Nothing needs a response right now. + + + ); + }, +); + export const ThreadListV2ShowMoreRow = memo(function ThreadListV2ShowMoreRow(props: { readonly pane?: "screen" | "sidebar"; readonly hiddenCount: number; diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 66de89bbc08aa..fc9fb902123a8 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -946,6 +946,46 @@ describe("buildThreadListV2ListItems", () => { ).toEqual([true, true, true, false, null, false]); }); + it("shows an all-caught-up placeholder when only shelves remain", () => { + const shelvesOnly = buildThreadListV2Items({ + threads: [ + makeThread({ + id: ThreadId.make("snoozed"), + title: "snoozed", + snoozedUntil: "2026-06-03T09:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("settled"), + title: "settled", + settledOverride: "settled", + settledAt: NOW, + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + }); + expect(shelvesOnly.activeCount).toBe(0); + const items = buildThreadListV2ListItems({ + items: shelvesOnly.items, + pendingTasks: [], + activeCount: shelvesOnly.activeCount, + snoozedCount: shelvesOnly.snoozedCount, + snoozedShelfExpanded: false, + snoozedShelfHeaderIndex: shelvesOnly.snoozedShelfHeaderIndex, + settledCount: shelvesOnly.settledCount, + settledShelfExpanded: true, + settledShelfHeaderIndex: shelvesOnly.settledShelfHeaderIndex, + }); + expect(items.map((item) => item.type)).toEqual([ + "v2-caught-up", + "v2-snoozed-shelf", + "v2-settled-shelf", + "v2-thread", + ]); + }); + it("places queued tasks above active rows and a collapsed snoozed shelf", () => { const snoozedLayout = buildThreadListV2Items({ threads: [ diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 03332c6d8cfb2..06d682944d435 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -374,6 +374,8 @@ export interface ThreadListV2Layout { readonly hiddenSettledCount: number; /** The settled rows the shelf may list, before its visible limit. */ readonly listedSettledThreads: ReadonlyArray; + /** Attention inbox rows above Working/Snoozed/Settled (excludes pinned). */ + readonly activeCount: number; readonly workingCount: number; readonly workingShelfHeaderIndex: number | null; readonly snoozedCount: number; @@ -408,6 +410,11 @@ export interface ThreadListV2PendingListItem { readonly showTrailingDivider: boolean; } +export interface ThreadListV2CaughtUpListItem { + readonly type: "v2-caught-up"; + readonly key: "v2-caught-up"; +} + export interface ThreadListV2WorkingShelfListItem { readonly type: "v2-working-shelf"; readonly key: "v2-working-shelf"; @@ -436,6 +443,7 @@ export interface ThreadListV2SettledShelfListItem { export type ThreadListV2ListItem = | ThreadListV2ThreadListItem | ThreadListV2PendingListItem + | ThreadListV2CaughtUpListItem | ThreadListV2WorkingShelfListItem | ThreadListV2SnoozedShelfListItem | ThreadListV2SettledShelfListItem; @@ -446,6 +454,7 @@ export function isThreadListV2ListItem(value: { return ( value.type === "v2-thread" || value.type === "v2-pending" || + value.type === "v2-caught-up" || value.type === "v2-working-shelf" || value.type === "v2-snoozed-shelf" || value.type === "v2-settled-shelf" @@ -483,6 +492,8 @@ export function threadListV2ListItemsAreEqual( previous.showPendingDivider === item.showPendingDivider && previous.showTrailingDivider === item.showTrailingDivider ); + case "v2-caught-up": + return previous.type === "v2-caught-up"; case "v2-working-shelf": return ( previous.type === "v2-working-shelf" && @@ -525,6 +536,7 @@ function resolveThreadListV2ItemTimeLabel( export function buildThreadListV2ListItems(input: { readonly items: ReadonlyArray; readonly pendingTasks: ReadonlyArray; + readonly activeCount?: number; readonly workingCount?: number; readonly workingShelfExpanded?: boolean; readonly workingShelfHeaderIndex?: number | null; @@ -579,6 +591,7 @@ export function buildThreadListV2ListItems(input: { showPendingDivider: index === 0, showTrailingDivider: false, })); + const activeCount = input.activeCount ?? 0; const workingCount = input.workingCount ?? 0; const workingShelfHeaderIndex = input.workingShelfHeaderIndex ?? null; const snoozedCount = input.snoozedCount ?? 0; @@ -590,6 +603,14 @@ export function buildThreadListV2ListItems(input: { const activeEnd = workingShelfHeaderIndex ?? workingEnd; const result: ThreadListV2ListItem[] = [...pendingItems, ...threadItems.slice(0, activeEnd)]; const shelfDisabled = input.shelfPreferencesLoading === true; + // Inbox zero: attention queue is clear, but Working/Snoozed/Settled still exist. + if ( + activeCount === 0 && + pendingItems.length === 0 && + workingCount + snoozedCount + settledCount > 0 + ) { + result.push({ type: "v2-caught-up", key: "v2-caught-up" }); + } if (workingShelfHeaderIndex !== null && workingCount > 0) { result.push({ type: "v2-working-shelf", @@ -792,6 +813,7 @@ export function buildThreadListV2Items(input: { })), hiddenSettledCount: 0, listedSettledThreads: [], + activeCount: 0, workingCount: 0, workingShelfHeaderIndex: null, snoozedCount: 0, @@ -915,6 +937,7 @@ export function buildThreadListV2Items(input: { items, hiddenSettledCount: Math.max(0, settledTotal - pagedSettled.length), listedSettledThreads: listedSettled, + activeCount: orderedActive.length, workingCount: orderedWorking.length, workingShelfHeaderIndex, snoozedCount: orderedSnoozed.length, diff --git a/apps/mobile/src/features/threads/useThreadListV2.ts b/apps/mobile/src/features/threads/useThreadListV2.ts index fcf1048d2b6af..13e9b1f8dbdb0 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.ts +++ b/apps/mobile/src/features/threads/useThreadListV2.ts @@ -214,6 +214,7 @@ export function useThreadListV2(input: { buildThreadListV2ListItems({ items: layout.items, pendingTasks, + activeCount: layout.activeCount, workingCount: layout.workingCount, workingShelfExpanded: input.workingShelfExpanded, workingShelfHeaderIndex: layout.workingShelfHeaderIndex, From f5d30cd830b306abb9e091ee8ca8e78cdf8e1b1d Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Sat, 7 Nov 2026 13:31:17 -0800 Subject: [PATCH 08/15] fix(mobile): give New thread Liquid Glass on iOS Empty-state and workspace New thread CTAs use interactive GlassSurface instead of a solid primary fill so they match the dock compose material. --- .../components/NewThreadButton.android.tsx | 4 +- .../src/components/NewThreadButton.ios.tsx | 53 +++++++++++++++++++ .../src/components/NewThreadButton.shared.tsx | 12 +++-- .../mobile/src/components/ui/GlassSurface.tsx | 3 ++ apps/mobile/src/features/home/HomeScreen.tsx | 40 +++++++------- .../features/layout/WorkspaceEmptyDetail.tsx | 14 +---- 6 files changed, 86 insertions(+), 40 deletions(-) create mode 100644 apps/mobile/src/components/NewThreadButton.ios.tsx diff --git a/apps/mobile/src/components/NewThreadButton.android.tsx b/apps/mobile/src/components/NewThreadButton.android.tsx index e874e7816fc1c..b8cca500f0f6c 100644 --- a/apps/mobile/src/components/NewThreadButton.android.tsx +++ b/apps/mobile/src/components/NewThreadButton.android.tsx @@ -10,8 +10,8 @@ export function NewThreadButton(props: ComponentProps diff --git a/apps/mobile/src/components/NewThreadButton.ios.tsx b/apps/mobile/src/components/NewThreadButton.ios.tsx new file mode 100644 index 0000000000000..ce77112a8ef67 --- /dev/null +++ b/apps/mobile/src/components/NewThreadButton.ios.tsx @@ -0,0 +1,53 @@ +import type { ComponentProps } from "react"; +import { Pressable } from "react-native"; + +import { cn } from "../lib/cn"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../native/native-glass"; +import { AppText } from "./ui/AppText"; +import { SymbolView } from "./ui/AppSymbol"; +import { GlassSurface } from "./ui/GlassSurface"; +import { NewThreadButton as SolidNewThreadButton } from "./NewThreadButton.shared"; + +export function NewThreadButton(props: ComponentProps) { + if (!NATIVE_LIQUID_GLASS_SUPPORTED) { + return ; + } + + const label = props.label ?? "New thread"; + const icon = props.icon ?? "square.and.pencil"; + const showLabel = props.extended === true && props.expanded !== false; + const height = props.extended ? 48 : 56; + + return ( + + + + {showLabel ? ( + {label} + ) : null} + + + ); +} diff --git a/apps/mobile/src/components/NewThreadButton.shared.tsx b/apps/mobile/src/components/NewThreadButton.shared.tsx index fac535e09e974..f234a7a23b09d 100644 --- a/apps/mobile/src/components/NewThreadButton.shared.tsx +++ b/apps/mobile/src/components/NewThreadButton.shared.tsx @@ -2,18 +2,22 @@ import { Pressable, type StyleProp, type ViewStyle } from "react-native"; import { cn } from "../lib/cn"; import { AppText } from "./ui/AppText"; -import { SymbolView } from "./ui/AppSymbol"; +import { SymbolView, type AppSymbolName } from "./ui/AppSymbol"; export function NewThreadButton(props: { readonly onPress: () => void; + readonly label?: string; + readonly icon?: AppSymbolName; readonly extended?: boolean; readonly expanded?: boolean; readonly className?: string; readonly style?: StyleProp; }) { + const label = props.label ?? "New thread"; + const icon = props.icon ?? "square.and.pencil"; return ( {props.extended && props.expanded !== false ? ( - New thread + {label} ) : null} ); diff --git a/apps/mobile/src/components/ui/GlassSurface.tsx b/apps/mobile/src/components/ui/GlassSurface.tsx index 8af080fc19cc7..a06581e074d86 100644 --- a/apps/mobile/src/components/ui/GlassSurface.tsx +++ b/apps/mobile/src/components/ui/GlassSurface.tsx @@ -28,6 +28,7 @@ interface GlassSurfaceProps extends ViewProps { // null leaves UIKit's material untinted instead of applying the theme tint. readonly tintColor?: ColorValue | null; readonly tintColorClassName?: string; + readonly isInteractive?: boolean; readonly chrome?: "default" | "none"; readonly fallbackColor?: ColorValue; readonly fallbackClassName?: string; @@ -40,6 +41,7 @@ export function GlassSurface({ chrome = "default", tintColor, tintColorClassName, + isInteractive = false, fallbackColor, fallbackClassName, className, @@ -72,6 +74,7 @@ export function GlassSurface({ className, )} glassEffectStyle={glassEffectStyle} + isInteractive={isInteractive} tintColor={tintColor == null ? undefined : String(tintColor)} tintColorClassName={ tintColorClassName ?? (tintColor === undefined ? "accent-glass-tint" : undefined) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 0eb4feb18cc79..768162f056e1f 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -37,6 +37,7 @@ import { ANDROID_READING_MAX_WIDTH, deriveCenteredContentHorizontalPadding, } from "../../lib/layout"; +import { NewThreadButton } from "../../components/NewThreadButton"; import { EmptyState } from "../../components/ui/EmptyState"; import { FloatingActionButton } from "../../components/ui/FloatingActionButton"; import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel"; @@ -701,29 +702,24 @@ export function HomeScreen(props: HomeScreenProps) { icon={needsEnvironment ? "desktopcomputer" : "bubble.left.and.bubble.right"} title={emptyState.title} detail={emptyState.detail} - actionLabel={ - showStartAction - ? needsEnvironment - ? "Add environment" - : "New thread" - : undefined - } - onAction={ - showStartAction - ? needsEnvironment - ? props.onAddConnection - : props.onStartNewTask - : undefined - } action={ - Platform.OS === "android" && showStartAction ? ( - + showStartAction ? ( + Platform.OS === "android" ? ( + + ) : ( + + ) ) : undefined } variant="plain" diff --git a/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx b/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx index e04b72cb9685d..21a52e3beda48 100644 --- a/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx +++ b/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx @@ -1,5 +1,5 @@ import { SymbolView } from "../../components/ui/AppSymbol"; -import { Platform, Pressable, View } from "react-native"; +import { Platform, View } from "react-native"; import { AppText as Text } from "../../components/ui/AppText"; import { NewThreadButton } from "../../components/NewThreadButton"; @@ -67,17 +67,7 @@ export function WorkspaceEmptyDetail(props: { : "Choose a thread from the sidebar or start a new task."} {props.onStartNewTask && !pullRequests && !settings ? ( - Platform.OS === "android" ? ( - - ) : ( - - New thread - - ) + ) : null} )} From c98759d1c819ff6c3be951366cf57bf44c971065 Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Sat, 7 Nov 2026 13:40:43 -0800 Subject: [PATCH 09/15] fix(mobile): Mail-style full-height thread swipe lanes Replace circular action chips with saturated system-color lanes that stretch on a full swipe, matching Mail trailing actions. --- .../features/home/thread-swipe-actions.tsx | 197 +++++++----------- 1 file changed, 81 insertions(+), 116 deletions(-) diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index a6c48086f2c85..68ab5fb6afb98 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -21,7 +21,7 @@ import type { StyleProp, ViewStyle, } from "react-native"; -import { Pressable, View } from "react-native"; +import { Platform, PlatformColor, Pressable, View } from "react-native"; import ReanimatedSwipeable, { type SwipeableMethods, } from "react-native-gesture-handler/ReanimatedSwipeable"; @@ -44,18 +44,17 @@ import { AppText as Text } from "../../components/ui/AppText"; import { SwipeRowActivationContext, type SwipeRowActivation } from "./swipe-row-activation"; import { registerThreadDismissal } from "./thread-dismissal"; -const ACTION_ITEM_WIDTH = 58; -const ACTION_CIRCLE_SIZE = 36; -const ACTION_ICON_SIZE = 15; -const COMPACT_ACTION_CIRCLE_SIZE = 28; -const COMPACT_ACTION_ICON_SIZE = 13; +// Mail-width trailing action lanes; compact shrinks for the sidebar column. +const ACTION_ITEM_WIDTH = 74; +const ACTION_ICON_SIZE = 18; +const COMPACT_ACTION_ITEM_WIDTH = 64; +const COMPACT_ACTION_ICON_SIZE = 16; -const THREAD_SWIPE_ACTIONS_WIDTH = ACTION_ITEM_WIDTH * 2; const THREAD_SWIPE_SPRING = { - damping: 26, - mass: 0.7, + damping: 28, + mass: 0.8, overshootClamping: true, - stiffness: 330, + stiffness: 300, }; interface ThreadSwipeAction { @@ -75,8 +74,13 @@ interface ThreadSwipeSecondaryAction extends ThreadSwipeAction { readonly tone: "primary" | "secondary" | "danger"; } -function swipeActionsWidth(hasSecondaryAction: boolean) { - return hasSecondaryAction ? THREAD_SWIPE_ACTIONS_WIDTH : ACTION_ITEM_WIDTH; +function actionItemWidth(compact: boolean) { + return compact ? COMPACT_ACTION_ITEM_WIDTH : ACTION_ITEM_WIDTH; +} + +function swipeActionsWidth(hasSecondaryAction: boolean, compact = false) { + const width = actionItemWidth(compact); + return hasSecondaryAction ? width * 2 : width; } function resolveSecondaryAction(input: { @@ -238,8 +242,9 @@ interface ThreadSwipeableProps { export function ThreadSwipeable(props: ThreadSwipeableProps) { const swipeableRef = useRef(null); + const compactActions = props.compactActions === true; const hasSecondaryAction = props.secondaryAction !== null; - const actionsWidth = swipeActionsWidth(hasSecondaryAction); + const actionsWidth = swipeActionsWidth(hasSecondaryAction, compactActions); const fullSwipeThreshold = Math.max(actionsWidth + 44, props.fullSwipeWidth * 0.58); const fullSwipeAction = props.fullSwipeAction ?? (props.secondaryAction === undefined ? "delete" : "primary"); @@ -419,7 +424,6 @@ export function ThreadSwipeable(props: ThreadSwipeableProps) { const handleSwipeableClose = useCallback(() => { if (swipeableRef.current) onSwipeableClose?.(swipeableRef.current); }, [onSwipeableClose]); - const compactActions = props.compactActions === true; const { secondaryAction, threadTitle } = props; // Swipeable calls this on every render; returning the previous element while nothing it shows // changed keeps a selection change from re-rendering the hidden actions. @@ -522,9 +526,39 @@ export function ThreadSwipeable(props: ThreadSwipeableProps) { ); } +// Mail uses saturated system fills; theme tokens for secondary/danger are pastel chips. +function swipeLaneBackground(tone: "primary" | "secondary" | "danger"): ColorValue | undefined { + if (Platform.OS !== "ios") return undefined; + if (tone === "danger") return PlatformColor("systemRed"); + if (tone === "secondary") return PlatformColor("systemGray"); + return PlatformColor("systemBlue"); +} + +function swipeLaneClassName(tone: "primary" | "secondary" | "danger") { + if (Platform.OS === "ios") return undefined; + if (tone === "danger") return "bg-danger"; + if (tone === "secondary") return "bg-secondary"; + return "bg-primary"; +} + +function swipeLaneForegroundClassName(tone: "primary" | "secondary" | "danger") { + if (Platform.OS === "ios") return "accent-white"; + if (tone === "danger") return "accent-danger-foreground"; + if (tone === "secondary") return "accent-secondary-foreground"; + return "accent-primary-foreground"; +} + +function swipeLaneLabelClassName(tone: "primary" | "secondary" | "danger") { + if (Platform.OS === "ios") return "text-3xs font-t3-medium text-white"; + if (tone === "danger") return "text-3xs font-t3-medium text-danger-foreground"; + if (tone === "secondary") return "text-3xs font-t3-medium text-secondary-foreground"; + return "text-3xs font-t3-medium text-primary-foreground"; +} + function SwipeActionButton(props: { readonly accessibilityLabel: string; readonly actionsWidth: number; + readonly itemWidth: number; readonly tone: "primary" | "secondary" | "danger"; readonly compact: boolean; readonly entryRange: readonly [number, number]; @@ -540,12 +574,14 @@ function SwipeActionButton(props: { actionsWidth, entryRange: [entryRangeStart, entryRangeEnd], fullSwipeThreshold, + itemWidth, stretchesOnFullSwipe, translation, } = props; - const circleSize = props.compact ? COMPACT_ACTION_CIRCLE_SIZE : ACTION_CIRCLE_SIZE; const iconSize = props.compact ? COMPACT_ACTION_ICON_SIZE : ACTION_ICON_SIZE; - const actionStyle = useAnimatedStyle(() => { + const foreground = swipeLaneForegroundClassName(props.tone); + // Mail lanes fill the row height; the committing lane grows left as the drag overshoots. + const laneStyle = useAnimatedStyle(() => { const reveal = Math.max(-translation.value, 0); const entryProgress = interpolate( reveal, @@ -565,51 +601,25 @@ function SwipeActionButton(props: { opacity: stretchesOnFullSwipe ? entryProgress : entryProgress * (1 - fullSwipeProgress), transform: [ { - translateX: - interpolate(entryProgress, [0, 1], [22, 0]) - (stretchesOnFullSwipe ? 0 : stretch), + translateX: stretchesOnFullSwipe ? -stretch : -stretch * fullSwipeProgress, }, - { scale: interpolate(entryProgress, [0, 1], [0.78, 1]) }, ], + width: itemWidth + (stretchesOnFullSwipe ? stretch : 0), }; }); - const circleStyle = useAnimatedStyle(() => { - const reveal = Math.max(-translation.value, 0); - const stretch = stretchesOnFullSwipe ? Math.max(reveal - actionsWidth, 0) : 0; - - return { - transform: [{ translateX: -stretch }], - width: circleSize + stretch, - }; - }); - const iconStyle = useAnimatedStyle(() => { + const contentStyle = useAnimatedStyle(() => { + if (!stretchesOnFullSwipe) return { transform: [{ translateX: 0 }] }; const reveal = Math.max(-translation.value, 0); - const stretch = stretchesOnFullSwipe ? Math.max(reveal - actionsWidth, 0) : 0; + const stretch = Math.max(reveal - actionsWidth, 0); const armedProgress = interpolate( reveal, [fullSwipeThreshold, fullSwipeThreshold + 20], [0, 1], Extrapolation.CLAMP, ); - - return { - transform: [{ translateX: -stretch * (0.5 + armedProgress * 0.5) }], - }; - }); - const labelStyle = useAnimatedStyle(() => { - if (!stretchesOnFullSwipe) { - return { opacity: 1 }; - } - - const reveal = Math.max(-translation.value, 0); - const stretch = Math.max(reveal - actionsWidth, 0); + // Keep the glyph near the visible center of the expanding lane, like Mail. return { - opacity: interpolate( - reveal, - [fullSwipeThreshold - 24, fullSwipeThreshold], - [1, 0], - Extrapolation.CLAMP, - ), - transform: [{ translateX: -stretch * 0.5 }], + transform: [{ translateX: -stretch * (0.5 + armedProgress * 0.15) }], }; }); @@ -618,69 +628,24 @@ function SwipeActionButton(props: { accessibilityLabel={props.accessibilityLabel} accessibilityRole="button" onPress={props.menu === undefined ? props.onPress : undefined} + className={swipeLaneClassName(props.tone)} style={({ pressed }) => ({ alignItems: "center", + backgroundColor: swipeLaneBackground(props.tone), height: "100%", justifyContent: "center", - opacity: pressed ? 0.72 : 1, + opacity: pressed ? 0.85 : 1, width: "100%", })} > - - + - - - - - - + {props.label} @@ -691,13 +656,11 @@ function SwipeActionButton(props: { {props.menu === undefined ? ( @@ -726,9 +689,11 @@ function ThreadSwipeActions(props: { readonly secondaryAction: ThreadSwipeSecondaryAction | null; readonly translation: SharedValue; }) { - const { fullSwipeThreshold, onFullSwipeArmedChange, secondaryAction, translation } = props; + const { compact, fullSwipeThreshold, onFullSwipeArmedChange, secondaryAction, translation } = + props; const fullSwipeIsPrimary = props.fullSwipeAction === "primary" || secondaryAction === null; - const actionsWidth = swipeActionsWidth(secondaryAction !== null); + const itemWidth = actionItemWidth(compact); + const actionsWidth = swipeActionsWidth(secondaryAction !== null, compact); useAnimatedReaction( () => -translation.value >= fullSwipeThreshold, (armed, previous) => { @@ -751,12 +716,11 @@ function ThreadSwipeActions(props: { Date: Sat, 7 Nov 2026 14:12:41 -0800 Subject: [PATCH 10/15] fix(mobile): stop filter chips from hard-cutting the inbox Liquid Glass blooms clip inside list headers and read as a background seam under the environment/project pills. Use solid chips on the list surface and hide the soft top scroll-edge on thread/PR lists. --- apps/mobile/src/features/home/HomeScreen.tsx | 7 ++++++- .../src/features/home/ThreadListFilters.tsx | 9 ++++++++- .../layout/workspace/ListFilterMenu.tsx | 4 +++- .../layout/workspace/WorkspaceRootScreen.tsx | 18 ++++++++++++++++-- .../pull-requests/PullRequestListFilters.tsx | 6 ++++++ apps/mobile/src/native/scrollEdgeEffects.ts | 2 +- 6 files changed, 40 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 768162f056e1f..8f4b8ff8af1c2 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -224,12 +224,17 @@ function HomeScrollView(props: ComponentProps) { if (Platform.OS !== "ios") return ; // Register the scroll view explicitly; layout and header siblings hide it from UIKit. + // Soft paints a hard-edged white band that bisects the glass filter chips on the phone + // list. Keep soft only for the iOS 26 sidebar fade; otherwise let the glass header alone + // handle the top edge so filters and rows share one continuous surface. + const topEdge = + sidebarSurface && Number.parseInt(String(Platform.Version), 10) < 27 ? "soft" : "hidden"; return ( = 27 ? "automatic" : "soft", + top: topEdge, }} > project.key === props.selectedProjectKey, ); + const showReset = props.selectedEnvironmentId !== null || props.selectedProjectKey !== null; + + // Solid chips (not Liquid Glass): glass blooms clip at the pill bounds inside a + // list header and read as a hard color cut. Opaque pills sit on the same continuous + // list surface as the rows below — matching Mail-style secondary controls. return ( - {props.selectedEnvironmentId !== null || props.selectedProjectKey !== null ? ( + {showReset ? ( void; }) { @@ -30,7 +32,7 @@ export function ListFilterMenu(props: { selected={props.active} icon={props.icon ? "line.3.horizontal.decrease" : undefined} trailingIcon={props.icon ? undefined : "chevron.down"} - glass + glass={props.glass !== false} colorScheme={themeAppearance} /> diff --git a/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.tsx index e8e7c0d325983..173745687cee0 100644 --- a/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.tsx @@ -5,13 +5,16 @@ import { WorkspaceSettingsList } from "../../settings/SettingsRouteScreen"; import { WorkspaceSectionToolbar } from "./WorkspaceSectionToolbar"; import { useNavigation } from "@react-navigation/native"; import { use } from "react"; -import { View, useWindowDimensions } from "react-native"; +import { Platform, View, useWindowDimensions } from "react-native"; import { useKeyboardState } from "react-native-keyboard-controller"; import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context"; import { CompactBrandTitle } from "../../../components/CompactBrandTitle"; import { HeaderItemsWidthContext } from "../../../native/header-items-width"; -import { NativeStackScreenOptions } from "../../../native/StackHeader"; +import { + NativeStackScreenOptions, + nativeHeaderScrollEdgeEffects, +} from "../../../native/StackHeader"; import { NativePrimaryColumnContext } from "../../../native/v5-workspace-context"; import { isDuoDevice } from "../../../lib/device-type"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../../native/native-glass"; @@ -146,6 +149,17 @@ function WorkspaceRootContent() { sidebarNavigation || (!settingsOpen && state.selectedTab !== "usage") ? renderHeaderTitle : undefined, + // Soft/automatic scroll-edge paints a hard band through the glass filter chips. + scrollEdgeEffects: NATIVE_LIQUID_GLASS_SUPPORTED + ? { + ...nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version), + top: + !settingsOpen && + (state.selectedTab === "threads" || state.selectedTab === "pullRequests") + ? "hidden" + : "automatic", + } + : undefined, }} /> diff --git a/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx b/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx index fbcb62406b7a7..be616463b9237 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx @@ -368,6 +368,7 @@ export function PullRequestListFilters(props: { > {Platform.OS === "android" ? null : ( 0} @@ -378,6 +379,7 @@ export function PullRequestListFilters(props: { )} {props.filters.environmentLabel !== null ? ( ) : null} 0} accessibilityLabel="Filter by label" @@ -406,6 +411,7 @@ export function PullRequestListFilters(props: { onSelect={select} /> Date: Sat, 7 Nov 2026 14:41:32 -0800 Subject: [PATCH 11/15] fix(mobile): Liquid Glass round thread swipe actions Replace full-height tinted Mail lanes with iOS 26-style circular glass pills. Keep circle size fixed so GlassView width animation cannot crash UIKit during the reveal. --- .../features/home/thread-swipe-actions.tsx | 197 ++++++++++++------ 1 file changed, 128 insertions(+), 69 deletions(-) diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 68ab5fb6afb98..b033b9358cee4 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -21,7 +21,7 @@ import type { StyleProp, ViewStyle, } from "react-native"; -import { Platform, PlatformColor, Pressable, View } from "react-native"; +import { Pressable, View } from "react-native"; import ReanimatedSwipeable, { type SwipeableMethods, } from "react-native-gesture-handler/ReanimatedSwipeable"; @@ -41,20 +41,23 @@ import Animated, { } from "react-native-reanimated"; import { AppText as Text } from "../../components/ui/AppText"; +import { GlassSurface } from "../../components/ui/GlassSurface"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { SwipeRowActivationContext, type SwipeRowActivation } from "./swipe-row-activation"; import { registerThreadDismissal } from "./thread-dismissal"; -// Mail-width trailing action lanes; compact shrinks for the sidebar column. -const ACTION_ITEM_WIDTH = 74; -const ACTION_ICON_SIZE = 18; -const COMPACT_ACTION_ITEM_WIDTH = 64; -const COMPACT_ACTION_ICON_SIZE = 16; +// iOS 26 Messages/Mail: round Liquid Glass action pills, not full-height tinted lanes. +const ACTION_ITEM_WIDTH = 58; +const ACTION_CIRCLE_SIZE = 40; +const ACTION_ICON_SIZE = 17; +const COMPACT_ACTION_CIRCLE_SIZE = 32; +const COMPACT_ACTION_ICON_SIZE = 14; const THREAD_SWIPE_SPRING = { - damping: 28, - mass: 0.8, + damping: 26, + mass: 0.7, overshootClamping: true, - stiffness: 300, + stiffness: 330, }; interface ThreadSwipeAction { @@ -74,13 +77,8 @@ interface ThreadSwipeSecondaryAction extends ThreadSwipeAction { readonly tone: "primary" | "secondary" | "danger"; } -function actionItemWidth(compact: boolean) { - return compact ? COMPACT_ACTION_ITEM_WIDTH : ACTION_ITEM_WIDTH; -} - -function swipeActionsWidth(hasSecondaryAction: boolean, compact = false) { - const width = actionItemWidth(compact); - return hasSecondaryAction ? width * 2 : width; +function swipeActionsWidth(hasSecondaryAction: boolean) { + return hasSecondaryAction ? ACTION_ITEM_WIDTH * 2 : ACTION_ITEM_WIDTH; } function resolveSecondaryAction(input: { @@ -244,7 +242,7 @@ export function ThreadSwipeable(props: ThreadSwipeableProps) { const swipeableRef = useRef(null); const compactActions = props.compactActions === true; const hasSecondaryAction = props.secondaryAction !== null; - const actionsWidth = swipeActionsWidth(hasSecondaryAction, compactActions); + const actionsWidth = swipeActionsWidth(hasSecondaryAction); const fullSwipeThreshold = Math.max(actionsWidth + 44, props.fullSwipeWidth * 0.58); const fullSwipeAction = props.fullSwipeAction ?? (props.secondaryAction === undefined ? "delete" : "primary"); @@ -526,39 +524,33 @@ export function ThreadSwipeable(props: ThreadSwipeableProps) { ); } -// Mail uses saturated system fills; theme tokens for secondary/danger are pastel chips. -function swipeLaneBackground(tone: "primary" | "secondary" | "danger"): ColorValue | undefined { - if (Platform.OS !== "ios") return undefined; - if (tone === "danger") return PlatformColor("systemRed"); - if (tone === "secondary") return PlatformColor("systemGray"); - return PlatformColor("systemBlue"); +function swipeGlassTintClassName(tone: "primary" | "secondary" | "danger") { + if (tone === "danger") return "accent-danger"; + if (tone === "secondary") return "accent-secondary"; + return "accent-primary"; +} + +function swipeIconTintClassName(tone: "primary" | "secondary" | "danger") { + if (tone === "danger") return "accent-danger"; + if (tone === "secondary") return "accent-icon-muted"; + return "accent-primary"; } -function swipeLaneClassName(tone: "primary" | "secondary" | "danger") { - if (Platform.OS === "ios") return undefined; +function swipeSolidFillClassName(tone: "primary" | "secondary" | "danger") { if (tone === "danger") return "bg-danger"; if (tone === "secondary") return "bg-secondary"; return "bg-primary"; } -function swipeLaneForegroundClassName(tone: "primary" | "secondary" | "danger") { - if (Platform.OS === "ios") return "accent-white"; +function swipeSolidIconTintClassName(tone: "primary" | "secondary" | "danger") { if (tone === "danger") return "accent-danger-foreground"; if (tone === "secondary") return "accent-secondary-foreground"; return "accent-primary-foreground"; } -function swipeLaneLabelClassName(tone: "primary" | "secondary" | "danger") { - if (Platform.OS === "ios") return "text-3xs font-t3-medium text-white"; - if (tone === "danger") return "text-3xs font-t3-medium text-danger-foreground"; - if (tone === "secondary") return "text-3xs font-t3-medium text-secondary-foreground"; - return "text-3xs font-t3-medium text-primary-foreground"; -} - function SwipeActionButton(props: { readonly accessibilityLabel: string; readonly actionsWidth: number; - readonly itemWidth: number; readonly tone: "primary" | "secondary" | "danger"; readonly compact: boolean; readonly entryRange: readonly [number, number]; @@ -574,14 +566,13 @@ function SwipeActionButton(props: { actionsWidth, entryRange: [entryRangeStart, entryRangeEnd], fullSwipeThreshold, - itemWidth, stretchesOnFullSwipe, translation, } = props; + const circleSize = props.compact ? COMPACT_ACTION_CIRCLE_SIZE : ACTION_CIRCLE_SIZE; const iconSize = props.compact ? COMPACT_ACTION_ICON_SIZE : ACTION_ICON_SIZE; - const foreground = swipeLaneForegroundClassName(props.tone); - // Mail lanes fill the row height; the committing lane grows left as the drag overshoots. - const laneStyle = useAnimatedStyle(() => { + const useGlass = NATIVE_LIQUID_GLASS_SUPPORTED; + const actionStyle = useAnimatedStyle(() => { const reveal = Math.max(-translation.value, 0); const entryProgress = interpolate( reveal, @@ -601,25 +592,45 @@ function SwipeActionButton(props: { opacity: stretchesOnFullSwipe ? entryProgress : entryProgress * (1 - fullSwipeProgress), transform: [ { - translateX: stretchesOnFullSwipe ? -stretch : -stretch * fullSwipeProgress, + translateX: + interpolate(entryProgress, [0, 1], [22, 0]) - (stretchesOnFullSwipe ? 0 : stretch), }, + { scale: interpolate(entryProgress, [0, 1], [0.78, 1]) }, ], - width: itemWidth + (stretchesOnFullSwipe ? stretch : 0), }; }); - const contentStyle = useAnimatedStyle(() => { - if (!stretchesOnFullSwipe) return { transform: [{ translateX: 0 }] }; + // Keep Liquid Glass circles fixed-size — animating GlassView width crashes UIKit. + // On a committing full-swipe the pill rides left with the drag and scales up slightly. + const circleStyle = useAnimatedStyle(() => { const reveal = Math.max(-translation.value, 0); - const stretch = Math.max(reveal - actionsWidth, 0); + const stretch = stretchesOnFullSwipe ? Math.max(reveal - actionsWidth, 0) : 0; const armedProgress = interpolate( reveal, [fullSwipeThreshold, fullSwipeThreshold + 20], [0, 1], Extrapolation.CLAMP, ); - // Keep the glyph near the visible center of the expanding lane, like Mail. + + return { + transform: [ + { translateX: -stretch * (0.55 + armedProgress * 0.2) }, + { scale: interpolate(armedProgress, [0, 1], [1, 1.08]) }, + ], + }; + }); + const labelStyle = useAnimatedStyle(() => { + if (!stretchesOnFullSwipe) { + return { opacity: 1 }; + } + + const reveal = Math.max(-translation.value, 0); return { - transform: [{ translateX: -stretch * (0.5 + armedProgress * 0.15) }], + opacity: interpolate( + reveal, + [fullSwipeThreshold - 24, fullSwipeThreshold], + [1, 0], + Extrapolation.CLAMP, + ), }; }); @@ -628,24 +639,72 @@ function SwipeActionButton(props: { accessibilityLabel={props.accessibilityLabel} accessibilityRole="button" onPress={props.menu === undefined ? props.onPress : undefined} - className={swipeLaneClassName(props.tone)} style={({ pressed }) => ({ alignItems: "center", - backgroundColor: swipeLaneBackground(props.tone), height: "100%", justifyContent: "center", - opacity: pressed ? 0.85 : 1, + opacity: pressed ? 0.78 : 1, width: "100%", })} > - - - + + {useGlass ? ( + + + + ) : ( + + + + )} + + + {props.label} @@ -656,11 +715,13 @@ function SwipeActionButton(props: { {props.menu === undefined ? ( @@ -689,11 +750,9 @@ function ThreadSwipeActions(props: { readonly secondaryAction: ThreadSwipeSecondaryAction | null; readonly translation: SharedValue; }) { - const { compact, fullSwipeThreshold, onFullSwipeArmedChange, secondaryAction, translation } = - props; + const { fullSwipeThreshold, onFullSwipeArmedChange, secondaryAction, translation } = props; const fullSwipeIsPrimary = props.fullSwipeAction === "primary" || secondaryAction === null; - const itemWidth = actionItemWidth(compact); - const actionsWidth = swipeActionsWidth(secondaryAction !== null, compact); + const actionsWidth = swipeActionsWidth(secondaryAction !== null); useAnimatedReaction( () => -translation.value >= fullSwipeThreshold, (armed, previous) => { @@ -716,11 +775,12 @@ function ThreadSwipeActions(props: { Date: Sat, 7 Nov 2026 15:32:00 -0800 Subject: [PATCH 12/15] fix(mobile): Messages-like glass swipe morph and shelf counts Morph Liquid Glass swipe pills into capsules on full swipe without animating GlassView width, harden swipe-row activation, and keep Mail shelf counts visible when expanded. --- apps/mobile/src/features/home/HomeScreen.tsx | 11 +- .../home/swipe-row-activation.test.ts | 7 + .../src/features/home/swipe-row-activation.ts | 20 +- .../features/home/thread-swipe-actions.tsx | 280 ++++++++++++------ .../features/threads/thread-list-v2-items.tsx | 2 +- 5 files changed, 221 insertions(+), 99 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 8f4b8ff8af1c2..b36cfaa02a3fd 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -339,7 +339,7 @@ export function HomeScreen(props: HomeScreenProps) { animated: false, }); }); - const swipeRowActivation = createSwipeRowActivation(); + const [swipeRowActivation] = useState(createSwipeRowActivation); const activateVisibleRows = (rows: ReadonlyArray) => { const state = listRef.current?.getState(); if (state === undefined || !(state.end >= 0)) return; @@ -364,17 +364,18 @@ export function HomeScreen(props: HomeScreenProps) { 200, ); }; + const { swipeEnabled, releaseGate, scrollGateHandlers } = useSwipeableScrollGate({ + onScroll: handleListScroll, + onScrollBeginDrag: handleScrollBeginDrag, + }); const trackListTouches = (event: GestureResponderEvent, started: boolean) => { const { changedTouches, touches } = event.nativeEvent; swipeRowActivation.trackTouches( started ? changedTouches.map((touch) => String(touch.identifier)) : [], touches.map((touch) => String(touch.identifier)), ); + if (!started && touches.length === 0) releaseGate(); }; - const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ - onScroll: handleListScroll, - onScrollBeginDrag: handleScrollBeginDrag, - }); const { projects, selectedEnvironmentId, projectGroupingMode } = props; const projectScopes = useMemo( diff --git a/apps/mobile/src/features/home/swipe-row-activation.test.ts b/apps/mobile/src/features/home/swipe-row-activation.test.ts index 8247f1941d9f4..5487b2caa11a8 100644 --- a/apps/mobile/src/features/home/swipe-row-activation.test.ts +++ b/apps/mobile/src/features/home/swipe-row-activation.test.ts @@ -3,9 +3,16 @@ import { describe, expect, it } from "vite-plus/test"; import { createSwipeRowActivation } from "./swipe-row-activation"; describe("createSwipeRowActivation", () => { + it("leaves rows live before the first activate so swipe works on first paint", () => { + const activation = createSwipeRowActivation(); + expect(activation.isPrimed()).toBe(false); + expect(activation.isActive("a")).toBe(false); + }); + it("defers changes while a finger is on the list so a press is never remounted", () => { const activation = createSwipeRowActivation(); activation.activate(["a"]); + expect(activation.isPrimed()).toBe(true); activation.trackTouches(["1"], ["1"]); activation.activate(["b"]); diff --git a/apps/mobile/src/features/home/swipe-row-activation.ts b/apps/mobile/src/features/home/swipe-row-activation.ts index 40da6157ec339..82cec2258c433 100644 --- a/apps/mobile/src/features/home/swipe-row-activation.ts +++ b/apps/mobile/src/features/home/swipe-row-activation.ts @@ -3,16 +3,27 @@ import { createContext, use, useSyncExternalStore } from "react"; /** * Only rows around the viewport enable swipe gestures and render actions. * Dormant rows keep the same child views so waking them preserves touches. + * Before the first activate(), no row is dormant so swipe works on first paint + * even if LegendList getState() is not ready yet. */ export function createSwipeRowActivation() { let activeKeys = new Set(); + let primed = false; // Turning a row's swipe gesture off ends a swipe in progress, so changes // wait until every finger that started on the list has lifted. const listTouches = new Set(); let pendingKeys: ReadonlyArray | null = null; const listeners = new Set<() => void>(); const apply = (keys: ReadonlyArray) => { - if (keys.length === activeKeys.size && keys.every((key) => activeKeys.has(key))) return; + const nextPrimed = true; + if ( + primed === nextPrimed && + keys.length === activeKeys.size && + keys.every((key) => activeKeys.has(key)) + ) { + return; + } + primed = nextPrimed; activeKeys = new Set(keys); for (const listener of listeners) listener(); }; @@ -22,6 +33,7 @@ export function createSwipeRowActivation() { return () => void listeners.delete(listener); }, isActive: (key: string) => activeKeys.has(key), + isPrimed: () => primed, activate(keys: ReadonlyArray) { if (listTouches.size > 0) pendingKeys = keys; else apply(keys); @@ -47,6 +59,10 @@ export function useSwipeRowDormant(key: string | undefined): boolean { const activation = use(SwipeRowActivationContext); return useSyncExternalStore( activation?.subscribe ?? subscribeNever, - () => activation !== null && key !== undefined && !activation.isActive(key), + () => + activation !== null && + key !== undefined && + activation.isPrimed() && + !activation.isActive(key), ); } diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index b033b9358cee4..d532632ed53a9 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -202,9 +202,16 @@ export function useSwipeableScrollGate(options?: { const onMomentumScrollEnd = useCallback(() => { update(false); }, [update]); + // Interrupted pans (automation, gesture handoff) can skip end-drag/momentum; lift clears the gate. + const releaseGate = useCallback(() => { + draggingRef.current = false; + clearSettle(); + update(false); + }, [clearSettle, update]); return { swipeEnabled: !gateActive, + releaseGate, scrollGateHandlers: { onScroll, onScrollBeginDrag, @@ -524,10 +531,12 @@ export function ThreadSwipeable(props: ThreadSwipeableProps) { ); } -function swipeGlassTintClassName(tone: "primary" | "secondary" | "danger") { - if (tone === "danger") return "accent-danger"; - if (tone === "secondary") return "accent-secondary"; - return "accent-primary"; +// Untinted Liquid Glass at rest (accent tint reads as a solid disc). Overdrag uses this soft +// fill instead — GlassView width animation crashes UIKit / paints a thin line, not a capsule. +function swipeCapsuleFillColor(tone: "primary" | "secondary" | "danger"): string { + if (tone === "danger") return "rgba(255, 59, 48, 0.34)"; + if (tone === "primary") return "rgba(0, 122, 255, 0.3)"; + return "rgba(120, 120, 128, 0.3)"; } function swipeIconTintClassName(tone: "primary" | "secondary" | "danger") { @@ -572,6 +581,7 @@ function SwipeActionButton(props: { const circleSize = props.compact ? COMPACT_ACTION_CIRCLE_SIZE : ACTION_CIRCLE_SIZE; const iconSize = props.compact ? COMPACT_ACTION_ICON_SIZE : ACTION_ICON_SIZE; const useGlass = NATIVE_LIQUID_GLASS_SUPPORTED; + const capsuleFill = swipeCapsuleFillColor(props.tone); const actionStyle = useAnimatedStyle(() => { const reveal = Math.max(-translation.value, 0); const entryProgress = interpolate( @@ -581,15 +591,16 @@ function SwipeActionButton(props: { Extrapolation.CLAMP, ); const stretch = Math.max(reveal - actionsWidth, 0); - const fullSwipeProgress = interpolate( - reveal, - [actionsWidth, fullSwipeThreshold + 20], + // Secondary yields as soon as the committing pill starts claiming overdrag. + const stretchFade = interpolate( + stretch, + [0, ACTION_ITEM_WIDTH * 0.7], [0, 1], Extrapolation.CLAMP, ); return { - opacity: stretchesOnFullSwipe ? entryProgress : entryProgress * (1 - fullSwipeProgress), + opacity: stretchesOnFullSwipe ? entryProgress : entryProgress * (1 - stretchFade), transform: [ { translateX: @@ -599,9 +610,33 @@ function SwipeActionButton(props: { ], }; }); - // Keep Liquid Glass circles fixed-size — animating GlassView width crashes UIKit. - // On a committing full-swipe the pill rides left with the drag and scales up slightly. - const circleStyle = useAnimatedStyle(() => { + // Messages morph: RN View width only. Glass stays fixed-size and fades once stretch begins. + const pillStyle = useAnimatedStyle(() => { + const reveal = Math.max(-translation.value, 0); + const stretch = stretchesOnFullSwipe ? Math.max(reveal - actionsWidth, 0) : 0; + + return { + transform: [{ translateX: -stretch }], + width: circleSize + stretch, + }; + }); + const glassRestStyle = useAnimatedStyle(() => { + if (!stretchesOnFullSwipe) return { opacity: 1 }; + const reveal = Math.max(-translation.value, 0); + const stretch = Math.max(reveal - actionsWidth, 0); + return { + opacity: interpolate(stretch, [0, 14], [1, 0], Extrapolation.CLAMP), + }; + }); + const capsuleFillStyle = useAnimatedStyle(() => { + if (!stretchesOnFullSwipe) return { opacity: useGlass ? 0 : 1 }; + const reveal = Math.max(-translation.value, 0); + const stretch = Math.max(reveal - actionsWidth, 0); + return { + opacity: useGlass ? interpolate(stretch, [0, 14], [0, 1], Extrapolation.CLAMP) : 1, + }; + }); + const iconStyle = useAnimatedStyle(() => { const reveal = Math.max(-translation.value, 0); const stretch = stretchesOnFullSwipe ? Math.max(reveal - actionsWidth, 0) : 0; const armedProgress = interpolate( @@ -612,10 +647,7 @@ function SwipeActionButton(props: { ); return { - transform: [ - { translateX: -stretch * (0.55 + armedProgress * 0.2) }, - { scale: interpolate(armedProgress, [0, 1], [1, 1.08]) }, - ], + transform: [{ translateX: -stretch * (0.5 + armedProgress * 0.2) }], }; }); const labelStyle = useAnimatedStyle(() => { @@ -624,6 +656,7 @@ function SwipeActionButton(props: { } const reveal = Math.max(-translation.value, 0); + const stretch = Math.max(reveal - actionsWidth, 0); return { opacity: interpolate( reveal, @@ -631,6 +664,7 @@ function SwipeActionButton(props: { [1, 0], Extrapolation.CLAMP, ), + transform: [{ translateX: -stretch * 0.45 }], }; }); @@ -647,57 +681,94 @@ function SwipeActionButton(props: { width: "100%", })} > - - {useGlass ? ( - + - + {useGlass ? ( + <> + + + + {null} + + + + ) : ( + - - ) : ( - + - - - )} - + }, + iconStyle, + ]} + > + + + + ); + const secondaryButton = + secondaryAction === null ? null : ( + + ); + return ( - - {secondaryAction === null ? null : ( - + {fullSwipeIsPrimary ? ( + <> + {secondaryButton} + {primaryButton} + + ) : ( + <> + {primaryButton} + {secondaryButton} + )} ); 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 8707e4e02bbbb..820232174392f 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -199,7 +199,7 @@ function ThreadListV2Section(props: { }; }) { const sidebarPane = props.pane === "sidebar"; - const showCount = props.count !== undefined && props.disclosure?.expanded !== true; + const showCount = props.count !== undefined; const labelClassName = cn( "text-[15px] font-t3-semibold", sidebarPane ? "text-drawer-foreground" : "text-foreground", From a192acf63760b2731158250080f78ab301448fe1 Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Sat, 7 Nov 2026 15:33:54 -0800 Subject: [PATCH 13/15] fix(mobile): align Mail list tests after rebase onto main Use settled for swipe-action resolution and drop the duplicated queued-settled expectation that still expected slim rows. --- .../src/features/threads/threadListV2.test.ts | 31 +------------------ 1 file changed, 1 insertion(+), 30 deletions(-) diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index fc9fb902123a8..b28e9de56ab8c 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -225,7 +225,7 @@ describe("resolveThreadListV2SwipeActions", () => { expect(thread.pendingUserInputAt).toBe(NOW); expect( resolveThreadListV2SwipeActions({ - variant: "card", + settled: false, settlementSupported: true, snoozeSupported: true, snoozable: canSnooze(thread, { now: NOW }), @@ -1941,32 +1941,3 @@ describe("Working section beta", () => { ]); }); }); - -describe("queued messages keep a settled thread active", () => { - const threads = [ - makeThread({ id: ThreadId.make("active"), title: "Active" }), - makeThread({ id: ThreadId.make("settled"), title: "Settled", settledOverride: "settled" }), - makeThread({ - id: ThreadId.make("settled-queued"), - title: "Settled with outbox", - settledOverride: "settled", - }), - ]; - const queuedThreadKeys = new Set([`${environmentId}:settled-queued`]); - - it("lists the thread in the active block instead of the settled shelf", () => { - const layout = buildThreadListV2Items({ - threads, - environmentId: null, - searchQuery: "", - now: NOW, - queuedThreadKeys, - }); - expect(layout.items.map((item) => [item.thread.id, item.variant] as const)).toEqual([ - ["active", "card"], - ["settled-queued", "card"], - ["settled", "slim"], - ]); - expect(layout.settledCount).toBe(1); - }); -}); From 723dad13db26fe2541574d8751ea6eab2a26697d Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Sun, 8 Nov 2026 09:08:10 -0800 Subject: [PATCH 14/15] fix(mobile): hide All Caught Up under actionable pinned rows Pinned threads sit outside activeCount, so an approval or input pin was treated as inbox-zero and showed the catch-up placeholder. --- .../src/features/threads/threadListV2.test.ts | 34 +++++++++++++++++++ .../src/features/threads/threadListV2.ts | 10 +++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index b28e9de56ab8c..89a0e75029b83 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -986,6 +986,40 @@ describe("buildThreadListV2ListItems", () => { ]); }); + it("hides all-caught-up when a pinned thread still needs a response", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ + id: ThreadId.make("pinned-approval"), + title: "pinned-approval", + pinnedAt: NOW, + hasPendingApprovals: true, + }), + makeThread({ + id: ThreadId.make("settled"), + title: "settled", + settledOverride: "settled", + settledAt: NOW, + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + }); + expect(layout.activeCount).toBe(0); + const items = buildThreadListV2ListItems({ + items: layout.items, + pendingTasks: [], + activeCount: layout.activeCount, + settledCount: layout.settledCount, + settledShelfExpanded: true, + settledShelfHeaderIndex: layout.settledShelfHeaderIndex, + }); + expect( + items.map((item) => (item.type === "v2-thread" ? item.item.thread.id : item.type)), + ).toEqual(["pinned-approval", "v2-settled-shelf", "settled"]); + }); + it("places queued tasks above active rows and a collapsed snoozed shelf", () => { const snoozedLayout = buildThreadListV2Items({ threads: [ diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 06d682944d435..627bc1ca07666 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -601,12 +601,20 @@ export function buildThreadListV2ListItems(input: { const snoozedEnd = settledShelfHeaderIndex ?? threadItems.length; const workingEnd = snoozedShelfHeaderIndex ?? snoozedEnd; const activeEnd = workingShelfHeaderIndex ?? workingEnd; - const result: ThreadListV2ListItem[] = [...pendingItems, ...threadItems.slice(0, activeEnd)]; + const attentionItems = threadItems.slice(0, activeEnd); + const result: ThreadListV2ListItem[] = [...pendingItems, ...attentionItems]; const shelfDisabled = input.shelfPreferencesLoading === true; + // Pinned rows are outside activeCount; an approval/input/request pin still needs a response. + const actionablePinned = input.items.slice(0, activeEnd).some((item) => { + if (!item.pinned) return false; + const status = resolveThreadListV2Status(item.thread); + return status === "approval" || status === "input" || status === "request"; + }); // Inbox zero: attention queue is clear, but Working/Snoozed/Settled still exist. if ( activeCount === 0 && pendingItems.length === 0 && + !actionablePinned && workingCount + snoozedCount + settledCount > 0 ) { result.push({ type: "v2-caught-up", key: "v2-caught-up" }); From b4b8a9ee640cf47cb999abdd5ebf5e2f888b1ebd Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Mon, 9 Nov 2026 10:19:32 -0800 Subject: [PATCH 15/15] fix(mobile): cover swipe primary and announce Mail previews Assert settle/unsettle primary selection in the existing swipe test, and include the visible message preview in the row accessibility label. Co-authored-by: Cursor --- .../src/features/threads/thread-list-v2-items.tsx | 5 +++++ .../mobile/src/features/threads/threadListV2.test.ts | 12 ++++++++++-- 2 files changed, 15 insertions(+), 2 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 820232174392f..7224b7955cf12 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -800,6 +800,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { wakeLabel == null && !showsWoke && statusLabel === undefined && props.timeLabel ? props.timeLabel : null, + messagePreview + ? messagePreview.speaker === "you" + ? `You: ${messagePreview.text}` + : messagePreview.text + : null, providerInstance?.displayName ?? null, props.hasQueuedMessages ? "messages queued to send" : null, isRegeneratingTitle ? "regenerating title" : null, diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 89a0e75029b83..1428d3e20fcb7 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -229,8 +229,16 @@ describe("resolveThreadListV2SwipeActions", () => { settlementSupported: true, snoozeSupported: true, snoozable: canSnooze(thread, { now: NOW }), - }).secondary, - ).toBe("snooze"); + }), + ).toEqual({ primary: "settle", secondary: "snooze" }); + expect( + resolveThreadListV2SwipeActions({ + settled: true, + settlementSupported: true, + snoozeSupported: true, + snoozable: canSnooze(thread, { now: NOW }), + }), + ).toEqual({ primary: "unsettle", secondary: "snooze" }); const layout = buildThreadListV2Items({ threads: [{ ...thread, snoozedAt: NOW, snoozedUntil: "2026-06-03T00:00:00.000Z" }],