Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
cd3c54b
🤖 Show pending user draft during compaction
ammar-agent Oct 12, 2025
923c649
🤖 Implement stateless /compact UX with structured metadata
ammar-agent Oct 14, 2025
377cb4d
🤖 Add integration test for frontend metadata round-trip
ammar-agent Oct 14, 2025
9dee6da
🤖 Disable input during compaction with clear feedback
ammar-agent Oct 14, 2025
c28fa18
🤖 Fix lint: Remove unused focusInput callback
ammar-agent Oct 14, 2025
bfc47e4
🤖 Fix editing issues: clear state on workspace change and preserve me…
ammar-agent Oct 14, 2025
a3b3016
🤖 Fix compaction message editing: regenerate summarization request
ammar-agent Oct 14, 2025
8ee97b8
🤖 DRY: Extract prepareCompactionMessage helper
ammar-agent Oct 14, 2025
28415b6
Fix rebase conflicts: adapt to Zustand stores
ammar-agent Oct 14, 2025
93c1ef3
🤖 Store rawCommand in metadata & remove CompactionBarrier
ammar-agent Oct 14, 2025
d0859b1
🤖 Fix compaction edit: remove broken detection logic
ammar-agent Oct 14, 2025
dfcd95a
🤖 Clear editing state when message no longer exists
ammar-agent Oct 14, 2025
12558cd
🤖 Fix compaction editing & use cmuxMetadata as source of truth
ammar-agent Oct 14, 2025
66e7d22
🤖 Fix lint errors: move useEffect before early return & remove unused…
ammar-agent Oct 14, 2025
ed50118
🤖 Fix continue message persistence through compaction
ammar-agent Oct 14, 2025
14b06cf
🤖 Enable input during edit mode & buffer stream events until caught-up
ammar-agent Oct 14, 2025
d1a3e79
🤖 Disable edit button during compaction with helpful tooltip
ammar-agent Oct 14, 2025
0318263
🤖 Fix WorkspaceStore tests: send caught-up before stream events
ammar-agent Oct 14, 2025
1be7567
Merge branch 'main' into compact-continue
ammario Oct 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ function AppInner() {
useResumeManager();

// Handle auto-continue after compaction (when user uses /compact -c)
const { handleCompactStart } = useAutoCompactContinue();
useAutoCompactContinue();

// Sync selectedWorkspace with URL hash
useEffect(() => {
Expand Down Expand Up @@ -641,13 +641,11 @@ function AppInner() {
workspaceInfo={`${selectedWorkspace.projectName}/${selectedWorkspace.workspacePath.split("/").pop() ?? ""}`}
>
<AIView
key={selectedWorkspace.workspaceId}
workspaceId={selectedWorkspace.workspaceId}
projectName={selectedWorkspace.projectName}
branch={selectedWorkspace.workspacePath.split("/").pop() ?? ""}
workspacePath={selectedWorkspace.workspacePath}
onCompactStart={(continueMessage) =>
handleCompactStart(selectedWorkspace.workspaceId, continueMessage)
}
/>
</ErrorBoundary>
) : (
Expand Down
22 changes: 18 additions & 4 deletions src/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,6 @@ interface AIViewProps {
projectName: string;
branch: string;
workspacePath: string;
onCompactStart?: (continueMessage: string | undefined) => void;
className?: string;
}

Expand All @@ -201,7 +200,6 @@ const AIViewInner: React.FC<AIViewProps> = ({
projectName,
branch,
workspacePath,
onCompactStart,
className,
}) => {
// NEW: Get workspace state from store (only re-renders when THIS workspace changes)
Expand Down Expand Up @@ -326,6 +324,23 @@ const AIViewInner: React.FC<AIViewProps> = ({
handleOpenTerminal,
});

// Clear editing state if the message being edited no longer exists
// Must be before early return to satisfy React Hooks rules
useEffect(() => {
if (!workspaceState || !editingMessage) return;

const mergedMessages = mergeConsecutiveStreamErrors(workspaceState.messages);
const editCutoffHistoryId = mergedMessages.find(
(msg): msg is Exclude<DisplayedMessage, { type: "history-hidden" }> =>
msg.type !== "history-hidden" && msg.historyId === editingMessage.id
)?.historyId;

if (!editCutoffHistoryId) {
// Message was replaced or deleted - clear editing state
setEditingMessage(undefined);
}
}, [workspaceState, editingMessage]);

// Return early if workspace state not loaded yet
if (!workspaceState) {
return (
Expand All @@ -344,7 +359,6 @@ const AIViewInner: React.FC<AIViewProps> = ({
workspaceState;

// Get active stream message ID for token counting
// Use getActiveStreamMessageId() which returns the messageId directly
const activeStreamMessageId = aggregator.getActiveStreamMessageId();

// Track if last message was interrupted or errored (for RetryBarrier)
Expand Down Expand Up @@ -451,6 +465,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
onEditUserMessage={handleEditUserMessage}
workspaceId={workspaceId}
model={currentModel ?? undefined}
isCompacting={isCompacting}
/>
{isAtCutoff && (
<EditBarrier>
Expand Down Expand Up @@ -507,7 +522,6 @@ const AIViewInner: React.FC<AIViewProps> = ({
onMessageSent={handleMessageSent}
onTruncateHistory={handleClearHistory}
onProviderConfig={handleProviderConfig}
onCompactStart={onCompactStart}
disabled={!projectName || !branch}
isCompacting={isCompacting}
editingMessage={editingMessage}
Expand Down
116 changes: 87 additions & 29 deletions src/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import { VimTextArea } from "./VimTextArea";
import { ImageAttachments, type ImageAttachment } from "./ImageAttachments";

import type { ThinkingLevel } from "@/types/thinking";
import type { CmuxFrontendMetadata } from "@/types/message";
import type { SendMessageOptions } from "@/types/ipc";

const InputSection = styled.div`
position: relative;
Expand Down Expand Up @@ -122,7 +124,6 @@ export interface ChatInputProps {
onTruncateHistory: (percentage?: number) => Promise<void>;
onProviderConfig?: (provider: string, keyPath: string[], value: string) => Promise<void>;
onModelChange?: (model: string) => void;
onCompactStart?: (continueMessage: string | undefined) => void; // Called when compaction starts to update continue message state
disabled?: boolean;
isCompacting?: boolean;
editingMessage?: { id: string; content: string };
Expand Down Expand Up @@ -282,21 +283,61 @@ const createErrorToast = (error: SendMessageErrorType): Toast => {
}
};

/**
* Prepare compaction message from /compact command
* Returns the actual message text (summarization request), metadata, and options
*/
function prepareCompactionMessage(
command: string,
sendMessageOptions: SendMessageOptions
): {
messageText: string;
metadata: CmuxFrontendMetadata;
options: Partial<SendMessageOptions>;
} {
const parsed = parseCommand(command);
if (parsed?.type !== "compact") {
throw new Error("Not a compact command");
}

const targetWords = parsed.maxOutputTokens ? Math.round(parsed.maxOutputTokens / 1.3) : 2000;

const messageText = `Summarize this conversation into a compact form for a new Assistant to continue helping the user. Use approximately ${targetWords} words.`;

const metadata: CmuxFrontendMetadata = {
type: "compaction-request",
rawCommand: command,
parsed: {
maxOutputTokens: parsed.maxOutputTokens,
continueMessage: parsed.continueMessage,
},
};

const isAnthropic = sendMessageOptions.model.startsWith("anthropic:");
const options: Partial<SendMessageOptions> = {
thinkingLevel: isAnthropic ? "off" : sendMessageOptions.thinkingLevel,
toolPolicy: [{ regex_match: "compact_summary", action: "require" }],
maxOutputTokens: parsed.maxOutputTokens,
mode: "compact" as const,
};

return { messageText, metadata, options };
}

export const ChatInput: React.FC<ChatInputProps> = ({
workspaceId,
onMessageSent,
onTruncateHistory,
onProviderConfig,
onModelChange,
onCompactStart,
disabled = false,
isCompacting = false,
editingMessage,
onCancelEdit,
canInterrupt = false,
onReady,
}) => {
const [input, setInput] = usePersistedState(getInputKey(workspaceId), "");
const [input, setInput] = usePersistedState(getInputKey(workspaceId), "", { listener: true });
const [isSending, setIsSending] = useState(false);
const [showCommandSuggestions, setShowCommandSuggestions] = useState(false);
const [commandSuggestions, setCommandSuggestions] = useState<SlashSuggestion[]>([]);
Expand Down Expand Up @@ -524,8 +565,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({

const handleSend = async () => {
// Allow sending if there's text or images
if ((!input.trim() && imageAttachments.length === 0) || disabled || isSending || isCompacting)
if ((!input.trim() && imageAttachments.length === 0) || disabled || isSending || isCompacting) {
return;
}

const messageText = input.trim();

Expand Down Expand Up @@ -610,41 +652,36 @@ export const ChatInput: React.FC<ChatInputProps> = ({
setIsSending(true);

try {
// Construct message asking for summarization
const targetWords = parsed.maxOutputTokens
? Math.round(parsed.maxOutputTokens / 1.3)
: 2000;
const compactionMessage = `Summarize this conversation into a compact form for a new Assistant to continue helping the user. Use approximately ${targetWords} words.`;

// Send message with compact_summary tool required and maxOutputTokens in options
// Note: Anthropic doesn't support extended thinking with required tool_choice,
// so disable thinking for Anthropic models during compaction
const isAnthropic = sendMessageOptions.model.startsWith("anthropic:");
const {
messageText: compactionMessage,
metadata,
options,
} = prepareCompactionMessage(messageText, sendMessageOptions);

const result = await window.api.workspace.sendMessage(workspaceId, compactionMessage, {
...sendMessageOptions,
thinkingLevel: isAnthropic ? "off" : sendMessageOptions.thinkingLevel,
toolPolicy: [{ regex_match: "compact_summary", action: "require" }],
maxOutputTokens: parsed.maxOutputTokens, // Pass to model directly
mode: "compact" as const, // Allow users to customize compaction behavior via Mode: compact in AGENTS.md
...options,
cmuxMetadata: metadata,
editMessageId: editingMessage?.id, // Support editing compaction messages
});

if (!result.success) {
console.error("Failed to initiate compaction:", result.error);
setToast(createErrorToast(result.error));
setInput(messageText); // Restore input on error
} else {
// Notify parent to update continue message state (parent handles storage)
if (onCompactStart) {
onCompactStart(parsed.continueMessage);
}

setToast({
id: Date.now().toString(),
type: "success",
message: parsed.continueMessage
? "Compaction started. Will continue automatically after completion."
: "Compaction started. AI will summarize the conversation.",
message:
metadata.type === "compaction-request" && metadata.parsed.continueMessage
? "Compaction started. Will continue automatically after completion."
: "Compaction started. AI will summarize the conversation.",
});
// Clear editing state on success
if (editingMessage && onCancelEdit) {
onCancelEdit();
}
}
} catch (error) {
console.error("Compaction error:", error);
Expand Down Expand Up @@ -678,10 +715,31 @@ export const ChatInput: React.FC<ChatInputProps> = ({
mimeType: img.mimeType,
}));

const result = await window.api.workspace.sendMessage(workspaceId, messageText, {
// When editing a /compact command, regenerate the actual summarization request
let actualMessageText = messageText;
let cmuxMetadata: CmuxFrontendMetadata | undefined;
let compactionOptions = {};

if (editingMessage && messageText.startsWith("/")) {
const parsed = parseCommand(messageText);
if (parsed?.type === "compact") {
const {
messageText: regeneratedText,
metadata,
options,
} = prepareCompactionMessage(messageText, sendMessageOptions);
actualMessageText = regeneratedText;
cmuxMetadata = metadata;
compactionOptions = options;
}
}

const result = await window.api.workspace.sendMessage(workspaceId, actualMessageText, {
...sendMessageOptions,
...compactionOptions,
editMessageId: editingMessage?.id,
imageParts: imageParts.length > 0 ? imageParts : undefined,
cmuxMetadata,
});

if (!result.success) {
Expand Down Expand Up @@ -782,7 +840,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
return `Edit your message... (${formatKeybind(KEYBINDS.CANCEL)} to cancel edit, ${formatKeybind(KEYBINDS.SEND_MESSAGE)} to send)`;
}
if (isCompacting) {
return "Compacting conversation...";
return `Compacting... (${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} to cancel)`;
}

// Build hints for normal input
Expand Down Expand Up @@ -818,7 +876,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
onPaste={handlePaste}
suppressKeys={showCommandSuggestions ? COMMAND_SUGGESTION_KEYS : undefined}
placeholder={placeholder}
disabled={disabled || isSending || isCompacting}
disabled={!editingMessage && (disabled || isSending || isCompacting)}
aria-label={editingMessage ? "Edit your last message" : "Message Claude"}
aria-autocomplete="list"
aria-controls={
Expand Down
12 changes: 10 additions & 2 deletions src/components/Messages/MessageRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,23 @@ interface MessageRendererProps {
onEditUserMessage?: (messageId: string, content: string) => void;
workspaceId?: string;
model?: string;
isCompacting?: boolean;
}

// Memoized to prevent unnecessary re-renders when parent (AIView) updates
export const MessageRenderer = React.memo<MessageRendererProps>(
({ message, className, onEditUserMessage, workspaceId, model }) => {
({ message, className, onEditUserMessage, workspaceId, model, isCompacting }) => {
// Route based on message type
switch (message.type) {
case "user":
return <UserMessage message={message} className={className} onEdit={onEditUserMessage} />;
return (
<UserMessage
message={message}
className={className}
onEdit={onEditUserMessage}
isCompacting={isCompacting}
/>
);
case "assistant":
return (
<AssistantMessage message={message} className={className} workspaceId={workspaceId} />
Expand Down
13 changes: 12 additions & 1 deletion src/components/Messages/UserMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { DisplayedMessage } from "@/types/message";
import type { ButtonConfig } from "./MessageWindow";
import { MessageWindow } from "./MessageWindow";
import { TerminalOutput } from "./TerminalOutput";
import { formatKeybind, KEYBINDS } from "@/utils/ui/keybinds";

const FormattedContent = styled.pre`
margin: 0;
Expand Down Expand Up @@ -34,9 +35,15 @@ interface UserMessageProps {
message: DisplayedMessage & { type: "user" };
className?: string;
onEdit?: (messageId: string, content: string) => void;
isCompacting?: boolean;
}

export const UserMessage: React.FC<UserMessageProps> = ({ message, className, onEdit }) => {
export const UserMessage: React.FC<UserMessageProps> = ({
message,
className,
onEdit,
isCompacting,
}) => {
const [copied, setCopied] = useState(false);

const content = message.content;
Expand Down Expand Up @@ -72,6 +79,10 @@ export const UserMessage: React.FC<UserMessageProps> = ({ message, className, on
{
label: "Edit",
onClick: handleEdit,
disabled: isCompacting,
tooltip: isCompacting
? `Cannot edit while compacting (press ${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} to cancel)`
: undefined,
},
]
: []),
Expand Down
Loading
Loading