import { BookmarkIcon, FileIcon, FileTextIcon } from "lucide-react"; import { memo, useEffect, useRef, useState } from "react"; import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; import { formatRelativeTimeLabel } from "../../timestampFormat"; import { cn } from "~/lib/utils"; import { type PromptStashEntry } from "../../promptStashStore"; import { ComposerBanner } from "./ComposerBanner"; 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 = assistantCitationsToPlainText(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; const fileCount = entry.files?.length ?? 0; const attachmentCount = imageCount + fileCount; if (attachmentCount === 0) { return "(empty)"; } const label = imageCount > 0 && fileCount > 0 ? "attachment" : fileCount > 0 ? "file" : "image"; return `(${attachmentCount} ${label}${attachmentCount === 1 ? "" : "s"})`; } /** * Attached banner listing the stashed prompts. Opened by the stash badge or ⌘S * when the empty composer cannot restore a single entry. 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; stashShortcutLabel: string | null; onRestore: (entry: PromptStashEntry) => void; onDelete: (entry: PromptStashEntry) => void; onClose: () => void; }) { const { entries, stashShortcutLabel, onRestore, onDelete, onClose } = props; const drawerRef = useRef(null); const [highlightedId, setHighlightedId] = useState(entries[0]?.id ?? null); const highlightedEntry = entries.find((entry) => entry.id === highlightedId) ?? entries[0]; useEffect(() => { const closeOnOutsidePointer = (event: PointerEvent) => { const drawer = drawerRef.current; if ( (drawer && event.composedPath().includes(drawer)) || (event.target instanceof Element && event.target.closest('[data-prompt-stash-badge="true"]')) ) { return; } onClose(); }; document.addEventListener("pointerdown", closeOnOutsidePointer, true); return () => document.removeEventListener("pointerdown", closeOnOutsidePointer, true); }, [onClose]); 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 === highlightedEntry?.id); 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); const nextButton = drawerRef.current?.querySelectorAll("[data-stash-restore]")[nextIndex]; nextButton?.scrollIntoView({ block: "nearest" }); if (drawerRef.current?.contains(document.activeElement)) { nextButton?.focus({ preventScroll: true }); } 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, onClose, onDelete, onRestore]); return ( } aria-label="Close stash" aria-expanded="true" onPointerDown={(event) => event.preventDefault()} onClick={onClose} > Stash {entries.length} } aria-label="Stashed prompts"> {entries.length === 0 ? ( }> Nothing stashed yet. {stashShortcutLabel ? ` Press ${stashShortcutLabel} with a prompt in the composer to stash it.` : null} ) : ( entries.map((entry) => ( } key={entry.id} data-stash-entry={entry.id} data-highlighted={highlightedEntry?.id === entry.id || undefined} className={cn( "relative rounded-sm", highlightedEntry?.id === entry.id && "bg-accent text-accent-foreground", )} onMouseMove={() => { if (highlightedId !== entry.id) setHighlightedId(entry.id); }} onFocus={() => setHighlightedId(entry.id)} > {entry.pendingImageCount ? ( saving {entry.pendingImageCount} image {entry.pendingImageCount === 1 ? "" : "s"}… ) : missingImageCount(entry) > 0 ? ( {missingImageCount(entry)} image {missingImageCount(entry) === 1 ? "" : "s"} dropped ) : null} {entry.attachments.length > 0 ? ( {entry.attachments.slice(0, 3).map((attachment) => ( ))} ) : null} {(entry.files?.length ?? 0) > 0 ? ( {entry.files!.length} ) : null} event.preventDefault()} onClick={() => onDelete(entry)} /> )) )} ); });