From 02a072ce4b497e768f34af1c7aebcf66bd768bfb Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:29:01 +0000 Subject: [PATCH] feat(mobile): render settled threads as slim history rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The classic mobile thread lists (phone Home and the iPad navigation sidebar) rendered settled threads with the same three-line chrome as active work — favicon, title, status pill, branch/server subtitle, PR badge, chevron — so the "Settled" shelf read as more inbox rather than history. Web has had a distinct slim settled row in both list modes (Sidebar.tsx settled shelf, SidebarV2 slim variant), and mobile's own Thread List v2 settled tail already ships one; only the classic mobile path was missing it. Settled rows now collapse to a single dimmed line — dimmed favicon, muted one-line title, draft dot, trailing settle time — in both the compact and sidebar variants, matching thread-list-v2-items.tsx. The row keeps its tap target (44pt), swipe actions, and long-press settle/unsettle menu. The trailing label uses the settle stamp when the server recorded one and falls back to last activity, so the shelf reads in the order it is sorted (resolveSettledRowTimestamp mirrors web's resolveSettledTimestamp). Settled rows lead with a favicon only where the row already carries project context (recency / flat / Needs attention); project-grouped lists already show one in the group header. --- .../features/threads/thread-list-items.tsx | 117 +++++++++++++++++- .../threads/threadPresentation.test.ts | 48 +++++++ .../features/threads/threadPresentation.ts | 15 +++ .../mobile/src/mobileSurfaceExistence.test.ts | 16 +++ 4 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/src/features/threads/threadPresentation.test.ts diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 63ed299eeed1..e9120ff460ac 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -7,7 +7,7 @@ import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state import type { MenuAction } from "@react-native-menu/menu"; import { SymbolView } from "../../components/AppSymbol"; import { memo, useCallback, useMemo, type ComponentProps } from "react"; -import { Pressable, useColorScheme, useWindowDimensions, View } from "react-native"; +import { Platform, Pressable, useColorScheme, useWindowDimensions, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import Svg, { Circle, Path } from "react-native-svg"; @@ -28,7 +28,7 @@ import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr"; import { composerDraftsAtom, hasComposerDraftMessage } from "../../state/use-composer-drafts"; import type { HomeGroupDisplayAction } from "../home/homeListItems"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; -import { resolveThreadStatus } from "./threadPresentation"; +import { resolveSettledRowTimestamp, resolveThreadStatus } from "./threadPresentation"; import { ThreadSearchMatchExcerpt } from "./thread-search-match"; import { hasUsageMarker, @@ -49,6 +49,12 @@ export type ThreadListVariant = "compact" | "sidebar"; export const THREAD_LIST_COMPACT_INSET = HOME_HORIZONTAL_INSET; const SIDEBAR_ROW_RADIUS = 12; +const MONO_FONT = Platform.select({ + ios: "Menlo", + android: "monospace", + default: "monospace", +}); + function pullRequestTintColor( state: ThreadPr["state"], colorScheme: ReturnType, @@ -551,6 +557,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const timestamp = relativeTime( thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, ); + // Settled rows label by when the work ENDED, matching the shelf sort. + const settledTimestamp = relativeTime(resolveSettledRowTimestamp(thread)); const threadAccessibilityLabel = pr ? `${thread.title}, ${pr.accessibilityLabel}` : thread.title; const subtitleParts = [props.projectTitle, props.environmentLabel, thread.branch].filter( (part): part is string => Boolean(part), @@ -654,8 +662,111 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ) : null; + // Project-grouped lists already show a favicon in the group header, so the + // slim row only leads with one where it also carries project context + // (recency / flat / Needs attention) — the same rule the subtitle uses. + const showSettledFavicon = Boolean(props.projectTitle) && props.projectCwd !== null; + + /** + * Settled threads are history, not inbox: they collapse to a single dimmed + * line so the active work above stays scannable. Status pill, subtitle, + * PR badge, provider icon and chevron all drop — a settled row is a title, + * a time, and a way back in. Matches the Thread List v2 settled tail + * (thread-list-v2-items.tsx) and web's settled shelf (Sidebar.tsx), so + * settled history reads the same in every list mode on every client. + */ + const settledRowContent = (close: () => void) => ( + setHovered(true)} + onHoverOut={compact ? undefined : () => setHovered(false)} + onPressIn={() => { + prefetchEnvironmentThread(thread.environmentId, thread.id); + }} + onPress={() => { + close(); + onSelectThread(thread); + }} + style={ + compact + ? ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 }) + : ({ pressed }) => ({ + backgroundColor: selected + ? selectedBackgroundColor + : pressed || hovered + ? effectivePressedBackground + : backgroundColor, + borderRadius: SIDEBAR_ROW_RADIUS, + cursor: "pointer", + }) + } + > + + {showSettledFavicon ? ( + + + + ) : null} + + + + {thread.title} + + {hasDraft ? ( + + ) : null} + + {props.searchMatch ? ( + + ) : null} + + + {settledTimestamp} + + + + ); + const rowContent = (close: () => void) => - compact ? ( + isSettled ? ( + settledRowContent(close) + ) : compact ? ( { + it("prefers the explicit settle stamp", () => { + expect( + resolveSettledRowTimestamp({ + ...base, + settledAt: "2026-02-02T00:00:00.000Z", + latestUserMessageAt: "2026-01-15T00:00:00.000Z", + }), + ).toBe("2026-02-02T00:00:00.000Z"); + }); + + it("falls back to last user activity for auto-settled threads", () => { + expect( + resolveSettledRowTimestamp({ ...base, latestUserMessageAt: "2026-01-15T00:00:00.000Z" }), + ).toBe("2026-01-15T00:00:00.000Z"); + }); + + it("falls back to updatedAt when the thread has no user message", () => { + expect(resolveSettledRowTimestamp(base)).toBe("2026-01-01T00:00:00.000Z"); + }); + + it("orders rows the same way the settled shelf sorts them", () => { + // The shelf sorts by settledAt ?? latestUserMessageAt ?? updatedAt, so a + // freshly settled old thread must label ahead of a stale newer one. + const settledRecently = { + ...base, + settledAt: "2026-03-01T00:00:00.000Z", + latestUserMessageAt: "2025-06-01T00:00:00.000Z", + }; + const touchedRecently = { ...base, latestUserMessageAt: "2026-02-01T00:00:00.000Z" }; + + expect( + Date.parse(resolveSettledRowTimestamp(settledRecently)) > + Date.parse(resolveSettledRowTimestamp(touchedRecently)), + ).toBe(true); + }); +}); diff --git a/apps/mobile/src/features/threads/threadPresentation.ts b/apps/mobile/src/features/threads/threadPresentation.ts index ab1b293a2dd1..39b2c72726f0 100644 --- a/apps/mobile/src/features/threads/threadPresentation.ts +++ b/apps/mobile/src/features/threads/threadPresentation.ts @@ -6,6 +6,21 @@ export function threadSortValue(thread: EnvironmentThreadShell): number { return Number.isNaN(candidate) ? 0 : candidate; } +/** + * The timestamp a settled row labels by: the settle stamp when the server + * recorded one (explicit settles), otherwise last activity. Mirrors the + * settled-shelf sort in HomeScreen / ThreadNavigationSidebar (and web's + * `resolveSettledTimestamp`) so a shelf reads in the order it is sorted. + */ +export function resolveSettledRowTimestamp( + thread: Pick< + EnvironmentThreadShell, + "settledAt" | "latestUserMessageAt" | "updatedAt" | "createdAt" + >, +): string { + return thread.settledAt ?? thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt; +} + export type ThreadStatusKind = | "pending-approval" | "awaiting-input" diff --git a/apps/mobile/src/mobileSurfaceExistence.test.ts b/apps/mobile/src/mobileSurfaceExistence.test.ts index 6380e5aea1c6..a202eaa410d3 100644 --- a/apps/mobile/src/mobileSurfaceExistence.test.ts +++ b/apps/mobile/src/mobileSurfaceExistence.test.ts @@ -27,6 +27,22 @@ describe("mobile surface existence (anti stack-drop)", () => { ); }); + it("renders settled threads as slim history rows in the classic thread lists", () => { + const listItems = readSrc("features/threads/thread-list-items.tsx"); + + // The settled branch must stay wired into the shared row renderer: a + // whole-file conflict resolve that keeps the helper but drops the branch + // would silently restore full-size settled rows. + expect(listItems).toContain('testID="thread-list-row-settled"'); + expect(listItems).toMatch(/isSettled \? \(\s*settledRowContent\(close\)/); + expect(listItems).toContain("resolveSettledRowTimestamp"); + // Slim chrome: dimmed favicon, one muted title line, no status pill. + expect(listItems).toMatch( + /testID="thread-list-row-settled"[\s\S]*?text-foreground-muted[\s\S]*?<\/Pressable>/, + ); + expect(listItems).toMatch(/settledRowContent[\s\S]*?opacity-40[\s\S]*?ProjectFavicon/); + }); + it("keys markdown nodes uniquely even when parser spans collide", () => { const nodeKey = NodeFS.readFileSync( NodePath.join(root, "../modules/t3-markdown-text/src/markdownNodeKey.ts"),