From 2b5d4fc7fb159ef72bfc0810dbbe183055619e4a Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 23 Nov 2025 18:30:54 -0600 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=A4=96=20fix:=20unify=20chat=20input?= =?UTF-8?q?=20and=20commands=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/App.tsx | 15 +- src/browser/components/ChatInput/index.tsx | 226 +++------------ src/browser/components/ChatInput/types.ts | 2 + src/browser/utils/chatCommands.ts | 318 ++++++++++++++++++++- src/browser/utils/workspaceFork.ts | 85 ------ 5 files changed, 367 insertions(+), 279 deletions(-) delete mode 100644 src/browser/utils/workspaceFork.ts diff --git a/src/browser/App.tsx b/src/browser/App.tsx index eea416ab8f..8843d86c0c 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/chatCommands"; import { getThinkingLevelKey } from "@/common/constants/storage"; import type { BranchListResult } from "@/common/types/ipc"; import { useTelemetry } from "./hooks/useTelemetry"; @@ -517,9 +517,21 @@ 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 ( <>
+ + { // Add to workspace metadata map diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index b033fff67a..a64aeadc61 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,7 +57,6 @@ 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"; @@ -467,8 +464,38 @@ export const ChatInput: React.FC = (props) => { } const messageText = input.trim(); + const parsed = parseCommand(messageText); + + 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(messageText); // Restore input on failure + } + return; + } - // Route to creation handler for creation variant if (variant === "creation") { // Creation variant: simple message send + workspace creation setIsSending(true); @@ -483,193 +510,10 @@ export const ChatInput: React.FC = (props) => { return; } - // Workspace variant: full command handling + message send + // Workspace variant: regular message send if (variant !== "workspace") return; // Type guard 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); diff --git a/src/browser/components/ChatInput/types.ts b/src/browser/components/ChatInput/types.ts index 25f7979c9c..9a1e19343f 100644 --- a/src/browser/components/ChatInput/types.ts +++ b/src/browser/components/ChatInput/types.ts @@ -31,6 +31,8 @@ export interface ChatInputCreationVariant { projectPath: string; projectName: string; onWorkspaceCreated: (metadata: FrontendWorkspaceMetadata) => 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/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index 51c41d0e22..f6af788b22 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -16,12 +16,326 @@ 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 { 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) { + console.error("Failed to clear history:", error); + setToast({ + id: Date.now().toString(), + type: "error", + message: "Failed to clear history", + }); + 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) { + console.error("Failed to truncate history:", error); + setToast({ + id: Date.now().toString(), + type: "error", + message: "Failed to truncate history", + }); + 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 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, + }); + return { clearInput: false, toastShown: true }; + } finally { + setIsSending(false); + } +} + + + /** * Parse runtime string from -r flag into RuntimeConfig for backend * Supports formats: @@ -168,7 +482,7 @@ export function formatNewCommand( // Workspace Forking (re-exported from workspaceFork for convenience) // ============================================================================ -export { forkWorkspace } from "./workspaceFork"; +export { forkWorkspace }; // ============================================================================ // Compaction diff --git a/src/browser/utils/workspaceFork.ts b/src/browser/utils/workspaceFork.ts deleted file mode 100644 index 18356de611..0000000000 --- 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; -} From 81be70093bed785898ab11da538254d18dd92b43 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 23 Nov 2025 19:03:56 -0600 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=A4=96=20fix:=20resolve=20circular=20?= =?UTF-8?q?deps=20and=20lint=20errors=20by=20extracting=20workspace=20even?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/App.tsx | 4 +- src/browser/components/ChatInput/index.tsx | 2 +- src/browser/utils/chatCommands.ts | 56 ++++++++++++---------- src/browser/utils/workspaceEvents.ts | 16 +++++++ 4 files changed, 49 insertions(+), 29 deletions(-) create mode 100644 src/browser/utils/workspaceEvents.ts diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 8843d86c0c..0911aaf830 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/chatCommands"; +import { isWorkspaceForkSwitchEvent } from "./utils/workspaceEvents"; import { getThinkingLevelKey } from "@/common/constants/storage"; import type { BranchListResult } from "@/common/types/ipc"; import { useTelemetry } from "./hooks/useTelemetry"; @@ -530,8 +530,6 @@ function AppInner() { return ( <>
- - = (props) => { }; const result = await processSlashCommand(parsed, context); - + if (!result.clearInput) { setInput(messageText); // Restore input on failure } diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index f6af788b22..3514ead493 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -16,6 +16,7 @@ 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 { dispatchWorkspaceSwitch } from "./workspaceEvents"; import { getRuntimeKey, copyWorkspaceStorage } from "@/common/constants/storage"; // ============================================================================ @@ -110,7 +111,15 @@ export async function processSlashCommand( context: SlashCommandContext ): Promise { if (!parsed) return { clearInput: false, toastShown: false }; - const { setInput, setIsSending, setToast, variant, setVimEnabled, setPreferredModel, onModelChange } = context; + const { + setInput, + setIsSending, + setToast, + variant, + setVimEnabled, + setPreferredModel, + onModelChange, + } = context; // 1. Global Commands if (parsed.type === "providers-set") { @@ -178,7 +187,7 @@ export async function processSlashCommand( const workspaceCommands = ["clear", "truncate", "compact", "fork", "new"]; const isWorkspaceCommand = workspaceCommands.includes(parsed.type); - if (isWorkspaceCommand) { + if (isWorkspaceCommand) { if (variant !== "workspace") { setToast({ id: Date.now().toString(), @@ -197,12 +206,18 @@ export async function processSlashCommand( 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); + 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); + return handleNewCommand(parsed, { + ...context, + workspaceId: context.workspaceId, + } as CommandHandlerContext); } } @@ -225,10 +240,10 @@ async function handleClearCommand( context: SlashCommandContext ): Promise { const { setInput, onTruncateHistory, resetInputHeight, setToast } = context; - + setInput(""); resetInputHeight(); - + if (!onTruncateHistory) return { clearInput: true, toastShown: false }; try { @@ -240,11 +255,12 @@ async function handleClearCommand( }); return { clearInput: true, toastShown: true }; } catch (error) { - console.error("Failed to clear history:", 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: "Failed to clear history", + message: normalized.message, }); return { clearInput: false, toastShown: true }; } @@ -270,11 +286,12 @@ async function handleTruncateCommand( }); return { clearInput: true, toastShown: true }; } catch (error) { - console.error("Failed to truncate history:", 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: "Failed to truncate history", + message: normalized.message, }); return { clearInput: false, toastShown: true }; } @@ -320,13 +337,13 @@ async function handleForkCommand( return { clearInput: true, toastShown: true }; } } catch (error) { - const errorMsg = error instanceof Error ? error.message : "Failed to fork workspace"; - console.error("Fork error:", 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: errorMsg, + message: normalized.message, }); return { clearInput: false, toastShown: true }; } finally { @@ -334,8 +351,6 @@ async function handleForkCommand( } } - - /** * Parse runtime string from -r flag into RuntimeConfig for backend * Supports formats: @@ -479,11 +494,9 @@ export function formatNewCommand( } // ============================================================================ -// Workspace Forking (re-exported from workspaceFork for convenience) +// Workspace Forking (Inline implementation) // ============================================================================ -export { forkWorkspace }; - // ============================================================================ // Compaction // ============================================================================ @@ -772,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 0000000000..2f34cda834 --- /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, + }) + ); +} From 139749cdbefea3076028b936205c6d8afb296452 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 24 Nov 2025 13:32:38 -0600 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=A4=96=20fix:=20prioritize=20slash=20?= =?UTF-8?q?command=20parsing=20to=20prevent=20fallthrough=20to=20creation?= =?UTF-8?q?=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/components/ChatInput/index.tsx | 24 ++++++++++------------ 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index a126d47d96..9e2046e2c1 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -496,8 +496,8 @@ export const ChatInput: React.FC = (props) => { return; } + // 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) { @@ -511,7 +511,7 @@ export const ChatInput: React.FC = (props) => { } // Workspace variant: regular message send - if (variant !== "workspace") return; // Type guard + if (variant !== "workspace") return; try { // Regular message - send directly via API @@ -762,17 +762,15 @@ export const ChatInput: React.FC = (props) => { )} - {/* Command suggestions - workspace only */} - {variant === "workspace" && ( - setShowCommandSuggestions(false)} - isVisible={showCommandSuggestions} - ariaLabel="Slash command suggestions" - listId={commandListId} - /> - )} + {/* Command suggestions - available in both variants */} + setShowCommandSuggestions(false)} + isVisible={showCommandSuggestions} + ariaLabel="Slash command suggestions" + listId={commandListId} + />
Date: Mon, 24 Nov 2025 14:04:52 -0600 Subject: [PATCH 4/5] fix: unify toast rendering for slash commands in both variants - Toast was only rendered for workspace variant, so slash command toasts (e.g., /providers help) were invisible in creation mode - Now renders a single ChatInputToast that shows either: - Shared toast from slash commands (priority) - Creation-specific toast as fallback - Command suggestions were already unified, this completes parity --- src/browser/components/ChatInput/index.tsx | 70 ++++++++++++++-------- tests/manual_parse_check.ts | 14 +++++ 2 files changed, 58 insertions(+), 26 deletions(-) create mode 100644 tests/manual_parse_check.ts diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 9e2046e2c1..6fdfdf4a1a 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -63,6 +63,14 @@ 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 { @@ -301,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 @@ -463,8 +472,11 @@ export const ChatInput: React.FC = (props) => { return; } - const messageText = input.trim(); - const parsed = parseCommand(messageText); + 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 = { @@ -491,11 +503,20 @@ export const ChatInput: React.FC = (props) => { const result = await processSlashCommand(parsed, context); if (!result.clearInput) { - setInput(messageText); // Restore input on failure + 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; + } + // Handle standard message sending based on variant if (variant === "creation") { setIsSending(true); @@ -511,7 +532,6 @@ export const ChatInput: React.FC = (props) => { } // Workspace variant: regular message send - if (variant !== "workspace") return; try { // Regular message - send directly via API @@ -555,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; @@ -602,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 @@ -623,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); @@ -749,18 +769,16 @@ export const ChatInput: React.FC = (props) => { data-component="ChatInputSection" >
- {/* Creation toast */} - {variant === "creation" && ( - creationState.setToast(null)} - /> - )} - - {/* Workspace toast */} - {variant === "workspace" && ( - - )} + {/* Toast - show shared toast (slash commands) or variant-specific toast */} + { + handleToastDismiss(); + if (variant === "creation") { + creationState.setToast(null); + } + }} + /> {/* Command suggestions - available in both variants */} Date: Mon, 24 Nov 2025 14:12:37 -0600 Subject: [PATCH 5/5] fix: use portal for command suggestions in creation mode The CommandSuggestions popup was being clipped by overflow:hidden on parent containers in creation mode. In workspace mode, ChatInput is positioned outside the overflow-hidden scroll container. Solution: Pass anchorRef to CommandSuggestions when in creation mode. When anchorRef is provided, the component uses createPortal to render to document.body with fixed positioning relative to the anchor element. This escapes the overflow:hidden containers and ensures suggestions are visible in both variants. --- src/browser/components/ChatInput/index.tsx | 2 + src/browser/components/CommandSuggestions.tsx | 68 ++++++++++++++++++- tests/manual_parse_check.ts | 14 ---- 3 files changed, 67 insertions(+), 17 deletions(-) delete mode 100644 tests/manual_parse_check.ts diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 6fdfdf4a1a..0ad550b505 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -781,6 +781,7 @@ export const ChatInput: React.FC = (props) => { /> {/* Command suggestions - available in both variants */} + {/* In creation mode, use portal (anchorRef) to escape overflow:hidden containers */} = (props) => { isVisible={showCommandSuggestions} ariaLabel="Slash command suggestions" listId={commandListId} + anchorRef={variant === "creation" ? inputRef : undefined} />
diff --git a/src/browser/components/CommandSuggestions.tsx b/src/browser/components/CommandSuggestions.tsx index 4328127399..31795659ae 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/tests/manual_parse_check.ts b/tests/manual_parse_check.ts deleted file mode 100644 index 4771d3c8bf..0000000000 --- a/tests/manual_parse_check.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { parseCommand } from "../src/browser/utils/slashCommands/parser"; - -try { - const result = parseCommand("/providers"); - console.log("Parsing /providers:", JSON.stringify(result, null, 2)); - - const result2 = parseCommand("/providers set anthropic apiKey 123"); - console.log("Parsing /providers set:", JSON.stringify(result2, null, 2)); - - const result4 = parseCommand("/providers "); - console.log("Parsing /providers (space):", JSON.stringify(result4, null, 2)); -} catch (e) { - console.error("Error:", e); -}