Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/code/src/renderer/features/message-editor/analytics.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<Set<string>>(new Set());
const [query, setQuery] = useState("");
const searchRef = useRef<HTMLInputElement>(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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<TooltipProvider delay={500}>
<Tooltip>
<DialogTrigger
render={
<TooltipTrigger
render={
<InputGroupButton
variant="default"
size="icon-sm"
aria-label="Prompt history"
disabled={disabled || !hasHistory}
>
<ClockCounterClockwise size={14} />
</InputGroupButton>
}
/>
}
/>
<TooltipContent>
{hasHistory ? "Prompt history" : "No prompts yet"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DialogContent
showCloseButton={false}
initialFocus={searchRef}
onClick={(e) => e.stopPropagation()}
className="w-[min(760px,calc(100vw-32px))] max-w-[760px] pt-3 pb-0 sm:max-w-[760px]"
>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2 px-3">
<div className="flex items-center gap-2">
<ClockCounterClockwise size={14} />
<DialogTitle className="font-medium text-sm">
Prompt history
</DialogTitle>
</div>
<div className="relative">
<MagnifyingGlass
size={13}
className="-translate-y-1/2 pointer-events-none absolute top-1/2 left-2 text-(--gray-9)"
/>
<Input
ref={searchRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search prompts…"
className="pl-7"
/>
</div>
</div>

<div className="flex max-h-[60vh] flex-col gap-2 overflow-y-auto pb-3 pl-3">
{filtered.length === 0 && (
<div className="px-1 py-3 text-(--gray-10) text-[13px]">
No matching prompts.
</div>
)}
{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" <button> inside a real <button>
<div
key={key}
role="button"
tabIndex={0}
onClick={() => handleEntryClick(entry.text)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleEntryClick(entry.text);
}
}}
className="mr-3 cursor-pointer rounded-(--radius-2) border border-(--gray-6) bg-(--gray-2) px-2 py-2 transition-colors hover:border-(--gray-7) hover:bg-(--gray-3) focus-visible:border-(--accent-7) focus-visible:bg-(--gray-3) focus-visible:outline-none"
>
{stamp && (
<span className="block pb-1 text-(--gray-9) text-[11px] uppercase tracking-wide">
{stamp}
</span>
)}
<span className="whitespace-pre-wrap text-(--gray-12) text-[13px]">
{display}
{tooLong && (
<>
{" "}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggleExpanded(key);
}}
className="rounded-(--radius-1) bg-(--accent-3) px-1 font-medium text-(--accent-11) hover:bg-(--accent-4) hover:text-(--accent-12)"
>
{isExpanded ? "Read less" : "Read more"}
</button>
</>
)}
</span>
</div>
);
})}
</div>
</div>
</DialogContent>
</Dialog>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -80,6 +81,7 @@ export const PromptInput = forwardRef<EditorHandle, PromptInputProps>(
enableCommands = true,
modelSelector,
reasoningSelector,
historyButton,
getPromptHistory,
onBeforeSubmit,
onSubmit,
Expand Down Expand Up @@ -251,7 +253,6 @@ export const PromptInput = forwardRef<EditorHandle, PromptInputProps>(
<InputGroupButton
variant="destructive"
size="icon-sm"
className="ml-auto"
onClick={onCancel}
aria-label="Stop"
>
Expand All @@ -263,7 +264,6 @@ export const PromptInput = forwardRef<EditorHandle, PromptInputProps>(
<InputGroupButton
variant="primary"
size="icon-sm"
className="ml-auto"
onClick={handleSubmitClick}
disabled={submitBlocked}
aria-label="Send message"
Expand Down Expand Up @@ -324,7 +324,10 @@ export const PromptInput = forwardRef<EditorHandle, PromptInputProps>(
! bash
</Text>
)}
{submitButton}
<span className="ml-auto flex items-center gap-1">
{historyButton}
{submitButton}
</span>
</InputGroupAddon>
</InputGroup>
</Flex>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -16,19 +21,40 @@ const MAX_HISTORY = 15;
export const useTaskInputHistoryStore = create<TaskInputHistoryStore>()(
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;
},
},
),
);
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -529,9 +530,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 = [
Expand Down Expand Up @@ -759,6 +768,13 @@ export function TaskInput({
onModelChange={handleModelChange}
/>
}
historyButton={
<PromptHistoryDialog
onSelect={handleHistorySelect}
hasPendingDraft={hasPendingDraft}
disabled={isCreatingTask}
/>
}
reasoningSelector={
!isPreviewLoading && (
<ReasoningLevelSelector
Expand Down
Loading
Loading