| 1 | import { BookmarkIcon, FileIcon, FileTextIcon } from "lucide-react"; |
| 2 | import { memo, useEffect, useRef, useState } from "react"; |
| 3 | import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; |
| 4 | |
| 5 | import { formatRelativeTimeLabel } from "../../timestampFormat"; |
| 6 | import { cn } from "~/lib/utils"; |
| 7 | import { type PromptStashEntry } from "../../promptStashStore"; |
| 8 | import { ComposerBanner } from "./ComposerBanner"; |
| 9 | |
| 10 | const SNIPPET_MAX_CHARS = 90; |
| 11 | |
| 12 | /** Images that did not make it into the entry, whatever the reason. */ |
| 13 | function missingImageCount(entry: PromptStashEntry): number { |
| 14 | return entry.droppedImageNames.length + (entry.unreadableImageNames?.length ?? 0); |
| 15 | } |
| 16 | |
| 17 | function stashEntrySnippet(entry: PromptStashEntry): string { |
| 18 | const trimmed = assistantCitationsToPlainText(entry.prompt).trim().replace(/\s+/g, " "); |
| 19 | if (trimmed.length > 0) { |
| 20 | return trimmed.length > SNIPPET_MAX_CHARS ? `${trimmed.slice(0, SNIPPET_MAX_CHARS)}…` : trimmed; |
| 21 | } |
| 22 | const imageCount = entry.attachments.length + entry.droppedImageNames.length; |
| 23 | const fileCount = entry.files?.length ?? 0; |
| 24 | const attachmentCount = imageCount + fileCount; |
| 25 | if (attachmentCount === 0) { |
| 26 | return "(empty)"; |
| 27 | } |
| 28 | const label = imageCount > 0 && fileCount > 0 ? "attachment" : fileCount > 0 ? "file" : "image"; |
| 29 | return `(${attachmentCount} ${label}${attachmentCount === 1 ? "" : "s"})`; |
| 30 | } |
| 31 | |
| 32 | /** |
| 33 | * Attached banner listing the stashed prompts. Opened by the stash badge or ⌘S |
| 34 | * when the empty composer cannot restore a single entry. Navigated with arrows, |
| 35 | * restored with Enter, dismissed with Escape. The listener runs capture-phase |
| 36 | * on window so it wins over the composer's handlers while the menu is open. |
| 37 | */ |
| 38 | export const ComposerStashMenu = memo(function ComposerStashMenu(props: { |
| 39 | entries: ReadonlyArray<PromptStashEntry>; |
| 40 | stashShortcutLabel: string | null; |
| 41 | onRestore: (entry: PromptStashEntry) => void; |
| 42 | onDelete: (entry: PromptStashEntry) => void; |
| 43 | onClose: () => void; |
| 44 | }) { |
| 45 | const { entries, stashShortcutLabel, onRestore, onDelete, onClose } = props; |
| 46 | const drawerRef = useRef<HTMLDivElement>(null); |
| 47 | const [highlightedId, setHighlightedId] = useState<string | null>(entries[0]?.id ?? null); |
| 48 | |
| 49 | const highlightedEntry = entries.find((entry) => entry.id === highlightedId) ?? entries[0]; |
| 50 | |
| 51 | useEffect(() => { |
| 52 | const closeOnOutsidePointer = (event: PointerEvent) => { |
| 53 | const drawer = drawerRef.current; |
| 54 | if ( |
| 55 | (drawer && event.composedPath().includes(drawer)) || |
| 56 | (event.target instanceof Element && |
| 57 | event.target.closest('[data-prompt-stash-badge="true"]')) |
| 58 | ) { |
| 59 | return; |
| 60 | } |
| 61 | onClose(); |
| 62 | }; |
| 63 | document.addEventListener("pointerdown", closeOnOutsidePointer, true); |
| 64 | return () => document.removeEventListener("pointerdown", closeOnOutsidePointer, true); |
| 65 | }, [onClose]); |
| 66 | |
| 67 | useEffect(() => { |
| 68 | const handler = (event: KeyboardEvent) => { |
| 69 | if (event.key === "Escape") { |
| 70 | event.preventDefault(); |
| 71 | event.stopPropagation(); |
| 72 | onClose(); |
| 73 | return; |
| 74 | } |
| 75 | if (event.key === "ArrowDown" || event.key === "ArrowUp") { |
| 76 | if (entries.length === 0) return; |
| 77 | event.preventDefault(); |
| 78 | event.stopPropagation(); |
| 79 | const currentIndex = entries.findIndex((entry) => entry.id === highlightedEntry?.id); |
| 80 | const offset = event.key === "ArrowDown" ? 1 : -1; |
| 81 | const normalizedIndex = currentIndex >= 0 ? currentIndex : offset === 1 ? -1 : 0; |
| 82 | const nextIndex = (normalizedIndex + offset + entries.length) % entries.length; |
| 83 | setHighlightedId(entries[nextIndex]?.id ?? null); |
| 84 | const nextButton = |
| 85 | drawerRef.current?.querySelectorAll<HTMLButtonElement>("[data-stash-restore]")[nextIndex]; |
| 86 | nextButton?.scrollIntoView({ block: "nearest" }); |
| 87 | if (drawerRef.current?.contains(document.activeElement)) { |
| 88 | nextButton?.focus({ preventScroll: true }); |
| 89 | } |
| 90 | return; |
| 91 | } |
| 92 | if (event.key === "Enter") { |
| 93 | // A focused control inside the row (the delete button) owns its own |
| 94 | // activation; swallowing Enter here would restore instead of delete. |
| 95 | if (event.target instanceof HTMLElement && event.target.closest("button[aria-label]")) { |
| 96 | return; |
| 97 | } |
| 98 | if (!highlightedEntry) return; |
| 99 | event.preventDefault(); |
| 100 | event.stopPropagation(); |
| 101 | onRestore(highlightedEntry); |
| 102 | return; |
| 103 | } |
| 104 | if (event.key === "Backspace" && (event.metaKey || event.ctrlKey)) { |
| 105 | if (!highlightedEntry) return; |
| 106 | event.preventDefault(); |
| 107 | event.stopPropagation(); |
| 108 | onDelete(highlightedEntry); |
| 109 | } |
| 110 | }; |
| 111 | window.addEventListener("keydown", handler, true); |
| 112 | return () => window.removeEventListener("keydown", handler, true); |
| 113 | }, [entries, highlightedEntry, onClose, onDelete, onRestore]); |
| 114 | |
| 115 | return ( |
| 116 | <ComposerBanner.Root ref={drawerRef} data-composer-stash-drawer="true"> |
| 117 | <ComposerBanner.Row |
| 118 | render={<button type="button" />} |
| 119 | aria-label="Close stash" |
| 120 | aria-expanded="true" |
| 121 | onPointerDown={(event) => event.preventDefault()} |
| 122 | onClick={onClose} |
| 123 | > |
| 124 | <ComposerBanner.Icon> |
| 125 | <BookmarkIcon /> |
| 126 | </ComposerBanner.Icon> |
| 127 | <ComposerBanner.Content className="text-muted-foreground">Stash</ComposerBanner.Content> |
| 128 | <ComposerBanner.Actions> |
| 129 | <ComposerBanner.Count>{entries.length}</ComposerBanner.Count> |
| 130 | <ComposerBanner.ToggleIcon expanded /> |
| 131 | </ComposerBanner.Actions> |
| 132 | </ComposerBanner.Row> |
| 133 | <ComposerBanner.Scroll> |
| 134 | <ComposerBanner.Children render={<ul role="list" />} aria-label="Stashed prompts"> |
| 135 | {entries.length === 0 ? ( |
| 136 | <ComposerBanner.Row render={<li />}> |
| 137 | <ComposerBanner.Icon /> |
| 138 | <ComposerBanner.Content className="text-muted-foreground"> |
| 139 | Nothing stashed yet. |
| 140 | {stashShortcutLabel |
| 141 | ? ` Press ${stashShortcutLabel} with a prompt in the composer to stash it.` |
| 142 | : null} |
| 143 | </ComposerBanner.Content> |
| 144 | </ComposerBanner.Row> |
| 145 | ) : ( |
| 146 | entries.map((entry) => ( |
| 147 | <ComposerBanner.Row |
| 148 | render={<li />} |
| 149 | key={entry.id} |
| 150 | data-stash-entry={entry.id} |
| 151 | data-highlighted={highlightedEntry?.id === entry.id || undefined} |
| 152 | className={cn( |
| 153 | "relative rounded-sm", |
| 154 | highlightedEntry?.id === entry.id && "bg-accent text-accent-foreground", |
| 155 | )} |
| 156 | onMouseMove={() => { |
| 157 | if (highlightedId !== entry.id) setHighlightedId(entry.id); |
| 158 | }} |
| 159 | onFocus={() => setHighlightedId(entry.id)} |
| 160 | > |
| 161 | <ComposerBanner.Icon> |
| 162 | <FileTextIcon /> |
| 163 | </ComposerBanner.Icon> |
| 164 | <ComposerBanner.Content> |
| 165 | <button |
| 166 | type="button" |
| 167 | className="min-w-0 flex-1 cursor-pointer truncate text-left text-foreground/80 outline-none before:absolute before:inset-0 before:rounded-sm focus-visible:before:ring-2 focus-visible:before:ring-inset focus-visible:before:ring-ring" |
| 168 | data-stash-restore={entry.id} |
| 169 | aria-label={`Restore stashed prompt: ${stashEntrySnippet(entry)}`} |
| 170 | onPointerDown={(event) => event.preventDefault()} |
| 171 | onClick={() => onRestore(entry)} |
| 172 | > |
| 173 | {stashEntrySnippet(entry)} |
| 174 | </button> |
| 175 | </ComposerBanner.Content> |
| 176 | <ComposerBanner.Actions> |
| 177 | {entry.pendingImageCount ? ( |
| 178 | <span className="shrink-0 text-muted-foreground"> |
| 179 | saving {entry.pendingImageCount} image |
| 180 | {entry.pendingImageCount === 1 ? "" : "s"}… |
| 181 | </span> |
| 182 | ) : missingImageCount(entry) > 0 ? ( |
| 183 | <span className="shrink-0 text-warning-foreground"> |
| 184 | {missingImageCount(entry)} image |
| 185 | {missingImageCount(entry) === 1 ? "" : "s"} dropped |
| 186 | </span> |
| 187 | ) : null} |
| 188 | {entry.attachments.length > 0 ? ( |
| 189 | <span className="flex shrink-0 items-center -space-x-1.5"> |
| 190 | {entry.attachments.slice(0, 3).map((attachment) => ( |
| 191 | <img |
| 192 | key={attachment.id} |
| 193 | src={attachment.dataUrl} |
| 194 | alt="" |
| 195 | aria-hidden="true" |
| 196 | className="size-4 rounded border border-border/70 object-cover" |
| 197 | /> |
| 198 | ))} |
| 199 | </span> |
| 200 | ) : null} |
| 201 | {(entry.files?.length ?? 0) > 0 ? ( |
| 202 | <span className="flex shrink-0 items-center gap-1 text-muted-foreground"> |
| 203 | <FileIcon className="size-3" aria-hidden /> |
| 204 | {entry.files!.length} |
| 205 | </span> |
| 206 | ) : null} |
| 207 | <time |
| 208 | dateTime={entry.createdAt} |
| 209 | className="shrink-0 text-muted-foreground tabular-nums max-sm:hidden" |
| 210 | > |
| 211 | {formatRelativeTimeLabel(entry.createdAt)} |
| 212 | </time> |
| 213 | <ComposerBanner.Dismiss |
| 214 | className="z-10" |
| 215 | aria-label="Delete stashed prompt" |
| 216 | onPointerDown={(event) => event.preventDefault()} |
| 217 | onClick={() => onDelete(entry)} |
| 218 | /> |
| 219 | </ComposerBanner.Actions> |
| 220 | </ComposerBanner.Row> |
| 221 | )) |
| 222 | )} |
| 223 | </ComposerBanner.Children> |
| 224 | </ComposerBanner.Scroll> |
| 225 | </ComposerBanner.Root> |
| 226 | ); |
| 227 | }); |