import { BookmarkIcon, XIcon } from "lucide-react"; import { memo, useEffect, useState } from "react"; import { formatRelativeTimeLabel } from "../../timestampFormat"; import { cn } from "~/lib/utils"; import { type PromptStashEntry } from "../../promptStashStore"; import { Command, CommandGroup, CommandGroupLabel, CommandItem, CommandList } from "../ui/command"; import { Button } from "../ui/button"; const SNIPPET_MAX_CHARS = 90; /** Images that did not make it into the entry, whatever the reason. */ function missingImageCount(entry: PromptStashEntry): number { return entry.droppedImageNames.length + (entry.unreadableImageNames?.length ?? 0); } function stashEntrySnippet(entry: PromptStashEntry): string { const trimmed = entry.prompt.trim().replace(/\s+/g, " "); if (trimmed.length > 0) { return trimmed.length > SNIPPET_MAX_CHARS ? `${trimmed.slice(0, SNIPPET_MAX_CHARS)}…` : trimmed; } const imageCount = entry.attachments.length + entry.droppedImageNames.length; return imageCount > 0 ? `(${imageCount} image${imageCount === 1 ? "" : "s"})` : "(empty)"; } /** * Popover listing the stashed prompts. Keyboard-first: opened by ⌘S on an * empty composer, navigated with arrows, restored with Enter, dismissed * with Escape. The listener runs capture-phase on window so it wins over * the Lexical editor's handlers while the menu is open. */ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { entries: ReadonlyArray; onRestore: (entry: PromptStashEntry) => void; onDelete: (entry: PromptStashEntry) => void; onClose: () => void; }) { const { entries, onRestore, onDelete, onClose } = props; const [highlightedId, setHighlightedId] = useState(entries[0]?.id ?? null); const highlightedEntry = entries.find((entry) => entry.id === highlightedId) ?? entries[0]; useEffect(() => { if (entries.length === 0) return; if (!entries.some((entry) => entry.id === highlightedId)) { setHighlightedId(entries[0]?.id ?? null); } }, [entries, highlightedId]); useEffect(() => { const handler = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); onClose(); return; } if (event.key === "ArrowDown" || event.key === "ArrowUp") { if (entries.length === 0) return; event.preventDefault(); event.stopPropagation(); const currentIndex = entries.findIndex((entry) => entry.id === highlightedId); const offset = event.key === "ArrowDown" ? 1 : -1; const normalizedIndex = currentIndex >= 0 ? currentIndex : offset === 1 ? -1 : 0; const nextIndex = (normalizedIndex + offset + entries.length) % entries.length; setHighlightedId(entries[nextIndex]?.id ?? null); return; } if (event.key === "Enter") { // A focused control inside the row (the delete button) owns its own // activation; swallowing Enter here would restore instead of delete. if (event.target instanceof HTMLElement && event.target.closest("button[aria-label]")) { return; } if (!highlightedEntry) return; event.preventDefault(); event.stopPropagation(); onRestore(highlightedEntry); return; } if (event.key === "Backspace" && (event.metaKey || event.ctrlKey)) { if (!highlightedEntry) return; event.preventDefault(); event.stopPropagation(); onDelete(highlightedEntry); } }; window.addEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true); }, [entries, highlightedEntry, highlightedId, onClose, onDelete, onRestore]); return (
{entries.length === 0 ? (

Nothing stashed yet. Press ⌘S with a prompt in the composer to stash it.

) : ( entries.map((entry) => ( { if (highlightedId !== entry.id) setHighlightedId(entry.id); }} onMouseDown={(event) => { event.preventDefault(); }} onClick={() => { onRestore(entry); }} > {entry.attachments.length > 0 ? ( {entry.attachments.slice(0, 3).map((attachment) => ( ))} ) : ( )} {stashEntrySnippet(entry)} {entry.pendingImageCount ? ( saving {entry.pendingImageCount} image {entry.pendingImageCount === 1 ? "" : "s"}… ) : missingImageCount(entry) > 0 ? ( {missingImageCount(entry)} image {missingImageCount(entry) === 1 ? "" : "s"} dropped ) : null} {formatRelativeTimeLabel(entry.createdAt)} )) )}
); });