Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 114 additions & 3 deletions apps/mobile/src/features/threads/thread-list-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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,
Expand All @@ -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<typeof useColorScheme>,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -654,8 +662,111 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
</View>
) : 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) => (
<Pressable
accessibilityHint="Opens the settled thread. Swipe left for archive and delete actions."
accessibilityLabel={threadAccessibilityLabel}
accessibilityRole="button"
accessibilityState={{ selected }}
className={compact ? "bg-screen" : undefined}
onHoverIn={compact ? undefined : () => 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",
})
}
>
<View
className="min-h-[44px] flex-row items-center gap-2.5 py-2"
style={{
paddingLeft: compact ? THREAD_LIST_COMPACT_INSET : 12,
paddingRight: compact ? 18 : 12,
}}
testID="thread-list-row-settled"
>
{showSettledFavicon ? (
<View className="opacity-40">
<ProjectFavicon
environmentId={thread.environmentId}
size={15}
projectTitle={props.projectTitle ?? ""}
workspaceRoot={props.projectCwd}
/>
</View>
) : null}
<View className="min-w-0 flex-1">
<View className="flex-row items-center gap-1.5">
<Text
className={cn(
"min-w-0 flex-1 text-base",
selected ? "text-user-bubble-foreground" : "text-foreground-muted",
)}
numberOfLines={1}
>
{thread.title}
</Text>
{hasDraft ? (
<View
accessibilityLabel="Unsent draft"
className="size-1.5 shrink-0 rounded-full bg-blue-500"
/>
) : null}
</View>
{props.searchMatch ? (
<ThreadSearchMatchExcerpt
compact={compact}
match={props.searchMatch}
query={props.searchQuery ?? ""}
selected={selected}
/>
) : null}
</View>
<Text
className={cn(
"text-sm tabular-nums",
selected ? "text-user-bubble-foreground-muted" : "text-foreground-tertiary",
)}
style={{ fontFamily: MONO_FONT }}
>
{settledTimestamp}
</Text>
</View>
</Pressable>
);

const rowContent = (close: () => void) =>
compact ? (
isSettled ? (
settledRowContent(close)
) : compact ? (
<Pressable
accessibilityHint="Swipe left for archive and delete actions"
accessibilityLabel={threadAccessibilityLabel}
Expand Down
48 changes: 48 additions & 0 deletions apps/mobile/src/features/threads/threadPresentation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vite-plus/test";

import { resolveSettledRowTimestamp } from "./threadPresentation";

const base = {
settledAt: null as string | null,
latestUserMessageAt: null as string | null,
updatedAt: "2026-01-01T00:00:00.000Z",
createdAt: "2025-12-01T00:00:00.000Z",
};

describe("resolveSettledRowTimestamp", () => {
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);
});
});
15 changes: 15 additions & 0 deletions apps/mobile/src/features/threads/threadPresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
16 changes: 16 additions & 0 deletions apps/mobile/src/mobileSurfaceExistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading