|
| 1 | +import { BookmarkIcon, XIcon } from "lucide-react"; |
| 2 | +import { memo } from "react"; |
| 3 | + |
| 4 | +import { cn } from "~/lib/utils"; |
| 5 | +import { stripInlineTerminalContextPlaceholders } from "../../lib/terminalContext"; |
| 6 | +import { stashEntryChipCount, type PromptStashEntry } from "../../promptStashStore"; |
| 7 | +import { formatRelativeTimeLabel } from "../../timestampFormat"; |
| 8 | +import { Button } from "../ui/button"; |
| 9 | +import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuShortcut, MenuTrigger } from "../ui/menu"; |
| 10 | + |
| 11 | +const SNIPPET_MAX_CHARS = 90; |
| 12 | +const MAX_ROW_THUMBNAILS = 3; |
| 13 | + |
| 14 | +export interface ComposerStashControlProps { |
| 15 | + entries: ReadonlyArray<PromptStashEntry>; |
| 16 | + open: boolean; |
| 17 | + onOpenChange: (open: boolean) => void; |
| 18 | + /** The composer has something worth stashing right now. */ |
| 19 | + canStash: boolean; |
| 20 | + /** Rendered right-aligned on the stash action row; null when unbound. */ |
| 21 | + stashShortcutLabel: string | null; |
| 22 | + onStash: () => void; |
| 23 | + onRestore: (entry: PromptStashEntry) => void; |
| 24 | + onDelete: (entry: PromptStashEntry) => void; |
| 25 | +} |
| 26 | + |
| 27 | +/** Attachments that did not make it into the entry, whatever the reason. */ |
| 28 | +function missingAttachmentCount(entry: PromptStashEntry): number { |
| 29 | + return entry.droppedAttachmentNames.length + (entry.unreadableAttachmentNames?.length ?? 0); |
| 30 | +} |
| 31 | + |
| 32 | +function stashEntrySnippet(entry: PromptStashEntry): string { |
| 33 | + // Inline terminal placeholders are invisible object-replacement characters |
| 34 | + // in the live composer; left in they would render as tofu in the list. |
| 35 | + const trimmed = stripInlineTerminalContextPlaceholders(entry.prompt).trim().replace(/\s+/g, " "); |
| 36 | + if (trimmed.length > 0) { |
| 37 | + return trimmed.length > SNIPPET_MAX_CHARS ? `${trimmed.slice(0, SNIPPET_MAX_CHARS)}…` : trimmed; |
| 38 | + } |
| 39 | + const attachmentCount = entry.attachments.length + entry.droppedAttachmentNames.length; |
| 40 | + if (attachmentCount > 0) { |
| 41 | + return `${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"}`; |
| 42 | + } |
| 43 | + const chipCount = stashEntryChipCount(entry); |
| 44 | + return chipCount > 0 ? `${chipCount} chip${chipCount === 1 ? "" : "s"}` : "Empty prompt"; |
| 45 | +} |
| 46 | + |
| 47 | +const StashEntryRow = memo(function StashEntryRow(props: { |
| 48 | + entry: PromptStashEntry; |
| 49 | + onRestore: (entry: PromptStashEntry) => void; |
| 50 | + onDelete: (entry: PromptStashEntry) => void; |
| 51 | +}) { |
| 52 | + const { entry, onRestore, onDelete } = props; |
| 53 | + const thumbnails = entry.attachments |
| 54 | + .filter((attachment) => attachment.mimeType.startsWith("image/")) |
| 55 | + .slice(0, MAX_ROW_THUMBNAILS); |
| 56 | + const chipCount = stashEntryChipCount(entry); |
| 57 | + const missingCount = missingAttachmentCount(entry); |
| 58 | + |
| 59 | + return ( |
| 60 | + <MenuItem |
| 61 | + className="group/stash gap-2 rounded-none border-border border-b px-2 last:border-b-0" |
| 62 | + onClick={() => { |
| 63 | + onRestore(entry); |
| 64 | + }} |
| 65 | + > |
| 66 | + {thumbnails.length > 0 ? ( |
| 67 | + <span className="-space-x-1.5 flex shrink-0 items-center"> |
| 68 | + {thumbnails.map((attachment) => ( |
| 69 | + <img |
| 70 | + key={attachment.id} |
| 71 | + src={attachment.dataUrl} |
| 72 | + alt="" |
| 73 | + aria-hidden="true" |
| 74 | + className="size-5 rounded-xs border border-border object-cover" |
| 75 | + /> |
| 76 | + ))} |
| 77 | + </span> |
| 78 | + ) : ( |
| 79 | + <BookmarkIcon className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" /> |
| 80 | + )} |
| 81 | + <span className="min-w-0 flex-1 truncate">{stashEntrySnippet(entry)}</span> |
| 82 | + {chipCount > 0 ? ( |
| 83 | + <span className="shrink-0 text-muted-foreground text-xs"> |
| 84 | + {chipCount} chip{chipCount === 1 ? "" : "s"} |
| 85 | + </span> |
| 86 | + ) : null} |
| 87 | + {entry.pendingAttachmentCount ? ( |
| 88 | + <span className="shrink-0 text-muted-foreground text-xs"> |
| 89 | + saving {entry.pendingAttachmentCount} |
| 90 | + </span> |
| 91 | + ) : missingCount > 0 ? ( |
| 92 | + <span className="shrink-0 text-warning-foreground text-xs">{missingCount} dropped</span> |
| 93 | + ) : null} |
| 94 | + <span className="shrink-0 font-mono text-muted-foreground text-xs tabular-nums"> |
| 95 | + {formatRelativeTimeLabel(entry.createdAt)} |
| 96 | + </span> |
| 97 | + <Button |
| 98 | + type="button" |
| 99 | + variant="ghost" |
| 100 | + size="icon-xs" |
| 101 | + className="shrink-0 text-muted-foreground opacity-0 group-hover/stash:opacity-100" |
| 102 | + aria-label={`Delete stashed prompt: ${stashEntrySnippet(entry)}`} |
| 103 | + // The row itself restores on click; without both of these the delete |
| 104 | + // would restore the very prompt it is meant to throw away. |
| 105 | + onPointerDown={(event) => { |
| 106 | + event.preventDefault(); |
| 107 | + event.stopPropagation(); |
| 108 | + }} |
| 109 | + onClick={(event) => { |
| 110 | + event.preventDefault(); |
| 111 | + event.stopPropagation(); |
| 112 | + onDelete(entry); |
| 113 | + }} |
| 114 | + > |
| 115 | + <XIcon aria-hidden="true" /> |
| 116 | + </Button> |
| 117 | + </MenuItem> |
| 118 | + ); |
| 119 | +}); |
| 120 | + |
| 121 | +/** |
| 122 | + * Popover body: the stash action for the current prompt, then the queue |
| 123 | + * itself as a dense divided list. |
| 124 | + */ |
| 125 | +const ComposerStashList = memo(function ComposerStashList(props: ComposerStashControlProps) { |
| 126 | + const { entries, canStash, stashShortcutLabel, onStash, onRestore, onDelete } = props; |
| 127 | + |
| 128 | + return ( |
| 129 | + <> |
| 130 | + {canStash ? ( |
| 131 | + <> |
| 132 | + <MenuItem onClick={onStash}> |
| 133 | + <BookmarkIcon aria-hidden="true" /> |
| 134 | + Stash this prompt |
| 135 | + {stashShortcutLabel ? <MenuShortcut>{stashShortcutLabel}</MenuShortcut> : null} |
| 136 | + </MenuItem> |
| 137 | + <MenuSeparator /> |
| 138 | + </> |
| 139 | + ) : null} |
| 140 | + {entries.length === 0 ? ( |
| 141 | + <p className="px-2 py-1.5 text-muted-foreground text-xs"> |
| 142 | + Nothing stashed yet. Press {stashShortcutLabel ?? "the stash shortcut"} with a prompt |
| 143 | + typed to stash it. |
| 144 | + </p> |
| 145 | + ) : ( |
| 146 | + entries.map((entry) => ( |
| 147 | + <StashEntryRow key={entry.id} entry={entry} onRestore={onRestore} onDelete={onDelete} /> |
| 148 | + )) |
| 149 | + )} |
| 150 | + </> |
| 151 | + ); |
| 152 | +}); |
| 153 | + |
| 154 | +/** |
| 155 | + * Composer footer stash control: a bookmark button carrying the queue depth, |
| 156 | + * opening the stashed prompts above it. Always visible, so the queue is never |
| 157 | + * something the user has to remember a shortcut to find. |
| 158 | + */ |
| 159 | +export const ComposerStashControl = memo(function ComposerStashControl( |
| 160 | + props: ComposerStashControlProps, |
| 161 | +) { |
| 162 | + const { entries, open, onOpenChange } = props; |
| 163 | + |
| 164 | + return ( |
| 165 | + <Menu open={open} onOpenChange={onOpenChange}> |
| 166 | + <MenuTrigger |
| 167 | + render={ |
| 168 | + <Button |
| 169 | + type="button" |
| 170 | + variant="ghost" |
| 171 | + size={entries.length > 0 ? "sm" : "icon-sm"} |
| 172 | + className={cn( |
| 173 | + "rounded-full text-muted-foreground/70 hover:text-foreground/80", |
| 174 | + entries.length > 0 && "gap-1 px-2", |
| 175 | + )} |
| 176 | + aria-label={ |
| 177 | + entries.length > 0 ? `Stashed prompts (${entries.length})` : "Stashed prompts" |
| 178 | + } |
| 179 | + /> |
| 180 | + } |
| 181 | + > |
| 182 | + <BookmarkIcon className="size-4" aria-hidden="true" /> |
| 183 | + {entries.length > 0 ? <span className="text-xs tabular-nums">{entries.length}</span> : null} |
| 184 | + </MenuTrigger> |
| 185 | + <MenuPopup align="end" side="top" className="w-96 max-w-[min(24rem,calc(100vw-2rem))]"> |
| 186 | + <ComposerStashList {...props} /> |
| 187 | + </MenuPopup> |
| 188 | + </Menu> |
| 189 | + ); |
| 190 | +}); |
0 commit comments