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, + }); +}