From bb98762929cfef43c4a06ff7001a3b8a473df5f6 Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Sat, 14 Nov 2026 15:34:32 -0800 Subject: [PATCH] feat(mobile): Mail-style thread list rows on iOS and Android Reopened from pingdotgg/t3code#31232 and squashed onto current main. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/features/home/HomeScreen.tsx | 2 +- .../threads/ThreadNavigationSidebar.tsx | 4 +- .../features/threads/thread-list-v2-items.tsx | 659 +++++++++--------- .../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, 434 insertions(+), 377 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 8110cca26793d..4a28c957c86a5 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -152,7 +152,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 6aa576f984fce..f98bc984c312a 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -609,7 +609,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} @@ -666,7 +666,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 69d0c84ecaa8d..4b61e22bb7f9b 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, THREAD_LIST_V2_ROW_DIVIDERS, selectedThreadRowColors, getThreadListV2NewBranchMenuTitle, @@ -75,13 +76,14 @@ import { canTurnThreadIntoProject, turnThreadIntoProject } from "./turnThreadInt import { threadCanArchive } from "../home/threadArchive"; import { THREAD_LIST_V2_SETTLED_PAGE_COUNT, + resolveThreadListMessagePreview, + resolveThreadListV2ProviderDrivers, resolveThreadListV2SnoozeGateExpiryMs, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozePresets, threadHasUnseenCompletion, resolveThreadListV2RowStatus, resolveThreadListV2Status, - resolveThreadListV2ProviderDrivers, resolveThreadListV2SwipeActions, layoutThreadListV2RowMenuActions, type ThreadListV2Status, @@ -144,13 +146,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", @@ -708,9 +703,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, @@ -719,7 +712,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { Platform.OS === "android" ? androidTitleLineHeight : titleTypography.lineHeight * fontScale; const { thread, - variant, onSelectThread, onOpenThreadPullRequests, onDeleteThread, @@ -752,7 +744,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { providerIconUrl: provider?.iconUrl, }; }, [thread, props.providers]); - const providerInstance = props.providerInstance; const providerNameLabel = providerInstance ? providerInstanceNameLabel({ @@ -779,13 +770,33 @@ 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}${providerNameLabel ? `, ${providerNameLabel}` : ""}${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, + 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, + providerNameLabel, + props.hasQueuedMessages ? "messages queued to send" : null, + isRegeneratingTitle ? "regenerating title" : null, + ] + .filter(Boolean) + .join(", "); const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); const handleAcknowledgeWoke = useCallback(() => { @@ -836,7 +847,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. @@ -847,7 +858,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 }), @@ -1162,195 +1173,122 @@ 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 ? ( - - - + const runtimeError = + (rawStatus === "failed" || rawStatus === "limited") && thread.runtime?.lastError + ? thread.runtime.lastError + : null; + const trailingMeta = ( + + + {props.hasQueuedMessages ? : null} + {pinnedRow ? ( + ) : null} - { - const firstLine = nativeEvent.lines[0]; - if (firstLine && Math.abs(firstLine.height - titleLineHeight) > 0.5) { - setTitleLineHeight(firstLine.height); - } - } - : undefined - } - > - {thread.title} - - - {props.hasQueuedMessages ? : null} - {pinnedRow ? ( + {showsWoke ? ( + undefined} + style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} + > - ) : null} - {showsWoke ? ( - undefined} - style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} + - - - Woke - - - ) : statusLabel ? ( - - - - {statusLabel.label} - {status === "working" ? ( - - ) : status === "waiting" ? ( - - ) : null} - - - ) : ( + Woke + + + ) : wakeLabel != null ? ( + + - {timeLabel} + {wakeLabel} - )} - - - {props.searchMatch ? ( - - - - ) : null} - - {(rawStatus === "failed" || rawStatus === "limited") && thread.runtime?.lastError ? ( - - {thread.runtime.lastError} - - ) : props.environmentLabel ? ( - - {props.environmentMachine ? ( - - ) : null} + + ) : statusLabel ? ( + + - {props.environmentLabel} + {statusLabel.label} + {status === "working" ? ( + + ) : status === "waiting" ? ( + + ) : null} ) : ( - + + {timeLabel} + )} {pr ? ( undefined} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} > @@ -1373,186 +1311,223 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { : "accent-adaptive-violet-600-400" } /> - - {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 */, - )} - - {providerNameLabel ? ( - - {providerNameLabel} - - ) : null} - - ) : null} + - - ); - - const fadedCardContent = recede ? ( - {cardContent} - ) : ( - cardContent + {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 */, + )} + + {providerNameLabel ? ( + + {providerNameLabel} + + ) : null} + + ) : null} + ); - - 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 + const cardContent = ( + + + {isUnread ? : null} + + + {props.project ? ( + ) : ( - - {fadedCardContent} - {THREAD_LIST_V2_ROW_DIVIDERS && props.showTrailingDivider !== false ? ( - - ) : null} + + )} - - ) : ( - { - close(); - selectThread(); - }} - style={rowAppearance.style} - > - - {props.project ? ( - - - - ) : null} - + + + + { + const firstLine = nativeEvent.lines[0]; + if (firstLine && Math.abs(firstLine.height - titleLineHeight) > 0.5) { + setTitleLineHeight(firstLine.height); + } + } + : undefined + } + > + {thread.title} + + {contextLabel != null ? ( - {thread.title} + {contextLabel} - {providerNameLabel ? ( + ) : null} + {props.searchMatch ? ( + + ) : runtimeError ? ( + + {runtimeError} + + ) : messagePreview ? ( + + {messagePreview.speaker === "you" ? ( + + ) : null} - {providerNameLabel} + {messagePreview.text} - ) : null} - {props.searchMatch ? ( - - ) : null} - - {props.hasQueuedMessages ? : null} - - {snoozedRow && props.snoozeWakeLabelText !== undefined - ? props.snoozeWakeLabelText - : timeLabel} - + + ) : null} - - ); + {trailingMeta} + + + ); + + const fadedCardContent = recede ? ( + {cardContent} + ) : ( + cardContent + ); + + 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} + + )} + + ); if (!canOperateThread) return {rowContent(() => {})}; @@ -1566,7 +1541,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" @@ -1576,7 +1551,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..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 @@ -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 = 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 96bf3d43682b4..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 @@ -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; +/** 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, diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index aa47594ddd618..4bea44c0735c6 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, resolveThreadListV2RowStatus, resolveThreadListV2Status, resolveThreadListV2WokeAt, @@ -227,6 +228,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({ @@ -240,7 +313,7 @@ describe("resolveThreadListV2SwipeActions", () => { expect(thread.pendingUserInputAt).toBe(NOW); expect( resolveThreadListV2SwipeActions({ - variant: "card", + settled: false, settlementSupported: true, snoozeSupported: true, snoozable: canSnooze(thread, { now: NOW }), @@ -278,7 +351,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"], ]); @@ -497,7 +570,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" }), @@ -521,8 +594,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); @@ -548,7 +621,7 @@ describe("buildThreadListV2Items", () => { expect(items.map((item) => [item.thread.id, item.variant])).toEqual([ ["match", "card"], - ["settled", "slim"], + ["settled", "card"], ]); }); @@ -624,7 +697,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", @@ -701,7 +776,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}`); } @@ -1826,7 +1901,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 f4dd34cefd5c0..56bdce2006686 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -116,7 +116,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; @@ -128,11 +128,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, @@ -246,6 +242,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 }, @@ -516,17 +525,12 @@ function resolveThreadListV2ItemTimeLabel( item: ThreadListV2Item, showSnoozeWakeLabel: boolean, ): string { - const { thread, variant, snoozed } = item; + const { thread, snoozed } = item; if (showSnoozeWakeLabel) return ""; - if ( - variant === "card" && - (resolveThreadListV2RowStatus(thread) !== "ready" || - item.wokeAt !== null || - threadHasUnseenCompletion(thread)) - ) - return ""; + // Status badges and Woke own the trailing slot; unread-ready rows still keep a date. + if (resolveThreadListV2RowStatus(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, ); @@ -565,7 +569,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 { @@ -777,7 +781,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( @@ -794,10 +798,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, @@ -899,7 +903,7 @@ export function buildThreadListV2Items(input: { for (const thread of visibleSnoozed) { items.push({ thread, - variant: "slim", + variant: "card", snoozed: true, pinned: false, wokeAt: null, @@ -910,7 +914,7 @@ export function buildThreadListV2Items(input: { for (const thread of visibleSettled) { items.push({ thread, - variant: "slim", + variant: "card", snoozed: false, pinned: false, wokeAt: null,