Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 34 additions & 17 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import { resolveSendableThreadEnvMode } from "@t3tools/shared/threadEnvMode";

import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";
import {
Expand Down Expand Up @@ -651,10 +652,16 @@ export function NewTaskDraftScreen(props: {
selectedEnvironmentServerConfig,
draft.modelSelection ?? null,
) ?? flow.selectedModel;
const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode;
const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName;
const selectedWorktreePath =
draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath;
const workspaceMode = resolveSendableThreadEnvMode({
requestedMode: draft.workspaceSelection?.mode ?? flow.workspaceMode,
isGitRepo: flow.isGitRepo,
});
const selectedBranchName = flow.isGitRepo
? (draft.workspaceSelection?.branch ?? flow.selectedBranchName)
: null;
const selectedWorktreePath = flow.isGitRepo
? (draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath)
: null;
const startFromOrigin = draft.workspaceSelection?.startFromOrigin ?? flow.startFromOrigin;
const runtimeMode = draft.runtimeMode ?? flow.runtimeMode;
const interactionMode = flow.planModeEnabled
Expand All @@ -666,7 +673,10 @@ export function NewTaskDraftScreen(props: {
!modelSelection ||
initialMessageText.length === 0 ||
flow.submitting ||
(workspaceMode === "worktree" && !selectedBranchName)
(workspaceMode === "worktree" && !selectedBranchName) ||
// A connected send must not trust the provisional repo status; a queued
// task is re-checked when the outbox drains it.
(workspaceMode === "worktree" && environmentConnected && !flow.gitStatusSettled)
) {
return;
}
Expand Down Expand Up @@ -805,7 +815,8 @@ export function NewTaskDraftScreen(props: {
isIncomingShareReady &&
!isImportingShare &&
!flow.submitting &&
!(flow.workspaceMode === "worktree" && !flow.selectedBranchName);
!(flow.workspaceMode === "worktree" && !flow.selectedBranchName) &&
!(flow.workspaceMode === "worktree" && environmentConnected && !flow.gitStatusSettled);
const promptEditor = (
<ComposerEditor
ref={promptInputRef}
Expand Down Expand Up @@ -927,9 +938,13 @@ export function NewTaskDraftScreen(props: {
const workspaceControls = (
<View className="flex-row items-center gap-1 px-2">
<ComposerInlineControl
accessibilityHint={`Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`}
accessibilityHint={
flow.isGitRepo
? `Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`
: "Worktrees need a git repository"
}
accessibilityLabel={workspaceLabel}
disabled={isIncomingShareTransferPending}
disabled={isIncomingShareTransferPending || !flow.isGitRepo}
iconNode={
<NewTaskWorkspaceIcon
workspaceMode={flow.workspaceMode}
Expand All @@ -942,15 +957,17 @@ export function NewTaskDraftScreen(props: {
showChevron={false}
/>

<ComposerInlineControl
accessibilityLabel={`${flow.workspaceMode === "worktree" ? "Base branch" : "Branch"}: ${selectedBranchLabel}`}
chevronDirection="right"
disabled={isIncomingShareTransferPending}
icon="arrow.triangle.branch"
label={showBranchLoading ? "Loading branches…" : selectedBranchLabel}
maxWidth={190}
onPress={() => openContextPicker("NewTaskBranch")}
/>
{flow.isGitRepo ? (
<ComposerInlineControl
accessibilityLabel={`${flow.workspaceMode === "worktree" ? "Base branch" : "Branch"}: ${selectedBranchLabel}`}
chevronDirection="right"
disabled={isIncomingShareTransferPending}
icon="arrow.triangle.branch"
label={showBranchLoading ? "Loading branches…" : selectedBranchLabel}
maxWidth={190}
onPress={() => openContextPicker("NewTaskBranch")}
/>
) : null}
</View>
);

Expand Down
86 changes: 63 additions & 23 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
import {
isDefaultThreadEnvModeSettled,
resolveDefaultThreadEnvMode,
resolveSendableThreadEnvMode,
} from "@t3tools/shared/threadEnvMode";
import * as Arr from "effect/Array";
import { pipe } from "effect/Function";
Expand Down Expand Up @@ -77,6 +78,7 @@ import {
} from "../home/homeThreadList";
import { useMobileProjectGroupingSettings } from "../../state/project-grouping";
import { resolvePendingTaskInteractionMode } from "./legacy-plan-mode";
import { isProjectThreadGitStatusSettled } from "./projectThreadCreationValidation";
import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled";
import {
resolveNewTaskBranchWorktreePath,
Expand Down Expand Up @@ -126,6 +128,12 @@ type NewTaskFlowContextValue = {
readonly selectedProjectKey: string | null;
readonly selectedModelKey: string | null;
readonly workspaceMode: WorkspaceMode;
/** False once the project's git status reports no repository; worktree
mode is then unavailable and the composer hides its branch controls. */
readonly isGitRepo: boolean;
/** True once the project's git status has arrived. `isGitRepo` is a
provisional true before that, so a worktree send must wait for it. */
readonly gitStatusSettled: boolean;
readonly selectedBranchName: string | null;
readonly selectedWorktreePath: string | null;
readonly startFromOrigin: boolean;
Expand Down Expand Up @@ -376,6 +384,35 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
if (t3ProjectFileData === null || t3ProjectFileData.truncated) return null;
return parseT3ProjectFile(t3ProjectFileData.contents)?.defaultThreadEnvMode ?? null;
}, [t3ProjectFileData]);
// The ref actually checked out in the project root, serialized onto new
// local threads. It comes from the live status stream rather than listRefs'
// `current` flag, which is served from a cache that can lag an out-of-band
// `git switch` by minutes — and from the same value the PR badge compares
// against. Detached HEAD and non-repository projects report no ref, so this
// stays null instead of fabricating a branch. The status family is
// deduplicated per (environmentId, cwd) with the thread rows.
const hasProjectWorkspaceRoot = selectedProject !== null && selectedProject.workspaceRoot !== "";
const projectGitStatus = useEnvironmentQuery(
// The empty-string check also skips the stand-in project's workspaceRoot.
hasProjectWorkspaceRoot
? vcsEnvironment.status({
environmentId: selectedProject.environmentId,
input: { cwd: selectedProject.workspaceRoot },
})
: null,
);
const currentCheckoutBranchName = projectGitStatus.data?.refName ?? null;
// Worktree mode needs a repository. Default true while the status loads so
// switching projects does not flash Current checkout before it settles;
// the send path waits on `gitStatusSettled` instead of trusting the default.
// A failed status read counts as settled: the send goes ahead with the
// default and the server reports the real error instead of a silent block.
const gitStatusSettled = isProjectThreadGitStatusSettled({
hasWorkspaceRoot: hasProjectWorkspaceRoot,
hasData: projectGitStatus.data !== null,
hasError: projectGitStatus.error !== null,
});
const isGitRepo = projectGitStatus.data?.isRepo ?? true;
Comment thread
cursor[bot] marked this conversation as resolved.
const defaultWorkspaceMode: WorkspaceMode = resolveDefaultThreadEnvMode({
projectSetting: selectedProject?.defaultThreadEnvMode,
projectFile: t3ProjectFileDefaultMode,
Expand All @@ -389,9 +426,18 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
projectSetting: selectedProject?.defaultThreadEnvMode,
projectFilePending: t3ProjectFileQuery.isPending,
});
const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode;
const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null;
const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null;
const workspaceMode = resolveSendableThreadEnvMode({
requestedMode: selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode,
isGitRepo,
});
// A non-repository has no branches or worktrees, so a persisted selection
// from before the status arrived is stale metadata rather than a choice.
const selectedBranchName = isGitRepo
? (selectedProjectDraft.workspaceSelection?.branch ?? null)
: null;
const selectedWorktreePath = isGitRepo
? (selectedProjectDraft.workspaceSelection?.worktreePath ?? null)
: null;
// Keep the user's explicit choice separate from the resolved display value:
// only the explicit flag is ever written back to the draft, so the resolved
// value keeps tracking the server setting when the config loads late.
Expand Down Expand Up @@ -555,22 +601,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
),
[allBranchRefs],
);
// The ref actually checked out in the project root, serialized onto new
// local threads. It comes from the live status stream rather than listRefs'
// `current` flag, which is served from a cache that can lag an out-of-band
// `git switch` by minutes — and from the same value the PR badge compares
// against. Detached HEAD and non-repository projects report no ref, so this
// stays null instead of fabricating a branch. The status family is
// deduplicated per (environmentId, cwd) with the thread rows.
const projectGitStatus = useEnvironmentQuery(
branchTarget.environmentId !== null && branchTarget.cwd !== null
? vcsEnvironment.status({
environmentId: branchTarget.environmentId,
input: { cwd: branchTarget.cwd },
})
: null,
);
const currentCheckoutBranchName = projectGitStatus.data?.refName ?? null;

const filteredBranches = useMemo(() => {
const query = branchQuery.trim().toLowerCase();
Expand Down Expand Up @@ -844,8 +874,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
}
const workspaceSelection = draft.workspaceSelection;
// Fall back to the resolved mode (server default) so queued tasks drain
// with the same mode the composer displayed.
const mode = workspaceSelection?.mode ?? workspaceMode;
// with the same mode the composer displayed. A non-git project drains
// as local even if the draft persisted worktree mode.
const mode = resolveSendableThreadEnvMode({
requestedMode: workspaceSelection?.mode ?? workspaceMode,
isGitRepo,
});
// When the selection is the stand-in built from the queued snapshot,
// persist the original (possibly absent) snapshot values — the
// stand-in's placeholder title/workspaceRoot must never be written back
Expand Down Expand Up @@ -881,8 +915,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
// queued local task drains days later against whatever is checked
// out then, so recording a queue-time guess would pin a stale label
// to a thread that ran somewhere else.
branch: workspaceSelection?.branch ?? null,
worktreePath: mode === "worktree" ? null : (workspaceSelection?.worktreePath ?? null),
branch: isGitRepo ? (workspaceSelection?.branch ?? null) : null,
worktreePath:
mode === "worktree" || !isGitRepo ? null : (workspaceSelection?.worktreePath ?? null),
// The draft only carries the flag when the user touched it; fall
// back to the resolved default (server settings) so queued tasks
// drain with the same origin mode the composer displayed.
Expand All @@ -896,6 +931,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
[
editingPendingProject,
editingPendingTask,
isGitRepo,
selectedEnvironmentServerConfig,
selectedModel,
selectedProject,
Expand Down Expand Up @@ -1019,6 +1055,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
hasMoreBranches,
availableBranches,
currentCheckoutBranchName,
isGitRepo,
gitStatusSettled,
runtimeMode,
interactionMode,
planModeEnabled,
Expand Down Expand Up @@ -1067,6 +1105,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
buildPendingTaskMessage,
cancelEditingPendingTask,
currentCheckoutBranchName,
isGitRepo,
gitStatusSettled,
editingPendingTask,
environments,
expandedProvider,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,44 @@
import { describe, expect, it } from "vite-plus/test";

import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation";
import {
isProjectThreadGitStatusSettled,
resolveProjectThreadCreationBranch,
} from "./projectThreadCreationValidation";

describe("isProjectThreadGitStatusSettled", () => {
it("settles when a queued-task stand-in has no workspace root", () => {
expect(
isProjectThreadGitStatusSettled({
hasWorkspaceRoot: false,
hasData: false,
hasError: false,
}),
).toBe(true);
});

it("waits while a real workspace status query is loading", () => {
expect(
isProjectThreadGitStatusSettled({
hasWorkspaceRoot: true,
hasData: false,
hasError: false,
}),
).toBe(false);
});

it.each([
{ hasData: true, hasError: false },
{ hasData: false, hasError: true },
])("settles when the workspace query completes: %o", ({ hasData, hasError }) => {
expect(
isProjectThreadGitStatusSettled({
hasWorkspaceRoot: true,
hasData,
hasError,
}),
).toBe(true);
});
});

describe("resolveProjectThreadCreationBranch", () => {
it("uses the live checkout for an untouched local draft label and recorded branch", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ export const ProjectThreadCreationValidationError = Schema.Union([
]);
export type ProjectThreadCreationValidationError = typeof ProjectThreadCreationValidationError.Type;

export function isProjectThreadGitStatusSettled(input: {
readonly hasWorkspaceRoot: boolean;
readonly hasData: boolean;
readonly hasError: boolean;
}): boolean {
return !input.hasWorkspaceRoot || input.hasData || input.hasError;
}

/**
* Branch recorded on a thread created from the new-task composer. An explicit
* picker choice always wins. An untouched current-checkout draft records the
Expand Down
25 changes: 25 additions & 0 deletions apps/mobile/src/state/thread-outbox-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type ProviderInteractionMode as ProviderInteractionModeType,
type RuntimeMode as RuntimeModeType,
} from "@t3tools/contracts";
import { resolveSendableThreadEnvMode } from "@t3tools/shared/threadEnvMode";
import * as Schema from "effect/Schema";

import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema";
Expand Down Expand Up @@ -66,6 +67,12 @@ export interface QueuedThreadCreation {
readonly startFromOrigin?: boolean;
}

export interface QueuedThreadCreationWorkspace {
readonly workspaceMode: "local" | "worktree";
readonly branch: string | null;
readonly worktreePath: string | null;
}

export interface QueuedThreadMessage {
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
Expand Down Expand Up @@ -146,6 +153,24 @@ export function threadOutboxRetryDelayMs(attempt: number): number {
return Math.min(1_000 * 2 ** Math.max(0, attempt - 1), THREAD_OUTBOX_MAX_RETRY_DELAY_MS);
}

export function resolveQueuedThreadCreationWorkspace(
creation: QueuedThreadCreationWorkspace,
isGitRepo: boolean | null,
): QueuedThreadCreationWorkspace {
const workspaceMode = resolveSendableThreadEnvMode({
requestedMode: creation.workspaceMode,
// Unknown status preserves the queued mode and lets the server decide.
isGitRepo: isGitRepo !== false,
});
return workspaceMode === creation.workspaceMode
? creation
: {
workspaceMode,
branch: null,
worktreePath: null,
};
}

export type ThreadOutboxDeliveryAction = "wait" | "remove" | "send";

export function resolveThreadOutboxDeliveryAction(input: {
Expand Down
Loading
Loading