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..fa7a50afae 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,13 @@ 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 + ); const draftReviewIdsByValueRef = useRef(new WeakMap()); const nextDraftReviewIdRef = useRef(0); const isDraftReviewData = (value: unknown): value is ReviewNoteDataForDisplay => @@ -536,6 +545,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(() => { @@ -645,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(); @@ -1209,6 +1244,7 @@ const ChatInputInner: React.FC = (props) => { (pending: PendingUserMessage) => { applyDraftFromPending(pending, `restored-${Date.now()}`); setDraftReviews(pending.reviews); + setDraftMuxMetadataOverride(pending.muxMetadata); focusMessageInput(); }, [applyDraftFromPending, focusMessageInput, setDraftReviews] @@ -1217,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) @@ -1307,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) { @@ -1744,23 +1783,36 @@ 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 || muxMetadata !== 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 ?? [], reviews: reviews ?? [], + muxMetadata, }); } else { + setDraftMuxMetadataOverride(undefined); restoreText(text); } } else if (hasFileParts || hasReviews) { @@ -1772,6 +1824,7 @@ const ChatInputInner: React.FC = (props) => { content: nextText, fileParts: fileParts ?? [], reviews: reviews ?? [], + muxMetadata, }, `restored-${Date.now()}` ); @@ -1782,7 +1835,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 }>) => { @@ -1793,6 +1855,7 @@ const ChatInputInner: React.FC = (props) => { setInput(""); setAttachments([]); setDraftReviews(null); + setDraftMuxMetadataOverride(undefined); onDetachAllReviewsForComposerClear?.(); if (inputRef.current) { inputRef.current.style.height = ""; @@ -2158,6 +2221,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, @@ -2171,6 +2235,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); @@ -2561,6 +2626,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 { @@ -2602,6 +2668,7 @@ const ChatInputInner: React.FC = (props) => { text: parsed.continueMessage ?? "", fileParts: sendFileParts, reviews: reviewsData, + muxMetadata: draftMuxMetadataOverride, } : undefined, model: parsed.model, @@ -2662,6 +2729,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 @@ -2721,6 +2789,7 @@ const ChatInputInner: React.FC = (props) => { setOptimisticallyDismissedEditId(null); setDraft(preSendDraft); setDraftReviews(preSendReviews); + setDraftMuxMetadataOverride(preSendMuxMetadataOverride); } else { // Track telemetry for successful message send telemetry.messageSent( @@ -2768,6 +2837,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 new file mode 100644 index 0000000000..e2a82f0984 --- /dev/null +++ b/src/browser/features/RightSidebar/PromptHistoryTab.test.ts @@ -0,0 +1,219 @@ +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, + }); + }); + + test("insert payload preserves compaction follow-up payloads", () => { + 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, + muxMetadata: { + type: "agent-skill", + skillName: "tests", + scope: "global", + rawCommand: "/tests run focused tests", + commandPrefix: "/tests", + }, + }, + }, + }, + }), + ]); + + 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(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 new file mode 100644 index 0000000000..f6746f8294 --- /dev/null +++ b/src/browser/features/RightSidebar/PromptHistoryTab.tsx @@ -0,0 +1,166 @@ +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 ?? [], + muxMetadata: entry.muxMetadata, + }; +} + +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..1a64d79959 --- /dev/null +++ b/src/browser/features/RightSidebar/promptHistoryEntries.ts @@ -0,0 +1,63 @@ +import type { DisplayedMessage, MuxMessageMetadata } 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"]; + muxMetadata?: MuxMessageMetadata; +} + +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 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, + 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 } : {}), + ...(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 df2b5ffbac..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 = { @@ -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,8 +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[]; + muxMetadata?: MuxMessageMetadata; + }; + [CUSTOM_EVENTS.NAVIGATE_TO_TRANSCRIPT_MESSAGE]: { + workspaceId: string; + historyId: string; }; [CUSTOM_EVENTS.CLEAR_CHAT_COMPOSER]: { workspaceId: string;