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
8 changes: 6 additions & 2 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,9 @@ The IPC layer is the boundary between backend and frontend. Follow these rules t

```typescript
// ✅ GOOD - Frontend combines backend data with context it already has
const result = await window.api.workspace.create(projectPath, branchName);
const { recommendedTrunk } = await window.api.projects.listBranches(projectPath);
const trunkBranch = recommendedTrunk ?? "main";
const result = await window.api.workspace.create(projectPath, branchName, trunkBranch);
if (result.success) {
setSelectedWorkspace({
...result.metadata,
Expand All @@ -404,7 +406,9 @@ The IPC layer is the boundary between backend and frontend. Follow these rules t
}

// ❌ BAD - Backend returns frontend-specific data
const result = await window.api.workspace.create(projectPath, branchName);
const { recommendedTrunk } = await window.api.projects.listBranches(projectPath);
const trunkBranch = recommendedTrunk ?? "main";
const result = await window.api.workspace.create(projectPath, branchName, trunkBranch);
if (result.success) {
setSelectedWorkspace(result.workspace); // Backend shouldn't know about WorkspaceSelection
}
Expand Down
40 changes: 36 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { GitStatusProvider } from "./contexts/GitStatusContext";
import type { ThinkingLevel } from "./types/thinking";
import { CUSTOM_EVENTS } from "./constants/events";
import { getThinkingLevelKey } from "./constants/storage";
import type { BranchListResult } from "./types/ipc";

const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"];

Expand Down Expand Up @@ -206,10 +207,15 @@ function AppInner() {
setWorkspaceModalOpen(true);
}, []);

const handleCreateWorkspace = async (branchName: string) => {
const handleCreateWorkspace = async (branchName: string, trunkBranch: string) => {
if (!workspaceModalProject) return;

const newWorkspace = await createWorkspace(workspaceModalProject, branchName);
console.assert(
typeof trunkBranch === "string" && trunkBranch.trim().length > 0,
"Expected trunk branch to be provided by the workspace modal"
);

const newWorkspace = await createWorkspace(workspaceModalProject, branchName, trunkBranch);
if (newWorkspace) {
setSelectedWorkspace(newWorkspace);
}
Expand Down Expand Up @@ -329,13 +335,38 @@ function AppInner() {
);

const createWorkspaceFromPalette = useCallback(
async (projectPath: string, branchName: string) => {
const newWs = await createWorkspace(projectPath, branchName);
async (projectPath: string, branchName: string, trunkBranch: string) => {
console.assert(
typeof trunkBranch === "string" && trunkBranch.trim().length > 0,
"Expected trunk branch to be provided by the command palette"
);
const newWs = await createWorkspace(projectPath, branchName, trunkBranch);
if (newWs) setSelectedWorkspace(newWs);
},
[createWorkspace, setSelectedWorkspace]
);

const getBranchesForProject = useCallback(
async (projectPath: string): Promise<BranchListResult> => {
const branchResult = await window.api.projects.listBranches(projectPath);
const sanitizedBranches = Array.isArray(branchResult?.branches)
? branchResult.branches.filter((branch): branch is string => typeof branch === "string")
: [];

const recommended =
typeof branchResult?.recommendedTrunk === "string" &&
sanitizedBranches.includes(branchResult.recommendedTrunk)
? branchResult.recommendedTrunk
: (sanitizedBranches[0] ?? "");

return {
branches: sanitizedBranches,
recommendedTrunk: recommended,
};
},
[]
);

const selectWorkspaceFromPalette = useCallback(
(selection: {
projectPath: string;
Expand Down Expand Up @@ -389,6 +420,7 @@ function AppInner() {
onSetThinkingLevel: setThinkingLevelFromPalette,
onOpenNewWorkspaceModal: openNewWorkspaceFromPalette,
onCreateWorkspace: createWorkspaceFromPalette,
getBranchesForProject,
onSelectWorkspace: selectWorkspaceFromPalette,
onRemoveWorkspace: removeWorkspaceFromPalette,
onRenameWorkspace: renameWorkspaceFromPalette,
Expand Down
64 changes: 62 additions & 2 deletions src/components/CommandPalette.tsx
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(essentially ignoring this file for the purpose of review)

Original file line number Diff line number Diff line change
Expand Up @@ -306,9 +306,65 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({ getSlashContext
}
}, [activePrompt]);

const [selectOptions, setSelectOptions] = useState<
Array<{ id: string; label: string; keywords?: string[] }>
>([]);
const [isLoadingOptions, setIsLoadingOptions] = useState(false);

const currentField: PromptField | null = activePrompt
? (activePrompt.fields[activePrompt.idx] ?? null)
: null;

useEffect(() => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this code is kind of monstrous -- needs comment or simplification

// Select prompts can return options synchronously or as a promise. This effect normalizes
// both flows, keeps the loading state in sync, and bails out early if the prompt switches
// while a request is in flight.
let cancelled = false;

const resetState = () => {
if (cancelled) return;
setSelectOptions([]);
setIsLoadingOptions(false);
};

const hydrateSelectOptions = async () => {
if (!currentField || currentField.type !== "select") {
resetState();
return;
}

setIsLoadingOptions(true);
try {
const rawOptions = await Promise.resolve(
currentField.getOptions(activePrompt?.values ?? {})
);

if (!Array.isArray(rawOptions)) {
throw new Error("Prompt select options must resolve to an array");
}

if (!cancelled) {
setSelectOptions(rawOptions);
}
} catch (error) {
if (!cancelled) {
console.error("Failed to resolve prompt select options", error);
setSelectOptions([]);
}
} finally {
if (!cancelled) {
setIsLoadingOptions(false);
}
}
};

void hydrateSelectOptions();

return () => {
cancelled = true;
};
}, [currentField, activePrompt]);

const isSlashQuery = !currentField && query.trim().startsWith("/");
const shouldUseCmdkFilter = currentField ? currentField.type === "select" : !isSlashQuery;

Expand All @@ -318,7 +374,7 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({ getSlashContext
if (currentField) {
const promptTitle = activePrompt?.title ?? currentField.label ?? "Provide details";
if (currentField.type === "select") {
const options = currentField.getOptions(activePrompt?.values ?? {});
const options = selectOptions;
groups = [
{
name: promptTitle,
Expand All @@ -331,7 +387,11 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({ getSlashContext
})),
},
];
emptyText = options.length ? undefined : "No options";
emptyText = isLoadingOptions
? "Loading options..."
: options.length
? undefined
: "No options";
} else {
const typed = query.trim();
const fallbackHint = currentField.placeholder ?? "Type value and press Enter";
Expand Down
127 changes: 121 additions & 6 deletions src/components/NewWorkspaceModal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useId } from "react";
import React, { useEffect, useId, useState } from "react";
import styled from "@emotion/styled";
import { Modal, ModalInfo, ModalActions, CancelButton, PrimaryButton } from "./Modal";

Expand All @@ -12,7 +12,8 @@ const FormGroup = styled.div`
font-size: 14px;
}

input {
input,
select {
width: 100%;
padding: 8px 12px;
background: #2d2d2d;
Expand All @@ -31,6 +32,15 @@ const FormGroup = styled.div`
cursor: not-allowed;
}
}

select {
cursor: pointer;

option {
background: #2d2d2d;
color: #fff;
}
}
`;

const ErrorMessage = styled.div`
Expand All @@ -48,7 +58,7 @@ interface NewWorkspaceModalProps {
isOpen: boolean;
projectPath: string;
onClose: () => void;
onAdd: (branchName: string) => Promise<void>;
onAdd: (branchName: string, trunkBranch: string) => Promise<void>;
}

const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
Expand All @@ -58,13 +68,20 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
onAdd,
}) => {
const [branchName, setBranchName] = useState("");
const [trunkBranch, setTrunkBranch] = useState("");
const [defaultTrunkBranch, setDefaultTrunkBranch] = useState("");
const [branches, setBranches] = useState<string[]>([]);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [branchesError, setBranchesError] = useState<string | null>(null);
const infoId = useId();

const handleCancel = () => {
setBranchName("");
setTrunkBranch(defaultTrunkBranch);
setError(null);
setBranchesError(null);
onClose();
};

Expand All @@ -76,12 +93,22 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
return;
}

const normalizedTrunkBranch = trunkBranch.trim();
if (normalizedTrunkBranch.length === 0) {
setError("Trunk branch is required");
return;
}

console.assert(normalizedTrunkBranch.length > 0, "Expected trunk branch name to be validated");
console.assert(trimmedBranchName.length > 0, "Expected branch name to be validated");

setIsLoading(true);
setError(null);

try {
await onAdd(trimmedBranchName);
await onAdd(trimmedBranchName, normalizedTrunkBranch);
setBranchName("");
setTrunkBranch(defaultTrunkBranch);
onClose();
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create workspace";
Expand All @@ -91,6 +118,60 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
}
};

// Load branches when modal opens
useEffect(() => {
if (!isOpen) {
return;
}

const loadBranches = async () => {
setIsLoadingBranches(true);
setBranchesError(null);
try {
const branchList = await window.api.projects.listBranches(projectPath);
const rawBranches = Array.isArray(branchList?.branches) ? branchList.branches : [];
const sanitizedBranches = rawBranches.filter(
(branch): branch is string => typeof branch === "string"
);

if (!Array.isArray(branchList?.branches)) {
console.warn("Expected listBranches to return BranchListResult", branchList);
}

setBranches(sanitizedBranches);

if (sanitizedBranches.length === 0) {
setTrunkBranch("");
setDefaultTrunkBranch("");
setBranchesError("No branches available in this project");
return;
}

const recommended =
typeof branchList?.recommendedTrunk === "string" &&
sanitizedBranches.includes(branchList.recommendedTrunk)
? branchList.recommendedTrunk
: sanitizedBranches[0];

setBranchesError(null);
setDefaultTrunkBranch(recommended);
setTrunkBranch((current) =>
current && sanitizedBranches.includes(current) ? current : recommended
);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to load branches";
setBranches([]);
setTrunkBranch("");
setDefaultTrunkBranch("");
setBranchesError(message);
} finally {
setIsLoadingBranches(false);
}
};

void loadBranches();
}, [isOpen, projectPath]);

const projectName = projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "project";

return (
Expand All @@ -104,7 +185,7 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
>
<form onSubmit={(event) => void handleSubmit(event)}>
<FormGroup>
<label htmlFor="branchName">Branch Name:</label>
<label htmlFor="branchName">Workspace Branch Name:</label>
<input
id="branchName"
type="text"
Expand All @@ -122,6 +203,31 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
{error && <ErrorMessage>{error}</ErrorMessage>}
</FormGroup>

<FormGroup>
<label htmlFor="trunkBranch">Trunk Branch:</label>
<select
id="trunkBranch"
value={trunkBranch}
onChange={(event) => setTrunkBranch(event.target.value)}
disabled={isLoading || isLoadingBranches || branches.length === 0}
required
aria-required="true"
>
{isLoadingBranches ? (
<option value="">Loading branches...</option>
) : branches.length === 0 ? (
<option value="">No branches available</option>
) : (
branches.map((branch) => (
<option key={branch} value={branch}>
{branch}
</option>
))
)}
</select>
{branchesError && <ErrorMessage>{branchesError}</ErrorMessage>}
</FormGroup>

<ModalInfo id={infoId}>
<p>This will create a git worktree at:</p>
<InfoCode>
Expand All @@ -133,7 +239,16 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
<CancelButton type="button" onClick={handleCancel} disabled={isLoading}>
Cancel
</CancelButton>
<PrimaryButton type="submit" disabled={isLoading || branchName.trim().length === 0}>
<PrimaryButton
type="submit"
disabled={
isLoading ||
isLoadingBranches ||
branchName.trim().length === 0 ||
trunkBranch.trim().length === 0 ||
branches.length === 0
}
>
{isLoading ? "Creating..." : "Create Workspace"}
</PrimaryButton>
</ModalActions>
Expand Down
1 change: 1 addition & 0 deletions src/constants/ipc-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const IPC_CHANNELS = {
PROJECT_CREATE: "project:create",
PROJECT_REMOVE: "project:remove",
PROJECT_LIST: "project:list",
PROJECT_LIST_BRANCHES: "project:listBranches",
PROJECT_SECRETS_GET: "project:secrets:get",
PROJECT_SECRETS_UPDATE: "project:secrets:update",

Expand Down
Loading
Loading