From e58529651010c475deaea0f4f6bb83fad5d774d3 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 3 Dec 2025 11:54:43 -0600 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A4=96=20feat:=20icon-based=20runtime?= =?UTF-8?q?=20selector=20for=20new=20workspace=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace dropdown selector with three clickable icons (Local, Worktree, SSH): - Selected icon shows active styling (brighter colors) - Tooltips show runtime name and description - Clicking an icon sets it as the project default Simplify runtime persistence: - Single default runtime per project (worktree by default) - Default can only be changed via icon selector - Slash command runtime overrides are one-time, don't change default Extract shared RuntimeIcons component for reuse with RuntimeBadge. --- .../components/ChatInput/CreationControls.tsx | 49 ++------ src/browser/components/ChatInput/index.tsx | 3 +- .../ChatInput/useCreationWorkspace.test.tsx | 26 ++-- .../ChatInput/useCreationWorkspace.ts | 10 +- src/browser/components/RuntimeBadge.tsx | 67 +--------- .../components/RuntimeIconSelector.tsx | 116 ++++++++++++++++++ src/browser/components/icons/RuntimeIcons.tsx | 75 +++++++++++ .../hooks/useDraftWorkspaceSettings.ts | 38 +++--- .../hooks/useStartWorkspaceCreation.test.ts | 28 +---- .../hooks/useStartWorkspaceCreation.ts | 35 +----- src/browser/utils/chatCommands.ts | 8 +- src/common/constants/storage.ts | 8 +- 12 files changed, 263 insertions(+), 200 deletions(-) create mode 100644 src/browser/components/RuntimeIconSelector.tsx create mode 100644 src/browser/components/icons/RuntimeIcons.tsx diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index 3ad806fe42..9a65d203a7 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -1,7 +1,7 @@ import React from "react"; import { RUNTIME_MODE, type RuntimeMode } from "@/common/types/runtime"; -import { TooltipWrapper, Tooltip } from "../Tooltip"; import { Select } from "../Select"; +import { RuntimeIconSelector } from "../RuntimeIconSelector"; interface CreationControlsProps { branches: string[]; @@ -9,7 +9,10 @@ interface CreationControlsProps { onTrunkBranchChange: (branch: string) => void; runtimeMode: RuntimeMode; sshHost: string; - onRuntimeChange: (mode: RuntimeMode, host: string) => void; + /** Called when user changes runtime mode via checkbox in tooltip */ + onRuntimeModeChange: (mode: RuntimeMode) => void; + /** Called when user changes SSH host */ + onSshHostChange: (host: string) => void; disabled: boolean; } @@ -25,40 +28,12 @@ export function CreationControls(props: CreationControlsProps) { return (
- {/* Runtime Selector - first */} -
- - props.onRuntimeChange(RUNTIME_MODE.SSH, e.target.value)} + onChange={(e) => props.onSshHostChange(e.target.value)} placeholder="user@host" disabled={props.disabled} className="bg-separator text-foreground border-border-medium focus:border-accent w-32 rounded border px-1 py-0.5 text-xs focus:outline-none disabled:opacity-50" diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 7588eb53f9..96fb76d15f 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -1383,7 +1383,8 @@ export const ChatInput: React.FC = (props) => { onTrunkBranchChange={creationState.setTrunkBranch} runtimeMode={creationState.runtimeMode} sshHost={creationState.sshHost} - onRuntimeChange={creationState.setRuntimeOptions} + onRuntimeModeChange={creationState.setRuntimeMode} + onSshHostChange={creationState.setSshHost} disabled={creationState.isSending || isSending} /> )} diff --git a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx index 53ce12c51d..a9fe9e8475 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx @@ -487,27 +487,32 @@ function createDraftSettingsHarness( runtimeString: string | undefined; }; - const setRuntimeOptions = mock((mode: RuntimeMode, host: string) => { - state.runtimeMode = mode; - state.sshHost = host; - const trimmedHost = host.trim(); - state.runtimeString = mode === "ssh" ? (trimmedHost ? `ssh ${trimmedHost}` : "ssh") : undefined; - }); - const setTrunkBranch = mock((branch: string) => { state.trunkBranch = branch; }); const getRuntimeString = mock(() => state.runtimeString); + const setRuntimeMode = mock((mode: RuntimeMode) => { + state.runtimeMode = mode; + const trimmedHost = state.sshHost.trim(); + state.runtimeString = mode === "ssh" ? (trimmedHost ? `ssh ${trimmedHost}` : "ssh") : undefined; + }); + + const setSshHost = mock((host: string) => { + state.sshHost = host; + }); + return { state, - setRuntimeOptions, + setRuntimeMode, + setSshHost, setTrunkBranch, getRuntimeString, snapshot(): { settings: DraftWorkspaceSettings; - setRuntimeOptions: typeof setRuntimeOptions; + setRuntimeMode: typeof setRuntimeMode; + setSshHost: typeof setSshHost; setTrunkBranch: typeof setTrunkBranch; getRuntimeString: typeof getRuntimeString; } { @@ -521,7 +526,8 @@ function createDraftSettingsHarness( }; return { settings, - setRuntimeOptions, + setRuntimeMode, + setSshHost, setTrunkBranch, getRuntimeString, }; diff --git a/src/browser/components/ChatInput/useCreationWorkspace.ts b/src/browser/components/ChatInput/useCreationWorkspace.ts index 98eea859bb..84b0d306a2 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.ts +++ b/src/browser/components/ChatInput/useCreationWorkspace.ts @@ -47,7 +47,10 @@ interface UseCreationWorkspaceReturn { setTrunkBranch: (branch: string) => void; runtimeMode: RuntimeMode; sshHost: string; - setRuntimeOptions: (mode: RuntimeMode, host: string) => void; + /** Set the default runtime mode for this project (only via checkbox) */ + setRuntimeMode: (mode: RuntimeMode) => void; + /** Set the SSH host (persisted separately from runtime mode) */ + setSshHost: (host: string) => void; toast: Toast | null; setToast: (toast: Toast | null) => void; isSending: boolean; @@ -72,7 +75,7 @@ export function useCreationWorkspace({ const [isSending, setIsSending] = useState(false); // Centralized draft workspace settings with automatic persistence - const { settings, setRuntimeOptions, setTrunkBranch, getRuntimeString } = + const { settings, setRuntimeMode, setSshHost, setTrunkBranch, getRuntimeString } = useDraftWorkspaceSettings(projectPath, branches, recommendedTrunk); // Get send options from shared hook (uses project-scoped storage key) @@ -181,7 +184,8 @@ export function useCreationWorkspace({ setTrunkBranch, runtimeMode: settings.runtimeMode, sshHost: settings.sshHost, - setRuntimeOptions, + setRuntimeMode, + setSshHost, toast, setToast, isSending, diff --git a/src/browser/components/RuntimeBadge.tsx b/src/browser/components/RuntimeBadge.tsx index 5d895eb76e..e70bdf3bde 100644 --- a/src/browser/components/RuntimeBadge.tsx +++ b/src/browser/components/RuntimeBadge.tsx @@ -4,6 +4,7 @@ import type { RuntimeConfig } from "@/common/types/runtime"; import { isSSHRuntime, isWorktreeRuntime, isLocalProjectRuntime } from "@/common/types/runtime"; import { extractSshHostname } from "@/browser/utils/ui/runtimeBadge"; import { TooltipWrapper, Tooltip } from "./Tooltip"; +import { SSHIcon, WorktreeIcon, LocalIcon } from "./icons/RuntimeIcons"; interface RuntimeBadgeProps { runtimeConfig?: RuntimeConfig; @@ -12,72 +13,6 @@ interface RuntimeBadgeProps { isWorking?: boolean; } -/** Server rack icon for SSH runtime */ -function SSHIcon() { - return ( - - - - - - - ); -} - -/** Git branch icon for worktree runtime */ -function WorktreeIcon() { - return ( - - {/* Simplified git branch: vertical line with branch off */} - - - - - - - ); -} - -/** Folder icon for local project-dir runtime */ -function LocalIcon() { - return ( - - {/* Folder icon */} - - - ); -} - // Runtime-specific color schemes - each type has consistent colors in idle/working states // Idle: subtle with visible colored border for discrimination // Working: brighter colors with pulse animation diff --git a/src/browser/components/RuntimeIconSelector.tsx b/src/browser/components/RuntimeIconSelector.tsx new file mode 100644 index 0000000000..86bb61755c --- /dev/null +++ b/src/browser/components/RuntimeIconSelector.tsx @@ -0,0 +1,116 @@ +import React from "react"; +import { cn } from "@/common/lib/utils"; +import { RUNTIME_MODE, type RuntimeMode } from "@/common/types/runtime"; +import { SSHIcon, WorktreeIcon, LocalIcon } from "./icons/RuntimeIcons"; +import { TooltipWrapper, Tooltip } from "./Tooltip"; + +interface RuntimeIconSelectorProps { + value: RuntimeMode; + onChange: (mode: RuntimeMode) => void; + disabled?: boolean; + className?: string; +} + +// Runtime-specific color schemes matching RuntimeBadge +// Selected (active) uses the "working" styling, unselected uses "idle" +const RUNTIME_STYLES = { + ssh: { + idle: "bg-transparent text-muted border-blue-500/30 hover:border-blue-500/50", + active: "bg-blue-500/20 text-blue-400 border-blue-500/60", + }, + worktree: { + idle: "bg-transparent text-muted border-purple-500/30 hover:border-purple-500/50", + active: "bg-purple-500/20 text-purple-400 border-purple-500/60", + }, + local: { + idle: "bg-transparent text-muted border-muted/30 hover:border-muted/50", + active: "bg-muted/30 text-foreground border-muted/60", + }, +} as const; + +const RUNTIME_INFO: Record = { + local: { + label: "Local", + description: "Work directly in project directory (no isolation)", + }, + worktree: { + label: "Worktree", + description: "Git worktree in ~/.mux/src (isolated)", + }, + ssh: { + label: "SSH", + description: "Remote clone on SSH host", + }, +}; + +interface RuntimeIconButtonProps { + mode: RuntimeMode; + isSelected: boolean; + onClick: () => void; + disabled?: boolean; +} + +function RuntimeIconButton(props: RuntimeIconButtonProps) { + const info = RUNTIME_INFO[props.mode]; + const styles = RUNTIME_STYLES[props.mode]; + const stateStyle = props.isSelected ? styles.active : styles.idle; + + const Icon = + props.mode === RUNTIME_MODE.SSH + ? SSHIcon + : props.mode === RUNTIME_MODE.WORKTREE + ? WorktreeIcon + : LocalIcon; + + return ( + + + + {info.label} +

{info.description}

+
+
+ ); +} + +/** + * Runtime selector using icons with tooltips. + * Shows Local, Worktree, and SSH options as clickable icons. + * Selected runtime uses "active" styling (brighter colors). + * Clicking an icon sets it as the project default. + */ +export function RuntimeIconSelector(props: RuntimeIconSelectorProps) { + const modes: RuntimeMode[] = [RUNTIME_MODE.LOCAL, RUNTIME_MODE.WORKTREE, RUNTIME_MODE.SSH]; + + return ( +
+ {modes.map((mode) => ( + props.onChange(mode)} + disabled={props.disabled} + /> + ))} +
+ ); +} diff --git a/src/browser/components/icons/RuntimeIcons.tsx b/src/browser/components/icons/RuntimeIcons.tsx new file mode 100644 index 0000000000..51ae7b7c72 --- /dev/null +++ b/src/browser/components/icons/RuntimeIcons.tsx @@ -0,0 +1,75 @@ +import React from "react"; + +interface IconProps { + size?: number; + className?: string; +} + +/** Server rack icon for SSH runtime */ +export function SSHIcon({ size = 10, className }: IconProps) { + return ( + + + + + + + ); +} + +/** Git branch icon for worktree runtime */ +export function WorktreeIcon({ size = 10, className }: IconProps) { + return ( + + {/* Simplified git branch: vertical line with branch off */} + + + + + + + ); +} + +/** Folder icon for local project-dir runtime */ +export function LocalIcon({ size = 10, className }: IconProps) { + return ( + + {/* Folder icon */} + + + ); +} diff --git a/src/browser/hooks/useDraftWorkspaceSettings.ts b/src/browser/hooks/useDraftWorkspaceSettings.ts index fffb6304fc..456be7ad7b 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.ts +++ b/src/browser/hooks/useDraftWorkspaceSettings.ts @@ -10,7 +10,7 @@ import { } from "@/common/types/runtime"; import { getModelKey, - getRuntimeKey, + getDefaultRuntimeKey, getTrunkBranchKey, getLastSshHostKey, getProjectScopeId, @@ -49,7 +49,8 @@ export function useDraftWorkspaceSettings( recommendedTrunk: string | null ): { settings: DraftWorkspaceSettings; - setRuntimeOptions: (mode: RuntimeMode, host: string) => void; + setRuntimeMode: (mode: RuntimeMode) => void; + setSshHost: (host: string) => void; setTrunkBranch: (branch: string) => void; getRuntimeString: () => string | undefined; } { @@ -65,10 +66,10 @@ export function useDraftWorkspaceSettings( { listener: true } ); - // Project-scoped runtime preference (persisted per project) - const [runtimeString, setRuntimeString] = usePersistedState( - getRuntimeKey(projectPath), - undefined, + // Project-scoped default runtime (worktree by default, only changed via checkbox) + const [defaultRuntimeString, setDefaultRuntimeString] = usePersistedState( + getDefaultRuntimeKey(projectPath), + undefined, // undefined means worktree (the app default) { listener: true } ); @@ -87,8 +88,9 @@ export function useDraftWorkspaceSettings( { listener: true } ); - // Parse runtime string into mode (host from runtime string is ignored in favor of lastSshHost) - const { mode: runtimeMode } = parseRuntimeModeAndHost(runtimeString); + // Parse default runtime string into mode (worktree when undefined) + // SSH host is stored separately so it persists across mode switches + const { mode: runtimeMode } = parseRuntimeModeAndHost(defaultRuntimeString); // Initialize trunk branch from backend recommendation or first branch useEffect(() => { @@ -98,14 +100,15 @@ export function useDraftWorkspaceSettings( } }, [branches, recommendedTrunk, trunkBranch, setTrunkBranch]); - // Setter for runtime options (updates persisted runtime mode and SSH host separately) - const setRuntimeOptions = (newMode: RuntimeMode, newHost: string) => { - const newRuntimeString = buildRuntimeString(newMode, newHost); - setRuntimeString(newRuntimeString); - // Always persist the SSH host separately so it's remembered across mode switches - if (newHost) { - setLastSshHost(newHost); - } + // Setter for default runtime mode (only way to change is via checkbox) + const setRuntimeMode = (newMode: RuntimeMode) => { + const newRuntimeString = buildRuntimeString(newMode, lastSshHost); + setDefaultRuntimeString(newRuntimeString); + }; + + // Setter for SSH host (persisted separately so it's remembered across mode switches) + const setSshHost = (newHost: string) => { + setLastSshHost(newHost); }; // Helper to get runtime string for IPC calls @@ -122,7 +125,8 @@ export function useDraftWorkspaceSettings( sshHost: lastSshHost, trunkBranch, }, - setRuntimeOptions, + setRuntimeMode, + setSshHost, setTrunkBranch, getRuntimeString, }; diff --git a/src/browser/hooks/useStartWorkspaceCreation.test.ts b/src/browser/hooks/useStartWorkspaceCreation.test.ts index d7bab6c673..ae1330a6db 100644 --- a/src/browser/hooks/useStartWorkspaceCreation.test.ts +++ b/src/browser/hooks/useStartWorkspaceCreation.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "bun:test"; import { getFirstProjectPath, - normalizeRuntimePreference, persistWorkspaceCreationPrefill, type StartWorkspaceCreationDetail, } from "./useStartWorkspaceCreation"; @@ -10,7 +9,6 @@ import { getModelKey, getPendingScopeId, getProjectScopeId, - getRuntimeKey, getTrunkBranchKey, } from "@/common/constants/storage"; import type { ProjectConfig } from "@/node/config"; @@ -20,25 +18,6 @@ import type { updatePersistedState } from "@/browser/hooks/usePersistedState"; type PersistFn = typeof updatePersistedState; type PersistCall = [string, unknown, unknown?]; -describe("normalizeRuntimePreference", () => { - test("returns undefined for local or empty runtime", () => { - expect(normalizeRuntimePreference(undefined)).toBeUndefined(); - expect(normalizeRuntimePreference(" ")).toBeUndefined(); - expect(normalizeRuntimePreference("local")).toBeUndefined(); - expect(normalizeRuntimePreference("LOCAL")).toBeUndefined(); - }); - - test("normalizes ssh runtimes", () => { - expect(normalizeRuntimePreference("ssh")).toBe("ssh"); - expect(normalizeRuntimePreference("ssh host")).toBe("ssh host"); - expect(normalizeRuntimePreference("SSH user@host")).toBe("ssh user@host"); - }); - - test("returns trimmed custom runtime", () => { - expect(normalizeRuntimePreference(" custom-runtime ")).toBe("custom-runtime"); - }); -}); - describe("persistWorkspaceCreationPrefill", () => { const projectPath = "/tmp/project"; @@ -57,7 +36,7 @@ describe("persistWorkspaceCreationPrefill", () => { startMessage: "Ship it", model: "provider/model", trunkBranch: " main ", - runtime: " ssh dev ", + runtime: " ssh dev ", // runtime is NOT persisted - it's a one-time override }; const { persist, calls } = createPersistSpy(); @@ -71,14 +50,14 @@ describe("persistWorkspaceCreationPrefill", () => { expect(callMap.get(getInputKey(getPendingScopeId(projectPath)))).toBe("Ship it"); expect(callMap.get(getModelKey(getProjectScopeId(projectPath)))).toBe("provider/model"); expect(callMap.get(getTrunkBranchKey(projectPath))).toBe("main"); - expect(callMap.get(getRuntimeKey(projectPath))).toBe("ssh dev"); + // runtime is intentionally not persisted - default can only be changed via icon selector + expect(calls.length).toBe(3); }); test("clears persisted values when empty strings are provided", () => { const detail: StartWorkspaceCreationDetail = { projectPath, trunkBranch: " ", - runtime: " ", }; const { persist, calls } = createPersistSpy(); @@ -90,7 +69,6 @@ describe("persistWorkspaceCreationPrefill", () => { } expect(callMap.get(getTrunkBranchKey(projectPath))).toBeUndefined(); - expect(callMap.get(getRuntimeKey(projectPath))).toBeUndefined(); }); test("no-op when detail is undefined", () => { diff --git a/src/browser/hooks/useStartWorkspaceCreation.ts b/src/browser/hooks/useStartWorkspaceCreation.ts index 10d55de5dd..e5b7bda373 100644 --- a/src/browser/hooks/useStartWorkspaceCreation.ts +++ b/src/browser/hooks/useStartWorkspaceCreation.ts @@ -8,41 +8,12 @@ import { getModelKey, getPendingScopeId, getProjectScopeId, - getRuntimeKey, getTrunkBranchKey, } from "@/common/constants/storage"; -import { RUNTIME_MODE, SSH_RUNTIME_PREFIX } from "@/common/types/runtime"; export type StartWorkspaceCreationDetail = CustomEventPayloads[typeof CUSTOM_EVENTS.START_WORKSPACE_CREATION]; -export function normalizeRuntimePreference(runtime: string | undefined): string | undefined { - if (!runtime) { - return undefined; - } - - const trimmed = runtime.trim(); - if (!trimmed) { - return undefined; - } - - const lower = trimmed.toLowerCase(); - if (lower === RUNTIME_MODE.LOCAL) { - return undefined; - } - - if (lower === RUNTIME_MODE.SSH) { - return RUNTIME_MODE.SSH; - } - - if (lower.startsWith(SSH_RUNTIME_PREFIX)) { - const host = trimmed.slice(SSH_RUNTIME_PREFIX.length).trim(); - return host ? `${RUNTIME_MODE.SSH} ${host}` : RUNTIME_MODE.SSH; - } - - return trimmed; -} - export function getFirstProjectPath(projects: Map): string | null { const iterator = projects.keys().next(); return iterator.done ? null : iterator.value; @@ -75,10 +46,8 @@ export function persistWorkspaceCreationPrefill( ); } - if (detail.runtime !== undefined) { - const normalizedRuntime = normalizeRuntimePreference(detail.runtime); - persist(getRuntimeKey(projectPath), normalizedRuntime); - } + // Note: runtime is intentionally NOT persisted here - it's a one-time override. + // The default runtime can only be changed via the icon selector. } interface UseStartWorkspaceCreationOptions { diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index 89643d5dcb..dba2dd2ec5 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -24,7 +24,7 @@ import { applyCompactionOverrides } from "@/browser/utils/messages/compactionOpt import { resolveCompactionModel } from "@/browser/utils/messages/compactionModelPreference"; import type { ImageAttachment } from "../components/ImageAttachments"; import { dispatchWorkspaceSwitch } from "./workspaceEvents"; -import { getRuntimeKey, copyWorkspaceStorage } from "@/common/constants/storage"; +import { getDefaultRuntimeKey, copyWorkspaceStorage } from "@/common/constants/storage"; import { DEFAULT_COMPACTION_WORD_TARGET, WORDS_TO_TOKENS_RATIO } from "@/common/constants/ui"; // ============================================================================ @@ -499,11 +499,11 @@ export async function createNewWorkspace( effectiveTrunk = recommendedTrunk ?? "main"; } - // Use saved runtime preference if not explicitly provided + // Use saved default runtime preference if not explicitly provided let effectiveRuntime = options.runtime; if (effectiveRuntime === undefined) { - const runtimeKey = getRuntimeKey(options.projectPath); - const savedRuntime = localStorage.getItem(runtimeKey); + const defaultRuntimeKey = getDefaultRuntimeKey(options.projectPath); + const savedRuntime = localStorage.getItem(defaultRuntimeKey); if (savedRuntime) { effectiveRuntime = savedRuntime; } diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index 09b4724162..b41b5f309f 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -110,11 +110,11 @@ export function getModeKey(workspaceId: string): string { /** * Get the localStorage key for the default runtime for a project - * Stores the last successfully used runtime config when creating a workspace - * Format: "runtime:{projectPath}" + * Defaults to worktree if not set; can only be changed via the runtime icon selector. + * Format: "defaultRuntime:{projectPath}" */ -export function getRuntimeKey(projectPath: string): string { - return `runtime:${projectPath}`; +export function getDefaultRuntimeKey(projectPath: string): string { + return `defaultRuntime:${projectPath}`; } /** From 95cf6f09e09d5a171a5e203bea0bdb732961b46b Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 3 Dec 2025 12:21:16 -0600 Subject: [PATCH 2/2] fix: reduce icon size and add 'Default for project' checkbox - Reduce icon size from 14px to 10px (default) to match RuntimeBadge - Reduce button padding from p-1.5 to p-1 - Add interactive tooltip with 'Default for project' checkbox - Separate current selection (non-persistent) from project default (persistent) - Clicking icon selects for this workspace, checkbox persists as default - Reuse existing 'runtime:{projectPath}' storage key for compatibility --- .../components/ChatInput/CreationControls.tsx | 7 ++- src/browser/components/ChatInput/index.tsx | 2 + .../ChatInput/useCreationWorkspace.test.tsx | 14 ++++++ .../ChatInput/useCreationWorkspace.ts | 17 +++++-- .../components/RuntimeIconSelector.tsx | 27 ++++++++--- .../hooks/useDraftWorkspaceSettings.ts | 45 ++++++++++++++----- src/browser/utils/chatCommands.ts | 6 +-- src/common/constants/storage.ts | 8 ++-- 8 files changed, 99 insertions(+), 27 deletions(-) diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index 9a65d203a7..dd01b02022 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -8,9 +8,12 @@ interface CreationControlsProps { trunkBranch: string; onTrunkBranchChange: (branch: string) => void; runtimeMode: RuntimeMode; + defaultRuntimeMode: RuntimeMode; sshHost: string; - /** Called when user changes runtime mode via checkbox in tooltip */ + /** Called when user clicks a runtime icon to select it (does not persist) */ onRuntimeModeChange: (mode: RuntimeMode) => void; + /** Called when user checks "Default for project" checkbox (persists) */ + onSetDefaultRuntime: (mode: RuntimeMode) => void; /** Called when user changes SSH host */ onSshHostChange: (host: string) => void; disabled: boolean; @@ -32,6 +35,8 @@ export function CreationControls(props: CreationControlsProps) { diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 96fb76d15f..994b83214c 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -1382,8 +1382,10 @@ export const ChatInput: React.FC = (props) => { trunkBranch={creationState.trunkBranch} onTrunkBranchChange={creationState.setTrunkBranch} runtimeMode={creationState.runtimeMode} + defaultRuntimeMode={creationState.defaultRuntimeMode} sshHost={creationState.sshHost} onRuntimeModeChange={creationState.setRuntimeMode} + onSetDefaultRuntime={creationState.setDefaultRuntimeMode} onSshHostChange={creationState.setSshHost} disabled={creationState.isSending || isSending} /> diff --git a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx index a9fe9e8475..172f45f5fc 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx @@ -473,15 +473,18 @@ function createDraftSettingsHarness( sshHost: string; trunkBranch: string; runtimeString?: string | undefined; + defaultRuntimeMode?: RuntimeMode; }> ) { const state = { runtimeMode: initial?.runtimeMode ?? ("local" as RuntimeMode), + defaultRuntimeMode: initial?.defaultRuntimeMode ?? ("worktree" as RuntimeMode), sshHost: initial?.sshHost ?? "", trunkBranch: initial?.trunkBranch ?? "main", runtimeString: initial?.runtimeString, } satisfies { runtimeMode: RuntimeMode; + defaultRuntimeMode: RuntimeMode; sshHost: string; trunkBranch: string; runtimeString: string | undefined; @@ -499,6 +502,13 @@ function createDraftSettingsHarness( state.runtimeString = mode === "ssh" ? (trimmedHost ? `ssh ${trimmedHost}` : "ssh") : undefined; }); + const setDefaultRuntimeMode = mock((mode: RuntimeMode) => { + state.defaultRuntimeMode = mode; + state.runtimeMode = mode; + const trimmedHost = state.sshHost.trim(); + state.runtimeString = mode === "ssh" ? (trimmedHost ? `ssh ${trimmedHost}` : "ssh") : undefined; + }); + const setSshHost = mock((host: string) => { state.sshHost = host; }); @@ -506,12 +516,14 @@ function createDraftSettingsHarness( return { state, setRuntimeMode, + setDefaultRuntimeMode, setSshHost, setTrunkBranch, getRuntimeString, snapshot(): { settings: DraftWorkspaceSettings; setRuntimeMode: typeof setRuntimeMode; + setDefaultRuntimeMode: typeof setDefaultRuntimeMode; setSshHost: typeof setSshHost; setTrunkBranch: typeof setTrunkBranch; getRuntimeString: typeof getRuntimeString; @@ -521,12 +533,14 @@ function createDraftSettingsHarness( thinkingLevel: "medium", mode: "exec", runtimeMode: state.runtimeMode, + defaultRuntimeMode: state.defaultRuntimeMode, sshHost: state.sshHost, trunkBranch: state.trunkBranch, }; return { settings, setRuntimeMode, + setDefaultRuntimeMode, setSshHost, setTrunkBranch, getRuntimeString, diff --git a/src/browser/components/ChatInput/useCreationWorkspace.ts b/src/browser/components/ChatInput/useCreationWorkspace.ts index 84b0d306a2..fcbf4a96b7 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.ts +++ b/src/browser/components/ChatInput/useCreationWorkspace.ts @@ -46,9 +46,12 @@ interface UseCreationWorkspaceReturn { trunkBranch: string; setTrunkBranch: (branch: string) => void; runtimeMode: RuntimeMode; + defaultRuntimeMode: RuntimeMode; sshHost: string; - /** Set the default runtime mode for this project (only via checkbox) */ + /** Set the currently selected runtime mode (does not persist) */ setRuntimeMode: (mode: RuntimeMode) => void; + /** Set the default runtime mode for this project (persists via checkbox) */ + setDefaultRuntimeMode: (mode: RuntimeMode) => void; /** Set the SSH host (persisted separately from runtime mode) */ setSshHost: (host: string) => void; toast: Toast | null; @@ -75,8 +78,14 @@ export function useCreationWorkspace({ const [isSending, setIsSending] = useState(false); // Centralized draft workspace settings with automatic persistence - const { settings, setRuntimeMode, setSshHost, setTrunkBranch, getRuntimeString } = - useDraftWorkspaceSettings(projectPath, branches, recommendedTrunk); + const { + settings, + setRuntimeMode, + setDefaultRuntimeMode, + setSshHost, + setTrunkBranch, + getRuntimeString, + } = useDraftWorkspaceSettings(projectPath, branches, recommendedTrunk); // Get send options from shared hook (uses project-scoped storage key) const sendMessageOptions = useSendMessageOptions(getProjectScopeId(projectPath)); @@ -183,8 +192,10 @@ export function useCreationWorkspace({ trunkBranch: settings.trunkBranch, setTrunkBranch, runtimeMode: settings.runtimeMode, + defaultRuntimeMode: settings.defaultRuntimeMode, sshHost: settings.sshHost, setRuntimeMode, + setDefaultRuntimeMode, setSshHost, toast, setToast, diff --git a/src/browser/components/RuntimeIconSelector.tsx b/src/browser/components/RuntimeIconSelector.tsx index 86bb61755c..e95da78d86 100644 --- a/src/browser/components/RuntimeIconSelector.tsx +++ b/src/browser/components/RuntimeIconSelector.tsx @@ -7,6 +7,10 @@ import { TooltipWrapper, Tooltip } from "./Tooltip"; interface RuntimeIconSelectorProps { value: RuntimeMode; onChange: (mode: RuntimeMode) => void; + /** The persisted default runtime for this project */ + defaultMode: RuntimeMode; + /** Called when user checks "Default for project" in tooltip */ + onSetDefault: (mode: RuntimeMode) => void; disabled?: boolean; className?: string; } @@ -46,7 +50,9 @@ const RUNTIME_INFO: Record interface RuntimeIconButtonProps { mode: RuntimeMode; isSelected: boolean; + isDefault: boolean; onClick: () => void; + onSetDefault: () => void; disabled?: boolean; } @@ -69,7 +75,7 @@ function RuntimeIconButton(props: RuntimeIconButtonProps) { onClick={props.onClick} disabled={props.disabled} className={cn( - "inline-flex items-center justify-center rounded border p-1.5 transition-colors", + "inline-flex items-center justify-center rounded border p-1 transition-colors", "focus:outline-none focus-visible:ring-1 focus-visible:ring-blue-500", stateStyle, props.disabled && "cursor-not-allowed opacity-50" @@ -77,11 +83,20 @@ function RuntimeIconButton(props: RuntimeIconButtonProps) { aria-label={`${info.label} runtime`} aria-pressed={props.isSelected} > - + - + {info.label} -

{info.description}

+

{info.description}

+
); @@ -91,7 +106,7 @@ function RuntimeIconButton(props: RuntimeIconButtonProps) { * Runtime selector using icons with tooltips. * Shows Local, Worktree, and SSH options as clickable icons. * Selected runtime uses "active" styling (brighter colors). - * Clicking an icon sets it as the project default. + * Each tooltip has a "Default for project" checkbox to persist the preference. */ export function RuntimeIconSelector(props: RuntimeIconSelectorProps) { const modes: RuntimeMode[] = [RUNTIME_MODE.LOCAL, RUNTIME_MODE.WORKTREE, RUNTIME_MODE.SSH]; @@ -107,7 +122,9 @@ export function RuntimeIconSelector(props: RuntimeIconSelectorProps) { key={mode} mode={mode} isSelected={props.value === mode} + isDefault={props.defaultMode === mode} onClick={() => props.onChange(mode)} + onSetDefault={() => props.onSetDefault(mode)} disabled={props.disabled} /> ))} diff --git a/src/browser/hooks/useDraftWorkspaceSettings.ts b/src/browser/hooks/useDraftWorkspaceSettings.ts index 456be7ad7b..b1aab7dd75 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.ts +++ b/src/browser/hooks/useDraftWorkspaceSettings.ts @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useState, useEffect } from "react"; import { usePersistedState } from "./usePersistedState"; import { useThinkingLevel } from "./useThinkingLevel"; import { useMode } from "@/browser/contexts/ModeContext"; @@ -10,7 +10,7 @@ import { } from "@/common/types/runtime"; import { getModelKey, - getDefaultRuntimeKey, + getRuntimeKey, getTrunkBranchKey, getLastSshHostKey, getProjectScopeId, @@ -29,7 +29,10 @@ export interface DraftWorkspaceSettings { mode: UIMode; // Workspace creation settings (project-specific) + /** Currently selected runtime for this workspace creation (may differ from default) */ runtimeMode: RuntimeMode; + /** Persisted default runtime for this project (used to initialize selection) */ + defaultRuntimeMode: RuntimeMode; sshHost: string; trunkBranch: string; } @@ -49,7 +52,10 @@ export function useDraftWorkspaceSettings( recommendedTrunk: string | null ): { settings: DraftWorkspaceSettings; + /** Set the currently selected runtime mode (does not persist) */ setRuntimeMode: (mode: RuntimeMode) => void; + /** Set the default runtime mode for this project (persists via checkbox) */ + setDefaultRuntimeMode: (mode: RuntimeMode) => void; setSshHost: (host: string) => void; setTrunkBranch: (branch: string) => void; getRuntimeString: () => string | undefined; @@ -68,11 +74,23 @@ export function useDraftWorkspaceSettings( // Project-scoped default runtime (worktree by default, only changed via checkbox) const [defaultRuntimeString, setDefaultRuntimeString] = usePersistedState( - getDefaultRuntimeKey(projectPath), + getRuntimeKey(projectPath), undefined, // undefined means worktree (the app default) { listener: true } ); + // Parse default runtime string into mode (worktree when undefined) + const { mode: defaultRuntimeMode } = parseRuntimeModeAndHost(defaultRuntimeString); + + // Currently selected runtime mode for this session (initialized from default) + // This allows user to select a different runtime without changing the default + const [selectedRuntimeMode, setSelectedRuntimeMode] = useState(defaultRuntimeMode); + + // Sync selected mode when default changes (e.g., from checkbox or project switch) + useEffect(() => { + setSelectedRuntimeMode(defaultRuntimeMode); + }, [defaultRuntimeMode]); + // Project-scoped trunk branch preference (persisted per project) const [trunkBranch, setTrunkBranch] = usePersistedState( getTrunkBranchKey(projectPath), @@ -88,10 +106,6 @@ export function useDraftWorkspaceSettings( { listener: true } ); - // Parse default runtime string into mode (worktree when undefined) - // SSH host is stored separately so it persists across mode switches - const { mode: runtimeMode } = parseRuntimeModeAndHost(defaultRuntimeString); - // Initialize trunk branch from backend recommendation or first branch useEffect(() => { if (!trunkBranch && branches.length > 0) { @@ -100,10 +114,17 @@ export function useDraftWorkspaceSettings( } }, [branches, recommendedTrunk, trunkBranch, setTrunkBranch]); - // Setter for default runtime mode (only way to change is via checkbox) + // Setter for selected runtime mode (changes current selection, does not persist) const setRuntimeMode = (newMode: RuntimeMode) => { + setSelectedRuntimeMode(newMode); + }; + + // Setter for default runtime mode (persists via checkbox in tooltip) + const setDefaultRuntimeMode = (newMode: RuntimeMode) => { const newRuntimeString = buildRuntimeString(newMode, lastSshHost); setDefaultRuntimeString(newRuntimeString); + // Also update selection to match new default + setSelectedRuntimeMode(newMode); }; // Setter for SSH host (persisted separately so it's remembered across mode switches) @@ -111,9 +132,9 @@ export function useDraftWorkspaceSettings( setLastSshHost(newHost); }; - // Helper to get runtime string for IPC calls + // Helper to get runtime string for IPC calls (uses selected mode, not default) const getRuntimeString = (): string | undefined => { - return buildRuntimeString(runtimeMode, lastSshHost); + return buildRuntimeString(selectedRuntimeMode, lastSshHost); }; return { @@ -121,11 +142,13 @@ export function useDraftWorkspaceSettings( model, thinkingLevel, mode, - runtimeMode, + runtimeMode: selectedRuntimeMode, + defaultRuntimeMode, sshHost: lastSshHost, trunkBranch, }, setRuntimeMode, + setDefaultRuntimeMode, setSshHost, setTrunkBranch, getRuntimeString, diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index dba2dd2ec5..f7fbdaab40 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -24,7 +24,7 @@ import { applyCompactionOverrides } from "@/browser/utils/messages/compactionOpt import { resolveCompactionModel } from "@/browser/utils/messages/compactionModelPreference"; import type { ImageAttachment } from "../components/ImageAttachments"; import { dispatchWorkspaceSwitch } from "./workspaceEvents"; -import { getDefaultRuntimeKey, copyWorkspaceStorage } from "@/common/constants/storage"; +import { getRuntimeKey, copyWorkspaceStorage } from "@/common/constants/storage"; import { DEFAULT_COMPACTION_WORD_TARGET, WORDS_TO_TOKENS_RATIO } from "@/common/constants/ui"; // ============================================================================ @@ -502,8 +502,8 @@ export async function createNewWorkspace( // Use saved default runtime preference if not explicitly provided let effectiveRuntime = options.runtime; if (effectiveRuntime === undefined) { - const defaultRuntimeKey = getDefaultRuntimeKey(options.projectPath); - const savedRuntime = localStorage.getItem(defaultRuntimeKey); + const runtimeKey = getRuntimeKey(options.projectPath); + const savedRuntime = localStorage.getItem(runtimeKey); if (savedRuntime) { effectiveRuntime = savedRuntime; } diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index b41b5f309f..5987a6be42 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -110,11 +110,11 @@ export function getModeKey(workspaceId: string): string { /** * Get the localStorage key for the default runtime for a project - * Defaults to worktree if not set; can only be changed via the runtime icon selector. - * Format: "defaultRuntime:{projectPath}" + * Defaults to worktree if not set; can only be changed via the "Default for project" checkbox. + * Format: "runtime:{projectPath}" */ -export function getDefaultRuntimeKey(projectPath: string): string { - return `defaultRuntime:${projectPath}`; +export function getRuntimeKey(projectPath: string): string { + return `runtime:${projectPath}`; } /**