diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx
index e628eea08e314..eb158f1b177e0 100644
--- a/apps/mobile/src/features/home/HomeScreen.tsx
+++ b/apps/mobile/src/features/home/HomeScreen.tsx
@@ -957,11 +957,12 @@ export function HomeScreen(props: HomeScreenProps) {
const extraData = useMemo(
() => ({
+ projectByKey,
savedConnectionsById: props.savedConnectionsById,
searchQuery: props.searchQuery,
threadSearchMatchByKey,
}),
- [props.savedConnectionsById, props.searchQuery, threadSearchMatchByKey],
+ [projectByKey, props.savedConnectionsById, props.searchQuery, threadSearchMatchByKey],
);
const renderItem = useCallback(
@@ -1007,6 +1008,10 @@ export function HomeScreen(props: HomeScreenProps) {
onNewThreadOnBranch={props.onNewThreadOnBranch}
variant="compact"
thread={thread}
+ projectCwd={
+ projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId))
+ ?.workspaceRoot ?? null
+ }
hasQueuedMessages={queuedThreadKeys.has(`${thread.environmentId}:${thread.id}`)}
environmentLabel={
props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null
@@ -1047,6 +1052,7 @@ export function HomeScreen(props: HomeScreenProps) {
handleSwipeableWillOpen,
handleRegenerateThreadTitle,
machineByEnvironmentId,
+ projectByKey,
queuedThreadKeys,
props.onArchiveThread,
props.onDeletePendingTask,
diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
index d3cd65fe8a7b1..32a8031975bdc 100644
--- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
+++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
@@ -1029,6 +1029,10 @@ function ThreadNavigationSidebarPane(
onNewThreadOnBranch={props.onNewThreadOnBranch}
variant="sidebar"
thread={thread}
+ projectCwd={
+ projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId))
+ ?.workspaceRoot ?? null
+ }
hasQueuedMessages={queuedThreadKeys.has(`${thread.environmentId}:${thread.id}`)}
environmentLabel={
savedConnectionsById[thread.environmentId]?.environmentLabel ?? null
diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx
index 7a677a5ec7972..e1bd262cd8199 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";
@@ -441,6 +442,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
readonly thread: EnvironmentThreadShell;
readonly environmentLabel: string | null;
readonly environmentMachine?: EnvironmentMachineKind;
+ readonly projectCwd: string | null;
/** A message for this thread is waiting in the outbox. */
readonly hasQueuedMessages?: boolean;
readonly searchMatch?: EnvironmentThreadSearchMatch;
@@ -487,6 +489,9 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
} = props;
const status = resolveThreadStatus(thread);
const pr = useThreadPr(thread);
+ // 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,
);
@@ -497,7 +502,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
]
.filter(Boolean)
.join(", ");
- 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 c2e66ece53def..b35e533f2b4b1 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 { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
@@ -427,6 +428,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const pinnedRow = props.pinned === true;
const pr = useThreadPr(thread);
+ // 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.project?.workspaceRoot ?? null);
const theme = useUniwindTheme();
const screenColor = theme["--color-screen"];
@@ -765,7 +769,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
@@ -780,7 +784,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 0000000000000..54fcc9a9ff5ee
--- /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 0000000000000..bd0297ee1fc64
--- /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,
+ });
+}