diff --git a/docs/AGENTS.md b/docs/AGENTS.md index d5147e4e3..8d74d8ace 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,27 @@ 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. + +**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/App.tsx b/src/App.tsx index 6e589466c..8b55fb3db 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,7 +14,7 @@ import { useResumeManager } from "./hooks/useResumeManager"; import { useUnreadTracking } from "./hooks/useUnreadTracking"; import { useAutoCompactContinue } from "./hooks/useAutoCompactContinue"; import { useWorkspaceStoreRaw, useWorkspaceRecency } from "./stores/WorkspaceStore"; -import { FirstMessageInput } from "./components/FirstMessageInput"; +import { ChatInput } from "./components/ChatInput/index"; import { useStableReference, compareMaps } from "./hooks/useStableReference"; import { CommandRegistryProvider, useCommandRegistry } from "./contexts/CommandRegistryContext"; @@ -120,10 +120,9 @@ 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; + const workspaceName = metadata?.name ?? selectedWorkspace.workspaceId; const title = `${workspaceName} - ${selectedWorkspace.projectName} - cmux`; void window.api.window.setTitle(title); } else { @@ -630,7 +629,8 @@ function AppInner() { return ( - { diff --git a/src/components/AIView.tsx b/src/components/AIView.tsx index 3203770bc..c0740836b 100644 --- a/src/components/AIView.tsx +++ b/src/components/AIView.tsx @@ -6,7 +6,7 @@ import { StreamingBarrier } from "./Messages/ChatBarrier/StreamingBarrier"; import { RetryBarrier } from "./Messages/ChatBarrier/RetryBarrier"; import { PinnedTodoList } from "./PinnedTodoList"; import { getAutoRetryKey, VIM_ENABLED_KEY } from "@/constants/storage"; -import { ChatInput, type ChatInputAPI } from "./ChatInput"; +import { ChatInput, type ChatInputAPI } from "./ChatInput/index"; import { RightSidebar, type TabType } from "./RightSidebar"; import { useResizableSidebar } from "@/hooks/useResizableSidebar"; import { @@ -462,6 +462,7 @@ const AIViewInner: React.FC = ({ + {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. +

+
+ )} + + ); +} diff --git a/src/components/ChatInput/CreationControls.tsx b/src/components/ChatInput/CreationControls.tsx new file mode 100644 index 000000000..cab849f28 --- /dev/null +++ b/src/components/ChatInput/CreationControls.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import { RUNTIME_MODE, type RuntimeMode } from "@/types/runtime"; +import { TooltipWrapper, Tooltip } from "../Tooltip"; +import { Select } from "../Select"; + +interface CreationControlsProps { + branches: string[]; + trunkBranch: string; + onTrunkBranchChange: (branch: string) => void; + runtimeMode: RuntimeMode; + sshHost: string; + onRuntimeChange: (mode: RuntimeMode, host: string) => void; + disabled: boolean; +} + +/** + * Additional controls shown only during workspace creation + * - Trunk branch selector (which branch to fork from) + * - Runtime mode (local vs SSH) + */ +export function CreationControls(props: CreationControlsProps) { + return ( +
+ {/* Trunk Branch Selector */} + {props.branches.length > 0 && ( +
+ + { + const mode = newMode as RuntimeMode; + props.onRuntimeChange(mode, mode === RUNTIME_MODE.LOCAL ? "" : props.sshHost); + }} + disabled={props.disabled} + aria-label="Runtime mode" + /> + {props.runtimeMode === RUNTIME_MODE.SSH && ( + 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-1 py-0.5 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 64% rename from src/components/ChatInput.tsx rename to src/components/ChatInput/index.tsx index f329565b7..af2cf8105 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput/index.tsx @@ -8,17 +8,23 @@ 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 { + getModelKey, + getInputKey, + VIM_ENABLED_KEY, + getProjectScopeId, + getPendingScopeId, +} from "@/constants/storage"; import { handleNewCommand, handleCompactCommand, @@ -31,13 +37,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 +55,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 +89,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(getPendingScopeId(props.projectPath)), + modelKey: getModelKey(getProjectScopeId(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 +137,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 : getProjectScopeId(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 +160,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 +222,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,11 +324,13 @@ 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 handler = (event: Event) => { const detail = (event as CustomEvent<{ workspaceId: string; level: ThinkingLevel }>).detail; - if (detail?.workspaceId !== workspaceId || !detail.level) { + if (detail?.workspaceId !== props.workspaceId || !detail.level) { return; } @@ -332,16 +352,19 @@ 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) + 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(); }, 100); return () => clearTimeout(timer); - }, [workspaceId, focusMessageInput]); + }, [variant, workspaceIdForFocus, focusMessageInput]); // Handle paste events to extract images const handlePaste = useCallback((e: React.ClipboardEvent) => { @@ -402,6 +425,19 @@ 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 + try { // Parse command const parsed = parseCommand(messageText); @@ -413,7 +449,7 @@ export const ChatInput: React.FC = ({ if (inputRef.current) { inputRef.current.style.height = "36px"; } - await onTruncateHistory(1.0); + await props.onTruncateHistory(1.0); setToast({ id: Date.now().toString(), type: "success", @@ -428,7 +464,7 @@ export const ChatInput: React.FC = ({ if (inputRef.current) { inputRef.current.style.height = "36px"; } - await onTruncateHistory(parsed.percentage); + await props.onTruncateHistory(parsed.percentage); setToast({ id: Date.now().toString(), type: "success", @@ -438,12 +474,12 @@ export const ChatInput: React.FC = ({ } // Handle /providers set command - if (parsed.type === "providers-set" && onProviderConfig) { + if (parsed.type === "providers-set" && props.onProviderConfig) { setIsSending(true); setInput(""); // Clear input immediately try { - await onProviderConfig(parsed.provider, parsed.keyPath, parsed.value); + await props.onProviderConfig(parsed.provider, parsed.keyPath, parsed.value); // Success - show toast setToast({ id: Date.now().toString(), @@ -468,7 +504,7 @@ export const ChatInput: React.FC = ({ if (parsed.type === "model-set") { setInput(""); // Clear input immediately setPreferredModel(parsed.modelString); - onModelChange?.(parsed.modelString); + props.onModelChange?.(parsed.modelString); setToast({ id: Date.now().toString(), type: "success", @@ -499,13 +535,13 @@ export const ChatInput: React.FC = ({ // Handle /compact command if (parsed.type === "compact") { const context: CommandHandlerContext = { - workspaceId, + workspaceId: props.workspaceId, sendMessageOptions, editMessageId: editingMessage?.id, setInput, setIsSending, setToast, - onCancelEdit, + onCancelEdit: props.onCancelEdit, }; const result = await handleCompactCommand(parsed, context); @@ -522,7 +558,7 @@ export const ChatInput: React.FC = ({ try { const forkResult = await forkWorkspace({ - sourceWorkspaceId: workspaceId, + sourceWorkspaceId: props.workspaceId, newName: parsed.newName, startMessage: parsed.startMessage, sendMessageOptions, @@ -564,7 +600,7 @@ export const ChatInput: React.FC = ({ // Handle /new command if (parsed.type === "new") { const context: CommandHandlerContext = { - workspaceId, + workspaceId: props.workspaceId, sendMessageOptions, setInput, setIsSending, @@ -635,7 +671,7 @@ export const ChatInput: React.FC = ({ metadata, sendOptions, } = prepareCompactionMessage({ - workspaceId, + workspaceId: props.workspaceId, maxOutputTokens: parsed.maxOutputTokens, continueMessage: parsed.continueMessage, model: parsed.model, @@ -656,13 +692,17 @@ export const ChatInput: React.FC = ({ inputRef.current.style.height = "36px"; } - const result = await window.api.workspace.sendMessage(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 @@ -677,10 +717,10 @@ export const ChatInput: React.FC = ({ telemetry.messageSent(sendMessageOptions.model, mode, actualMessageText.length); // Exit editing mode if we were editing - if (editingMessage && onCancelEdit) { - onCancelEdit(); + if (editingMessage && props.onCancelEdit) { + props.onCancelEdit(); } - onMessageSent?.(); + props.onMessageSent?.(); } } catch (error) { // Handle unexpected errors @@ -705,6 +745,13 @@ export const ChatInput: React.FC = ({ }; const handleKeyDown = (e: React.KeyboardEvent) => { + // Handle cancel for creation variant + if (variant === "creation" && matchesKeybind(e, KEYBINDS.CANCEL) && 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,16 @@ 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 +806,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)`; } @@ -778,113 +837,162 @@ export const ChatInput: React.FC = ({ 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 ( -
- - setShowCommandSuggestions(false)} - isVisible={showCommandSuggestions} - ariaLabel="Slash command suggestions" - listId={commandListId} - /> -
- 0 ? commandListId : undefined - } - aria-expanded={showCommandSuggestions && commandSuggestions.length > 0} + + {/* Creation center content (shows while loading or idle) */} + {variant === "creation" && ( + -
- -
- {editingMessage && ( -
- Editing message ({formatKeybind(KEYBINDS.CANCEL_EDIT)} to cancel) + )} + + {/* Input section */} +
+ {/* Creation error toast */} + {variant === "creation" && creationState?.error && ( +
+ {creationState.error}
)} -
- {/* 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 */} -
- -
+ {/* Workspace toast */} + {variant === "workspace" && } + + {/* Command suggestions - workspace only */} + {variant === "workspace" && ( + setShowCommandSuggestions(false)} + isVisible={showCommandSuggestions} + ariaLabel="Slash command suggestions" + listId={commandListId} + /> + )} - {/* Context 1M Checkbox - always visible */} -
- -
+
+ 0 ? commandListId : undefined + } + aria-expanded={showCommandSuggestions && commandSuggestions.length > 0} + /> +
- {preferredModel && ( -
- - Calculating tokens… -
- } - > - - + {/* 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 */} +
+ 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 */} +
+ +
+ + {/* Context 1M Checkbox - always visible */} +
+ +
+ + {preferredModel && ( +
+ + Calculating tokens… +
+ } + > + + +
+ )} + + +
+ + {/* 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..56045ac02 --- /dev/null +++ b/src/components/ChatInput/useCreationWorkspace.ts @@ -0,0 +1,131 @@ +import { useState, useEffect, useCallback } from "react"; +import type { FrontendWorkspaceMetadata } from "@/types/workspace"; +import type { RuntimeConfig, RuntimeMode } from "@/types/runtime"; +import { parseRuntimeString } from "@/utils/chatCommands"; +import { useDraftWorkspaceSettings } from "@/hooks/useDraftWorkspaceSettings"; +import { useSendMessageOptions } from "@/hooks/useSendMessageOptions"; +import { getProjectScopeId } from "@/constants/storage"; +import { extractErrorMessage } from "./utils"; + +interface UseCreationWorkspaceOptions { + projectPath: string; + onWorkspaceCreated: (metadata: FrontendWorkspaceMetadata) => void; +} + +interface UseCreationWorkspaceReturn { + branches: string[]; + trunkBranch: string; + setTrunkBranch: (branch: string) => void; + runtimeMode: RuntimeMode; + sshHost: string; + setRuntimeOptions: (mode: RuntimeMode, 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 [recommendedTrunk, setRecommendedTrunk] = useState(null); + const [error, setError] = useState(null); + const [isSending, setIsSending] = useState(false); + + // 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(getProjectScopeId(projectPath)); + + // Load branches on mount + useEffect(() => { + const loadBranches = async () => { + try { + const result = await window.api.projects.listBranches(projectPath); + setBranches(result.branches); + setRecommendedTrunk(result.recommendedTrunk); + } 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: settings.trunkBranch, // Pass selected trunk branch from settings + }); + + 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) { + // Settings are already persisted via useDraftWorkspaceSettings + // 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, + settings.trunkBranch, + ] + ); + + return { + branches, + trunkBranch: settings.trunkBranch, + setTrunkBranch, + runtimeMode: settings.runtimeMode, + sshHost: settings.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 ea5c2d062..000000000 --- a/src/components/FirstMessageInput.tsx +++ /dev/null @@ -1,343 +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 } from "@/constants/storage"; -import { useModelLRU } from "@/hooks/useModelLRU"; -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 = localStorage.getItem(projectModelKey) ?? recentModels[0]; - - // Setter for model - const setPreferredModel = useCallback( - (model: string) => { - addModel(model); - localStorage.setItem(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(""); - - // 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 row - Model + Thinking + Context + Mode + Runtime */} -
-
- {/* Model Selector */} -
- inputRef.current?.focus()} - /> -
- - {/* Trunk Branch Selector */} - {branches.length > 0 && ( -
- - -
- )} - - {/* Thinking Slider - slider hidden on narrow containers, label always clickable */} -
- -
- - {/* Context 1M Checkbox - always visible */} -
- -
- - - - {/* 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 -
-
-
-
-
-
-
- ); -} diff --git a/src/components/NewWorkspaceModal.tsx b/src/components/NewWorkspaceModal.tsx index 9b1d7e51f..462c3b32e 100644 --- a/src/components/NewWorkspaceModal.tsx +++ b/src/components/NewWorkspaceModal.tsx @@ -217,7 +217,7 @@ const NewWorkspaceModal: React.FC = ({ disabled={isLoading} > - +
diff --git a/src/components/Select.tsx b/src/components/Select.tsx new file mode 100644 index 000000000..2f529e55c --- /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/components/WorkspaceListItem.tsx b/src/components/WorkspaceListItem.tsx index cdb6af1ad..4f2405e11 100644 --- a/src/components/WorkspaceListItem.tsx +++ b/src/components/WorkspaceListItem.tsx @@ -38,12 +38,7 @@ const WorkspaceListItemInner: React.FC = ({ onToggleUnread, }) => { // Destructure metadata for convenience - const { - id: workspaceId, - name: workspaceName, - displayName: displayTitle, - namedWorkspacePath, - } = metadata; + const { id: workspaceId, name: workspaceName, namedWorkspacePath } = metadata; const gitStatus = useGitStatus(workspaceId); // Get rename context @@ -53,8 +48,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/constants/storage.ts b/src/constants/storage.ts index a367f1cfe..5a2b2f121 100644 --- a/src/constants/storage.ts +++ b/src/constants/storage.ts @@ -3,6 +3,33 @@ * These keys are used for persisting state in localStorage */ +/** + * Scope ID Helpers + * These create consistent scope identifiers for storage keys + */ + +/** + * Get project-scoped ID for storage keys (e.g., model preference before workspace creation) + * Format: "__project__/{projectPath}" + * Uses "/" delimiter to safely handle projectPath values containing special characters + */ +export function getProjectScopeId(projectPath: string): string { + return `__project__/${projectPath}`; +} + +/** + * Get pending workspace scope ID for storage keys (e.g., input text during workspace creation) + * Format: "__pending__{projectPath}" + */ +export function getPendingScopeId(projectPath: string): string { + return `__pending__${projectPath}`; +} + +/** + * Global scope ID for workspace-independent preferences + */ +export const GLOBAL_SCOPE_ID = "__global__"; + /** * Helper to create a thinking level storage key for a workspace * Format: "thinkingLevel:{workspaceId}" @@ -72,6 +99,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/contexts/ModeContext.tsx b/src/contexts/ModeContext.tsx index 6fbfa6c1e..b8abf7bdd 100644 --- a/src/contexts/ModeContext.tsx +++ b/src/contexts/ModeContext.tsx @@ -3,7 +3,7 @@ import React, { createContext, useContext, useEffect } from "react"; import type { UIMode } from "@/types/mode"; import { usePersistedState } from "@/hooks/usePersistedState"; import { matchesKeybind, KEYBINDS } from "@/utils/ui/keybinds"; -import { getModeKey } from "@/constants/storage"; +import { getModeKey, getProjectScopeId, GLOBAL_SCOPE_ID } from "@/constants/storage"; type ModeContextType = [UIMode, (mode: UIMode) => void]; @@ -20,9 +20,8 @@ export const ModeProvider: React.FC = ({ projectPath, children, }) => { - // Priority: workspace-scoped > project-scoped - // Use "/" delimiter so projectPath like "/home/user/project" becomes "__project__/home/user/project" - const scopeId = workspaceId ?? (projectPath ? `__project__/${projectPath}` : "__global__"); + // Priority: workspace-scoped > project-scoped > global + const scopeId = workspaceId ?? (projectPath ? getProjectScopeId(projectPath) : GLOBAL_SCOPE_ID); const modeKey = getModeKey(scopeId); const [mode, setMode] = usePersistedState(modeKey, "exec", { listener: true, // Listen for changes from command palette and other sources diff --git a/src/contexts/ThinkingContext.tsx b/src/contexts/ThinkingContext.tsx index 32e19f4f9..a07d69831 100644 --- a/src/contexts/ThinkingContext.tsx +++ b/src/contexts/ThinkingContext.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from "react"; import React, { createContext, useContext } from "react"; import type { ThinkingLevel } from "@/types/thinking"; import { usePersistedState } from "@/hooks/usePersistedState"; -import { getThinkingLevelKey } from "@/constants/storage"; +import { getThinkingLevelKey, getProjectScopeId, GLOBAL_SCOPE_ID } from "@/constants/storage"; interface ThinkingContextType { thinkingLevel: ThinkingLevel; @@ -22,9 +22,8 @@ export const ThinkingProvider: React.FC = ({ projectPath, children, }) => { - // Priority: workspace-scoped > project-scoped - // Use "/" delimiter so projectPath like "/home/user/project" becomes "__project__/home/user/project" - const scopeId = workspaceId ?? (projectPath ? `__project__/${projectPath}` : "__global__"); + // Priority: workspace-scoped > project-scoped > global + const scopeId = workspaceId ?? (projectPath ? getProjectScopeId(projectPath) : GLOBAL_SCOPE_ID); const key = getThinkingLevelKey(scopeId); const [thinkingLevel, setThinkingLevel] = usePersistedState( key, diff --git a/src/hooks/useDraftWorkspaceSettings.ts b/src/hooks/useDraftWorkspaceSettings.ts new file mode 100644 index 000000000..14ef5f4a9 --- /dev/null +++ b/src/hooks/useDraftWorkspaceSettings.ts @@ -0,0 +1,116 @@ +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, + getProjectScopeId, +} 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(getProjectScopeId(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, + }; +} diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index 503173973..559c94711 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,10 @@ 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( - message, - options.model, - this.config - ); + // 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 +216,6 @@ export class IpcMain { const metadata = { id: workspaceId, name: branchName, - displayName: title, projectName, projectPath, createdAt: new Date().toISOString(), @@ -236,7 +231,6 @@ export class IpcMain { path: createResult.workspacePath!, id: workspaceId, name: branchName, - displayName: title, createdAt: metadata.createdAt, runtimeConfig: finalRuntimeConfig, }); @@ -824,7 +818,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 +835,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/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; } 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;