diff --git a/src/browser/App.tsx b/src/browser/App.tsx index eea416ab8..0911aaf83 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -28,7 +28,7 @@ import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sour import type { ThinkingLevel } from "@/common/types/thinking"; import { CUSTOM_EVENTS } from "@/common/constants/events"; -import { isWorkspaceForkSwitchEvent } from "./utils/workspaceFork"; +import { isWorkspaceForkSwitchEvent } from "./utils/workspaceEvents"; import { getThinkingLevelKey } from "@/common/constants/storage"; import type { BranchListResult } from "@/common/types/ipc"; import { useTelemetry } from "./hooks/useTelemetry"; @@ -517,6 +517,16 @@ function AppInner() { ); }, [projects, setSelectedWorkspace, setWorkspaceMetadata]); + const handleProviderConfig = useCallback( + async (provider: string, keyPath: string[], value: string) => { + const result = await window.api.providers.setProviderConfig(provider, keyPath, value); + if (!result.success) { + throw new Error(result.error); + } + }, + [] + ); + return ( <>
@@ -561,6 +571,7 @@ function AppInner() { variant="creation" projectPath={projectPath} projectName={projectName} + onProviderConfig={handleProviderConfig} onReady={handleCreationChatReady} onWorkspaceCreated={(metadata) => { // Add to workspace metadata map diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index b033fff67..0ad550b50 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -11,7 +11,7 @@ import React, { import { CommandSuggestions, COMMAND_SUGGESTION_KEYS } from "../CommandSuggestions"; import type { Toast } from "../ChatInputToast"; import { ChatInputToast } from "../ChatInputToast"; -import { createCommandToast, createErrorToast } from "../ChatInputToasts"; +import { createErrorToast } from "../ChatInputToasts"; import { parseCommand } from "@/browser/utils/slashCommands/parser"; import { usePersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import { useMode } from "@/browser/contexts/ModeContext"; @@ -26,11 +26,9 @@ import { getPendingScopeId, } from "@/common/constants/storage"; import { - handleNewCommand, - handleCompactCommand, - forkWorkspace, prepareCompactionMessage, - type CommandHandlerContext, + processSlashCommand, + type SlashCommandContext, } from "@/browser/utils/chatCommands"; import { CUSTOM_EVENTS } from "@/common/constants/events"; import { @@ -59,13 +57,20 @@ import { import type { ThinkingLevel } from "@/common/types/thinking"; import type { MuxFrontendMetadata } from "@/common/types/message"; import { useTelemetry } from "@/browser/hooks/useTelemetry"; -import { setTelemetryEnabled } from "@/common/telemetry"; import { getTokenCountPromise } from "@/browser/utils/tokenizer/rendererClient"; import { CreationCenterContent } from "./CreationCenterContent"; import { cn } from "@/common/lib/utils"; import { CreationControls } from "./CreationControls"; import { useCreationWorkspace } from "./useCreationWorkspace"; +const LEADING_COMMAND_NOISE = /^(?:\s|\u200B|\u200C|\u200D|\u200E|\u200F|\uFEFF)+/; + +function normalizeSlashCommandInput(value: string): string { + if (!value) { + return value; + } + return value.replace(LEADING_COMMAND_NOISE, ""); +} type TokenCountReader = () => number; function createTokenCountResource(promise: Promise): TokenCountReader { @@ -304,9 +309,10 @@ export const ChatInput: React.FC = (props) => { // Watch input for slash commands useEffect(() => { - const suggestions = getSlashCommandSuggestions(input, { providerNames }); + const normalizedSlashSource = normalizeSlashCommandInput(input); + const suggestions = getSlashCommandSuggestions(normalizedSlashSource, { providerNames }); setCommandSuggestions(suggestions); - setShowCommandSuggestions(suggestions.length > 0); + setShowCommandSuggestions(normalizedSlashSource.startsWith("/") && suggestions.length > 0); }, [input, providerNames]); // Load provider names for suggestions @@ -466,11 +472,53 @@ export const ChatInput: React.FC = (props) => { return; } - const messageText = input.trim(); + const rawInputValue = input; + const messageText = rawInputValue.trim(); + const normalizedCommandInput = normalizeSlashCommandInput(messageText); + const isSlashCommand = normalizedCommandInput.startsWith("/"); + const parsed = isSlashCommand ? parseCommand(normalizedCommandInput) : null; + + if (parsed) { + const context: SlashCommandContext = { + variant, + workspaceId: variant === "workspace" ? props.workspaceId : undefined, + sendMessageOptions, + setInput, + setIsSending, + setToast, + setVimEnabled, + setPreferredModel, + onProviderConfig: props.onProviderConfig, + onModelChange: props.onModelChange, + onTruncateHistory: variant === "workspace" ? props.onTruncateHistory : undefined, + onCancelEdit: variant === "workspace" ? props.onCancelEdit : undefined, + editMessageId: editingMessage?.id, + resetInputHeight: () => { + if (inputRef.current) { + inputRef.current.style.height = "36px"; + } + }, + }; + + const result = await processSlashCommand(parsed, context); + + if (!result.clearInput) { + setInput(rawInputValue); // Restore exact input on failure + } + return; + } + + if (isSlashCommand) { + setToast({ + id: Date.now().toString(), + type: "error", + message: `Unknown command: ${normalizedCommandInput.split(/\s+/)[0] ?? ""}`, + }); + return; + } - // Route to creation handler for creation variant + // Handle standard message sending based on variant if (variant === "creation") { - // Creation variant: simple message send + workspace creation setIsSending(true); const ok = await creationState.handleSend(messageText); if (ok) { @@ -483,193 +531,9 @@ export const ChatInput: React.FC = (props) => { return; } - // Workspace variant: full command handling + message send - if (variant !== "workspace") return; // Type guard + // Workspace variant: regular message send try { - // Parse command - const parsed = parseCommand(messageText); - - if (parsed) { - // Handle /clear command - if (parsed.type === "clear") { - setInput(""); - if (inputRef.current) { - inputRef.current.style.height = "36px"; - } - await props.onTruncateHistory(1.0); - setToast({ - id: Date.now().toString(), - type: "success", - message: "Chat history cleared", - }); - return; - } - - // Handle /truncate command - if (parsed.type === "truncate") { - setInput(""); - if (inputRef.current) { - inputRef.current.style.height = "36px"; - } - await props.onTruncateHistory(parsed.percentage); - setToast({ - id: Date.now().toString(), - type: "success", - message: `Chat history truncated by ${Math.round(parsed.percentage * 100)}%`, - }); - return; - } - - // Handle /providers set command - if (parsed.type === "providers-set" && props.onProviderConfig) { - setIsSending(true); - setInput(""); // Clear input immediately - - try { - await props.onProviderConfig(parsed.provider, parsed.keyPath, parsed.value); - // Success - show toast - setToast({ - id: Date.now().toString(), - type: "success", - message: `Provider ${parsed.provider} updated`, - }); - } catch (error) { - console.error("Failed to update provider config:", error); - setToast({ - id: Date.now().toString(), - type: "error", - message: error instanceof Error ? error.message : "Failed to update provider", - }); - setInput(messageText); // Restore input on error - } finally { - setIsSending(false); - } - return; - } - - // Handle /model command - if (parsed.type === "model-set") { - setInput(""); // Clear input immediately - setPreferredModel(parsed.modelString); - props.onModelChange?.(parsed.modelString); - setToast({ - id: Date.now().toString(), - type: "success", - message: `Model changed to ${parsed.modelString}`, - }); - return; - } - - // Handle /vim command - if (parsed.type === "vim-toggle") { - setInput(""); // Clear input immediately - setVimEnabled((prev) => !prev); - return; - } - - // Handle /telemetry command - if (parsed.type === "telemetry-set") { - setInput(""); // Clear input immediately - setTelemetryEnabled(parsed.enabled); - setToast({ - id: Date.now().toString(), - type: "success", - message: `Telemetry ${parsed.enabled ? "enabled" : "disabled"}`, - }); - return; - } - - // Handle /compact command - if (parsed.type === "compact") { - const context: CommandHandlerContext = { - workspaceId: props.workspaceId, - sendMessageOptions, - editMessageId: editingMessage?.id, - setInput, - setIsSending, - setToast, - onCancelEdit: props.onCancelEdit, - }; - - const result = await handleCompactCommand(parsed, context); - if (!result.clearInput) { - setInput(messageText); // Restore input on error - } - return; - } - - // Handle /fork command - if (parsed.type === "fork") { - setInput(""); // Clear input immediately - setIsSending(true); - - try { - const forkResult = await forkWorkspace({ - sourceWorkspaceId: props.workspaceId, - newName: parsed.newName, - startMessage: parsed.startMessage, - sendMessageOptions, - }); - - if (!forkResult.success) { - const errorMsg = forkResult.error ?? "Failed to fork workspace"; - console.error("Failed to fork workspace:", errorMsg); - setToast({ - id: Date.now().toString(), - type: "error", - title: "Fork Failed", - message: errorMsg, - }); - setInput(messageText); // Restore input on error - } else { - setToast({ - id: Date.now().toString(), - type: "success", - message: `Forked to workspace "${parsed.newName}"`, - }); - } - } catch (error) { - const errorMsg = error instanceof Error ? error.message : "Failed to fork workspace"; - console.error("Fork error:", error); - setToast({ - id: Date.now().toString(), - type: "error", - title: "Fork Failed", - message: errorMsg, - }); - setInput(messageText); // Restore input on error - } - - setIsSending(false); - return; - } - - // Handle /new command - if (parsed.type === "new") { - const context: CommandHandlerContext = { - workspaceId: props.workspaceId, - sendMessageOptions, - setInput, - setIsSending, - setToast, - }; - - const result = await handleNewCommand(parsed, context); - if (!result.clearInput) { - setInput(messageText); // Restore input on error - } - return; - } - - // Handle all other commands - show display toast - const commandToast = createCommandToast(parsed); - if (commandToast) { - setToast(commandToast); - return; - } - } - // Regular message - send directly via API setIsSending(true); @@ -711,18 +575,18 @@ export const ChatInput: React.FC = (props) => { let muxMetadata: MuxFrontendMetadata | undefined; let compactionOptions = {}; - if (editingMessage && messageText.startsWith("/")) { - const parsed = parseCommand(messageText); - if (parsed?.type === "compact") { + if (editingMessage && normalizedCommandInput.startsWith("/")) { + const parsedEditingCommand = parseCommand(normalizedCommandInput); + if (parsedEditingCommand?.type === "compact") { const { messageText: regeneratedText, metadata, sendOptions, } = prepareCompactionMessage({ workspaceId: props.workspaceId, - maxOutputTokens: parsed.maxOutputTokens, - continueMessage: parsed.continueMessage, - model: parsed.model, + maxOutputTokens: parsedEditingCommand.maxOutputTokens, + continueMessage: parsedEditingCommand.continueMessage, + model: parsedEditingCommand.model, sendMessageOptions, }); actualMessageText = regeneratedText; @@ -758,7 +622,7 @@ export const ChatInput: React.FC = (props) => { // Show error using enhanced toast setToast(createErrorToast(result.error)); // Restore input and images on error so user can try again - setInput(messageText); + setInput(rawInputValue); setImageAttachments(previousImageAttachments); } else { // Track telemetry for successful message send @@ -779,7 +643,7 @@ export const ChatInput: React.FC = (props) => { raw: error instanceof Error ? error.message : "Failed to send message", }) ); - setInput(messageText); + setInput(rawInputValue); setImageAttachments(previousImageAttachments); } finally { setIsSending(false); @@ -905,30 +769,28 @@ export const ChatInput: React.FC = (props) => { data-component="ChatInputSection" >
- {/* Creation toast */} - {variant === "creation" && ( - creationState.setToast(null)} - /> - )} - - {/* Workspace toast */} - {variant === "workspace" && ( - - )} - - {/* Command suggestions - workspace only */} - {variant === "workspace" && ( - setShowCommandSuggestions(false)} - isVisible={showCommandSuggestions} - ariaLabel="Slash command suggestions" - listId={commandListId} - /> - )} + {/* Toast - show shared toast (slash commands) or variant-specific toast */} + { + handleToastDismiss(); + if (variant === "creation") { + creationState.setToast(null); + } + }} + /> + + {/* Command suggestions - available in both variants */} + {/* In creation mode, use portal (anchorRef) to escape overflow:hidden containers */} + setShowCommandSuggestions(false)} + isVisible={showCommandSuggestions} + ariaLabel="Slash command suggestions" + listId={commandListId} + anchorRef={variant === "creation" ? inputRef : undefined} + />
void; + onProviderConfig?: (provider: string, keyPath: string[], value: string) => Promise; + onModelChange?: (model: string) => void; onCancel?: () => void; disabled?: boolean; onReady?: (api: ChatInputAPI) => void; diff --git a/src/browser/components/CommandSuggestions.tsx b/src/browser/components/CommandSuggestions.tsx index 432812739..31795659a 100644 --- a/src/browser/components/CommandSuggestions.tsx +++ b/src/browser/components/CommandSuggestions.tsx @@ -1,4 +1,5 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef, useLayoutEffect } from "react"; +import { createPortal } from "react-dom"; import { cn } from "@/common/lib/utils"; import type { SlashSuggestion } from "@/browser/utils/slashCommands/types"; @@ -13,6 +14,8 @@ interface CommandSuggestionsProps { isVisible: boolean; ariaLabel?: string; listId?: string; + /** Reference to the input element for portal positioning */ + anchorRef?: React.RefObject; } // Main component @@ -23,14 +26,51 @@ export const CommandSuggestions: React.FC = ({ isVisible, ariaLabel = "Command suggestions", listId, + anchorRef, }) => { const [selectedIndex, setSelectedIndex] = useState(0); + const [position, setPosition] = useState<{ top: number; left: number; width: number } | null>( + null + ); + const menuRef = useRef(null); // Reset selection whenever suggestions change useEffect(() => { setSelectedIndex(0); }, [suggestions]); + // Calculate position when using portal mode + useLayoutEffect(() => { + if (!anchorRef?.current || !isVisible) { + setPosition(null); + return; + } + + const updatePosition = () => { + const anchor = anchorRef.current; + if (!anchor) return; + + const rect = anchor.getBoundingClientRect(); + const menuHeight = menuRef.current?.offsetHeight ?? 200; + + setPosition({ + top: rect.top - menuHeight - 8, // 8px gap above anchor + left: rect.left, + width: rect.width, + }); + }; + + updatePosition(); + + // Update on resize/scroll + window.addEventListener("resize", updatePosition); + window.addEventListener("scroll", updatePosition, true); + return () => { + window.removeEventListener("resize", updatePosition); + window.removeEventListener("scroll", updatePosition, true); + }; + }, [anchorRef, isVisible, suggestions]); + // Handle keyboard navigation useEffect(() => { if (!isVisible || suggestions.length === 0) return; @@ -84,8 +124,9 @@ export const CommandSuggestions: React.FC = ({ const activeSuggestion = suggestions[selectedIndex] ?? suggestions[0]; const resolvedListId = listId ?? `command-suggestions-list`; - return ( + const content = (
= ({ activeSuggestion ? `${resolvedListId}-option-${activeSuggestion.id}` : undefined } data-command-suggestions - className="bg-separator border-border-light absolute right-0 bottom-full left-0 z-[100] mb-2 flex max-h-[200px] flex-col overflow-y-auto rounded border shadow-[0_-4px_12px_rgba(0,0,0,0.4)]" + className={cn( + "bg-separator border-border-light z-[100] flex max-h-[200px] flex-col overflow-y-auto rounded border shadow-[0_-4px_12px_rgba(0,0,0,0.4)]", + // Use absolute positioning relative to parent when not in portal mode + !anchorRef && "absolute right-0 bottom-full left-0 mb-2" + )} + style={ + anchorRef && position + ? { + position: "fixed", + top: position.top, + left: position.left, + width: position.width, + } + : undefined + } > {suggestions.map((suggestion, index) => (
= ({
); + + // Use portal when anchorRef is provided (to escape overflow:hidden containers) + if (anchorRef) { + return createPortal(content, document.body); + } + + return content; }; diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index 51c41d0e2..3514ead49 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -16,12 +16,341 @@ import type { Toast } from "@/browser/components/ChatInputToast"; import type { ParsedCommand } from "@/browser/utils/slashCommands/types"; import { applyCompactionOverrides } from "@/browser/utils/messages/compactionOptions"; import { resolveCompactionModel } from "@/browser/utils/messages/compactionModelPreference"; -import { getRuntimeKey } from "@/common/constants/storage"; +import { dispatchWorkspaceSwitch } from "./workspaceEvents"; +import { getRuntimeKey, copyWorkspaceStorage } from "@/common/constants/storage"; // ============================================================================ // Workspace Creation // ============================================================================ +import { createCommandToast } from "@/browser/components/ChatInputToasts"; +import { setTelemetryEnabled } from "@/common/telemetry"; + +export interface ForkOptions { + sourceWorkspaceId: string; + newName: string; + startMessage?: string; + sendMessageOptions?: SendMessageOptions; +} + +export interface ForkResult { + success: boolean; + workspaceInfo?: FrontendWorkspaceMetadata; + error?: string; +} + +/** + * Fork a workspace and switch to it + * Handles copying storage, dispatching switch event, and optionally sending start message + * + * Caller is responsible for error handling, logging, and showing toasts + */ +export async function forkWorkspace(options: ForkOptions): Promise { + const result = await window.api.workspace.fork(options.sourceWorkspaceId, options.newName); + + if (!result.success) { + return { success: false, error: result.error ?? "Failed to fork workspace" }; + } + + // Copy UI state to the new workspace + copyWorkspaceStorage(options.sourceWorkspaceId, result.metadata.id); + + // Get workspace info for switching + const workspaceInfo = await window.api.workspace.getInfo(result.metadata.id); + if (!workspaceInfo) { + return { success: false, error: "Failed to get workspace info after fork" }; + } + + // Dispatch event to switch workspace + dispatchWorkspaceSwitch(workspaceInfo); + + // If there's a start message, defer until React finishes rendering and WorkspaceStore subscribes + // Using requestAnimationFrame ensures we wait for: + // 1. React to process the workspace switch and update state + // 2. Effects to run (workspaceStore.syncWorkspaces in App.tsx) + // 3. WorkspaceStore to subscribe to the new workspace's IPC channel + if (options.startMessage && options.sendMessageOptions) { + requestAnimationFrame(() => { + void window.api.workspace.sendMessage( + result.metadata.id, + options.startMessage!, + options.sendMessageOptions + ); + }); + } + + return { success: true, workspaceInfo }; +} + +export interface SlashCommandContext extends Omit { + workspaceId?: string; + variant: "workspace" | "creation"; + + // Global Actions + onProviderConfig?: (provider: string, keyPath: string[], value: string) => Promise; + onModelChange?: (model: string) => void; + setPreferredModel: (model: string) => void; + setVimEnabled: (cb: (prev: boolean) => boolean) => void; + + // Workspace Actions + onTruncateHistory?: (percentage?: number) => Promise; + resetInputHeight: () => void; +} + +// ============================================================================ +// Command Dispatcher +// ============================================================================ + +/** + * Process any slash command + * Returns true if the command was handled (even if it failed) + * Returns false if it's not a command (should be sent as message) - though parsed usually implies it is a command + */ +export async function processSlashCommand( + parsed: ParsedCommand, + context: SlashCommandContext +): Promise { + if (!parsed) return { clearInput: false, toastShown: false }; + const { + setInput, + setIsSending, + setToast, + variant, + setVimEnabled, + setPreferredModel, + onModelChange, + } = context; + + // 1. Global Commands + if (parsed.type === "providers-set") { + if (context.onProviderConfig) { + setIsSending(true); + setInput(""); // Clear input immediately + + try { + await context.onProviderConfig(parsed.provider, parsed.keyPath, parsed.value); + setToast({ + id: Date.now().toString(), + type: "success", + message: `Provider ${parsed.provider} updated`, + }); + } catch (error) { + console.error("Failed to update provider config:", error); + setToast({ + id: Date.now().toString(), + type: "error", + message: error instanceof Error ? error.message : "Failed to update provider", + }); + return { clearInput: false, toastShown: true }; // Input restored by caller if clearInput is false? + // Actually caller restores if we return clearInput: false. + // But here we cleared it proactively? + // The caller (ChatInput) pattern is: if (!result.clearInput) setInput(original). + // So we should return clearInput: false on error. + } finally { + setIsSending(false); + } + return { clearInput: true, toastShown: true }; + } + return { clearInput: false, toastShown: false }; + } + + if (parsed.type === "model-set") { + setInput(""); + setPreferredModel(parsed.modelString); + onModelChange?.(parsed.modelString); + setToast({ + id: Date.now().toString(), + type: "success", + message: `Model changed to ${parsed.modelString}`, + }); + return { clearInput: true, toastShown: true }; + } + + if (parsed.type === "vim-toggle") { + setInput(""); + setVimEnabled((prev) => !prev); + return { clearInput: true, toastShown: false }; + } + + if (parsed.type === "telemetry-set") { + setInput(""); + setTelemetryEnabled(parsed.enabled); + setToast({ + id: Date.now().toString(), + type: "success", + message: `Telemetry ${parsed.enabled ? "enabled" : "disabled"}`, + }); + return { clearInput: true, toastShown: true }; + } + + // 2. Workspace Commands + const workspaceCommands = ["clear", "truncate", "compact", "fork", "new"]; + const isWorkspaceCommand = workspaceCommands.includes(parsed.type); + + if (isWorkspaceCommand) { + if (variant !== "workspace") { + setToast({ + id: Date.now().toString(), + type: "error", + message: "Command not available during workspace creation", + }); + return { clearInput: false, toastShown: true }; + } + + // Dispatch workspace commands + switch (parsed.type) { + case "clear": + return handleClearCommand(parsed, context); + case "truncate": + return handleTruncateCommand(parsed, context); + case "compact": + // handleCompactCommand expects workspaceId in context + if (!context.workspaceId) throw new Error("Workspace ID required"); + return handleCompactCommand(parsed, { + ...context, + workspaceId: context.workspaceId, + } as CommandHandlerContext); + case "fork": + return handleForkCommand(parsed, context); + case "new": + if (!context.workspaceId) throw new Error("Workspace ID required"); + return handleNewCommand(parsed, { + ...context, + workspaceId: context.workspaceId, + } as CommandHandlerContext); + } + } + + // 3. Fallback / Help / Unknown + const commandToast = createCommandToast(parsed); + if (commandToast) { + setToast(commandToast); + return { clearInput: false, toastShown: true }; + } + + return { clearInput: false, toastShown: false }; +} + +// ============================================================================ +// Command Handlers +// ============================================================================ + +async function handleClearCommand( + _parsed: Extract, + context: SlashCommandContext +): Promise { + const { setInput, onTruncateHistory, resetInputHeight, setToast } = context; + + setInput(""); + resetInputHeight(); + + if (!onTruncateHistory) return { clearInput: true, toastShown: false }; + + try { + await onTruncateHistory(1.0); + setToast({ + id: Date.now().toString(), + type: "success", + message: "Chat history cleared", + }); + return { clearInput: true, toastShown: true }; + } catch (error) { + const normalized = error instanceof Error ? error : new Error("Failed to clear history"); + console.error("Failed to clear history:", normalized); + setToast({ + id: Date.now().toString(), + type: "error", + message: normalized.message, + }); + return { clearInput: false, toastShown: true }; + } +} + +async function handleTruncateCommand( + parsed: Extract, + context: SlashCommandContext +): Promise { + const { setInput, onTruncateHistory, resetInputHeight, setToast } = context; + + setInput(""); + resetInputHeight(); + + if (!onTruncateHistory) return { clearInput: true, toastShown: false }; + + try { + await onTruncateHistory(parsed.percentage); + setToast({ + id: Date.now().toString(), + type: "success", + message: `Chat history truncated by ${Math.round(parsed.percentage * 100)}%`, + }); + return { clearInput: true, toastShown: true }; + } catch (error) { + const normalized = error instanceof Error ? error : new Error("Failed to truncate history"); + console.error("Failed to truncate history:", normalized); + setToast({ + id: Date.now().toString(), + type: "error", + message: normalized.message, + }); + return { clearInput: false, toastShown: true }; + } +} + +async function handleForkCommand( + parsed: Extract, + context: SlashCommandContext +): Promise { + const { workspaceId, sendMessageOptions, setInput, setIsSending, setToast } = context; + + setInput(""); // Clear input immediately + setIsSending(true); + + try { + // Note: workspaceId is required for fork, but SlashCommandContext allows undefined workspaceId. + // If we are here, variant === "workspace", so workspaceId should be defined. + if (!workspaceId) throw new Error("Workspace ID required for fork"); + + const forkResult = await forkWorkspace({ + sourceWorkspaceId: workspaceId, + newName: parsed.newName, + startMessage: parsed.startMessage, + sendMessageOptions, + }); + + if (!forkResult.success) { + const errorMsg = forkResult.error ?? "Failed to fork workspace"; + console.error("Failed to fork workspace:", errorMsg); + setToast({ + id: Date.now().toString(), + type: "error", + title: "Fork Failed", + message: errorMsg, + }); + return { clearInput: false, toastShown: true }; + } else { + setToast({ + id: Date.now().toString(), + type: "success", + message: `Forked to workspace "${parsed.newName}"`, + }); + return { clearInput: true, toastShown: true }; + } + } catch (error) { + const normalized = error instanceof Error ? error : new Error("Failed to fork workspace"); + console.error("Fork error:", normalized); + setToast({ + id: Date.now().toString(), + type: "error", + title: "Fork Failed", + message: normalized.message, + }); + return { clearInput: false, toastShown: true }; + } finally { + setIsSending(false); + } +} + /** * Parse runtime string from -r flag into RuntimeConfig for backend * Supports formats: @@ -165,11 +494,9 @@ export function formatNewCommand( } // ============================================================================ -// Workspace Forking (re-exported from workspaceFork for convenience) +// Workspace Forking (Inline implementation) // ============================================================================ -export { forkWorkspace } from "./workspaceFork"; - // ============================================================================ // Compaction // ============================================================================ @@ -458,10 +785,3 @@ export async function handleCompactCommand( /** * Dispatch a custom event to switch workspaces */ -export function dispatchWorkspaceSwitch(workspaceInfo: FrontendWorkspaceMetadata): void { - window.dispatchEvent( - new CustomEvent(CUSTOM_EVENTS.WORKSPACE_FORK_SWITCH, { - detail: workspaceInfo, - }) - ); -} diff --git a/src/browser/utils/workspaceEvents.ts b/src/browser/utils/workspaceEvents.ts new file mode 100644 index 000000000..2f34cda83 --- /dev/null +++ b/src/browser/utils/workspaceEvents.ts @@ -0,0 +1,16 @@ +import { CUSTOM_EVENTS } from "@/common/constants/events"; +import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; + +export function isWorkspaceForkSwitchEvent( + event: Event +): event is CustomEvent { + return event.type === CUSTOM_EVENTS.WORKSPACE_FORK_SWITCH; +} + +export function dispatchWorkspaceSwitch(workspaceInfo: FrontendWorkspaceMetadata): void { + window.dispatchEvent( + new CustomEvent(CUSTOM_EVENTS.WORKSPACE_FORK_SWITCH, { + detail: workspaceInfo, + }) + ); +} diff --git a/src/browser/utils/workspaceFork.ts b/src/browser/utils/workspaceFork.ts deleted file mode 100644 index 18356de61..000000000 --- a/src/browser/utils/workspaceFork.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Workspace forking utilities - * Handles forking workspaces and switching UI state - */ - -import type { SendMessageOptions } from "@/common/types/ipc"; -import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; -import { CUSTOM_EVENTS } from "@/common/constants/events"; -import { copyWorkspaceStorage } from "@/common/constants/storage"; - -export interface ForkOptions { - sourceWorkspaceId: string; - newName: string; - startMessage?: string; - sendMessageOptions?: SendMessageOptions; -} - -export interface ForkResult { - success: boolean; - workspaceInfo?: FrontendWorkspaceMetadata; - error?: string; -} - -/** - * Fork a workspace and switch to it - * Handles copying storage, dispatching switch event, and optionally sending start message - * - * Caller is responsible for error handling, logging, and showing toasts - */ -export async function forkWorkspace(options: ForkOptions): Promise { - const result = await window.api.workspace.fork(options.sourceWorkspaceId, options.newName); - - if (!result.success) { - return { success: false, error: result.error ?? "Failed to fork workspace" }; - } - - // Copy UI state to the new workspace - copyWorkspaceStorage(options.sourceWorkspaceId, result.metadata.id); - - // Get workspace info for switching - const workspaceInfo = await window.api.workspace.getInfo(result.metadata.id); - if (!workspaceInfo) { - return { success: false, error: "Failed to get workspace info after fork" }; - } - - // Dispatch event to switch workspace - dispatchWorkspaceSwitch(workspaceInfo); - - // If there's a start message, defer until React finishes rendering and WorkspaceStore subscribes - // Using requestAnimationFrame ensures we wait for: - // 1. React to process the workspace switch and update state - // 2. Effects to run (workspaceStore.syncWorkspaces in App.tsx) - // 3. WorkspaceStore to subscribe to the new workspace's IPC channel - if (options.startMessage && options.sendMessageOptions) { - requestAnimationFrame(() => { - void window.api.workspace.sendMessage( - result.metadata.id, - options.startMessage!, - options.sendMessageOptions - ); - }); - } - - return { success: true, workspaceInfo }; -} - -/** - * Dispatch a custom event to switch workspaces - */ -export function dispatchWorkspaceSwitch(workspaceInfo: FrontendWorkspaceMetadata): void { - window.dispatchEvent( - new CustomEvent(CUSTOM_EVENTS.WORKSPACE_FORK_SWITCH, { - detail: workspaceInfo, - }) - ); -} - -/** - * Type guard for workspace fork switch events - */ -export function isWorkspaceForkSwitchEvent( - event: Event -): event is CustomEvent { - return event.type === CUSTOM_EVENTS.WORKSPACE_FORK_SWITCH; -}