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