From 197e469ab2eabb78092d5f98c2d09f599faffb74 Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Wed, 6 May 2026 17:11:19 +0200 Subject: [PATCH] feat(message-editor): add prompt history viewer Generated-By: PostHog Code Task-Id: a3bb89ed-37d8-44c5-be81-e4a1f677035f --- .../features/message-editor/analytics.ts | 11 + .../components/PromptHistoryDialog.tsx | 238 ++++++++++++++++++ .../message-editor/components/PromptInput.tsx | 9 +- .../stores/taskInputHistoryStore.ts | 38 ++- .../task-detail/components/TaskInput.tsx | 20 +- apps/code/src/renderer/styles/globals.css | 18 ++ apps/code/src/shared/types/analytics.ts | 13 + 7 files changed, 336 insertions(+), 11 deletions(-) create mode 100644 apps/code/src/renderer/features/message-editor/analytics.ts create mode 100644 apps/code/src/renderer/features/message-editor/components/PromptHistoryDialog.tsx diff --git a/apps/code/src/renderer/features/message-editor/analytics.ts b/apps/code/src/renderer/features/message-editor/analytics.ts new file mode 100644 index 000000000..0153821f2 --- /dev/null +++ b/apps/code/src/renderer/features/message-editor/analytics.ts @@ -0,0 +1,11 @@ +export interface PromptHistoryOpenedProperties { + entry_count: number; +} + +export interface PromptHistorySelectedProperties { + entry_count: number; + entry_age_seconds: number | null; + had_pending_draft: boolean; + had_search_query: boolean; + prompt_length: number; +} diff --git a/apps/code/src/renderer/features/message-editor/components/PromptHistoryDialog.tsx b/apps/code/src/renderer/features/message-editor/components/PromptHistoryDialog.tsx new file mode 100644 index 000000000..d6814e7ae --- /dev/null +++ b/apps/code/src/renderer/features/message-editor/components/PromptHistoryDialog.tsx @@ -0,0 +1,238 @@ +import { ClockCounterClockwise, MagnifyingGlass } from "@phosphor-icons/react"; +import { + Dialog, + DialogContent, + DialogTitle, + DialogTrigger, + Input, + InputGroupButton, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@posthog/quill"; +import { ANALYTICS_EVENTS } from "@shared/types/analytics"; +import { track } from "@utils/analytics"; +import { showMessageBox } from "@utils/dialog"; +import { formatRelativeTimeLong } from "@utils/time"; +import Fuse from "fuse.js"; +import { useMemo, useRef, useState } from "react"; +import { useTaskInputHistoryStore } from "../stores/taskInputHistoryStore"; + +const COLLAPSED_LIMIT = 180; + +interface PromptHistoryDialogProps { + onSelect: (text: string) => void; + hasPendingDraft: () => boolean; + disabled?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export function PromptHistoryDialog({ + onSelect, + hasPendingDraft, + disabled, + onOpenChange, +}: PromptHistoryDialogProps) { + const entries = useTaskInputHistoryStore((s) => s.entries); + const hasHistory = entries.length > 0; + const [open, setOpen] = useState(false); + const [expanded, setExpanded] = useState>(new Set()); + const [query, setQuery] = useState(""); + const searchRef = useRef(null); + + const handleOpenChange = (next: boolean) => { + setOpen(next); + onOpenChange?.(next); + if (next) { + // Reset transient state when re-opening so the dialog starts fresh, + // but leave it untouched during the close animation to avoid flashing + // the unfiltered list before the popup unmounts. + setExpanded(new Set()); + setQuery(""); + track(ANALYTICS_EVENTS.PROMPT_HISTORY_OPENED, { + entry_count: entries.length, + }); + } + }; + + const applySelection = (text: string, draftWasPending: boolean) => { + const entry = entries.find((e) => e.text === text); + track(ANALYTICS_EVENTS.PROMPT_HISTORY_SELECTED, { + entry_count: entries.length, + entry_age_seconds: entry?.createdAt + ? Math.round((Date.now() - entry.createdAt) / 1000) + : null, + had_pending_draft: draftWasPending, + had_search_query: query.trim().length > 0, + prompt_length: text.length, + }); + handleOpenChange(false); + onSelect(text); + }; + + const handleEntryClick = async (text: string) => { + if (hasPendingDraft()) { + const result = await showMessageBox({ + type: "warning", + title: "Replace draft?", + message: "Replace draft with this prompt?", + detail: + "Loading this prompt will overwrite the text currently in the editor.", + buttons: ["Cancel", "Replace"], + defaultId: 1, + cancelId: 0, + }); + if (result.response !== 1) return; + applySelection(text, true); + return; + } + applySelection(text, false); + }; + + const toggleExpanded = (key: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + const reversed = useMemo(() => [...entries].reverse(), [entries]); + const fuse = useMemo( + () => + new Fuse(reversed, { + keys: ["text"], + threshold: 0.4, + ignoreLocation: true, + }), + [reversed], + ); + const filtered = useMemo(() => { + const q = query.trim(); + if (!q) return reversed; + return fuse.search(q).map((r) => r.item); + }, [fuse, reversed, query]); + + return ( + + + + + + + } + /> + } + /> + + {hasHistory ? "Prompt history" : "No prompts yet"} + + + + e.stopPropagation()} + className="w-[min(760px,calc(100vw-32px))] max-w-[760px] pt-3 pb-0 sm:max-w-[760px]" + > +
+
+
+ + + Prompt history + +
+
+ + setQuery(e.target.value)} + placeholder="Search prompts…" + className="pl-7" + /> +
+
+ +
+ {filtered.length === 0 && ( +
+ No matching prompts. +
+ )} + {filtered.map((entry) => { + const key = `${entry.createdAt}-${entry.text}`; + const isExpanded = expanded.has(key); + const stamp = + entry.createdAt != null + ? formatRelativeTimeLong(entry.createdAt) + : null; + const tooLong = entry.text.length > COLLAPSED_LIMIT; + const display = + tooLong && !isExpanded + ? `${entry.text.slice(0, COLLAPSED_LIMIT).trimEnd()}…` + : entry.text; + + return ( + // biome-ignore lint/a11y/useSemanticElements: cannot nest the inline "Read more" + + )} + +
+ ); + })} +
+ +
+
+ ); +} diff --git a/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx b/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx index f15364bac..9ebd58675 100644 --- a/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx +++ b/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx @@ -41,6 +41,7 @@ export interface PromptInputProps { // toolbar slots modelSelector?: React.ReactElement | null | false; reasoningSelector?: React.ReactElement | null | false; + historyButton?: React.ReactNode; // prompt history provider getPromptHistory?: () => string[]; // callbacks @@ -80,6 +81,7 @@ export const PromptInput = forwardRef( enableCommands = true, modelSelector, reasoningSelector, + historyButton, getPromptHistory, onBeforeSubmit, onSubmit, @@ -251,7 +253,6 @@ export const PromptInput = forwardRef( @@ -263,7 +264,6 @@ export const PromptInput = forwardRef( ( ! bash )} - {submitButton} + + {historyButton} + {submitButton} + diff --git a/apps/code/src/renderer/features/message-editor/stores/taskInputHistoryStore.ts b/apps/code/src/renderer/features/message-editor/stores/taskInputHistoryStore.ts index 9083e762a..6147eced4 100644 --- a/apps/code/src/renderer/features/message-editor/stores/taskInputHistoryStore.ts +++ b/apps/code/src/renderer/features/message-editor/stores/taskInputHistoryStore.ts @@ -1,8 +1,13 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; +export interface TaskInputHistoryEntry { + text: string; + createdAt: number | null; +} + interface TaskInputHistoryState { - prompts: string[]; + entries: TaskInputHistoryEntry[]; } interface TaskInputHistoryActions { @@ -16,19 +21,40 @@ const MAX_HISTORY = 15; export const useTaskInputHistoryStore = create()( persist( (set) => ({ - prompts: [], + entries: [], addPrompt: (prompt) => set((state) => { const trimmed = prompt.trim(); if (!trimmed) return state; - const filtered = state.prompts.filter((p) => p !== trimmed); - const updated = [...filtered, trimmed].slice(-MAX_HISTORY); - return { prompts: updated }; + const filtered = state.entries.filter((e) => e.text !== trimmed); + const updated = [ + ...filtered, + { text: trimmed, createdAt: Date.now() }, + ].slice(-MAX_HISTORY); + return { entries: updated }; }), }), { name: "task-input-history", - partialize: (state) => ({ prompts: state.prompts }), + version: 1, + partialize: (state) => ({ entries: state.entries }), + // v0 → v1: convert the flat `prompts: string[]` list into the new + // `entries: { text, createdAt }[]` shape. Old prompts predate + // timestamps so `createdAt` is null — the dialog omits the + // relative-time row when it's missing. + migrate: (persisted, version) => { + if (version === 0 && persisted && typeof persisted === "object") { + const old = persisted as { prompts?: unknown }; + if (Array.isArray(old.prompts)) { + return { + entries: old.prompts + .filter((p): p is string => typeof p === "string") + .map((text) => ({ text, createdAt: null })), + }; + } + } + return persisted as TaskInputHistoryState; + }, }, ), ); diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index ab2fa468a..d9a4e25ed 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -12,6 +12,7 @@ import { getBranchNameInputState, } from "@features/git-interaction/utils/branchCreation"; import { useInboxReportSelectionStore } from "@features/inbox/stores/inboxReportSelectionStore"; +import { PromptHistoryDialog } from "@features/message-editor/components/PromptHistoryDialog"; import { PromptInput } from "@features/message-editor/components/PromptInput"; import { useTaskInputHistoryStore } from "@features/message-editor/stores/taskInputHistoryStore"; import type { EditorHandle } from "@features/message-editor/types"; @@ -517,9 +518,17 @@ export function TaskInput({ }; }, [promptSessionId]); - const hasHistory = useTaskInputHistoryStore((s) => s.prompts.length > 0); + const hasHistory = useTaskInputHistoryStore((s) => s.entries.length > 0); const getPromptHistory = useCallback( - () => useTaskInputHistoryStore.getState().prompts, + () => useTaskInputHistoryStore.getState().entries.map((e) => e.text), + [], + ); + const handleHistorySelect = useCallback((text: string) => { + editorRef.current?.setContent(text); + editorRef.current?.focus(); + }, []); + const hasPendingDraft = useCallback( + () => !(editorRef.current?.isEmpty() ?? true), [], ); const hints = [ @@ -747,6 +756,13 @@ export function TaskInput({ onModelChange={handleModelChange} /> } + historyButton={ + + } reasoningSelector={ !isPreviewLoading && (