Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 34 additions & 59 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,8 @@ import {
useEffectiveComposerModelState,
} from "../../composerDraftStore";
import {
EMPTY_PROMPT_STASH_QUEUE,
MAX_STASH_ENTRIES_PER_QUEUE,
MAX_STASH_ENTRIES,
partitionStashAttachments,
promptStashScopeKey,
usePromptStashStore,
type PromptStashEntry,
} from "../../promptStashStore";
Expand Down Expand Up @@ -733,7 +731,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
const clearComposerDraftPromptAndImages = useComposerDraftStore(
(store) => store.clearComposerPromptAndImages,
);
const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection);
const syncComposerDraftPersistedAttachments = useComposerDraftStore(
(store) => store.syncPersistedAttachments,
);
Expand Down Expand Up @@ -1888,23 +1885,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
// ------------------------------------------------------------------
// Prompt stash (⌘S)
// ------------------------------------------------------------------
const stashScopeInstanceId = noProviderAvailable ? null : selectedInstanceId;
const stashScope = promptStashScopeKey(stashScopeInstanceId);
const stashQueue = usePromptStashStore(
(state) => state.queuesByScopeKey[stashScope] ?? EMPTY_PROMPT_STASH_QUEUE,
);
const stashOtherScopesCount = usePromptStashStore((state) =>
Object.entries(state.queuesByScopeKey).reduce(
(total, [key, queue]) => (key === stashScope ? total : total + queue.length),
0,
),
);
// One global queue. Stashed prompts carry only text + images so they can be
// restored into any thread or provider — stash, switch, restore is the
// whole point.
const stashQueue = usePromptStashStore((state) => state.entries);
const stashEntryToQueue = usePromptStashStore((state) => state.stashEntry);
const takeStashEntry = usePromptStashStore((state) => state.takeEntry);
const finalizeStashEntryImages = usePromptStashStore((state) => state.finalizeEntryImages);
const stashProviderLabel = noProviderAvailable
? "No provider"
: getProviderDisplayName(providerStatuses, selectedProvider);

useEffect(() => {
return () => {
Expand All @@ -1930,10 +1917,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
const restoreStashEntry = useCallback(
(entry: PromptStashEntry) => {
// Remove first so a double activation (click + Enter) can't restore twice.
const { entry: taken, durable } = takeStashEntry(
promptStashScopeKey(entry.providerInstanceId),
entry.id,
);
const { entry: taken, durable } = takeStashEntry(entry.id);
if (!taken) return;
if (!durable) {
toastManager.add({
Expand Down Expand Up @@ -1996,21 +1980,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
}
}

const restorableSelection =
entry.modelSelection &&
providerInstanceEntries.some(
(candidate) =>
candidate.instanceId === entry.modelSelection?.instanceId &&
candidate.enabled &&
candidate.isAvailable,
)
? entry.modelSelection
: null;
if (restorableSelection) {
setComposerDraftModelSelection(composerDraftTarget, restorableSelection, {
replaceOptions: true,
});
}
// Deliberately no model/provider restore: the stash exists to carry a
// prompt across threads and providers, so whatever the composer has
// selected right now stays selected.

// Each cause gets its own sentence so "too large" is never blamed for a
// file that actually failed to decode, or for one the composer simply
Expand Down Expand Up @@ -2052,16 +2024,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
composerDraftTarget,
composerImagesRef,
promptRef,
providerInstanceEntries,
setComposerDraftModelSelection,
setComposerDraftPrompt,
takeStashEntry,
],
);

const deleteStashEntry = useCallback(
(entry: PromptStashEntry) => {
const { durable } = takeStashEntry(promptStashScopeKey(entry.providerInstanceId), entry.id);
const { durable } = takeStashEntry(entry.id);
if (!durable) {
toastManager.add({
type: "warning",
Expand Down Expand Up @@ -2097,30 +2067,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)

const stashTarget = composerDraftTarget;
const entryId = randomUUID();
const scopeKey = promptStashScopeKey(stashScopeInstanceId);
try {
// Persist the text-only entry *first*, then clear. Ordering matters in
// both directions: writing before clearing means a crash or closed tab
// mid-encode still leaves the prompt recoverable, while clearing before
// the async image work means edits typed during encoding are not wiped.
// Images are appended to the stored entry as they finish encoding.
const { evicted, durable } = stashEntryToQueue({
const { evicted, written, durable } = stashEntryToQueue({
id: entryId,
createdAt: new Date().toISOString(),
prompt,
attachments: [],
providerInstanceId: stashScopeInstanceId,
modelSelection: noProviderAvailable ? null : selectedModelSelection,
droppedImageNames: [],
unreadableImageNames: [],
pendingImageCount: images.length,
});

// Clearing the composer is only safe once the entry is durable. If the
// write was rejected (quota, blocked storage) the store has already
// rolled itself back, so leave the composer untouched rather than
// making it the second casualty of a reload.
if (!durable) {
// Clearing the composer is only safe once the write actually landed.
// If it was rejected (quota) the store has already rolled itself back,
// so leave the composer untouched rather than making it the second
// casualty of a reload.
if (!written) {
toastManager.add({
type: "error",
title: "Could not stash this prompt",
Expand All @@ -2130,6 +2097,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
});
return;
}
// Written but only into the in-memory fallback (localStorage blocked):
// the entry is visible and restorable this session, so proceed with the
// clear, but say it won't survive a reload.
if (!durable) {
toastManager.add({
type: "warning",
title: "Stashed prompt will not survive a reload",
description:
"Browser storage is unavailable, so this stash is kept in memory only for this session.",
data: { hideCopyButton: true },
});
}
Comment thread
cursor[bot] marked this conversation as resolved.

// Only the prompt and images are cleared — terminal/element contexts,
// preview annotations, and review comments are not stashable, so
Expand All @@ -2144,7 +2123,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
toastManager.add({
type: "warning",
title: "Oldest stashed prompt discarded",
description: `The ${stashProviderLabel} stash holds ${MAX_STASH_ENTRIES_PER_QUEUE} prompts; the oldest was removed to make room.`,
description: `The stash holds ${MAX_STASH_ENTRIES} prompts; the oldest was removed to make room.`,
data: { hideCopyButton: true },
});
}
Expand Down Expand Up @@ -2176,7 +2155,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
}
const { kept, droppedNames } = partitionStashAttachments(candidateAttachments);

const { attached, durable: imagesDurable } = finalizeStashEntryImages(scopeKey, entryId, {
const { attached, durable: imagesDurable } = finalizeStashEntryImages(entryId, {
attachments: kept,
droppedImageNames: [...oversizedImageNames, ...droppedNames],
unreadableImageNames,
Expand All @@ -2185,8 +2164,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
// The second phase can be rejected on its own: the text-only entry
// fit, but adding image payloads pushed past the quota. Disk would
// then still hold the phase-one entry with pendingImageCount set,
// which reads as an orphan after reload — so say so now.
if (!imagesDurable && images.length > 0) {
// which reads as an orphan after reload — so say so now. Gated on the
// entry write having been durable: on the in-memory fallback nothing
// is ever durable, and the session-only warning already covered it.
if (!imagesDurable && durable && images.length > 0) {
toastManager.add({
type: "warning",
title: "Stashed images were not saved",
Expand Down Expand Up @@ -2216,13 +2197,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
composerDraftTarget,
composerImagesRef,
finalizeStashEntryImages,
noProviderAvailable,
promptRef,
pulseStashBadge,
selectedModelSelection,
stashEntryToQueue,
stashProviderLabel,
stashScopeInstanceId,
]);

const toggleStashMenu = useCallback(() => {
Expand Down Expand Up @@ -2832,8 +2809,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
<ComposerCommandMenuLayer anchor={composerMenuAnchor}>
<ComposerStashMenu
entries={stashQueue}
providerLabel={stashProviderLabel}
otherScopesCount={stashOtherScopesCount}
onRestore={restoreStashEntry}
onDelete={deleteStashEntry}
onClose={() => setIsStashMenuOpen(false)}
Expand Down
3 changes: 1 addition & 2 deletions apps/web/src/components/chat/ComposerStashBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ 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.
* 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
Expand Down
22 changes: 6 additions & 16 deletions apps/web/src/components/chat/ComposerStashMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,13 @@ function stashEntrySnippet(entry: PromptStashEntry): string {
}

/**
* 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.
* Popover listing the 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;
Expand Down Expand Up @@ -99,12 +96,11 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
<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}
Stashed prompts
</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.
Nothing stashed yet. Press ⌘S with a prompt in the composer to stash it.
</p>
) : (
entries.map((entry) => (
Expand Down Expand Up @@ -173,12 +169,6 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
))
)}
</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>
Expand Down
Loading
Loading