diff --git a/src/components/CompactModal.tsx b/src/components/CompactModal.tsx new file mode 100644 index 000000000..847918ddc --- /dev/null +++ b/src/components/CompactModal.tsx @@ -0,0 +1,153 @@ +import React, { useEffect, useId, useState } from "react"; +import { + Modal, + ModalInfo, + ModalActions, + CancelButton, + PrimaryButton, + FormGroup, + HelpText, + CommandDisplay, + CommandLabel, +} from "./Modal"; +import { formatCompactCommand, type CompactOptions } from "@/utils/chatCommands"; +import { useCompactOptions } from "@/hooks/useCompactOptions"; + +interface CompactModalProps { + isOpen: boolean; + onClose: () => void; + onCompact: (options: CompactOptions) => Promise; +} + +const CompactModal: React.FC = ({ isOpen, onClose, onCompact }) => { + const { options, setOptions, resetOptions } = useCompactOptions(); + const [maxOutputTokensInput, setMaxOutputTokensInput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const infoId = useId(); + + // Sync maxOutputTokens from input field to options + useEffect(() => { + setOptions((prev) => ({ + ...prev, + maxOutputTokens: maxOutputTokensInput.trim() + ? parseInt(maxOutputTokensInput.trim(), 10) + : undefined, + })); + }, [maxOutputTokensInput, setOptions]); + + // Reset form when modal opens + useEffect(() => { + if (isOpen) { + resetOptions(); + setMaxOutputTokensInput(""); + setIsLoading(false); + } + }, [isOpen, resetOptions]); + + const handleCancel = () => { + if (!isLoading) { + onClose(); + } + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + setIsLoading(true); + + try { + await onCompact(options); + setOptions({}); + setMaxOutputTokensInput(""); + onClose(); + } catch (err) { + console.error("Compact failed:", err); + // Error handling is done by the parent component + } finally { + setIsLoading(false); + } + }; + + return ( + +
void handleSubmit(event)}> + + + setMaxOutputTokensInput(event.target.value)} + disabled={isLoading} + placeholder="e.g., 3000" + min="100" + /> + + Controls the length of the summary. Leave empty for default (~2000 words). + + + + + + 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 + shorter context window. The AI will create a compact version that preserves important + information for future interactions. +

+
+ +
+ Equivalent command: + {formatCompactCommand(options)} +
+ + + + 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..04fe9cbd1 --- /dev/null +++ b/src/components/ForkWorkspaceModal.tsx @@ -0,0 +1,127 @@ +import React, { useEffect, useId, useState } from "react"; +import { + Modal, + ModalInfo, + ModalActions, + CancelButton, + PrimaryButton, + FormGroup, + ErrorMessage, + CommandDisplay, + CommandLabel, +} from "./Modal"; +import { formatForkCommand, type ForkOptions } from "@/utils/chatCommands"; + +interface ForkWorkspaceModalProps { + isOpen: boolean; + sourceWorkspaceName: string; + onClose: () => void; + onFork: (options: ForkOptions) => Promise; +} + +const ForkWorkspaceModal: React.FC = ({ + isOpen, + sourceWorkspaceName, + onClose, + onFork, +}) => { + const [options, setOptions] = useState({ newName: "" }); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const infoId = useId(); + + // Reset form when modal opens + useEffect(() => { + if (isOpen) { + setOptions({ newName: "" }); + setError(null); + setIsLoading(false); + } + }, [isOpen]); + + const handleCancel = () => { + if (!isLoading) { + onClose(); + } + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + const trimmedName = options.newName.trim(); + if (!trimmedName) { + setError("Workspace name cannot be empty"); + return; + } + + setIsLoading(true); + setError(null); + + try { + await onFork({ ...options, newName: trimmedName }); + setOptions({ newName: "" }); + onClose(); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to fork workspace"; + setError(message); + } finally { + setIsLoading(false); + } + }; + + return ( + +
void handleSubmit(event)}> + + + setOptions({ ...options, newName: 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. +

+
+ + {options.newName.trim() && ( +
+ Equivalent command: + + {formatForkCommand({ ...options, newName: options.newName.trim() })} + +
+ )} + + + + Cancel + + + {isLoading ? "Forking..." : "Fork Workspace"} + + +
+
+ ); +}; + +export default ForkWorkspaceModal; diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index bc083f962..52ec4482b 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -161,6 +161,84 @@ 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; diff --git a/src/components/ProjectSidebar.tsx b/src/components/ProjectSidebar.tsx index f4ded754f..b2188221b 100644 --- a/src/components/ProjectSidebar.tsx +++ b/src/components/ProjectSidebar.tsx @@ -17,6 +17,15 @@ 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, + type ForkOptions, + type CompactOptions, +} from "@/utils/chatCommands"; +import { useSendMessageOptions } from "@/hooks/useSendMessageOptions"; // Re-export WorkspaceSelection for backwards compatibility export type { WorkspaceSelection } from "./WorkspaceListItem"; @@ -544,6 +553,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 +664,61 @@ 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 (options: ForkOptions) => { + if (!forkModalState) { + return; + } + + const result = await forkWorkspace({ + sourceWorkspaceId: forkModalState.workspaceId, + newName: options.newName, + startMessage: options.startMessage, + sendMessageOptions, + }); + + if (!result.success) { + throw new Error(result.error ?? "Failed to fork workspace"); + } + }; + + const handleCompactSubmit = async (options: CompactOptions) => { + if (!compactModalState) { + return; + } + + const result = await executeCompaction({ + workspaceId: compactModalState.workspaceId, + maxOutputTokens: options.maxOutputTokens, + model: options.model, + continueMessage: options.continueMessage, + 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 +915,8 @@ const ProjectSidebarInner: React.FC = ({ onSelectWorkspace={onSelectWorkspace} onRemoveWorkspace={handleRemoveWorkspace} onToggleUnread={_onToggleUnread} + onForkWorkspace={handleForkWorkspace} + onCompactWorkspace={handleCompactWorkspace} /> ); })} @@ -877,6 +955,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..70562e7c2 100644 --- a/src/components/WorkspaceListItem.tsx +++ b/src/components/WorkspaceListItem.tsx @@ -5,11 +5,11 @@ 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"; import { useRename } from "@/contexts/WorkspaceRenameContext"; +import { KebabMenu, type KebabMenuItem } from "./KebabMenu"; // Styled Components const WorkspaceStatusIndicator = styled(StatusIndicator)` @@ -17,10 +17,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 +37,6 @@ const WorkspaceItem = styled.div<{ selected?: boolean }>` &:hover { background: #2a2a2b; - - button { - opacity: 1; - } } `; @@ -93,31 +89,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 +102,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 +114,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 +127,8 @@ const WorkspaceListItemInner: React.FC = ({ onSelectWorkspace, onRemoveWorkspace, onToggleUnread, + onForkWorkspace, + onCompactWorkspace, }) => { // Destructure metadata for convenience const { id: workspaceId, name: workspaceName, namedWorkspacePath } = metadata; @@ -238,6 +219,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/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 }; +} diff --git a/src/utils/chatCommands.ts b/src/utils/chatCommands.ts index 9e19c6779..1587165e0 100644 --- a/src/utils/chatCommands.ts +++ b/src/utils/chatCommands.ts @@ -101,20 +101,45 @@ 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"; +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: UserForkOptions): string { + let cmd = `/fork ${options.newName}`; + if (options.startMessage) { + cmd += `\n${options.startMessage}`; + } + return cmd; +} // ============================================================================ // Compaction // ============================================================================ -export interface CompactionOptions { - workspaceId: string; +/** + * User-facing compaction options (modal/command inputs) + */ +export interface CompactOptions { maxOutputTokens?: number; - continueMessage?: string; model?: string; + continueMessage?: string; +} + +/** + * Internal execution options (includes workspace context) + */ +export interface CompactExecutionOptions extends CompactOptions { + workspaceId: string; sendMessageOptions: SendMessageOptions; editMessageId?: string; } @@ -128,7 +153,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; @@ -154,7 +179,7 @@ export function prepareCompactionMessage(options: CompactionOptions): { const metadata: CmuxFrontendMetadata = { type: "compaction-request", - rawCommand: formatCompactionCommand(options), + rawCommand: formatCompactCommand(options), parsed: compactData, }; @@ -167,7 +192,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, { @@ -194,7 +221,7 @@ export async function executeCompaction(options: CompactionOptions): Promise