From ddf7f0e7996e0d5c072f74805fcdaa309e5c27c7 Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Wed, 4 Nov 2026 17:49:24 -0800 Subject: [PATCH 1/4] 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 | 623 ++++++++---------- .../thread-list-v2-row-appearance.android.ts | 3 +- .../threads/thread-list-v2-row-appearance.ts | 4 +- .../src/features/threads/threadListV2.test.ts | 93 ++- .../src/features/threads/threadListV2.ts | 46 +- 7 files changed, 399 insertions(+), 376 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 4ed447eb69f2..38e8153bd6ee 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 dce389c83666..a68e3f6ca1be 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 77250f85a74c..343dca3f1af7 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, @@ -134,13 +134,6 @@ const STATUS_LABEL_BY_STATUS: Partial> = iconTintClassName: "accent-warning-foreground", }, }; -const DONE_STATUS_LABEL: StatusLabel = { - label: "Done", - icon: "checkmark.circle", - className: "text-adaptive-emerald-700-300", - iconTintClassName: "accent-adaptive-emerald-700-300", -}; - // DM Sans has no tabular figures, so a ticking timer in it reflows the row every second. const TABULAR_DURATION_STYLE = { fontFamily: Platform.OS === "ios" ? "System" : "sans-serif", @@ -697,9 +690,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 +699,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { Platform.OS === "android" ? androidTitleLineHeight : titleTypography.lineHeight * fontScale; const { thread, - variant, onSelectThread, onOpenThreadPullRequests, onDeleteThread, @@ -730,19 +720,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(() => { @@ -759,13 +736,27 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const workingLabel = STATUS_LABEL_BY_STATUS[status]; const showsWoke = status === "ready" && props.wokeAt !== null; const statusLabel = - (status === "working" && workingLabel !== undefined && thread.goal?.status === "active" + status === "working" && workingLabel !== undefined && thread.goal?.status === "active" ? { ...workingLabel, label: "Goal" } - : workingLabel) ?? (isUnread ? DONE_STATUS_LABEL : undefined); + : workingLabel; 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 +807,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 +818,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 }), @@ -1142,249 +1133,273 @@ 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} - - - ) : ( - - )} - {pr ? ( - undefined} - style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} - > - - - {pr.label} + {messagePreview.text} - - ) : 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 +1408,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 +1456,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 +1466,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 c63d327764cb..b07b63a82bae 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 96bf3d43682b..2bebe3007a13 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 786f1ce22911..e048aacbe9fc 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, @@ -211,6 +212,78 @@ describe("resolveThreadListV2Status", () => { ); }); +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("resolveThreadListV2SwipeActions", () => { it("offers snooze for a presented input request and moves it onto the snoozed shelf", () => { const raw = makeRawThreadShell({ @@ -224,7 +297,7 @@ describe("resolveThreadListV2SwipeActions", () => { expect(thread.pendingUserInputAt).toBe(NOW); expect( resolveThreadListV2SwipeActions({ - variant: "card", + settled: false, settlementSupported: true, snoozeSupported: true, snoozable: canSnooze(thread, { now: NOW }), @@ -262,7 +335,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 +565,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 +589,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 +616,7 @@ describe("buildThreadListV2Items", () => { expect(items.map((item) => [item.thread.id, item.variant])).toEqual([ ["match", "card"], - ["settled", "slim"], + ["settled", "card"], ]); }); @@ -619,7 +692,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 +771,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}`); } @@ -1821,7 +1896,7 @@ describe("queued messages keep a settled thread active", () => { expect(layout.items.map((item) => [item.thread.id, item.variant] as const)).toEqual([ ["active", "card"], ["settled-queued", "card"], - ["settled", "slim"], + ["settled", "card"], ]); expect(layout.settledCount).toBe(1); }); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 3245c1c281e0..03332c6d8cfb 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 e46f28734d4410254995521289aa67dac3eb1126 Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Wed, 4 Nov 2026 20:01:48 -0800 Subject: [PATCH 2/4] 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 343dca3f1af7..ddccc4484b83 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, @@ -720,6 +722,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(() => { @@ -749,9 +763,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, ] @@ -1268,6 +1288,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 3/4] 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 | 380 +++++++++--------- 1 file changed, 192 insertions(+), 188 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 ddccc4484b83..3a8b601dc46d 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -1157,137 +1157,151 @@ 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( @@ -1311,16 +1325,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { /> ) : null} - ); const cardContent = ( @@ -1352,11 +1356,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 f1ca07c407e112e24cac52083b4d17d3373c992c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 5 Nov 2026 20:46:03 +0000 Subject: [PATCH 4/4] 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 3a8b601dc46d..1d7c05591279 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, @@ -1483,7 +1483,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 b07b63a82bae..f47df5be02f9 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 2bebe3007a13..1ff93ceef051 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,