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
5656import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox";
5757import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
5858import { useNewTaskFlow } from "./new-task-flow-provider";
59import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation";
5960import { useCreateProjectThread } from "./use-project-actions";
6061import { resolveDraftProjectSelection } from "./new-task-project-selection";
6162import {
......@@ -583,11 +584,17 @@ export function NewTaskDraftScreen(props: {
583584 flow.environments.find(
584585 (environment) => environment.environmentId === flow.selectedEnvironmentId,
585586 )?.environmentLabel ?? "Environment";
586 const currentBranchName =
587 const availableCurrentBranchName =
587588 flow.availableBranches.find((branch) => branch.current)?.name ??
588589 flow.availableBranches.find((branch) => branch.isDefault)?.name ??
589590 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 });
591598 const selectedBranchLabel = resolveNewTaskBranchLabel({
592599 branchName: selectedBranchName,
593600 startFromOrigin: flow.startFromOrigin,
......@@ -713,11 +720,16 @@ export function NewTaskDraftScreen(props: {
713720 threadTitle: deriveThreadTitleFromPrompt(initialMessageText),
714721 projectTitle: selectedProject.title,
715722 });
723 const creationBranch = resolveProjectThreadCreationBranch({
724 workspaceMode,
725 selectedBranch: selectedBranchName,
726 currentCheckoutBranch: flow.currentCheckoutBranchName,
727 });
716728 const result = await createProjectThread({
717729 project: selectedProject,
718730 modelSelection,
719731 envMode: workspaceMode,
720 branch: selectedBranchName,
732 branch: creationBranch,
721733 worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath,
722734 startFromOrigin,
723735 runtimeMode,
apps/mobile/src/features/threads/new-task-flow-provider.tsx+24
......@@ -52,6 +52,7 @@ import {
5252 useComposerDraft,
5353} from "../../state/use-composer-drafts";
5454import { useDebouncedValue, usePaginatedBranches } from "../../state/queries";
55import { vcsEnvironment } from "../../state/vcs";
5556import {
5657 flattenQueuedThreadMessages,
5758 threadOutboxManager,
......@@ -139,6 +140,7 @@ type NewTaskFlowContextValue = {
139140 readonly branchesFetchingNextPage: boolean;
140141 readonly hasMoreBranches: boolean;
141142 readonly availableBranches: ReadonlyArray<VcsRef>;
143 readonly currentCheckoutBranchName: string | null;
142144 readonly runtimeMode: RuntimeMode;
143145 readonly interactionMode: ProviderInteractionMode;
144146 readonly planModeEnabled: boolean;
......@@ -553,6 +555,22 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
553555 ),
554556 [allBranchRefs],
555557 );
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
557575 const filteredBranches = useMemo(() => {
558576 const query = branchQuery.trim().toLowerCase();
......@@ -859,6 +877,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
859877 ...(projectTitle !== undefined ? { projectTitle } : {}),
860878 ...(projectCwd !== undefined ? { projectCwd } : {}),
861879 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.
862884 branch: workspaceSelection?.branch ?? null,
863885 worktreePath: mode === "worktree" ? null : (workspaceSelection?.worktreePath ?? null),
864886 // The draft only carries the flag when the user touched it; fall
......@@ -996,6 +1018,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
9961018 branchesFetchingNextPage,
9971019 hasMoreBranches,
9981020 availableBranches,
1021 currentCheckoutBranchName,
9991022 runtimeMode,
10001023 interactionMode,
10011024 planModeEnabled,
......@@ -1043,6 +1066,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
10431066 branchesFetchingNextPage,
10441067 buildPendingTaskMessage,
10451068 cancelEditingPendingTask,
1069 currentCheckoutBranchName,
10461070 editingPendingTask,
10471071 environments,
10481072 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([
3232]);
3333export 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
3555export function validateProjectThreadCreation(input: {
3656 readonly environmentId: EnvironmentId;
3757 readonly projectId: ProjectId;