-
Notifications
You must be signed in to change notification settings - Fork 3.4k
feat(web): prompt stash — cmd+S saves the composer to a per-provider queue #4453
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
9e19eea
feat(web): prompt stash — cmd+S saves the composer to a per-provider …
t3dotgg caa0491
fix(web): address prompt stash review feedback
t3dotgg 71c62b4
fix(web): address second round of prompt stash review feedback
t3dotgg 29cf237
fix(web): third round of prompt stash review feedback
t3dotgg 9872f89
fix(web): roll back the whole queue when a stash write is rejected
t3dotgg 5bfe69d
fix(web): fix prompt stash issues found in self-audit
t3dotgg f0abec9
fix(web): make stash persistence a synchronous read-modify-write
t3dotgg 69fcddd
refactor(web): drop cross-tab handling from the prompt stash
t3dotgg File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| import { BookmarkIcon } from "lucide-react"; | ||
| import { memo } from "react"; | ||
|
|
||
| import { cn } from "~/lib/utils"; | ||
|
|
||
| /** | ||
| * Bookmark pill perched on the composer's top-right shoulder. Shows the | ||
| * current method's stash count and doubles as the click target for opening | ||
| * the stash menu. | ||
| * | ||
| * On save the badge gives one quiet acknowledgement: it lifts to full | ||
| * opacity and the count ticks over. `pulseKey` changes per stash, remounting | ||
| * the count so the transition replays without a continuous animation. | ||
| */ | ||
| export const ComposerStashBadge = memo(function ComposerStashBadge(props: { | ||
| count: number; | ||
| pulseKey: number; | ||
| pulsing: boolean; | ||
| menuOpen: boolean; | ||
| onToggleMenu: () => void; | ||
| }) { | ||
| if (props.count === 0) return null; | ||
|
|
||
| return ( | ||
| <button | ||
| type="button" | ||
| data-prompt-stash-badge="true" | ||
| aria-label={`Stashed prompts: ${props.count}. Open stash.`} | ||
| aria-expanded={props.menuOpen} | ||
| className={cn( | ||
| "absolute -top-3 right-4 z-10 inline-flex cursor-pointer items-center gap-1.5 rounded-full border bg-popover px-2.5 py-0.5 text-xs shadow-sm", | ||
| "transition-[color,border-color,opacity] duration-200", | ||
| props.menuOpen || props.pulsing | ||
| ? "border-border text-foreground opacity-100" | ||
| : "border-border/70 text-muted-foreground opacity-70 hover:opacity-100 hover:text-foreground", | ||
| )} | ||
| onPointerDown={(event) => { | ||
| // Keep composer focus so Escape/typing flows stay intact. | ||
| event.preventDefault(); | ||
| }} | ||
| onClick={props.onToggleMenu} | ||
| > | ||
| <BookmarkIcon className="size-3" aria-hidden="true" /> | ||
| Stash | ||
| <span | ||
| key={props.pulseKey} | ||
| className={cn( | ||
| "rounded-full px-1.5 text-[10px] font-medium tabular-nums", | ||
| props.pulsing | ||
| ? "prompt-stash-count-enter bg-primary text-primary-foreground" | ||
| : "bg-muted text-muted-foreground", | ||
| )} | ||
| > | ||
| {props.count} | ||
| </span> | ||
| </button> | ||
| ); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,186 @@ | ||
| 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 current connection method's 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<PromptStashEntry>; | ||
| providerLabel: string; | ||
| otherScopesCount: number; | ||
| onRestore: (entry: PromptStashEntry) => void; | ||
| onDelete: (entry: PromptStashEntry) => void; | ||
| onClose: () => void; | ||
| }) { | ||
| const { entries, onRestore, onDelete, onClose } = props; | ||
| const [highlightedId, setHighlightedId] = useState<string | null>(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 ( | ||
| <Command autoHighlight={false} mode="none"> | ||
| <div className="dropdown-glass relative w-full overflow-hidden rounded-[20px]"> | ||
| <CommandList className="max-h-72"> | ||
| <CommandGroup> | ||
| <CommandGroupLabel className="flex items-center gap-1.5 px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/55"> | ||
| <BookmarkIcon className="size-3" aria-hidden="true" /> | ||
| Stashed prompts — {props.providerLabel} | ||
| </CommandGroupLabel> | ||
| {entries.length === 0 ? ( | ||
| <p className="px-3 pb-3 pt-1 text-muted-foreground/70 text-xs"> | ||
| Nothing stashed for this method yet. Press ⌘S with a prompt in the composer to stash | ||
| it. | ||
| </p> | ||
| ) : ( | ||
| entries.map((entry) => ( | ||
| <CommandItem | ||
| key={entry.id} | ||
| value={entry.id} | ||
| className={cn( | ||
| "group/stash cursor-pointer select-none gap-2 hover:bg-transparent hover:text-inherit data-highlighted:bg-transparent data-highlighted:text-inherit", | ||
| highlightedId === entry.id && "bg-accent! text-accent-foreground!", | ||
| )} | ||
| onMouseMove={() => { | ||
| if (highlightedId !== entry.id) setHighlightedId(entry.id); | ||
| }} | ||
| onMouseDown={(event) => { | ||
| event.preventDefault(); | ||
| }} | ||
| onClick={() => { | ||
| onRestore(entry); | ||
| }} | ||
| > | ||
| {entry.attachments.length > 0 ? ( | ||
| <span className="flex shrink-0 items-center -space-x-1.5"> | ||
| {entry.attachments.slice(0, 3).map((attachment) => ( | ||
| <img | ||
| key={attachment.id} | ||
| src={attachment.dataUrl} | ||
| alt="" | ||
| aria-hidden="true" | ||
| className="size-5 rounded border border-border/70 object-cover" | ||
| /> | ||
| ))} | ||
| </span> | ||
| ) : ( | ||
| <BookmarkIcon className="size-4 shrink-0 text-muted-foreground/60" /> | ||
| )} | ||
| <span className="min-w-0 flex-1 truncate text-sm"> | ||
| {stashEntrySnippet(entry)} | ||
| </span> | ||
| {entry.pendingImageCount ? ( | ||
| <span className="shrink-0 text-[10px] text-muted-foreground/60"> | ||
| saving {entry.pendingImageCount} image | ||
| {entry.pendingImageCount === 1 ? "" : "s"}… | ||
| </span> | ||
| ) : missingImageCount(entry) > 0 ? ( | ||
| <span className="shrink-0 text-[10px] text-amber-600"> | ||
| {missingImageCount(entry)} image | ||
| {missingImageCount(entry) === 1 ? "" : "s"} dropped | ||
| </span> | ||
| ) : null} | ||
| <span className="shrink-0 text-muted-foreground/60 text-xs"> | ||
| {formatRelativeTimeLabel(entry.createdAt)} | ||
| </span> | ||
| <Button | ||
| variant="ghost" | ||
| size="icon-xs" | ||
| className="shrink-0 opacity-0 transition-opacity group-hover/stash:opacity-100" | ||
| aria-label="Delete stashed prompt" | ||
| onClick={(event) => { | ||
| event.stopPropagation(); | ||
| onDelete(entry); | ||
| }} | ||
| > | ||
| <XIcon /> | ||
| </Button> | ||
| </CommandItem> | ||
| )) | ||
| )} | ||
| </CommandGroup> | ||
| {props.otherScopesCount > 0 ? ( | ||
| <p className="border-t border-border/50 px-3 py-2 text-[11px] text-muted-foreground/60"> | ||
| {props.otherScopesCount} more stashed under other connection methods — switch provider | ||
| to see them. | ||
| </p> | ||
| ) : null} | ||
| </CommandList> | ||
| </div> | ||
| </Command> | ||
| ); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.