| author | |
| committer | |
| log | f8bb92b51b720dd6feba654e6ae50d2b575c0328 |
| tree | 546cfaa21d17156c4bb26e435572826005c96e9f |
| parent | 277a7cb4458257e950b7962f67c8fafd8d0e31ab |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 56 | import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox"; |
| 57 | 57 | import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; |
| 58 | 58 | import { useNewTaskFlow } from "./new-task-flow-provider"; |
| 59 | import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation"; | |
| 59 | 60 | import { useCreateProjectThread } from "./use-project-actions"; |
| 60 | 61 | import { resolveDraftProjectSelection } from "./new-task-project-selection"; |
| 61 | 62 | import { |
| ... | ... | @@ -583,11 +584,17 @@ export function NewTaskDraftScreen(props: { |
| 583 | 584 | flow.environments.find( |
| 584 | 585 | (environment) => environment.environmentId === flow.selectedEnvironmentId, |
| 585 | 586 | )?.environmentLabel ?? "Environment"; |
| 586 | const currentBranchName = | |
| 587 | const availableCurrentBranchName = | |
| 587 | 588 | flow.availableBranches.find((branch) => branch.current)?.name ?? |
| 588 | 589 | flow.availableBranches.find((branch) => branch.isDefault)?.name ?? |
| 589 | 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 | 598 | const selectedBranchLabel = resolveNewTaskBranchLabel({ |
| 592 | 599 | branchName: selectedBranchName, |
| 593 | 600 | startFromOrigin: flow.startFromOrigin, |
| ... | ... | @@ -713,11 +720,16 @@ export function NewTaskDraftScreen(props: { |
| 713 | 720 | threadTitle: deriveThreadTitleFromPrompt(initialMessageText), |
| 714 | 721 | projectTitle: selectedProject.title, |
| 715 | 722 | }); |
| 723 | const creationBranch = resolveProjectThreadCreationBranch({ | |
| 724 | workspaceMode, | |
| 725 | selectedBranch: selectedBranchName, | |
| 726 | currentCheckoutBranch: flow.currentCheckoutBranchName, | |
| 727 | }); | |
| 716 | 728 | const result = await createProjectThread({ |
| 717 | 729 | project: selectedProject, |
| 718 | 730 | modelSelection, |
| 719 | 731 | envMode: workspaceMode, |
| 720 | branch: selectedBranchName, | |
| 732 | branch: creationBranch, | |
| 721 | 733 | worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath, |
| 722 | 734 | startFromOrigin, |
| 723 | 735 | runtimeMode, |
apps/mobile/src/features/threads/new-task-flow-provider.tsx+24| ... | ... | @@ -52,6 +52,7 @@ import { |
| 52 | 52 | useComposerDraft, |
| 53 | 53 | } from "../../state/use-composer-drafts"; |
| 54 | 54 | import { useDebouncedValue, usePaginatedBranches } from "../../state/queries"; |
| 55 | import { vcsEnvironment } from "../../state/vcs"; | |
| 55 | 56 | import { |
| 56 | 57 | flattenQueuedThreadMessages, |
| 57 | 58 | threadOutboxManager, |
| ... | ... | @@ -139,6 +140,7 @@ type NewTaskFlowContextValue = { |
| 139 | 140 | readonly branchesFetchingNextPage: boolean; |
| 140 | 141 | readonly hasMoreBranches: boolean; |
| 141 | 142 | readonly availableBranches: ReadonlyArray<VcsRef>; |
| 143 | readonly currentCheckoutBranchName: string | null; | |
| 142 | 144 | readonly runtimeMode: RuntimeMode; |
| 143 | 145 | readonly interactionMode: ProviderInteractionMode; |
| 144 | 146 | readonly planModeEnabled: boolean; |
| ... | ... | @@ -553,6 +555,22 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 553 | 555 | ), |
| 554 | 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; | |
| 556 | 574 | |
| 557 | 575 | const filteredBranches = useMemo(() => { |
| 558 | 576 | const query = branchQuery.trim().toLowerCase(); |
| ... | ... | @@ -859,6 +877,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 859 | 877 | ...(projectTitle !== undefined ? { projectTitle } : {}), |
| 860 | 878 | ...(projectCwd !== undefined ? { projectCwd } : {}), |
| 861 | 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 | 884 | branch: workspaceSelection?.branch ?? null, |
| 863 | 885 | worktreePath: mode === "worktree" ? null : (workspaceSelection?.worktreePath ?? null), |
| 864 | 886 | // The draft only carries the flag when the user touched it; fall |
| ... | ... | @@ -996,6 +1018,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 996 | 1018 | branchesFetchingNextPage, |
| 997 | 1019 | hasMoreBranches, |
| 998 | 1020 | availableBranches, |
| 1021 | currentCheckoutBranchName, | |
| 999 | 1022 | runtimeMode, |
| 1000 | 1023 | interactionMode, |
| 1001 | 1024 | planModeEnabled, |
| ... | ... | @@ -1043,6 +1066,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 1043 | 1066 | branchesFetchingNextPage, |
| 1044 | 1067 | buildPendingTaskMessage, |
| 1045 | 1068 | cancelEditingPendingTask, |
| 1069 | currentCheckoutBranchName, | |
| 1046 | 1070 | editingPendingTask, |
| 1047 | 1071 | environments, |
| 1048 | 1072 | expandedProvider, |
apps/mobile/src/features/threads/projectThreadCreationValidation.test.ts created+55| ... | ... | @@ -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+20| ... | ... | @@ -32,6 +32,26 @@ export const ProjectThreadCreationValidationError = Schema.Union([ |
| 32 | 32 | ]); |
| 33 | 33 | export type ProjectThreadCreationValidationError = typeof ProjectThreadCreationValidationError.Type; |
| 34 | 34 | |
| 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 | ||
| 35 | 55 | export function validateProjectThreadCreation(input: { |
| 36 | 56 | readonly environmentId: EnvironmentId; |
| 37 | 57 | readonly projectId: ProjectId; |