Skip to content
153 changes: 153 additions & 0 deletions src/components/CompactModal.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
}

const CompactModal: React.FC<CompactModalProps> = ({ isOpen, onClose, onCompact }) => {
const { options, setOptions, resetOptions } = useCompactOptions();
const [maxOutputTokensInput, setMaxOutputTokensInput] = useState<string>("");
const [isLoading, setIsLoading] = useState<boolean>(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 (
<Modal
isOpen={isOpen}
title="Compact Conversation"
subtitle="Summarize conversation history into a compact form"
onClose={handleCancel}
isLoading={isLoading}
describedById={infoId}
>
<form onSubmit={(event) => void handleSubmit(event)}>
<FormGroup>
<label htmlFor="maxOutputTokens">Max Output Tokens (optional):</label>
<input
id="maxOutputTokens"
type="number"
value={maxOutputTokensInput}
onChange={(event) => setMaxOutputTokensInput(event.target.value)}
disabled={isLoading}
placeholder="e.g., 3000"
min="100"
/>
<HelpText>
Controls the length of the summary. Leave empty for default (~2000 words).
</HelpText>
</FormGroup>

<FormGroup>
<label htmlFor="model">Model (optional):</label>
<input
id="model"
type="text"
value={options.model ?? ""}
onChange={(event) => setOptions({ ...options, model: event.target.value || undefined })}
disabled={isLoading}
placeholder="e.g., claude-3-5-sonnet-20241022"
/>
<HelpText>Specify a model for compaction. Leave empty to use current model.</HelpText>
</FormGroup>

<FormGroup>
<label htmlFor="continueMessage">Continue Message (optional):</label>
<input
id="continueMessage"
type="text"
value={options.continueMessage ?? ""}
onChange={(event) =>
setOptions({ ...options, continueMessage: event.target.value || undefined })
}
disabled={isLoading}
placeholder="Message to send after compaction completes"
/>
<HelpText>
If provided, this message will be sent automatically after compaction finishes.
</HelpText>
</FormGroup>

<ModalInfo id={infoId}>
<p>
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.
</p>
</ModalInfo>

<div>
<CommandLabel>Equivalent command:</CommandLabel>
<CommandDisplay>{formatCompactCommand(options)}</CommandDisplay>
</div>

<ModalActions>
<CancelButton type="button" onClick={handleCancel} disabled={isLoading}>
Cancel
</CancelButton>
<PrimaryButton type="submit" disabled={isLoading}>
{isLoading ? "Compacting..." : "Start Compaction"}
</PrimaryButton>
</ModalActions>
</form>
</Modal>
);
};

export default CompactModal;
127 changes: 127 additions & 0 deletions src/components/ForkWorkspaceModal.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
}

const ForkWorkspaceModal: React.FC<ForkWorkspaceModalProps> = ({
isOpen,
sourceWorkspaceName,
onClose,
onFork,
}) => {
const [options, setOptions] = useState<ForkOptions>({ newName: "" });
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(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 (
<Modal
isOpen={isOpen}
title="Fork Workspace"
subtitle={`Create a fork of ${sourceWorkspaceName}`}
onClose={handleCancel}
isLoading={isLoading}
describedById={infoId}
>
<form onSubmit={(event) => void handleSubmit(event)}>
<FormGroup>
<label htmlFor="newName">New Workspace Name:</label>
<input
id="newName"
type="text"
value={options.newName}
onChange={(event) => setOptions({ ...options, newName: event.target.value })}
disabled={isLoading}
placeholder="Enter new workspace name"
required
aria-required="true"
autoFocus
/>
{error && <ErrorMessage>{error}</ErrorMessage>}
</FormGroup>

<ModalInfo id={infoId}>
<p>
This will create a new git branch and worktree from the current workspace state,
preserving all uncommitted changes.
</p>
</ModalInfo>

{options.newName.trim() && (
<div>
<CommandLabel>Equivalent command:</CommandLabel>
<CommandDisplay>
{formatForkCommand({ ...options, newName: options.newName.trim() })}
</CommandDisplay>
</div>
)}

<ModalActions>
<CancelButton type="button" onClick={handleCancel} disabled={isLoading}>
Cancel
</CancelButton>
<PrimaryButton type="submit" disabled={isLoading || options.newName.trim().length === 0}>
{isLoading ? "Forking..." : "Fork Workspace"}
</PrimaryButton>
</ModalActions>
</form>
</Modal>
);
};

export default ForkWorkspaceModal;
78 changes: 78 additions & 0 deletions src/components/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading