1import { BookmarkIcon, FileIcon, FileTextIcon } from "lucide-react";
2import { memo, useEffect, useRef, useState } from "react";
3import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations";
4
5import { formatRelativeTimeLabel } from "../../timestampFormat";
6import { cn } from "~/lib/utils";
7import { type PromptStashEntry } from "../../promptStashStore";
8import { ComposerBanner } from "./ComposerBanner";
9
10const SNIPPET_MAX_CHARS = 90;
11
12/** Images that did not make it into the entry, whatever the reason. */
13function missingImageCount(entry: PromptStashEntry): number {
14 return entry.droppedImageNames.length + (entry.unreadableImageNames?.length ?? 0);
15}
16
17function 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 */
38export 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});