From c837c50aa9efdfd4664088cf3ec91813def1b075 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 1/5] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20prompt=20histo?= =?UTF-8?q?ry=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 1bc5b9f374..d322114186 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -87,6 +87,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, @@ -728,6 +729,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 7d5213f57a..027b3f9da1 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -198,6 +198,8 @@ import { normalizeAgentId } from "@/common/utils/agentIds"; import { isGoalRunning } from "@/common/types/goal"; 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. @@ -496,6 +498,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 => @@ -536,6 +542,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(() => { @@ -1749,12 +1780,21 @@ const ChatInputInner: React.FC = (props) => { const { text, mode = "append", fileParts, reviews } = customEvent.detail; const hasFileParts = !!fileParts && fileParts.length > 0; const hasReviews = !!reviews && reviews.length > 0; + const hasDraftReplacementPayload = fileParts !== undefined || reviews !== undefined; if (mode === "replace") { if (editingMessageForUi) { return; } - if (hasFileParts || hasReviews) { + if (hasDraftReplacementPayload) { + onDetachAllReviewsForComposerClear?.(); + const clearsReviews = reviews === undefined || reviews.length === 0; + if (clearsReviews) { + clearedAttachedReviewIdsRef.current = attachedReviewIdsSignature; + } else { + clearedAttachedReviewIdsRef.current = null; + } + restoreDraft({ content: text, fileParts: fileParts ?? [], @@ -1782,7 +1822,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 333ce8fdd9..7470173446 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, @@ -221,6 +222,13 @@ export const GoalTabLabel: React.FC = ({ workspaceId }) => { ); }; +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 7d99ad771d..c79c2a8284 100644 --- a/src/browser/features/RightSidebar/Tabs/tabConfig.ts +++ b/src/browser/features/RightSidebar/Tabs/tabConfig.ts @@ -53,6 +53,12 @@ const TAB_CONFIG_DEF = { defaultOrder: 35, paletteKeywords: ["goal", "target", "objective"], }, + 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 fec368c503..5af215bbe7 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 type { GoalSnapshot, GoalStatus } from "@/common/types/goal"; import type { ReviewNoteData } from "@/common/types/review"; import { BASE_TAB_IDS, TAB_CONFIG, type BaseTabType, type TabConfig } from "./tabConfig"; @@ -34,6 +35,7 @@ import { InstructionsTabLabel, MemoryTabLabel, OutputTabLabel, + PromptHistoryTabLabel, ReviewTabLabel, StatsTabLabel, } from "./TabLabels"; @@ -159,6 +161,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 f4472344905f67a85f6ce70497c027d6efc96455 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 2/5] 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 00e7d21de51069ca72b593067a04ff2119b04375 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 3/5] fix: preserve compaction follow-up metadata --- src/browser/features/ChatInput/index.tsx | 19 +++++++++++++-- .../RightSidebar/PromptHistoryTab.test.ts | 23 ++++++++++++++++++- .../RightSidebar/PromptHistoryTab.tsx | 1 + .../RightSidebar/promptHistoryEntries.ts | 4 +++- src/browser/utils/chatCommands.ts | 2 ++ src/browser/utils/chatEditing.ts | 3 +++ src/common/constants/events.ts | 3 ++- 7 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 027b3f9da1..f5d197049c 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -498,6 +498,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 @@ -1240,6 +1243,7 @@ const ChatInputInner: React.FC = (props) => { (pending: PendingUserMessage) => { applyDraftFromPending(pending, `restored-${Date.now()}`); setDraftReviews(pending.reviews); + setDraftMuxMetadataOverride(pending.muxMetadata); focusMessageInput(); }, [applyDraftFromPending, focusMessageInput, setDraftReviews] @@ -1775,12 +1779,14 @@ 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 hasFileParts = !!fileParts && fileParts.length > 0; const hasReviews = !!reviews && reviews.length > 0; - const hasDraftReplacementPayload = fileParts !== undefined || reviews !== undefined; + const hasDraftReplacementPayload = + fileParts !== undefined || reviews !== undefined || muxMetadata !== undefined; if (mode === "replace") { if (editingMessageForUi) { @@ -1799,6 +1805,7 @@ const ChatInputInner: React.FC = (props) => { content: text, fileParts: fileParts ?? [], reviews: reviews ?? [], + muxMetadata, }); } else { restoreText(text); @@ -1812,6 +1819,7 @@ const ChatInputInner: React.FC = (props) => { content: nextText, fileParts: fileParts ?? [], reviews: reviews ?? [], + muxMetadata, }, `restored-${Date.now()}` ); @@ -2207,6 +2215,7 @@ const ChatInputInner: React.FC = (props) => { editMessageId: editingMessageForUi?.id, onCancelEdit: commandOnCancelEdit, reviews: reviewsData, + followUpMuxMetadata: draftMuxMetadataOverride, fileParts: commandFileParts.length > 0 ? commandFileParts : undefined, onMessageSent: variant === "workspace" ? props.onMessageSent : undefined, onDetachAllReviews: variant === "workspace" ? props.onDetachAllReviews : undefined, @@ -2220,6 +2229,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); @@ -2610,6 +2620,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 { @@ -2651,6 +2662,7 @@ const ChatInputInner: React.FC = (props) => { text: parsed.continueMessage ?? "", fileParts: sendFileParts, reviews: reviewsData, + muxMetadata: draftMuxMetadataOverride, } : undefined, model: parsed.model, @@ -2711,6 +2723,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 @@ -2770,6 +2783,7 @@ const ChatInputInner: React.FC = (props) => { setOptimisticallyDismissedEditId(null); setDraft(preSendDraft); setDraftReviews(preSendReviews); + setDraftMuxMetadataOverride(preSendMuxMetadataOverride); } else { // Track telemetry for successful message send telemetry.messageSent( @@ -2817,6 +2831,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 a124d34709..17cb8f69d2 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -1636,6 +1636,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; @@ -1778,6 +1779,7 @@ export async function handleCompactCommand( text: parsed.continueMessage ?? "", 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 5a4b0a4bb3..ed60762b10 100644 --- a/src/browser/utils/chatEditing.ts +++ b/src/browser/utils/chatEditing.ts @@ -2,6 +2,7 @@ import type { FilePart } from "@/common/orpc/types"; import type { CompactionFollowUpRequest, DisplayedUserMessage, + MuxMessageMetadata, QueuedMessage, ReviewNoteDataForDisplay, } from "@/common/types/message"; @@ -14,6 +15,7 @@ export interface PendingUserMessage extends Omit< > { fileParts: FilePart[]; reviews: ReviewNoteDataForDisplay[]; + muxMetadata?: MuxMessageMetadata; } export interface EditingMessageState { @@ -77,5 +79,6 @@ export const buildEditingStateFromCompaction = ( content: command, fileParts: followUp?.fileParts ?? [], 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 a23c3f1a370520648276fe2662176fb3a3e7bed6 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 4/5] 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 f5d197049c..7a48455d86 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -679,6 +679,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(); @@ -1252,6 +1253,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) @@ -1342,8 +1344,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) { @@ -1808,6 +1812,7 @@ const ChatInputInner: React.FC = (props) => { muxMetadata, }); } else { + setDraftMuxMetadataOverride(undefined); restoreText(text); } } else if (hasFileParts || hasReviews) { From 3793b63afa3817ad7ebcb08a6fb6acf77554c3de 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 5/5] 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 7a48455d86..fa7a50afae 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -1855,6 +1855,7 @@ const ChatInputInner: React.FC = (props) => { setInput(""); setAttachments([]); setDraftReviews(null); + setDraftMuxMetadataOverride(undefined); onDetachAllReviewsForComposerClear?.(); if (inputRef.current) { inputRef.current.style.height = "";