Skip to content

Commit f8bb92b

Browse files
Zeus-Deusjuliusmarmingecodex
authored
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>
1 parent 277a7cb commit f8bb92b

4 files changed

Lines changed: 114 additions & 3 deletions

File tree

apps/mobile/src/features/threads/NewTaskDraftScreen.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/re
5656
import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox";
5757
import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
5858
import { useNewTaskFlow } from "./new-task-flow-provider";
59+
import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation";
5960
import { useCreateProjectThread } from "./use-project-actions";
6061
import { resolveDraftProjectSelection } from "./new-task-project-selection";
6162
import {
@@ -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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import {
5252
useComposerDraft,
5353
} from "../../state/use-composer-drafts";
5454
import { useDebouncedValue, usePaginatedBranches } from "../../state/queries";
55+
import { vcsEnvironment } from "../../state/vcs";
5556
import {
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,
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { describe, expect, it } from "vite-plus/test";
2+
3+
import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation";
4+
5+
describe("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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,26 @@ export const ProjectThreadCreationValidationError = Schema.Union([
3232
]);
3333
export 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+
*/
44+
export 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+
3555
export function validateProjectThreadCreation(input: {
3656
readonly environmentId: EnvironmentId;
3757
readonly projectId: ProjectId;

0 commit comments

Comments
 (0)