Skip to content
Open
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: 9 additions & 2 deletions src/browser/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,15 @@ const webApi: IPCApi = {
},
workspace: {
list: () => invokeIPC(IPC_CHANNELS.WORKSPACE_LIST),
create: (projectPath, branchName, trunkBranch) =>
invokeIPC(IPC_CHANNELS.WORKSPACE_CREATE, projectPath, branchName, trunkBranch),
create: (projectPath, branchName, trunkBranch, runtimeConfig, options) =>
invokeIPC(
IPC_CHANNELS.WORKSPACE_CREATE,
projectPath,
branchName,
trunkBranch,
runtimeConfig,
options
),
remove: (workspaceId, options) =>
invokeIPC(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId, options),
rename: (workspaceId, newName) =>
Expand Down
26 changes: 26 additions & 0 deletions src/browser/components/ChatInput/CreationControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ interface CreationControlsProps {
branches: string[];
trunkBranch: string;
onTrunkBranchChange: (branch: string) => void;
fetchLatest: boolean;
onFetchLatestChange: (value: boolean) => void;
runtimeMode: RuntimeMode;
sshHost: string;
onRuntimeChange: (mode: RuntimeMode, host: string) => void;
Expand Down Expand Up @@ -74,6 +76,30 @@ export function CreationControls(props: CreationControlsProps) {
</Tooltip>
</TooltipWrapper>
</div>

{/* Fetch latest option */}
<div className="flex items-center gap-1" data-component="FetchLatestGroup">
<label className="text-muted flex items-center gap-1 text-xs" htmlFor="fetch-latest">
<input
id="fetch-latest"
type="checkbox"
className="accent-accent h-3.5 w-3.5"
checked={props.fetchLatest}
onChange={(event) => props.onFetchLatestChange(event.target.checked)}
disabled={props.disabled}
/>
Fetch latest
</label>
<TooltipWrapper inline>
<span className="text-muted cursor-help text-xs">?</span>
<Tooltip className="tooltip" align="center" width="wide">
<strong>Fetch latest:</strong>
<br />
Runs <code>git fetch origin --prune</code> before branching so new workspaces start from
the freshest remote state.
</Tooltip>
</TooltipWrapper>
</div>
</div>
);
}
2 changes: 2 additions & 0 deletions src/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -993,6 +993,8 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
branches={creationState.branches}
trunkBranch={creationState.trunkBranch}
onTrunkBranchChange={creationState.setTrunkBranch}
fetchLatest={creationState.fetchLatest}
onFetchLatestChange={creationState.setFetchLatest}
runtimeMode={creationState.runtimeMode}
sshHost={creationState.sshHost}
onRuntimeChange={creationState.setRuntimeOptions}
Expand Down
8 changes: 7 additions & 1 deletion src/browser/components/ChatInput/useCreationWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ interface UseCreationWorkspaceReturn {
branches: string[];
trunkBranch: string;
setTrunkBranch: (branch: string) => void;
fetchLatest: boolean;
setFetchLatest: (value: boolean) => void;
runtimeMode: RuntimeMode;
sshHost: string;
setRuntimeOptions: (mode: RuntimeMode, host: string) => void;
Expand All @@ -62,7 +64,7 @@ export function useCreationWorkspace({
const [isSending, setIsSending] = useState(false);

// Centralized draft workspace settings with automatic persistence
const { settings, setRuntimeOptions, setTrunkBranch, getRuntimeString } =
const { settings, setRuntimeOptions, setTrunkBranch, setFetchLatest, getRuntimeString } =
useDraftWorkspaceSettings(projectPath, branches, recommendedTrunk);

// Get send options from shared hook (uses project-scoped storage key)
Expand Down Expand Up @@ -107,6 +109,7 @@ export function useCreationWorkspace({
runtimeConfig,
projectPath, // Pass projectPath when workspaceId is null
trunkBranch: settings.trunkBranch, // Pass selected trunk branch from settings
fetchLatest: settings.fetchLatest, // Fetch remote updates before branching when requested
});

if (!result.success) {
Expand Down Expand Up @@ -139,13 +142,16 @@ export function useCreationWorkspace({
getRuntimeString,
sendMessageOptions,
settings.trunkBranch,
settings.fetchLatest,
]
);

return {
branches,
trunkBranch: settings.trunkBranch,
setTrunkBranch,
fetchLatest: settings.fetchLatest,
setFetchLatest,
runtimeMode: settings.runtimeMode,
sshHost: settings.sshHost,
setRuntimeOptions,
Expand Down
8 changes: 7 additions & 1 deletion src/browser/contexts/WorkspaceContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,13 @@ describe("WorkspaceContext", () => {
result = await ctx().createWorkspace("/gamma", "feature", "main");
});

expect(workspaceApi.create).toHaveBeenCalledWith("/gamma", "feature", "main", undefined);
expect(workspaceApi.create).toHaveBeenCalledWith(
"/gamma",
"feature",
"main",
undefined,
undefined
);
expect(projectsApi.list).toHaveBeenCalled();
expect(result!.workspaceId).toBe("ws-new");
expect(result!.projectPath).toBe("/gamma");
Expand Down
6 changes: 4 additions & 2 deletions src/browser/contexts/WorkspaceContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,8 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
projectPath: string,
branchName: string,
trunkBranch: string,
runtimeConfig?: RuntimeConfig
runtimeConfig?: RuntimeConfig,
options?: { fetchLatest?: boolean }
) => {
console.assert(
typeof trunkBranch === "string" && trunkBranch.trim().length > 0,
Expand All @@ -232,7 +233,8 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
projectPath,
branchName,
trunkBranch,
runtimeConfig
runtimeConfig,
options
);
if (result.success) {
// Backend has already updated the config - reload projects to get updated state
Expand Down
13 changes: 12 additions & 1 deletion src/browser/hooks/useDraftWorkspaceSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getModelKey,
getRuntimeKey,
getTrunkBranchKey,
getFetchLatestKey,
getProjectScopeId,
} from "@/common/constants/storage";
import type { UIMode } from "@/common/types/mode";
Expand All @@ -33,6 +34,7 @@ export interface DraftWorkspaceSettings {
runtimeMode: RuntimeMode;
sshHost: string;
trunkBranch: string;
fetchLatest: boolean;
}

/**
Expand All @@ -52,6 +54,7 @@ export function useDraftWorkspaceSettings(
settings: DraftWorkspaceSettings;
setRuntimeOptions: (mode: RuntimeMode, host: string) => void;
setTrunkBranch: (branch: string) => void;
setFetchLatest: (value: boolean) => void;
getRuntimeString: () => string | undefined;
} {
// Global AI settings (read-only from global state)
Expand All @@ -74,7 +77,13 @@ export function useDraftWorkspaceSettings(
{ listener: true }
);

// Project-scoped trunk branch preference (persisted per project)
// Project-scoped workspace creation preferences (persisted per project)
const [fetchLatest, setFetchLatest] = usePersistedState<boolean>(
getFetchLatestKey(projectPath),
false,
{ listener: true }
);

const [trunkBranch, setTrunkBranch] = usePersistedState<string>(
getTrunkBranchKey(projectPath),
"",
Expand Down Expand Up @@ -112,9 +121,11 @@ export function useDraftWorkspaceSettings(
runtimeMode,
sshHost,
trunkBranch,
fetchLatest,
},
setRuntimeOptions,
setTrunkBranch,
setFetchLatest,
getRuntimeString,
};
}
6 changes: 5 additions & 1 deletion src/browser/utils/chatCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export interface CreateWorkspaceOptions {
runtime?: string;
startMessage?: string;
sendMessageOptions?: SendMessageOptions;
fetchLatest?: boolean;
}

export interface CreateWorkspaceResult {
Expand Down Expand Up @@ -107,12 +108,15 @@ export async function createNewWorkspace(

// Parse runtime config if provided
const runtimeConfig = parseRuntimeString(effectiveRuntime, options.workspaceName);
const creationOptions =
options.fetchLatest !== undefined ? { fetchLatest: options.fetchLatest } : undefined;

const result = await window.api.workspace.create(
options.projectPath,
options.workspaceName,
effectiveTrunk,
runtimeConfig
runtimeConfig,
creationOptions
);

if (!result.success) {
Expand Down
9 changes: 9 additions & 0 deletions src/common/constants/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,15 @@ export function getTrunkBranchKey(projectPath: string): string {
return `trunkBranch:${projectPath}`;
}

/**
* Get the localStorage key for the fetch-latest preference for a project
* Stores the last fetch option state when creating a workspace
* Format: "fetchLatest:{projectPath}"
*/
export function getFetchLatestKey(projectPath: string): string {
return `fetchLatest:${projectPath}`;
}

/**
* Get the localStorage key for the 1M context preference (global)
* Format: "use1MContext"
Expand Down
5 changes: 4 additions & 1 deletion src/common/types/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ export interface SendMessageOptions {
maxOutputTokens?: number;
providerOptions?: MuxProviderOptions;
mode?: string; // Mode name - frontend narrows to specific values, backend accepts any string
fetchLatest?: boolean;
muxMetadata?: MuxFrontendMetadata; // Frontend-defined metadata, backend treats as black-box
}

Expand Down Expand Up @@ -233,7 +234,8 @@ export interface IPCApi {
projectPath: string,
branchName: string,
trunkBranch: string,
runtimeConfig?: RuntimeConfig
runtimeConfig?: RuntimeConfig,
options?: { fetchLatest?: boolean }
): Promise<
{ success: true; metadata: FrontendWorkspaceMetadata } | { success: false; error: string }
>;
Expand All @@ -260,6 +262,7 @@ export interface IPCApi {
runtimeConfig?: RuntimeConfig;
projectPath?: string; // Required when workspaceId is null
trunkBranch?: string; // Optional - trunk branch to branch from (when workspaceId is null)
fetchLatest?: boolean; // Optional - fetch origin before branching (when workspaceId is null)
}
): Promise<
| Result<void, SendMessageError>
Expand Down
5 changes: 3 additions & 2 deletions src/desktop/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,14 @@ const api: IPCApi = {
},
workspace: {
list: () => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_LIST),
create: (projectPath, branchName, trunkBranch: string, runtimeConfig?) =>
create: (projectPath, branchName, trunkBranch: string, runtimeConfig?, options?) =>
ipcRenderer.invoke(
IPC_CHANNELS.WORKSPACE_CREATE,
projectPath,
branchName,
trunkBranch,
runtimeConfig
runtimeConfig,
options
),
remove: (workspaceId: string, options?: { force?: boolean }) =>
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId, options),
Expand Down
32 changes: 30 additions & 2 deletions src/node/runtime/LocalRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ export class LocalRuntime implements Runtime {
}

async createWorkspace(params: WorkspaceCreationParams): Promise<WorkspaceCreationResult> {
const { projectPath, branchName, trunkBranch, initLogger } = params;
const { projectPath, branchName, trunkBranch, initLogger, fetchLatest = false } = params;

try {
// Compute workspace path using the canonical method
Expand All @@ -344,6 +344,34 @@ export class LocalRuntime implements Runtime {
// Workspace doesn't exist, proceed with creation
}

if (fetchLatest) {
initLogger.logStep("Fetching latest from origin...");
try {
using proc = execAsync(`git -C "${projectPath}" fetch origin --prune`);
await proc.result;
initLogger.logStep("Origin fetch complete");
} catch (error) {
const errorMessage = getErrorMessage(error);
return {
success: false,
error: `Failed to fetch origin: ${errorMessage}`,
};
}
}

let baseRef = trunkBranch;
if (fetchLatest) {
try {
using proc = execAsync(
`git -C "${projectPath}" rev-parse --verify "origin/${trunkBranch}"`
);
await proc.result;
baseRef = `origin/${trunkBranch}`;
} catch {
// Remote branch missing - fall back to local trunk branch
}
}

// Check if branch exists locally
const localBranches = await listLocalBranches(projectPath);
const branchExists = localBranches.includes(branchName);
Expand All @@ -358,7 +386,7 @@ export class LocalRuntime implements Runtime {
} else {
// Branch doesn't exist, create it from trunk
using proc = execAsync(
`git -C "${projectPath}" worktree add -b "${branchName}" "${workspacePath}" "${trunkBranch}"`
`git -C "${projectPath}" worktree add -b "${branchName}" "${workspacePath}" "${baseRef}"`
);
await proc.result;
}
Expand Down
4 changes: 4 additions & 0 deletions src/node/runtime/Runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ export interface WorkspaceCreationParams {
directoryName: string;
/** Logger for streaming creation progress and init hook output */
initLogger: InitLogger;
/** Whether to fetch remote updates before branching */
fetchLatest?: boolean;
/** Optional abort signal for cancellation */
abortSignal?: AbortSignal;
}
Expand Down Expand Up @@ -149,6 +151,8 @@ export interface WorkspaceInitParams {
workspacePath: string;
/** Logger for streaming initialization progress and output */
initLogger: InitLogger;
/** Whether to fetch remote updates before branching */
fetchLatest?: boolean;
/** Optional abort signal for cancellation */
abortSignal?: AbortSignal;
}
Expand Down
Loading