Skip to content

Commit 5f61c90

Browse files
committed
🤖 feat: add 'Pull latest' checkbox to workspace creation
Add checkbox after the From: dropdown that fetches and rebases the workspace on the latest version of the trunk branch from origin. UI Changes: - Add checkbox to CreationControls.tsx with persisted state - Expose pullLatest setting via useDraftWorkspaceSettings hook - Wire through useCreationWorkspace to sendMessage options Backend Changes: - Add pullLatest param to WorkspaceCreationParams and WorkspaceInitParams - LocalRuntime: fetch origin/trunk and rebase after worktree creation - SSHRuntime: fetch and rebase after branch checkout during init Error Handling: - Network errors during fetch: log warning, continue without pull - Rebase conflicts: abort rebase, log warning, continue with original state - Best-effort operation that never fails workspace creation _Generated with mux_
1 parent d7560e1 commit 5f61c90

File tree

11 files changed

+195
-5
lines changed

11 files changed

+195
-5
lines changed

src/browser/components/ChatInput/CreationControls.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ import React from "react";
22
import { RUNTIME_MODE, type RuntimeMode } from "@/common/types/runtime";
33
import { TooltipWrapper, Tooltip } from "../Tooltip";
44
import { Select } from "../Select";
5+
import { Checkbox } from "../ui/checkbox";
56

67
interface CreationControlsProps {
78
branches: string[];
89
trunkBranch: string;
910
onTrunkBranchChange: (branch: string) => void;
11+
pullLatest: boolean;
12+
onPullLatestChange: (checked: boolean) => void;
1013
runtimeMode: RuntimeMode;
1114
sshHost: string;
1215
onRuntimeChange: (mode: RuntimeMode, host: string) => void;
@@ -35,6 +38,18 @@ export function CreationControls(props: CreationControlsProps) {
3538
disabled={props.disabled}
3639
className="max-w-[120px]"
3740
/>
41+
<div className="flex items-center gap-1">
42+
<Checkbox
43+
id="pull-latest"
44+
checked={props.pullLatest}
45+
onCheckedChange={(checked) => props.onPullLatestChange(checked === true)}
46+
disabled={props.disabled}
47+
className="h-3.5 w-3.5"
48+
/>
49+
<label htmlFor="pull-latest" className="text-muted cursor-pointer text-xs select-none">
50+
Pull latest
51+
</label>
52+
</div>
3853
</div>
3954
)}
4055

src/browser/components/ChatInput/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -989,6 +989,8 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
989989
branches={creationState.branches}
990990
trunkBranch={creationState.trunkBranch}
991991
onTrunkBranchChange={creationState.setTrunkBranch}
992+
pullLatest={creationState.pullLatest}
993+
onPullLatestChange={creationState.setPullLatest}
992994
runtimeMode={creationState.runtimeMode}
993995
sshHost={creationState.sshHost}
994996
onRuntimeChange={creationState.setRuntimeOptions}

src/browser/components/ChatInput/useCreationWorkspace.test.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,18 +266,21 @@ function createDraftSettingsHarness(
266266
runtimeMode: RuntimeMode;
267267
sshHost: string;
268268
trunkBranch: string;
269+
pullLatest: boolean;
269270
runtimeString?: string | undefined;
270271
}>
271272
) {
272273
const state = {
273274
runtimeMode: initial?.runtimeMode ?? ("local" as RuntimeMode),
274275
sshHost: initial?.sshHost ?? "",
275276
trunkBranch: initial?.trunkBranch ?? "main",
277+
pullLatest: initial?.pullLatest ?? true,
276278
runtimeString: initial?.runtimeString,
277279
} satisfies {
278280
runtimeMode: RuntimeMode;
279281
sshHost: string;
280282
trunkBranch: string;
283+
pullLatest: boolean;
281284
runtimeString: string | undefined;
282285
};
283286

@@ -292,17 +295,23 @@ function createDraftSettingsHarness(
292295
state.trunkBranch = branch;
293296
});
294297

298+
const setPullLatest = mock((enabled: boolean) => {
299+
state.pullLatest = enabled;
300+
});
301+
295302
const getRuntimeString = mock(() => state.runtimeString);
296303

297304
return {
298305
state,
299306
setRuntimeOptions,
300307
setTrunkBranch,
308+
setPullLatest,
301309
getRuntimeString,
302310
snapshot(): {
303311
settings: DraftWorkspaceSettings;
304312
setRuntimeOptions: typeof setRuntimeOptions;
305313
setTrunkBranch: typeof setTrunkBranch;
314+
setPullLatest: typeof setPullLatest;
306315
getRuntimeString: typeof getRuntimeString;
307316
} {
308317
const settings: DraftWorkspaceSettings = {
@@ -312,11 +321,13 @@ function createDraftSettingsHarness(
312321
runtimeMode: state.runtimeMode,
313322
sshHost: state.sshHost,
314323
trunkBranch: state.trunkBranch,
324+
pullLatest: state.pullLatest,
315325
};
316326
return {
317327
settings,
318328
setRuntimeOptions,
319329
setTrunkBranch,
330+
setPullLatest,
320331
getRuntimeString,
321332
};
322333
},

src/browser/components/ChatInput/useCreationWorkspace.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ interface UseCreationWorkspaceReturn {
4343
branches: string[];
4444
trunkBranch: string;
4545
setTrunkBranch: (branch: string) => void;
46+
pullLatest: boolean;
47+
setPullLatest: (enabled: boolean) => void;
4648
runtimeMode: RuntimeMode;
4749
sshHost: string;
4850
setRuntimeOptions: (mode: RuntimeMode, host: string) => void;
@@ -69,7 +71,7 @@ export function useCreationWorkspace({
6971
const [isSending, setIsSending] = useState(false);
7072

7173
// Centralized draft workspace settings with automatic persistence
72-
const { settings, setRuntimeOptions, setTrunkBranch, getRuntimeString } =
74+
const { settings, setRuntimeOptions, setTrunkBranch, setPullLatest, getRuntimeString } =
7375
useDraftWorkspaceSettings(projectPath, branches, recommendedTrunk);
7476

7577
// Get send options from shared hook (uses project-scoped storage key)
@@ -114,6 +116,7 @@ export function useCreationWorkspace({
114116
runtimeConfig,
115117
projectPath, // Pass projectPath when workspaceId is null
116118
trunkBranch: settings.trunkBranch, // Pass selected trunk branch from settings
119+
pullLatest: settings.pullLatest, // Pass pull latest preference
117120
});
118121

119122
if (!result.success) {
@@ -162,13 +165,16 @@ export function useCreationWorkspace({
162165
getRuntimeString,
163166
sendMessageOptions,
164167
settings.trunkBranch,
168+
settings.pullLatest,
165169
]
166170
);
167171

168172
return {
169173
branches,
170174
trunkBranch: settings.trunkBranch,
171175
setTrunkBranch,
176+
pullLatest: settings.pullLatest,
177+
setPullLatest,
172178
runtimeMode: settings.runtimeMode,
173179
sshHost: settings.sshHost,
174180
setRuntimeOptions,

src/browser/hooks/useDraftWorkspaceSettings.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
getModelKey,
1313
getRuntimeKey,
1414
getTrunkBranchKey,
15+
getPullLatestKey,
1516
getProjectScopeId,
1617
} from "@/common/constants/storage";
1718
import type { UIMode } from "@/common/types/mode";
@@ -31,6 +32,7 @@ export interface DraftWorkspaceSettings {
3132
runtimeMode: RuntimeMode;
3233
sshHost: string;
3334
trunkBranch: string;
35+
pullLatest: boolean;
3436
}
3537

3638
/**
@@ -50,6 +52,7 @@ export function useDraftWorkspaceSettings(
5052
settings: DraftWorkspaceSettings;
5153
setRuntimeOptions: (mode: RuntimeMode, host: string) => void;
5254
setTrunkBranch: (branch: string) => void;
55+
setPullLatest: (enabled: boolean) => void;
5356
getRuntimeString: () => string | undefined;
5457
} {
5558
// Global AI settings (read-only from global state)
@@ -78,6 +81,13 @@ export function useDraftWorkspaceSettings(
7881
{ listener: true }
7982
);
8083

84+
// Project-scoped pull latest preference (persisted per project, default true)
85+
const [pullLatest, setPullLatest] = usePersistedState<boolean>(
86+
getPullLatestKey(projectPath),
87+
true,
88+
{ listener: true }
89+
);
90+
8191
// Parse runtime string into mode and host
8292
const { mode: runtimeMode, host: sshHost } = parseRuntimeModeAndHost(runtimeString);
8393

@@ -108,9 +118,11 @@ export function useDraftWorkspaceSettings(
108118
runtimeMode,
109119
sshHost,
110120
trunkBranch,
121+
pullLatest,
111122
},
112123
setRuntimeOptions,
113124
setTrunkBranch,
125+
setPullLatest,
114126
getRuntimeString,
115127
};
116128
}

src/common/constants/storage.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,15 @@ export function getTrunkBranchKey(projectPath: string): string {
114114
return `trunkBranch:${projectPath}`;
115115
}
116116

117+
/**
118+
* Get the localStorage key for pull latest preference for a project
119+
* Stores whether to fetch and rebase on latest origin/<trunk> when creating a workspace
120+
* Format: "pullLatest:{projectPath}"
121+
*/
122+
export function getPullLatestKey(projectPath: string): string {
123+
return `pullLatest:${projectPath}`;
124+
}
125+
117126
/**
118127
* Get the localStorage key for the preferred compaction model (global)
119128
* Format: "preferredCompactionModel"

src/common/types/ipc.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,7 @@ export interface IPCApi {
309309
runtimeConfig?: RuntimeConfig;
310310
projectPath?: string; // Required when workspaceId is null
311311
trunkBranch?: string; // Optional - trunk branch to branch from (when workspaceId is null)
312+
pullLatest?: boolean; // Optional - fetch and rebase on origin/<trunk> (when workspaceId is null)
312313
}
313314
): Promise<
314315
| Result<void, SendMessageError>

src/node/runtime/LocalRuntime.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,7 @@ export class LocalRuntime implements Runtime {
318318
}
319319

320320
async createWorkspace(params: WorkspaceCreationParams): Promise<WorkspaceCreationResult> {
321-
const { projectPath, branchName, trunkBranch, initLogger } = params;
321+
const { projectPath, branchName, trunkBranch, initLogger, pullLatest } = params;
322322

323323
try {
324324
// Compute workspace path using the canonical method
@@ -365,6 +365,11 @@ export class LocalRuntime implements Runtime {
365365

366366
initLogger.logStep("Worktree created successfully");
367367

368+
// Pull latest from origin if requested (best-effort, non-blocking on failure)
369+
if (pullLatest) {
370+
await this.pullLatestFromOrigin(workspacePath, trunkBranch, initLogger);
371+
}
372+
368373
return { success: true, workspacePath };
369374
} catch (error) {
370375
return {
@@ -374,6 +379,45 @@ export class LocalRuntime implements Runtime {
374379
}
375380
}
376381

382+
/**
383+
* Fetch and rebase on latest origin/<trunkBranch>
384+
* Best-effort operation - logs warnings but doesn't fail workspace creation
385+
*/
386+
private async pullLatestFromOrigin(
387+
workspacePath: string,
388+
trunkBranch: string,
389+
initLogger: InitLogger
390+
): Promise<void> {
391+
try {
392+
initLogger.logStep("Fetching latest from origin...");
393+
394+
// Fetch the trunk branch from origin
395+
using fetchProc = execAsync(`git -C "${workspacePath}" fetch origin "${trunkBranch}"`);
396+
await fetchProc.result;
397+
398+
initLogger.logStep("Rebasing on latest...");
399+
400+
// Attempt rebase on origin/<trunkBranch>
401+
using rebaseProc = execAsync(`git -C "${workspacePath}" rebase "origin/${trunkBranch}"`);
402+
const rebaseResult = await rebaseProc.result;
403+
404+
if (rebaseResult.stderr?.includes("CONFLICT")) {
405+
// Rebase has conflicts - abort and warn
406+
using abortProc = execAsync(`git -C "${workspacePath}" rebase --abort`);
407+
await abortProc.result;
408+
initLogger.logStderr(
409+
"Warning: Rebase failed due to conflicts, continuing with current state"
410+
);
411+
} else {
412+
initLogger.logStep("Rebased on latest successfully");
413+
}
414+
} catch (error) {
415+
// Non-fatal: log warning and continue
416+
const errorMsg = getErrorMessage(error);
417+
initLogger.logStderr(`Warning: Failed to pull latest (${errorMsg}), continuing anyway`);
418+
}
419+
}
420+
377421
async initWorkspace(params: WorkspaceInitParams): Promise<WorkspaceInitResult> {
378422
const { projectPath, workspacePath, initLogger } = params;
379423

src/node/runtime/Runtime.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ export interface WorkspaceCreationParams {
123123
initLogger: InitLogger;
124124
/** Optional abort signal for cancellation */
125125
abortSignal?: AbortSignal;
126+
/** Whether to fetch and rebase on origin/<trunkBranch> after creation */
127+
pullLatest?: boolean;
126128
}
127129

128130
/**
@@ -151,6 +153,8 @@ export interface WorkspaceInitParams {
151153
initLogger: InitLogger;
152154
/** Optional abort signal for cancellation */
153155
abortSignal?: AbortSignal;
156+
/** Whether to fetch and rebase on origin/<trunkBranch> after init */
157+
pullLatest?: boolean;
154158
}
155159

156160
/**

0 commit comments

Comments
 (0)