Skip to content

Commit 2cea8a7

Browse files
committed
🤖 Add workspace kebab menu with Fork and Compact modals
- 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`
1 parent 979de51 commit 2cea8a7

File tree

5 files changed

+537
-50
lines changed

5 files changed

+537
-50
lines changed

src/components/CompactModal.tsx

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import React, { useEffect, useId, useState } from "react";
2+
import styled from "@emotion/styled";
3+
import { Modal, ModalInfo, ModalActions, CancelButton, PrimaryButton } from "./Modal";
4+
import { formatCompactCommand } from "@/utils/chatCommands";
5+
6+
const FormGroup = styled.div`
7+
margin-bottom: 20px;
8+
9+
label {
10+
display: block;
11+
margin-bottom: 8px;
12+
color: #ccc;
13+
font-size: 14px;
14+
}
15+
16+
input,
17+
select {
18+
width: 100%;
19+
padding: 8px 12px;
20+
background: #2d2d2d;
21+
border: 1px solid #444;
22+
border-radius: 4px;
23+
color: #fff;
24+
font-size: 14px;
25+
26+
&:focus {
27+
outline: none;
28+
border-color: #007acc;
29+
}
30+
31+
&:disabled {
32+
opacity: 0.6;
33+
cursor: not-allowed;
34+
}
35+
}
36+
37+
select {
38+
cursor: pointer;
39+
40+
option {
41+
background: #2d2d2d;
42+
color: #fff;
43+
}
44+
}
45+
`;
46+
47+
const HelpText = styled.div`
48+
color: #888;
49+
font-size: 12px;
50+
margin-top: 4px;
51+
`;
52+
53+
const CommandDisplay = styled.div`
54+
margin-top: 20px;
55+
padding: 12px;
56+
background: #1e1e1e;
57+
border: 1px solid #3e3e42;
58+
border-radius: 4px;
59+
font-family: "Menlo", "Monaco", "Courier New", monospace;
60+
font-size: 13px;
61+
color: #d4d4d4;
62+
white-space: pre-wrap;
63+
word-break: break-all;
64+
`;
65+
66+
const CommandLabel = styled.div`
67+
font-size: 12px;
68+
color: #888;
69+
margin-bottom: 8px;
70+
font-family:
71+
system-ui,
72+
-apple-system,
73+
sans-serif;
74+
`;
75+
76+
interface CompactModalProps {
77+
isOpen: boolean;
78+
onClose: () => void;
79+
onCompact: (maxOutputTokens?: number, model?: string) => Promise<void>;
80+
}
81+
82+
const CompactModal: React.FC<CompactModalProps> = ({ isOpen, onClose, onCompact }) => {
83+
const [maxOutputTokens, setMaxOutputTokens] = useState<string>("");
84+
const [model, setModel] = useState<string>("");
85+
const [isLoading, setIsLoading] = useState<boolean>(false);
86+
const infoId = useId();
87+
88+
// Reset form when modal opens
89+
useEffect(() => {
90+
if (isOpen) {
91+
setMaxOutputTokens("");
92+
setModel("");
93+
setIsLoading(false);
94+
}
95+
}, [isOpen]);
96+
97+
const handleCancel = () => {
98+
if (!isLoading) {
99+
onClose();
100+
}
101+
};
102+
103+
const handleSubmit = async (event: React.FormEvent) => {
104+
event.preventDefault();
105+
106+
setIsLoading(true);
107+
108+
try {
109+
const tokens = maxOutputTokens.trim() ? parseInt(maxOutputTokens.trim(), 10) : undefined;
110+
const modelParam = model.trim() || undefined;
111+
112+
await onCompact(tokens, modelParam);
113+
setMaxOutputTokens("");
114+
setModel("");
115+
onClose();
116+
} catch (err) {
117+
console.error("Compact failed:", err);
118+
// Error handling is done by the parent component
119+
} finally {
120+
setIsLoading(false);
121+
}
122+
};
123+
124+
const tokensValue = maxOutputTokens.trim() ? parseInt(maxOutputTokens.trim(), 10) : undefined;
125+
const modelValue = model.trim() || undefined;
126+
127+
return (
128+
<Modal
129+
isOpen={isOpen}
130+
title="Compact Conversation"
131+
subtitle="Summarize conversation history into a compact form"
132+
onClose={handleCancel}
133+
isLoading={isLoading}
134+
describedById={infoId}
135+
>
136+
<form onSubmit={(event) => void handleSubmit(event)}>
137+
<FormGroup>
138+
<label htmlFor="maxOutputTokens">Max Output Tokens (optional):</label>
139+
<input
140+
id="maxOutputTokens"
141+
type="number"
142+
value={maxOutputTokens}
143+
onChange={(event) => setMaxOutputTokens(event.target.value)}
144+
disabled={isLoading}
145+
placeholder="e.g., 3000"
146+
min="100"
147+
/>
148+
<HelpText>
149+
Controls the length of the summary. Leave empty for default (~2000 words).
150+
</HelpText>
151+
</FormGroup>
152+
153+
<FormGroup>
154+
<label htmlFor="model">Model (optional):</label>
155+
<input
156+
id="model"
157+
type="text"
158+
value={model}
159+
onChange={(event) => setModel(event.target.value)}
160+
disabled={isLoading}
161+
placeholder="e.g., claude-3-5-sonnet-20241022"
162+
/>
163+
<HelpText>Specify a model for compaction. Leave empty to use current model.</HelpText>
164+
</FormGroup>
165+
166+
<ModalInfo id={infoId}>
167+
<p>
168+
Compaction will summarize your conversation history, allowing you to continue with a
169+
shorter context window. The AI will create a compact version that preserves important
170+
information for future interactions.
171+
</p>
172+
</ModalInfo>
173+
174+
<div>
175+
<CommandLabel>Equivalent command:</CommandLabel>
176+
<CommandDisplay>{formatCompactCommand(tokensValue, modelValue)}</CommandDisplay>
177+
</div>
178+
179+
<ModalActions>
180+
<CancelButton type="button" onClick={handleCancel} disabled={isLoading}>
181+
Cancel
182+
</CancelButton>
183+
<PrimaryButton type="submit" disabled={isLoading}>
184+
{isLoading ? "Compacting..." : "Start Compaction"}
185+
</PrimaryButton>
186+
</ModalActions>
187+
</form>
188+
</Modal>
189+
);
190+
};
191+
192+
export default CompactModal;
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import React, { useEffect, useId, useState } from "react";
2+
import styled from "@emotion/styled";
3+
import { Modal, ModalInfo, ModalActions, CancelButton, PrimaryButton } from "./Modal";
4+
import { formatForkCommand } from "@/utils/chatCommands";
5+
6+
const FormGroup = styled.div`
7+
margin-bottom: 20px;
8+
9+
label {
10+
display: block;
11+
margin-bottom: 8px;
12+
color: #ccc;
13+
font-size: 14px;
14+
}
15+
16+
input {
17+
width: 100%;
18+
padding: 8px 12px;
19+
background: #2d2d2d;
20+
border: 1px solid #444;
21+
border-radius: 4px;
22+
color: #fff;
23+
font-size: 14px;
24+
25+
&:focus {
26+
outline: none;
27+
border-color: #007acc;
28+
}
29+
30+
&:disabled {
31+
opacity: 0.6;
32+
cursor: not-allowed;
33+
}
34+
}
35+
`;
36+
37+
const ErrorMessage = styled.div`
38+
color: #ff5555;
39+
font-size: 13px;
40+
margin-top: 6px;
41+
`;
42+
43+
const CommandDisplay = styled.div`
44+
margin-top: 20px;
45+
padding: 12px;
46+
background: #1e1e1e;
47+
border: 1px solid #3e3e42;
48+
border-radius: 4px;
49+
font-family: "Menlo", "Monaco", "Courier New", monospace;
50+
font-size: 13px;
51+
color: #d4d4d4;
52+
white-space: pre-wrap;
53+
word-break: break-all;
54+
`;
55+
56+
const CommandLabel = styled.div`
57+
font-size: 12px;
58+
color: #888;
59+
margin-bottom: 8px;
60+
font-family:
61+
system-ui,
62+
-apple-system,
63+
sans-serif;
64+
`;
65+
66+
interface ForkWorkspaceModalProps {
67+
isOpen: boolean;
68+
sourceWorkspaceName: string;
69+
onClose: () => void;
70+
onFork: (newName: string) => Promise<void>;
71+
}
72+
73+
const ForkWorkspaceModal: React.FC<ForkWorkspaceModalProps> = ({
74+
isOpen,
75+
sourceWorkspaceName,
76+
onClose,
77+
onFork,
78+
}) => {
79+
const [newName, setNewName] = useState<string>("");
80+
const [error, setError] = useState<string | null>(null);
81+
const [isLoading, setIsLoading] = useState<boolean>(false);
82+
const infoId = useId();
83+
84+
// Reset form when modal opens
85+
useEffect(() => {
86+
if (isOpen) {
87+
setNewName("");
88+
setError(null);
89+
setIsLoading(false);
90+
}
91+
}, [isOpen]);
92+
93+
const handleCancel = () => {
94+
if (!isLoading) {
95+
onClose();
96+
}
97+
};
98+
99+
const handleSubmit = async (event: React.FormEvent) => {
100+
event.preventDefault();
101+
102+
const trimmedName = newName.trim();
103+
if (!trimmedName) {
104+
setError("Workspace name cannot be empty");
105+
return;
106+
}
107+
108+
setIsLoading(true);
109+
setError(null);
110+
111+
try {
112+
await onFork(trimmedName);
113+
setNewName("");
114+
onClose();
115+
} catch (err) {
116+
const message = err instanceof Error ? err.message : "Failed to fork workspace";
117+
setError(message);
118+
} finally {
119+
setIsLoading(false);
120+
}
121+
};
122+
123+
return (
124+
<Modal
125+
isOpen={isOpen}
126+
title="Fork Workspace"
127+
subtitle={`Create a fork of ${sourceWorkspaceName}`}
128+
onClose={handleCancel}
129+
isLoading={isLoading}
130+
describedById={infoId}
131+
>
132+
<form onSubmit={(event) => void handleSubmit(event)}>
133+
<FormGroup>
134+
<label htmlFor="newName">New Workspace Name:</label>
135+
<input
136+
id="newName"
137+
type="text"
138+
value={newName}
139+
onChange={(event) => setNewName(event.target.value)}
140+
disabled={isLoading}
141+
placeholder="Enter new workspace name"
142+
required
143+
aria-required="true"
144+
autoFocus
145+
/>
146+
{error && <ErrorMessage>{error}</ErrorMessage>}
147+
</FormGroup>
148+
149+
<ModalInfo id={infoId}>
150+
<p>
151+
This will create a new git branch and worktree from the current workspace state,
152+
preserving all uncommitted changes.
153+
</p>
154+
</ModalInfo>
155+
156+
{newName.trim() && (
157+
<div>
158+
<CommandLabel>Equivalent command:</CommandLabel>
159+
<CommandDisplay>{formatForkCommand(newName.trim())}</CommandDisplay>
160+
</div>
161+
)}
162+
163+
<ModalActions>
164+
<CancelButton type="button" onClick={handleCancel} disabled={isLoading}>
165+
Cancel
166+
</CancelButton>
167+
<PrimaryButton type="submit" disabled={isLoading || newName.trim().length === 0}>
168+
{isLoading ? "Forking..." : "Fork Workspace"}
169+
</PrimaryButton>
170+
</ModalActions>
171+
</form>
172+
</Modal>
173+
);
174+
};
175+
176+
export default ForkWorkspaceModal;

0 commit comments

Comments
 (0)