Skip to content
448 changes: 448 additions & 0 deletions apps/web/src/components/chat/ChatComposer.tsx

Large diffs are not rendered by default.

58 changes: 58 additions & 0 deletions apps/web/src/components/chat/ComposerStashBadge.tsx
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>
);
});
186 changes: 186 additions & 0 deletions apps/web/src/components/chat/ComposerStashMenu.tsx
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;
}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
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>
);
});
38 changes: 37 additions & 1 deletion apps/web/src/composerDraftStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,13 @@ interface ComposerDraftStoreState {
attachments: PersistedComposerImageAttachment[],
) => void;
clearComposerContent: (threadRef: ComposerThreadTarget) => void;
/**
* Clears only the prompt text and image attachments, preserving terminal /
* element contexts, preview annotations, and review comments. Used by the
* prompt stash, which can only round-trip text + images: clearing the
* session-bound contexts would destroy state nothing can restore.
*/
clearComposerPromptAndImages: (threadRef: ComposerThreadTarget) => void;
}

export interface EffectiveComposerModelState {
Expand Down Expand Up @@ -2091,7 +2098,7 @@ function hydratePersistedComposerImageAttachment(
}
}

function hydrateImagesFromPersisted(
export function hydrateImagesFromPersisted(
attachments: ReadonlyArray<PersistedComposerImageAttachment>,
): ComposerImageAttachment[] {
return attachments.flatMap((attachment) => {
Expand Down Expand Up @@ -3347,6 +3354,35 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
return { draftsByThreadKey: nextDraftsByThreadKey };
});
},
clearComposerPromptAndImages: (threadRef) => {
const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
if (threadKey.length === 0) {
return;
}
set((state) => {
const current = state.draftsByThreadKey[threadKey];
if (!current) {
return state;
}
for (const image of current.images) {
revokeObjectPreviewUrl(image.previewUrl);
}
const nextDraft: ComposerThreadDraftState = {
...current,
prompt: ensureInlineTerminalContextPlaceholders("", current.terminalContexts.length),
images: [],
nonPersistedImageIds: [],
persistedAttachments: [],
};
const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
if (shouldRemoveDraft(nextDraft)) {
delete nextDraftsByThreadKey[threadKey];
} else {
nextDraftsByThreadKey[threadKey] = nextDraft;
}
return { draftsByThreadKey: nextDraftsByThreadKey };
});
},
};
},
{
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1432,6 +1432,30 @@ label:has(> select#reasoning-effort) select {
margin-top: 0.125rem;
}

/* Prompt-stash save acknowledgement: the new count fades up from just below
its resting position, once, then stops. One-shot and event-driven (React
remounts the element by key on each stash) — no continuous animation. */
@keyframes prompt-stash-count-enter {
from {
opacity: 0;
transform: translateY(2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

.prompt-stash-count-enter {
animation: prompt-stash-count-enter 180ms ease-out both;
}

@media (prefers-reduced-motion: reduce) {
.prompt-stash-count-enter {
animation: none;
}
}

@keyframes provider-update-pill-countdown {
from {
transform: scaleX(1);
Expand Down
Loading
Loading