Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
63 changes: 12 additions & 51 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,7 +2067,6 @@ 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
Expand All @@ -2109,8 +2078,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
createdAt: new Date().toISOString(),
prompt,
attachments: [],
providerInstanceId: stashScopeInstanceId,
modelSelection: noProviderAvailable ? null : selectedModelSelection,
droppedImageNames: [],
unreadableImageNames: [],
pendingImageCount: images.length,
Expand Down Expand Up @@ -2144,7 +2111,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 +2143,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 Down Expand Up @@ -2216,13 +2183,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 +2795,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