diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index 107bfde516..290b9f3727 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -85,6 +85,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, @@ -653,6 +654,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 e137a0d220..2ea0f548e8 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -467,6 +467,8 @@ 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 => @@ -507,6 +509,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"; + return; + } + + if ( + clearedAttachedReviewIdsRef.current === "awaiting-new" || + 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(() => { @@ -1578,12 +1605,20 @@ 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) { + if (reviews !== undefined && reviews.length === 0) { + clearedAttachedReviewIdsRef.current = attachedReviewIdsSignature; + onDetachAllReviewsForComposerClear?.(); + } else { + clearedAttachedReviewIdsRef.current = null; + } + restoreDraft({ content: text, fileParts: fileParts ?? [], @@ -1611,7 +1646,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..3aad6c6700 --- /dev/null +++ b/src/browser/features/RightSidebar/PromptHistoryTab.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "bun:test"; +import type { DisplayedMessage } from "@/common/types/message"; +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("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)]); + + expect(entry).toBeDefined(); + expect(createPromptHistoryInsertPayload(entry!)).toEqual({ + text: "Reuse this", + 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..98f3778a69 --- /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: [], + }; +} + +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.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 e8921801c5..2bcd4ec9c5 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 105558a3ec..1796af8a4f 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"], + }, desktop: { name: "Desktop", contentClassName: "overflow-hidden p-0", diff --git a/src/browser/features/RightSidebar/Tabs/tabRegistry.tsx b/src/browser/features/RightSidebar/Tabs/tabRegistry.tsx index 395b6e7168..250436f4ce 100644 --- a/src/browser/features/RightSidebar/Tabs/tabRegistry.tsx +++ b/src/browser/features/RightSidebar/Tabs/tabRegistry.tsx @@ -22,6 +22,7 @@ import { DesktopPanel } from "@/browser/features/desktop/DesktopPanel"; import { BrowserTab } from "@/browser/features/RightSidebar/BrowserTab"; import { DevToolsTab } from "@/browser/features/RightSidebar/DevToolsTab"; import { GoalTab, type GoalCreateIntent } from "@/browser/features/RightSidebar/GoalTab"; +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"; @@ -32,6 +33,7 @@ import { GoalTabLabel, InstructionsTabLabel, OutputTabLabel, + PromptHistoryTabLabel, ReviewTabLabel, StatsTabLabel, } from "./TabLabels"; @@ -157,6 +159,14 @@ const TAB_RENDERERS = { ), }, + history: { + Label: PromptHistoryTabLabel, + renderPanel: (ctx) => ( + + + + ), + }, desktop: { Label: DesktopTabLabel, renderPanel: (ctx) => ( diff --git a/src/browser/features/RightSidebar/promptHistoryEntries.ts b/src/browser/features/RightSidebar/promptHistoryEntries.ts new file mode 100644 index 0000000000..502c30b9bc --- /dev/null +++ b/src/browser/features/RightSidebar/promptHistoryEntries.ts @@ -0,0 +1,41 @@ +import type { DisplayedMessage } from "@/common/types/message"; + +export interface PromptHistoryEntry { + historyId: string; + content: string; + historySequence: number; + timestamp?: number; + commandPrefix?: string; + isSideQuestion: boolean; + fileCount: number; + fileParts?: Extract["fileParts"]; +} + +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; + } + return message.content.trim().length > 0 || (message.fileParts?.length ?? 0) > 0; + }) + .map((message) => { + const fileParts = message.fileParts ?? []; + return { + historyId: message.historyId, + content: message.content, + historySequence: message.historySequence, + timestamp: message.timestamp, + commandPrefix: message.commandPrefix, + isSideQuestion: message.isSideQuestion === true, + fileCount: fileParts.length, + ...(fileParts.length > 0 ? { fileParts } : {}), + }; + }) + .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; };