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;
-}