| 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,6 +56,7 @@ import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/re |
| 56 | import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox"; | 56 | import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox"; |
| 57 | import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; | 57 | import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; |
| 58 | import { useNewTaskFlow } from "./new-task-flow-provider"; | 58 | import { useNewTaskFlow } from "./new-task-flow-provider"; |
| 59 | import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation"; | ||
| 59 | import { useCreateProjectThread } from "./use-project-actions"; | 60 | import { useCreateProjectThread } from "./use-project-actions"; |
| 60 | import { resolveDraftProjectSelection } from "./new-task-project-selection"; | 61 | import { resolveDraftProjectSelection } from "./new-task-project-selection"; |
| 61 | import { | 62 | import { |
| ... | @@ -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"; |
| 54 | import { useDebouncedValue, usePaginatedBranches } from "../../state/queries"; | 54 | import { useDebouncedValue, usePaginatedBranches } from "../../state/queries"; |
| 55 | import { vcsEnvironment } from "../../state/vcs"; | ||
| 55 | import { | 56 | import { |
| 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; | ||
| 556 | 574 | ||
| 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; fall | 886 | // 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 @@ | ||
| 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,6 +32,26 @@ export const ProjectThreadCreationValidationError = Schema.Union([ |
| 32 | ]); | 32 | ]); |
| 33 | export type ProjectThreadCreationValidationError = typeof ProjectThreadCreationValidationError.Type; | 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 | export function validateProjectThreadCreation(input: { | 55 | export function validateProjectThreadCreation(input: { |
| 36 | readonly environmentId: EnvironmentId; | 56 | readonly environmentId: EnvironmentId; |
| 37 | readonly projectId: ProjectId; | 57 | readonly projectId: ProjectId; |