authorgravatar for 100132710+Zeus-Deus@users.noreply.github.comZeus-Deus <100132710+Zeus-Deus@users.noreply.github.com> 2026-08-15 14:27:29+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 14:27:29+02:00
logf8bb92b51b720dd6feba654e6ae50d2b575c0328
tree546cfaa21d17156c4bb26e435572826005c96e9f
parent277a7cb4458257e950b7962f67c8fafd8d0e31ab
signature Signed by PGP key B5690EEEBB952194

fix(mobile): local-checkout threads record their branch so PR badges show (#4986)

Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com>

4 files changed, 114 insertions(+), 3 deletions(-)

apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+15-3
...@@ -56,6 +56,7 @@ import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/re...@@ -56,6 +56,7 @@ import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/re
56import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox";56import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox";
57import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";57import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
58import { useNewTaskFlow } from "./new-task-flow-provider";58import { useNewTaskFlow } from "./new-task-flow-provider";
59import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation";
59import { useCreateProjectThread } from "./use-project-actions";60import { useCreateProjectThread } from "./use-project-actions";
60import { resolveDraftProjectSelection } from "./new-task-project-selection";61import { resolveDraftProjectSelection } from "./new-task-project-selection";
61import {62import {
...@@ -583,11 +584,17 @@ export function NewTaskDraftScreen(props: {...@@ -583,11 +584,17 @@ export function NewTaskDraftScreen(props: {
583 flow.environments.find(584 flow.environments.find(
584 (environment) => environment.environmentId === flow.selectedEnvironmentId,585 (environment) => environment.environmentId === flow.selectedEnvironmentId,
585 )?.environmentLabel ?? "Environment";586 )?.environmentLabel ?? "Environment";
586 const currentBranchName =587 const availableCurrentBranchName =
587 flow.availableBranches.find((branch) => branch.current)?.name ??588 flow.availableBranches.find((branch) => branch.current)?.name ??
588 flow.availableBranches.find((branch) => branch.isDefault)?.name ??589 flow.availableBranches.find((branch) => branch.isDefault)?.name ??
589 null;590 null;
590 const selectedBranchName = flow.selectedBranchName ?? currentBranchName;591 const selectedBranchName = resolveProjectThreadCreationBranch({
592 workspaceMode: flow.workspaceMode,
593 selectedBranch:
594 flow.selectedBranchName ??
595 (flow.workspaceMode === "worktree" ? availableCurrentBranchName : null),
596 currentCheckoutBranch: flow.currentCheckoutBranchName,
597 });
591 const selectedBranchLabel = resolveNewTaskBranchLabel({598 const selectedBranchLabel = resolveNewTaskBranchLabel({
592 branchName: selectedBranchName,599 branchName: selectedBranchName,
593 startFromOrigin: flow.startFromOrigin,600 startFromOrigin: flow.startFromOrigin,
...@@ -713,11 +720,16 @@ export function NewTaskDraftScreen(props: {...@@ -713,11 +720,16 @@ export function NewTaskDraftScreen(props: {
713 threadTitle: deriveThreadTitleFromPrompt(initialMessageText),720 threadTitle: deriveThreadTitleFromPrompt(initialMessageText),
714 projectTitle: selectedProject.title,721 projectTitle: selectedProject.title,
715 });722 });
723 const creationBranch = resolveProjectThreadCreationBranch({
724 workspaceMode,
725 selectedBranch: selectedBranchName,
726 currentCheckoutBranch: flow.currentCheckoutBranchName,
727 });
716 const result = await createProjectThread({728 const result = await createProjectThread({
717 project: selectedProject,729 project: selectedProject,
718 modelSelection,730 modelSelection,
719 envMode: workspaceMode,731 envMode: workspaceMode,
720 branch: selectedBranchName,732 branch: creationBranch,
721 worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath,733 worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath,
722 startFromOrigin,734 startFromOrigin,
723 runtimeMode,735 runtimeMode,
apps/mobile/src/features/threads/new-task-flow-provider.tsx+24
...@@ -52,6 +52,7 @@ import {...@@ -52,6 +52,7 @@ import {
52 useComposerDraft,52 useComposerDraft,
53} from "../../state/use-composer-drafts";53} from "../../state/use-composer-drafts";
54import { useDebouncedValue, usePaginatedBranches } from "../../state/queries";54import { useDebouncedValue, usePaginatedBranches } from "../../state/queries";
55import { vcsEnvironment } from "../../state/vcs";
55import {56import {
56 flattenQueuedThreadMessages,57 flattenQueuedThreadMessages,
57 threadOutboxManager,58 threadOutboxManager,
...@@ -139,6 +140,7 @@ type NewTaskFlowContextValue = {...@@ -139,6 +140,7 @@ type NewTaskFlowContextValue = {
139 readonly branchesFetchingNextPage: boolean;140 readonly branchesFetchingNextPage: boolean;
140 readonly hasMoreBranches: boolean;141 readonly hasMoreBranches: boolean;
141 readonly availableBranches: ReadonlyArray<VcsRef>;142 readonly availableBranches: ReadonlyArray<VcsRef>;
143 readonly currentCheckoutBranchName: string | null;
142 readonly runtimeMode: RuntimeMode;144 readonly runtimeMode: RuntimeMode;
143 readonly interactionMode: ProviderInteractionMode;145 readonly interactionMode: ProviderInteractionMode;
144 readonly planModeEnabled: boolean;146 readonly planModeEnabled: boolean;
...@@ -553,6 +555,22 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -553,6 +555,22 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
553 ),555 ),
554 [allBranchRefs],556 [allBranchRefs],
555 );557 );
558 // The ref actually checked out in the project root, serialized onto new
559 // local threads. It comes from the live status stream rather than listRefs'
560 // `current` flag, which is served from a cache that can lag an out-of-band
561 // `git switch` by minutes — and from the same value the PR badge compares
562 // against. Detached HEAD and non-repository projects report no ref, so this
563 // stays null instead of fabricating a branch. The status family is
564 // deduplicated per (environmentId, cwd) with the thread rows.
565 const projectGitStatus = useEnvironmentQuery(
566 branchTarget.environmentId !== null && branchTarget.cwd !== null
567 ? vcsEnvironment.status({
568 environmentId: branchTarget.environmentId,
569 input: { cwd: branchTarget.cwd },
570 })
571 : null,
572 );
573 const currentCheckoutBranchName = projectGitStatus.data?.refName ?? null;
556574
557 const filteredBranches = useMemo(() => {575 const filteredBranches = useMemo(() => {
558 const query = branchQuery.trim().toLowerCase();576 const query = branchQuery.trim().toLowerCase();
...@@ -859,6 +877,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -859,6 +877,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
859 ...(projectTitle !== undefined ? { projectTitle } : {}),877 ...(projectTitle !== undefined ? { projectTitle } : {}),
860 ...(projectCwd !== undefined ? { projectCwd } : {}),878 ...(projectCwd !== undefined ? { projectCwd } : {}),
861 workspaceMode: mode,879 workspaceMode: mode,
880 // Only an explicit picker choice, never the current checkout: a
881 // queued local task drains days later against whatever is checked
882 // out then, so recording a queue-time guess would pin a stale label
883 // to a thread that ran somewhere else.
862 branch: workspaceSelection?.branch ?? null,884 branch: workspaceSelection?.branch ?? null,
863 worktreePath: mode === "worktree" ? null : (workspaceSelection?.worktreePath ?? null),885 worktreePath: mode === "worktree" ? null : (workspaceSelection?.worktreePath ?? null),
864 // The draft only carries the flag when the user touched it; fall886 // The draft only carries the flag when the user touched it; fall
...@@ -996,6 +1018,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -996,6 +1018,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
996 branchesFetchingNextPage,1018 branchesFetchingNextPage,
997 hasMoreBranches,1019 hasMoreBranches,
998 availableBranches,1020 availableBranches,
1021 currentCheckoutBranchName,
999 runtimeMode,1022 runtimeMode,
1000 interactionMode,1023 interactionMode,
1001 planModeEnabled,1024 planModeEnabled,
...@@ -1043,6 +1066,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -1043,6 +1066,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
1043 branchesFetchingNextPage,1066 branchesFetchingNextPage,
1044 buildPendingTaskMessage,1067 buildPendingTaskMessage,
1045 cancelEditingPendingTask,1068 cancelEditingPendingTask,
1069 currentCheckoutBranchName,
1046 editingPendingTask,1070 editingPendingTask,
1047 environments,1071 environments,
1048 expandedProvider,1072 expandedProvider,
apps/mobile/src/features/threads/projectThreadCreationValidation.test.ts created+55
...@@ -0,0 +1,55 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation";
4
5describe("resolveProjectThreadCreationBranch", () => {
6 it("uses the live checkout for an untouched local draft label and recorded branch", () => {
7 expect(
8 resolveProjectThreadCreationBranch({
9 workspaceMode: "local",
10 selectedBranch: null,
11 currentCheckoutBranch: "feature/x",
12 }),
13 ).toBe("feature/x");
14 });
15
16 it("prefers an explicit picker choice over the current checkout", () => {
17 expect(
18 resolveProjectThreadCreationBranch({
19 workspaceMode: "local",
20 selectedBranch: "main",
21 currentCheckoutBranch: "feature/x",
22 }),
23 ).toBe("main");
24 });
25
26 it("stays null when no ref is checked out (detached HEAD, non-repository, status not loaded)", () => {
27 expect(
28 resolveProjectThreadCreationBranch({
29 workspaceMode: "local",
30 selectedBranch: null,
31 currentCheckoutBranch: null,
32 }),
33 ).toBeNull();
34 });
35
36 it("never borrows the current checkout for a worktree draft", () => {
37 expect(
38 resolveProjectThreadCreationBranch({
39 workspaceMode: "worktree",
40 selectedBranch: null,
41 currentCheckoutBranch: "feature/x",
42 }),
43 ).toBeNull();
44 });
45
46 it("keeps the explicit base branch for a worktree draft", () => {
47 expect(
48 resolveProjectThreadCreationBranch({
49 workspaceMode: "worktree",
50 selectedBranch: "main",
51 currentCheckoutBranch: "feature/x",
52 }),
53 ).toBe("main");
54 });
55});
apps/mobile/src/features/threads/projectThreadCreationValidation.ts+20
...@@ -32,6 +32,26 @@ export const ProjectThreadCreationValidationError = Schema.Union([...@@ -32,6 +32,26 @@ export const ProjectThreadCreationValidationError = Schema.Union([
32]);32]);
33export type ProjectThreadCreationValidationError = typeof ProjectThreadCreationValidationError.Type;33export type ProjectThreadCreationValidationError = typeof ProjectThreadCreationValidationError.Type;
3434
35/**
36 * Branch recorded on a thread created from the new-task composer. An explicit
37 * picker choice always wins. An untouched current-checkout draft records the
38 * ref that is actually checked out, so the thread's PR badge and branch label
39 * match the live git status instead of staying blank. A detached HEAD, a
40 * non-repository project, or a status that has not arrived stays null rather
41 * than fabricating a branch. Only the online creation path resolves a
42 * checkout; a queued task cannot know the checkout it will drain against.
43 */
44export function resolveProjectThreadCreationBranch(input: {
45 readonly workspaceMode: "local" | "worktree";
46 readonly selectedBranch: string | null;
47 readonly currentCheckoutBranch: string | null;
48}): string | null {
49 if (input.selectedBranch !== null) {
50 return input.selectedBranch;
51 }
52 return input.workspaceMode === "local" ? input.currentCheckoutBranch : null;
53}
54
35export function validateProjectThreadCreation(input: {55export function validateProjectThreadCreation(input: {
36 readonly environmentId: EnvironmentId;56 readonly environmentId: EnvironmentId;
37 readonly projectId: ProjectId;57 readonly projectId: ProjectId;