# Files changed · PR \#17717 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/17717/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(mobile\): header menu and Git menu reach a thread's linked pull request](https://git.cafe/pingdotgg/t3code/pull/17717.md)

Version: 5

Head commit: a193851f631543e3cf54a011ed931150a2434fd4

Base commit: 86d130af6c02a126515488ed6038b68906c35bf7

Comparison base: 86d130af6c02a126515488ed6038b68906c35bf7

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/mobile/src/features/threads/ThreadHeader.tsx

Status: modified; +28; −0

```diff
@@ -1,8 +1,12 @@
+import type { ThreadPullRequestLink } from "@t3tools/contracts";
 import { useMemo } from "react";
 import { AppBar } from "../../components/ui/AppBar";
 import { ScreenHeaderButton } from "../../components/ui/ScreenHeaderButton";
 import type { AppBarAction } from "../../components/ui/AppBar.types";
+import * as Dialog from "../../components/ui/Dialog";
+import { useOpenPullRequest } from "../../native/useOpenPullRequest";
 import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
+import { resolveLinkedPullRequestHeaderAction } from "./git/linkedPullRequestStatus";
 import type { ThreadInspectorMode } from "./thread-inspector-content-stack";
 import { useThreadHeaderOptions } from "./useThreadHeaderOptions";
 import { useWorkspaceListNavigation } from "../workspace/useAndroidWorkspaceNavigation";
@@ -10,6 +14,7 @@
 
 export function ThreadHeader(
   props: Parameters<typeof useThreadHeaderOptions>[0] & {
+    readonly linkedPullRequests?: ReadonlyArray<ThreadPullRequestLink>;
     readonly hasThreadCwd: boolean;
     readonly hasWorkspaceRoot: boolean;
     readonly fileInspectorSupported: boolean;
@@ -23,6 +28,11 @@
   const { layout, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
   const { onOpenTerminal, onMergeBack } = props.gitControls;
   const native = useThreadHeaderOptions(props);
+  const openPullRequest = useOpenPullRequest();
+  const linkedPullRequestAction = useMemo(
+    () => resolveLinkedPullRequestHeaderAction(props.linkedPullRequests),
+    [props.linkedPullRequests],
+  );
   const androidHeaderActions = useMemo<ReadonlyArray<AppBarAction>>(() => {
     const actions: AppBarAction[] = [];
     if (props.onReturnToThread) {
@@ -53,6 +63,22 @@
       icon: "point.topleft.down.curvedto.point.bottomright.up",
       onPress: props.onOpenGitInspector,
     });
+    if (linkedPullRequestAction !== null) {
+      actions.push({
+        accessibilityLabel: linkedPullRequestAction.label,
+        icon: PullRequestGlyph.pullRequest,
+        onPress:
+          linkedPullRequestAction.kind === "open"
+            ? () => {
+                void openPullRequest(linkedPullRequestAction.url).then((opened) => {
+                  if (!opened) {
+                    Dialog.alert("Unable to open PR", "The pull request could not be opened.");
+                  }
+                });
+              }
+            : props.onOpenGitInspector,
+      });
+    }
     if (onMergeBack) {
       actions.push({
         accessibilityLabel: "Merge back to source",
@@ -62,6 +88,8 @@
     }
     return actions;
   }, [
+    linkedPullRequestAction,
+    openPullRequest,
     props.inspectorMode,
     panes.auxiliaryPaneVisible,
     props.onOpenFilesInspector,
```

## apps/mobile/src/features/threads/ThreadRouteScreen.tsx

Status: modified; +7; −1

```diff
@@ -66,6 +66,7 @@
 import { terminalDebugLog } from "../terminal/terminalDebugLog";
 import { ThreadDetailScreen, type ThreadDetailScreenProps } from "./ThreadDetailScreen";
 import { GitOverviewSheet } from "./git/GitOverviewSheet";
+import { withLinkedPullRequest } from "./git/linkedPullRequestStatus";
 import { useAtomCommand } from "../../state/use-atom-command";
 import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions";
 import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state";
@@ -436,6 +437,10 @@
         })
       : null,
   );
+  const gitStatusForActions = withLinkedPullRequest(
+    gitStatus.data ?? null,
+    selectedThread?.pullRequests,
+  );
   const { sessions: knownTerminalSessions } = useKnownTerminalSessions({
     environmentId: selectedThread?.environmentId ?? null,
     threadId: selectedThread?.id ?? null,
@@ -711,7 +716,7 @@
         ? () => void handleMergeBack()
         : undefined,
     currentBranch: selectedThread?.branch ?? null,
-    gitStatus: gitStatus.data,
+    gitStatus: gitStatusForActions,
     gitOperationLabel: gitState.gitOperationLabel,
     canOpenTerminal:
       Boolean(selectedThreadProject?.workspaceRoot) && (canReadTerminal || canOperateTerminal),
@@ -992,6 +997,7 @@
         headerColor={headerColor}
         usesNativeHeaderGlass={usesNativeHeaderGlass}
         gitControls={threadGitControlProps}
+        linkedPullRequests={selectedThread.pullRequests}
         hasThreadCwd={selectedThreadCwd !== null}
         hasWorkspaceRoot={Boolean(selectedThreadProject?.workspaceRoot)}
         fileInspectorSupported={fileInspector.supported}
```

## apps/mobile/src/features/threads/git/GitOverviewSheet.tsx

Status: modified; +15; −11

```diff
@@ -42,6 +42,7 @@
 import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree";
 import { vcsEnvironment } from "../../../state/vcs";
 import { resolveGitOverviewReviewNavigationAction } from "./git-overview-navigation";
+import { withLinkedPullRequest } from "./linkedPullRequestStatus";
 import { menuItemIconName, statusSummary } from "./gitSheetComponents";
 import {
   LinkedPullRequestRow,
@@ -105,16 +106,19 @@
       : null,
   );
 
+  // The ref-derived status misses a linked PR that lives on another ref (e.g. a fork branch).
+  const gitStatusData = withLinkedPullRequest(gitStatus.data ?? null, selectedThread?.pullRequests);
+
+  const currentBranchLabel = gitStatusData?.refName ?? selectedThread?.branch ?? "Detached HEAD";
+  const currentStatusSummary = statusSummary(gitStatusData);
-  const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD";
-  const currentStatusSummary = statusSummary(gitStatus.data);
   const currentWorktreePath = selectedThreadWorktreePath;
   const gitOperationLabel = gitState.gitOperationLabel;
   const busy = gitOperationLabel !== null;
-  const isRepo = gitStatus.data?.isRepo ?? true;
-  const hasPrimaryRemote = gitStatus.data?.hasPrimaryRemote ?? false;
+  const isRepo = gitStatusData?.isRepo ?? true;
+  const hasPrimaryRemote = gitStatusData?.hasPrimaryRemote ?? false;
-  const isDefaultRef = gitStatus.data?.isDefaultRef ?? false;
+  const isDefaultRef = gitStatusData?.isDefaultRef ?? false;
 
-  const menuItems = isRepo ? buildMenuItems(gitStatus.data, busy, hasPrimaryRemote) : [];
+  const menuItems = isRepo ? buildMenuItems(gitStatusData, busy, hasPrimaryRemote) : [];
 
   const sheetMenuItems = menuItems.map((item) => ({
     item: {
@@ -126,14 +130,14 @@
         ? "This connection cannot change source control."
         : getGitActionDisabledReason({
             item,
+            gitStatus: gitStatusData,
-            gitStatus: gitStatus.data,
             isBusy: busy,
             hasOriginRemote: hasPrimaryRemote,
           }),
   }));
 
   const openExistingPr = async () => {
+    const prUrl = gitStatusData?.pr?.state === "open" ? gitStatusData.pr.url : null;
-    const prUrl = gitStatus.data?.pr?.state === "open" ? gitStatus.data.pr.url : null;
     if (!prUrl) {
       Dialog.alert("No open PR", "This branch does not have an open pull request.");
       return;
@@ -152,7 +156,7 @@
       input.action === "commit_push_pr"
         ? input.action
         : null;
-    const branchName = gitStatus.data?.refName;
+    const branchName = gitStatusData?.refName;
     if (
       branchName &&
       confirmableAction &&
@@ -199,7 +203,7 @@
   };
 
   const rowStatusDetail = (item: (typeof menuItems)[number]): string | undefined => {
-    const status = gitStatus.data;
+    const status = gitStatusData;
     if (status == null) {
       return undefined;
     }
@@ -217,7 +221,7 @@
     return undefined;
   };
 
+  const behindCount = gitStatusData?.behindCount ?? 0;
-  const behindCount = gitStatus.data?.behindCount ?? 0;
 
   // Tying RefreshControl to the query's isPending flag left the spinner stuck
   // (the status query reports pending during quiet background refreshes too).
```

## apps/mobile/src/features/threads/git/linkedPullRequestStatus.test.ts

Status: added; +96; −0

```diff
@@ -1,0 +1,96 @@
+import type { ThreadPullRequestLink, VcsStatusResult } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+  resolveLinkedPullRequestHeaderAction,
+  withLinkedPullRequest,
+} from "./linkedPullRequestStatus";
+
+const status = { isRepo: true, refName: "fork-branch", pr: null } as unknown as VcsStatusResult;
+
+function link(
+  number: number,
+  snapshot: Partial<NonNullable<ThreadPullRequestLink["snapshot"]>> | null = {},
+): ThreadPullRequestLink {
+  return {
+    host: "github.com",
+    repository: "t3tools/t3code",
+    number,
+    url: `https://github.com/t3tools/t3code/pull/${number}`,
+    source: "manual",
+    linkedAt: "2026-09-08T00:00:00.000Z",
+    stack: null,
+    snapshot:
+      snapshot === null
+        ? null
+        : {
+            state: "open",
+            title: `Change ${number}`,
+            headBranch: `change-${number}`,
+            baseBranch: "main",
+            isDraft: false,
+            updatedAt: null,
+            syncedAt: "2026-09-08T00:00:00.000Z",
+            ...snapshot,
+          },
+  };
+}
+
+describe("withLinkedPullRequest", () => {
+  it("offers the linked pull request when the checkout's ref has none", () => {
+    expect(withLinkedPullRequest(status, [link(7)])?.pr).toMatchObject({
+      number: 7,
+      url: "https://github.com/t3tools/t3code/pull/7",
+      state: "open",
+      headRef: "change-7",
+      baseRef: "main",
+    });
+  });
+
+  it("treats a link awaiting its first sync as open", () => {
+    expect(withLinkedPullRequest(status, [link(7, null)])?.pr).toMatchObject({
+      number: 7,
+      state: "open",
+    });
+  });
+
+  it("keeps the ref-derived open pull request", () => {
+    const open = { ...status, pr: { number: 3, state: "open" } } as unknown as VcsStatusResult;
+    expect(withLinkedPullRequest(open, [link(7)])).toBe(open);
+  });
+
+  it("ignores links that are no longer open", () => {
+    expect(withLinkedPullRequest(status, [link(7, { state: "merged" })])).toBe(status);
+  });
+
+  it("passes through a missing status or no links", () => {
+    expect(withLinkedPullRequest(null, [link(7)])).toBeNull();
+    expect(withLinkedPullRequest(status, undefined)).toBe(status);
+    expect(withLinkedPullRequest(status, [])).toBe(status);
+  });
+});
+
+describe("resolveLinkedPullRequestHeaderAction", () => {
+  it("opens the only linked pull request directly", () => {
+    expect(resolveLinkedPullRequestHeaderAction([link(7)])).toEqual({
+      kind: "open",
+      label: "Open pull request #7",
+      url: "https://github.com/t3tools/t3code/pull/7",
+    });
+  });
+
+  it("lists several linked pull requests", () => {
+    expect(resolveLinkedPullRequestHeaderAction([link(7), link(8)])).toEqual({
+      kind: "list",
+      label: "Linked pull requests (2)",
+    });
+  });
+
+  it("offers nothing without a visible link", () => {
+    expect(resolveLinkedPullRequestHeaderAction(undefined)).toBeNull();
+    expect(resolveLinkedPullRequestHeaderAction([])).toBeNull();
+    expect(
+      resolveLinkedPullRequestHeaderAction([{ ...link(7), source: "stack-dismissed" }]),
+    ).toBeNull();
+  });
+});
```

## apps/mobile/src/features/threads/git/linkedPullRequestStatus.ts

Status: added; +54; −0

```diff
@@ -1,0 +1,54 @@
+import type { ThreadPullRequestLink, VcsStatusResult } from "@t3tools/contracts";
+import {
+  resolveThreadCurrentPullRequestLink,
+  visibleThreadPullRequests,
+} from "@t3tools/shared/threadPullRequests";
+
+/**
+ * A thread's linked pull request can live on a ref other than the checkout (a fork branch, a
+ * stacked layer), so the ref-derived git status misses it and the menus offer Create PR instead
+ * of opening the one that exists. Fold the current open link into the status. A link whose
+ * first sync has not landed has no snapshot; like the thread list, treat it as open.
+ */
+export function withLinkedPullRequest(
+  gitStatus: VcsStatusResult | null,
+  pullRequests: ReadonlyArray<ThreadPullRequestLink> | undefined,
+): VcsStatusResult | null {
+  if (gitStatus === null || gitStatus.pr?.state === "open") return gitStatus;
+  const link = resolveThreadCurrentPullRequestLink(pullRequests ?? []);
+  if (link === null) return gitStatus;
+  const { snapshot } = link;
+  if (snapshot !== null && snapshot.state !== "open") return gitStatus;
+  return {
+    ...gitStatus,
+    pr: {
+      number: link.number,
+      url: link.url,
+      title: snapshot?.title ?? `#${link.number}`,
+      baseRef: snapshot?.baseBranch ?? "unknown",
+      headRef: snapshot?.headBranch ?? "unknown",
+      state: "open",
+      ...(snapshot ? { isDraft: snapshot.isDraft, updatedAt: snapshot.updatedAt } : {}),
+    },
+  };
+}
+
+export type LinkedPullRequestHeaderAction =
+  | { readonly kind: "open"; readonly label: string; readonly url: string }
+  | { readonly kind: "list"; readonly label: string };
+
+/**
+ * What the thread header offers for the thread's linked pull requests: open the only one
+ * directly, or show the Git sheet's list when there are several. Null when nothing is linked.
+ */
+export function resolveLinkedPullRequestHeaderAction(
+  pullRequests: ReadonlyArray<ThreadPullRequestLink> | undefined,
+): LinkedPullRequestHeaderAction | null {
+  const links = visibleThreadPullRequests(pullRequests ?? []);
+  const [only] = links;
+  if (only === undefined) return null;
+  if (links.length === 1) {
+    return { kind: "open", label: `Open pull request #${only.number}`, url: only.url };
+  }
+  return { kind: "list", label: `Linked pull requests (${links.length})` };
+}
```
