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..4967449145 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -139,7 +139,12 @@ import { } from "@/browser/utils/attachmentsHandling"; import { buildPendingFromRestoredInput, + getReviewNoteSignature, + getRestoredDraftPayloadSignature, + getRestoredMuxMetadataForCurrentDraft, + mergeNewAttachedReviewsIntoDraft, type PendingUserMessage, + type RestoredDraftPayload, } from "@/browser/utils/chatEditing"; import type { AgentSkillDescriptor } from "@/common/types/agentSkill"; @@ -201,6 +206,18 @@ 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"); +const EMPTY_ATTACHED_REVIEWS: Review[] = []; +type DraftReviewCheckIdsByDraftId = Map>; + +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. // Be conservative here so we can warn the user before writes start failing. @@ -498,9 +515,36 @@ 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); + const draftMuxMetadataSourceSignatureRef = useRef(null); + const setDraftMuxMetadataOverrideForDraft = useCallback( + (metadata: MuxMessageMetadata | undefined, draft: RestoredDraftPayload) => { + draftMuxMetadataSourceSignatureRef.current = metadata + ? getRestoredDraftPayloadSignature(draft) + : null; + setDraftMuxMetadataOverride(metadata); + }, + [] + ); + const clearDraftMuxMetadataOverride = useCallback(() => { + draftMuxMetadataSourceSignatureRef.current = null; + setDraftMuxMetadataOverride(undefined); + }, []); + const attachedReviewIdsSignature = attachedReviews.map((review) => review.id).join("\u0000"); + const clearedAttachedReviewIdsRef = useRef( + null + ); + const draftReviewMergedAttachedIdsRef = useRef | null>(null); + const draftReviewCheckIdsByDraftIdRef = useRef(new Map()); const draftReviewIdsByValueRef = useRef(new WeakMap()); const nextDraftReviewIdRef = useRef(0); const isDraftReviewData = (value: unknown): value is ReviewNoteDataForDisplay => @@ -526,9 +570,10 @@ const ChatInputInner: React.FC = (props) => { }); const removeDraftReview = (reviewId: string) => - withDraftReview(reviewId, (prev, reviewIndex) => - prev.filter((_, index) => index !== reviewIndex) - ); + withDraftReview(reviewId, (prev, reviewIndex) => { + draftReviewCheckIdsByDraftIdRef.current.delete(reviewId); + return prev.filter((_, index) => index !== reviewIndex); + }); const updateDraftReviewNote = (reviewId: string, newNote: string) => withDraftReview(reviewId, (prev, reviewIndex) => { @@ -541,6 +586,72 @@ 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); + draftReviewCheckIdsByDraftIdRef.current.clear(); + } + }, [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.mergedReviewIds) { + const review = attachedReviews.find((attachedReview) => attachedReview.id === reviewId); + if (review) { + const signature = getReviewNoteSignature(review.data); + 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); + } + }, [attachedReviewIdsSignature, attachedReviews, draftReviews, onDetachReviewForDraftMerge]); + // Creation sends can resolve after navigation; guard draft clears on unmounted inputs. const isMountedRef = useRef(true); useEffect(() => { @@ -650,6 +761,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(); @@ -1039,7 +1151,24 @@ const ChatInputInner: React.FC = (props) => { : attachedReviews.length > 0 ? attachedReviews.map((review) => review.data) : undefined; - const reviewIdsForCheck = reviewOverrideActive ? [] : attachedReviews.map((review) => review.id); + const activeDraftMuxMetadataOverride = getRestoredMuxMetadataForCurrentDraft({ + currentDraft: { + text: input, + attachments, + reviews: reviewData, + }, + sourceSignature: draftMuxMetadataSourceSignatureRef.current, + muxMetadata: draftMuxMetadataOverride, + }); + const reviewIdsForCheck = reviewOverrideActive + ? [ + ...new Set( + draftReviewItems.flatMap((review) => + Array.from(draftReviewCheckIdsByDraftIdRef.current.get(getDraftReviewId(review)) ?? []) + ) + ), + ] + : attachedReviews.map((review) => review.id); const reviewPanelItems: Review[] = reviewOverrideActive ? draftReviewItems.map((data) => ({ id: getDraftReviewId(data), @@ -1211,10 +1340,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] ); @@ -1222,25 +1353,44 @@ 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); + draftReviewCheckIdsByDraftIdRef.current.clear(); + draftReviewMergedAttachedIdsRef.current = + pending.reviews.length > 0 ? new Set(attachedReviews.map((review) => review.id)) : null; + setDraftMuxMetadataOverrideForDraft(pending.muxMetadata, { + text: pending.content, + attachments: restoredAttachments, + reviews: pending.reviews, + }); focusMessageInput(); }, - [applyDraftFromPending, focusMessageInput, setDraftReviews] + [applyDraftFromPending, attachedReviews, focusMessageInput, setDraftMuxMetadataOverrideForDraft] ); const restorePreEditDraft = useCallback(() => { setDraft(preEditDraftRef.current); setDraftReviews(preEditReviewsRef.current); - }, [setDraft, setDraftReviews]); + draftReviewCheckIdsByDraftIdRef.current.clear(); + 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, + }); + }, [attachedReviews, setDraft, setDraftReviews, setDraftMuxMetadataOverrideForDraft]); // 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) @@ -1322,8 +1472,22 @@ const ChatInputInner: React.FC = (props) => { if (editingMessage) { preEditDraftRef.current = getDraft(); preEditReviewsRef.current = draftReviews; - applyDraftFromPending(editingMessage.pending, `edit-${editingMessage.id}`); + preEditMuxMetadataOverrideRef.current = activeDraftMuxMetadataOverride; + const editingAttachments = applyDraftFromPending( + editingMessage.pending, + `edit-${editingMessage.id}` + ); setDraftReviews(editingMessage.pending.reviews); + draftReviewCheckIdsByDraftIdRef.current.clear(); + 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, + reviews: editingMessage.pending.reviews, + }); // Auto-resize textarea and focus setTimeout(() => { if (inputRef.current) { @@ -1741,25 +1905,37 @@ 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 || muxMetadata !== 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 +1957,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 }>) => { @@ -1792,6 +1977,8 @@ const ChatInputInner: React.FC = (props) => { setInput(""); setAttachments([]); setDraftReviews(null); + draftReviewCheckIdsByDraftIdRef.current.clear(); + clearDraftMuxMetadataOverride(); onDetachAllReviewsForComposerClear?.(); if (inputRef.current) { inputRef.current.style.height = ""; @@ -1801,7 +1988,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(() => { @@ -2210,6 +2403,7 @@ const ChatInputInner: React.FC = (props) => { onCancelEdit: commandOnCancelEdit, reviews: reviewsData, attachments, + followUpMuxMetadata: activeDraftMuxMetadataOverride, fileParts: commandFileParts.length > 0 ? commandFileParts : undefined, onMessageSent: variant === "workspace" ? props.onMessageSent : undefined, onDetachAllReviews: variant === "workspace" ? props.onDetachAllReviews : undefined, @@ -2223,6 +2417,8 @@ const ChatInputInner: React.FC = (props) => { setInput(restoreInput); } else { setDraftReviews(null); + draftReviewCheckIdsByDraftIdRef.current.clear(); + clearDraftMuxMetadataOverride(); if (variant === "workspace" && parsed.type === "compact") { if (reviewIdsForCheck.length > 0) { props.onCheckReviews?.(reviewIdsForCheck); @@ -2619,6 +2815,10 @@ const ChatInputInner: React.FC = (props) => { // Save current draft state for restoration on error const preSendDraft = getDraft(); const preSendReviews = draftReviews; + const preSendMuxMetadataOverride = activeDraftMuxMetadataOverride; + const preSendDraftReviewCheckIdsByDraftId = cloneDraftReviewCheckIdsByDraftId( + draftReviewCheckIdsByDraftIdRef.current + ); const editMessageForSend = editingMessageForUi; try { @@ -2664,6 +2864,7 @@ const ChatInputInner: React.FC = (props) => { text: appendStagedAttachmentNotice(parsed.continueMessage ?? "", attachments), fileParts: sendFileParts, reviews: reviewsData, + muxMetadata: activeDraftMuxMetadataOverride, } : undefined, model: parsed.model, @@ -2731,6 +2932,8 @@ const ChatInputInner: React.FC = (props) => { // so they'll reappear naturally on failure (we only call onCheckReviews on success) setInput(""); setDraftReviews(null); + draftReviewCheckIdsByDraftIdRef.current.clear(); + clearDraftMuxMetadataOverride(); setAttachments([]); setHideReviewsDuringSend(true); // Clear inline height style - VimTextArea's useLayoutEffect will handle sizing @@ -2790,6 +2993,18 @@ const ChatInputInner: React.FC = (props) => { setOptimisticallyDismissedEditId(null); setDraft(preSendDraft); setDraftReviews(preSendReviews); + draftReviewCheckIdsByDraftIdRef.current = cloneDraftReviewCheckIdsByDraftId( + preSendDraftReviewCheckIdsByDraftId + ); + draftReviewMergedAttachedIdsRef.current = + preSendReviews && preSendReviews.length > 0 + ? new Set(attachedReviews.map((review) => review.id)) + : null; + setDraftMuxMetadataOverrideForDraft(preSendMuxMetadataOverride, { + text: preSendDraft.text, + attachments: preSendDraft.attachments, + reviews: preSendReviews ?? undefined, + }); } else { // Track telemetry for successful message send telemetry.messageSent( @@ -2815,6 +3030,7 @@ const ChatInputInner: React.FC = (props) => { if (sentReviewIds.length > 0) { props.onCheckReviews?.(sentReviewIds); } + draftReviewCheckIdsByDraftIdRef.current.clear(); // Exit editing mode if we were editing if (editMessageForSend && props.onCancelEdit) { @@ -2837,6 +3053,18 @@ const ChatInputInner: React.FC = (props) => { setOptimisticallyDismissedEditId(null); setDraft(preSendDraft); setDraftReviews(preSendReviews); + draftReviewCheckIdsByDraftIdRef.current = cloneDraftReviewCheckIdsByDraftId( + preSendDraftReviewCheckIdsByDraftId + ); + draftReviewMergedAttachedIdsRef.current = + preSendReviews && preSendReviews.length > 0 + ? new Set(attachedReviews.map((review) => review.id)) + : null; + setDraftMuxMetadataOverrideForDraft(preSendMuxMetadataOverride, { + text: preSendDraft.text, + attachments: preSendDraft.attachments, + reviews: preSendReviews ?? undefined, + }); } 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..d49ba24e5a --- /dev/null +++ b/src/browser/features/RightSidebar/PromptHistoryTab.test.ts @@ -0,0 +1,361 @@ +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"; +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("preserves synthetic auto-compaction follow-up prompts", () => { + const entries = 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", + }, + }, + }, + }), + 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", + 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("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), + 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 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 = [ + { + 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..8b656bfabf --- /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.insertContent ?? 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 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..304f652032 --- /dev/null +++ b/src/browser/features/RightSidebar/promptHistoryEntries.ts @@ -0,0 +1,145 @@ +import { + 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; +const LOCAL_COMMAND_STDOUT_OPEN_TAG = ""; +const LOCAL_COMMAND_STDOUT_CLOSE_TAG = ""; + +export interface PromptHistoryEntry { + historyId: string; + content: string; + insertContent?: 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) + ); +} + +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 || followUpContent.dispatchOptions?.source === "internal-resume") { + return null; + } + + const parsed = parseStagedAttachmentNotice(followUpContent.text ?? ""); + const stagedAttachments = displayStagedAttachmentsToChatAttachments( + parsed.attachments, + `history-${message.historyId}` + ); + 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 && + !rawCommand + ) { + return null; + } + + const insertContent = + rawCommand ?? + (stagedAttachments.length > 0 + ? appendStagedAttachmentNotice(parsed.text, stagedAttachments) + : parsed.text); + + return { + historyId: message.historyId, + content: parsed.text.trim().length > 0 ? parsed.text : (rawCommand ?? 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 + .flatMap((message): PromptHistoryEntry[] => { + if (message.type !== "user") { + return []; + } + if (message.isGoalContinuation || message.isBudgetLimitWrapup) { + return []; + } + if (isCompletedLocalCommandOutput(message)) { + return []; + } + + 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); + 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 + draft.stagedAttachments.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.test.ts b/src/browser/utils/chatEditing.test.ts index 7683b2f549..d3d78be37c 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,9 @@ import { buildPendingFromDisplayed, buildPendingFromRestoredInput, canEditDisplayedUserMessage, + getRestoredDraftPayloadSignature, + getRestoredMuxMetadataForCurrentDraft, + mergeNewAttachedReviewsIntoDraft, normalizeQueuedMessage, } from "./chatEditing"; @@ -34,6 +38,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); @@ -130,6 +141,116 @@ describe("canEditDisplayedUserMessage", () => { }); }); + test("keeps restored mux metadata only while the restored draft payload is unchanged", () => { + const metadata: MuxMessageMetadata = { + type: "agent-skill", + rawCommand: "/test-skill investigate", + commandPrefix: "/test-skill", + skillName: "test-skill", + scope: "project", + }; + const sourceDraft = { + text: "investigate", + attachments: [STAGED_ATTACHMENT], + reviews: [REVIEW_NOTE], + }; + const sourceSignature = getRestoredDraftPayloadSignature(sourceDraft); + + expect( + getRestoredMuxMetadataForCurrentDraft({ + currentDraft: sourceDraft, + sourceSignature, + muxMetadata: metadata, + }) + ).toBe(metadata); + + expect( + 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(); + }); + + 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: "duplicate-parent-review", + data: REVIEW_NOTE, + 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: 4, + }, + ], + mergedAttachedReviewIds: new Set(["existing-parent-review"]), + }); + + expect(result.reviews).toEqual([REVIEW_NOTE, laterReview]); + 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", + ]); + }); + 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 2aaca093d2..d79a82b440 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, @@ -7,9 +10,11 @@ import { import type { CompactionFollowUpRequest, DisplayedUserMessage, + MuxMessageMetadata, 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. @@ -20,6 +25,7 @@ export interface PendingUserMessage extends Omit< fileParts: FilePart[]; stagedAttachments: StagedChatAttachment[]; reviews: ReviewNoteDataForDisplay[]; + muxMetadata?: MuxMessageMetadata; } export interface EditingMessageState { @@ -56,6 +62,7 @@ export function buildPendingFromRestoredInput(params: { fileParts: FilePart[]; reviews: ReviewNoteDataForDisplay[]; idPrefix: string; + muxMetadata?: MuxMessageMetadata; }): PendingUserMessage { const parsed = parseStagedAttachmentNotice(params.content); return { @@ -66,6 +73,110 @@ export function buildPendingFromRestoredInput(params: { params.idPrefix ), reviews: params.reviews, + muxMetadata: params.muxMetadata, + }; +} + +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 { + if (!params.muxMetadata || params.sourceSignature === null) { + return undefined; + } + return getRestoredDraftPayloadSignature(params.currentDraft) === params.sourceSignature + ? params.muxMetadata + : undefined; +} + +export 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; + mergedReviewIds: string[]; +} { + const mergedAttachedReviewIds = new Set(params.mergedAttachedReviewIds); + const existingReviewSignatures = new Set(params.draftReviews.map(getReviewNoteSignature)); + const additions: ReviewNoteDataForDisplay[] = []; + const mergedReviewIds: 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)) { + mergedReviewIds.push(review.id); + continue; + } + + existingReviewSignatures.add(signature); + additions.push(review.data); + mergedReviewIds.push(review.id); + } + + return { + reviews: additions.length > 0 ? [...params.draftReviews, ...additions] : params.draftReviews, + mergedAttachedReviewIds, + mergedReviewIds, }; } @@ -119,5 +230,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 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;