Skip to content
Draft
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
11 changes: 11 additions & 0 deletions apps/web/src/components/BranchToolbar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,17 @@ describe("resolveEffectiveEnvMode", () => {
}),
).toBe("worktree");
});

it("falls back to local mode for non-git projects even if the draft prefers worktree mode", () => {
expect(
resolveEffectiveEnvMode({
activeWorktreePath: null,
hasServerThread: false,
draftThreadEnvMode: "worktree",
isGitRepo: false,
}),
).toBe("local");
});
});

describe("resolveEnvModeLabel", () => {
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/components/BranchToolbar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,12 @@ export function resolveEffectiveEnvMode(input: {
activeWorktreePath: string | null;
hasServerThread: boolean;
draftThreadEnvMode: EnvMode | undefined;
isGitRepo?: boolean;
}): EnvMode {
const { activeWorktreePath, hasServerThread, draftThreadEnvMode } = input;
const { activeWorktreePath, hasServerThread, draftThreadEnvMode, isGitRepo = true } = input;
if (!isGitRepo) {
return "local";
}
if (!hasServerThread) {
if (activeWorktreePath) {
return "local";
Expand Down
218 changes: 216 additions & 2 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,23 @@ import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test
import { estimateTimelineMessageHeight } from "./timelineHeight";
import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";

const { gitStatusStateRef, refreshGitStatusResultRef, refreshGitStatusSpy } = vi.hoisted(() => ({
gitStatusStateRef: {
current: { data: null, error: null, cause: null, isPending: false } as {
data: unknown;
error: unknown;
cause: unknown;
isPending: boolean;
},
},
refreshGitStatusResultRef: { current: null as unknown },
refreshGitStatusSpy: vi.fn(() => Promise.resolve(refreshGitStatusResultRef.current)),
}));

vi.mock("../lib/gitStatusState", () => ({
useGitStatus: () => ({ data: null, error: null, cause: null, isPending: false }),
useGitStatus: () => gitStatusStateRef.current,
useGitStatuses: () => new Map(),
refreshGitStatus: () => Promise.resolve(null),
refreshGitStatus: refreshGitStatusSpy,
resetGitStatusStateForTests: () => undefined,
}));

Expand Down Expand Up @@ -1458,6 +1471,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
document.body.innerHTML = "";
wsRequests.length = 0;
customWsRpcResolver = null;
gitStatusStateRef.current = { data: null, error: null, cause: null, isPending: false };
refreshGitStatusResultRef.current = null;
refreshGitStatusSpy.mockClear();
useComposerDraftStore.setState({
draftsByThreadKey: {},
draftThreadsByThreadKey: {},
Expand Down Expand Up @@ -2448,6 +2464,204 @@ describe("ChatView timeline estimator parity (full app)", () => {
}
});

it("falls back to local send behavior for non-git drafts even when worktree mode is persisted", async () => {
const draftId = draftIdFromPath("/draft/draft-non-git-local-fallback");
gitStatusStateRef.current = {
data: {
isRepo: false,
hasOriginRemote: false,
isDefaultBranch: false,
branch: null,
hasWorkingTreeChanges: false,
workingTree: { files: [], insertions: 0, deletions: 0 },
hasUpstream: false,
aheadCount: 0,
behindCount: 0,
pr: null,
},
error: null,
cause: null,
isPending: false,
};
useComposerDraftStore.setState({
draftThreadsByThreadKey: {
[draftId]: {
threadId: THREAD_ID,
environmentId: LOCAL_ENVIRONMENT_ID,
projectId: PROJECT_ID,
logicalProjectKey: PROJECT_DRAFT_KEY,
createdAt: NOW_ISO,
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: null,
envMode: "worktree",
},
},
logicalProjectDraftThreadKeyByLogicalProjectKey: {
[PROJECT_DRAFT_KEY]: draftId,
},
});

const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createDraftOnlySnapshot(),
initialPath: `/draft/${draftId}`,
resolveRpc: (body) => {
if (body._tag === WS_METHODS.gitListBranches) {
return new Promise<never>(() => {});
}
if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) {
return {
sequence: fixture.snapshot.snapshotSequence + 1,
};
}
return undefined;
},
});

try {
useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it");
await waitForLayout();

const sendButton = await waitForSendButton();
expect(sendButton.disabled).toBe(false);
sendButton.click();

await vi.waitFor(
() => {
const dispatchRequest = wsRequests.find(
(request) => request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand,
) as
| {
_tag: string;
bootstrap?: {
createThread?: { projectId?: string };
prepareWorktree?: unknown;
};
}
| undefined;
expect(dispatchRequest).toMatchObject({
_tag: ORCHESTRATION_WS_METHODS.dispatchCommand,
bootstrap: {
createThread: {
projectId: PROJECT_ID,
},
},
});
expect(dispatchRequest?.bootstrap?.prepareWorktree).toBeUndefined();
expect(document.querySelector('button[aria-label="Preparing worktree"]')).toBeNull();
},
{ timeout: 8_000, interval: 16 },
);

expect(useComposerDraftStore.getState().getDraftThread(THREAD_REF)?.envMode ?? null).toBe(
"local",
);
} finally {
await mounted.cleanup();
}
});

it("refreshes pending git status before tripping the worktree base-branch guard", async () => {
const draftId = draftIdFromPath("/draft/draft-non-git-race");
gitStatusStateRef.current = {
data: null,
error: null,
cause: null,
isPending: true,
};
refreshGitStatusResultRef.current = {
isRepo: false,
hasOriginRemote: false,
isDefaultBranch: false,
branch: null,
hasWorkingTreeChanges: false,
workingTree: { files: [], insertions: 0, deletions: 0 },
hasUpstream: false,
aheadCount: 0,
behindCount: 0,
pr: null,
};
useComposerDraftStore.setState({
draftThreadsByThreadKey: {
[draftId]: {
threadId: THREAD_ID,
environmentId: LOCAL_ENVIRONMENT_ID,
projectId: PROJECT_ID,
logicalProjectKey: PROJECT_DRAFT_KEY,
createdAt: NOW_ISO,
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: null,
envMode: "worktree",
},
},
logicalProjectDraftThreadKeyByLogicalProjectKey: {
[PROJECT_DRAFT_KEY]: draftId,
},
});

const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createDraftOnlySnapshot(),
initialPath: `/draft/${draftId}`,
resolveRpc: (body) => {
if (body._tag === WS_METHODS.gitListBranches) {
return new Promise<never>(() => {});
}
if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) {
return {
sequence: fixture.snapshot.snapshotSequence + 1,
};
}
return undefined;
},
});

try {
useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it");
await waitForLayout();

const sendButton = await waitForSendButton();
expect(sendButton.disabled).toBe(false);
sendButton.click();

await vi.waitFor(
() => {
expect(refreshGitStatusSpy).toHaveBeenCalledWith({
environmentId: LOCAL_ENVIRONMENT_ID,
cwd: "/repo/project",
});
const dispatchRequest = wsRequests.find(
(request) => request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand,
) as
| {
_tag: string;
bootstrap?: {
createThread?: { projectId?: string };
prepareWorktree?: unknown;
};
}
| undefined;
expect(dispatchRequest).toMatchObject({
_tag: ORCHESTRATION_WS_METHODS.dispatchCommand,
bootstrap: {
createThread: {
projectId: PROJECT_ID,
},
},
});
expect(dispatchRequest?.bootstrap?.prepareWorktree).toBeUndefined();
},
{ timeout: 8_000, interval: 16 },
);
} finally {
await mounted.cleanup();
}
});

it("toggles plan mode with Shift+Tab only while the composer is focused", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
Expand Down
79 changes: 71 additions & 8 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { truncate } from "@t3tools/shared/String";
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { useShallow } from "zustand/react/shallow";
import { useGitStatus } from "~/lib/gitStatusState";
import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState";
import { usePrimaryEnvironmentId } from "../environments/primary";
import { readEnvironmentApi } from "../environmentApi";
import { isElectron } from "../env";
Expand Down Expand Up @@ -2196,7 +2196,38 @@ export default function ChatView(props: ChatViewProps) {
activeWorktreePath,
hasServerThread: isServerThread,
draftThreadEnvMode: isLocalDraftThread ? draftThread?.envMode : undefined,
isGitRepo,
});
const draftThreadEnvMode = draftThread?.envMode;
const draftThreadBranch = draftThread?.branch;

useEffect(() => {
if (!isLocalDraftThread) {
return;
}
if (!draftThread) {
return;
}
if (gitStatusQuery.data?.isRepo !== false) {
return;
}
if (draftThreadEnvMode !== "worktree" && draftThreadBranch === null) {
return;
}
setDraftThreadContext(composerDraftTarget, {
envMode: "local",
branch: null,
worktreePath: null,
});
}, [
composerDraftTarget,
draftThread,
draftThreadBranch,
draftThreadEnvMode,
gitStatusQuery.data?.isRepo,
isLocalDraftThread,
setDraftThreadContext,
]);

useEffect(() => {
if (!activeThreadId) {
Expand Down Expand Up @@ -2516,16 +2547,48 @@ export default function ChatView(props: ChatViewProps) {
if (!activeProject) return;
const threadIdForSend = activeThread.id;
const isFirstMessage = !isServerThread || activeThread.messages.length === 0;
const baseBranchForWorktree =
isFirstMessage && envMode === "worktree" && !activeThread.worktreePath
? activeThread.branch
: null;
let envModeForSend = envMode;
let threadBranchForSend = activeThread.branch;

if (
isFirstMessage &&
envMode === "worktree" &&
!activeThread.worktreePath &&
!threadBranchForSend &&
gitCwd
) {
const latestGitStatus = await refreshGitStatus({
environmentId,
cwd: gitCwd,
}).catch(() => gitStatusQuery.data);

if (latestGitStatus?.isRepo === false) {
envModeForSend = "local";
threadBranchForSend = null;
if (isLocalDraftThread) {
setDraftThreadContext(composerDraftTarget, {
envMode: "local",
branch: null,
worktreePath: null,
});
}
} else if (latestGitStatus?.branch) {
threadBranchForSend = latestGitStatus.branch;
if (isLocalDraftThread) {
setDraftThreadContext(composerDraftTarget, {
envMode: "worktree",
branch: latestGitStatus.branch,
});
}
}
}

// In worktree mode, require an explicit base branch so we don't silently
// fall back to local execution when branch selection is missing.
const shouldCreateWorktree =
isFirstMessage && envMode === "worktree" && !activeThread.worktreePath;
if (shouldCreateWorktree && !activeThread.branch) {
isFirstMessage && envModeForSend === "worktree" && !activeThread.worktreePath;
const baseBranchForWorktree = shouldCreateWorktree ? threadBranchForSend : null;
if (shouldCreateWorktree && !threadBranchForSend) {
setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode.");
return;
}
Expand Down Expand Up @@ -2659,7 +2722,7 @@ export default function ChatView(props: ChatViewProps) {
modelSelection: threadCreateModelSelection,
runtimeMode,
interactionMode,
branch: activeThread.branch,
branch: threadBranchForSend,
worktreePath: activeThread.worktreePath,
createdAt: activeThread.createdAt,
},
Expand Down
Loading