Skip to content
Merged
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
18 changes: 16 additions & 2 deletions src/renderer/components/thread/ThreadDraftComposerArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ export function ThreadDraftComposerArea(props: {
onWorktreeModeChange: (worktreeMode: boolean) => void;
onSwitchBranch: (branch: string, createNew: boolean) => void;
onRememberPresentationMode: () => void;
onStart: (input: DraftStartInput) => void;
onStart: (input: DraftStartInput) => void | Promise<void>;
}) {
const [prompt, setPrompt] = useState("");
const [hasContent, setHasContent] = useState(false);
Expand Down Expand Up @@ -393,7 +393,7 @@ export function ThreadDraftComposerArea(props: {
if (props.supportsModePicker) {
props.onRememberPresentationMode();
}
props.onStart({
const startResult = props.onStart({
agentKind: props.selectedAgent.kind,
config: props.config,
prompt: flatPrompt,
Expand All @@ -415,6 +415,20 @@ export function ThreadDraftComposerArea(props: {
}
: {}),
});
// On success the draft pane unmounts (replaced by the launched thread), so
// this state never matters. On failure (e.g. worktree creation errored) the
// pane stays mounted — re-enable the composer instead of leaving it stuck on
// the launch spinner with the user's prompt trapped behind it. `onStart` may
// return void or a promise; Promise.resolve normalizes both.
void Promise.resolve(startResult).catch(() => {
submittedRef.current = false;
// resetDraftRefs() above cleared the snapshot the unmount-cleanup save
// reads. The prompt is still in the editor, so re-capture it — otherwise
// navigating away without another edit would silently drop it.
latestSegmentsRef.current = mentionRef.current?.serializeSegments() ?? [];
attachmentsRef.current = attachments.attachments;
setIsSubmitting(false);
});
}

useLayoutEffect(() => {
Expand Down
29 changes: 29 additions & 0 deletions src/renderer/components/thread/ThreadDraftView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,35 @@ describe("ThreadDraftView", () => {
});
});

it("re-enables the composer when onStart rejects (e.g. worktree creation fails)", async () => {
const onStart = vi.fn<(input: unknown) => void | Promise<void>>(() =>
Promise.reject(new Error("worktree creation failed")),
);

render(
<ThreadDraftView project={project} agentStatuses={[dualModeCodexStatus]} onStart={onStart} />,
);

await waitFor(() => {
const props = composerSpy.mock.lastCall?.[0] as { controls: Array<{ kind?: string }> };
expect(props.controls.some((c) => c.kind === "provider-model")).toBe(true);
});

fireEvent.click(screen.getByText("set-prompt"));
await act(async () => {
fireEvent.click(screen.getByText("submit"));
});

expect(onStart).toHaveBeenCalledTimes(1);

// Once the rejection settles the composer is interactive again rather than
// frozen on the launch spinner with the prompt trapped behind it.
await waitFor(() => {
const props = composerSpy.mock.lastCall?.[0] as { submitPending?: boolean };
expect(props.submitPending).toBe(false);
});
});

it("defaults Home Codex drafts to provider defaults, same as any other project", async () => {
const onStart = vi.fn<(input: unknown) => void>();
useSharedSettings.setState({
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/components/thread/ThreadDraftView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ export function ThreadDraftView(props: {
droppableRef?: React.RefObject<HTMLDivElement | null>;
onClose?: (() => void) | undefined;
dragHandleRef?: React.RefCallback<Element>;
onStart: (input: DraftStartInput) => void;
onStart: (input: DraftStartInput) => void | Promise<void>;
}) {
const {
project,
Expand Down
37 changes: 10 additions & 27 deletions src/renderer/views/MainView/parts/AppContent/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { useShallow } from "zustand/shallow";
import { X } from "lucide-react";
import { toast } from "@heroui/react";
import type {
AgentStatus,
ExtractContextResult,
Project,
PromptSegment,
Expand All @@ -11,6 +10,7 @@ import type {
ThreadPresentationMode,
} from "@/shared/contracts";
import { getProjectAgentStatuses } from "@/shared/agentStatus";
import { friendlyError } from "@/shared/messages";
import { isHomeProject } from "@/shared/homeScope";
import { buildWorktreeLocation } from "@/shared/worktree";
import { isDraftPaneId, parseDraftProjectId } from "@/shared/paneId";
Expand All @@ -34,6 +34,7 @@ import { closeAllPanels } from "@/renderer/actions/panelActions";
import { SplitPaneContainer, type Rect } from "@/renderer/components/layout/SplitPaneContainer";
import { macosTrafficLightPadClass } from "@/renderer/components/layout/sidebarChrome";
import { ThreadDraftView } from "@/renderer/components/thread/ThreadDraftView";
import type { DraftStartInput } from "@/renderer/components/thread/ThreadDraftComposerArea";
import {
normalizeShellScript,
startShellWithToast,
Expand Down Expand Up @@ -62,18 +63,7 @@ export function AppContent() {
});
async function handleDraftStart(
project: Project,
input: {
agentKind: AgentStatus["kind"];
config: import("@/shared/contracts").ThreadConfig;
prompt: string;
segments?: PromptSegment[];
existingWorktreePath?: string;
worktreeBranch?: string;
worktreeBaseBranch?: string;
worktreeIsNewBranch?: boolean;
worktreeTransferUncommitted?: boolean;
presentationMode?: import("@/shared/contracts").ThreadPresentationMode;
},
input: DraftStartInput,
replacePaneIdParam?: string,
) {
const {
Expand Down Expand Up @@ -127,7 +117,10 @@ export function AppContent() {
}
} catch (err) {
console.error("[renderer] failed to create worktree:", err);
return;
// Surface the failure and propagate so the composer can re-enable
// itself — otherwise it stays stuck on the launch spinner forever.
toast.danger(friendlyError(err));
throw err;
}
}

Expand Down Expand Up @@ -288,7 +281,7 @@ export function AppContent() {
<DraftViewContent
project={draftProject}
lastDraftConfig={draftLastDraftConfig}
onStart={(input) => void handleDraftStart(draftProject, input)}
onStart={(input) => handleDraftStart(draftProject, input)}
/>
</div>
);
Expand Down Expand Up @@ -333,7 +326,7 @@ export function AppContent() {
paneAlign={paneAlign}
headerNeedsTrafficLightPad={headerNeedsTrafficLightPad}
onClose={() => closePane(paneId)}
onStart={(project, input) => void handleDraftStart(project, input, paneId)}
onStart={(project, input) => handleDraftStart(project, input, paneId)}
/>
) : (
<ThreadPane
Expand Down Expand Up @@ -485,17 +478,7 @@ function removeWorktreeSetupTab(tab: DevTerminalTab): void {
function DraftViewContent(props: {
project: Project;
lastDraftConfig?: Project["lastDraftConfig"];
onStart: (input: {
agentKind: AgentStatus["kind"];
config: ThreadConfig;
prompt: string;
segments?: PromptSegment[];
existingWorktreePath?: string;
worktreeBranch?: string;
worktreeBaseBranch?: string;
worktreeIsNewBranch?: boolean;
worktreeTransferUncommitted?: boolean;
}) => void;
onStart: (input: DraftStartInput) => void | Promise<void>;
}) {
const { project, lastDraftConfig, onStart } = props;
const projectAgentStatuses = useAgentStatusesStore(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useRef } from "react";
import type { AgentStatus, Project, PromptSegment } from "@/shared/contracts";
import type { Project } from "@/shared/contracts";
import { getProjectAgentStatuses } from "@/shared/agentStatus";
import {
isDetectingAgentsForLocation,
Expand All @@ -10,6 +10,7 @@ import {
useProjectWithoutDraftConfig,
} from "@/renderer/state/useThread";
import { ThreadDraftView } from "@/renderer/components/thread/ThreadDraftView";
import type { DraftStartInput } from "@/renderer/components/thread/ThreadDraftComposerArea";
import { useDraggable, useDroppable } from "@dnd-kit/react";
import { useShallow } from "zustand/shallow";
import { useIsDraggingPane, usePaneDropIndicatorState, type DragSourceData } from "@/renderer/dnd";
Expand All @@ -21,21 +22,7 @@ export function DraftPane(props: {
paneAlign: "left" | "center" | "right";
headerNeedsTrafficLightPad?: boolean;
onClose: () => void;
onStart: (
project: Project,
input: {
agentKind: AgentStatus["kind"];
config: import("@/shared/contracts").ThreadConfig;
prompt: string;
segments?: PromptSegment[];
existingWorktreePath?: string;
worktreeBranch?: string;
worktreeBaseBranch?: string;
worktreeIsNewBranch?: boolean;
worktreeTransferUncommitted?: boolean;
presentationMode?: import("@/shared/contracts").ThreadPresentationMode;
},
) => void;
onStart: (project: Project, input: DraftStartInput) => void | Promise<void>;
}) {
const project = useProjectWithoutDraftConfig(props.projectId);
const initialLastDraftConfig = useInitialProjectDraftConfig(props.projectId);
Expand Down
161 changes: 161 additions & 0 deletions src/supervisor/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,167 @@ describe("GitService.addWorktree", () => {
expect(configCall![1]).toContain("master");
});

it("qualifies a bare remote-tracking branch start point with its remote", async () => {
// The composer passes a remote branch's short name (no "origin/" prefix) as
// the base. On its own that is not a valid object, so the worktree add must
// fork from the qualified `origin/<name>` ref instead.
mockGitCommands((args) => {
if (args[0] === "worktree" && args[1] === "add") return { stdout: "" };
if (args[0] === "remote") return { stdout: "origin\n" };
if (args[0] === "rev-parse") {
const ref = args[args.length - 1];
// The bare name does not resolve; only the qualified remote ref does.
if (ref === "refs/remotes/origin/lightcode/silver-meadow-abcd") return { stdout: "sha\n" };
return { error: new Error("fatal: Needed a single revision") };
}
if (args[0] === "config") return { stdout: "" };
return { stdout: "" };
});

await new GitService().addWorktree(
location,
"C:\\Users\\demo\\.lightcode\\worktrees\\lightcode-12345678\\lightcode-brave-heron",
"lightcode/brave-heron",
true,
"lightcode/silver-meadow-abcd",
);

const commands = execFileMock.mock.calls.map((c: unknown[]) => (c[1] as string[]).join(" "));
expect(
commands.some((c) =>
c.includes(
"worktree add -b lightcode/brave-heron " +
"C:\\Users\\demo\\.lightcode\\worktrees\\lightcode-12345678\\lightcode-brave-heron " +
"origin/lightcode/silver-meadow-abcd",
),
),
).toBe(true);

// The recorded source branch is the qualified ref, so diff bases line up.
const configCall = execFileMock.mock.calls.find(
(call: unknown[]) =>
Array.isArray(call[1]) &&
(call[1] as string[])[0] === "config" &&
(call[1] as string[]).includes("branch.lightcode/brave-heron.lightcodeSource"),
);
expect(configCall).toBeDefined();
expect(configCall![1]).toContain("origin/lightcode/silver-meadow-abcd");
});

it("leaves a start point untouched when it resolves locally", async () => {
const revParseRefs: string[] = [];
mockGitCommands((args) => {
if (args[0] === "worktree" && args[1] === "add") return { stdout: "" };
if (args[0] === "rev-parse") {
revParseRefs.push(args[args.length - 1]!);
return { stdout: "sha\n" }; // resolves directly — no remote lookup needed
}
if (args[0] === "config") return { stdout: "" };
return { stdout: "" };
});

await new GitService().addWorktree(
location,
"C:\\Users\\demo\\.lightcode\\worktrees\\lightcode-12345678\\lightcode-brave-heron",
"lightcode/brave-heron",
true,
"main",
);

const commands = execFileMock.mock.calls.map((c: unknown[]) => (c[1] as string[]).join(" "));
expect(
commands.some(
(c) => c.includes("worktree add -b lightcode/brave-heron") && c.endsWith("main"),
),
).toBe(true);
// A resolvable start point short-circuits before any `git remote` lookup.
expect(commands.some((c) => c === "remote")).toBe(false);
expect(revParseRefs).toEqual(["main"]);
});

it("qualifies with the single matching remote when several remotes are configured", async () => {
// Two remotes exist, but only `upstream` carries the branch — qualify with it.
mockGitCommands((args) => {
if (args[0] === "worktree" && args[1] === "add") return { stdout: "" };
if (args[0] === "remote") return { stdout: "origin\nupstream\n" };
if (args[0] === "rev-parse") {
const ref = args[args.length - 1];
if (ref === "refs/remotes/upstream/feature/x") return { stdout: "sha\n" };
return { error: new Error("fatal: Needed a single revision") };
}
if (args[0] === "config") return { stdout: "" };
return { stdout: "" };
});

await new GitService().addWorktree(
location,
"C:\\Users\\demo\\.lightcode\\worktrees\\lightcode-12345678\\lightcode-brave-heron",
"lightcode/brave-heron",
true,
"feature/x",
);

const wtAdd = execFileMock.mock.calls
.map((c: unknown[]) => (c[1] as string[]).join(" "))
.find((c) => c.startsWith("worktree add -b lightcode/brave-heron"));
expect(wtAdd?.endsWith("upstream/feature/x")).toBe(true);
});

it("prefers origin when the branch name exists on multiple remotes", async () => {
// Both `origin` and `upstream` carry the name — prefer origin, matching the
// module's origin-centric diff-base handling, rather than failing as ambiguous.
mockGitCommands((args) => {
if (args[0] === "worktree" && args[1] === "add") return { stdout: "" };
if (args[0] === "remote") return { stdout: "upstream\norigin\n" };
if (args[0] === "rev-parse") {
const ref = args[args.length - 1];
if (ref === "refs/remotes/origin/feature/x" || ref === "refs/remotes/upstream/feature/x") {
return { stdout: "sha\n" };
}
return { error: new Error("fatal: Needed a single revision") };
}
if (args[0] === "config") return { stdout: "" };
return { stdout: "" };
});

await new GitService().addWorktree(
location,
"C:\\Users\\demo\\.lightcode\\worktrees\\lightcode-12345678\\lightcode-brave-heron",
"lightcode/brave-heron",
true,
"feature/x",
);

const wtAdd = execFileMock.mock.calls
.map((c: unknown[]) => (c[1] as string[]).join(" "))
.find((c) => c.startsWith("worktree add -b lightcode/brave-heron"));
expect(wtAdd?.endsWith("origin/feature/x")).toBe(true);
});

it("forks a local branch directly when its name also exists on a remote", async () => {
// The bare name resolves locally, so the worktree forks the local branch and
// never consults remotes (no qualification, no `git remote` call).
mockGitCommands((args) => {
if (args[0] === "worktree" && args[1] === "add") return { stdout: "" };
if (args[0] === "rev-parse") return { stdout: "sha\n" }; // resolves locally
if (args[0] === "config") return { stdout: "" };
return { stdout: "" };
});

await new GitService().addWorktree(
location,
"C:\\Users\\demo\\.lightcode\\worktrees\\lightcode-12345678\\lightcode-brave-heron",
"lightcode/brave-heron",
true,
"feature/x",
);

const commands = execFileMock.mock.calls.map((c: unknown[]) => (c[1] as string[]).join(" "));
const wtAdd = commands.find((c) => c.startsWith("worktree add -b lightcode/brave-heron"));
expect(wtAdd?.endsWith("feature/x")).toBe(true);
expect(commands.some((c) => c === "remote")).toBe(false);
});

it("resolves default WSL worktree paths through the bridge", async () => {
const home = vi.fn<() => Promise<{ home: string }>>(async () => ({ home: "/home/demo" }));
const bridgeMkdir = vi.fn<() => Promise<void>>(async () => undefined);
Expand Down
Loading