From ab9ad36c03e2bae3b004e99e891deb8968e7a4b4 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 13 Nov 2025 16:47:08 +0000 Subject: [PATCH 01/13] =?UTF-8?q?=F0=9F=A4=96=20fix:=20title=20gen=20regre?= =?UTF-8?q?ssion=20-=20runtime=20saving=20and=20UI=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes two issues introduced in PR #500: 1. Runtime saving regression: The FirstMessageInput component wasn't persisting runtime selection to localStorage after workspace creation. Added runtime preference saving using updatePersistedState helper. 2. UI layout jank: Controls specific to new workspace creation (trunk branch selector and runtime selector) were mixed with general chat controls. Separated them into two rows for better visual organization. Also fixed: Use persistedState helpers (readPersistedState, updatePersistedState) instead of direct localStorage calls throughout FirstMessageInput for consistency and cross-component synchronization. Added note to AGENTS.md about using persistedState helpers and keeping edits minimal/token-efficient. _Generated with `cmux`_ --- docs/AGENTS.md | 4 +++ src/components/FirstMessageInput.tsx | 51 +++++++++++++++++----------- 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/docs/AGENTS.md b/docs/AGENTS.md index d5147e4e3..d16e9f66d 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -1,5 +1,7 @@ # AGENT INSTRUCTIONS +**Edits to this file must be minimal and token-efficient.** Think carefully about how to represent information concisely. Avoid redundant examples or verbose explanations when the knowledge can be conveyed in a sentence or two. + ## Project Context - Project is named `mux` @@ -365,6 +367,8 @@ If IPC is hard to test, fix the test infrastructure or IPC layer, don't work aro **For per-operation state tied to async workflows, parent components should own all localStorage operations.** Child components should notify parents of user intent without manipulating storage directly, preventing bugs from stale or orphaned state across component lifecycles. +**Always use persistedState helpers (`usePersistedState`, `readPersistedState`, `updatePersistedState`) instead of direct `localStorage` calls** - provides cross-component sync and consistent error handling. + ## Module Imports - **NEVER use dynamic imports** - Always use static `import` statements at the top of files. Dynamic imports (`await import()`) are a code smell that indicates improper module structure. diff --git a/src/components/FirstMessageInput.tsx b/src/components/FirstMessageInput.tsx index ea5c2d062..84527d4f3 100644 --- a/src/components/FirstMessageInput.tsx +++ b/src/components/FirstMessageInput.tsx @@ -3,8 +3,9 @@ import type { FrontendWorkspaceMetadata } from "@/types/workspace"; import type { RuntimeConfig } from "@/types/runtime"; import { RUNTIME_MODE } from "@/types/runtime"; import { parseRuntimeString } from "@/utils/chatCommands"; -import { getModelKey } from "@/constants/storage"; +import { getModelKey, getRuntimeKey } from "@/constants/storage"; import { useModelLRU } from "@/hooks/useModelLRU"; +import { updatePersistedState, readPersistedState } from "@/hooks/usePersistedState"; import { useNewWorkspaceOptions } from "@/hooks/useNewWorkspaceOptions"; import { useMode } from "@/contexts/ModeContext"; import { useThinkingLevel } from "@/hooks/useThinkingLevel"; @@ -60,13 +61,13 @@ export function FirstMessageInput({ // Get most recent model from LRU (project-scoped preference) const { recentModels, addModel } = useModelLRU(); const projectModelKey = getModelKey(`__project__${projectPath}`); - const preferredModel = localStorage.getItem(projectModelKey) ?? recentModels[0]; + const preferredModel = readPersistedState(projectModelKey, recentModels[0]); // Setter for model const setPreferredModel = useCallback( (model: string) => { addModel(model); - localStorage.setItem(projectModelKey, model); + updatePersistedState(projectModelKey, model); }, [projectModelKey, addModel] ); @@ -156,6 +157,13 @@ export function FirstMessageInput({ // Clear input setInput(""); + // Save runtime preference for this project + const runtimeString = getRuntimeString(); + if (runtimeString) { + const runtimeKey = getRuntimeKey(projectPath); + updatePersistedState(runtimeKey, runtimeString); + } + // Notify parent to switch workspace onWorkspaceCreated(result.metadata); } else { @@ -249,8 +257,9 @@ export function FirstMessageInput({ /> - {/* Options row - Model + Thinking + Context + Mode + Runtime */} + {/* Options section - separated into two rows */}
+ {/* First row: Model + Thinking + Context + Mode */}
{/* Model Selector */}
@@ -262,6 +271,24 @@ export function FirstMessageInput({ />
+ {/* Thinking Slider - slider hidden on narrow containers, label always clickable */} +
+ +
+ + {/* Context 1M Checkbox - always visible */} +
+ +
+ + +
+ + {/* Second row: New workspace controls (Trunk Branch + Runtime) */} +
{/* Trunk Branch Selector */} {branches.length > 0 && (
@@ -284,23 +311,9 @@ export function FirstMessageInput({
)} - {/* Thinking Slider - slider hidden on narrow containers, label always clickable */} -
- -
- - {/* Context 1M Checkbox - always visible */} -
- -
- - - {/* Runtime Selector */}
+ onTrunkBranchChange(e.target.value)} + disabled={disabled} + className="bg-separator text-foreground border-border-medium focus:border-accent max-w-[120px] rounded border px-2 py-1 text-xs focus:outline-none disabled:opacity-50" + > + {branches.map((branch) => ( + + ))} + +
+ )} + + {/* Runtime Selector */} +
+ + + {runtimeMode === RUNTIME_MODE.SSH && ( + onRuntimeChange(RUNTIME_MODE.SSH, e.target.value)} + placeholder="user@host" + disabled={disabled} + className="bg-separator text-foreground border-border-medium focus:border-accent w-32 rounded border px-2 py-1 text-xs focus:outline-none disabled:opacity-50" + /> + )} + + ? + + Runtime: +
+ • Local: git worktree in ~/.cmux/src +
• SSH: remote clone in ~/cmux on SSH host +
+
+
+
+ ); +} diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput/index.tsx similarity index 76% rename from src/components/ChatInput.tsx rename to src/components/ChatInput/index.tsx index f329565b7..2edd7437d 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput/index.tsx @@ -8,15 +8,15 @@ import React, { useMemo, useDeferredValue, } from "react"; -import { CommandSuggestions, COMMAND_SUGGESTION_KEYS } from "./CommandSuggestions"; -import type { Toast } from "./ChatInputToast"; -import { ChatInputToast } from "./ChatInputToast"; -import { createCommandToast, createErrorToast } from "./ChatInputToasts"; +import { CommandSuggestions, COMMAND_SUGGESTION_KEYS } from "../CommandSuggestions"; +import type { Toast } from "../ChatInputToast"; +import { ChatInputToast } from "../ChatInputToast"; +import { createCommandToast, createErrorToast } from "../ChatInputToasts"; import { parseCommand } from "@/utils/slashCommands/parser"; import { usePersistedState, updatePersistedState } from "@/hooks/usePersistedState"; import { useMode } from "@/contexts/ModeContext"; -import { ThinkingSliderComponent } from "./ThinkingSlider"; -import { Context1MCheckbox } from "./Context1MCheckbox"; +import { ThinkingSliderComponent } from "../ThinkingSlider"; +import { Context1MCheckbox } from "../Context1MCheckbox"; import { useSendMessageOptions } from "@/hooks/useSendMessageOptions"; import { getModelKey, getInputKey, VIM_ENABLED_KEY } from "@/constants/storage"; import { @@ -31,13 +31,13 @@ import { getSlashCommandSuggestions, type SlashSuggestion, } from "@/utils/slashCommands/suggestions"; -import { TooltipWrapper, Tooltip, HelpIndicator } from "./Tooltip"; -import { ModeSelector } from "./ModeSelector"; +import { TooltipWrapper, Tooltip, HelpIndicator } from "../Tooltip"; +import { ModeSelector } from "../ModeSelector"; import { matchesKeybind, formatKeybind, KEYBINDS, isEditableElement } from "@/utils/ui/keybinds"; -import { ModelSelector, type ModelSelectorRef } from "./ModelSelector"; +import { ModelSelector, type ModelSelectorRef } from "../ModelSelector"; import { useModelLRU } from "@/hooks/useModelLRU"; -import { VimTextArea } from "./VimTextArea"; -import { ImageAttachments, type ImageAttachment } from "./ImageAttachments"; +import { VimTextArea } from "../VimTextArea"; +import { ImageAttachments, type ImageAttachment } from "../ImageAttachments"; import { extractImagesFromClipboard, extractImagesFromDrop, @@ -49,6 +49,9 @@ import type { MuxFrontendMetadata } from "@/types/message"; import { useTelemetry } from "@/hooks/useTelemetry"; import { setTelemetryEnabled } from "@/telemetry"; import { getTokenCountPromise } from "@/utils/tokenizer/rendererClient"; +import { CreationCenterContent } from "./CreationCenterContent"; +import { CreationControls } from "./CreationControls"; +import { useCreationWorkspace } from "./useCreationWorkspace"; type TokenCountReader = () => number; @@ -80,44 +83,34 @@ function createTokenCountResource(promise: Promise): TokenCountReader { }; } -export interface ChatInputAPI { - focus: () => void; - restoreText: (text: string) => void; - appendText: (text: string) => void; -} - -export interface ChatInputProps { - workspaceId: string; - onMessageSent?: () => void; // Optional callback after successful send - onTruncateHistory: (percentage?: number) => Promise; - onProviderConfig?: (provider: string, keyPath: string[], value: string) => Promise; - onModelChange?: (model: string) => void; - disabled?: boolean; - isCompacting?: boolean; - editingMessage?: { id: string; content: string }; - onCancelEdit?: () => void; - onEditLastUserMessage?: () => void; - canInterrupt?: boolean; // Whether Esc can be used to interrupt streaming - onReady?: (api: ChatInputAPI) => void; // Callback with focus method -} +// Import types from local types file +import type { ChatInputProps, ChatInputAPI } from "./types"; +export type { ChatInputProps, ChatInputAPI }; + +export const ChatInput: React.FC = (props) => { + const { variant } = props; + + // Extract workspace-specific props with defaults + const disabled = props.disabled ?? false; + const editingMessage = variant === "workspace" ? props.editingMessage : undefined; + const isCompacting = variant === "workspace" ? (props.isCompacting ?? false) : false; + const canInterrupt = variant === "workspace" ? (props.canInterrupt ?? false) : false; + + // Storage keys differ by variant + const storageKeys = (() => { + if (variant === "creation") { + return { + inputKey: getInputKey(`__pending__${props.projectPath}`), + modelKey: getModelKey(`__project__${props.projectPath}`), + }; + } + return { + inputKey: getInputKey(props.workspaceId), + modelKey: getModelKey(props.workspaceId), + }; + })(); -// Helper function to convert parsed command to display toast - -export const ChatInput: React.FC = ({ - workspaceId, - onMessageSent, - onTruncateHistory, - onProviderConfig, - onModelChange, - disabled = false, - isCompacting = false, - editingMessage, - onCancelEdit, - onEditLastUserMessage, - canInterrupt = false, - onReady, -}) => { - const [input, setInput] = usePersistedState(getInputKey(workspaceId), "", { listener: true }); + const [input, setInput] = usePersistedState(storageKeys.inputKey, "", { listener: true }); const [isSending, setIsSending] = useState(false); const [showCommandSuggestions, setShowCommandSuggestions] = useState(false); const [commandSuggestions, setCommandSuggestions] = useState([]); @@ -138,7 +131,10 @@ export const ChatInput: React.FC = ({ }); // Get current send message options from shared hook (must be at component top level) - const sendMessageOptions = useSendMessageOptions(workspaceId); + // For creation variant, use project-scoped key; for workspace, use workspace ID + const sendMessageOptions = useSendMessageOptions( + variant === "workspace" ? props.workspaceId : `__project__${props.projectPath}` + ); // Extract model for convenience (don't create separate state - use hook as single source of truth) const preferredModel = sendMessageOptions.model; const deferredModel = useDeferredValue(preferredModel); @@ -158,9 +154,25 @@ export const ChatInput: React.FC = ({ const setPreferredModel = useCallback( (model: string) => { addModel(model); // Update LRU - updatePersistedState(getModelKey(workspaceId), model); // Update workspace-specific + updatePersistedState(storageKeys.modelKey, model); // Update workspace or project-specific }, - [workspaceId, addModel] + [storageKeys.modelKey, addModel] + ); + + // Creation-specific state (hook always called, but only used when variant === "creation") + // This avoids conditional hook calls which violate React rules + const creationState = useCreationWorkspace( + variant === "creation" + ? { + projectPath: props.projectPath, + onWorkspaceCreated: props.onWorkspaceCreated, + } + : { + // Dummy values for workspace variant (never used) + projectPath: "", + // eslint-disable-next-line @typescript-eslint/no-empty-function + onWorkspaceCreated: () => {}, + } ); const focusMessageInput = useCallback(() => { @@ -204,14 +216,14 @@ export const ChatInput: React.FC = ({ // Provide API to parent via callback useEffect(() => { - if (onReady) { - onReady({ + if (props.onReady) { + props.onReady({ focus: focusMessageInput, restoreText, appendText, }); } - }, [onReady, focusMessageInput, restoreText, appendText]); + }, [props.onReady, focusMessageInput, restoreText, appendText, props]); useEffect(() => { const handleGlobalKeyDown = (event: KeyboardEvent) => { @@ -306,8 +318,11 @@ export const ChatInput: React.FC = ({ window.removeEventListener(CUSTOM_EVENTS.OPEN_MODEL_SELECTOR, handler as EventListener); }, []); - // Show toast when thinking level is changed via command palette + // Show toast when thinking level is changed via command palette (workspace only) useEffect(() => { + if (variant !== "workspace") return; + + const workspaceId = props.workspaceId; const handler = (event: Event) => { const detail = (event as CustomEvent<{ workspaceId: string; level: ThinkingLevel }>).detail; if (detail?.workspaceId !== workspaceId || !detail.level) { @@ -332,16 +347,18 @@ export const ChatInput: React.FC = ({ window.addEventListener(CUSTOM_EVENTS.THINKING_LEVEL_TOAST, handler as EventListener); return () => window.removeEventListener(CUSTOM_EVENTS.THINKING_LEVEL_TOAST, handler as EventListener); - }, [workspaceId, setToast]); + }, [variant, props, setToast]); - // Auto-focus chat input when workspace changes (e.g., new workspace created or switched) + // Auto-focus chat input when workspace changes (workspace only) useEffect(() => { + if (variant !== "workspace") return; + // Small delay to ensure DOM is ready and other components have settled const timer = setTimeout(() => { focusMessageInput(); }, 100); return () => clearTimeout(timer); - }, [workspaceId, focusMessageInput]); + }, [variant, props, focusMessageInput]); // Handle paste events to extract images const handlePaste = useCallback((e: React.ClipboardEvent) => { @@ -402,6 +419,29 @@ export const ChatInput: React.FC = ({ const messageText = input.trim(); + // Route to creation handler for creation variant + if (variant === "creation") { + // Creation variant: simple message send + workspace creation + setIsSending(true); + setInput(""); // Clear input immediately (will be restored by parent if creation fails) + await creationState.handleSend(messageText); + setIsSending(false); + return; + } + + // Workspace variant: full command handling + message send + if (variant !== "workspace") return; // Type guard + + // Destructure workspace-specific props + const { + workspaceId, + onTruncateHistory, + onProviderConfig, + onModelChange, + onMessageSent, + onCancelEdit, + } = props; + try { // Parse command const parsed = parseCommand(messageText); @@ -705,6 +745,13 @@ export const ChatInput: React.FC = ({ }; const handleKeyDown = (e: React.KeyboardEvent) => { + // Handle Escape for creation variant cancel + if (variant === "creation" && e.key === "Escape" && props.onCancel) { + e.preventDefault(); + props.onCancel(); + return; + } + // Handle open model selector if (matchesKeybind(e, KEYBINDS.OPEN_MODEL_SELECTOR)) { e.preventDefault(); @@ -712,11 +759,11 @@ export const ChatInput: React.FC = ({ return; } - // Handle cancel edit (Ctrl+Q) + // Handle cancel edit (Ctrl+Q) - workspace only if (matchesKeybind(e, KEYBINDS.CANCEL_EDIT)) { - if (editingMessage && onCancelEdit) { + if (variant === "workspace" && editingMessage && props.onCancelEdit) { e.preventDefault(); - onCancelEdit(); + props.onCancelEdit(); const isFocused = document.activeElement === inputRef.current; if (isFocused) { inputRef.current?.blur(); @@ -725,10 +772,10 @@ export const ChatInput: React.FC = ({ } } - // Handle up arrow on empty input - edit last user message - if (e.key === "ArrowUp" && !editingMessage && input.trim() === "" && onEditLastUserMessage) { + // Handle up arrow on empty input - edit last user message (workspace only) + if (variant === "workspace" && e.key === "ArrowUp" && !editingMessage && input.trim() === "" && props.onEditLastUserMessage) { e.preventDefault(); - onEditLastUserMessage(); + props.onEditLastUserMessage(); return; } @@ -753,6 +800,12 @@ export const ChatInput: React.FC = ({ // Build placeholder text based on current state const placeholder = (() => { + // Creation variant has simple placeholder + if (variant === "creation") { + return `Type your first message to create a workspace... (${formatKeybind(KEYBINDS.SEND_MESSAGE)} to send, Esc to cancel)`; + } + + // Workspace variant placeholders if (editingMessage) { return `Edit your message... (${formatKeybind(KEYBINDS.CANCEL_EDIT)} to cancel, ${formatKeybind(KEYBINDS.SEND_MESSAGE)} to send)`; } @@ -779,30 +832,53 @@ export const ChatInput: React.FC = ({ })(); return ( -
- - setShowCommandSuggestions(false)} - isVisible={showCommandSuggestions} - ariaLabel="Slash command suggestions" - listId={commandListId} - /> -
- + {/* Creation center content (shows while loading or idle) */} + {variant === "creation" && ( + + )} + + {/* Input section */} +
+ {/* Creation error toast */} + {variant === "creation" && creationState?.error && ( +
+ {creationState.error} +
+ )} + + {/* Workspace toast */} + {variant === "workspace" && } + + {/* Command suggestions - workspace only */} + {variant === "workspace" && ( + setShowCommandSuggestions(false)} + isVisible={showCommandSuggestions} + ariaLabel="Slash command suggestions" + listId={commandListId} + /> + )} + +
+ = ({ showCommandSuggestions && commandSuggestions.length > 0 ? commandListId : undefined } aria-expanded={showCommandSuggestions && commandSuggestions.length > 0} - /> -
- -
- {editingMessage && ( -
- Editing message ({formatKeybind(KEYBINDS.CANCEL_EDIT)} to cancel) -
- )} -
+ /> +
+ + {/* Image attachments - workspace only */} + {variant === "workspace" && } + +
+ {/* Editing indicator - workspace only */} + {variant === "workspace" && editingMessage && ( +
+ Editing message ({formatKeybind(KEYBINDS.CANCEL_EDIT)} to cancel) +
+ )} + +
{/* Model Selector - always visible */}
= ({
)} - + +
+ + {/* Creation controls - second row for creation variant */} + {variant === "creation" && ( + + )}
-
+ ); }; diff --git a/src/components/ChatInput/types.ts b/src/components/ChatInput/types.ts new file mode 100644 index 000000000..69da80edd --- /dev/null +++ b/src/components/ChatInput/types.ts @@ -0,0 +1,37 @@ +import type { FrontendWorkspaceMetadata } from "@/types/workspace"; + +export interface ChatInputAPI { + focus: () => void; + restoreText: (text: string) => void; + appendText: (text: string) => void; +} + +// Workspace variant: full functionality for existing workspaces +export interface ChatInputWorkspaceVariant { + variant: "workspace"; + workspaceId: string; + onMessageSent?: () => void; + onTruncateHistory: (percentage?: number) => Promise; + onProviderConfig?: (provider: string, keyPath: string[], value: string) => Promise; + onModelChange?: (model: string) => void; + isCompacting?: boolean; + editingMessage?: { id: string; content: string }; + onCancelEdit?: () => void; + onEditLastUserMessage?: () => void; + canInterrupt?: boolean; + disabled?: boolean; + onReady?: (api: ChatInputAPI) => void; +} + +// Creation variant: simplified for first message / workspace creation +export interface ChatInputCreationVariant { + variant: "creation"; + projectPath: string; + projectName: string; + onWorkspaceCreated: (metadata: FrontendWorkspaceMetadata) => void; + onCancel?: () => void; + disabled?: boolean; + onReady?: (api: ChatInputAPI) => void; +} + +export type ChatInputProps = ChatInputWorkspaceVariant | ChatInputCreationVariant; diff --git a/src/components/ChatInput/useCreationWorkspace.ts b/src/components/ChatInput/useCreationWorkspace.ts new file mode 100644 index 000000000..38c936018 --- /dev/null +++ b/src/components/ChatInput/useCreationWorkspace.ts @@ -0,0 +1,139 @@ +import { useState, useEffect, useCallback } from "react"; +import type { FrontendWorkspaceMetadata } from "@/types/workspace"; +import type { RuntimeConfig } from "@/types/runtime"; +import type { RUNTIME_MODE } from "@/types/runtime"; +import { parseRuntimeString } from "@/utils/chatCommands"; +import { getRuntimeKey } from "@/constants/storage"; +import { updatePersistedState } from "@/hooks/usePersistedState"; +import { useNewWorkspaceOptions } from "@/hooks/useNewWorkspaceOptions"; +import { useSendMessageOptions } from "@/hooks/useSendMessageOptions"; +import { extractErrorMessage } from "./utils"; + +interface UseCreationWorkspaceOptions { + projectPath: string; + onWorkspaceCreated: (metadata: FrontendWorkspaceMetadata) => void; +} + +interface UseCreationWorkspaceReturn { + branches: string[]; + trunkBranch: string; + setTrunkBranch: (branch: string) => void; + runtimeMode: typeof RUNTIME_MODE.LOCAL | typeof RUNTIME_MODE.SSH; + sshHost: string; + setRuntimeOptions: ( + mode: typeof RUNTIME_MODE.LOCAL | typeof RUNTIME_MODE.SSH, + host: string + ) => void; + error: string | null; + setError: (error: string | null) => void; + isSending: boolean; + handleSend: (message: string) => Promise; +} + +/** + * Hook for managing workspace creation state and logic + * Handles: + * - Branch selection + * - Runtime configuration (local vs SSH) + * - Message sending with workspace creation + */ +export function useCreationWorkspace({ + projectPath, + onWorkspaceCreated, +}: UseCreationWorkspaceOptions): UseCreationWorkspaceReturn { + const [branches, setBranches] = useState([]); + const [trunkBranch, setTrunkBranch] = useState(""); + const [error, setError] = useState(null); + const [isSending, setIsSending] = useState(false); + + // Runtime configuration (Local vs SSH) + const [runtimeOptions, setRuntimeOptions] = useNewWorkspaceOptions(projectPath); + const { runtimeMode, sshHost, getRuntimeString } = runtimeOptions; + + // Get send options from shared hook (uses project-scoped storage key) + const sendMessageOptions = useSendMessageOptions(`__project__${projectPath}`); + + // Load branches on mount + useEffect(() => { + const loadBranches = async () => { + try { + const result = await window.api.projects.listBranches(projectPath); + setBranches(result.branches); + if (result.recommendedTrunk) { + setTrunkBranch(result.recommendedTrunk); + } else if (result.branches.length > 0) { + setTrunkBranch(result.branches[0]); + } + } catch (err) { + console.error("Failed to load branches:", err); + } + }; + void loadBranches(); + }, [projectPath]); + + const handleSend = useCallback( + async (message: string) => { + if (!message.trim() || isSending) return; + + setIsSending(true); + setError(null); + + try { + // Get runtime config from options + const runtimeString = getRuntimeString(); + const runtimeConfig: RuntimeConfig | undefined = runtimeString + ? parseRuntimeString(runtimeString, "") + : undefined; + + // Send message with runtime config and creation-specific params + const result = await window.api.workspace.sendMessage(null, message, { + ...sendMessageOptions, + runtimeConfig, + projectPath, // Pass projectPath when workspaceId is null + trunkBranch, // Pass selected trunk branch + }); + + if (!result.success) { + setError(extractErrorMessage(result.error)); + setIsSending(false); + return; + } + + // Check if this is a workspace creation result (has metadata field) + if ("metadata" in result && result.metadata) { + // Save runtime preference for this project + const runtimeString = getRuntimeString(); + if (runtimeString) { + const runtimeKey = getRuntimeKey(projectPath); + updatePersistedState(runtimeKey, runtimeString); + } + + // Notify parent to switch workspace (clears input via parent unmount) + onWorkspaceCreated(result.metadata); + } else { + // This shouldn't happen for null workspaceId, but handle gracefully + setError("Unexpected response from server"); + setIsSending(false); + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + setError(`Failed to create workspace: ${errorMessage}`); + setIsSending(false); + } + }, + [isSending, projectPath, onWorkspaceCreated, getRuntimeString, sendMessageOptions, trunkBranch] + ); + + return { + branches, + trunkBranch, + setTrunkBranch, + runtimeMode, + sshHost, + setRuntimeOptions, + error, + setError, + isSending, + handleSend, + }; +} diff --git a/src/components/ChatInput/utils.ts b/src/components/ChatInput/utils.ts new file mode 100644 index 000000000..a07bcec96 --- /dev/null +++ b/src/components/ChatInput/utils.ts @@ -0,0 +1,12 @@ +import type { SendMessageError } from "@/types/errors"; + +/** + * Extract error message from SendMessageError or string + * Handles both string errors and structured error objects + */ +export function extractErrorMessage(error: SendMessageError | string): string { + if (typeof error === "string") { + return error; + } + return "raw" in error ? error.raw : error.type; +} diff --git a/src/components/FirstMessageInput.tsx b/src/components/FirstMessageInput.tsx deleted file mode 100644 index 84527d4f3..000000000 --- a/src/components/FirstMessageInput.tsx +++ /dev/null @@ -1,356 +0,0 @@ -import React, { useState, useRef, useCallback, useEffect } from "react"; -import type { FrontendWorkspaceMetadata } from "@/types/workspace"; -import type { RuntimeConfig } from "@/types/runtime"; -import { RUNTIME_MODE } from "@/types/runtime"; -import { parseRuntimeString } from "@/utils/chatCommands"; -import { getModelKey, getRuntimeKey } from "@/constants/storage"; -import { useModelLRU } from "@/hooks/useModelLRU"; -import { updatePersistedState, readPersistedState } from "@/hooks/usePersistedState"; -import { useNewWorkspaceOptions } from "@/hooks/useNewWorkspaceOptions"; -import { useMode } from "@/contexts/ModeContext"; -import { useThinkingLevel } from "@/hooks/useThinkingLevel"; -import { use1MContext } from "@/hooks/use1MContext"; -import { modeToToolPolicy, PLAN_MODE_INSTRUCTION } from "@/utils/ui/modeUtils"; -import { enforceThinkingPolicy } from "@/utils/thinking/policy"; -import type { SendMessageOptions } from "@/types/ipc"; -import { ModelSelector } from "./ModelSelector"; -import { VimTextArea } from "./VimTextArea"; -import { ThinkingSliderComponent } from "./ThinkingSlider"; -import { Context1MCheckbox } from "./Context1MCheckbox"; -import { matchesKeybind, KEYBINDS } from "@/utils/ui/keybinds"; -import { ModeSelector } from "./ModeSelector"; -import { TooltipWrapper, Tooltip } from "./Tooltip"; - -interface FirstMessageInputProps { - projectPath: string; - projectName: string; - onWorkspaceCreated: (metadata: FrontendWorkspaceMetadata) => void; - onCancel?: () => void; -} - -/** - * FirstMessageInput - Simplified input for sending first message without a workspace - * - * When user sends a message, it: - * 1. Creates a workspace with AI-generated title/branch - * 2. Sends the message to the new workspace - * 3. Switches to the new workspace (via callback) - */ -export function FirstMessageInput({ - projectPath, - projectName, - onWorkspaceCreated, - onCancel, -}: FirstMessageInputProps) { - const [input, setInput] = useState(""); - const [isSending, setIsSending] = useState(false); - const [error, setError] = useState(null); - const [branches, setBranches] = useState([]); - const [trunkBranch, setTrunkBranch] = useState(""); - const inputRef = useRef(null); - - // Mode selection (Exec/Plan) - uses global key via ModeProvider - const [mode, setMode] = useMode(); - - // Thinking level - uses global key via ThinkingProvider - const [thinkingLevel] = useThinkingLevel(); - - // 1M context (global setting) - const [use1M] = use1MContext(); - - // Get most recent model from LRU (project-scoped preference) - const { recentModels, addModel } = useModelLRU(); - const projectModelKey = getModelKey(`__project__${projectPath}`); - const preferredModel = readPersistedState(projectModelKey, recentModels[0]); - - // Setter for model - const setPreferredModel = useCallback( - (model: string) => { - addModel(model); - updatePersistedState(projectModelKey, model); - }, - [projectModelKey, addModel] - ); - - // Runtime configuration (Local vs SSH) - const [runtimeOptions, setRuntimeOptions] = useNewWorkspaceOptions(projectPath); - const { runtimeMode, sshHost, getRuntimeString } = runtimeOptions; - - // Load branches on mount - useEffect(() => { - async function loadBranches() { - try { - const result = await window.api.projects.listBranches(projectPath); - const sanitizedBranches = Array.isArray(result?.branches) - ? result.branches.filter((branch): branch is string => typeof branch === "string") - : []; - setBranches(sanitizedBranches); - - // Set default trunk branch - const recommended = - typeof result?.recommendedTrunk === "string" && - sanitizedBranches.includes(result.recommendedTrunk) - ? result.recommendedTrunk - : (sanitizedBranches[0] ?? "main"); - setTrunkBranch(recommended); - } catch (err) { - console.error("Failed to load branches:", err); - setTrunkBranch("main"); // Fallback - } - } - void loadBranches(); - }, [projectPath]); - - const handleSend = useCallback(async () => { - if (!input.trim() || isSending) return; - - setIsSending(true); - setError(null); - - try { - // Get runtime config from options - const runtimeString = getRuntimeString(); - const runtimeConfig: RuntimeConfig | undefined = runtimeString - ? parseRuntimeString(runtimeString, "") - : undefined; - - // Build SendMessageOptions (same logic as useSendMessageOptions) - const additionalSystemInstructions = mode === "plan" ? PLAN_MODE_INSTRUCTION : undefined; - const model = - typeof preferredModel === "string" && preferredModel ? preferredModel : recentModels[0]; - const uiThinking = enforceThinkingPolicy(model, thinkingLevel); - - const sendMessageOptions: SendMessageOptions = { - thinkingLevel: uiThinking, - model, - mode: mode === "exec" || mode === "plan" ? mode : "exec", - toolPolicy: modeToToolPolicy(mode), - additionalSystemInstructions, - providerOptions: { - anthropic: { - use1MContext: use1M, - }, - }, - }; - - const result = await window.api.workspace.sendMessage(null, input, { - ...sendMessageOptions, - runtimeConfig, - projectPath, // Pass projectPath when workspaceId is null - trunkBranch, // Pass selected trunk branch - }); - - if (!result.success) { - const errorMsg = - typeof result.error === "string" - ? result.error - : "raw" in result.error - ? result.error.raw - : result.error.type; - setError(errorMsg); - setIsSending(false); - return; - } - - // Check if this is a workspace creation result (has metadata field) - if ("metadata" in result && result.metadata) { - // Clear input - setInput(""); - - // Save runtime preference for this project - const runtimeString = getRuntimeString(); - if (runtimeString) { - const runtimeKey = getRuntimeKey(projectPath); - updatePersistedState(runtimeKey, runtimeString); - } - - // Notify parent to switch workspace - onWorkspaceCreated(result.metadata); - } else { - // This shouldn't happen for null workspaceId, but handle gracefully - setError("Unexpected response from server"); - setIsSending(false); - } - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); - setError(`Failed to create workspace: ${errorMessage}`); - setIsSending(false); - } - }, [ - input, - isSending, - projectPath, - preferredModel, - onWorkspaceCreated, - getRuntimeString, - mode, - thinkingLevel, - use1M, - recentModels, - trunkBranch, - ]); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - // Handle send message (Shift+Enter for newline is default behavior) - if (matchesKeybind(e, KEYBINDS.SEND_MESSAGE)) { - e.preventDefault(); - void handleSend(); - } - // Cancel on Escape - if (e.key === "Escape" && onCancel) { - e.preventDefault(); - onCancel(); - } - }, - [handleSend, onCancel] - ); - - return ( -
- {/* Project title or loading state in center */} -
- {isSending ? ( -
-
-

Creating workspace...

-
- ) : ( -
-

{projectName}

-

- Describe what you want to build. A new workspace will be created with an automatically - generated branch name. Configure runtime and model options below. -

-
- )} -
- - {/* Input area - styled like ChatInput */} -
- {/* Error toast */} - {error && ( -
- {error} -
- )} - - {/* Text input */} -
- -
- - {/* Options section - separated into two rows */} -
- {/* First row: Model + Thinking + Context + Mode */} -
- {/* Model Selector */} -
- inputRef.current?.focus()} - /> -
- - {/* Thinking Slider - slider hidden on narrow containers, label always clickable */} -
- -
- - {/* Context 1M Checkbox - always visible */} -
- -
- - -
- - {/* Second row: New workspace controls (Trunk Branch + Runtime) */} -
- {/* Trunk Branch Selector */} - {branches.length > 0 && ( -
- - -
- )} - - {/* Runtime Selector */} -
- - - {runtimeMode === RUNTIME_MODE.SSH && ( - setRuntimeOptions(RUNTIME_MODE.SSH, e.target.value)} - placeholder="user@host" - disabled={isSending} - className="bg-separator text-foreground border-border-medium focus:border-accent w-32 rounded border px-2 py-1 text-xs focus:outline-none disabled:opacity-50" - /> - )} - - ? - - Runtime: -
- • Local: git worktree in ~/.cmux/src -
• SSH: remote clone in ~/cmux on SSH host -
-
-
-
-
-
-
- ); -} From 1d8bbb050dc79076f8b1cc6ad65c8eca59b257d1 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 13 Nov 2025 17:46:04 +0000 Subject: [PATCH 03/13] =?UTF-8?q?=F0=9F=A4=96=20fix:=20add=20full-height?= =?UTF-8?q?=20flex=20wrapper=20for=20creation=20variant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creation variant needs 'flex h-full flex-1 flex-col' wrapper to: - Make center content vertically centered - Pin input section to bottom Uses conditional wrapper pattern to avoid affecting workspace variant. --- src/components/ChatInput/index.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/ChatInput/index.tsx b/src/components/ChatInput/index.tsx index 2edd7437d..cdea8c19c 100644 --- a/src/components/ChatInput/index.tsx +++ b/src/components/ChatInput/index.tsx @@ -831,8 +831,13 @@ export const ChatInput: React.FC = (props) => { return `Type a message... (${hints.join(", ")})`; })(); + // Wrapper for creation variant to enable full-height flex layout + const Wrapper = variant === "creation" ? "div" : React.Fragment; + const wrapperProps = + variant === "creation" ? { className: "flex h-full flex-1 flex-col" } : {}; + return ( - <> + {/* Creation center content (shows while loading or idle) */} {variant === "creation" && ( = (props) => { )}
- + ); }; From 656584d85e79e746466d627bfa1fb9ced4e590c8 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 13 Nov 2025 17:54:30 +0000 Subject: [PATCH 04/13] refactor: centralize draft workspace settings persistence Problem: - Runtime config & trunk branch weren't persisted when creating workspace - Settings lost when navigating away from draft workspace - Piecemeal field-by-field persistence scattered across components - Select elements had excessive vertical spacing (browser defaults) Solution: 1. Created useDraftWorkspaceSettings hook - centralized persistence - Model, mode, thinking level, use1M (synced from global state) - Runtime mode, SSH host, trunk branch (project-scoped) - All settings auto-persist via usePersistedState - Single source of truth for draft workspace settings 2. Created reusable Select component - Consistent styling across codebase - Normalizes string[] or SelectOption[] inputs - Replaces raw onTrunkBranchChange(e.target.value)} + options={branches} + onChange={onTrunkBranchChange} disabled={disabled} - className="bg-separator text-foreground border-border-medium focus:border-accent max-w-[120px] rounded border px-2 py-1 text-xs focus:outline-none disabled:opacity-50" - > - {branches.map((branch) => ( - - ))} - + className="max-w-[120px]" + />
)} {/* Runtime Selector */}
- + aria-label="Runtime mode" + /> {runtimeMode === RUNTIME_MODE.SSH && ( ([]); - const [trunkBranch, setTrunkBranch] = useState(""); + const [recommendedTrunk, setRecommendedTrunk] = useState(null); const [error, setError] = useState(null); const [isSending, setIsSending] = useState(false); - // Runtime configuration (Local vs SSH) - const [runtimeOptions, setRuntimeOptions] = useNewWorkspaceOptions(projectPath); - const { runtimeMode, sshHost, getRuntimeString } = runtimeOptions; + // Centralized draft workspace settings with automatic persistence + const { settings, setRuntimeOptions, setTrunkBranch, getRuntimeString } = + useDraftWorkspaceSettings(projectPath, branches, recommendedTrunk); // Get send options from shared hook (uses project-scoped storage key) const sendMessageOptions = useSendMessageOptions(`__project__${projectPath}`); @@ -59,11 +57,7 @@ export function useCreationWorkspace({ try { const result = await window.api.projects.listBranches(projectPath); setBranches(result.branches); - if (result.recommendedTrunk) { - setTrunkBranch(result.recommendedTrunk); - } else if (result.branches.length > 0) { - setTrunkBranch(result.branches[0]); - } + setRecommendedTrunk(result.recommendedTrunk); } catch (err) { console.error("Failed to load branches:", err); } @@ -90,7 +84,7 @@ export function useCreationWorkspace({ ...sendMessageOptions, runtimeConfig, projectPath, // Pass projectPath when workspaceId is null - trunkBranch, // Pass selected trunk branch + trunkBranch: settings.trunkBranch, // Pass selected trunk branch from settings }); if (!result.success) { @@ -101,13 +95,7 @@ export function useCreationWorkspace({ // Check if this is a workspace creation result (has metadata field) if ("metadata" in result && result.metadata) { - // Save runtime preference for this project - const runtimeString = getRuntimeString(); - if (runtimeString) { - const runtimeKey = getRuntimeKey(projectPath); - updatePersistedState(runtimeKey, runtimeString); - } - + // Settings are already persisted via useDraftWorkspaceSettings // Notify parent to switch workspace (clears input via parent unmount) onWorkspaceCreated(result.metadata); } else { @@ -121,15 +109,15 @@ export function useCreationWorkspace({ setIsSending(false); } }, - [isSending, projectPath, onWorkspaceCreated, getRuntimeString, sendMessageOptions, trunkBranch] + [isSending, projectPath, onWorkspaceCreated, getRuntimeString, sendMessageOptions, settings.trunkBranch] ); return { branches, - trunkBranch, + trunkBranch: settings.trunkBranch, setTrunkBranch, - runtimeMode, - sshHost, + runtimeMode: settings.runtimeMode, + sshHost: settings.sshHost, setRuntimeOptions, error, setError, diff --git a/src/components/Select.tsx b/src/components/Select.tsx new file mode 100644 index 000000000..fcd67a126 --- /dev/null +++ b/src/components/Select.tsx @@ -0,0 +1,52 @@ +import React from "react"; + +interface SelectOption { + value: string; + label: string; +} + +interface SelectProps { + value: string; + options: SelectOption[] | string[]; + onChange: (value: string) => void; + disabled?: boolean; + className?: string; + id?: string; + "aria-label"?: string; +} + +/** + * Reusable select component with consistent styling + * Centralizes select styling to avoid duplication and ensure consistent UX + */ +export function Select({ + value, + options, + onChange, + disabled = false, + className = "", + id, + "aria-label": ariaLabel, +}: SelectProps) { + // Normalize options to SelectOption format + const normalizedOptions: SelectOption[] = options.map((opt) => + typeof opt === "string" ? { value: opt, label: opt } : opt + ); + + return ( + + ); +} diff --git a/src/constants/storage.ts b/src/constants/storage.ts index a367f1cfe..3dd368f0c 100644 --- a/src/constants/storage.ts +++ b/src/constants/storage.ts @@ -72,6 +72,15 @@ export function getRuntimeKey(projectPath: string): string { return `runtime:${projectPath}`; } +/** + * Get the localStorage key for trunk branch preference for a project + * Stores the last used trunk branch when creating a workspace + * Format: "trunkBranch:{projectPath}" + */ +export function getTrunkBranchKey(projectPath: string): string { + return `trunkBranch:${projectPath}`; +} + /** * Get the localStorage key for the 1M context preference (global) * Format: "use1MContext" diff --git a/src/hooks/useDraftWorkspaceSettings.ts b/src/hooks/useDraftWorkspaceSettings.ts new file mode 100644 index 000000000..fe3a896c1 --- /dev/null +++ b/src/hooks/useDraftWorkspaceSettings.ts @@ -0,0 +1,119 @@ +import { useEffect } from "react"; +import { usePersistedState } from "./usePersistedState"; +import { use1MContext } from "./use1MContext"; +import { useThinkingLevel } from "./useThinkingLevel"; +import { useMode } from "@/contexts/ModeContext"; +import { useModelLRU } from "./useModelLRU"; +import { + type RuntimeMode, + parseRuntimeModeAndHost, + buildRuntimeString, +} from "@/types/runtime"; +import { + getModelKey, + getRuntimeKey, + getTrunkBranchKey, +} from "@/constants/storage"; +import type { UIMode } from "@/types/mode"; +import type { ThinkingLevel } from "@/types/thinking"; + +/** + * Centralized draft workspace settings for project-level persistence + * All settings persist across navigation and are restored when returning to the same project + */ +export interface DraftWorkspaceSettings { + // Model & AI settings (synced with global state) + model: string; + thinkingLevel: ThinkingLevel; + mode: UIMode; + use1M: boolean; + + // Workspace creation settings (project-specific) + runtimeMode: RuntimeMode; + sshHost: string; + trunkBranch: string; +} + +/** + * Hook to manage all draft workspace settings with centralized persistence + * Loads saved preferences when projectPath changes, persists all changes automatically + * + * @param projectPath - Path to the project (used as key prefix for localStorage) + * @param branches - Available branches (used to set default trunk branch) + * @param recommendedTrunk - Backend-recommended trunk branch + * @returns Settings object and setters + */ +export function useDraftWorkspaceSettings( + projectPath: string, + branches: string[], + recommendedTrunk: string | null +): { + settings: DraftWorkspaceSettings; + setRuntimeOptions: (mode: RuntimeMode, host: string) => void; + setTrunkBranch: (branch: string) => void; + getRuntimeString: () => string | undefined; +} { + // Global AI settings (read-only from global state) + const [use1M] = use1MContext(); + const [thinkingLevel] = useThinkingLevel(); + const [mode] = useMode(); + const { recentModels } = useModelLRU(); + + // Project-scoped model preference (persisted per project) + const [model] = usePersistedState( + getModelKey(`__project__${projectPath}`), + recentModels[0], + { listener: true } + ); + + // Project-scoped runtime preference (persisted per project) + const [runtimeString, setRuntimeString] = usePersistedState( + getRuntimeKey(projectPath), + undefined, + { listener: true } + ); + + // Project-scoped trunk branch preference (persisted per project) + const [trunkBranch, setTrunkBranch] = usePersistedState( + getTrunkBranchKey(projectPath), + "", + { listener: true } + ); + + // Parse runtime string into mode and host + const { mode: runtimeMode, host: sshHost } = parseRuntimeModeAndHost(runtimeString); + + // Initialize trunk branch from backend recommendation or first branch + useEffect(() => { + if (!trunkBranch && branches.length > 0) { + const defaultBranch = recommendedTrunk ?? branches[0]; + setTrunkBranch(defaultBranch); + } + }, [branches, recommendedTrunk, trunkBranch, setTrunkBranch]); + + // Setter for runtime options (updates persisted runtime string) + const setRuntimeOptions = (newMode: RuntimeMode, newHost: string) => { + const newRuntimeString = buildRuntimeString(newMode, newHost); + setRuntimeString(newRuntimeString); + }; + + // Helper to get runtime string for IPC calls + const getRuntimeString = (): string | undefined => { + return buildRuntimeString(runtimeMode, sshHost); + }; + + return { + settings: { + model, + thinkingLevel, + mode, + use1M, + runtimeMode, + sshHost, + trunkBranch, + }, + setRuntimeOptions, + setTrunkBranch, + getRuntimeString, + }; +} From 60e3f5fa8a140c38783e729d14a8f4956ad2a8d7 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 13 Nov 2025 17:56:00 +0000 Subject: [PATCH 05/13] fix: prevent auto-focus during streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: Focus was stolen by ChatInput during streaming, interrupting user interaction with other UI elements (Review tab, scrolling, etc.) Root cause: Auto-focus effect in commit 9731da3e changed dependency from [workspaceId, focusMessageInput] to [variant, props, focusMessageInput]. The 'props' object includes streaming-related fields (isCompacting, canInterrupt, editingMessage) which change frequently during streaming, causing the effect to re-run and steal focus. Fix: Extract workspaceId from props for dependency array, so effect only triggers when workspace actually changes, not on every props update. Before: [variant, props, focusMessageInput] ❌ Triggers on every render After: [variant, workspaceIdForFocus, focusMessageInput] ✅ Only on workspace change --- src/components/ChatInput/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/ChatInput/index.tsx b/src/components/ChatInput/index.tsx index cdea8c19c..6efa07853 100644 --- a/src/components/ChatInput/index.tsx +++ b/src/components/ChatInput/index.tsx @@ -350,6 +350,7 @@ export const ChatInput: React.FC = (props) => { }, [variant, props, setToast]); // Auto-focus chat input when workspace changes (workspace only) + const workspaceIdForFocus = variant === "workspace" ? props.workspaceId : null; useEffect(() => { if (variant !== "workspace") return; @@ -358,7 +359,7 @@ export const ChatInput: React.FC = (props) => { focusMessageInput(); }, 100); return () => clearTimeout(timer); - }, [variant, props, focusMessageInput]); + }, [variant, workspaceIdForFocus, focusMessageInput]); // Handle paste events to extract images const handlePaste = useCallback((e: React.ClipboardEvent) => { From 74e6fc90f5d3be029cfe162dc86b29fcbc225311 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 13 Nov 2025 17:58:55 +0000 Subject: [PATCH 06/13] refactor: use props. instead of destructuring in CreationControls Destructuring props in function signatures duplicates field names and makes refactoring more cumbersome. Using props.fieldName provides a single source of truth and easier maintenance. Also added guideline to AGENTS.md discouraging prop destructuring. --- docs/AGENTS.md | 19 +++++++++++ src/components/ChatInput/CreationControls.tsx | 34 +++++++------------ 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/docs/AGENTS.md b/docs/AGENTS.md index d16e9f66d..8d74d8ace 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -369,6 +369,25 @@ If IPC is hard to test, fix the test infrastructure or IPC layer, don't work aro **Always use persistedState helpers (`usePersistedState`, `readPersistedState`, `updatePersistedState`) instead of direct `localStorage` calls** - provides cross-component sync and consistent error handling. +**Avoid destructuring props in function signatures** - Use `props.fieldName` instead of destructuring in the parameter list. Destructuring duplicates field names and makes refactoring more cumbersome. + +```typescript +// ❌ BAD - Duplicates field names, harder to refactor +export function MyComponent({ + field1, + field2, + field3, + onAction, +}: MyComponentProps) { + return
{field1}
; +} + +// ✅ GOOD - Single source of truth, easier to refactor +export function MyComponent(props: MyComponentProps) { + return
{props.field1}
; +} +``` + ## Module Imports - **NEVER use dynamic imports** - Always use static `import` statements at the top of files. Dynamic imports (`await import()`) are a code smell that indicates improper module structure. diff --git a/src/components/ChatInput/CreationControls.tsx b/src/components/ChatInput/CreationControls.tsx index 748e2d78e..6c0abdb72 100644 --- a/src/components/ChatInput/CreationControls.tsx +++ b/src/components/ChatInput/CreationControls.tsx @@ -21,29 +21,21 @@ interface CreationControlsProps { * - Trunk branch selector (which branch to fork from) * - Runtime mode (local vs SSH) */ -export function CreationControls({ - branches, - trunkBranch, - onTrunkBranchChange, - runtimeMode, - sshHost, - onRuntimeChange, - disabled, -}: CreationControlsProps) { +export function CreationControls(props: CreationControlsProps) { return (
{/* Trunk Branch Selector */} - {branches.length > 0 && ( + {props.branches.length > 0 && (
{ const mode = newMode as typeof RUNTIME_MODE.LOCAL | typeof RUNTIME_MODE.SSH; - onRuntimeChange(mode, mode === RUNTIME_MODE.LOCAL ? "" : sshHost); + props.onRuntimeChange(mode, mode === RUNTIME_MODE.LOCAL ? "" : props.sshHost); }} - disabled={disabled} + disabled={props.disabled} aria-label="Runtime mode" /> - {runtimeMode === RUNTIME_MODE.SSH && ( + {props.runtimeMode === RUNTIME_MODE.SSH && ( onRuntimeChange(RUNTIME_MODE.SSH, e.target.value)} + value={props.sshHost} + onChange={(e) => props.onRuntimeChange(RUNTIME_MODE.SSH, e.target.value)} placeholder="user@host" - disabled={disabled} + disabled={props.disabled} className="bg-separator text-foreground border-border-medium focus:border-accent w-32 rounded border px-2 py-1 text-xs focus:outline-none disabled:opacity-50" /> )} From 776b73bbc328c2aef0e89970b85187409d98521d Mon Sep 17 00:00:00 2001 From: root Date: Thu, 13 Nov 2025 18:01:15 +0000 Subject: [PATCH 07/13] style: reduce padding on select and text input controls Changed padding from 8px horizontal, 4px vertical (px-2 py-1) to 4px horizontal, 2px vertical (px-1 py-0.5) for tighter UI spacing. Affects: - Select component (src/components/Select.tsx) - SSH host input in CreationControls --- src/components/ChatInput/CreationControls.tsx | 2 +- src/components/Select.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ChatInput/CreationControls.tsx b/src/components/ChatInput/CreationControls.tsx index 6c0abdb72..15430924e 100644 --- a/src/components/ChatInput/CreationControls.tsx +++ b/src/components/ChatInput/CreationControls.tsx @@ -64,7 +64,7 @@ export function CreationControls(props: CreationControlsProps) { onChange={(e) => props.onRuntimeChange(RUNTIME_MODE.SSH, 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-2 py-1 text-xs focus:outline-none disabled:opacity-50" + 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/components/Select.tsx b/src/components/Select.tsx index fcd67a126..2f529e55c 100644 --- a/src/components/Select.tsx +++ b/src/components/Select.tsx @@ -40,7 +40,7 @@ export function Select({ onChange={(e) => onChange(e.target.value)} disabled={disabled} aria-label={ariaLabel} - className={`bg-separator text-foreground border-border-medium focus:border-accent rounded border px-2 py-1 text-xs focus:outline-none disabled:opacity-50 ${className}`} + className={`bg-separator text-foreground border-border-medium focus:border-accent rounded border px-1 py-0.5 text-xs focus:outline-none disabled:opacity-50 ${className}`} > {normalizedOptions.map((opt) => (
From 70a55337f40fc06efb10c8a0054b4eb4830081d3 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 13 Nov 2025 19:26:56 +0000 Subject: [PATCH 11/13] refactor: remove displayName concept from workspaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: displayName added unnecessary complexity - two names for every workspace (displayName + name), causing confusion and duplication. Solution: Removed displayName entirely from codebase: 1. Types: Removed displayName from WorkspaceMetadata, WorkspaceEntry, WorkspaceMetadataSchema 2. Generator: generateWorkspaceNames() → generateWorkspaceName() - Only generates git-safe branch name (e.g., "feature-auth") - No separate title generation 3. Backend: Workspace creation uses single name field 4. Config: Removed displayName handling from updateWorkspaceMetadata() 5. UI: WorkspaceListItem shows workspace name directly 6. Title generation: Name regeneration updates name field only Benefits: - Simpler mental model - one name per workspace - Workspace name is git-safe branch name shown in UI - Less code duplication - Clearer semantics (name = what user sees) Note: displayName field remains in schema for backwards compatibility during config load, but is ignored (existing workspaces have both displayName and name, so name is used). --- src/App.tsx | 4 +- src/components/WorkspaceListItem.tsx | 4 +- src/config.ts | 4 +- src/services/ipcMain.ts | 20 ++++------ src/services/workspaceTitleGenerator.ts | 50 ++++++++++--------------- src/types/project.ts | 5 +-- src/types/workspace.ts | 4 -- 7 files changed, 32 insertions(+), 59 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 00293241a..f66abaff7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -120,10 +120,10 @@ function AppInner() { window.history.replaceState(null, "", newHash); } - // Update window title with workspace name (prefer displayName if available) + // Update window title with workspace name const metadata = workspaceMetadata.get(selectedWorkspace.workspaceId); const workspaceName = - metadata?.displayName ?? metadata?.name ?? selectedWorkspace.workspaceId; + metadata?.name ?? selectedWorkspace.workspaceId; const title = `${workspaceName} - ${selectedWorkspace.projectName} - cmux`; void window.api.window.setTitle(title); } else { diff --git a/src/components/WorkspaceListItem.tsx b/src/components/WorkspaceListItem.tsx index cdb6af1ad..6dee35ccc 100644 --- a/src/components/WorkspaceListItem.tsx +++ b/src/components/WorkspaceListItem.tsx @@ -41,7 +41,6 @@ const WorkspaceListItemInner: React.FC = ({ const { id: workspaceId, name: workspaceName, - displayName: displayTitle, namedWorkspacePath, } = metadata; const gitStatus = useGitStatus(workspaceId); @@ -53,8 +52,7 @@ const WorkspaceListItemInner: React.FC = ({ const [editingName, setEditingName] = useState(""); const [renameError, setRenameError] = useState(null); - // Prefer displayName (human-readable title) over name (branch name) for AI-generated workspaces - const displayName = displayTitle ?? workspaceName; + const displayName = workspaceName; const isEditing = editingWorkspaceId === workspaceId; const startRenaming = () => { diff --git a/src/config.ts b/src/config.ts index c16075294..b0e9e9ed3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -252,7 +252,6 @@ export class Config { const metadata: WorkspaceMetadata = { id: workspace.id, name: workspace.name, - displayName: workspace.displayName, // Optional display title projectName, projectPath, // GUARANTEE: All workspaces must have createdAt (assign now if missing) @@ -407,14 +406,13 @@ export class Config { */ async updateWorkspaceMetadata( workspaceId: string, - updates: Partial> + updates: Partial> ): Promise { await this.editConfig((config) => { for (const [_projectPath, projectConfig] of config.projects) { const workspace = projectConfig.workspaces.find((w) => w.id === workspaceId); if (workspace) { if (updates.name !== undefined) workspace.name = updates.name; - if (updates.displayName !== undefined) workspace.displayName = updates.displayName; return config; } } diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index 503173973..097426066 100644 --- a/src/services/ipcMain.ts +++ b/src/services/ipcMain.ts @@ -31,7 +31,7 @@ import type { RuntimeConfig } from "@/types/runtime"; import { isSSHRuntime } from "@/types/runtime"; import { validateProjectPath } from "@/utils/pathUtils"; import { ExtensionMetadataService } from "@/services/ExtensionMetadataService"; -import { generateWorkspaceNames } from "./workspaceTitleGenerator"; +import { generateWorkspaceName } from "./workspaceTitleGenerator"; /** * IpcMain - Manages all IPC handlers and service coordination * @@ -156,14 +156,14 @@ export class IpcMain { | { success: false; error: string } > { try { - // 1. Generate workspace title and branch name using AI (use same model as message) - const { title, branchName } = await generateWorkspaceNames( + // 1. Generate workspace branch name using AI (use same model as message) + const branchName = await generateWorkspaceName( message, options.model, this.config ); - log.debug("Generated workspace names", { title, branchName }); + log.debug("Generated workspace name", { branchName }); // 2. Get trunk branch (use provided trunkBranch or auto-detect) const branches = await listLocalBranches(projectPath); @@ -220,7 +220,6 @@ export class IpcMain { const metadata = { id: workspaceId, name: branchName, - displayName: title, projectName, projectPath, createdAt: new Date().toISOString(), @@ -236,7 +235,6 @@ export class IpcMain { path: createResult.workspacePath!, id: workspaceId, name: branchName, - displayName: title, createdAt: metadata.createdAt, runtimeConfig: finalRuntimeConfig, }); @@ -824,7 +822,7 @@ export class IpcMain { const metadata = allMetadata.find((m) => m.id === workspaceId); // Regenerate title/branch if missing (robust to errors/restarts) - if (metadata && (!metadata.displayName || !metadata.name)) { + if (metadata && !metadata.name) { log.info(`Workspace ${workspaceId} missing title or branch name, regenerating...`); try { const historyResult = await this.historyService.getHistory(workspaceId); @@ -841,22 +839,20 @@ export class IpcMain { const messageText = textParts.map((p) => p.text).join(" "); if (messageText.trim()) { - const { title, branchName } = await generateWorkspaceNames( + const branchName = await generateWorkspaceName( messageText, "anthropic:claude-sonnet-4-5", // Use reasonable default model this.config ); - // Update config with regenerated names + // Update config with regenerated name await this.config.updateWorkspaceMetadata(workspaceId, { name: branchName, - displayName: title, }); // Return updated metadata metadata.name = branchName; - metadata.displayName = title; - log.info(`Regenerated workspace names: ${title} (${branchName})`); + log.info(`Regenerated workspace name: ${branchName}`); } } } catch (error) { diff --git a/src/services/workspaceTitleGenerator.ts b/src/services/workspaceTitleGenerator.ts index ceb3c5590..353b4ab50 100644 --- a/src/services/workspaceTitleGenerator.ts +++ b/src/services/workspaceTitleGenerator.ts @@ -5,61 +5,52 @@ import { log } from "./log"; import { createAnthropic } from "@ai-sdk/anthropic"; import { createOpenAI } from "@ai-sdk/openai"; -const workspaceNamesSchema = z.object({ - title: z - .string() - .min(10) - .max(80) - .describe("Human-readable workspace title with proper capitalization and spaces"), - branchName: z +const workspaceNameSchema = z.object({ + name: z .string() .regex(/^[a-z0-9-]+$/) .min(3) .max(50) - .describe("Git-safe branch name: lowercase, hyphens only"), + .describe("Git-safe branch/workspace name: lowercase, hyphens only"), }); /** - * Generate workspace title and branch name using AI - * Falls back to timestamp-based names if AI generation fails + * Generate workspace name using AI + * Falls back to timestamp-based name if AI generation fails * @param message - The user's first message * @param modelString - Model string from send message options (e.g., "anthropic:claude-3-5-sonnet-20241022") * @param config - Config instance for provider access */ -export async function generateWorkspaceNames( +export async function generateWorkspaceName( message: string, modelString: string, config: Config -): Promise<{ title: string; branchName: string }> { +): Promise { try { const model = getModelForTitleGeneration(modelString, config); if (!model) { // No providers available, use fallback immediately - return createFallbackNames(); + return createFallbackName(); } const result = await generateObject({ model, - schema: workspaceNamesSchema, - prompt: `Generate a workspace title and git branch name for this development task: + schema: workspaceNameSchema, + prompt: `Generate a git-safe branch/workspace name for this development task: "${message}" Requirements: -- title: Clear, readable description (e.g., "Implementing automatic chat title generation") -- branchName: Git-safe identifier (e.g., "automatic-title-generation") - -Both should be concise (2-5 words) and descriptive of the task.`, +- Git-safe identifier (e.g., "automatic-title-generation") +- Lowercase, hyphens only, no spaces +- Concise (2-5 words) and descriptive of the task`, }); - return { - title: result.object.title, - branchName: validateBranchName(result.object.branchName), - }; + return validateBranchName(result.object.name); } catch (error) { - log.error("Failed to generate workspace names with AI, using fallback", error); - return createFallbackNames(); + log.error("Failed to generate workspace name with AI, using fallback", error); + return createFallbackName(); } } @@ -122,14 +113,11 @@ function getModelForTitleGeneration(modelString: string, config: Config): Langua } /** - * Create fallback names using timestamp + * Create fallback name using timestamp */ -function createFallbackNames(): { title: string; branchName: string } { +function createFallbackName(): string { const timestamp = Date.now().toString(36); - return { - title: `Chat ${timestamp}`, - branchName: `chat-${timestamp}`, - }; + return `chat-${timestamp}`; } /** diff --git a/src/types/project.ts b/src/types/project.ts index 94fe61977..90689337a 100644 --- a/src/types/project.ts +++ b/src/types/project.ts @@ -31,12 +31,9 @@ export interface Workspace { /** Stable workspace ID (10 hex chars for new workspaces) - optional for legacy */ id?: string; - /** Git branch / directory name - optional for legacy */ + /** Git branch / directory name (e.g., "feature-branch") - optional for legacy */ name?: string; - /** Optional human-readable display title (for AI-generated workspaces) */ - displayName?: string; - /** ISO 8601 creation timestamp - optional for legacy */ createdAt?: string; diff --git a/src/types/workspace.ts b/src/types/workspace.ts index ce6fd49a5..cd338ee73 100644 --- a/src/types/workspace.ts +++ b/src/types/workspace.ts @@ -6,7 +6,6 @@ import { z } from "zod"; export const WorkspaceMetadataSchema = z.object({ id: z.string().min(1, "Workspace ID is required"), name: z.string().min(1, "Workspace name is required"), - displayName: z.string().optional(), // Optional human-readable title (for AI-generated workspaces) projectName: z.string().min(1, "Project name is required"), projectPath: z.string().min(1, "Project path is required"), createdAt: z.string().optional(), // ISO 8601 timestamp (optional for backward compatibility) @@ -44,9 +43,6 @@ export interface WorkspaceMetadata { /** Git branch / directory name (e.g., "feature-branch") - used for path computation */ name: string; - /** Optional human-readable display title (e.g., "Building feature X") - for AI-generated workspaces */ - displayName?: string; - /** Project name extracted from project path (for display) */ projectName: string; From 231584cc2e985c095de69a215f2658bc7acde474 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 13 Nov 2025 19:32:09 +0000 Subject: [PATCH 12/13] style: fix prettier formatting --- src/App.tsx | 3 +- src/components/ChatInput/index.tsx | 166 ++++++++++-------- .../ChatInput/useCreationWorkspace.ts | 9 +- src/components/WorkspaceListItem.tsx | 6 +- src/hooks/useDraftWorkspaceSettings.ts | 6 +- src/services/ipcMain.ts | 6 +- 6 files changed, 101 insertions(+), 95 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index f66abaff7..8b55fb3db 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -122,8 +122,7 @@ function AppInner() { // Update window title with workspace name const metadata = workspaceMetadata.get(selectedWorkspace.workspaceId); - const workspaceName = - metadata?.name ?? selectedWorkspace.workspaceId; + const workspaceName = metadata?.name ?? selectedWorkspace.workspaceId; const title = `${workspaceName} - ${selectedWorkspace.projectName} - cmux`; void window.api.window.setTitle(title); } else { diff --git a/src/components/ChatInput/index.tsx b/src/components/ChatInput/index.tsx index 64379d704..af2cf8105 100644 --- a/src/components/ChatInput/index.tsx +++ b/src/components/ChatInput/index.tsx @@ -327,7 +327,7 @@ export const ChatInput: React.FC = (props) => { // Show toast when thinking level is changed via command palette (workspace only) useEffect(() => { if (variant !== "workspace") return; - + const handler = (event: Event) => { const detail = (event as CustomEvent<{ workspaceId: string; level: ThinkingLevel }>).detail; if (detail?.workspaceId !== props.workspaceId || !detail.level) { @@ -358,7 +358,7 @@ export const ChatInput: React.FC = (props) => { const workspaceIdForFocus = variant === "workspace" ? props.workspaceId : null; useEffect(() => { if (variant !== "workspace") return; - + // Small delay to ensure DOM is ready and other components have settled const timer = setTimeout(() => { focusMessageInput(); @@ -692,13 +692,17 @@ export const ChatInput: React.FC = (props) => { inputRef.current.style.height = "36px"; } - const result = await window.api.workspace.sendMessage(props.workspaceId, actualMessageText, { - ...sendMessageOptions, - ...compactionOptions, - editMessageId: editingMessage?.id, - imageParts: imageParts.length > 0 ? imageParts : undefined, - cmuxMetadata, - }); + const result = await window.api.workspace.sendMessage( + props.workspaceId, + actualMessageText, + { + ...sendMessageOptions, + ...compactionOptions, + editMessageId: editingMessage?.id, + imageParts: imageParts.length > 0 ? imageParts : undefined, + cmuxMetadata, + } + ); if (!result.success) { // Log error for debugging @@ -769,7 +773,13 @@ export const ChatInput: React.FC = (props) => { } // Handle up arrow on empty input - edit last user message (workspace only) - if (variant === "workspace" && e.key === "ArrowUp" && !editingMessage && input.trim() === "" && props.onEditLastUserMessage) { + if ( + variant === "workspace" && + e.key === "ArrowUp" && + !editingMessage && + input.trim() === "" && + props.onEditLastUserMessage + ) { e.preventDefault(); props.onEditLastUserMessage(); return; @@ -829,8 +839,7 @@ export const ChatInput: React.FC = (props) => { // Wrapper for creation variant to enable full-height flex layout const Wrapper = variant === "creation" ? "div" : React.Fragment; - const wrapperProps = - variant === "creation" ? { className: "flex h-full flex-1 flex-col" } : {}; + const wrapperProps = variant === "creation" ? { className: "flex h-full flex-1 flex-col" } : {}; return ( @@ -880,20 +889,22 @@ export const ChatInput: React.FC = (props) => { onPaste={variant === "workspace" ? handlePaste : undefined} onDragOver={variant === "workspace" ? handleDragOver : undefined} onDrop={variant === "workspace" ? handleDrop : undefined} - suppressKeys={showCommandSuggestions ? COMMAND_SUGGESTION_KEYS : undefined} - placeholder={placeholder} - disabled={!editingMessage && (disabled || isSending || isCompacting)} - aria-label={editingMessage ? "Edit your last message" : "Message Claude"} - aria-autocomplete="list" - aria-controls={ - showCommandSuggestions && commandSuggestions.length > 0 ? commandListId : undefined - } - aria-expanded={showCommandSuggestions && commandSuggestions.length > 0} + suppressKeys={showCommandSuggestions ? COMMAND_SUGGESTION_KEYS : undefined} + placeholder={placeholder} + disabled={!editingMessage && (disabled || isSending || isCompacting)} + aria-label={editingMessage ? "Edit your last message" : "Message Claude"} + aria-autocomplete="list" + aria-controls={ + showCommandSuggestions && commandSuggestions.length > 0 ? commandListId : undefined + } + aria-expanded={showCommandSuggestions && commandSuggestions.length > 0} />
{/* Image attachments - workspace only */} - {variant === "workspace" && } + {variant === "workspace" && ( + + )}
{/* Editing indicator - workspace only */} @@ -904,64 +915,65 @@ export const ChatInput: React.FC = (props) => { )}
- {/* Model Selector - always visible */} -
- inputRef.current?.focus()} - /> - - ? - - Click to edit or use {formatKeybind(KEYBINDS.OPEN_MODEL_SELECTOR)} -
-
- Abbreviations: -
/model opus - Claude Opus 4.1 -
/model sonnet - Claude Sonnet 4.5 -
-
- Full format: -
- /model provider:model-name -
- (e.g., /model anthropic:claude-sonnet-4-5) -
-
-
- - {/* Thinking Slider - slider hidden on narrow containers, label always clickable */} -
- -
+ {/* Model Selector - always visible */} +
+ inputRef.current?.focus()} + /> + + ? + + Click to edit or use{" "} + {formatKeybind(KEYBINDS.OPEN_MODEL_SELECTOR)} +
+
+ Abbreviations: +
/model opus - Claude Opus 4.1 +
/model sonnet - Claude Sonnet 4.5 +
+
+ Full format: +
+ /model provider:model-name +
+ (e.g., /model anthropic:claude-sonnet-4-5) +
+
+
- {/* Context 1M Checkbox - always visible */} -
- -
+ {/* Thinking Slider - slider hidden on narrow containers, label always clickable */} +
+ +
- {preferredModel && ( -
- - Calculating tokens… -
- } - > - - + {/* Context 1M Checkbox - always visible */} +
+
- )} + + {preferredModel && ( +
+ + Calculating tokens… +
+ } + > + + +
+ )}
diff --git a/src/components/ChatInput/useCreationWorkspace.ts b/src/components/ChatInput/useCreationWorkspace.ts index 584d2077e..56045ac02 100644 --- a/src/components/ChatInput/useCreationWorkspace.ts +++ b/src/components/ChatInput/useCreationWorkspace.ts @@ -106,7 +106,14 @@ export function useCreationWorkspace({ setIsSending(false); } }, - [isSending, projectPath, onWorkspaceCreated, getRuntimeString, sendMessageOptions, settings.trunkBranch] + [ + isSending, + projectPath, + onWorkspaceCreated, + getRuntimeString, + sendMessageOptions, + settings.trunkBranch, + ] ); return { diff --git a/src/components/WorkspaceListItem.tsx b/src/components/WorkspaceListItem.tsx index 6dee35ccc..4f2405e11 100644 --- a/src/components/WorkspaceListItem.tsx +++ b/src/components/WorkspaceListItem.tsx @@ -38,11 +38,7 @@ const WorkspaceListItemInner: React.FC = ({ onToggleUnread, }) => { // Destructure metadata for convenience - const { - id: workspaceId, - name: workspaceName, - namedWorkspacePath, - } = metadata; + const { id: workspaceId, name: workspaceName, namedWorkspacePath } = metadata; const gitStatus = useGitStatus(workspaceId); // Get rename context diff --git a/src/hooks/useDraftWorkspaceSettings.ts b/src/hooks/useDraftWorkspaceSettings.ts index 377470d19..14ef5f4a9 100644 --- a/src/hooks/useDraftWorkspaceSettings.ts +++ b/src/hooks/useDraftWorkspaceSettings.ts @@ -4,11 +4,7 @@ import { use1MContext } from "./use1MContext"; import { useThinkingLevel } from "./useThinkingLevel"; import { useMode } from "@/contexts/ModeContext"; import { useModelLRU } from "./useModelLRU"; -import { - type RuntimeMode, - parseRuntimeModeAndHost, - buildRuntimeString, -} from "@/types/runtime"; +import { type RuntimeMode, parseRuntimeModeAndHost, buildRuntimeString } from "@/types/runtime"; import { getModelKey, getRuntimeKey, diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index 097426066..559c94711 100644 --- a/src/services/ipcMain.ts +++ b/src/services/ipcMain.ts @@ -157,11 +157,7 @@ export class IpcMain { > { try { // 1. Generate workspace branch name using AI (use same model as message) - const branchName = await generateWorkspaceName( - message, - options.model, - this.config - ); + const branchName = await generateWorkspaceName(message, options.model, this.config); log.debug("Generated workspace name", { branchName }); From 0047171c3dc0d1dab1a897bba962d968cb9bdd4d Mon Sep 17 00:00:00 2001 From: root Date: Thu, 13 Nov 2025 19:37:06 +0000 Subject: [PATCH 13/13] fix: persist SSH mode even without host When user selects SSH runtime without entering a host, the mode now persists so the SSH host input remains visible and editable. Previously buildRuntimeString returned undefined for empty host, causing mode to snap back to Local. Changes: - buildRuntimeString returns 'ssh' for SSH mode with empty host - parseRuntimeModeAndHost handles bare 'ssh' string - Added comprehensive tests for runtime parsing/building Fixes: Codex P1 comment on PR #578 --- src/types/runtime.test.ts | 73 +++++++++++++++++++++++++++++++++++++++ src/types/runtime.ts | 9 +++-- 2 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 src/types/runtime.test.ts diff --git a/src/types/runtime.test.ts b/src/types/runtime.test.ts new file mode 100644 index 000000000..8b5690bf7 --- /dev/null +++ b/src/types/runtime.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from "@jest/globals"; +import { parseRuntimeModeAndHost, buildRuntimeString } from "./runtime"; + +describe("parseRuntimeModeAndHost", () => { + it("parses SSH mode with host", () => { + expect(parseRuntimeModeAndHost("ssh user@host")).toEqual({ + mode: "ssh", + host: "user@host", + }); + }); + + it("parses SSH mode without host", () => { + expect(parseRuntimeModeAndHost("ssh")).toEqual({ + mode: "ssh", + host: "", + }); + }); + + it("parses local mode", () => { + expect(parseRuntimeModeAndHost("local")).toEqual({ + mode: "local", + host: "", + }); + }); + + it("defaults to local for undefined", () => { + expect(parseRuntimeModeAndHost(undefined)).toEqual({ + mode: "local", + host: "", + }); + }); + + it("defaults to local for null", () => { + expect(parseRuntimeModeAndHost(null)).toEqual({ + mode: "local", + host: "", + }); + }); +}); + +describe("buildRuntimeString", () => { + it("builds SSH string with host", () => { + expect(buildRuntimeString("ssh", "user@host")).toBe("ssh user@host"); + }); + + it("builds SSH string without host (persists SSH mode)", () => { + expect(buildRuntimeString("ssh", "")).toBe("ssh"); + }); + + it("returns undefined for local mode", () => { + expect(buildRuntimeString("local", "")).toBeUndefined(); + }); + + it("trims whitespace from host", () => { + expect(buildRuntimeString("ssh", " user@host ")).toBe("ssh user@host"); + }); +}); + +describe("round-trip parsing and building", () => { + it("preserves SSH mode without host", () => { + const built = buildRuntimeString("ssh", ""); + const parsed = parseRuntimeModeAndHost(built); + expect(parsed.mode).toBe("ssh"); + expect(parsed.host).toBe(""); + }); + + it("preserves SSH mode with host", () => { + const built = buildRuntimeString("ssh", "user@host"); + const parsed = parseRuntimeModeAndHost(built); + expect(parsed.mode).toBe("ssh"); + expect(parsed.host).toBe("user@host"); + }); +}); diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 8f39754a8..bd408dcb8 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -35,6 +35,7 @@ export type RuntimeConfig = /** * Parse runtime string from localStorage or UI input into mode and host * Format: "ssh " -> { mode: "ssh", host: "" } + * "ssh" -> { mode: "ssh", host: "" } * "local" or undefined -> { mode: "local", host: "" } * * Use this for UI state management (localStorage, form inputs) @@ -54,7 +55,8 @@ export function parseRuntimeModeAndHost(runtime: string | null | undefined): { return { mode: RUNTIME_MODE.LOCAL, host: "" }; } - if (lowerTrimmed.startsWith(SSH_RUNTIME_PREFIX)) { + // Handle both "ssh" and "ssh " + if (lowerTrimmed === RUNTIME_MODE.SSH || lowerTrimmed.startsWith(SSH_RUNTIME_PREFIX)) { const host = trimmed.substring(SSH_RUNTIME_PREFIX.length).trim(); return { mode: RUNTIME_MODE.SSH, host }; } @@ -65,12 +67,13 @@ export function parseRuntimeModeAndHost(runtime: string | null | undefined): { /** * Build runtime string for storage/IPC from mode and host - * Returns: "ssh " for SSH, undefined for local + * Returns: "ssh " for SSH with host, "ssh" for SSH without host, undefined for local */ export function buildRuntimeString(mode: RuntimeMode, host: string): string | undefined { if (mode === RUNTIME_MODE.SSH) { const trimmedHost = host.trim(); - return trimmedHost ? `${SSH_RUNTIME_PREFIX}${trimmedHost}` : undefined; + // Persist SSH mode even without a host so UI remains in SSH state + return trimmedHost ? `${SSH_RUNTIME_PREFIX}${trimmedHost}` : "ssh"; } return undefined; }