Skip to content

Conversation

@ammar-agent
Copy link
Collaborator

@ammar-agent ammar-agent commented Oct 12, 2025

Overview

Replace localStorage-based compaction state tracking with structured metadata embedded in messages. Four critical bugs discovered after initial implementation were fixed through systematic improvements to the replay and metadata flow architecture.

Key insight: Embed structured metadata directly in the user's compaction request message. The backend stores it as a black-box, and the frontend queries it when needed—no localStorage, no callback chains, no event systems.

What Changed

Architecture

Before (localStorage-based):

User types /compact -c msg → ChatInput → onCompactStart callback → App → localStorage
Backend compacts → useAutoCompactContinue reads localStorage → sends continue message

After (metadata-based):

User types /compact -c msg → ChatInput creates cmuxMetadata: { type: "compaction-request", parsed: { continueMessage } }
Backend stores metadata as-is (black-box) → useAutoCompactContinue queries messages → sends continue message

Key Improvements

  1. Metadata Flow: compaction-request → backend black-box → compaction-result in summary
  2. Stream Event Buffering: All stream events buffered until caught-up to ensure aggregator has full historical context
  3. Single Source of Truth: cmuxMetadata drives all compaction detection, display, and continuation logic
  4. Type Safety: Discriminated unions with proper narrowing throughout

Bugs Fixed

Issue #1: Continue Message Lost After Edit ✅

Problem: Editing /compact -c "msg" lost the continue message.

Root Cause: When compaction completed, history was replaced with just the summary. The original compaction-request message (containing continueMessage) was deleted. useAutoCompactContinue looked for it and found nothing.

Solution: Extract continueMessage from request before history replacement and store it in summary metadata as compaction-result. Hook now reads from summary, not request.

// In WorkspaceStore during compaction completion:
const continueMessage = compactRequestMsg?.metadata?.cmuxMetadata?.parsed.continueMessage;
const summaryMessage = createCmuxMessage(/*...*/,  {
  cmuxMetadata: continueMessage 
    ? { type: "compaction-result", continueMessage } 
    : { type: "normal" },
});

Issue #2: "streaming..." Instead of "compacting..." After Reload ✅

Problem: After page reload during active compaction, StreamingBarrier showed "streaming..." instead of "compacting...".

Root Cause: Replay timing issue. When stream-start arrived during replay, aggregator's message cache was still empty (messages buffered separately), so isCompacting check failed.

Solution: Buffer all stream events (start/delta/end/abort/tool calls) until caught-up message arrives. Process them only after historical messages are loaded. This ensures aggregator always has full context when detecting compaction state.

// In WorkspaceStore:
if (!isCaughtUp && this.isStreamEvent(data)) {
  const pending = this.pendingStreamEvents.get(workspaceId) ?? [];
  pending.push(data);
  return; // Don't process yet
}

if (isCaughtUpMessage(data)) {
  aggregator.loadHistoricalMessages(historicalMsgs);
  // NOW process buffered events with full context
  for (const event of pendingEvents) {
    this.processStreamEvent(workspaceId, aggregator, event);
  }
}

Benefits:

  • Eliminates entire class of replay timing bugs
  • Works for all stream events (not just compaction detection)
  • No special cases or optional parameters

Issue #3: Edit Button Active During Compaction ✅

Problem: Clicking "Edit" during compaction created a stuck state—input populated but disabled, user couldn't type or cancel.

Solution: Disable Edit button during compaction with helpful tooltip explaining how to cancel first. Prevents user from entering broken state.

// In UserMessage:
{
  label: "Edit",
  onClick: handleEdit,
  disabled: isCompacting,
  tooltip: isCompacting
    ? `Cannot edit while compacting (press ${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} to cancel)`
    : undefined,
}

Issue #4: Duplicate Messages When Editing Compaction ✅

Problem: Editing a compaction message created duplicates instead of replacing.

Root Cause: Compaction command path didn't pass editMessageId.

Solution: Pass editMessageId through compaction path and clear editing state on success.

Files Changed

Deleted

  • src/components/Messages/PendingUserDraft.tsx (84 lines)
  • src/components/Messages/ChatBarrier/CompactionBarrier.tsx (37 lines)

Modified

  • src/stores/WorkspaceStore.ts - Stream event buffering until caught-up, extract continueMessage before replacement
  • src/hooks/useAutoCompactContinue.ts - Query compaction-result from summary instead of request
  • src/utils/messages/StreamingMessageAggregator.ts - Use cmuxMetadata for isCompacting detection
  • src/components/ChatInput.tsx - Create metadata on send, regenerate on edit
  • src/components/AIView.tsx - Pass isCompacting to MessageRenderer
  • src/components/Messages/MessageRenderer.tsx - Pass isCompacting to UserMessage
  • src/components/Messages/UserMessage.tsx - Disable edit button during compaction
  • src/types/message.ts - Add CmuxFrontendMetadata discriminated union
  • src/types/ipc.ts - Add cmuxMetadata to SendMessageOptions
  • src/services/agentSession.ts - Pass through metadata as black-box

Added

  • tests/ipcMain/sendMessage.test.ts - Integration test verifying metadata round-trip

Benefits

  1. Type-safe - Discriminated unions enforce correct structure at compile time
  2. Single source of truth - Message metadata is authoritative, no synchronization issues
  3. No string parsing on read - Data is already structured from send
  4. Queryable - Easy to find compaction messages with .find()
  5. Editable - Re-parses edited commands automatically
  6. Stateless - No localStorage or callback chains
  7. Simpler - Removed 2 components, ~105 net LoC reduction
  8. Tested - Integration test verifies metadata round-trip through IPC
  9. Robust - Stream event buffering eliminates replay timing bugs

Testing

  • ✅ TypeScript compilation passes (both renderer and main)
  • ✅ Integration test passes (metadata round-trip)
  • ✅ All type constraints satisfied
  • ✅ No breaking changes to existing APIs
  • ✅ Static checks passing (lint, fmt, typecheck)

Manual testing recommended:

  • /compact without continue message
  • /compact -c "message" with auto-continue
  • Edit compaction message after completion
  • Reload during compaction (verify "compacting..." barrier)
  • Click Edit button during compaction (verify disabled with tooltip)

Generated with cmux

@ammar-agent ammar-agent force-pushed the compact-continue branch 10 times, most recently from 7ed9245 to 0d5ca84 Compare October 14, 2025 00:21
@ammar-agent ammar-agent changed the title 🤖 Show pending user draft during compaction 🤖 Stateless /compact UX with structured metadata Oct 14, 2025
@ammar-agent ammar-agent force-pushed the compact-continue branch 2 times, most recently from a4198f6 to 3876d0f Compare October 14, 2025 17:39
When compaction is running with a user message queued, display that pending message under the compacting barrier and allow the user to edit it.

Changes:
- Add PendingUserDraft component that displays queued input under compacting barrier
- Add CompactionBarrier component for visual distinction from normal streaming
- Keep ChatInput enabled during compaction (users can edit draft)
- Block sending during compaction with friendly toast
- Persist draft with listener mode for live cross-component sync
- Expose ChatInput focus API to parent for Edit button

UX Flow:
1. User types a message but doesn't send it
2. User triggers compaction (e.g., /compact)
3. While compacting:
   - CompactionBarrier appears
   - Below it: PENDING USER [not sent] block shows current draft
   - User can continue editing; changes reflect live
   - Edit button focuses input; Discard clears draft
   - Pressing Enter shows toast: message is queued
4. When compaction completes, user presses Enter to send

Generated with `cmux`
Replace localStorage-based compaction state tracking with structured metadata
embedded in messages. This eliminates string parsing, event systems, and
localStorage complexity.

Key changes:
- Add CmuxFrontendMetadata to IPC layer for frontend-defined metadata
- Backend passes through cmuxMetadata as black-box (no interpretation)
- ChatInput sends structured metadata with compaction requests
- useAutoCompactContinue queries message metadata instead of localStorage
- Remove PendingUserDraft component (no longer needed)
- Remove onCompactStart callback chain

Benefits:
- Type-safe structured metadata with discriminated unions
- No string parsing - direct access to parsed command options
- Single source of truth (message metadata)
- Queryable - easy to find compaction messages
- Handles edited commands (re-parses on edit)
- Simpler: ~60 lines net reduction

Deleted:
- src/components/Messages/PendingUserDraft.tsx (84 lines)

Modified:
- src/types/message.ts - CmuxFrontendMetadata already defined
- src/types/ipc.ts - Add cmuxMetadata to SendMessageOptions
- src/services/agentSession.ts - Pass through metadata
- src/components/ChatInput.tsx - Send structured metadata, remove callback
- src/utils/messages/StreamingMessageAggregator.ts - Handle compaction-request
- src/components/AIView.tsx - Remove PendingUserDraft and callback
- src/App.tsx - Remove callback chain
- src/hooks/useAutoCompactContinue.ts - Rewrite to use metadata

_Generated with `cmux`_
Verify that arbitrary frontend metadata (cmuxMetadata) can be:
1. Sent from frontend via IPC (SendMessageOptions)
2. Stored by backend as black-box (no interpretation)
3. Read back by frontend via chat events

Test specifically validates:
- Structured compaction-request metadata storage
- All fields preserved exactly as sent
- Backend treats metadata as opaque data
- Round-trip through IPC + history maintains integrity

This confirms the structured metadata approach works correctly
through the full IPC layer.

_Generated with `cmux`_
Remove obsolete "Message Queued" toast that was paired with the deleted
PendingUserDraft component. Now input is disabled during compaction
(consistent with streaming behavior) with a clear placeholder message.

Changes:
- Disable textarea during compaction
- Update placeholder: "Compacting... (Esc to cancel)"
- Remove spammy "Message Queued" toast on every Enter press
- Simplify handleSend early return logic

This makes compaction UX consistent with streaming - the barrier shows
status and the input is disabled until the operation completes.

_Generated with `cmux`_
…tadata

Fix two editing-related bugs:

1. **Editing state persists across workspace switches**
   - Add useEffect to clear editingMessage when workspaceId changes
   - Prevents stuck editing state when switching workspaces

2. **Duplicate/missing compaction messages after edit + reload**
   - Parse commands when editing to preserve metadata
   - Re-create cmuxMetadata for /compact commands during edit
   - Ensures edited compaction messages maintain their metadata
   - Prevents duplicate messages or missing metadata after reload

Changes:
- src/components/AIView.tsx: Clear editing state on workspace change
- src/components/ChatInput.tsx: Parse commands when editing, include metadata

This ensures:
- Clean state when switching workspaces
- Consistent display of compaction messages after editing
- Metadata preserved through edit operations

_Generated with `cmux`_
When editing a compaction message, properly regenerate the actual
summarization request instead of sending the command text directly.

Problem:
- Original: sends "Summarize this conversation..."
- Edit to "/compact -c new": was sending "/compact -c new" as text ❌
- Result: broken message, duplicate UI, locked editing state

Solution:
- Detect when editing a /compact command
- Regenerate: "Summarize this conversation... Use approximately N words"
- Include proper metadata (command + parsed options)
- Include compaction options (toolPolicy, mode, thinkingLevel)

This allows natural editing of compaction messages:
- Edit "/compact -c old" to "/compact -c new"
- New compaction runs with new settings
- Hook picks up new continue message
- No duplicate messages, clean state

Changes:
- Regenerate actualMessageText from parsed command
- Include compactionOptions when editing /compact
- Hook already handles "most recent" correctly

_Generated with `cmux`_
Extract duplicated compaction message preparation logic into a single
helper function used by both slash command handler and edit handler.

Changes:
- Add prepareCompactionMessage() helper function
- Returns: messageText, metadata, options
- Slash command handler: use helper (removed 18 lines)
- Edit handler: use helper (removed 19 lines)
- Net: +40 lines helper, -37 lines duplication = +3 lines

Benefits:
- DRY: Single source of truth for compaction logic
- Type-safe: Return type ensures consistency
- Maintainable: Change once, applies everywhere
- Testable: Can unit test independently

The helper encapsulates:
- Parsing command
- Generating summarization request text
- Creating metadata structure
- Setting compaction options (toolPolicy, mode, thinkingLevel)

_Generated with `cmux`_
Changes:
- Renamed 'command' to 'rawCommand' in CmuxFrontendMetadata
  - Clarifies this is the user's exact input for faithful display
- Removed CompactionBarrier component
  - StreamingBarrier now handles both streaming and compaction
  - Reduces code duplication (37 lines removed)
- Updated all references to use rawCommand:
  - prepareCompactionMessage() in ChatInput
  - StreamingMessageAggregator display logic
  - useAutoCompactContinue edit detection
  - Integration test

Benefits:
- More semantic field name (rawCommand vs command)
- Single barrier component for both streaming states
- No functional changes - all tests pass
The edit detection logic was fundamentally broken:
- Compared message parts ("Summarize...") to rawCommand ("/compact -c msg")
- These are ALWAYS different by design
- Condition always triggered, tried parsing "Summarize...", failed
- Set continueMessage = undefined, breaking auto-continue for ALL compactions

Root cause: Unnecessary complexity. ChatInput already regenerates metadata
on every edit, so the metadata always reflects the current state.

Solution: Remove the broken edit detection entirely. Just use the metadata.

Changes:
- Remove edit detection block (lines 69-80)
- Remove unused imports (parseCommand, CmuxTextPart)
- Simplify: continueMessage = cmuxMeta.parsed.continueMessage

Benefits:
- Fixes auto-continue for all compaction messages
- Reduces duplicate message race conditions
- Simplifies code (-14 lines)
- Works correctly for all edit scenarios

Net: -14 lines
Fix stuck editing state and duplicate messages during compaction edits.

Problem:
- editingMessage state persists even after message is replaced
- User sees editing controls but message doesn't exist
- Causes duplicate display and stuck UI state

Root cause:
- Message replaced during edit (new historySequence)
- editCutoffHistoryId becomes undefined (can't find old historyId)
- But editingMessage still set, causing UI confusion

Solution:
- Add useEffect to detect when editingMessage is set but editCutoffHistoryId is undefined
- Automatically clear editingMessage when this happens
- Handles: edits, deletions, replacements

Benefits:
- Editing state clears automatically when message replaced
- No stuck editing mode
- No duplicate messages
- Works for all edit scenarios (compact, regular, delete)

Net: +8 lines
- Pass editMessageId in compaction path to replace instead of duplicate
- Clear editing state on successful compaction submission
- Use cmuxMetadata.type check instead of toolPolicy regex matching
- Ensures consistent 'compacting...' status during stream replay

Fixes three issues:
1. Editing compaction messages no longer creates duplicates
2. Continue message preserved correctly (no duplicate confusion)
3. After reload, barrier shows 'compacting...' not 'streaming...'
Store continueMessage in summary metadata so it survives history replacement.

**Problem:**
- User sends /compact -c "continue working"
- Compaction completes, history replaced with summary
- Original compaction-request message deleted
- useAutoCompactContinue can't find continueMessage

**Solution:**
- Extract continueMessage from compaction-request BEFORE replacement
- Store in summary message as compaction-result metadata
- useAutoCompactContinue reads from summary instead of deleted request

**Changes:**
- Add compaction-result type to CmuxFrontendMetadata
- WorkspaceStore extracts and preserves continueMessage during replacement
- useAutoCompactContinue reads from summary's compaction-result metadata
**Fix 1: Enable input when editing during compaction**
- Editing mode now overrides isCompacting/isSending disable logic
- Users can edit messages during active streams as designed
- Change: disabled={!editingMessage && (disabled || isSending || isCompacting)}

**Fix 2: Buffer stream events until history is loaded**
- Stream events (stream-start, deltas, etc.) now buffer until caught-up
- Ensures aggregator has full historical context when processing events
- Fixes isCompacting detection during page reload/replay

**Why this fixes "streaming" vs "compacting" after reload:**
- Before: stream-start processed with empty message cache → no user messages → isCompacting=false
- After: stream-start buffered → history loaded → THEN stream-start processed → finds compaction-request → isCompacting=true

**Benefits:**
- ✅ Eliminates entire class of timing bugs during replay
- ✅ All stream events processed with complete context
- ✅ isCompacting detection works correctly after reload
- ✅ Cleaner architecture - don't process events until ready

**Changes:**
- Add pendingStreamEvents buffer
- Add isStreamEvent() helper to identify stream events
- Buffer stream events when !isCaughtUp
- Process buffered events after loading history
- Extract processStreamEvent() method for event handling
- Edit button now disabled when isCompacting=true
- Tooltip shows: "Cannot edit while compacting (press Esc to cancel)"
- Uses formatKeybind(KEYBINDS.INTERRUPT_STREAM) for platform-appropriate key display
- Prevents broken state from editing during compaction

**Why disable vs enable input:**
- Simpler UX - clear visual feedback that editing is unavailable
- Prevents confusion - user sees grayed button with explanation
- Consistent with "cancel to edit" pattern
- No risk of broken state from edit during compaction
Stream event buffering requires workspaces to be caught-up before
processing stream events. Updated tests to send caught-up message
first to match real IPC replay behavior.
@ammario ammario added this pull request to the merge queue Oct 14, 2025
@github-merge-queue github-merge-queue bot removed this pull request from the merge queue due to failed status checks Oct 14, 2025
@ammario ammario enabled auto-merge October 14, 2025 19:54
@ammario ammario disabled auto-merge October 14, 2025 20:03
@ammario
Copy link
Member

ammario commented Oct 14, 2025

Manual merging due to flake

@ammario ammario merged commit cd97c31 into main Oct 14, 2025
6 of 7 checks passed
@ammario ammario deleted the compact-continue branch October 14, 2025 20:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants