From ca95f002a8c9719bde227394bc323103045323ef Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Thu, 3 Sep 2026 21:35:59 +0200 Subject: [PATCH 1/3] fix(mobile): show checkout branch for phone-created threads --- apps/mobile/src/features/home/HomeScreen.tsx | 5 ++ .../threads/ThreadNavigationSidebar.tsx | 4 ++ .../features/threads/thread-list-items.tsx | 7 ++- .../features/threads/thread-list-v2-items.tsx | 12 +++-- .../src/state/thread-display-branch.test.ts | 54 +++++++++++++++++++ .../mobile/src/state/thread-display-branch.ts | 25 +++++++++ .../src/state/use-thread-display-branch.ts | 33 ++++++++++++ 7 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 apps/mobile/src/state/thread-display-branch.test.ts create mode 100644 apps/mobile/src/state/thread-display-branch.ts create mode 100644 apps/mobile/src/state/use-thread-display-branch.ts diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index e628eea08e314..486904b33d9a2 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -1007,6 +1007,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 +1051,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..1ae3a9b54fbb1 --- /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 null-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 === null && 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, + }); +} From 538cd0547360e52dbc12ebbe98da20b0b23e5cc8 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Thu, 3 Sep 2026 22:12:53 +0200 Subject: [PATCH 2/3] fix(mobile): load checkout for blank thread branches --- apps/mobile/src/state/use-thread-display-branch.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/state/use-thread-display-branch.ts b/apps/mobile/src/state/use-thread-display-branch.ts index 1ae3a9b54fbb1..bd0297ee1fc64 100644 --- a/apps/mobile/src/state/use-thread-display-branch.ts +++ b/apps/mobile/src/state/use-thread-display-branch.ts @@ -6,7 +6,7 @@ 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 null-branch threads, + * 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. @@ -16,7 +16,7 @@ export function useThreadDisplayBranch( projectCwd: string | null, ): string | null { const cwd = thread.worktreePath ?? projectCwd; - const needsLiveBranch = thread.branch === null && thread.worktreePath === null && cwd !== null; + const needsLiveBranch = !thread.branch?.trim() && thread.worktreePath === null && cwd !== null; const liveStatus = useEnvironmentQuery( needsLiveBranch ? vcsEnvironment.status({ From 40dbed935af2b3e4e3cf2a62bf205dd074e9fd5d Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Mon, 7 Sep 2026 21:13:19 +0200 Subject: [PATCH 3/3] fix(mobile): refresh thread rows when projects load --- apps/mobile/src/features/home/HomeScreen.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 486904b33d9a2..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(