diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx
index fcba4626be2d..f544c4cb1170 100644
--- a/apps/mobile/src/features/threads/thread-list-items.tsx
+++ b/apps/mobile/src/features/threads/thread-list-items.tsx
@@ -23,6 +23,7 @@ import { relativeTime } from "../../lib/time";
import { themeColorWithAlpha } from "../../lib/mobileTheme";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
+import { useThreadDisplayBranch } from "../../state/use-thread-display-branch";
import { useThreadPr, type ThreadPrPresentation } from "../../state/use-thread-pr";
import type { HomeGroupDisplayAction } from "../home/homeListItems";
import { ThreadSwipeable } from "../home/thread-swipe-actions";
@@ -454,11 +455,14 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
props;
const status = resolveThreadStatus(thread);
const pr = useThreadPr(thread, props.projectCwd);
+ // Same live-checkout fallback as the v2 rows: phone-created local threads
+ // can persist branch=null, and should still read like PC rows.
+ const displayBranch = useThreadDisplayBranch(thread, props.projectCwd);
const timestamp = relativeTime(
thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,
);
const threadAccessibilityLabel = pr ? `${thread.title}, ${pr.accessibilityLabel}` : thread.title;
- const subtitleParts = [props.environmentLabel, thread.branch].filter((part): part is string =>
+ const subtitleParts = [props.environmentLabel, displayBranch].filter((part): part is string =>
Boolean(part),
);
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 97c13de56aab..7edf5ec587b1 100644
--- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx
+++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
@@ -21,6 +21,7 @@ import { cn } from "../../lib/cn";
import { relativeTime } from "../../lib/time";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
+import { useThreadDisplayBranch } from "../../state/use-thread-display-branch";
import { useThreadPr } from "../../state/use-thread-pr";
import { ThreadSwipeable } from "../home/thread-swipe-actions";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
@@ -409,6 +410,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const pinnedRow = props.pinned === true;
const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null);
+ // Local threads created before the checkout was known persist branch=null;
+ // fall back to the live checkout so a phone-created row reads like PC.
+ const displayBranch = useThreadDisplayBranch(
+ thread,
+ props.projectCwd ?? props.project?.workspaceRoot ?? null,
+ );
const theme = useUniwindTheme();
const screenColor = theme["--color-screen"];
@@ -743,7 +750,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
>
{thread.session.lastError}
- ) : thread.branch || props.environmentLabel ? (
+ ) : displayBranch || props.environmentLabel ? (
/* "branch · machine" share one truncating line. The machine sits
last so a tight fit cuts the repetitive label, not the branch —
and machine-only fills the row for non-git projects. The glyph
@@ -758,7 +765,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
)}
numberOfLines={1}
>
- {thread.branch ? (
+ {displayBranch ? (
- {thread.branch}
+ {displayBranch}
) : null}
- {thread.branch && props.environmentLabel ? " · " : null}
+ {displayBranch && props.environmentLabel ? " · " : null}
{props.environmentLabel ? (
{
+ it("prefers the stored branch over the live checkout", () => {
+ expect(
+ resolveThreadDisplayBranch({
+ branch: "feature/phone-thread",
+ worktreePath: null,
+ liveCheckoutBranch: "main",
+ }),
+ ).toBe("feature/phone-thread");
+ });
+
+ it("falls back to the live checkout for local threads with no stored branch", () => {
+ expect(
+ resolveThreadDisplayBranch({
+ branch: null,
+ worktreePath: null,
+ liveCheckoutBranch: "main",
+ }),
+ ).toBe("main");
+ });
+
+ it("stays blank for local threads while the checkout is unknown", () => {
+ expect(
+ resolveThreadDisplayBranch({
+ branch: null,
+ worktreePath: null,
+ liveCheckoutBranch: null,
+ }),
+ ).toBeNull();
+ });
+
+ it("never falls back for worktree threads", () => {
+ expect(
+ resolveThreadDisplayBranch({
+ branch: null,
+ worktreePath: "/repo/.t3/worktrees/feature",
+ liveCheckoutBranch: "main",
+ }),
+ ).toBeNull();
+ });
+
+ it("treats blank strings as missing", () => {
+ expect(
+ resolveThreadDisplayBranch({ branch: " ", worktreePath: null, liveCheckoutBranch: "main" }),
+ ).toBe("main");
+ expect(
+ resolveThreadDisplayBranch({ branch: null, worktreePath: null, liveCheckoutBranch: " " }),
+ ).toBeNull();
+ });
+});
diff --git a/apps/mobile/src/state/thread-display-branch.ts b/apps/mobile/src/state/thread-display-branch.ts
new file mode 100644
index 000000000000..54fcc9a9ff5e
--- /dev/null
+++ b/apps/mobile/src/state/thread-display-branch.ts
@@ -0,0 +1,25 @@
+/**
+ * Branch shown on a thread list row. The stored `thread.branch` always wins:
+ * it is the ref the thread was created against and the value the PR badge
+ * compares. A local thread (`worktreePath == null`) with a null branch was
+ * created before the live checkout was known (status pending, detached HEAD,
+ * non-repo, or offline queue) — the server never backfills that null, so the
+ * row falls back to the live checkout, mirroring web's header
+ * (`resolveBranchToolbarValue`). Worktree threads never fall back: their cwd
+ * is isolated and its checkout may be a temporary `t3-*` placeholder.
+ */
+export function resolveThreadDisplayBranch(input: {
+ readonly branch: string | null;
+ readonly worktreePath: string | null;
+ readonly liveCheckoutBranch: string | null;
+}): string | null {
+ const stored = input.branch?.trim();
+ if (stored) {
+ return stored;
+ }
+ if (input.worktreePath !== null) {
+ return null;
+ }
+ const live = input.liveCheckoutBranch?.trim();
+ return live ? live : null;
+}
diff --git a/apps/mobile/src/state/use-thread-display-branch.ts b/apps/mobile/src/state/use-thread-display-branch.ts
new file mode 100644
index 000000000000..bd0297ee1fc6
--- /dev/null
+++ b/apps/mobile/src/state/use-thread-display-branch.ts
@@ -0,0 +1,33 @@
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
+
+import { useEnvironmentQuery } from "./query";
+import { resolveThreadDisplayBranch } from "./thread-display-branch";
+import { vcsEnvironment } from "./vcs";
+
+/**
+ * Branch label for a thread list row. Stored `thread.branch` needs no fetch;
+ * the live status stream is only subscribed for local missing-branch threads,
+ * where it is the same deduplicated per-(environmentId, cwd) stream the PR
+ * badge and the new-task composer already share — so rows on one project
+ * root share one subscription, and virtualization keeps it to visible rows.
+ */
+export function useThreadDisplayBranch(
+ thread: EnvironmentThreadShell,
+ projectCwd: string | null,
+): string | null {
+ const cwd = thread.worktreePath ?? projectCwd;
+ const needsLiveBranch = !thread.branch?.trim() && thread.worktreePath === null && cwd !== null;
+ const liveStatus = useEnvironmentQuery(
+ needsLiveBranch
+ ? vcsEnvironment.status({
+ environmentId: thread.environmentId,
+ input: { cwd },
+ })
+ : null,
+ );
+ return resolveThreadDisplayBranch({
+ branch: thread.branch,
+ worktreePath: thread.worktreePath,
+ liveCheckoutBranch: liveStatus.data?.refName ?? null,
+ });
+}