From e8058556be4bbfba45681e7335a8f42f29f268a8 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 20:10:24 -0500 Subject: [PATCH 1/8] =?UTF-8?q?=F0=9F=A4=96=20Add=20workspace=20kebab=20me?= =?UTF-8?q?nu=20with=20Fork=20and=20Compact=20modals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace delete button with KebabMenu (Fork, Compact, Delete) - Add ForkWorkspaceModal with command display pattern - Add CompactModal with command display pattern - Export formatForkCommand and formatCompactCommand utilities - Maximize code reuse following /new + NewWorkspaceModal pattern Generated with `cmux` --- src/components/CompactModal.tsx | 192 ++++++++++++++++++++++++++ src/components/ForkWorkspaceModal.tsx | 176 +++++++++++++++++++++++ src/components/ProjectSidebar.tsx | 86 ++++++++++++ src/components/WorkspaceListItem.tsx | 96 +++++++------ src/utils/chatCommands.ts | 37 ++++- 5 files changed, 537 insertions(+), 50 deletions(-) create mode 100644 src/components/CompactModal.tsx create mode 100644 src/components/ForkWorkspaceModal.tsx diff --git a/src/components/CompactModal.tsx b/src/components/CompactModal.tsx new file mode 100644 index 000000000..f7df24231 --- /dev/null +++ b/src/components/CompactModal.tsx @@ -0,0 +1,192 @@ +import React, { useEffect, useId, useState } from "react"; +import styled from "@emotion/styled"; +import { Modal, ModalInfo, ModalActions, CancelButton, PrimaryButton } from "./Modal"; +import { formatCompactCommand } from "@/utils/chatCommands"; + +const FormGroup = styled.div` + margin-bottom: 20px; + + label { + display: block; + margin-bottom: 8px; + color: #ccc; + font-size: 14px; + } + + input, + select { + width: 100%; + padding: 8px 12px; + background: #2d2d2d; + border: 1px solid #444; + border-radius: 4px; + color: #fff; + font-size: 14px; + + &:focus { + outline: none; + border-color: #007acc; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } + + select { + cursor: pointer; + + option { + background: #2d2d2d; + color: #fff; + } + } +`; + +const HelpText = styled.div` + color: #888; + font-size: 12px; + margin-top: 4px; +`; + +const CommandDisplay = styled.div` + margin-top: 20px; + padding: 12px; + background: #1e1e1e; + border: 1px solid #3e3e42; + border-radius: 4px; + font-family: "Menlo", "Monaco", "Courier New", monospace; + font-size: 13px; + color: #d4d4d4; + white-space: pre-wrap; + word-break: break-all; +`; + +const CommandLabel = styled.div` + font-size: 12px; + color: #888; + margin-bottom: 8px; + font-family: + system-ui, + -apple-system, + sans-serif; +`; + +interface CompactModalProps { + isOpen: boolean; + onClose: () => void; + onCompact: (maxOutputTokens?: number, model?: string) => Promise; +} + +const CompactModal: React.FC = ({ isOpen, onClose, onCompact }) => { + const [maxOutputTokens, setMaxOutputTokens] = useState(""); + const [model, setModel] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const infoId = useId(); + + // Reset form when modal opens + useEffect(() => { + if (isOpen) { + setMaxOutputTokens(""); + setModel(""); + setIsLoading(false); + } + }, [isOpen]); + + const handleCancel = () => { + if (!isLoading) { + onClose(); + } + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + setIsLoading(true); + + try { + const tokens = maxOutputTokens.trim() ? parseInt(maxOutputTokens.trim(), 10) : undefined; + const modelParam = model.trim() || undefined; + + await onCompact(tokens, modelParam); + setMaxOutputTokens(""); + setModel(""); + onClose(); + } catch (err) { + console.error("Compact failed:", err); + // Error handling is done by the parent component + } finally { + setIsLoading(false); + } + }; + + const tokensValue = maxOutputTokens.trim() ? parseInt(maxOutputTokens.trim(), 10) : undefined; + const modelValue = model.trim() || undefined; + + return ( + +
void handleSubmit(event)}> + + + setMaxOutputTokens(event.target.value)} + disabled={isLoading} + placeholder="e.g., 3000" + min="100" + /> + + Controls the length of the summary. Leave empty for default (~2000 words). + + + + + + setModel(event.target.value)} + disabled={isLoading} + placeholder="e.g., claude-3-5-sonnet-20241022" + /> + Specify a model for compaction. Leave empty to use current model. + + + +

+ Compaction will summarize your conversation history, allowing you to continue with a + shorter context window. The AI will create a compact version that preserves important + information for future interactions. +

+
+ +
+ Equivalent command: + {formatCompactCommand(tokensValue, modelValue)} +
+ + + + Cancel + + + {isLoading ? "Compacting..." : "Start Compaction"} + + +
+
+ ); +}; + +export default CompactModal; diff --git a/src/components/ForkWorkspaceModal.tsx b/src/components/ForkWorkspaceModal.tsx new file mode 100644 index 000000000..5fb9616c6 --- /dev/null +++ b/src/components/ForkWorkspaceModal.tsx @@ -0,0 +1,176 @@ +import React, { useEffect, useId, useState } from "react"; +import styled from "@emotion/styled"; +import { Modal, ModalInfo, ModalActions, CancelButton, PrimaryButton } from "./Modal"; +import { formatForkCommand } from "@/utils/chatCommands"; + +const FormGroup = styled.div` + margin-bottom: 20px; + + label { + display: block; + margin-bottom: 8px; + color: #ccc; + font-size: 14px; + } + + input { + width: 100%; + padding: 8px 12px; + background: #2d2d2d; + border: 1px solid #444; + border-radius: 4px; + color: #fff; + font-size: 14px; + + &:focus { + outline: none; + border-color: #007acc; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } +`; + +const ErrorMessage = styled.div` + color: #ff5555; + font-size: 13px; + margin-top: 6px; +`; + +const CommandDisplay = styled.div` + margin-top: 20px; + padding: 12px; + background: #1e1e1e; + border: 1px solid #3e3e42; + border-radius: 4px; + font-family: "Menlo", "Monaco", "Courier New", monospace; + font-size: 13px; + color: #d4d4d4; + white-space: pre-wrap; + word-break: break-all; +`; + +const CommandLabel = styled.div` + font-size: 12px; + color: #888; + margin-bottom: 8px; + font-family: + system-ui, + -apple-system, + sans-serif; +`; + +interface ForkWorkspaceModalProps { + isOpen: boolean; + sourceWorkspaceName: string; + onClose: () => void; + onFork: (newName: string) => Promise; +} + +const ForkWorkspaceModal: React.FC = ({ + isOpen, + sourceWorkspaceName, + onClose, + onFork, +}) => { + const [newName, setNewName] = useState(""); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const infoId = useId(); + + // Reset form when modal opens + useEffect(() => { + if (isOpen) { + setNewName(""); + setError(null); + setIsLoading(false); + } + }, [isOpen]); + + const handleCancel = () => { + if (!isLoading) { + onClose(); + } + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + const trimmedName = newName.trim(); + if (!trimmedName) { + setError("Workspace name cannot be empty"); + return; + } + + setIsLoading(true); + setError(null); + + try { + await onFork(trimmedName); + setNewName(""); + onClose(); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to fork workspace"; + setError(message); + } finally { + setIsLoading(false); + } + }; + + return ( + +
void handleSubmit(event)}> + + + setNewName(event.target.value)} + disabled={isLoading} + placeholder="Enter new workspace name" + required + aria-required="true" + autoFocus + /> + {error && {error}} + + + +

+ This will create a new git branch and worktree from the current workspace state, + preserving all uncommitted changes. +

+
+ + {newName.trim() && ( +
+ Equivalent command: + {formatForkCommand(newName.trim())} +
+ )} + + + + Cancel + + + {isLoading ? "Forking..." : "Fork Workspace"} + + +
+
+ ); +}; + +export default ForkWorkspaceModal; diff --git a/src/components/ProjectSidebar.tsx b/src/components/ProjectSidebar.tsx index f4ded754f..b3561f7b5 100644 --- a/src/components/ProjectSidebar.tsx +++ b/src/components/ProjectSidebar.tsx @@ -17,6 +17,10 @@ import type { Secret } from "@/types/secrets"; import { ForceDeleteModal } from "./ForceDeleteModal"; import { WorkspaceListItem, type WorkspaceSelection } from "./WorkspaceListItem"; import { RenameProvider } from "@/contexts/WorkspaceRenameContext"; +import ForkWorkspaceModal from "./ForkWorkspaceModal"; +import CompactModal from "./CompactModal"; +import { forkWorkspace, executeCompaction } from "@/utils/chatCommands"; +import { useSendMessageOptions } from "@/hooks/useSendMessageOptions"; // Re-export WorkspaceSelection for backwards compatibility export type { WorkspaceSelection } from "./WorkspaceListItem"; @@ -544,6 +548,18 @@ const ProjectSidebarInner: React.FC = ({ anchor: { top: number; left: number } | null; } | null>(null); + const [forkModalState, setForkModalState] = useState<{ + workspaceId: string; + workspaceName: string; + } | null>(null); + + const [compactModalState, setCompactModalState] = useState<{ + workspaceId: string; + } | null>(null); + + // Get send message options for fork/compact operations + const sendMessageOptions = useSendMessageOptions(selectedWorkspace?.workspaceId ?? ""); + const getProjectName = (path: string) => { if (!path || typeof path !== "string") { return "Unknown"; @@ -643,6 +659,59 @@ const ProjectSidebarInner: React.FC = ({ } }; + const handleForkWorkspace = (workspaceId: string) => { + // Find workspace metadata from all projects + let metadata: FrontendWorkspaceMetadata | undefined; + for (const workspaces of sortedWorkspacesByProject.values()) { + metadata = workspaces.find((ws: FrontendWorkspaceMetadata) => ws.id === workspaceId); + if (metadata) break; + } + + if (metadata) { + setForkModalState({ + workspaceId, + workspaceName: metadata.name, + }); + } + }; + + const handleCompactWorkspace = (workspaceId: string) => { + setCompactModalState({ workspaceId }); + }; + + const handleForkSubmit = async (newName: string) => { + if (!forkModalState) { + return; + } + + const result = await forkWorkspace({ + sourceWorkspaceId: forkModalState.workspaceId, + newName, + sendMessageOptions, + }); + + if (!result.success) { + throw new Error(result.error ?? "Failed to fork workspace"); + } + }; + + const handleCompactSubmit = async (maxOutputTokens?: number, model?: string) => { + if (!compactModalState) { + return; + } + + const result = await executeCompaction({ + workspaceId: compactModalState.workspaceId, + maxOutputTokens, + model, + sendMessageOptions, + }); + + if (!result.success) { + throw new Error(result.error ?? "Failed to compact conversation"); + } + }; + const handleSaveSecrets = async (secrets: Secret[]) => { if (secretsModalState) { await onUpdateSecrets(secretsModalState.projectPath, secrets); @@ -839,6 +908,8 @@ const ProjectSidebarInner: React.FC = ({ onSelectWorkspace={onSelectWorkspace} onRemoveWorkspace={handleRemoveWorkspace} onToggleUnread={_onToggleUnread} + onForkWorkspace={handleForkWorkspace} + onCompactWorkspace={handleCompactWorkspace} /> ); })} @@ -877,6 +948,21 @@ const ProjectSidebarInner: React.FC = ({ onForceDelete={handleForceDelete} /> )} + {forkModalState && ( + setForkModalState(null)} + onFork={handleForkSubmit} + /> + )} + {compactModalState && ( + setCompactModalState(null)} + onCompact={handleCompactSubmit} + /> + )} {removeError && createPortal( diff --git a/src/components/WorkspaceListItem.tsx b/src/components/WorkspaceListItem.tsx index 53e9b753e..61bc5f9a4 100644 --- a/src/components/WorkspaceListItem.tsx +++ b/src/components/WorkspaceListItem.tsx @@ -10,6 +10,7 @@ import { GitStatusIndicator } from "./GitStatusIndicator"; import { ModelDisplay } from "./Messages/ModelDisplay"; import { StatusIndicator } from "./StatusIndicator"; import { useRename } from "@/contexts/WorkspaceRenameContext"; +import { KebabMenu, type KebabMenuItem } from "./KebabMenu"; // Styled Components const WorkspaceStatusIndicator = styled(StatusIndicator)` @@ -17,10 +18,10 @@ const WorkspaceStatusIndicator = styled(StatusIndicator)` `; const WorkspaceItem = styled.div<{ selected?: boolean }>` - padding: 6px 12px 6px 28px; + padding: 6px 12px 6px 12px; cursor: pointer; display: grid; - grid-template-columns: auto auto 1fr auto; + grid-template-columns: auto auto 1fr auto auto; gap: 8px; align-items: center; border-left: 3px solid transparent; @@ -37,10 +38,6 @@ const WorkspaceItem = styled.div<{ selected?: boolean }>` &:hover { background: #2a2a2b; - - button { - opacity: 1; - } } `; @@ -93,31 +90,11 @@ const WorkspaceErrorContainer = styled.div` z-index: 10; `; -const RemoveBtn = styled.button` - opacity: 0; - background: transparent; - color: #888; - border: none; - cursor: pointer; - font-size: 16px; - padding: 0; - width: 20px; - height: 20px; +const KebabMenuWrapper = styled.div` + grid-column: 5; display: flex; align-items: center; justify-content: center; - transition: all 0.2s; - flex-shrink: 0; - - &:hover { - color: #ccc; - background: rgba(255, 255, 255, 0.1); - border-radius: 3px; - } -`; - -const WorkspaceRemoveBtn = styled(RemoveBtn)` - grid-column: 1; `; export interface WorkspaceSelection { @@ -126,6 +103,7 @@ export interface WorkspaceSelection { namedWorkspacePath: string; // User-friendly path (symlink for new workspaces) workspaceId: string; } + export interface WorkspaceListItemProps { // Workspace metadata passed directly metadata: FrontendWorkspaceMetadata; @@ -137,6 +115,8 @@ export interface WorkspaceListItemProps { onSelectWorkspace: (selection: WorkspaceSelection) => void; onRemoveWorkspace: (workspaceId: string, button: HTMLElement) => Promise; onToggleUnread: (workspaceId: string) => void; + onForkWorkspace?: (workspaceId: string) => void; + onCompactWorkspace?: (workspaceId: string) => void; } const WorkspaceListItemInner: React.FC = ({ @@ -148,6 +128,8 @@ const WorkspaceListItemInner: React.FC = ({ onSelectWorkspace, onRemoveWorkspace, onToggleUnread, + onForkWorkspace, + onCompactWorkspace, }) => { // Destructure metadata for convenience const { id: workspaceId, name: workspaceName, namedWorkspacePath } = metadata; @@ -238,6 +220,42 @@ const WorkspaceListItemInner: React.FC = ({ return "Idle"; }, [isStreaming, streamingModel, isUnread, sidebarState.recencyTimestamp]); + // Kebab menu items + const kebabMenuItems: KebabMenuItem[] = useMemo(() => { + const items: KebabMenuItem[] = []; + + if (onForkWorkspace) { + items.push({ + label: "Fork", + emoji: "🔱", + onClick: () => onForkWorkspace(workspaceId), + tooltip: "Create a fork of this workspace", + }); + } + + if (onCompactWorkspace) { + items.push({ + label: "Compact", + emoji: "🗜️", + onClick: () => onCompactWorkspace(workspaceId), + tooltip: "Summarize conversation history", + }); + } + + items.push({ + label: "Delete", + emoji: "🗑️", + onClick: () => { + // Create a synthetic button element for the onRemoveWorkspace callback + const syntheticButton = document.createElement("button"); + void onRemoveWorkspace(workspaceId, syntheticButton); + }, + tooltip: "Remove workspace", + }); + + return items; + }, [workspaceId, onForkWorkspace, onCompactWorkspace, onRemoveWorkspace]); + return ( = ({ data-workspace-path={namedWorkspacePath} data-workspace-id={workspaceId} > - - { - e.stopPropagation(); - void onRemoveWorkspace(workspaceId, e.currentTarget); - }} - aria-label={`Remove workspace ${displayName}`} - data-workspace-id={workspaceId} - > - × - - - Remove workspace - - = ({ onClick={handleToggleUnread} title={statusTooltipTitle} /> + { + e.stopPropagation(); + }} + > + + {renameError && isEditing && {renameError}} diff --git a/src/utils/chatCommands.ts b/src/utils/chatCommands.ts index 9e19c6779..b7c5cf5b6 100644 --- a/src/utils/chatCommands.ts +++ b/src/utils/chatCommands.ts @@ -106,6 +106,17 @@ export function formatNewCommand( export { forkWorkspace, type ForkOptions, type ForkResult } from "./workspaceFork"; +/** + * Format /fork command string for display + */ +export function formatForkCommand(newName: string, startMessage?: string): string { + let cmd = `/fork ${newName}`; + if (startMessage) { + cmd += `\n${startMessage}`; + } + return cmd; +} + // ============================================================================ // Compaction // ============================================================================ @@ -194,20 +205,32 @@ export async function executeCompaction(options: CompactionOptions): Promise Date: Sat, 18 Oct 2025 21:47:52 -0500 Subject: [PATCH 2/8] =?UTF-8?q?=F0=9F=A4=96=20Extract=20duplicated=20style?= =?UTF-8?q?d=20components=20to=20Modal.tsx?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move FormGroup, ErrorMessage, HelpText to shared exports - Move CommandDisplay and CommandLabel to shared exports - Update ForkWorkspaceModal, CompactModal, NewWorkspaceModal to use shared components - Eliminates ~180 lines of duplication Generated with `cmux` --- src/components/CompactModal.tsx | 83 ++++----------------------- src/components/ForkWorkspaceModal.tsx | 73 ++++------------------- src/components/Modal.tsx | 75 ++++++++++++++++++++++++ src/components/NewWorkspaceModal.tsx | 82 ++++---------------------- 4 files changed, 108 insertions(+), 205 deletions(-) diff --git a/src/components/CompactModal.tsx b/src/components/CompactModal.tsx index f7df24231..ce9ffe67f 100644 --- a/src/components/CompactModal.tsx +++ b/src/components/CompactModal.tsx @@ -1,78 +1,17 @@ import React, { useEffect, useId, useState } from "react"; -import styled from "@emotion/styled"; -import { Modal, ModalInfo, ModalActions, CancelButton, PrimaryButton } from "./Modal"; +import { + Modal, + ModalInfo, + ModalActions, + CancelButton, + PrimaryButton, + FormGroup, + HelpText, + CommandDisplay, + CommandLabel, +} from "./Modal"; import { formatCompactCommand } from "@/utils/chatCommands"; -const FormGroup = styled.div` - margin-bottom: 20px; - - label { - display: block; - margin-bottom: 8px; - color: #ccc; - font-size: 14px; - } - - input, - select { - width: 100%; - padding: 8px 12px; - background: #2d2d2d; - border: 1px solid #444; - border-radius: 4px; - color: #fff; - font-size: 14px; - - &:focus { - outline: none; - border-color: #007acc; - } - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } - } - - select { - cursor: pointer; - - option { - background: #2d2d2d; - color: #fff; - } - } -`; - -const HelpText = styled.div` - color: #888; - font-size: 12px; - margin-top: 4px; -`; - -const CommandDisplay = styled.div` - margin-top: 20px; - padding: 12px; - background: #1e1e1e; - border: 1px solid #3e3e42; - border-radius: 4px; - font-family: "Menlo", "Monaco", "Courier New", monospace; - font-size: 13px; - color: #d4d4d4; - white-space: pre-wrap; - word-break: break-all; -`; - -const CommandLabel = styled.div` - font-size: 12px; - color: #888; - margin-bottom: 8px; - font-family: - system-ui, - -apple-system, - sans-serif; -`; - interface CompactModalProps { isOpen: boolean; onClose: () => void; diff --git a/src/components/ForkWorkspaceModal.tsx b/src/components/ForkWorkspaceModal.tsx index 5fb9616c6..aaaba67f5 100644 --- a/src/components/ForkWorkspaceModal.tsx +++ b/src/components/ForkWorkspaceModal.tsx @@ -1,68 +1,17 @@ import React, { useEffect, useId, useState } from "react"; -import styled from "@emotion/styled"; -import { Modal, ModalInfo, ModalActions, CancelButton, PrimaryButton } from "./Modal"; +import { + Modal, + ModalInfo, + ModalActions, + CancelButton, + PrimaryButton, + FormGroup, + ErrorMessage, + CommandDisplay, + CommandLabel, +} from "./Modal"; import { formatForkCommand } from "@/utils/chatCommands"; -const FormGroup = styled.div` - margin-bottom: 20px; - - label { - display: block; - margin-bottom: 8px; - color: #ccc; - font-size: 14px; - } - - input { - width: 100%; - padding: 8px 12px; - background: #2d2d2d; - border: 1px solid #444; - border-radius: 4px; - color: #fff; - font-size: 14px; - - &:focus { - outline: none; - border-color: #007acc; - } - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } - } -`; - -const ErrorMessage = styled.div` - color: #ff5555; - font-size: 13px; - margin-top: 6px; -`; - -const CommandDisplay = styled.div` - margin-top: 20px; - padding: 12px; - background: #1e1e1e; - border: 1px solid #3e3e42; - border-radius: 4px; - font-family: "Menlo", "Monaco", "Courier New", monospace; - font-size: 13px; - color: #d4d4d4; - white-space: pre-wrap; - word-break: break-all; -`; - -const CommandLabel = styled.div` - font-size: 12px; - color: #888; - margin-bottom: 8px; - font-family: - system-ui, - -apple-system, - sans-serif; -`; - interface ForkWorkspaceModalProps { isOpen: boolean; sourceWorkspaceName: string; diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index bc083f962..d1aefac10 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -161,6 +161,81 @@ export const DangerButton = styled(Button)` } `; +// Shared form components +export const FormGroup = styled.div` + margin-bottom: 20px; + + label { + display: block; + margin-bottom: 8px; + color: #ccc; + font-size: 14px; + } + + input, + select { + width: 100%; + padding: 8px 12px; + background: #2d2d2d; + border: 1px solid #444; + border-radius: 4px; + color: #fff; + font-size: 14px; + + &:focus { + outline: none; + border-color: #007acc; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } + + select { + cursor: pointer; + + option { + background: #2d2d2d; + color: #fff; + } + } +`; + +export const ErrorMessage = styled.div` + color: #ff5555; + font-size: 13px; + margin-top: 6px; +`; + +export const HelpText = styled.div` + color: #888; + font-size: 12px; + margin-top: 4px; +`; + +// Command display components (for showing equivalent slash commands) +export const CommandDisplay = styled.div` + margin-top: 20px; + padding: 12px; + background: #1e1e1e; + border: 1px solid #3e3e42; + border-radius: 4px; + font-family: "Menlo", "Monaco", "Courier New", monospace; + font-size: 13px; + color: #d4d4d4; + white-space: pre-wrap; + word-break: break-all; +`; + +export const CommandLabel = styled.div` + font-size: 12px; + color: #888; + margin-bottom: 8px; + font-family: system-ui, -apple-system, sans-serif; +`; + // Modal wrapper component interface ModalProps { isOpen: boolean; diff --git a/src/components/NewWorkspaceModal.tsx b/src/components/NewWorkspaceModal.tsx index 4ef7f894b..40a5de920 100644 --- a/src/components/NewWorkspaceModal.tsx +++ b/src/components/NewWorkspaceModal.tsx @@ -1,56 +1,19 @@ import React, { useEffect, useId, useState } from "react"; import styled from "@emotion/styled"; -import { Modal, ModalInfo, ModalActions, CancelButton, PrimaryButton } from "./Modal"; +import { + Modal, + ModalInfo, + ModalActions, + CancelButton, + PrimaryButton, + FormGroup, + ErrorMessage, + CommandDisplay, + CommandLabel, +} from "./Modal"; import { TooltipWrapper, Tooltip } from "./Tooltip"; import { formatNewCommand } from "@/utils/chatCommands"; -const FormGroup = styled.div` - margin-bottom: 20px; - - label { - display: block; - margin-bottom: 8px; - color: #ccc; - font-size: 14px; - } - - input, - select { - width: 100%; - padding: 8px 12px; - background: #2d2d2d; - border: 1px solid #444; - border-radius: 4px; - color: #fff; - font-size: 14px; - - &:focus { - outline: none; - border-color: #007acc; - } - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } - } - - select { - cursor: pointer; - - option { - background: #2d2d2d; - color: #fff; - } - } -`; - -const ErrorMessage = styled.div` - color: #ff5555; - font-size: 13px; - margin-top: 6px; -`; - const InfoCode = styled.code` display: block; word-break: break-all; @@ -62,29 +25,6 @@ const UnderlinedLabel = styled.span` cursor: help; `; -const CommandDisplay = styled.div` - margin-top: 20px; - padding: 12px; - background: #1e1e1e; - border: 1px solid #3e3e42; - border-radius: 4px; - font-family: "Menlo", "Monaco", "Courier New", monospace; - font-size: 13px; - color: #d4d4d4; - white-space: pre-wrap; - word-break: break-all; -`; - -const CommandLabel = styled.div` - font-size: 12px; - color: #888; - margin-bottom: 8px; - font-family: - system-ui, - -apple-system, - sans-serif; -`; - interface NewWorkspaceModalProps { isOpen: boolean; projectName: string; From ada4e735e14befd0a3327ad5b12d30ab7a69cb9b Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 21:50:17 -0500 Subject: [PATCH 3/8] =?UTF-8?q?=F0=9F=A4=96=20Fix=20Modal.tsx=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated with `cmux` --- src/components/Modal.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index d1aefac10..52ec4482b 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -233,7 +233,10 @@ export const CommandLabel = styled.div` font-size: 12px; color: #888; margin-bottom: 8px; - font-family: system-ui, -apple-system, sans-serif; + font-family: + system-ui, + -apple-system, + sans-serif; `; // Modal wrapper component From b4b2237187e389d1bca81ee03f6eaf6ba3563953 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 21:55:03 -0500 Subject: [PATCH 4/8] =?UTF-8?q?=F0=9F=A4=96=20Use=20shared=20CompactOption?= =?UTF-8?q?s=20and=20ForkOptions=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename internal execution types to avoid confusion - ForkOptions/CompactOptions are now user-facing types - ForkExecutionOptions/CompactExecutionOptions for internal use - Modals use options type directly as state - Eliminates intermediate object construction Generated with `cmux` --- src/components/CompactModal.tsx | 64 +++++++++++++------- src/components/ForkWorkspaceModal.tsx | 26 ++++---- src/components/ProjectSidebar.tsx | 19 ++++-- src/utils/chatCommands.ts | 85 +++++++++++++++++++++------ 4 files changed, 137 insertions(+), 57 deletions(-) diff --git a/src/components/CompactModal.tsx b/src/components/CompactModal.tsx index ce9ffe67f..d3f1f2c2e 100644 --- a/src/components/CompactModal.tsx +++ b/src/components/CompactModal.tsx @@ -10,25 +10,36 @@ import { CommandDisplay, CommandLabel, } from "./Modal"; -import { formatCompactCommand } from "@/utils/chatCommands"; +import { formatCompactCommand, type CompactOptions } from "@/utils/chatCommands"; interface CompactModalProps { isOpen: boolean; onClose: () => void; - onCompact: (maxOutputTokens?: number, model?: string) => Promise; + onCompact: (options: CompactOptions) => Promise; } const CompactModal: React.FC = ({ isOpen, onClose, onCompact }) => { - const [maxOutputTokens, setMaxOutputTokens] = useState(""); - const [model, setModel] = useState(""); + const [options, setOptions] = useState({}); + const [maxOutputTokensInput, setMaxOutputTokensInput] = useState(""); const [isLoading, setIsLoading] = useState(false); const infoId = useId(); + // Sync options with input fields + useEffect(() => { + setOptions({ + maxOutputTokens: maxOutputTokensInput.trim() + ? parseInt(maxOutputTokensInput.trim(), 10) + : undefined, + model: options.model?.trim() || undefined, + continueMessage: options.continueMessage?.trim() || undefined, + }); + }, [maxOutputTokensInput]); + // Reset form when modal opens useEffect(() => { if (isOpen) { - setMaxOutputTokens(""); - setModel(""); + setOptions({}); + setMaxOutputTokensInput(""); setIsLoading(false); } }, [isOpen]); @@ -45,12 +56,9 @@ const CompactModal: React.FC = ({ isOpen, onClose, onCompact setIsLoading(true); try { - const tokens = maxOutputTokens.trim() ? parseInt(maxOutputTokens.trim(), 10) : undefined; - const modelParam = model.trim() || undefined; - - await onCompact(tokens, modelParam); - setMaxOutputTokens(""); - setModel(""); + await onCompact(options); + setOptions({}); + setMaxOutputTokensInput(""); onClose(); } catch (err) { console.error("Compact failed:", err); @@ -60,9 +68,6 @@ const CompactModal: React.FC = ({ isOpen, onClose, onCompact } }; - const tokensValue = maxOutputTokens.trim() ? parseInt(maxOutputTokens.trim(), 10) : undefined; - const modelValue = model.trim() || undefined; - return ( = ({ isOpen, onClose, onCompact setMaxOutputTokens(event.target.value)} + value={maxOutputTokensInput} + onChange={(event) => setMaxOutputTokensInput(event.target.value)} disabled={isLoading} placeholder="e.g., 3000" min="100" @@ -94,14 +99,33 @@ const CompactModal: React.FC = ({ isOpen, onClose, onCompact setModel(event.target.value)} + value={options.model ?? ""} + onChange={(event) => + setOptions({ ...options, model: event.target.value || undefined }) + } disabled={isLoading} placeholder="e.g., claude-3-5-sonnet-20241022" /> Specify a model for compaction. Leave empty to use current model. + + + + setOptions({ ...options, continueMessage: event.target.value || undefined }) + } + disabled={isLoading} + placeholder="Message to send after compaction completes" + /> + + If provided, this message will be sent automatically after compaction finishes. + + +

Compaction will summarize your conversation history, allowing you to continue with a @@ -112,7 +136,7 @@ const CompactModal: React.FC = ({ isOpen, onClose, onCompact

Equivalent command: - {formatCompactCommand(tokensValue, modelValue)} + {formatCompactCommand(options)}
diff --git a/src/components/ForkWorkspaceModal.tsx b/src/components/ForkWorkspaceModal.tsx index aaaba67f5..04fe9cbd1 100644 --- a/src/components/ForkWorkspaceModal.tsx +++ b/src/components/ForkWorkspaceModal.tsx @@ -10,13 +10,13 @@ import { CommandDisplay, CommandLabel, } from "./Modal"; -import { formatForkCommand } from "@/utils/chatCommands"; +import { formatForkCommand, type ForkOptions } from "@/utils/chatCommands"; interface ForkWorkspaceModalProps { isOpen: boolean; sourceWorkspaceName: string; onClose: () => void; - onFork: (newName: string) => Promise; + onFork: (options: ForkOptions) => Promise; } const ForkWorkspaceModal: React.FC = ({ @@ -25,7 +25,7 @@ const ForkWorkspaceModal: React.FC = ({ onClose, onFork, }) => { - const [newName, setNewName] = useState(""); + const [options, setOptions] = useState({ newName: "" }); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); const infoId = useId(); @@ -33,7 +33,7 @@ const ForkWorkspaceModal: React.FC = ({ // Reset form when modal opens useEffect(() => { if (isOpen) { - setNewName(""); + setOptions({ newName: "" }); setError(null); setIsLoading(false); } @@ -48,7 +48,7 @@ const ForkWorkspaceModal: React.FC = ({ const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); - const trimmedName = newName.trim(); + const trimmedName = options.newName.trim(); if (!trimmedName) { setError("Workspace name cannot be empty"); return; @@ -58,8 +58,8 @@ const ForkWorkspaceModal: React.FC = ({ setError(null); try { - await onFork(trimmedName); - setNewName(""); + await onFork({ ...options, newName: trimmedName }); + setOptions({ newName: "" }); onClose(); } catch (err) { const message = err instanceof Error ? err.message : "Failed to fork workspace"; @@ -84,8 +84,8 @@ const ForkWorkspaceModal: React.FC = ({ setNewName(event.target.value)} + value={options.newName} + onChange={(event) => setOptions({ ...options, newName: event.target.value })} disabled={isLoading} placeholder="Enter new workspace name" required @@ -102,10 +102,12 @@ const ForkWorkspaceModal: React.FC = ({

- {newName.trim() && ( + {options.newName.trim() && (
Equivalent command: - {formatForkCommand(newName.trim())} + + {formatForkCommand({ ...options, newName: options.newName.trim() })} +
)} @@ -113,7 +115,7 @@ const ForkWorkspaceModal: React.FC = ({ Cancel - + {isLoading ? "Forking..." : "Fork Workspace"} diff --git a/src/components/ProjectSidebar.tsx b/src/components/ProjectSidebar.tsx index b3561f7b5..b2188221b 100644 --- a/src/components/ProjectSidebar.tsx +++ b/src/components/ProjectSidebar.tsx @@ -19,7 +19,12 @@ import { WorkspaceListItem, type WorkspaceSelection } from "./WorkspaceListItem" import { RenameProvider } from "@/contexts/WorkspaceRenameContext"; import ForkWorkspaceModal from "./ForkWorkspaceModal"; import CompactModal from "./CompactModal"; -import { forkWorkspace, executeCompaction } from "@/utils/chatCommands"; +import { + forkWorkspace, + executeCompaction, + type ForkOptions, + type CompactOptions, +} from "@/utils/chatCommands"; import { useSendMessageOptions } from "@/hooks/useSendMessageOptions"; // Re-export WorkspaceSelection for backwards compatibility @@ -679,14 +684,15 @@ const ProjectSidebarInner: React.FC = ({ setCompactModalState({ workspaceId }); }; - const handleForkSubmit = async (newName: string) => { + const handleForkSubmit = async (options: ForkOptions) => { if (!forkModalState) { return; } const result = await forkWorkspace({ sourceWorkspaceId: forkModalState.workspaceId, - newName, + newName: options.newName, + startMessage: options.startMessage, sendMessageOptions, }); @@ -695,15 +701,16 @@ const ProjectSidebarInner: React.FC = ({ } }; - const handleCompactSubmit = async (maxOutputTokens?: number, model?: string) => { + const handleCompactSubmit = async (options: CompactOptions) => { if (!compactModalState) { return; } const result = await executeCompaction({ workspaceId: compactModalState.workspaceId, - maxOutputTokens, - model, + maxOutputTokens: options.maxOutputTokens, + model: options.model, + continueMessage: options.continueMessage, sendMessageOptions, }); diff --git a/src/utils/chatCommands.ts b/src/utils/chatCommands.ts index b7c5cf5b6..acc5b8a72 100644 --- a/src/utils/chatCommands.ts +++ b/src/utils/chatCommands.ts @@ -101,18 +101,35 @@ export function formatNewCommand( } // ============================================================================ -// Workspace Forking (re-exported from workspaceFork for convenience) +// Workspace Forking // ============================================================================ -export { forkWorkspace, type ForkOptions, type ForkResult } from "./workspaceFork"; +export { forkWorkspace, type ForkResult } from "./workspaceFork"; +// Re-export internal type with different name to avoid confusion +export type { ForkOptions as ForkExecutionOptions } from "./workspaceFork"; + +/** + * User-facing fork options (modal/command inputs) + */ +export interface ForkOptions { + newName: string; + startMessage?: string; +} /** * Format /fork command string for display */ -export function formatForkCommand(newName: string, startMessage?: string): string { - let cmd = `/fork ${newName}`; - if (startMessage) { - cmd += `\n${startMessage}`; +export function formatForkCommand(options: ForkOptions): string; +export function formatForkCommand(newName: string, startMessage?: string): string; +export function formatForkCommand( + optionsOrName: ForkOptions | string, + startMessage?: string +): string { + const name = typeof optionsOrName === "string" ? optionsOrName : optionsOrName.newName; + const msg = typeof optionsOrName === "string" ? startMessage : optionsOrName.startMessage; + let cmd = `/fork ${name}`; + if (msg) { + cmd += `\n${msg}`; } return cmd; } @@ -121,7 +138,19 @@ export function formatForkCommand(newName: string, startMessage?: string): strin // Compaction // ============================================================================ -export interface CompactionOptions { +/** + * User-facing compaction options (modal/command inputs) + */ +export interface CompactOptions { + maxOutputTokens?: number; + model?: string; + continueMessage?: string; +} + +/** + * Internal execution options (includes workspace context) + */ +export interface CompactExecutionOptions { workspaceId: string; maxOutputTokens?: number; continueMessage?: string; @@ -139,7 +168,7 @@ export interface CompactionResult { * Prepare compaction message from options * Returns the actual message text (summarization request), metadata, and options */ -export function prepareCompactionMessage(options: CompactionOptions): { +export function prepareCompactionMessage(options: CompactExecutionOptions): { messageText: string; metadata: CmuxFrontendMetadata; sendOptions: SendMessageOptions; @@ -178,7 +207,9 @@ export function prepareCompactionMessage(options: CompactionOptions): { /** * Execute a compaction command */ -export async function executeCompaction(options: CompactionOptions): Promise { +export async function executeCompaction( + options: CompactExecutionOptions +): Promise { const { messageText, metadata, sendOptions } = prepareCompactionMessage(options); const result = await window.api.workspace.sendMessage(options.workspaceId, messageText, { @@ -205,30 +236,46 @@ export async function executeCompaction(options: CompactionOptions): Promise Date: Sat, 18 Oct 2025 21:56:01 -0500 Subject: [PATCH 5/8] =?UTF-8?q?=F0=9F=A4=96=20Remove=20unnecessary=20forma?= =?UTF-8?q?tCompactionCommand=20wrapper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deprecated function was just pointless indirection. Generated with `cmux` --- src/components/CompactModal.tsx | 4 +--- src/utils/chatCommands.ts | 18 ++++++------------ 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/src/components/CompactModal.tsx b/src/components/CompactModal.tsx index d3f1f2c2e..0762d207e 100644 --- a/src/components/CompactModal.tsx +++ b/src/components/CompactModal.tsx @@ -100,9 +100,7 @@ const CompactModal: React.FC = ({ isOpen, onClose, onCompact id="model" type="text" value={options.model ?? ""} - onChange={(event) => - setOptions({ ...options, model: event.target.value || undefined }) - } + onChange={(event) => setOptions({ ...options, model: event.target.value || undefined })} disabled={isLoading} placeholder="e.g., claude-3-5-sonnet-20241022" /> diff --git a/src/utils/chatCommands.ts b/src/utils/chatCommands.ts index acc5b8a72..03eefc0ad 100644 --- a/src/utils/chatCommands.ts +++ b/src/utils/chatCommands.ts @@ -194,7 +194,11 @@ export function prepareCompactionMessage(options: CompactExecutionOptions): { const metadata: CmuxFrontendMetadata = { type: "compaction-request", - rawCommand: formatCompactionCommand(options), + rawCommand: formatCompactCommand({ + maxOutputTokens: options.maxOutputTokens, + model: options.model, + continueMessage: options.continueMessage, + }), parsed: compactData, }; @@ -266,17 +270,7 @@ export function formatCompactCommand( return cmd; } -/** - * Format compaction command string for display (accepts CompactExecutionOptions) - * @deprecated Use formatCompactCommand with CompactOptions instead - */ -function formatCompactionCommand(options: CompactExecutionOptions): string { - return formatCompactCommand({ - maxOutputTokens: options.maxOutputTokens, - model: options.model, - continueMessage: options.continueMessage, - }); -} + // ============================================================================ // Command Handler Types From 6c8855b3d6de6c0a5dbd73b30e37ba620305a0da Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 22:01:10 -0500 Subject: [PATCH 6/8] Simplify format functions - remove overloaded signatures - Remove formatForkCommand overloads (only ForkOptions signature needed) - Remove formatCompactCommand overloads (only CompactOptions signature needed) - Fix lint issues: - Remove unused Tooltip imports in WorkspaceListItem - Use nullish coalescing (??) instead of logical OR (||) in CompactModal - Fix missing dependencies in useEffect hook All call sites already use the options object signature. --- src/components/CompactModal.tsx | 6 ++-- src/components/WorkspaceListItem.tsx | 1 - src/utils/chatCommands.ts | 45 +++++++--------------------- 3 files changed, 14 insertions(+), 38 deletions(-) diff --git a/src/components/CompactModal.tsx b/src/components/CompactModal.tsx index 0762d207e..7bfc54135 100644 --- a/src/components/CompactModal.tsx +++ b/src/components/CompactModal.tsx @@ -30,10 +30,10 @@ const CompactModal: React.FC = ({ isOpen, onClose, onCompact maxOutputTokens: maxOutputTokensInput.trim() ? parseInt(maxOutputTokensInput.trim(), 10) : undefined, - model: options.model?.trim() || undefined, - continueMessage: options.continueMessage?.trim() || undefined, + model: options.model?.trim() ?? undefined, + continueMessage: options.continueMessage?.trim() ?? undefined, }); - }, [maxOutputTokensInput]); + }, [maxOutputTokensInput, options.model, options.continueMessage]); // Reset form when modal opens useEffect(() => { diff --git a/src/components/WorkspaceListItem.tsx b/src/components/WorkspaceListItem.tsx index 61bc5f9a4..70562e7c2 100644 --- a/src/components/WorkspaceListItem.tsx +++ b/src/components/WorkspaceListItem.tsx @@ -5,7 +5,6 @@ import type { FrontendWorkspaceMetadata } from "@/types/workspace"; import { useWorkspaceSidebarState } from "@/stores/WorkspaceStore"; import { useGitStatus } from "@/stores/GitStatusStore"; import { formatRelativeTime } from "@/utils/ui/dateTime"; -import { TooltipWrapper, Tooltip } from "./Tooltip"; import { GitStatusIndicator } from "./GitStatusIndicator"; import { ModelDisplay } from "./Messages/ModelDisplay"; import { StatusIndicator } from "./StatusIndicator"; diff --git a/src/utils/chatCommands.ts b/src/utils/chatCommands.ts index 03eefc0ad..f91b42e17 100644 --- a/src/utils/chatCommands.ts +++ b/src/utils/chatCommands.ts @@ -119,17 +119,10 @@ export interface ForkOptions { /** * Format /fork command string for display */ -export function formatForkCommand(options: ForkOptions): string; -export function formatForkCommand(newName: string, startMessage?: string): string; -export function formatForkCommand( - optionsOrName: ForkOptions | string, - startMessage?: string -): string { - const name = typeof optionsOrName === "string" ? optionsOrName : optionsOrName.newName; - const msg = typeof optionsOrName === "string" ? startMessage : optionsOrName.startMessage; - let cmd = `/fork ${name}`; - if (msg) { - cmd += `\n${msg}`; +export function formatForkCommand(options: ForkOptions): string { + let cmd = `/fork ${options.newName}`; + if (options.startMessage) { + cmd += `\n${options.startMessage}`; } return cmd; } @@ -240,32 +233,16 @@ export async function executeCompaction( /** * Format compaction command string for display */ -export function formatCompactCommand(options: CompactOptions): string; -export function formatCompactCommand( - maxOutputTokens?: number, - model?: string, - continueMessage?: string -): string; -export function formatCompactCommand( - optionsOrTokens?: CompactOptions | number, - model?: string, - continueMessage?: string -): string { - const tokens = - typeof optionsOrTokens === "object" ? optionsOrTokens.maxOutputTokens : optionsOrTokens; - const mdl = typeof optionsOrTokens === "object" ? optionsOrTokens.model : model; - const msg = - typeof optionsOrTokens === "object" ? optionsOrTokens.continueMessage : continueMessage; - +export function formatCompactCommand(options: CompactOptions): string { let cmd = "/compact"; - if (tokens) { - cmd += ` -t ${tokens}`; + if (options.maxOutputTokens) { + cmd += ` -t ${options.maxOutputTokens}`; } - if (mdl) { - cmd += ` -m ${mdl}`; + if (options.model) { + cmd += ` -m ${options.model}`; } - if (msg) { - cmd += `\n${msg}`; + if (options.continueMessage) { + cmd += `\n${options.continueMessage}`; } return cmd; } From d6174fca6f1dd7ae6df849df833f908ea2b32fd9 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 22:05:04 -0500 Subject: [PATCH 7/8] Make execution options extend base options - CompactExecutionOptions now extends CompactOptions - ForkOptions (execution) now extends UserForkOptions (base) - Moved UserForkOptions definition to workspaceFork.ts (source of truth) - Pass options object directly to formatCompactCommand (no reconstruction) - Eliminates field duplication and ensures type consistency --- src/utils/chatCommands.ts | 28 ++++++++-------------------- src/utils/workspaceFork.ts | 13 +++++++++++-- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/src/utils/chatCommands.ts b/src/utils/chatCommands.ts index f91b42e17..1587165e0 100644 --- a/src/utils/chatCommands.ts +++ b/src/utils/chatCommands.ts @@ -105,21 +105,16 @@ export function formatNewCommand( // ============================================================================ export { forkWorkspace, type ForkResult } from "./workspaceFork"; -// Re-export internal type with different name to avoid confusion -export type { ForkOptions as ForkExecutionOptions } from "./workspaceFork"; - -/** - * User-facing fork options (modal/command inputs) - */ -export interface ForkOptions { - newName: string; - startMessage?: string; -} +export type { + ForkOptions as ForkExecutionOptions, + UserForkOptions as ForkOptions, +} from "./workspaceFork"; +import type { UserForkOptions } from "./workspaceFork"; /** * Format /fork command string for display */ -export function formatForkCommand(options: ForkOptions): string { +export function formatForkCommand(options: UserForkOptions): string { let cmd = `/fork ${options.newName}`; if (options.startMessage) { cmd += `\n${options.startMessage}`; @@ -143,11 +138,8 @@ export interface CompactOptions { /** * Internal execution options (includes workspace context) */ -export interface CompactExecutionOptions { +export interface CompactExecutionOptions extends CompactOptions { workspaceId: string; - maxOutputTokens?: number; - continueMessage?: string; - model?: string; sendMessageOptions: SendMessageOptions; editMessageId?: string; } @@ -187,11 +179,7 @@ export function prepareCompactionMessage(options: CompactExecutionOptions): { const metadata: CmuxFrontendMetadata = { type: "compaction-request", - rawCommand: formatCompactCommand({ - maxOutputTokens: options.maxOutputTokens, - model: options.model, - continueMessage: options.continueMessage, - }), + rawCommand: formatCompactCommand(options), parsed: compactData, }; diff --git a/src/utils/workspaceFork.ts b/src/utils/workspaceFork.ts index fb8dc5fd9..e0b00fbe8 100644 --- a/src/utils/workspaceFork.ts +++ b/src/utils/workspaceFork.ts @@ -8,10 +8,19 @@ import type { FrontendWorkspaceMetadata } from "@/types/workspace"; import { CUSTOM_EVENTS } from "@/constants/events"; import { copyWorkspaceStorage } from "@/constants/storage"; -export interface ForkOptions { - sourceWorkspaceId: string; +/** + * User-facing fork options (modal/command inputs) + */ +export interface UserForkOptions { newName: string; startMessage?: string; +} + +/** + * Internal execution options (includes workspace context) + */ +export interface ForkOptions extends UserForkOptions { + sourceWorkspaceId: string; sendMessageOptions?: SendMessageOptions; } From 91dd74f9d8c197d39bd7c7b5b2e4e4c3243157c9 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 19 Oct 2025 10:26:48 -0500 Subject: [PATCH 8/8] Use shared compact model defaults via useCompactOptions hook - Created useCompactOptions hook to centralize default model resolution - Both CompactModal and /compact command now use same model logic - Hook initializes with preferred compaction model from localStorage - Provides resetOptions() to reset form while preserving model preference - Simplifies useEffect dependencies in CompactModal Ensures consistent treatment of model defaults across modal and slash command paths. --- src/components/CompactModal.tsx | 18 +++++++++--------- src/hooks/useCompactOptions.ts | 24 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 9 deletions(-) create mode 100644 src/hooks/useCompactOptions.ts diff --git a/src/components/CompactModal.tsx b/src/components/CompactModal.tsx index 7bfc54135..847918ddc 100644 --- a/src/components/CompactModal.tsx +++ b/src/components/CompactModal.tsx @@ -11,6 +11,7 @@ import { CommandLabel, } from "./Modal"; import { formatCompactCommand, type CompactOptions } from "@/utils/chatCommands"; +import { useCompactOptions } from "@/hooks/useCompactOptions"; interface CompactModalProps { isOpen: boolean; @@ -19,30 +20,29 @@ interface CompactModalProps { } const CompactModal: React.FC = ({ isOpen, onClose, onCompact }) => { - const [options, setOptions] = useState({}); + const { options, setOptions, resetOptions } = useCompactOptions(); const [maxOutputTokensInput, setMaxOutputTokensInput] = useState(""); const [isLoading, setIsLoading] = useState(false); const infoId = useId(); - // Sync options with input fields + // Sync maxOutputTokens from input field to options useEffect(() => { - setOptions({ + setOptions((prev) => ({ + ...prev, maxOutputTokens: maxOutputTokensInput.trim() ? parseInt(maxOutputTokensInput.trim(), 10) : undefined, - model: options.model?.trim() ?? undefined, - continueMessage: options.continueMessage?.trim() ?? undefined, - }); - }, [maxOutputTokensInput, options.model, options.continueMessage]); + })); + }, [maxOutputTokensInput, setOptions]); // Reset form when modal opens useEffect(() => { if (isOpen) { - setOptions({}); + resetOptions(); setMaxOutputTokensInput(""); setIsLoading(false); } - }, [isOpen]); + }, [isOpen, resetOptions]); const handleCancel = () => { if (!isLoading) { diff --git a/src/hooks/useCompactOptions.ts b/src/hooks/useCompactOptions.ts new file mode 100644 index 000000000..061a8d55c --- /dev/null +++ b/src/hooks/useCompactOptions.ts @@ -0,0 +1,24 @@ +/** + * Hook to manage compact options with consistent defaults + * Ensures both modal and slash command paths use same model resolution + */ + +import { useState, useCallback } from "react"; +import type { CompactOptions } from "@/utils/chatCommands"; +import { resolveCompactionModel } from "@/utils/messages/compactionModelPreference"; + +export function useCompactOptions() { + const [options, setOptions] = useState(() => { + // Initialize with preferred compaction model + const preferredModel = resolveCompactionModel(undefined); + return preferredModel ? { model: preferredModel } : {}; + }); + + // Reset to defaults (preserving preferred model) + const resetOptions = useCallback(() => { + const preferredModel = resolveCompactionModel(undefined); + setOptions(preferredModel ? { model: preferredModel } : {}); + }, []); + + return { options, setOptions, resetOptions }; +}