Skip to content
13 changes: 5 additions & 8 deletions src/hooks/useAutoCompactContinue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,12 @@ export function useAutoCompactContinue() {
// Check all workspaces for completed compaction
for (const [workspaceId, state] of newStates) {
// Detect if workspace is in "single compacted message" state
// Skip workspace-init messages since they're UI-only metadata
const cmuxMessages = state.messages.filter((m) => m.type !== "workspace-init");
const isSingleCompacted =
state.messages.length === 1 &&
state.messages[0].type === "assistant" &&
state.messages[0].isCompacted === true;
cmuxMessages.length === 1 &&
cmuxMessages[0]?.type === "assistant" &&
cmuxMessages[0].isCompacted === true;

if (!isSingleCompacted) {
// Workspace no longer in compacted state - no action needed
Expand Down Expand Up @@ -74,11 +76,6 @@ export function useAutoCompactContinue() {
// Mark THIS RESULT as processed before sending to prevent duplicates
processedMessageIds.current.add(idForGuard);

console.log(
`[useAutoCompactContinue] Sending continue message for ${workspaceId}:`,
continueMessage
);

// Build options and send message directly
const options = buildSendMessageOptions(workspaceId);
void (async () => {
Expand Down
44 changes: 27 additions & 17 deletions src/stores/WorkspaceStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { updatePersistedState } from "@/hooks/usePersistedState";
import { getRetryStateKey } from "@/constants/storage";
import { CUSTOM_EVENTS } from "@/constants/events";
import { useSyncExternalStore } from "react";
import { isCaughtUpMessage, isStreamError, isDeleteMessage } from "@/types/ipc";
import { isCaughtUpMessage, isStreamError, isDeleteMessage, isCmuxMessage } from "@/types/ipc";
import { MapStore } from "./MapStore";
import { createDisplayUsage } from "@/utils/tokens/displayUsage";
import { WorkspaceConsumerManager } from "./WorkspaceConsumerManager";
Expand All @@ -20,6 +20,7 @@ import { getCancelledCompactionKey } from "@/constants/storage";
import { isCompactingStream, findCompactionRequestMessage } from "@/utils/compaction/handler";

export interface WorkspaceState {
name: string; // User-facing workspace name (e.g., "feature-branch")
messages: DisplayedMessage[];
canInterrupt: boolean;
isCompacting: boolean;
Expand Down Expand Up @@ -108,6 +109,7 @@ export class WorkspaceStore {
private caughtUp = new Map<string, boolean>();
private historicalMessages = new Map<string, CmuxMessage[]>();
private pendingStreamEvents = new Map<string, WorkspaceChatMessage[]>();
private workspaceMetadata = new Map<string, FrontendWorkspaceMetadata>(); // Store metadata for name lookup

/**
* Map of event types to their handlers. This is the single source of truth for:
Expand Down Expand Up @@ -335,8 +337,10 @@ export class WorkspaceStore {
const isCaughtUp = this.caughtUp.get(workspaceId) ?? false;
const activeStreams = aggregator.getActiveStreams();
const messages = aggregator.getAllMessages();
const metadata = this.workspaceMetadata.get(workspaceId);

return {
name: metadata?.name ?? workspaceId, // Fall back to ID if metadata missing
messages: aggregator.getDisplayedMessages(),
canInterrupt: activeStreams.length > 0,
isCompacting: aggregator.isCompacting(),
Expand Down Expand Up @@ -730,6 +734,9 @@ export class WorkspaceStore {
return;
}

// Store metadata for name lookup
this.workspaceMetadata.set(workspaceId, metadata);

const aggregator = this.getOrCreateAggregator(workspaceId, metadata.createdAt);

// Initialize recency cache and bump derived store immediately
Expand Down Expand Up @@ -958,23 +965,26 @@ export class WorkspaceStore {
}

// Regular messages (CmuxMessage without type field)
const isCaughtUp = this.caughtUp.get(workspaceId) ?? false;
if (!isCaughtUp && "role" in data && !("type" in data)) {
// Buffer historical CmuxMessages
const historicalMsgs = this.historicalMessages.get(workspaceId) ?? [];
historicalMsgs.push(data);
this.historicalMessages.set(workspaceId, historicalMsgs);
} else if (isCaughtUp && "role" in data) {
// Process live events immediately (after history loaded)
// Check for role field to ensure this is a CmuxMessage
aggregator.handleMessage(data);
this.states.bump(workspaceId);
this.checkAndBumpRecencyIfChanged();
} else if ("role" in data || "type" in data) {
// Unexpected: message with role/type field didn't match any condition
console.error("[WorkspaceStore] Message not processed - unexpected state", {
if (isCmuxMessage(data)) {
const isCaughtUp = this.caughtUp.get(workspaceId) ?? false;
if (!isCaughtUp) {
// Buffer historical CmuxMessages
const historicalMsgs = this.historicalMessages.get(workspaceId) ?? [];
historicalMsgs.push(data);
this.historicalMessages.set(workspaceId, historicalMsgs);
} else {
// Process live events immediately (after history loaded)
aggregator.handleMessage(data);
this.states.bump(workspaceId);
this.checkAndBumpRecencyIfChanged();
}
return;
}

// If we reach here, unknown message type - log for debugging
if ("role" in data || "type" in data) {
console.error("[WorkspaceStore] Unknown message type - not processed", {
workspaceId,
isCaughtUp,
hasRole: "role" in data,
hasType: "type" in data,
type: "type" in data ? (data as { type: string }).type : undefined,
Expand Down
5 changes: 5 additions & 0 deletions src/types/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@ export function isReasoningEnd(msg: WorkspaceChatMessage): msg is ReasoningEndEv
return "type" in msg && msg.type === "reasoning-end";
}

// Type guard for CmuxMessage (messages with role but no type field)
export function isCmuxMessage(msg: WorkspaceChatMessage): msg is CmuxMessage {
return "role" in msg && !("type" in msg);
}

// Type guards for init events
export function isInitStart(
msg: WorkspaceChatMessage
Expand Down
4 changes: 2 additions & 2 deletions src/utils/messages/StreamingMessageAggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type {
import type { TodoItem } from "@/types/tools";

import type { WorkspaceChatMessage, StreamErrorMessage, DeleteMessage } from "@/types/ipc";
import { isInitStart, isInitOutput, isInitEnd } from "@/types/ipc";
import { isInitStart, isInitOutput, isInitEnd, isCmuxMessage } from "@/types/ipc";
import type {
DynamicToolPart,
DynamicToolPartPending,
Expand Down Expand Up @@ -543,7 +543,7 @@ export class StreamingMessageAggregator {

// Handle regular messages (user messages, historical messages)
// Check if it's a CmuxMessage (has role property but no type)
if ("role" in data && !("type" in data)) {
if (isCmuxMessage(data)) {
const incomingMessage = data;

// Smart replacement logic for edits:
Expand Down