From b8e1237c6974497353be4c6ec0629a49e6b429c6 Mon Sep 17 00:00:00 2001 From: Leonidas Zhak <70497898+LeonidasZhak@users.noreply.github.com> Date: Fri, 29 May 2026 21:09:02 +0800 Subject: [PATCH 01/15] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20prompt=20his?= =?UTF-8?q?tory=20sidebar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a right sidebar History tab that lists transcript user prompts oldest-to-newest, lets users jump back to the original message, copy the prompt, or insert it into the composer. Links #3416. --- _Generated with `mux` • Model: `GPT-5` • Thinking: `unknown` • Cost: ``_ --- src/browser/components/ChatPane/ChatPane.tsx | 19 ++ src/browser/features/ChatInput/index.tsx | 53 +++++- .../RightSidebar/PromptHistoryTab.test.ts | 154 ++++++++++++++++ .../RightSidebar/PromptHistoryTab.tsx | 165 ++++++++++++++++++ .../features/RightSidebar/Tabs/TabLabels.tsx | 8 + .../features/RightSidebar/Tabs/tabConfig.ts | 6 + .../RightSidebar/Tabs/tabRegistry.tsx | 10 ++ .../RightSidebar/promptHistoryEntries.ts | 60 +++++++ src/common/constants/events.ts | 11 ++ 9 files changed, 484 insertions(+), 2 deletions(-) create mode 100644 src/browser/features/RightSidebar/PromptHistoryTab.test.ts create mode 100644 src/browser/features/RightSidebar/PromptHistoryTab.tsx create mode 100644 src/browser/features/RightSidebar/promptHistoryEntries.ts diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index f6442226fa..67adfaf018 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -89,6 +89,7 @@ import { useTranscriptDensity } from "@/browser/hooks/useTranscriptDensity"; import { useReviews } from "@/browser/hooks/useReviews"; import { ReviewsBanner } from "../ReviewsBanner/ReviewsBanner"; import type { ReviewNoteData } from "@/common/types/review"; +import { CUSTOM_EVENTS, type CustomEventPayloads } from "@/common/constants/events"; import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; import { useBackgroundBashActions, @@ -730,6 +731,24 @@ const ChatPaneContent: React.FC = (props) => { [contentRef, disableAutoScroll] ); + useEffect(() => { + const handler = ( + event: CustomEvent + ) => { + if (event.detail.workspaceId !== workspaceId) { + return; + } + handleNavigateToMessage(event.detail.historyId); + }; + + window.addEventListener(CUSTOM_EVENTS.NAVIGATE_TO_TRANSCRIPT_MESSAGE, handler as EventListener); + return () => + window.removeEventListener( + CUSTOM_EVENTS.NAVIGATE_TO_TRANSCRIPT_MESSAGE, + handler as EventListener + ); + }, [handleNavigateToMessage, workspaceId]); + // Precompute per-user navigation objects so MessageRenderer rows receive stable prop // references across non-message updates (usage bumps, stats updates, etc.). const userMessageNavigationByHistoryId = useMemo(() => { diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index d1c8e6b112..f09c6191ed 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -201,6 +201,8 @@ import { isGoalRunning } from "@/common/types/goal"; import { appendStagedAttachmentNotice, getStagedAttachments } from "./stagedAttachments"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; +const AWAITING_NEW_ATTACHED_REVIEWS = Symbol("awaiting-new-attached-reviews"); + // localStorage quotas are environment-dependent and relatively small. // Be conservative here so we can warn the user before writes start failing. @@ -501,6 +503,10 @@ const ChatInputInner: React.FC = (props) => { // draftReviews takes precedence when restoring or editing message drafts. const attachedReviews = variant === "workspace" ? (props.attachedReviews ?? []) : []; + const attachedReviewIdsSignature = attachedReviews.map((review) => review.id).join("\u0000"); + const clearedAttachedReviewIdsRef = useRef( + null + ); const draftReviewIdsByValueRef = useRef(new WeakMap()); const nextDraftReviewIdRef = useRef(0); const isDraftReviewData = (value: unknown): value is ReviewNoteDataForDisplay => @@ -541,6 +547,31 @@ const ChatInputInner: React.FC = (props) => { return next; }); + // Empty review replacements clear the current parent-attached reviews but should not hide + // reviews attached later from the Code Review tab. + useEffect(() => { + if ( + draftReviews === null || + draftReviews.length > 0 || + clearedAttachedReviewIdsRef.current === null + ) { + return; + } + + if (attachedReviews.length === 0) { + clearedAttachedReviewIdsRef.current = AWAITING_NEW_ATTACHED_REVIEWS; + return; + } + + if ( + clearedAttachedReviewIdsRef.current === AWAITING_NEW_ATTACHED_REVIEWS || + clearedAttachedReviewIdsRef.current !== attachedReviewIdsSignature + ) { + clearedAttachedReviewIdsRef.current = null; + setDraftReviews(null); + } + }, [attachedReviewIdsSignature, attachedReviews.length, draftReviews]); + // Creation sends can resolve after navigation; guard draft clears on unmounted inputs. const isMountedRef = useRef(true); useEffect(() => { @@ -1754,12 +1785,21 @@ const ChatInputInner: React.FC = (props) => { const hasFileParts = restoredPending.fileParts.length > 0; const hasStagedAttachments = restoredPending.stagedAttachments.length > 0; const hasReviews = restoredPending.reviews.length > 0; + const hasDraftReplacementPayload = fileParts !== undefined || reviews !== undefined; if (mode === "replace") { if (editingMessageForUi) { return; } - if (hasFileParts || hasStagedAttachments || hasReviews) { + if (hasDraftReplacementPayload) { + onDetachAllReviewsForComposerClear?.(); + const clearsReviews = reviews === undefined || reviews.length === 0; + if (clearsReviews) { + clearedAttachedReviewIdsRef.current = attachedReviewIdsSignature; + } else { + clearedAttachedReviewIdsRef.current = null; + } + restoreDraft(restoredPending); } else { restoreText(restoredPending.content); @@ -1781,7 +1821,16 @@ const ChatInputInner: React.FC = (props) => { window.addEventListener(CUSTOM_EVENTS.UPDATE_CHAT_INPUT, handler as EventListener); return () => window.removeEventListener(CUSTOM_EVENTS.UPDATE_CHAT_INPUT, handler as EventListener); - }, [appendText, restoreText, restoreDraft, applyDraftFromPending, getDraft, editingMessageForUi]); + }, [ + appendText, + applyDraftFromPending, + attachedReviewIdsSignature, + editingMessageForUi, + getDraft, + onDetachAllReviewsForComposerClear, + restoreDraft, + restoreText, + ]); useEffect(() => { const handler = (event: CustomEvent<{ workspaceId: string }>) => { diff --git a/src/browser/features/RightSidebar/PromptHistoryTab.test.ts b/src/browser/features/RightSidebar/PromptHistoryTab.test.ts new file mode 100644 index 0000000000..508ba28eab --- /dev/null +++ b/src/browser/features/RightSidebar/PromptHistoryTab.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, test } from "bun:test"; +import type { DisplayedMessage } from "@/common/types/message"; +import type { ReviewNoteData } from "@/common/types/review"; +import { createPromptHistoryInsertPayload } from "./PromptHistoryTab"; +import { getPromptHistoryEntries } from "./promptHistoryEntries"; + +function userMessage( + id: string, + content: string, + historySequence: number, + overrides: Partial> = {} +): Extract { + return { + type: "user", + id, + historyId: id, + content, + historySequence, + ...overrides, + }; +} + +describe("getPromptHistoryEntries", () => { + test("returns real user prompts sorted from oldest to newest", () => { + const messages: DisplayedMessage[] = [ + userMessage("newer", "Newer prompt", 3), + { + type: "assistant", + id: "assistant", + historyId: "assistant", + content: "Response", + historySequence: 2, + isStreaming: false, + isPartial: false, + isCompacted: false, + isIdleCompacted: false, + }, + userMessage("older", "Older prompt", 1), + ]; + + expect(getPromptHistoryEntries(messages).map((entry) => entry.historyId)).toEqual([ + "older", + "newer", + ]); + }); + + test("skips synthetic continuation prompts", () => { + const messages: DisplayedMessage[] = [ + userMessage("real", "Please continue the work", 1), + userMessage("auto", "Continue", 2, { isSynthetic: true }), + userMessage("goal", "Synthetic goal continuation", 3, { isGoalContinuation: true }), + userMessage("wrap", "Budget wrap-up", 4, { isBudgetLimitWrapup: true }), + ]; + + expect(getPromptHistoryEntries(messages).map((entry) => entry.historyId)).toEqual(["real"]); + }); + + test("skips completed local command output rows", () => { + const entries = getPromptHistoryEntries([ + userMessage("stdout", "build complete", 1), + userMessage("prompt", "What changed?", 2), + ]); + + expect(entries.map((entry) => entry.historyId)).toEqual(["prompt"]); + }); + + test("keeps side-question prompts visible", () => { + const [entry] = getPromptHistoryEntries([ + userMessage("side", "Can you compare this quickly?", 1, { + commandPrefix: "/btw", + isSideQuestion: true, + }), + ]); + + expect(entry).toMatchObject({ + historyId: "side", + commandPrefix: "/btw", + isSideQuestion: true, + }); + }); + + test("keeps attachment-only user prompts with file parts", () => { + const fileParts = [ + { + url: "data:text/plain;base64,SGVsbG8=", + mediaType: "text/plain", + filename: "note.txt", + }, + ]; + const entries = getPromptHistoryEntries([ + userMessage("file-only", "", 1, { + fileParts, + }), + ]); + + expect(entries).toEqual([ + { + historyId: "file-only", + content: "", + historySequence: 1, + timestamp: undefined, + commandPrefix: undefined, + isSideQuestion: false, + fileCount: 1, + fileParts, + }, + ]); + }); + + test("insert payload clears attachments and reviews for text-only history", () => { + const [entry] = getPromptHistoryEntries([userMessage("text-only", "Reuse this", 1)]); + + if (!entry) throw new Error("expected prompt history entry"); + expect(createPromptHistoryInsertPayload(entry)).toEqual({ + text: "Reuse this", + mode: "replace", + fileParts: [], + reviews: [], + }); + }); + + test("insert payload preserves attached review notes from history", () => { + const reviews: ReviewNoteData[] = [ + { + filePath: "src/example.ts", + lineRange: "+10-12", + selectedCode: "const marker = '';", + userNote: "Please revisit this branch.", + }, + ]; + const serializedReview = ` +Re src/example.ts:+10-12 +\`\`\` +const marker = ''; +\`\`\` +> Please revisit this branch. +`; + const [entry] = getPromptHistoryEntries([ + userMessage("with-review", `${serializedReview}\n\nReuse review context`, 1, { + reviews, + }), + ]); + + if (!entry) throw new Error("expected prompt history entry"); + expect(entry.content).toBe("Reuse review context"); + expect(entry.reviews).toEqual(reviews); + expect(createPromptHistoryInsertPayload(entry)).toEqual({ + text: "Reuse review context", + mode: "replace", + fileParts: [], + reviews, + }); + }); +}); diff --git a/src/browser/features/RightSidebar/PromptHistoryTab.tsx b/src/browser/features/RightSidebar/PromptHistoryTab.tsx new file mode 100644 index 0000000000..cf643ceffc --- /dev/null +++ b/src/browser/features/RightSidebar/PromptHistoryTab.tsx @@ -0,0 +1,165 @@ +import React from "react"; +import { Clipboard, CornerDownLeft, LocateFixed, MessageSquareText } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/browser/components/Tooltip/Tooltip"; +import { useWorkspaceState } from "@/browser/stores/WorkspaceStore"; +import { copyToClipboard } from "@/browser/utils/clipboard"; +import { formatTimestamp } from "@/browser/utils/ui/dateTime"; +import { + CUSTOM_EVENTS, + createCustomEvent, + type CustomEventPayloads, +} from "@/common/constants/events"; +import { cn } from "@/common/lib/utils"; +import { getPromptHistoryEntries, type PromptHistoryEntry } from "./promptHistoryEntries"; + +interface PromptHistoryTabProps { + workspaceId: string; +} + +export function PromptHistoryTab(props: PromptHistoryTabProps) { + const workspaceState = useWorkspaceState(props.workspaceId); + const entries = getPromptHistoryEntries(workspaceState.messages); + + const navigateToMessage = (historyId: string) => { + // The transcript owns its scroll container, so the sidebar asks it to reveal + // the message instead of reaching across the layout with a DOM query. + window.dispatchEvent( + createCustomEvent(CUSTOM_EVENTS.NAVIGATE_TO_TRANSCRIPT_MESSAGE, { + workspaceId: props.workspaceId, + historyId, + }) + ); + }; + + const insertIntoComposer = (entry: PromptHistoryEntry) => { + window.dispatchEvent( + createCustomEvent(CUSTOM_EVENTS.UPDATE_CHAT_INPUT, createPromptHistoryInsertPayload(entry)) + ); + }; + + if (entries.length === 0) { + return ( +
+
+ ); + } + + return ( +
+
+ {entries.length.toLocaleString()} prompt{entries.length === 1 ? "" : "s"} in this transcript +
+
+ {entries.map((entry, index) => ( + + ))} +
+
+ ); +} + +export function createPromptHistoryInsertPayload( + entry: PromptHistoryEntry +): CustomEventPayloads[typeof CUSTOM_EVENTS.UPDATE_CHAT_INPUT] { + return { + text: entry.content, + mode: "replace", + fileParts: entry.fileParts ?? [], + reviews: entry.reviews ?? [], + }; +} + +interface PromptHistoryEntryCardProps { + entry: PromptHistoryEntry; + ordinal: number; + onNavigate: (historyId: string) => void; + onInsert: (entry: PromptHistoryEntry) => void; +} + +function PromptHistoryEntryCard(props: PromptHistoryEntryCardProps) { + const timestamp = props.entry.timestamp ? formatTimestamp(props.entry.timestamp) : null; + const accessoryLabel = props.entry.isSideQuestion + ? "Side question" + : (props.entry.commandPrefix ?? + (props.entry.fileCount > 0 + ? `${props.entry.fileCount.toLocaleString()} file${props.entry.fileCount === 1 ? "" : "s"}` + : null)); + + return ( +
+ +
+ void copyToClipboard(props.entry.content)} + > + + props.onInsert(props.entry)} + > + + props.onNavigate(props.entry.historyId)} + > + +
+
+ ); +} + +interface PromptHistoryIconButtonProps { + label: string; + onClick: () => void; + children: React.ReactNode; +} + +function PromptHistoryIconButton(props: PromptHistoryIconButtonProps) { + return ( + + + + + {props.label} + + ); +} diff --git a/src/browser/features/RightSidebar/Tabs/TabLabels.tsx b/src/browser/features/RightSidebar/Tabs/TabLabels.tsx index 5bf7821259..499438eb16 100644 --- a/src/browser/features/RightSidebar/Tabs/TabLabels.tsx +++ b/src/browser/features/RightSidebar/Tabs/TabLabels.tsx @@ -11,6 +11,7 @@ import React from "react"; import { BugPlay, ExternalLink, + History, Monitor, Globe, Sparkles, @@ -251,6 +252,13 @@ export const WorkflowsTabLabel: React.FC = ({ workspaceI ); }; +export const PromptHistoryTabLabel: React.FC = () => ( + + + History + +); + export function OutputTabLabel() { return <>Output; } diff --git a/src/browser/features/RightSidebar/Tabs/tabConfig.ts b/src/browser/features/RightSidebar/Tabs/tabConfig.ts index 0d22e926cd..da83e58dd1 100644 --- a/src/browser/features/RightSidebar/Tabs/tabConfig.ts +++ b/src/browser/features/RightSidebar/Tabs/tabConfig.ts @@ -62,6 +62,12 @@ const TAB_CONFIG_DEF = { defaultOrder: 36, paletteKeywords: ["workflow", "workflows", "orchestration", "agents", "run"], }, + history: { + name: "History", + contentClassName: "overflow-y-auto p-0", + defaultOrder: 37, + paletteKeywords: ["prompt", "message", "history", "reuse"], + }, memory: { name: "Memory", contentClassName: "overflow-hidden p-0", diff --git a/src/browser/features/RightSidebar/Tabs/tabRegistry.tsx b/src/browser/features/RightSidebar/Tabs/tabRegistry.tsx index 4cee5a73c0..fd8832a263 100644 --- a/src/browser/features/RightSidebar/Tabs/tabRegistry.tsx +++ b/src/browser/features/RightSidebar/Tabs/tabRegistry.tsx @@ -23,6 +23,7 @@ import { BrowserTab } from "@/browser/features/RightSidebar/BrowserTab"; import { DevToolsTab } from "@/browser/features/RightSidebar/DevToolsTab"; import { GoalTab, type GoalCreateIntent } from "@/browser/features/RightSidebar/GoalTab"; import { MemoryTab } from "@/browser/features/RightSidebar/Memory/MemoryTab"; +import { PromptHistoryTab } from "@/browser/features/RightSidebar/PromptHistoryTab"; import { WorkflowsTab } from "@/browser/features/RightSidebar/Workflows/WorkflowsTab"; import type { GoalSnapshot, GoalStatus } from "@/common/types/goal"; import type { ReviewNoteData } from "@/common/types/review"; @@ -35,6 +36,7 @@ import { InstructionsTabLabel, MemoryTabLabel, OutputTabLabel, + PromptHistoryTabLabel, ReviewTabLabel, StatsTabLabel, WorkflowsTabLabel, @@ -161,6 +163,14 @@ const TAB_RENDERERS = { ), }, + history: { + Label: PromptHistoryTabLabel, + renderPanel: (ctx) => ( + + + + ), + }, memory: { Label: MemoryTabLabel, renderPanel: (ctx) => , diff --git a/src/browser/features/RightSidebar/promptHistoryEntries.ts b/src/browser/features/RightSidebar/promptHistoryEntries.ts new file mode 100644 index 0000000000..3218f1c515 --- /dev/null +++ b/src/browser/features/RightSidebar/promptHistoryEntries.ts @@ -0,0 +1,60 @@ +import type { DisplayedMessage } from "@/common/types/message"; +import { getEditableUserMessageText } from "@/browser/utils/messages/messageUtils"; + +type UserMessage = Extract; +const LOCAL_COMMAND_STDOUT_OPEN_TAG = ""; +const LOCAL_COMMAND_STDOUT_CLOSE_TAG = ""; + +export interface PromptHistoryEntry { + historyId: string; + content: string; + historySequence: number; + timestamp?: number; + commandPrefix?: string; + isSideQuestion: boolean; + fileCount: number; + fileParts?: UserMessage["fileParts"]; + reviews?: UserMessage["reviews"]; +} + +function isCompletedLocalCommandOutput(message: UserMessage): boolean { + return ( + message.content.startsWith(LOCAL_COMMAND_STDOUT_OPEN_TAG) && + message.content.endsWith(LOCAL_COMMAND_STDOUT_CLOSE_TAG) + ); +} + +export function getPromptHistoryEntries( + messages: readonly DisplayedMessage[] +): PromptHistoryEntry[] { + return messages + .filter((message): message is Extract => { + if (message.type !== "user") { + return false; + } + if (message.isSynthetic || message.isGoalContinuation || message.isBudgetLimitWrapup) { + return false; + } + if (isCompletedLocalCommandOutput(message)) { + return false; + } + return message.content.trim().length > 0 || (message.fileParts?.length ?? 0) > 0; + }) + .map((message) => { + const fileParts = message.fileParts ?? []; + const reviews = message.reviews ?? []; + const content = getEditableUserMessageText(message); + return { + historyId: message.historyId, + content, + historySequence: message.historySequence, + timestamp: message.timestamp, + commandPrefix: message.commandPrefix, + isSideQuestion: message.isSideQuestion === true, + fileCount: fileParts.length, + ...(fileParts.length > 0 ? { fileParts } : {}), + ...(reviews.length > 0 ? { reviews } : {}), + }; + }) + .sort((left, right) => left.historySequence - right.historySequence); +} diff --git a/src/common/constants/events.ts b/src/common/constants/events.ts index df2b5ffbac..5a08e52517 100644 --- a/src/common/constants/events.ts +++ b/src/common/constants/events.ts @@ -22,6 +22,12 @@ export const CUSTOM_EVENTS = { */ UPDATE_CHAT_INPUT: "mux:updateChatInput", + /** + * Event to scroll the active transcript to a persisted message. + * Detail: { workspaceId: string, historyId: string } + */ + NAVIGATE_TO_TRANSCRIPT_MESSAGE: "mux:navigateToTranscriptMessage", + /** * Event to clear the active chat composer after an out-of-band command succeeds. * Detail: { workspaceId: string } @@ -148,9 +154,14 @@ export interface CustomEventPayloads { [CUSTOM_EVENTS.UPDATE_CHAT_INPUT]: { text: string; mode?: "replace" | "append"; + /** In replace mode, presence means replace the whole draft, even when empty. */ fileParts?: FilePart[]; reviews?: ReviewNoteDataForDisplay[]; }; + [CUSTOM_EVENTS.NAVIGATE_TO_TRANSCRIPT_MESSAGE]: { + workspaceId: string; + historyId: string; + }; [CUSTOM_EVENTS.CLEAR_CHAT_COMPOSER]: { workspaceId: string; }; From e4bdbf36010c6b740f5d8de2afd281696dc55890 Mon Sep 17 00:00:00 2001 From: Leonidas Zhak <70497898+LeonidasZhak@users.noreply.github.com> Date: Sat, 20 Jun 2026 11:29:16 +0800 Subject: [PATCH 02/15] fix: preserve compaction follow-up history payloads --- .../RightSidebar/PromptHistoryTab.test.ts | 44 +++++++++++++++++++ .../RightSidebar/promptHistoryEntries.ts | 5 ++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/browser/features/RightSidebar/PromptHistoryTab.test.ts b/src/browser/features/RightSidebar/PromptHistoryTab.test.ts index 508ba28eab..63d83f6ae7 100644 --- a/src/browser/features/RightSidebar/PromptHistoryTab.test.ts +++ b/src/browser/features/RightSidebar/PromptHistoryTab.test.ts @@ -151,4 +151,48 @@ const marker = ''; reviews, }); }); + + test("insert payload preserves compaction follow-up attachments and reviews", () => { + const fileParts = [ + { + url: "data:text/plain;base64,SGVsbG8=", + mediaType: "text/plain", + filename: "follow-up.txt", + }, + ]; + const reviews: ReviewNoteData[] = [ + { + filePath: "src/follow-up.ts", + lineRange: "+3-5", + selectedCode: "const retry = true;", + userNote: "Keep this context when retrying.", + }, + ]; + const [entry] = getPromptHistoryEntries([ + userMessage("compact", "/compact\nContinue after compaction", 1, { + compactionRequest: { + parsed: { + followUpContent: { + text: "Continue after compaction", + model: "openai:gpt-5", + agentId: "exec", + fileParts, + reviews, + }, + }, + }, + }), + ]); + + if (!entry) throw new Error("expected prompt history entry"); + expect(entry.fileCount).toBe(1); + expect(entry.fileParts).toEqual(fileParts); + expect(entry.reviews).toEqual(reviews); + expect(createPromptHistoryInsertPayload(entry)).toEqual({ + text: "/compact\nContinue after compaction", + mode: "replace", + fileParts, + reviews, + }); + }); }); diff --git a/src/browser/features/RightSidebar/promptHistoryEntries.ts b/src/browser/features/RightSidebar/promptHistoryEntries.ts index 3218f1c515..9438866959 100644 --- a/src/browser/features/RightSidebar/promptHistoryEntries.ts +++ b/src/browser/features/RightSidebar/promptHistoryEntries.ts @@ -41,8 +41,9 @@ export function getPromptHistoryEntries( return message.content.trim().length > 0 || (message.fileParts?.length ?? 0) > 0; }) .map((message) => { - const fileParts = message.fileParts ?? []; - const reviews = message.reviews ?? []; + const followUpContent = message.compactionRequest?.parsed.followUpContent; + const fileParts = followUpContent?.fileParts ?? message.fileParts ?? []; + const reviews = followUpContent?.reviews ?? message.reviews ?? []; const content = getEditableUserMessageText(message); return { historyId: message.historyId, From c5898f38b1a33ef30a736b8b554b804cfba200f9 Mon Sep 17 00:00:00 2001 From: Leonidas Zhak <70497898+LeonidasZhak@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:03:19 +0800 Subject: [PATCH 03/15] fix: preserve compaction follow-up metadata --- src/browser/features/ChatInput/index.tsx | 18 +++++++++++++-- .../RightSidebar/PromptHistoryTab.test.ts | 23 ++++++++++++++++++- .../RightSidebar/PromptHistoryTab.tsx | 1 + .../RightSidebar/promptHistoryEntries.ts | 4 +++- src/browser/utils/chatCommands.ts | 2 ++ src/browser/utils/chatEditing.ts | 5 ++++ src/common/constants/events.ts | 3 ++- 7 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index f09c6191ed..3c5e3749b0 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -503,6 +503,9 @@ const ChatInputInner: React.FC = (props) => { // draftReviews takes precedence when restoring or editing message drafts. const attachedReviews = variant === "workspace" ? (props.attachedReviews ?? []) : []; + const [draftMuxMetadataOverride, setDraftMuxMetadataOverride] = useState< + MuxMessageMetadata | undefined + >(undefined); const attachedReviewIdsSignature = attachedReviews.map((review) => review.id).join("\u0000"); const clearedAttachedReviewIdsRef = useRef( null @@ -1255,6 +1258,7 @@ const ChatInputInner: React.FC = (props) => { (pending: PendingUserMessage) => { applyDraftFromPending(pending, `restored-${Date.now()}`); setDraftReviews(pending.reviews); + setDraftMuxMetadataOverride(pending.muxMetadata); focusMessageInput(); }, [applyDraftFromPending, focusMessageInput, setDraftReviews] @@ -1772,20 +1776,23 @@ const ChatInputInner: React.FC = (props) => { mode?: "append" | "replace"; fileParts?: FilePart[]; reviews?: ReviewNoteDataForDisplay[]; + muxMetadata?: MuxMessageMetadata; }>; - const { text, mode = "append", fileParts, reviews } = customEvent.detail; + const { text, mode = "append", fileParts, reviews, muxMetadata } = customEvent.detail; const restoredIdPrefix = `restored-${Date.now()}`; const restoredPending = buildPendingFromRestoredInput({ content: text, fileParts: fileParts ?? [], reviews: reviews ?? [], idPrefix: restoredIdPrefix, + muxMetadata, }); const hasFileParts = restoredPending.fileParts.length > 0; const hasStagedAttachments = restoredPending.stagedAttachments.length > 0; const hasReviews = restoredPending.reviews.length > 0; - const hasDraftReplacementPayload = fileParts !== undefined || reviews !== undefined; + const hasDraftReplacementPayload = + fileParts !== undefined || reviews !== undefined || muxMetadata !== undefined; if (mode === "replace") { if (editingMessageForUi) { @@ -2259,6 +2266,7 @@ const ChatInputInner: React.FC = (props) => { onCancelEdit: commandOnCancelEdit, reviews: reviewsData, attachments, + followUpMuxMetadata: draftMuxMetadataOverride, fileParts: commandFileParts.length > 0 ? commandFileParts : undefined, onMessageSent: variant === "workspace" ? props.onMessageSent : undefined, onDetachAllReviews: variant === "workspace" ? props.onDetachAllReviews : undefined, @@ -2272,6 +2280,7 @@ const ChatInputInner: React.FC = (props) => { setInput(restoreInput); } else { setDraftReviews(null); + setDraftMuxMetadataOverride(undefined); if (variant === "workspace" && parsed.type === "compact") { if (reviewIdsForCheck.length > 0) { props.onCheckReviews?.(reviewIdsForCheck); @@ -2668,6 +2677,7 @@ const ChatInputInner: React.FC = (props) => { // Save current draft state for restoration on error const preSendDraft = getDraft(); const preSendReviews = draftReviews; + const preSendMuxMetadataOverride = draftMuxMetadataOverride; const editMessageForSend = editingMessageForUi; try { @@ -2713,6 +2723,7 @@ const ChatInputInner: React.FC = (props) => { text: appendStagedAttachmentNotice(parsed.continueMessage ?? "", attachments), fileParts: sendFileParts, reviews: reviewsData, + muxMetadata: draftMuxMetadataOverride, } : undefined, model: parsed.model, @@ -2780,6 +2791,7 @@ const ChatInputInner: React.FC = (props) => { // so they'll reappear naturally on failure (we only call onCheckReviews on success) setInput(""); setDraftReviews(null); + setDraftMuxMetadataOverride(undefined); setAttachments([]); setHideReviewsDuringSend(true); // Clear inline height style - VimTextArea's useLayoutEffect will handle sizing @@ -2839,6 +2851,7 @@ const ChatInputInner: React.FC = (props) => { setOptimisticallyDismissedEditId(null); setDraft(preSendDraft); setDraftReviews(preSendReviews); + setDraftMuxMetadataOverride(preSendMuxMetadataOverride); } else { // Track telemetry for successful message send telemetry.messageSent( @@ -2886,6 +2899,7 @@ const ChatInputInner: React.FC = (props) => { setOptimisticallyDismissedEditId(null); setDraft(preSendDraft); setDraftReviews(preSendReviews); + setDraftMuxMetadataOverride(preSendMuxMetadataOverride); } finally { setSendingCount((c) => c - 1); setHideReviewsDuringSend(false); diff --git a/src/browser/features/RightSidebar/PromptHistoryTab.test.ts b/src/browser/features/RightSidebar/PromptHistoryTab.test.ts index 63d83f6ae7..e2a82f0984 100644 --- a/src/browser/features/RightSidebar/PromptHistoryTab.test.ts +++ b/src/browser/features/RightSidebar/PromptHistoryTab.test.ts @@ -152,7 +152,7 @@ const marker = ''; }); }); - test("insert payload preserves compaction follow-up attachments and reviews", () => { + test("insert payload preserves compaction follow-up payloads", () => { const fileParts = [ { url: "data:text/plain;base64,SGVsbG8=", @@ -178,6 +178,13 @@ const marker = ''; agentId: "exec", fileParts, reviews, + muxMetadata: { + type: "agent-skill", + skillName: "tests", + scope: "global", + rawCommand: "/tests run focused tests", + commandPrefix: "/tests", + }, }, }, }, @@ -188,11 +195,25 @@ const marker = ''; expect(entry.fileCount).toBe(1); expect(entry.fileParts).toEqual(fileParts); expect(entry.reviews).toEqual(reviews); + expect(entry.muxMetadata).toEqual({ + type: "agent-skill", + skillName: "tests", + scope: "global", + rawCommand: "/tests run focused tests", + commandPrefix: "/tests", + }); expect(createPromptHistoryInsertPayload(entry)).toEqual({ text: "/compact\nContinue after compaction", mode: "replace", fileParts, reviews, + muxMetadata: { + type: "agent-skill", + skillName: "tests", + scope: "global", + rawCommand: "/tests run focused tests", + commandPrefix: "/tests", + }, }); }); }); diff --git a/src/browser/features/RightSidebar/PromptHistoryTab.tsx b/src/browser/features/RightSidebar/PromptHistoryTab.tsx index cf643ceffc..f6746f8294 100644 --- a/src/browser/features/RightSidebar/PromptHistoryTab.tsx +++ b/src/browser/features/RightSidebar/PromptHistoryTab.tsx @@ -74,6 +74,7 @@ export function createPromptHistoryInsertPayload( mode: "replace", fileParts: entry.fileParts ?? [], reviews: entry.reviews ?? [], + muxMetadata: entry.muxMetadata, }; } diff --git a/src/browser/features/RightSidebar/promptHistoryEntries.ts b/src/browser/features/RightSidebar/promptHistoryEntries.ts index 9438866959..1a64d79959 100644 --- a/src/browser/features/RightSidebar/promptHistoryEntries.ts +++ b/src/browser/features/RightSidebar/promptHistoryEntries.ts @@ -1,4 +1,4 @@ -import type { DisplayedMessage } from "@/common/types/message"; +import type { DisplayedMessage, MuxMessageMetadata } from "@/common/types/message"; import { getEditableUserMessageText } from "@/browser/utils/messages/messageUtils"; type UserMessage = Extract; @@ -15,6 +15,7 @@ export interface PromptHistoryEntry { fileCount: number; fileParts?: UserMessage["fileParts"]; reviews?: UserMessage["reviews"]; + muxMetadata?: MuxMessageMetadata; } function isCompletedLocalCommandOutput(message: UserMessage): boolean { @@ -55,6 +56,7 @@ export function getPromptHistoryEntries( fileCount: fileParts.length, ...(fileParts.length > 0 ? { fileParts } : {}), ...(reviews.length > 0 ? { reviews } : {}), + ...(followUpContent?.muxMetadata ? { muxMetadata: followUpContent.muxMetadata } : {}), }; }) .sort((left, right) => left.historySequence - right.historySequence); diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index fec2d7244d..3b9a31aa1f 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -1643,6 +1643,7 @@ export interface CommandHandlerContext { fileParts?: FilePart[]; /** Reviews attached to the message (from code review panel) */ reviews?: ReviewNoteData[]; + followUpMuxMetadata?: MuxMessageMetadata; editMessageId?: string; setInput: (value: string) => void; setAttachments: (attachments: ChatAttachment[]) => void; @@ -1789,6 +1790,7 @@ export async function handleCompactCommand( text: appendStagedAttachmentNotice(parsed.continueMessage ?? "", stagedAttachments), fileParts: context.fileParts, reviews: context.reviews, + muxMetadata: context.followUpMuxMetadata, } : undefined; diff --git a/src/browser/utils/chatEditing.ts b/src/browser/utils/chatEditing.ts index 2aaca093d2..00f4c85ed8 100644 --- a/src/browser/utils/chatEditing.ts +++ b/src/browser/utils/chatEditing.ts @@ -7,6 +7,7 @@ import { import type { CompactionFollowUpRequest, DisplayedUserMessage, + MuxMessageMetadata, QueuedMessage, ReviewNoteDataForDisplay, } from "@/common/types/message"; @@ -20,6 +21,7 @@ export interface PendingUserMessage extends Omit< fileParts: FilePart[]; stagedAttachments: StagedChatAttachment[]; reviews: ReviewNoteDataForDisplay[]; + muxMetadata?: MuxMessageMetadata; } export interface EditingMessageState { @@ -56,6 +58,7 @@ export function buildPendingFromRestoredInput(params: { fileParts: FilePart[]; reviews: ReviewNoteDataForDisplay[]; idPrefix: string; + muxMetadata?: MuxMessageMetadata; }): PendingUserMessage { const parsed = parseStagedAttachmentNotice(params.content); return { @@ -66,6 +69,7 @@ export function buildPendingFromRestoredInput(params: { params.idPrefix ), reviews: params.reviews, + muxMetadata: params.muxMetadata, }; } @@ -119,5 +123,6 @@ export const buildEditingStateFromCompaction = ( fileParts: followUp?.fileParts ?? [], stagedAttachments: stagedAttachmentsFromText(followUp?.text, `compaction-${messageId}`), reviews: followUp?.reviews ?? [], + muxMetadata: followUp?.muxMetadata, }, }); diff --git a/src/common/constants/events.ts b/src/common/constants/events.ts index 5a08e52517..e5ad56c412 100644 --- a/src/common/constants/events.ts +++ b/src/common/constants/events.ts @@ -6,7 +6,7 @@ */ import type { ThinkingLevel } from "@/common/types/thinking"; -import type { ReviewNoteDataForDisplay } from "@/common/types/message"; +import type { MuxMessageMetadata, ReviewNoteDataForDisplay } from "@/common/types/message"; import type { FilePart } from "@/common/orpc/schemas"; export const CUSTOM_EVENTS = { @@ -157,6 +157,7 @@ export interface CustomEventPayloads { /** In replace mode, presence means replace the whole draft, even when empty. */ fileParts?: FilePart[]; reviews?: ReviewNoteDataForDisplay[]; + muxMetadata?: MuxMessageMetadata; }; [CUSTOM_EVENTS.NAVIGATE_TO_TRANSCRIPT_MESSAGE]: { workspaceId: string; From 8ace11133b3a1d0379a720995c27c46d5a3d4158 Mon Sep 17 00:00:00 2001 From: Leonidas Zhak <70497898+LeonidasZhak@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:31:44 +0800 Subject: [PATCH 04/15] fix: clear stale compaction metadata overrides --- src/browser/features/ChatInput/index.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 3c5e3749b0..3bf06b0210 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -684,6 +684,7 @@ const ChatInputInner: React.FC = (props) => { ); const preEditDraftRef = useRef({ text: "", attachments: [] }); const preEditReviewsRef = useRef(null); + const preEditMuxMetadataOverrideRef = useRef(undefined); const { open } = useSettings(); const { selectedWorkspace } = useWorkspaceContext(); const { agentId, currentAgent } = useAgent(); @@ -1267,6 +1268,7 @@ const ChatInputInner: React.FC = (props) => { const restorePreEditDraft = useCallback(() => { setDraft(preEditDraftRef.current); setDraftReviews(preEditReviewsRef.current); + setDraftMuxMetadataOverride(preEditMuxMetadataOverrideRef.current); }, [setDraft, setDraftReviews]); // Method to restore text to input (used by compaction cancel) @@ -1357,8 +1359,10 @@ const ChatInputInner: React.FC = (props) => { if (editingMessage) { preEditDraftRef.current = getDraft(); preEditReviewsRef.current = draftReviews; + preEditMuxMetadataOverrideRef.current = draftMuxMetadataOverride; applyDraftFromPending(editingMessage.pending, `edit-${editingMessage.id}`); setDraftReviews(editingMessage.pending.reviews); + setDraftMuxMetadataOverride(editingMessage.pending.muxMetadata); // Auto-resize textarea and focus setTimeout(() => { if (inputRef.current) { @@ -1809,6 +1813,7 @@ const ChatInputInner: React.FC = (props) => { restoreDraft(restoredPending); } else { + setDraftMuxMetadataOverride(undefined); restoreText(restoredPending.content); } } else if (hasFileParts || hasStagedAttachments || hasReviews) { From e80785513b2354d0955094977fde2823ef7804e6 Mon Sep 17 00:00:00 2001 From: Leonidas Zhak <70497898+LeonidasZhak@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:57:22 +0800 Subject: [PATCH 05/15] fix: clear compaction metadata on composer reset --- src/browser/features/ChatInput/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 3bf06b0210..c547d955a6 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -1853,6 +1853,7 @@ const ChatInputInner: React.FC = (props) => { setInput(""); setAttachments([]); setDraftReviews(null); + setDraftMuxMetadataOverride(undefined); onDetachAllReviewsForComposerClear?.(); if (inputRef.current) { inputRef.current.style.height = ""; From cd687314d59dc082e37ed4d7bbcf64dfd1a96376 Mon Sep 17 00:00:00 2001 From: Leonidas Zhak <70497898+LeonidasZhak@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:32:41 +0800 Subject: [PATCH 06/15] =?UTF-8?q?=F0=9F=A4=96=20fix:=20preserve=20staged?= =?UTF-8?q?=20prompt=20history=20attachments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep Prompt History cards showing clean prompt text while restoring staged attachment notices into the insert payload, without duplicating serialized review text. --- _Generated with `mux` • Model: `GPT-5` • Thinking: `unknown` • Cost: ``_ --- .../RightSidebar/PromptHistoryTab.test.ts | 48 +++++++++++++++++++ .../RightSidebar/PromptHistoryTab.tsx | 2 +- .../RightSidebar/promptHistoryEntries.ts | 14 ++++-- 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/browser/features/RightSidebar/PromptHistoryTab.test.ts b/src/browser/features/RightSidebar/PromptHistoryTab.test.ts index e2a82f0984..1655aa14ab 100644 --- a/src/browser/features/RightSidebar/PromptHistoryTab.test.ts +++ b/src/browser/features/RightSidebar/PromptHistoryTab.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { appendStagedAttachmentNotice } from "@/browser/features/ChatInput/stagedAttachments"; import type { DisplayedMessage } from "@/common/types/message"; import type { ReviewNoteData } from "@/common/types/review"; import { createPromptHistoryInsertPayload } from "./PromptHistoryTab"; @@ -152,6 +153,53 @@ const marker = ''; }); }); + test("insert payload preserves staged attachments without duplicating review text", () => { + const reviews: ReviewNoteData[] = [ + { + filePath: "src/example.ts", + lineRange: "+10-12", + selectedCode: "const archived = true;", + userNote: "Use this with the ZIP.", + }, + ]; + const serializedReview = ` +Re src/example.ts:+10-12 +\`\`\` +const archived = true; +\`\`\` +> Use this with the ZIP. +`; + const stagedAttachment = { + kind: "staged" as const, + id: "zip-1", + filename: "archive.zip", + mediaType: "application/zip", + sizeBytes: 128, + stagedPath: ".mux/user-attachments/id/archive.zip", + }; + const visibleText = "Inspect the attached archive."; + const messageContent = appendStagedAttachmentNotice(`${serializedReview}\n\n${visibleText}`, [ + stagedAttachment, + ]); + const [entry] = getPromptHistoryEntries([ + userMessage("with-staged", messageContent, 1, { + reviews, + }), + ]); + + if (!entry) throw new Error("expected prompt history entry"); + expect(entry.content).toBe(visibleText); + expect(entry.fileCount).toBe(1); + expect(entry.insertContent).toContain(""); + expect(entry.insertContent).not.toContain(""); + expect(createPromptHistoryInsertPayload(entry)).toEqual({ + text: appendStagedAttachmentNotice(visibleText, [stagedAttachment]), + mode: "replace", + fileParts: [], + reviews, + }); + }); + test("insert payload preserves compaction follow-up payloads", () => { const fileParts = [ { diff --git a/src/browser/features/RightSidebar/PromptHistoryTab.tsx b/src/browser/features/RightSidebar/PromptHistoryTab.tsx index f6746f8294..8b656bfabf 100644 --- a/src/browser/features/RightSidebar/PromptHistoryTab.tsx +++ b/src/browser/features/RightSidebar/PromptHistoryTab.tsx @@ -70,7 +70,7 @@ export function createPromptHistoryInsertPayload( entry: PromptHistoryEntry ): CustomEventPayloads[typeof CUSTOM_EVENTS.UPDATE_CHAT_INPUT] { return { - text: entry.content, + text: entry.insertContent ?? entry.content, mode: "replace", fileParts: entry.fileParts ?? [], reviews: entry.reviews ?? [], diff --git a/src/browser/features/RightSidebar/promptHistoryEntries.ts b/src/browser/features/RightSidebar/promptHistoryEntries.ts index 1a64d79959..b25df459f1 100644 --- a/src/browser/features/RightSidebar/promptHistoryEntries.ts +++ b/src/browser/features/RightSidebar/promptHistoryEntries.ts @@ -1,5 +1,6 @@ import type { DisplayedMessage, MuxMessageMetadata } from "@/common/types/message"; -import { getEditableUserMessageText } from "@/browser/utils/messages/messageUtils"; +import { appendStagedAttachmentNotice } from "@/browser/features/ChatInput/stagedAttachments"; +import { getEditableUserMessageDraftContent } from "@/browser/utils/messages/messageUtils"; type UserMessage = Extract; const LOCAL_COMMAND_STDOUT_OPEN_TAG = ""; @@ -8,6 +9,7 @@ const LOCAL_COMMAND_STDOUT_CLOSE_TAG = ""; export interface PromptHistoryEntry { historyId: string; content: string; + insertContent?: string; historySequence: number; timestamp?: number; commandPrefix?: string; @@ -45,15 +47,21 @@ export function getPromptHistoryEntries( const followUpContent = message.compactionRequest?.parsed.followUpContent; const fileParts = followUpContent?.fileParts ?? message.fileParts ?? []; const reviews = followUpContent?.reviews ?? message.reviews ?? []; - const content = getEditableUserMessageText(message); + const draft = getEditableUserMessageDraftContent(message); + const content = draft.text; + const insertContent = + draft.stagedAttachments.length > 0 + ? appendStagedAttachmentNotice(content, draft.stagedAttachments) + : content; return { historyId: message.historyId, content, + ...(insertContent !== content ? { insertContent } : {}), historySequence: message.historySequence, timestamp: message.timestamp, commandPrefix: message.commandPrefix, isSideQuestion: message.isSideQuestion === true, - fileCount: fileParts.length, + fileCount: fileParts.length + draft.stagedAttachments.length, ...(fileParts.length > 0 ? { fileParts } : {}), ...(reviews.length > 0 ? { reviews } : {}), ...(followUpContent?.muxMetadata ? { muxMetadata: followUpContent.muxMetadata } : {}), From 0aa8bc13350a81cba24a92c09dff4f710b57d1a2 Mon Sep 17 00:00:00 2001 From: Leonidas Zhak <70497898+LeonidasZhak@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:50:16 +0800 Subject: [PATCH 07/15] =?UTF-8?q?=F0=9F=A4=96=20fix:=20clear=20stale=20pro?= =?UTF-8?q?mpt=20history=20metadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bind restored prompt-history mux metadata to the exact restored composer text so editing the restored follow-up cannot send stale skill or raw-command metadata. --- _Generated with `mux` • Model: `GPT-5` • Thinking: `unknown` • Cost: ``_ --- src/browser/features/ChatInput/index.tsx | 64 +++++++++++++++++------- src/browser/utils/chatEditing.test.ts | 28 +++++++++++ src/browser/utils/chatEditing.ts | 8 +++ 3 files changed, 83 insertions(+), 17 deletions(-) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index c547d955a6..312c4b0f35 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -139,6 +139,7 @@ import { } from "@/browser/utils/attachmentsHandling"; import { buildPendingFromRestoredInput, + getRestoredMuxMetadataForCurrentText, type PendingUserMessage, } from "@/browser/utils/chatEditing"; @@ -506,6 +507,23 @@ const ChatInputInner: React.FC = (props) => { const [draftMuxMetadataOverride, setDraftMuxMetadataOverride] = useState< MuxMessageMetadata | undefined >(undefined); + const draftMuxMetadataSourceTextRef = useRef(null); + const setDraftMuxMetadataOverrideForContent = useCallback( + (metadata: MuxMessageMetadata | undefined, content: string) => { + draftMuxMetadataSourceTextRef.current = metadata ? content : null; + setDraftMuxMetadataOverride(metadata); + }, + [] + ); + const clearDraftMuxMetadataOverride = useCallback(() => { + draftMuxMetadataSourceTextRef.current = null; + setDraftMuxMetadataOverride(undefined); + }, []); + const activeDraftMuxMetadataOverride = getRestoredMuxMetadataForCurrentText({ + currentText: input, + sourceText: draftMuxMetadataSourceTextRef.current, + muxMetadata: draftMuxMetadataOverride, + }); const attachedReviewIdsSignature = attachedReviews.map((review) => review.id).join("\u0000"); const clearedAttachedReviewIdsRef = useRef( null @@ -1259,25 +1277,29 @@ const ChatInputInner: React.FC = (props) => { (pending: PendingUserMessage) => { applyDraftFromPending(pending, `restored-${Date.now()}`); setDraftReviews(pending.reviews); - setDraftMuxMetadataOverride(pending.muxMetadata); + setDraftMuxMetadataOverrideForContent(pending.muxMetadata, pending.content); focusMessageInput(); }, - [applyDraftFromPending, focusMessageInput, setDraftReviews] + [applyDraftFromPending, focusMessageInput, setDraftMuxMetadataOverrideForContent] ); const restorePreEditDraft = useCallback(() => { setDraft(preEditDraftRef.current); setDraftReviews(preEditReviewsRef.current); - setDraftMuxMetadataOverride(preEditMuxMetadataOverrideRef.current); - }, [setDraft, setDraftReviews]); + setDraftMuxMetadataOverrideForContent( + preEditMuxMetadataOverrideRef.current, + preEditDraftRef.current.text + ); + }, [setDraft, setDraftReviews, setDraftMuxMetadataOverrideForContent]); // Method to restore text to input (used by compaction cancel) const restoreText = useCallback( (text: string) => { + clearDraftMuxMetadataOverride(); setInput(() => text); focusMessageInput(); }, - [focusMessageInput, setInput] + [clearDraftMuxMetadataOverride, focusMessageInput, setInput] ); // Method to append text to input (used by Code Review notes) @@ -1359,10 +1381,13 @@ const ChatInputInner: React.FC = (props) => { if (editingMessage) { preEditDraftRef.current = getDraft(); preEditReviewsRef.current = draftReviews; - preEditMuxMetadataOverrideRef.current = draftMuxMetadataOverride; + preEditMuxMetadataOverrideRef.current = activeDraftMuxMetadataOverride; applyDraftFromPending(editingMessage.pending, `edit-${editingMessage.id}`); setDraftReviews(editingMessage.pending.reviews); - setDraftMuxMetadataOverride(editingMessage.pending.muxMetadata); + setDraftMuxMetadataOverrideForContent( + editingMessage.pending.muxMetadata, + editingMessage.pending.content + ); // Auto-resize textarea and focus setTimeout(() => { if (inputRef.current) { @@ -1813,7 +1838,6 @@ const ChatInputInner: React.FC = (props) => { restoreDraft(restoredPending); } else { - setDraftMuxMetadataOverride(undefined); restoreText(restoredPending.content); } } else if (hasFileParts || hasStagedAttachments || hasReviews) { @@ -1853,7 +1877,7 @@ const ChatInputInner: React.FC = (props) => { setInput(""); setAttachments([]); setDraftReviews(null); - setDraftMuxMetadataOverride(undefined); + clearDraftMuxMetadataOverride(); onDetachAllReviewsForComposerClear?.(); if (inputRef.current) { inputRef.current.style.height = ""; @@ -1863,7 +1887,13 @@ const ChatInputInner: React.FC = (props) => { window.addEventListener(CUSTOM_EVENTS.CLEAR_CHAT_COMPOSER, handler as EventListener); return () => window.removeEventListener(CUSTOM_EVENTS.CLEAR_CHAT_COMPOSER, handler as EventListener); - }, [onDetachAllReviewsForComposerClear, setAttachments, setInput, workspaceIdForComposerClear]); + }, [ + clearDraftMuxMetadataOverride, + onDetachAllReviewsForComposerClear, + setAttachments, + setInput, + workspaceIdForComposerClear, + ]); // Allow external components to open the Model Selector useEffect(() => { @@ -2272,7 +2302,7 @@ const ChatInputInner: React.FC = (props) => { onCancelEdit: commandOnCancelEdit, reviews: reviewsData, attachments, - followUpMuxMetadata: draftMuxMetadataOverride, + followUpMuxMetadata: activeDraftMuxMetadataOverride, fileParts: commandFileParts.length > 0 ? commandFileParts : undefined, onMessageSent: variant === "workspace" ? props.onMessageSent : undefined, onDetachAllReviews: variant === "workspace" ? props.onDetachAllReviews : undefined, @@ -2286,7 +2316,7 @@ const ChatInputInner: React.FC = (props) => { setInput(restoreInput); } else { setDraftReviews(null); - setDraftMuxMetadataOverride(undefined); + clearDraftMuxMetadataOverride(); if (variant === "workspace" && parsed.type === "compact") { if (reviewIdsForCheck.length > 0) { props.onCheckReviews?.(reviewIdsForCheck); @@ -2683,7 +2713,7 @@ const ChatInputInner: React.FC = (props) => { // Save current draft state for restoration on error const preSendDraft = getDraft(); const preSendReviews = draftReviews; - const preSendMuxMetadataOverride = draftMuxMetadataOverride; + const preSendMuxMetadataOverride = activeDraftMuxMetadataOverride; const editMessageForSend = editingMessageForUi; try { @@ -2729,7 +2759,7 @@ const ChatInputInner: React.FC = (props) => { text: appendStagedAttachmentNotice(parsed.continueMessage ?? "", attachments), fileParts: sendFileParts, reviews: reviewsData, - muxMetadata: draftMuxMetadataOverride, + muxMetadata: activeDraftMuxMetadataOverride, } : undefined, model: parsed.model, @@ -2797,7 +2827,7 @@ const ChatInputInner: React.FC = (props) => { // so they'll reappear naturally on failure (we only call onCheckReviews on success) setInput(""); setDraftReviews(null); - setDraftMuxMetadataOverride(undefined); + clearDraftMuxMetadataOverride(); setAttachments([]); setHideReviewsDuringSend(true); // Clear inline height style - VimTextArea's useLayoutEffect will handle sizing @@ -2857,7 +2887,7 @@ const ChatInputInner: React.FC = (props) => { setOptimisticallyDismissedEditId(null); setDraft(preSendDraft); setDraftReviews(preSendReviews); - setDraftMuxMetadataOverride(preSendMuxMetadataOverride); + setDraftMuxMetadataOverrideForContent(preSendMuxMetadataOverride, preSendDraft.text); } else { // Track telemetry for successful message send telemetry.messageSent( @@ -2905,7 +2935,7 @@ const ChatInputInner: React.FC = (props) => { setOptimisticallyDismissedEditId(null); setDraft(preSendDraft); setDraftReviews(preSendReviews); - setDraftMuxMetadataOverride(preSendMuxMetadataOverride); + setDraftMuxMetadataOverrideForContent(preSendMuxMetadataOverride, preSendDraft.text); } finally { setSendingCount((c) => c - 1); setHideReviewsDuringSend(false); diff --git a/src/browser/utils/chatEditing.test.ts b/src/browser/utils/chatEditing.test.ts index 7683b2f549..36f934cb64 100644 --- a/src/browser/utils/chatEditing.test.ts +++ b/src/browser/utils/chatEditing.test.ts @@ -3,6 +3,7 @@ import { appendStagedAttachmentNotice } from "@/browser/features/ChatInput/stage import type { CompactionFollowUpRequest, DisplayedUserMessage, + MuxMessageMetadata, QueuedMessage, } from "@/common/types/message"; import { @@ -11,6 +12,7 @@ import { buildPendingFromDisplayed, buildPendingFromRestoredInput, canEditDisplayedUserMessage, + getRestoredMuxMetadataForCurrentText, normalizeQueuedMessage, } from "./chatEditing"; @@ -130,6 +132,32 @@ describe("canEditDisplayedUserMessage", () => { }); }); + test("keeps restored mux metadata only while the restored text is unchanged", () => { + const metadata: MuxMessageMetadata = { + type: "agent-skill", + rawCommand: "/test-skill investigate", + commandPrefix: "/test-skill", + skillName: "test-skill", + scope: "project", + }; + + expect( + getRestoredMuxMetadataForCurrentText({ + currentText: "investigate", + sourceText: "investigate", + muxMetadata: metadata, + }) + ).toBe(metadata); + + expect( + getRestoredMuxMetadataForCurrentText({ + currentText: "plain follow-up", + sourceText: "investigate", + muxMetadata: metadata, + }) + ).toBeUndefined(); + }); + test("restores staged ZIPs from compaction follow-up content", () => { const followUp: CompactionFollowUpRequest = { text: appendStagedAttachmentNotice("Continue after compaction.", [STAGED_ATTACHMENT]), diff --git a/src/browser/utils/chatEditing.ts b/src/browser/utils/chatEditing.ts index 00f4c85ed8..78776c38c8 100644 --- a/src/browser/utils/chatEditing.ts +++ b/src/browser/utils/chatEditing.ts @@ -73,6 +73,14 @@ export function buildPendingFromRestoredInput(params: { }; } +export function getRestoredMuxMetadataForCurrentText(params: { + currentText: string; + sourceText: string | null; + muxMetadata?: MuxMessageMetadata; +}): MuxMessageMetadata | undefined { + return params.sourceText === params.currentText ? params.muxMetadata : undefined; +} + const LOCAL_COMMAND_STDOUT_OPEN_TAG = ""; const LOCAL_COMMAND_STDOUT_CLOSE_TAG = ""; From 2f25d4f6cf8722a91c7e93a3f3314fdedaea9d62 Mon Sep 17 00:00:00 2001 From: Leonidas Zhak <70497898+LeonidasZhak@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:02:40 +0800 Subject: [PATCH 08/15] =?UTF-8?q?=F0=9F=A4=96=20fix:=20invalidate=20restor?= =?UTF-8?q?ed=20metadata=20on=20draft=20edits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bind restored prompt-history mux metadata to the full draft payload so editing text, attachments, or reviews clears stale follow-up command metadata. --- _Generated with `mux` • Model: `GPT-5` • Thinking: `unknown` • Cost: ``_ --- src/browser/features/ChatInput/index.tsx | 81 ++++++++++++++++-------- src/browser/utils/chatEditing.test.ts | 55 +++++++++++++--- src/browser/utils/chatEditing.ts | 58 +++++++++++++++-- 3 files changed, 154 insertions(+), 40 deletions(-) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 312c4b0f35..70c5df2973 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -139,8 +139,10 @@ import { } from "@/browser/utils/attachmentsHandling"; import { buildPendingFromRestoredInput, - getRestoredMuxMetadataForCurrentText, + getRestoredDraftPayloadSignature, + getRestoredMuxMetadataForCurrentDraft, type PendingUserMessage, + type RestoredDraftPayload, } from "@/browser/utils/chatEditing"; import type { AgentSkillDescriptor } from "@/common/types/agentSkill"; @@ -507,23 +509,20 @@ const ChatInputInner: React.FC = (props) => { const [draftMuxMetadataOverride, setDraftMuxMetadataOverride] = useState< MuxMessageMetadata | undefined >(undefined); - const draftMuxMetadataSourceTextRef = useRef(null); - const setDraftMuxMetadataOverrideForContent = useCallback( - (metadata: MuxMessageMetadata | undefined, content: string) => { - draftMuxMetadataSourceTextRef.current = metadata ? content : null; + const draftMuxMetadataSourceSignatureRef = useRef(null); + const setDraftMuxMetadataOverrideForDraft = useCallback( + (metadata: MuxMessageMetadata | undefined, draft: RestoredDraftPayload) => { + draftMuxMetadataSourceSignatureRef.current = metadata + ? getRestoredDraftPayloadSignature(draft) + : null; setDraftMuxMetadataOverride(metadata); }, [] ); const clearDraftMuxMetadataOverride = useCallback(() => { - draftMuxMetadataSourceTextRef.current = null; + draftMuxMetadataSourceSignatureRef.current = null; setDraftMuxMetadataOverride(undefined); }, []); - const activeDraftMuxMetadataOverride = getRestoredMuxMetadataForCurrentText({ - currentText: input, - sourceText: draftMuxMetadataSourceTextRef.current, - muxMetadata: draftMuxMetadataOverride, - }); const attachedReviewIdsSignature = attachedReviews.map((review) => review.id).join("\u0000"); const clearedAttachedReviewIdsRef = useRef( null @@ -1092,6 +1091,15 @@ const ChatInputInner: React.FC = (props) => { : attachedReviews.length > 0 ? attachedReviews.map((review) => review.data) : undefined; + const activeDraftMuxMetadataOverride = getRestoredMuxMetadataForCurrentDraft({ + currentDraft: { + text: input, + attachments, + reviews: reviewData, + }, + sourceSignature: draftMuxMetadataSourceSignatureRef.current, + muxMetadata: draftMuxMetadataOverride, + }); const reviewIdsForCheck = reviewOverrideActive ? [] : attachedReviews.map((review) => review.id); const reviewPanelItems: Review[] = reviewOverrideActive ? draftReviewItems.map((data) => ({ @@ -1264,10 +1272,12 @@ const ChatInputInner: React.FC = (props) => { ...attachment, id: `${attachmentKeyPrefix}-staged-${index}`, })); + const nextAttachments = [...providerAttachments, ...stagedAttachments]; setDraft({ text: pending.content, - attachments: [...providerAttachments, ...stagedAttachments], + attachments: nextAttachments, }); + return nextAttachments; }, [setDraft] ); @@ -1275,22 +1285,27 @@ const ChatInputInner: React.FC = (props) => { // Restore a full pending draft (text + attachments + reviews), e.g. queued message edits. const restoreDraft = useCallback( (pending: PendingUserMessage) => { - applyDraftFromPending(pending, `restored-${Date.now()}`); + const restoredAttachments = applyDraftFromPending(pending, `restored-${Date.now()}`); setDraftReviews(pending.reviews); - setDraftMuxMetadataOverrideForContent(pending.muxMetadata, pending.content); + setDraftMuxMetadataOverrideForDraft(pending.muxMetadata, { + text: pending.content, + attachments: restoredAttachments, + reviews: pending.reviews, + }); focusMessageInput(); }, - [applyDraftFromPending, focusMessageInput, setDraftMuxMetadataOverrideForContent] + [applyDraftFromPending, focusMessageInput, setDraftMuxMetadataOverrideForDraft] ); const restorePreEditDraft = useCallback(() => { setDraft(preEditDraftRef.current); setDraftReviews(preEditReviewsRef.current); - setDraftMuxMetadataOverrideForContent( - preEditMuxMetadataOverrideRef.current, - preEditDraftRef.current.text - ); - }, [setDraft, setDraftReviews, setDraftMuxMetadataOverrideForContent]); + setDraftMuxMetadataOverrideForDraft(preEditMuxMetadataOverrideRef.current, { + text: preEditDraftRef.current.text, + attachments: preEditDraftRef.current.attachments, + reviews: preEditReviewsRef.current ?? undefined, + }); + }, [setDraft, setDraftReviews, setDraftMuxMetadataOverrideForDraft]); // Method to restore text to input (used by compaction cancel) const restoreText = useCallback( @@ -1382,12 +1397,16 @@ const ChatInputInner: React.FC = (props) => { preEditDraftRef.current = getDraft(); preEditReviewsRef.current = draftReviews; preEditMuxMetadataOverrideRef.current = activeDraftMuxMetadataOverride; - applyDraftFromPending(editingMessage.pending, `edit-${editingMessage.id}`); - setDraftReviews(editingMessage.pending.reviews); - setDraftMuxMetadataOverrideForContent( - editingMessage.pending.muxMetadata, - editingMessage.pending.content + const editingAttachments = applyDraftFromPending( + editingMessage.pending, + `edit-${editingMessage.id}` ); + setDraftReviews(editingMessage.pending.reviews); + setDraftMuxMetadataOverrideForDraft(editingMessage.pending.muxMetadata, { + text: editingMessage.pending.content, + attachments: editingAttachments, + reviews: editingMessage.pending.reviews, + }); // Auto-resize textarea and focus setTimeout(() => { if (inputRef.current) { @@ -2887,7 +2906,11 @@ const ChatInputInner: React.FC = (props) => { setOptimisticallyDismissedEditId(null); setDraft(preSendDraft); setDraftReviews(preSendReviews); - setDraftMuxMetadataOverrideForContent(preSendMuxMetadataOverride, preSendDraft.text); + setDraftMuxMetadataOverrideForDraft(preSendMuxMetadataOverride, { + text: preSendDraft.text, + attachments: preSendDraft.attachments, + reviews: preSendReviews ?? undefined, + }); } else { // Track telemetry for successful message send telemetry.messageSent( @@ -2935,7 +2958,11 @@ const ChatInputInner: React.FC = (props) => { setOptimisticallyDismissedEditId(null); setDraft(preSendDraft); setDraftReviews(preSendReviews); - setDraftMuxMetadataOverrideForContent(preSendMuxMetadataOverride, preSendDraft.text); + setDraftMuxMetadataOverrideForDraft(preSendMuxMetadataOverride, { + text: preSendDraft.text, + attachments: preSendDraft.attachments, + reviews: preSendReviews ?? undefined, + }); } finally { setSendingCount((c) => c - 1); setHideReviewsDuringSend(false); diff --git a/src/browser/utils/chatEditing.test.ts b/src/browser/utils/chatEditing.test.ts index 36f934cb64..4c7d2f6edc 100644 --- a/src/browser/utils/chatEditing.test.ts +++ b/src/browser/utils/chatEditing.test.ts @@ -12,7 +12,8 @@ import { buildPendingFromDisplayed, buildPendingFromRestoredInput, canEditDisplayedUserMessage, - getRestoredMuxMetadataForCurrentText, + getRestoredDraftPayloadSignature, + getRestoredMuxMetadataForCurrentDraft, normalizeQueuedMessage, } from "./chatEditing"; @@ -36,6 +37,13 @@ const STAGED_ATTACHMENT = { stagedPath: ".mux/user-attachments/id/archive.zip", }; +const REVIEW_NOTE = { + filePath: "src/app.ts", + lineRange: "+1", + selectedCode: "const value = 1;", + userNote: "Review this", +}; + describe("canEditDisplayedUserMessage", () => { test("excludes goal-synthetic messages from all edit paths", () => { expect(canEditDisplayedUserMessage(userMessage({ isGoalContinuation: true }))).toBe(false); @@ -132,7 +140,7 @@ describe("canEditDisplayedUserMessage", () => { }); }); - test("keeps restored mux metadata only while the restored text is unchanged", () => { + test("keeps restored mux metadata only while the restored draft payload is unchanged", () => { const metadata: MuxMessageMetadata = { type: "agent-skill", rawCommand: "/test-skill investigate", @@ -140,19 +148,50 @@ describe("canEditDisplayedUserMessage", () => { skillName: "test-skill", scope: "project", }; + const sourceDraft = { + text: "investigate", + attachments: [STAGED_ATTACHMENT], + reviews: [REVIEW_NOTE], + }; + const sourceSignature = getRestoredDraftPayloadSignature(sourceDraft); expect( - getRestoredMuxMetadataForCurrentText({ - currentText: "investigate", - sourceText: "investigate", + getRestoredMuxMetadataForCurrentDraft({ + currentDraft: sourceDraft, + sourceSignature, muxMetadata: metadata, }) ).toBe(metadata); expect( - getRestoredMuxMetadataForCurrentText({ - currentText: "plain follow-up", - sourceText: "investigate", + getRestoredMuxMetadataForCurrentDraft({ + currentDraft: { + ...sourceDraft, + text: "plain follow-up", + }, + sourceSignature, + muxMetadata: metadata, + }) + ).toBeUndefined(); + + expect( + getRestoredMuxMetadataForCurrentDraft({ + currentDraft: { + ...sourceDraft, + attachments: [], + }, + sourceSignature, + muxMetadata: metadata, + }) + ).toBeUndefined(); + + expect( + getRestoredMuxMetadataForCurrentDraft({ + currentDraft: { + ...sourceDraft, + reviews: [{ ...REVIEW_NOTE, userNote: "Different review" }], + }, + sourceSignature, muxMetadata: metadata, }) ).toBeUndefined(); diff --git a/src/browser/utils/chatEditing.ts b/src/browser/utils/chatEditing.ts index 78776c38c8..f36141a5a8 100644 --- a/src/browser/utils/chatEditing.ts +++ b/src/browser/utils/chatEditing.ts @@ -1,5 +1,8 @@ import type { FilePart } from "@/common/orpc/types"; -import type { StagedChatAttachment } from "@/browser/features/ChatInput/ChatAttachments"; +import type { + ChatAttachment, + StagedChatAttachment, +} from "@/browser/features/ChatInput/ChatAttachments"; import { displayStagedAttachmentsToChatAttachments, parseStagedAttachmentNotice, @@ -73,12 +76,57 @@ export function buildPendingFromRestoredInput(params: { }; } -export function getRestoredMuxMetadataForCurrentText(params: { - currentText: string; - sourceText: string | null; +export interface RestoredDraftPayload { + text: string; + attachments: ChatAttachment[]; + reviews?: ReviewNoteDataForDisplay[]; +} + +export function getRestoredDraftPayloadSignature(payload: RestoredDraftPayload): string { + return JSON.stringify({ + text: payload.text, + attachments: payload.attachments.map((attachment) => + attachment.kind === "staged" + ? { + kind: attachment.kind, + id: attachment.id, + filename: attachment.filename, + mediaType: attachment.mediaType, + sizeBytes: attachment.sizeBytes, + stagedPath: attachment.stagedPath, + } + : { + kind: attachment.kind, + id: attachment.id, + filename: attachment.filename, + mediaType: attachment.mediaType, + resizeInfo: attachment.resizeInfo, + url: attachment.url, + } + ), + reviews: (payload.reviews ?? []).map((review) => ({ + filePath: review.filePath, + lineRange: review.lineRange, + newStart: review.newStart, + oldStart: review.oldStart, + selectedCode: review.selectedCode, + selectedDiff: review.selectedDiff, + userNote: review.userNote, + })), + }); +} + +export function getRestoredMuxMetadataForCurrentDraft(params: { + currentDraft: RestoredDraftPayload; + sourceSignature: string | null; muxMetadata?: MuxMessageMetadata; }): MuxMessageMetadata | undefined { - return params.sourceText === params.currentText ? params.muxMetadata : undefined; + if (!params.muxMetadata || params.sourceSignature === null) { + return undefined; + } + return getRestoredDraftPayloadSignature(params.currentDraft) === params.sourceSignature + ? params.muxMetadata + : undefined; } const LOCAL_COMMAND_STDOUT_OPEN_TAG = ""; From 833fd63519fdb5ed4acfc03f73bc7cbaefafe42b Mon Sep 17 00:00:00 2001 From: Leonidas Zhak <70497898+LeonidasZhak@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:37:14 +0800 Subject: [PATCH 09/15] =?UTF-8?q?=F0=9F=A4=96=20fix:=20merge=20later=20rev?= =?UTF-8?q?iews=20into=20restored=20drafts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep prompt-history restored review drafts receptive to reviews attached later from the Code Review tab, then detach merged parent reviews so send state stays coherent. --- _Generated with `mux` • Model: `GPT-5` • Thinking: `unknown` • Cost: ``_ --- src/browser/features/ChatInput/index.tsx | 54 ++++++++++++++++++++++-- src/browser/utils/chatEditing.test.ts | 36 ++++++++++++++++ src/browser/utils/chatEditing.ts | 50 ++++++++++++++++++++++ 3 files changed, 137 insertions(+), 3 deletions(-) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 70c5df2973..f8306a169e 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -141,6 +141,7 @@ import { buildPendingFromRestoredInput, getRestoredDraftPayloadSignature, getRestoredMuxMetadataForCurrentDraft, + mergeNewAttachedReviewsIntoDraft, type PendingUserMessage, type RestoredDraftPayload, } from "@/browser/utils/chatEditing"; @@ -205,6 +206,7 @@ import { appendStagedAttachmentNotice, getStagedAttachments } from "./stagedAtta import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; const AWAITING_NEW_ATTACHED_REVIEWS = Symbol("awaiting-new-attached-reviews"); +const EMPTY_ATTACHED_REVIEWS: Review[] = []; // localStorage quotas are environment-dependent and relatively small. // Be conservative here so we can warn the user before writes start failing. @@ -503,9 +505,13 @@ const ChatInputInner: React.FC = (props) => { const workspaceIdForComposerClear = variant === "workspace" ? props.workspaceId : null; const onDetachAllReviewsForComposerClear = variant === "workspace" ? props.onDetachAllReviews : undefined; + const onDetachReviewForDraftMerge = variant === "workspace" ? props.onDetachReview : undefined; // draftReviews takes precedence when restoring or editing message drafts. - const attachedReviews = variant === "workspace" ? (props.attachedReviews ?? []) : []; + const attachedReviews = + variant === "workspace" + ? (props.attachedReviews ?? EMPTY_ATTACHED_REVIEWS) + : EMPTY_ATTACHED_REVIEWS; const [draftMuxMetadataOverride, setDraftMuxMetadataOverride] = useState< MuxMessageMetadata | undefined >(undefined); @@ -527,6 +533,7 @@ const ChatInputInner: React.FC = (props) => { const clearedAttachedReviewIdsRef = useRef( null ); + const draftReviewMergedAttachedIdsRef = useRef | null>(null); const draftReviewIdsByValueRef = useRef(new WeakMap()); const nextDraftReviewIdRef = useRef(0); const isDraftReviewData = (value: unknown): value is ReviewNoteDataForDisplay => @@ -592,6 +599,29 @@ const ChatInputInner: React.FC = (props) => { } }, [attachedReviewIdsSignature, attachedReviews.length, draftReviews]); + // Restored drafts with review notes own their review list, but reviews attached later + // from the Code Review tab should still be visible and sent with that draft. + useEffect(() => { + const mergedAttachedReviewIds = draftReviewMergedAttachedIdsRef.current; + if (draftReviews === null || mergedAttachedReviewIds === null || attachedReviews.length === 0) { + return; + } + + const result = mergeNewAttachedReviewsIntoDraft({ + draftReviews, + attachedReviews, + mergedAttachedReviewIds, + }); + draftReviewMergedAttachedIdsRef.current = result.mergedAttachedReviewIds; + + if (result.reviews !== draftReviews) { + setDraftReviews(result.reviews); + for (const reviewId of result.addedReviewIds) { + onDetachReviewForDraftMerge?.(reviewId); + } + } + }, [attachedReviewIdsSignature, attachedReviews, draftReviews, onDetachReviewForDraftMerge]); + // Creation sends can resolve after navigation; guard draft clears on unmounted inputs. const isMountedRef = useRef(true); useEffect(() => { @@ -1287,6 +1317,8 @@ const ChatInputInner: React.FC = (props) => { (pending: PendingUserMessage) => { const restoredAttachments = applyDraftFromPending(pending, `restored-${Date.now()}`); setDraftReviews(pending.reviews); + draftReviewMergedAttachedIdsRef.current = + pending.reviews.length > 0 ? new Set(attachedReviews.map((review) => review.id)) : null; setDraftMuxMetadataOverrideForDraft(pending.muxMetadata, { text: pending.content, attachments: restoredAttachments, @@ -1294,18 +1326,22 @@ const ChatInputInner: React.FC = (props) => { }); focusMessageInput(); }, - [applyDraftFromPending, focusMessageInput, setDraftMuxMetadataOverrideForDraft] + [applyDraftFromPending, attachedReviews, focusMessageInput, setDraftMuxMetadataOverrideForDraft] ); const restorePreEditDraft = useCallback(() => { setDraft(preEditDraftRef.current); setDraftReviews(preEditReviewsRef.current); + draftReviewMergedAttachedIdsRef.current = + preEditReviewsRef.current && preEditReviewsRef.current.length > 0 + ? new Set(attachedReviews.map((review) => review.id)) + : null; setDraftMuxMetadataOverrideForDraft(preEditMuxMetadataOverrideRef.current, { text: preEditDraftRef.current.text, attachments: preEditDraftRef.current.attachments, reviews: preEditReviewsRef.current ?? undefined, }); - }, [setDraft, setDraftReviews, setDraftMuxMetadataOverrideForDraft]); + }, [attachedReviews, setDraft, setDraftReviews, setDraftMuxMetadataOverrideForDraft]); // Method to restore text to input (used by compaction cancel) const restoreText = useCallback( @@ -1402,6 +1438,10 @@ const ChatInputInner: React.FC = (props) => { `edit-${editingMessage.id}` ); setDraftReviews(editingMessage.pending.reviews); + draftReviewMergedAttachedIdsRef.current = + editingMessage.pending.reviews.length > 0 + ? new Set(attachedReviews.map((review) => review.id)) + : null; setDraftMuxMetadataOverrideForDraft(editingMessage.pending.muxMetadata, { text: editingMessage.pending.content, attachments: editingAttachments, @@ -2906,6 +2946,10 @@ const ChatInputInner: React.FC = (props) => { setOptimisticallyDismissedEditId(null); setDraft(preSendDraft); setDraftReviews(preSendReviews); + draftReviewMergedAttachedIdsRef.current = + preSendReviews && preSendReviews.length > 0 + ? new Set(attachedReviews.map((review) => review.id)) + : null; setDraftMuxMetadataOverrideForDraft(preSendMuxMetadataOverride, { text: preSendDraft.text, attachments: preSendDraft.attachments, @@ -2958,6 +3002,10 @@ const ChatInputInner: React.FC = (props) => { setOptimisticallyDismissedEditId(null); setDraft(preSendDraft); setDraftReviews(preSendReviews); + draftReviewMergedAttachedIdsRef.current = + preSendReviews && preSendReviews.length > 0 + ? new Set(attachedReviews.map((review) => review.id)) + : null; setDraftMuxMetadataOverrideForDraft(preSendMuxMetadataOverride, { text: preSendDraft.text, attachments: preSendDraft.attachments, diff --git a/src/browser/utils/chatEditing.test.ts b/src/browser/utils/chatEditing.test.ts index 4c7d2f6edc..9348512803 100644 --- a/src/browser/utils/chatEditing.test.ts +++ b/src/browser/utils/chatEditing.test.ts @@ -14,6 +14,7 @@ import { canEditDisplayedUserMessage, getRestoredDraftPayloadSignature, getRestoredMuxMetadataForCurrentDraft, + mergeNewAttachedReviewsIntoDraft, normalizeQueuedMessage, } from "./chatEditing"; @@ -197,6 +198,41 @@ describe("canEditDisplayedUserMessage", () => { ).toBeUndefined(); }); + test("merges newly attached reviews into restored draft reviews", () => { + const laterReview = { + filePath: "src/later.ts", + lineRange: "+8", + selectedCode: "const later = true;", + userNote: "Add this too", + }; + + const result = mergeNewAttachedReviewsIntoDraft({ + draftReviews: [REVIEW_NOTE], + attachedReviews: [ + { + id: "existing-parent-review", + data: REVIEW_NOTE, + status: "attached", + createdAt: 1, + }, + { + id: "new-parent-review", + data: laterReview, + status: "attached", + createdAt: 2, + }, + ], + mergedAttachedReviewIds: new Set(["existing-parent-review"]), + }); + + expect(result.reviews).toEqual([REVIEW_NOTE, laterReview]); + expect(result.addedReviewIds).toEqual(["new-parent-review"]); + expect([...result.mergedAttachedReviewIds].sort()).toEqual([ + "existing-parent-review", + "new-parent-review", + ]); + }); + test("restores staged ZIPs from compaction follow-up content", () => { const followUp: CompactionFollowUpRequest = { text: appendStagedAttachmentNotice("Continue after compaction.", [STAGED_ATTACHMENT]), diff --git a/src/browser/utils/chatEditing.ts b/src/browser/utils/chatEditing.ts index f36141a5a8..2a87e479f7 100644 --- a/src/browser/utils/chatEditing.ts +++ b/src/browser/utils/chatEditing.ts @@ -14,6 +14,7 @@ import type { QueuedMessage, ReviewNoteDataForDisplay, } from "@/common/types/message"; +import type { Review } from "@/common/types/review"; import { getEditableUserMessageDraftContent } from "@/browser/utils/messages/messageUtils"; // Keep pending edit data normalized with required arrays so edits can't drop attachments/reviews. @@ -129,6 +130,55 @@ export function getRestoredMuxMetadataForCurrentDraft(params: { : undefined; } +function getReviewNoteSignature(review: ReviewNoteDataForDisplay): string { + return JSON.stringify({ + filePath: review.filePath, + lineRange: review.lineRange, + newStart: review.newStart, + oldStart: review.oldStart, + selectedCode: review.selectedCode, + selectedDiff: review.selectedDiff, + userNote: review.userNote, + }); +} + +export function mergeNewAttachedReviewsIntoDraft(params: { + draftReviews: ReviewNoteDataForDisplay[]; + attachedReviews: Review[]; + mergedAttachedReviewIds: ReadonlySet; +}): { + reviews: ReviewNoteDataForDisplay[]; + mergedAttachedReviewIds: Set; + addedReviewIds: string[]; +} { + const mergedAttachedReviewIds = new Set(params.mergedAttachedReviewIds); + const existingReviewSignatures = new Set(params.draftReviews.map(getReviewNoteSignature)); + const additions: ReviewNoteDataForDisplay[] = []; + const addedReviewIds: string[] = []; + + for (const review of params.attachedReviews) { + if (mergedAttachedReviewIds.has(review.id)) { + continue; + } + + mergedAttachedReviewIds.add(review.id); + const signature = getReviewNoteSignature(review.data); + if (existingReviewSignatures.has(signature)) { + continue; + } + + existingReviewSignatures.add(signature); + additions.push(review.data); + addedReviewIds.push(review.id); + } + + return { + reviews: additions.length > 0 ? [...params.draftReviews, ...additions] : params.draftReviews, + mergedAttachedReviewIds, + addedReviewIds, + }; +} + const LOCAL_COMMAND_STDOUT_OPEN_TAG = ""; const LOCAL_COMMAND_STDOUT_CLOSE_TAG = ""; From b7efcd45171f5dfce8eaef459c0fbe36246f18ff Mon Sep 17 00:00:00 2001 From: Leonidas Zhak <70497898+LeonidasZhak@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:52:28 +0800 Subject: [PATCH 10/15] =?UTF-8?q?=F0=9F=A4=96=20fix:=20check=20merged=20re?= =?UTF-8?q?stored=20draft=20reviews?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track parent review IDs merged into restored prompt-history drafts so successful sends mark those reviews checked after their contents are included. --- _Generated with `mux` • Model: `GPT-5` • Thinking: `unknown` • Cost: ``_ --- src/browser/features/ChatInput/index.tsx | 27 ++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index f8306a169e..2e524c7477 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -534,6 +534,7 @@ const ChatInputInner: React.FC = (props) => { null ); const draftReviewMergedAttachedIdsRef = useRef | null>(null); + const draftReviewIdsForCheckRef = useRef(new Map()); const draftReviewIdsByValueRef = useRef(new WeakMap()); const nextDraftReviewIdRef = useRef(0); const isDraftReviewData = (value: unknown): value is ReviewNoteDataForDisplay => @@ -559,9 +560,10 @@ const ChatInputInner: React.FC = (props) => { }); const removeDraftReview = (reviewId: string) => - withDraftReview(reviewId, (prev, reviewIndex) => - prev.filter((_, index) => index !== reviewIndex) - ); + withDraftReview(reviewId, (prev, reviewIndex) => { + draftReviewIdsForCheckRef.current.delete(reviewId); + return prev.filter((_, index) => index !== reviewIndex); + }); const updateDraftReviewNote = (reviewId: string, newNote: string) => withDraftReview(reviewId, (prev, reviewIndex) => { @@ -596,6 +598,7 @@ const ChatInputInner: React.FC = (props) => { ) { clearedAttachedReviewIdsRef.current = null; setDraftReviews(null); + draftReviewIdsForCheckRef.current.clear(); } }, [attachedReviewIdsSignature, attachedReviews.length, draftReviews]); @@ -617,6 +620,10 @@ const ChatInputInner: React.FC = (props) => { if (result.reviews !== draftReviews) { setDraftReviews(result.reviews); for (const reviewId of result.addedReviewIds) { + const review = attachedReviews.find((attachedReview) => attachedReview.id === reviewId); + if (review) { + draftReviewIdsForCheckRef.current.set(getDraftReviewId(review.data), review.id); + } onDetachReviewForDraftMerge?.(reviewId); } } @@ -1130,7 +1137,9 @@ const ChatInputInner: React.FC = (props) => { sourceSignature: draftMuxMetadataSourceSignatureRef.current, muxMetadata: draftMuxMetadataOverride, }); - const reviewIdsForCheck = reviewOverrideActive ? [] : attachedReviews.map((review) => review.id); + const reviewIdsForCheck = reviewOverrideActive + ? [...draftReviewIdsForCheckRef.current.values()] + : attachedReviews.map((review) => review.id); const reviewPanelItems: Review[] = reviewOverrideActive ? draftReviewItems.map((data) => ({ id: getDraftReviewId(data), @@ -1317,6 +1326,7 @@ const ChatInputInner: React.FC = (props) => { (pending: PendingUserMessage) => { const restoredAttachments = applyDraftFromPending(pending, `restored-${Date.now()}`); setDraftReviews(pending.reviews); + draftReviewIdsForCheckRef.current.clear(); draftReviewMergedAttachedIdsRef.current = pending.reviews.length > 0 ? new Set(attachedReviews.map((review) => review.id)) : null; setDraftMuxMetadataOverrideForDraft(pending.muxMetadata, { @@ -1332,6 +1342,7 @@ const ChatInputInner: React.FC = (props) => { const restorePreEditDraft = useCallback(() => { setDraft(preEditDraftRef.current); setDraftReviews(preEditReviewsRef.current); + draftReviewIdsForCheckRef.current.clear(); draftReviewMergedAttachedIdsRef.current = preEditReviewsRef.current && preEditReviewsRef.current.length > 0 ? new Set(attachedReviews.map((review) => review.id)) @@ -1438,6 +1449,7 @@ const ChatInputInner: React.FC = (props) => { `edit-${editingMessage.id}` ); setDraftReviews(editingMessage.pending.reviews); + draftReviewIdsForCheckRef.current.clear(); draftReviewMergedAttachedIdsRef.current = editingMessage.pending.reviews.length > 0 ? new Set(attachedReviews.map((review) => review.id)) @@ -1936,6 +1948,7 @@ const ChatInputInner: React.FC = (props) => { setInput(""); setAttachments([]); setDraftReviews(null); + draftReviewIdsForCheckRef.current.clear(); clearDraftMuxMetadataOverride(); onDetachAllReviewsForComposerClear?.(); if (inputRef.current) { @@ -2375,6 +2388,7 @@ const ChatInputInner: React.FC = (props) => { setInput(restoreInput); } else { setDraftReviews(null); + draftReviewIdsForCheckRef.current.clear(); clearDraftMuxMetadataOverride(); if (variant === "workspace" && parsed.type === "compact") { if (reviewIdsForCheck.length > 0) { @@ -2773,6 +2787,7 @@ const ChatInputInner: React.FC = (props) => { const preSendDraft = getDraft(); const preSendReviews = draftReviews; const preSendMuxMetadataOverride = activeDraftMuxMetadataOverride; + const preSendDraftReviewIdsForCheck = new Map(draftReviewIdsForCheckRef.current); const editMessageForSend = editingMessageForUi; try { @@ -2886,6 +2901,7 @@ const ChatInputInner: React.FC = (props) => { // so they'll reappear naturally on failure (we only call onCheckReviews on success) setInput(""); setDraftReviews(null); + draftReviewIdsForCheckRef.current.clear(); clearDraftMuxMetadataOverride(); setAttachments([]); setHideReviewsDuringSend(true); @@ -2946,6 +2962,7 @@ const ChatInputInner: React.FC = (props) => { setOptimisticallyDismissedEditId(null); setDraft(preSendDraft); setDraftReviews(preSendReviews); + draftReviewIdsForCheckRef.current = new Map(preSendDraftReviewIdsForCheck); draftReviewMergedAttachedIdsRef.current = preSendReviews && preSendReviews.length > 0 ? new Set(attachedReviews.map((review) => review.id)) @@ -2980,6 +2997,7 @@ const ChatInputInner: React.FC = (props) => { if (sentReviewIds.length > 0) { props.onCheckReviews?.(sentReviewIds); } + draftReviewIdsForCheckRef.current.clear(); // Exit editing mode if we were editing if (editMessageForSend && props.onCancelEdit) { @@ -3002,6 +3020,7 @@ const ChatInputInner: React.FC = (props) => { setOptimisticallyDismissedEditId(null); setDraft(preSendDraft); setDraftReviews(preSendReviews); + draftReviewIdsForCheckRef.current = new Map(preSendDraftReviewIdsForCheck); draftReviewMergedAttachedIdsRef.current = preSendReviews && preSendReviews.length > 0 ? new Set(attachedReviews.map((review) => review.id)) From a8af5a0b2903ad92f194c2cd7743b45390fc11ae Mon Sep 17 00:00:00 2001 From: Leonidas Zhak <70497898+LeonidasZhak@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:45:32 +0800 Subject: [PATCH 11/15] =?UTF-8?q?=F0=9F=A4=96=20fix:=20preserve=20duplicat?= =?UTF-8?q?e=20restored=20review=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track duplicate parent review IDs merged into restored prompt-history drafts by review signature so successful sends mark those reviews checked even when no new draft note is appended. --- _Generated with `mux` • Model: `GPT-5` • Thinking: `unknown` • Cost: ``_ --- src/browser/features/ChatInput/index.tsx | 60 ++++++++++++++++-------- src/browser/utils/chatEditing.test.ts | 11 ++++- src/browser/utils/chatEditing.ts | 11 +++-- 3 files changed, 55 insertions(+), 27 deletions(-) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 2e524c7477..9da40d6efa 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -139,6 +139,7 @@ import { } from "@/browser/utils/attachmentsHandling"; import { buildPendingFromRestoredInput, + getReviewNoteSignature, getRestoredDraftPayloadSignature, getRestoredMuxMetadataForCurrentDraft, mergeNewAttachedReviewsIntoDraft, @@ -534,7 +535,7 @@ const ChatInputInner: React.FC = (props) => { null ); const draftReviewMergedAttachedIdsRef = useRef | null>(null); - const draftReviewIdsForCheckRef = useRef(new Map()); + const draftReviewCheckIdsBySignatureRef = useRef(new Map()); const draftReviewIdsByValueRef = useRef(new WeakMap()); const nextDraftReviewIdRef = useRef(0); const isDraftReviewData = (value: unknown): value is ReviewNoteDataForDisplay => @@ -561,7 +562,10 @@ const ChatInputInner: React.FC = (props) => { const removeDraftReview = (reviewId: string) => withDraftReview(reviewId, (prev, reviewIndex) => { - draftReviewIdsForCheckRef.current.delete(reviewId); + const review = prev[reviewIndex]; + if (review) { + draftReviewCheckIdsBySignatureRef.current.delete(getReviewNoteSignature(review)); + } return prev.filter((_, index) => index !== reviewIndex); }); @@ -598,7 +602,7 @@ const ChatInputInner: React.FC = (props) => { ) { clearedAttachedReviewIdsRef.current = null; setDraftReviews(null); - draftReviewIdsForCheckRef.current.clear(); + draftReviewCheckIdsBySignatureRef.current.clear(); } }, [attachedReviewIdsSignature, attachedReviews.length, draftReviews]); @@ -619,13 +623,17 @@ const ChatInputInner: React.FC = (props) => { if (result.reviews !== draftReviews) { setDraftReviews(result.reviews); - for (const reviewId of result.addedReviewIds) { - const review = attachedReviews.find((attachedReview) => attachedReview.id === reviewId); - if (review) { - draftReviewIdsForCheckRef.current.set(getDraftReviewId(review.data), review.id); - } - onDetachReviewForDraftMerge?.(reviewId); + } + + for (const reviewId of result.mergedReviewIds) { + const review = attachedReviews.find((attachedReview) => attachedReview.id === reviewId); + if (review) { + draftReviewCheckIdsBySignatureRef.current.set( + getReviewNoteSignature(review.data), + review.id + ); } + onDetachReviewForDraftMerge?.(reviewId); } }, [attachedReviewIdsSignature, attachedReviews, draftReviews, onDetachReviewForDraftMerge]); @@ -1138,7 +1146,15 @@ const ChatInputInner: React.FC = (props) => { muxMetadata: draftMuxMetadataOverride, }); const reviewIdsForCheck = reviewOverrideActive - ? [...draftReviewIdsForCheckRef.current.values()] + ? [ + ...new Set( + draftReviewItems + .map((review) => + draftReviewCheckIdsBySignatureRef.current.get(getReviewNoteSignature(review)) + ) + .filter((reviewId): reviewId is string => reviewId !== undefined) + ), + ] : attachedReviews.map((review) => review.id); const reviewPanelItems: Review[] = reviewOverrideActive ? draftReviewItems.map((data) => ({ @@ -1326,7 +1342,7 @@ const ChatInputInner: React.FC = (props) => { (pending: PendingUserMessage) => { const restoredAttachments = applyDraftFromPending(pending, `restored-${Date.now()}`); setDraftReviews(pending.reviews); - draftReviewIdsForCheckRef.current.clear(); + draftReviewCheckIdsBySignatureRef.current.clear(); draftReviewMergedAttachedIdsRef.current = pending.reviews.length > 0 ? new Set(attachedReviews.map((review) => review.id)) : null; setDraftMuxMetadataOverrideForDraft(pending.muxMetadata, { @@ -1342,7 +1358,7 @@ const ChatInputInner: React.FC = (props) => { const restorePreEditDraft = useCallback(() => { setDraft(preEditDraftRef.current); setDraftReviews(preEditReviewsRef.current); - draftReviewIdsForCheckRef.current.clear(); + draftReviewCheckIdsBySignatureRef.current.clear(); draftReviewMergedAttachedIdsRef.current = preEditReviewsRef.current && preEditReviewsRef.current.length > 0 ? new Set(attachedReviews.map((review) => review.id)) @@ -1449,7 +1465,7 @@ const ChatInputInner: React.FC = (props) => { `edit-${editingMessage.id}` ); setDraftReviews(editingMessage.pending.reviews); - draftReviewIdsForCheckRef.current.clear(); + draftReviewCheckIdsBySignatureRef.current.clear(); draftReviewMergedAttachedIdsRef.current = editingMessage.pending.reviews.length > 0 ? new Set(attachedReviews.map((review) => review.id)) @@ -1948,7 +1964,7 @@ const ChatInputInner: React.FC = (props) => { setInput(""); setAttachments([]); setDraftReviews(null); - draftReviewIdsForCheckRef.current.clear(); + draftReviewCheckIdsBySignatureRef.current.clear(); clearDraftMuxMetadataOverride(); onDetachAllReviewsForComposerClear?.(); if (inputRef.current) { @@ -2388,7 +2404,7 @@ const ChatInputInner: React.FC = (props) => { setInput(restoreInput); } else { setDraftReviews(null); - draftReviewIdsForCheckRef.current.clear(); + draftReviewCheckIdsBySignatureRef.current.clear(); clearDraftMuxMetadataOverride(); if (variant === "workspace" && parsed.type === "compact") { if (reviewIdsForCheck.length > 0) { @@ -2787,7 +2803,9 @@ const ChatInputInner: React.FC = (props) => { const preSendDraft = getDraft(); const preSendReviews = draftReviews; const preSendMuxMetadataOverride = activeDraftMuxMetadataOverride; - const preSendDraftReviewIdsForCheck = new Map(draftReviewIdsForCheckRef.current); + const preSendDraftReviewCheckIdsBySignature = new Map( + draftReviewCheckIdsBySignatureRef.current + ); const editMessageForSend = editingMessageForUi; try { @@ -2901,7 +2919,7 @@ const ChatInputInner: React.FC = (props) => { // so they'll reappear naturally on failure (we only call onCheckReviews on success) setInput(""); setDraftReviews(null); - draftReviewIdsForCheckRef.current.clear(); + draftReviewCheckIdsBySignatureRef.current.clear(); clearDraftMuxMetadataOverride(); setAttachments([]); setHideReviewsDuringSend(true); @@ -2962,7 +2980,9 @@ const ChatInputInner: React.FC = (props) => { setOptimisticallyDismissedEditId(null); setDraft(preSendDraft); setDraftReviews(preSendReviews); - draftReviewIdsForCheckRef.current = new Map(preSendDraftReviewIdsForCheck); + draftReviewCheckIdsBySignatureRef.current = new Map( + preSendDraftReviewCheckIdsBySignature + ); draftReviewMergedAttachedIdsRef.current = preSendReviews && preSendReviews.length > 0 ? new Set(attachedReviews.map((review) => review.id)) @@ -2997,7 +3017,7 @@ const ChatInputInner: React.FC = (props) => { if (sentReviewIds.length > 0) { props.onCheckReviews?.(sentReviewIds); } - draftReviewIdsForCheckRef.current.clear(); + draftReviewCheckIdsBySignatureRef.current.clear(); // Exit editing mode if we were editing if (editMessageForSend && props.onCancelEdit) { @@ -3020,7 +3040,7 @@ const ChatInputInner: React.FC = (props) => { setOptimisticallyDismissedEditId(null); setDraft(preSendDraft); setDraftReviews(preSendReviews); - draftReviewIdsForCheckRef.current = new Map(preSendDraftReviewIdsForCheck); + draftReviewCheckIdsBySignatureRef.current = new Map(preSendDraftReviewCheckIdsBySignature); draftReviewMergedAttachedIdsRef.current = preSendReviews && preSendReviews.length > 0 ? new Set(attachedReviews.map((review) => review.id)) diff --git a/src/browser/utils/chatEditing.test.ts b/src/browser/utils/chatEditing.test.ts index 9348512803..794f4b8f9c 100644 --- a/src/browser/utils/chatEditing.test.ts +++ b/src/browser/utils/chatEditing.test.ts @@ -215,19 +215,26 @@ describe("canEditDisplayedUserMessage", () => { status: "attached", createdAt: 1, }, + { + id: "duplicate-parent-review", + data: REVIEW_NOTE, + status: "attached", + createdAt: 2, + }, { id: "new-parent-review", data: laterReview, status: "attached", - createdAt: 2, + createdAt: 3, }, ], mergedAttachedReviewIds: new Set(["existing-parent-review"]), }); expect(result.reviews).toEqual([REVIEW_NOTE, laterReview]); - expect(result.addedReviewIds).toEqual(["new-parent-review"]); + expect(result.mergedReviewIds).toEqual(["duplicate-parent-review", "new-parent-review"]); expect([...result.mergedAttachedReviewIds].sort()).toEqual([ + "duplicate-parent-review", "existing-parent-review", "new-parent-review", ]); diff --git a/src/browser/utils/chatEditing.ts b/src/browser/utils/chatEditing.ts index 2a87e479f7..d79a82b440 100644 --- a/src/browser/utils/chatEditing.ts +++ b/src/browser/utils/chatEditing.ts @@ -130,7 +130,7 @@ export function getRestoredMuxMetadataForCurrentDraft(params: { : undefined; } -function getReviewNoteSignature(review: ReviewNoteDataForDisplay): string { +export function getReviewNoteSignature(review: ReviewNoteDataForDisplay): string { return JSON.stringify({ filePath: review.filePath, lineRange: review.lineRange, @@ -149,12 +149,12 @@ export function mergeNewAttachedReviewsIntoDraft(params: { }): { reviews: ReviewNoteDataForDisplay[]; mergedAttachedReviewIds: Set; - addedReviewIds: string[]; + mergedReviewIds: string[]; } { const mergedAttachedReviewIds = new Set(params.mergedAttachedReviewIds); const existingReviewSignatures = new Set(params.draftReviews.map(getReviewNoteSignature)); const additions: ReviewNoteDataForDisplay[] = []; - const addedReviewIds: string[] = []; + const mergedReviewIds: string[] = []; for (const review of params.attachedReviews) { if (mergedAttachedReviewIds.has(review.id)) { @@ -164,18 +164,19 @@ export function mergeNewAttachedReviewsIntoDraft(params: { mergedAttachedReviewIds.add(review.id); const signature = getReviewNoteSignature(review.data); if (existingReviewSignatures.has(signature)) { + mergedReviewIds.push(review.id); continue; } existingReviewSignatures.add(signature); additions.push(review.data); - addedReviewIds.push(review.id); + mergedReviewIds.push(review.id); } return { reviews: additions.length > 0 ? [...params.draftReviews, ...additions] : params.draftReviews, mergedAttachedReviewIds, - addedReviewIds, + mergedReviewIds, }; } From 355e3750c30878ccc06e0bbb0b44aaf284c7de93 Mon Sep 17 00:00:00 2001 From: Leonidas Zhak <70497898+LeonidasZhak@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:58:32 +0800 Subject: [PATCH 12/15] =?UTF-8?q?=F0=9F=A4=96=20fix:=20keep=20all=20duplic?= =?UTF-8?q?ate=20review=20check=20IDs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store merged restored-draft review check IDs as sets per review signature so duplicate parent reviews with the same note all get checked after a successful send. --- _Generated with `mux` • Model: `GPT-5` • Thinking: `unknown` • Cost: ``_ --- src/browser/features/ChatInput/index.tsx | 36 ++++++++++++++++-------- src/browser/utils/chatEditing.test.ts | 15 ++++++++-- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 9da40d6efa..c1b9abda49 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -208,6 +208,13 @@ import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; const AWAITING_NEW_ATTACHED_REVIEWS = Symbol("awaiting-new-attached-reviews"); const EMPTY_ATTACHED_REVIEWS: Review[] = []; +type DraftReviewCheckIdsBySignature = Map>; + +function cloneDraftReviewCheckIdsBySignature( + source: DraftReviewCheckIdsBySignature +): DraftReviewCheckIdsBySignature { + return new Map(Array.from(source, ([signature, reviewIds]) => [signature, new Set(reviewIds)])); +} // localStorage quotas are environment-dependent and relatively small. // Be conservative here so we can warn the user before writes start failing. @@ -535,7 +542,7 @@ const ChatInputInner: React.FC = (props) => { null ); const draftReviewMergedAttachedIdsRef = useRef | null>(null); - const draftReviewCheckIdsBySignatureRef = useRef(new Map()); + const draftReviewCheckIdsBySignatureRef = useRef(new Map()); const draftReviewIdsByValueRef = useRef(new WeakMap()); const nextDraftReviewIdRef = useRef(0); const isDraftReviewData = (value: unknown): value is ReviewNoteDataForDisplay => @@ -628,10 +635,13 @@ const ChatInputInner: React.FC = (props) => { for (const reviewId of result.mergedReviewIds) { const review = attachedReviews.find((attachedReview) => attachedReview.id === reviewId); if (review) { - draftReviewCheckIdsBySignatureRef.current.set( - getReviewNoteSignature(review.data), - review.id - ); + const signature = getReviewNoteSignature(review.data); + const reviewIds = draftReviewCheckIdsBySignatureRef.current.get(signature); + if (reviewIds) { + reviewIds.add(review.id); + } else { + draftReviewCheckIdsBySignatureRef.current.set(signature, new Set([review.id])); + } } onDetachReviewForDraftMerge?.(reviewId); } @@ -1148,11 +1158,11 @@ const ChatInputInner: React.FC = (props) => { const reviewIdsForCheck = reviewOverrideActive ? [ ...new Set( - draftReviewItems - .map((review) => - draftReviewCheckIdsBySignatureRef.current.get(getReviewNoteSignature(review)) + draftReviewItems.flatMap((review) => + Array.from( + draftReviewCheckIdsBySignatureRef.current.get(getReviewNoteSignature(review)) ?? [] ) - .filter((reviewId): reviewId is string => reviewId !== undefined) + ) ), ] : attachedReviews.map((review) => review.id); @@ -2803,7 +2813,7 @@ const ChatInputInner: React.FC = (props) => { const preSendDraft = getDraft(); const preSendReviews = draftReviews; const preSendMuxMetadataOverride = activeDraftMuxMetadataOverride; - const preSendDraftReviewCheckIdsBySignature = new Map( + const preSendDraftReviewCheckIdsBySignature = cloneDraftReviewCheckIdsBySignature( draftReviewCheckIdsBySignatureRef.current ); const editMessageForSend = editingMessageForUi; @@ -2980,7 +2990,7 @@ const ChatInputInner: React.FC = (props) => { setOptimisticallyDismissedEditId(null); setDraft(preSendDraft); setDraftReviews(preSendReviews); - draftReviewCheckIdsBySignatureRef.current = new Map( + draftReviewCheckIdsBySignatureRef.current = cloneDraftReviewCheckIdsBySignature( preSendDraftReviewCheckIdsBySignature ); draftReviewMergedAttachedIdsRef.current = @@ -3040,7 +3050,9 @@ const ChatInputInner: React.FC = (props) => { setOptimisticallyDismissedEditId(null); setDraft(preSendDraft); setDraftReviews(preSendReviews); - draftReviewCheckIdsBySignatureRef.current = new Map(preSendDraftReviewCheckIdsBySignature); + draftReviewCheckIdsBySignatureRef.current = cloneDraftReviewCheckIdsBySignature( + preSendDraftReviewCheckIdsBySignature + ); draftReviewMergedAttachedIdsRef.current = preSendReviews && preSendReviews.length > 0 ? new Set(attachedReviews.map((review) => review.id)) diff --git a/src/browser/utils/chatEditing.test.ts b/src/browser/utils/chatEditing.test.ts index 794f4b8f9c..d3d78be37c 100644 --- a/src/browser/utils/chatEditing.test.ts +++ b/src/browser/utils/chatEditing.test.ts @@ -221,22 +221,33 @@ describe("canEditDisplayedUserMessage", () => { status: "attached", createdAt: 2, }, + { + id: "second-duplicate-parent-review", + data: REVIEW_NOTE, + status: "attached", + createdAt: 3, + }, { id: "new-parent-review", data: laterReview, status: "attached", - createdAt: 3, + createdAt: 4, }, ], mergedAttachedReviewIds: new Set(["existing-parent-review"]), }); expect(result.reviews).toEqual([REVIEW_NOTE, laterReview]); - expect(result.mergedReviewIds).toEqual(["duplicate-parent-review", "new-parent-review"]); + expect(result.mergedReviewIds).toEqual([ + "duplicate-parent-review", + "second-duplicate-parent-review", + "new-parent-review", + ]); expect([...result.mergedAttachedReviewIds].sort()).toEqual([ "duplicate-parent-review", "existing-parent-review", "new-parent-review", + "second-duplicate-parent-review", ]); }); From 6e41e3ba35c0efafc888d59b2763954f26d7d6bf Mon Sep 17 00:00:00 2001 From: Leonidas Zhak <70497898+LeonidasZhak@users.noreply.github.com> Date: Thu, 2 Jul 2026 04:16:17 +0800 Subject: [PATCH 13/15] =?UTF-8?q?=F0=9F=A4=96=20fix:=20keep=20restored=20r?= =?UTF-8?q?eview=20checks=20across=20edits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Key restored-draft parent review check IDs by stable draft review IDs so editing a merged review note before sending still checks every detached parent review on success. --- _Generated with `mux` • Model: `GPT-5` • Thinking: `unknown` • Cost: ``_ --- src/browser/features/ChatInput/index.tsx | 67 +++++++++++++----------- 1 file changed, 35 insertions(+), 32 deletions(-) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index c1b9abda49..4967449145 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -208,12 +208,14 @@ import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; const AWAITING_NEW_ATTACHED_REVIEWS = Symbol("awaiting-new-attached-reviews"); const EMPTY_ATTACHED_REVIEWS: Review[] = []; -type DraftReviewCheckIdsBySignature = Map>; +type DraftReviewCheckIdsByDraftId = Map>; -function cloneDraftReviewCheckIdsBySignature( - source: DraftReviewCheckIdsBySignature -): DraftReviewCheckIdsBySignature { - return new Map(Array.from(source, ([signature, reviewIds]) => [signature, new Set(reviewIds)])); +function cloneDraftReviewCheckIdsByDraftId( + source: DraftReviewCheckIdsByDraftId +): DraftReviewCheckIdsByDraftId { + return new Map( + Array.from(source, ([draftReviewId, reviewIds]) => [draftReviewId, new Set(reviewIds)]) + ); } // localStorage quotas are environment-dependent and relatively small. @@ -542,7 +544,7 @@ const ChatInputInner: React.FC = (props) => { null ); const draftReviewMergedAttachedIdsRef = useRef | null>(null); - const draftReviewCheckIdsBySignatureRef = useRef(new Map()); + const draftReviewCheckIdsByDraftIdRef = useRef(new Map()); const draftReviewIdsByValueRef = useRef(new WeakMap()); const nextDraftReviewIdRef = useRef(0); const isDraftReviewData = (value: unknown): value is ReviewNoteDataForDisplay => @@ -569,10 +571,7 @@ const ChatInputInner: React.FC = (props) => { const removeDraftReview = (reviewId: string) => withDraftReview(reviewId, (prev, reviewIndex) => { - const review = prev[reviewIndex]; - if (review) { - draftReviewCheckIdsBySignatureRef.current.delete(getReviewNoteSignature(review)); - } + draftReviewCheckIdsByDraftIdRef.current.delete(reviewId); return prev.filter((_, index) => index !== reviewIndex); }); @@ -609,7 +608,7 @@ const ChatInputInner: React.FC = (props) => { ) { clearedAttachedReviewIdsRef.current = null; setDraftReviews(null); - draftReviewCheckIdsBySignatureRef.current.clear(); + draftReviewCheckIdsByDraftIdRef.current.clear(); } }, [attachedReviewIdsSignature, attachedReviews.length, draftReviews]); @@ -636,11 +635,17 @@ const ChatInputInner: React.FC = (props) => { const review = attachedReviews.find((attachedReview) => attachedReview.id === reviewId); if (review) { const signature = getReviewNoteSignature(review.data); - const reviewIds = draftReviewCheckIdsBySignatureRef.current.get(signature); - if (reviewIds) { - reviewIds.add(review.id); - } else { - draftReviewCheckIdsBySignatureRef.current.set(signature, new Set([review.id])); + const draftReview = result.reviews.find( + (draftReview) => getReviewNoteSignature(draftReview) === signature + ); + if (draftReview) { + const draftReviewId = getDraftReviewId(draftReview); + const reviewIds = draftReviewCheckIdsByDraftIdRef.current.get(draftReviewId); + if (reviewIds) { + reviewIds.add(review.id); + } else { + draftReviewCheckIdsByDraftIdRef.current.set(draftReviewId, new Set([review.id])); + } } } onDetachReviewForDraftMerge?.(reviewId); @@ -1159,9 +1164,7 @@ const ChatInputInner: React.FC = (props) => { ? [ ...new Set( draftReviewItems.flatMap((review) => - Array.from( - draftReviewCheckIdsBySignatureRef.current.get(getReviewNoteSignature(review)) ?? [] - ) + Array.from(draftReviewCheckIdsByDraftIdRef.current.get(getDraftReviewId(review)) ?? []) ) ), ] @@ -1352,7 +1355,7 @@ const ChatInputInner: React.FC = (props) => { (pending: PendingUserMessage) => { const restoredAttachments = applyDraftFromPending(pending, `restored-${Date.now()}`); setDraftReviews(pending.reviews); - draftReviewCheckIdsBySignatureRef.current.clear(); + draftReviewCheckIdsByDraftIdRef.current.clear(); draftReviewMergedAttachedIdsRef.current = pending.reviews.length > 0 ? new Set(attachedReviews.map((review) => review.id)) : null; setDraftMuxMetadataOverrideForDraft(pending.muxMetadata, { @@ -1368,7 +1371,7 @@ const ChatInputInner: React.FC = (props) => { const restorePreEditDraft = useCallback(() => { setDraft(preEditDraftRef.current); setDraftReviews(preEditReviewsRef.current); - draftReviewCheckIdsBySignatureRef.current.clear(); + draftReviewCheckIdsByDraftIdRef.current.clear(); draftReviewMergedAttachedIdsRef.current = preEditReviewsRef.current && preEditReviewsRef.current.length > 0 ? new Set(attachedReviews.map((review) => review.id)) @@ -1475,7 +1478,7 @@ const ChatInputInner: React.FC = (props) => { `edit-${editingMessage.id}` ); setDraftReviews(editingMessage.pending.reviews); - draftReviewCheckIdsBySignatureRef.current.clear(); + draftReviewCheckIdsByDraftIdRef.current.clear(); draftReviewMergedAttachedIdsRef.current = editingMessage.pending.reviews.length > 0 ? new Set(attachedReviews.map((review) => review.id)) @@ -1974,7 +1977,7 @@ const ChatInputInner: React.FC = (props) => { setInput(""); setAttachments([]); setDraftReviews(null); - draftReviewCheckIdsBySignatureRef.current.clear(); + draftReviewCheckIdsByDraftIdRef.current.clear(); clearDraftMuxMetadataOverride(); onDetachAllReviewsForComposerClear?.(); if (inputRef.current) { @@ -2414,7 +2417,7 @@ const ChatInputInner: React.FC = (props) => { setInput(restoreInput); } else { setDraftReviews(null); - draftReviewCheckIdsBySignatureRef.current.clear(); + draftReviewCheckIdsByDraftIdRef.current.clear(); clearDraftMuxMetadataOverride(); if (variant === "workspace" && parsed.type === "compact") { if (reviewIdsForCheck.length > 0) { @@ -2813,8 +2816,8 @@ const ChatInputInner: React.FC = (props) => { const preSendDraft = getDraft(); const preSendReviews = draftReviews; const preSendMuxMetadataOverride = activeDraftMuxMetadataOverride; - const preSendDraftReviewCheckIdsBySignature = cloneDraftReviewCheckIdsBySignature( - draftReviewCheckIdsBySignatureRef.current + const preSendDraftReviewCheckIdsByDraftId = cloneDraftReviewCheckIdsByDraftId( + draftReviewCheckIdsByDraftIdRef.current ); const editMessageForSend = editingMessageForUi; @@ -2929,7 +2932,7 @@ const ChatInputInner: React.FC = (props) => { // so they'll reappear naturally on failure (we only call onCheckReviews on success) setInput(""); setDraftReviews(null); - draftReviewCheckIdsBySignatureRef.current.clear(); + draftReviewCheckIdsByDraftIdRef.current.clear(); clearDraftMuxMetadataOverride(); setAttachments([]); setHideReviewsDuringSend(true); @@ -2990,8 +2993,8 @@ const ChatInputInner: React.FC = (props) => { setOptimisticallyDismissedEditId(null); setDraft(preSendDraft); setDraftReviews(preSendReviews); - draftReviewCheckIdsBySignatureRef.current = cloneDraftReviewCheckIdsBySignature( - preSendDraftReviewCheckIdsBySignature + draftReviewCheckIdsByDraftIdRef.current = cloneDraftReviewCheckIdsByDraftId( + preSendDraftReviewCheckIdsByDraftId ); draftReviewMergedAttachedIdsRef.current = preSendReviews && preSendReviews.length > 0 @@ -3027,7 +3030,7 @@ const ChatInputInner: React.FC = (props) => { if (sentReviewIds.length > 0) { props.onCheckReviews?.(sentReviewIds); } - draftReviewCheckIdsBySignatureRef.current.clear(); + draftReviewCheckIdsByDraftIdRef.current.clear(); // Exit editing mode if we were editing if (editMessageForSend && props.onCancelEdit) { @@ -3050,8 +3053,8 @@ const ChatInputInner: React.FC = (props) => { setOptimisticallyDismissedEditId(null); setDraft(preSendDraft); setDraftReviews(preSendReviews); - draftReviewCheckIdsBySignatureRef.current = cloneDraftReviewCheckIdsBySignature( - preSendDraftReviewCheckIdsBySignature + draftReviewCheckIdsByDraftIdRef.current = cloneDraftReviewCheckIdsByDraftId( + preSendDraftReviewCheckIdsByDraftId ); draftReviewMergedAttachedIdsRef.current = preSendReviews && preSendReviews.length > 0 From 7473eba9379cd912c4b46ca256666509b050910e Mon Sep 17 00:00:00 2001 From: Leonidas Zhak <70497898+LeonidasZhak@users.noreply.github.com> Date: Thu, 2 Jul 2026 04:32:08 +0800 Subject: [PATCH 14/15] =?UTF-8?q?=F0=9F=A4=96=20fix:=20keep=20auto-compact?= =?UTF-8?q?ion=20prompts=20in=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract real follow-up content from synthetic auto-compaction request rows before skipping synthetic maintenance messages so user-authored prompts remain reusable in Prompt History. --- _Generated with `mux` • Model: `GPT-5` • Thinking: `unknown` • Cost: ``_ --- .../RightSidebar/PromptHistoryTab.test.ts | 31 +++++ .../RightSidebar/promptHistoryEntries.ts | 109 ++++++++++++++---- 2 files changed, 117 insertions(+), 23 deletions(-) diff --git a/src/browser/features/RightSidebar/PromptHistoryTab.test.ts b/src/browser/features/RightSidebar/PromptHistoryTab.test.ts index 1655aa14ab..cf865b3e8f 100644 --- a/src/browser/features/RightSidebar/PromptHistoryTab.test.ts +++ b/src/browser/features/RightSidebar/PromptHistoryTab.test.ts @@ -56,6 +56,37 @@ describe("getPromptHistoryEntries", () => { expect(getPromptHistoryEntries(messages).map((entry) => entry.historyId)).toEqual(["real"]); }); + test("preserves synthetic auto-compaction follow-up prompts", () => { + const [entry] = getPromptHistoryEntries([ + userMessage("auto-compact", "Synthetic compaction request", 1, { + isSynthetic: true, + compactionRequest: { + parsed: { + followUpContent: { + text: "Original prompt near the context limit", + model: "openai:gpt-5", + agentId: "exec", + }, + }, + }, + }), + ]); + + if (!entry) throw new Error("expected prompt history entry"); + expect(entry).toMatchObject({ + historyId: "auto-compact", + content: "Original prompt near the context limit", + fileCount: 0, + isSideQuestion: false, + }); + expect(createPromptHistoryInsertPayload(entry)).toEqual({ + text: "Original prompt near the context limit", + mode: "replace", + fileParts: [], + reviews: [], + }); + }); + test("skips completed local command output rows", () => { const entries = getPromptHistoryEntries([ userMessage("stdout", "build complete", 1), diff --git a/src/browser/features/RightSidebar/promptHistoryEntries.ts b/src/browser/features/RightSidebar/promptHistoryEntries.ts index b25df459f1..3f4efcccdf 100644 --- a/src/browser/features/RightSidebar/promptHistoryEntries.ts +++ b/src/browser/features/RightSidebar/promptHistoryEntries.ts @@ -1,5 +1,14 @@ -import type { DisplayedMessage, MuxMessageMetadata } from "@/common/types/message"; -import { appendStagedAttachmentNotice } from "@/browser/features/ChatInput/stagedAttachments"; +import { + isDefaultSourceContent, + type CompactionFollowUpRequest, + type DisplayedMessage, + type MuxMessageMetadata, +} from "@/common/types/message"; +import { + appendStagedAttachmentNotice, + displayStagedAttachmentsToChatAttachments, + parseStagedAttachmentNotice, +} from "@/browser/features/ChatInput/stagedAttachments"; import { getEditableUserMessageDraftContent } from "@/browser/utils/messages/messageUtils"; type UserMessage = Extract; @@ -27,24 +36,76 @@ function isCompletedLocalCommandOutput(message: UserMessage): boolean { ); } +function buildSyntheticFollowUpEntry( + message: UserMessage, + followUpContent?: CompactionFollowUpRequest +): PromptHistoryEntry | null { + if (!followUpContent || isDefaultSourceContent(followUpContent)) { + return null; + } + + const parsed = parseStagedAttachmentNotice(followUpContent.text ?? ""); + const stagedAttachments = displayStagedAttachmentsToChatAttachments( + parsed.attachments, + `history-${message.historyId}` + ); + const fileParts = followUpContent.fileParts ?? []; + const reviews = followUpContent.reviews ?? []; + + if ( + parsed.text.trim().length === 0 && + stagedAttachments.length === 0 && + fileParts.length === 0 && + reviews.length === 0 + ) { + return null; + } + + const insertContent = + stagedAttachments.length > 0 + ? appendStagedAttachmentNotice(parsed.text, stagedAttachments) + : parsed.text; + + return { + historyId: message.historyId, + content: parsed.text, + ...(insertContent !== parsed.text ? { insertContent } : {}), + historySequence: message.historySequence, + timestamp: message.timestamp, + commandPrefix: undefined, + isSideQuestion: false, + fileCount: fileParts.length + stagedAttachments.length, + ...(fileParts.length > 0 ? { fileParts } : {}), + ...(reviews.length > 0 ? { reviews } : {}), + ...(followUpContent.muxMetadata ? { muxMetadata: followUpContent.muxMetadata } : {}), + }; +} + export function getPromptHistoryEntries( messages: readonly DisplayedMessage[] ): PromptHistoryEntry[] { return messages - .filter((message): message is Extract => { + .flatMap((message): PromptHistoryEntry[] => { if (message.type !== "user") { - return false; + return []; } - if (message.isSynthetic || message.isGoalContinuation || message.isBudgetLimitWrapup) { - return false; + if (message.isGoalContinuation || message.isBudgetLimitWrapup) { + return []; } if (isCompletedLocalCommandOutput(message)) { - return false; + return []; } - return message.content.trim().length > 0 || (message.fileParts?.length ?? 0) > 0; - }) - .map((message) => { + const followUpContent = message.compactionRequest?.parsed.followUpContent; + if (message.isSynthetic) { + const entry = buildSyntheticFollowUpEntry(message, followUpContent); + return entry ? [entry] : []; + } + + if (message.content.trim().length === 0 && (message.fileParts?.length ?? 0) === 0) { + return []; + } + const fileParts = followUpContent?.fileParts ?? message.fileParts ?? []; const reviews = followUpContent?.reviews ?? message.reviews ?? []; const draft = getEditableUserMessageDraftContent(message); @@ -53,19 +114,21 @@ export function getPromptHistoryEntries( draft.stagedAttachments.length > 0 ? appendStagedAttachmentNotice(content, draft.stagedAttachments) : content; - return { - historyId: message.historyId, - content, - ...(insertContent !== content ? { insertContent } : {}), - historySequence: message.historySequence, - timestamp: message.timestamp, - commandPrefix: message.commandPrefix, - isSideQuestion: message.isSideQuestion === true, - fileCount: fileParts.length + draft.stagedAttachments.length, - ...(fileParts.length > 0 ? { fileParts } : {}), - ...(reviews.length > 0 ? { reviews } : {}), - ...(followUpContent?.muxMetadata ? { muxMetadata: followUpContent.muxMetadata } : {}), - }; + return [ + { + historyId: message.historyId, + content, + ...(insertContent !== content ? { insertContent } : {}), + historySequence: message.historySequence, + timestamp: message.timestamp, + commandPrefix: message.commandPrefix, + isSideQuestion: message.isSideQuestion === true, + fileCount: fileParts.length + draft.stagedAttachments.length, + ...(fileParts.length > 0 ? { fileParts } : {}), + ...(reviews.length > 0 ? { reviews } : {}), + ...(followUpContent?.muxMetadata ? { muxMetadata: followUpContent.muxMetadata } : {}), + }, + ]; }) .sort((left, right) => left.historySequence - right.historySequence); } From ea38cb2a2d5dc4d5bbbe850313cdd4d6375622c5 Mon Sep 17 00:00:00 2001 From: Leonidas Zhak <70497898+LeonidasZhak@users.noreply.github.com> Date: Thu, 2 Jul 2026 05:01:17 +0800 Subject: [PATCH 15/15] =?UTF-8?q?=F0=9F=A4=96=20fix:=20restore=20auto-comp?= =?UTF-8?q?action=20command=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep synthetic auto-compaction follow-up history entries replayable by inserting preserved raw commands when present and by distinguishing internal resume sentinels from user-authored Continue prompts. --- _Generated with `mux` • Model: `GPT-5` • Thinking: `unknown` • Cost: ``_ --- .../RightSidebar/PromptHistoryTab.test.ts | 65 ++++++++++++++++++- .../RightSidebar/promptHistoryEntries.ts | 23 +++++-- 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/src/browser/features/RightSidebar/PromptHistoryTab.test.ts b/src/browser/features/RightSidebar/PromptHistoryTab.test.ts index cf865b3e8f..d49ba24e5a 100644 --- a/src/browser/features/RightSidebar/PromptHistoryTab.test.ts +++ b/src/browser/features/RightSidebar/PromptHistoryTab.test.ts @@ -57,7 +57,7 @@ describe("getPromptHistoryEntries", () => { }); test("preserves synthetic auto-compaction follow-up prompts", () => { - const [entry] = getPromptHistoryEntries([ + const entries = getPromptHistoryEntries([ userMessage("auto-compact", "Synthetic compaction request", 1, { isSynthetic: true, compactionRequest: { @@ -70,8 +70,35 @@ describe("getPromptHistoryEntries", () => { }, }, }), + userMessage("internal-resume", "Internal resume compaction request", 2, { + isSynthetic: true, + compactionRequest: { + parsed: { + followUpContent: { + text: "Continue", + model: "openai:gpt-5", + agentId: "exec", + dispatchOptions: { source: "internal-resume" }, + }, + }, + }, + }), + userMessage("literal-continue", "Synthetic compaction request", 3, { + isSynthetic: true, + compactionRequest: { + parsed: { + followUpContent: { + text: "Continue", + model: "openai:gpt-5", + agentId: "exec", + }, + }, + }, + }), ]); + expect(entries.map((entry) => entry.historyId)).toEqual(["auto-compact", "literal-continue"]); + const entry = entries[0]; if (!entry) throw new Error("expected prompt history entry"); expect(entry).toMatchObject({ historyId: "auto-compact", @@ -87,6 +114,42 @@ describe("getPromptHistoryEntries", () => { }); }); + test("inserts raw commands for synthetic auto-compaction slash follow-ups", () => { + const muxMetadata = { + type: "agent-skill" as const, + skillName: "tests", + scope: "global" as const, + rawCommand: "/tests run focused tests", + commandPrefix: "/tests", + }; + const [entry] = getPromptHistoryEntries([ + userMessage("auto-compact-skill", "Synthetic compaction request", 1, { + isSynthetic: true, + compactionRequest: { + parsed: { + followUpContent: { + text: "run focused tests", + model: "openai:gpt-5", + agentId: "exec", + muxMetadata, + }, + }, + }, + }), + ]); + + if (!entry) throw new Error("expected prompt history entry"); + expect(entry.content).toBe("run focused tests"); + expect(entry.muxMetadata).toEqual(muxMetadata); + expect(createPromptHistoryInsertPayload(entry)).toEqual({ + text: "/tests run focused tests", + mode: "replace", + fileParts: [], + reviews: [], + muxMetadata, + }); + }); + test("skips completed local command output rows", () => { const entries = getPromptHistoryEntries([ userMessage("stdout", "build complete", 1), diff --git a/src/browser/features/RightSidebar/promptHistoryEntries.ts b/src/browser/features/RightSidebar/promptHistoryEntries.ts index 3f4efcccdf..304f652032 100644 --- a/src/browser/features/RightSidebar/promptHistoryEntries.ts +++ b/src/browser/features/RightSidebar/promptHistoryEntries.ts @@ -1,5 +1,4 @@ import { - isDefaultSourceContent, type CompactionFollowUpRequest, type DisplayedMessage, type MuxMessageMetadata, @@ -36,11 +35,20 @@ function isCompletedLocalCommandOutput(message: UserMessage): boolean { ); } +function getMuxMetadataRawCommand(metadata?: MuxMessageMetadata): string | undefined { + if (!metadata || !("rawCommand" in metadata)) { + return undefined; + } + + const { rawCommand } = metadata; + return rawCommand && rawCommand.trim().length > 0 ? rawCommand : undefined; +} + function buildSyntheticFollowUpEntry( message: UserMessage, followUpContent?: CompactionFollowUpRequest ): PromptHistoryEntry | null { - if (!followUpContent || isDefaultSourceContent(followUpContent)) { + if (!followUpContent || followUpContent.dispatchOptions?.source === "internal-resume") { return null; } @@ -51,24 +59,27 @@ function buildSyntheticFollowUpEntry( ); const fileParts = followUpContent.fileParts ?? []; const reviews = followUpContent.reviews ?? []; + const rawCommand = getMuxMetadataRawCommand(followUpContent.muxMetadata); if ( parsed.text.trim().length === 0 && stagedAttachments.length === 0 && fileParts.length === 0 && - reviews.length === 0 + reviews.length === 0 && + !rawCommand ) { return null; } const insertContent = - stagedAttachments.length > 0 + rawCommand ?? + (stagedAttachments.length > 0 ? appendStagedAttachmentNotice(parsed.text, stagedAttachments) - : parsed.text; + : parsed.text); return { historyId: message.historyId, - content: parsed.text, + content: parsed.text.trim().length > 0 ? parsed.text : (rawCommand ?? parsed.text), ...(insertContent !== parsed.text ? { insertContent } : {}), historySequence: message.historySequence, timestamp: message.timestamp,