From abd89bc2b5f67c26903c456ee6b75fa43f3a1e03 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 27 Jul 2026 06:34:13 -0700 Subject: [PATCH 01/35] =?UTF-8?q?feat(web):=20prompt=20stash=20=E2=80=94?= =?UTF-8?q?=20cmd+S=20saves=20the=20composer=20to=20a=20per-provider=20que?= =?UTF-8?q?ue=20(#4453)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Fable 5 (cherry picked from commit 200fa826b02cf0503c6f6c2bd7250a58747bff2d) --- apps/web/src/components/chat/ChatComposer.tsx | 448 ++++++++++++++++++ .../components/chat/ComposerStashBadge.tsx | 58 +++ .../src/components/chat/ComposerStashMenu.tsx | 186 ++++++++ apps/web/src/composerDraftStore.ts | 38 +- apps/web/src/index.css | 24 + .../web/src/lib/stashImageCompression.test.ts | 198 ++++++++ apps/web/src/lib/stashImageCompression.ts | 250 ++++++++++ apps/web/src/promptStashStore.test.ts | 259 ++++++++++ apps/web/src/promptStashStore.ts | 322 +++++++++++++ packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 1 + 11 files changed, 1784 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/chat/ComposerStashBadge.tsx create mode 100644 apps/web/src/components/chat/ComposerStashMenu.tsx create mode 100644 apps/web/src/lib/stashImageCompression.test.ts create mode 100644 apps/web/src/lib/stashImageCompression.ts create mode 100644 apps/web/src/promptStashStore.test.ts create mode 100644 apps/web/src/promptStashStore.ts diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 6eba26ae70b..67ebb3b50b7 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -51,13 +51,29 @@ import { type ComposerImageAttachment, type DraftId, type PersistedComposerImageAttachment, + hydrateImagesFromPersisted, useComposerDraftStore, useComposerThreadDraft, useEffectiveComposerModelState, } from "../../composerDraftStore"; +import { + EMPTY_PROMPT_STASH_QUEUE, + MAX_STASH_ENTRIES_PER_QUEUE, + partitionStashAttachments, + promptStashScopeKey, + usePromptStashStore, + type PromptStashEntry, +} from "../../promptStashStore"; +import { ComposerStashBadge } from "./ComposerStashBadge"; +import { ComposerStashMenu } from "./ComposerStashMenu"; +import { compressImageForStash } from "../../lib/stashImageCompression"; +import { isCommandPaletteOpen } from "../../commandPaletteBus"; +import { getTerminalFocusOwner } from "../../lib/terminalFocus"; +import { resolveShortcutCommand } from "../../keybindings"; import { type TerminalContextDraft, type TerminalContextSelection, + INLINE_TERMINAL_CONTEXT_PLACEHOLDER, insertInlineTerminalContextPlaceholder, removeInlineTerminalContextPlaceholder, } from "../../lib/terminalContext"; @@ -689,6 +705,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const clearComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.clearPersistedAttachments, ); + const clearComposerDraftPromptAndImages = useComposerDraftStore( + (store) => store.clearComposerPromptAndImages, + ); + const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); const syncComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.syncPersistedAttachments, ); @@ -927,6 +947,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); const [isComposerFocused, setIsComposerFocused] = useState(false); const [composerMenuAnchor, setComposerMenuAnchor] = useState(null); + const [isStashMenuOpen, setIsStashMenuOpen] = useState(false); + const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({ + key: 0, + active: false, + }); const isMobileViewport = useMediaQuery("max-sm"); const isComposerCollapsedMobile = isMobileViewport && !forceExpandedOnMobile && !isComposerFocused; @@ -946,6 +971,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const mobileComposerExpandReleaseFrameRef = useRef(null); const mobileComposerExpandInFlightRef = useRef(false); const dragDepthRef = useRef(0); + const stashPulseKeyRef = useRef(0); + const stashPulseTimeoutRef = useRef(null); + /** + * Snapshots currently being encoded, keyed by target+prompt+image ids. + * Keyed rather than boolean so a genuinely different prompt (or a different + * thread) can still be stashed while an earlier encode is running. + */ + const stashInFlightRef = useRef>(new Set()); // ------------------------------------------------------------------ // Derived: composer send state @@ -1827,6 +1860,400 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return false; }; + // ------------------------------------------------------------------ + // 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, + ), + ); + 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 () => { + if (stashPulseTimeoutRef.current !== null) { + window.clearTimeout(stashPulseTimeoutRef.current); + } + }; + }, []); + + /** Briefly highlight the badge so the save registers without a flourish. */ + const pulseStashBadge = useCallback(() => { + stashPulseKeyRef.current += 1; + setStashPulse({ key: stashPulseKeyRef.current, active: true }); + if (stashPulseTimeoutRef.current !== null) { + window.clearTimeout(stashPulseTimeoutRef.current); + } + stashPulseTimeoutRef.current = window.setTimeout(() => { + stashPulseTimeoutRef.current = null; + setStashPulse((current) => ({ ...current, active: false })); + }, 1200); + }, []); + + 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, + ); + if (!taken) return; + if (!durable) { + toastManager.add({ + type: "warning", + title: "Restored prompt may reappear in the stash", + description: + "Browser storage rejected the update, so this entry could still be there after a reload.", + data: { hideCopyButton: true }, + }); + } + setIsStashMenuOpen(false); + + const currentPrompt = promptRef.current; + // An image-only stash must not append blank lines to whatever is + // already in the composer. + const nextPrompt = + entry.prompt.length === 0 + ? currentPrompt + : currentPrompt.trim().length + ? `${currentPrompt.replace(/\s+$/, "")}\n\n${entry.prompt}` + : entry.prompt; + const promptChanged = nextPrompt !== currentPrompt; + if (promptChanged) { + promptRef.current = nextPrompt; + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + setComposerCursor(collapseExpandedComposerCursor(nextPrompt, nextPrompt.length)); + setComposerTrigger(null); + } + + let unrestoredImageNames: string[] = []; + if (entry.attachments.length > 0) { + const existingIds = new Set(composerImagesRef.current.map((image) => image.id)); + // The draft store also dedupes by mimeType+sizeBytes+name, so filter + // on the same key here. Counting a duplicate against capacity would + // burn a slot the store then refuses to fill, pushing a genuinely + // unique image into the overflow list for nothing. + const existingDedupKeys = new Set( + composerImagesRef.current.map( + (image) => `${image.mimeType}${image.sizeBytes}${image.name}`, + ), + ); + const capacity = Math.max( + 0, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS - composerImagesRef.current.length, + ); + const pending = entry.attachments.filter( + (attachment) => + !existingIds.has(attachment.id) && + !existingDedupKeys.has( + `${attachment.mimeType}${attachment.sizeBytes}${attachment.name}`, + ), + ); + // Anything past the attachment limit cannot be restored. The entry is + // already out of the queue, so report the overflow by name instead of + // discarding it silently. + unrestoredImageNames = pending.slice(capacity).map((attachment) => attachment.name); + const restoredImages = hydrateImagesFromPersisted(pending.slice(0, capacity)); + if (restoredImages.length > 0) { + addComposerDraftImages(composerDraftTarget, restoredImages); + } + } + + 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, + }); + } + + // 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 + // had no room to take back. + const missingImageReasons: string[] = []; + if (entry.droppedImageNames.length > 0) { + missingImageReasons.push( + `${entry.droppedImageNames.join(", ")} exceeded the stash size limit when this prompt was saved.`, + ); + } + if (entry.unreadableImageNames && entry.unreadableImageNames.length > 0) { + missingImageReasons.push( + `${entry.unreadableImageNames.join(", ")} could not be read when this prompt was saved.`, + ); + } + if (unrestoredImageNames.length > 0) { + missingImageReasons.push( + `${unrestoredImageNames.join(", ")} could not be restored: the composer is at its ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-image limit.`, + ); + } + if (missingImageReasons.length > 0) { + toastManager.add({ + type: "warning", + title: "Some images were not restored", + description: missingImageReasons.join(" "), + }); + } + + // Only yank the caret to the end when text was actually inserted; + // restoring images alone should leave the user where they were typing. + if (promptChanged) { + window.requestAnimationFrame(() => { + composerEditorRef.current?.focusAtEnd(); + }); + } + }, + [ + addComposerDraftImages, + composerDraftTarget, + composerImagesRef, + promptRef, + providerInstanceEntries, + setComposerDraftModelSelection, + setComposerDraftPrompt, + takeStashEntry, + ], + ); + + const deleteStashEntry = useCallback( + (entry: PromptStashEntry) => { + const { durable } = takeStashEntry(promptStashScopeKey(entry.providerInstanceId), entry.id); + if (!durable) { + toastManager.add({ + type: "warning", + title: "Stash entry may come back", + description: + "Browser storage rejected the delete, so this prompt could reappear after a reload.", + data: { hideCopyButton: true }, + }); + } + }, + [takeStashEntry], + ); + + const stashCurrentPrompt = useCallback(async () => { + // Terminal-context placeholders reference live sessions the stash can't + // round-trip, so they are stripped from the stashed prompt. + const prompt = promptRef.current.split(INLINE_TERMINAL_CONTEXT_PLACEHOLDER).join("").trim(); + const images = [...composerImagesRef.current]; + if (prompt.length === 0 && images.length === 0) { + setIsStashMenuOpen((open) => !open); + return; + } + // A repeat ⌘S on the *same* still-unencoded snapshot would stash it + // twice. Guard on the snapshot itself rather than a bare boolean: once + // the composer has been cleared the user can type something genuinely + // new (or switch threads) while encoding continues, and that deserves its + // own entry. + const snapshotKey = `${String(composerDraftTarget)}${prompt}${images + .map((image) => image.id) + .join(",")}`; + if (stashInFlightRef.current.has(snapshotKey)) return; + stashInFlightRef.current.add(snapshotKey); + + 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({ + 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) { + toastManager.add({ + type: "error", + title: "Could not stash this prompt", + description: + "Browser storage rejected the write, so the composer was left as-is. Free up site data and try again.", + data: { hideCopyButton: true }, + }); + return; + } + + // Only the prompt and images are cleared — terminal/element contexts, + // preview annotations, and review comments are not stashable, so + // destroying them here would be unrecoverable. + promptRef.current = ""; + clearComposerDraftPromptAndImages(stashTarget); + setComposerCursor(0); + setComposerTrigger(null); + pulseStashBadge(); + + if (evicted) { + 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.`, + data: { hideCopyButton: true }, + }); + } + + // Images are re-encoded for the stash rather than stored verbatim: the + // composer allows up to 10MB per image, but localStorage gives the whole + // origin ~5MB. Only the stashed copy shrinks; the live attachment (and + // anything sent without stashing) keeps the original file. + const candidateAttachments: PersistedComposerImageAttachment[] = []; + const oversizedImageNames: string[] = []; + const unreadableImageNames: string[] = []; + for (const image of images) { + const result = await compressImageForStash(image.file); + if (!result.ok) { + // "too large" and "could not be read" are distinct outcomes; the + // menu and restore toast report them separately. + (result.reason === "too-large" ? oversizedImageNames : unreadableImageNames).push( + image.name, + ); + continue; + } + candidateAttachments.push({ + id: image.id, + name: image.name, + mimeType: result.image.mimeType, + sizeBytes: result.image.sizeBytes, + dataUrl: result.image.dataUrl, + }); + } + const { kept, droppedNames } = partitionStashAttachments(candidateAttachments); + + const { attached, durable: imagesDurable } = finalizeStashEntryImages(scopeKey, entryId, { + attachments: kept, + droppedImageNames: [...oversizedImageNames, ...droppedNames], + unreadableImageNames, + }); + if (attached) { + // 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) { + toastManager.add({ + type: "warning", + title: "Stashed images were not saved", + description: + "The prompt was stashed, but browser storage rejected its images. They will be missing if you reload.", + data: { hideCopyButton: true }, + }); + } + } else if (kept.length > 0) { + // The entry was restored or deleted before its images finished + // encoding, so they have nowhere to land. Say so rather than letting + // them evaporate. + toastManager.add({ + type: "warning", + title: "Stashed images did not attach", + description: `That prompt was restored or deleted before ${kept.length} image${kept.length === 1 ? "" : "s"} finished saving. Re-attach ${kept.length === 1 ? "it" : "them"} if you still need ${kept.length === 1 ? "it" : "them"}.`, + data: { hideCopyButton: true }, + }); + } + } finally { + // Must clear on every path: a throw that left this set would wedge this + // snapshot's ⌘S until the composer remounts. + stashInFlightRef.current.delete(snapshotKey); + } + }, [ + clearComposerDraftPromptAndImages, + composerDraftTarget, + composerImagesRef, + finalizeStashEntryImages, + noProviderAvailable, + promptRef, + pulseStashBadge, + selectedModelSelection, + stashEntryToQueue, + stashProviderLabel, + stashScopeInstanceId, + ]); + + const toggleStashMenu = useCallback(() => { + setIsStashMenuOpen((open) => !open); + }, []); + + // Close the stash menu whenever the trigger-driven command menu opens so + // the two popovers never stack in the same layer, and when the user + // resumes typing (the menu is a transient picker, not a panel). + useEffect(() => { + if (composerMenuOpen) { + setIsStashMenuOpen(false); + } + }, [composerMenuOpen]); + useEffect(() => { + setIsStashMenuOpen(false); + }, [prompt]); + + useEffect(() => { + const handler = (event: globalThis.KeyboardEvent) => { + const command = resolveShortcutCommand(event, keybindings, { + context: { + terminalFocus: getTerminalFocusOwner() !== null, + terminalOpen, + modelPickerOpen: isComposerModelPickerOpen, + }, + }); + if (command !== "composer.stash") return; + // Always claim the shortcut so the browser save dialog never opens, + // even when the composer is in a state that can't stash. + event.preventDefault(); + event.stopPropagation(); + if ( + isCommandPaletteOpen() || + isComposerApprovalState || + pendingUserInputs.length > 0 || + projectSelectionRequired || + activePendingProgress !== null + ) { + return; + } + void stashCurrentPrompt(); + }; + window.addEventListener("keydown", handler, true); + return () => window.removeEventListener("keydown", handler, true); + }, [ + activePendingProgress, + isComposerApprovalState, + isComposerModelPickerOpen, + keybindings, + pendingUserInputs.length, + projectSelectionRequired, + stashCurrentPrompt, + terminalOpen, + ]); + // ------------------------------------------------------------------ // Callbacks: images // ------------------------------------------------------------------ @@ -2368,6 +2795,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isComposerCollapsedMobile && "hidden", )} > + + + {isStashMenuOpen && !composerMenuOpen && !isComposerApprovalState && ( + + setIsStashMenuOpen(false)} + /> + + )} + {composerMenuOpen && !isComposerApprovalState && ( void; +}) { + if (props.count === 0) return null; + + return ( + + ); +}); diff --git a/apps/web/src/components/chat/ComposerStashMenu.tsx b/apps/web/src/components/chat/ComposerStashMenu.tsx new file mode 100644 index 00000000000..22950074307 --- /dev/null +++ b/apps/web/src/components/chat/ComposerStashMenu.tsx @@ -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; + providerLabel: string; + otherScopesCount: number; + onRestore: (entry: PromptStashEntry) => void; + onDelete: (entry: PromptStashEntry) => void; + onClose: () => void; +}) { + const { entries, onRestore, onDelete, onClose } = props; + const [highlightedId, setHighlightedId] = useState(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 ( + +
+ + + + + {entries.length === 0 ? ( +

+ Nothing stashed for this method yet. Press ⌘S with a prompt in the composer to stash + it. +

+ ) : ( + entries.map((entry) => ( + { + if (highlightedId !== entry.id) setHighlightedId(entry.id); + }} + onMouseDown={(event) => { + event.preventDefault(); + }} + onClick={() => { + onRestore(entry); + }} + > + {entry.attachments.length > 0 ? ( + + {entry.attachments.slice(0, 3).map((attachment) => ( + + ))} + + ) : ( + + )} + + {stashEntrySnippet(entry)} + + {entry.pendingImageCount ? ( + + saving {entry.pendingImageCount} image + {entry.pendingImageCount === 1 ? "" : "s"}… + + ) : missingImageCount(entry) > 0 ? ( + + {missingImageCount(entry)} image + {missingImageCount(entry) === 1 ? "" : "s"} dropped + + ) : null} + + {formatRelativeTimeLabel(entry.createdAt)} + + + + )) + )} +
+ {props.otherScopesCount > 0 ? ( +

+ {props.otherScopesCount} more stashed under other connection methods — switch provider + to see them. +

+ ) : null} +
+
+
+ ); +}); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index d4f38adcacd..16c99abad8a 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -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 { @@ -2091,7 +2098,7 @@ function hydratePersistedComposerImageAttachment( } } -function hydrateImagesFromPersisted( +export function hydrateImagesFromPersisted( attachments: ReadonlyArray, ): ComposerImageAttachment[] { return attachments.flatMap((attachment) => { @@ -3347,6 +3354,35 @@ const composerDraftStore = create()( 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 }; + }); + }, }; }, { diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 29dd99b8e6d..19c05845535 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -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); diff --git a/apps/web/src/lib/stashImageCompression.test.ts b/apps/web/src/lib/stashImageCompression.test.ts new file mode 100644 index 00000000000..aaffa284cbb --- /dev/null +++ b/apps/web/src/lib/stashImageCompression.test.ts @@ -0,0 +1,198 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { compressImageForStash, MAX_STASH_IMAGE_DATA_URL_CHARS } from "./stashImageCompression"; + +/** + * jsdom has no real canvas/codec, so the re-encode path is exercised with + * stubbed `createImageBitmap` + `OffscreenCanvas`. The encoder stub returns a + * payload whose size scales with quality, mirroring how a real JPEG encoder + * shrinks as quality drops — enough to verify the ladder logic and budget + * enforcement without pulling in a native canvas. + */ + +const originalCreateImageBitmap = globalThis.createImageBitmap; +const originalOffscreenCanvas = globalThis.OffscreenCanvas; + +function makeFile(sizeBytes: number, type = "image/png"): File { + return new File([new Uint8Array(sizeBytes).fill(7)], "shot.png", { type }); +} + +/** + * Installs a fake bitmap + canvas whose encoded size follows `sizeForQuality`. + * `supportsWebp: false` makes `convertToBlob` hand back a differently-typed + * blob for WebP requests, which is how a real browser signals it cannot + * encode that format. + */ +function stubCanvasPipeline( + sizeForQuality: (quality: number) => number, + options?: { supportsWebp?: boolean }, +) { + const supportsWebp = options?.supportsWebp ?? true; + const close = vi.fn(); + const fillRect = vi.fn(); + vi.stubGlobal( + "createImageBitmap", + vi.fn(async () => ({ width: 4000, height: 3000, close })), + ); + vi.stubGlobal( + "OffscreenCanvas", + class { + constructor( + public width: number, + public height: number, + ) {} + getContext() { + return { + fillStyle: "", + fillRect, + drawImage: vi.fn(), + }; + } + async convertToBlob({ type, quality }: { type: string; quality: number }) { + const resolvedType = type === "image/webp" && !supportsWebp ? "image/png" : type; + return new Blob([new Uint8Array(sizeForQuality(quality))], { type: resolvedType }); + } + }, + ); + return { close, fillRect }; +} + +afterEach(() => { + vi.unstubAllGlobals(); + globalThis.createImageBitmap = originalCreateImageBitmap; + globalThis.OffscreenCanvas = originalOffscreenCanvas; +}); + +describe("compressImageForStash", () => { + it("stores a small image verbatim without re-encoding", async () => { + const bitmapSpy = vi.fn(); + vi.stubGlobal("createImageBitmap", bitmapSpy); + + const result = await compressImageForStash(makeFile(1024)); + + expect(result.ok).toBe(true); + expect(result.ok && result.image.recompressed).toBe(false); + expect(result.ok && result.image.mimeType).toBe("image/png"); + expect(result.ok && result.image.dataUrl.startsWith("data:image/png")).toBe(true); + // Untouched payloads must not pay for a decode. + expect(bitmapSpy).not.toHaveBeenCalled(); + }); + + it("re-encodes an oversized image to WebP within the budget", async () => { + // Comfortably under budget at the very first quality step. + const { close, fillRect } = stubCanvasPipeline(() => 120_000); + + const result = await compressImageForStash(makeFile(4_000_000)); + + expect(result.ok).toBe(true); + expect(result.ok && result.image.recompressed).toBe(true); + expect(result.ok && result.image.mimeType).toBe("image/webp"); + expect(result.ok && result.image.dataUrl.length <= MAX_STASH_IMAGE_DATA_URL_CHARS).toBe(true); + // sizeBytes should describe the re-encoded payload, not the 4MB original. + expect(result.ok && result.image.sizeBytes).toBeLessThan(4_000_000); + // WebP keeps alpha, so no white matte should be painted. + expect(fillRect).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalled(); + }); + + it("falls back to JPEG with a white matte when WebP encoding is unavailable", async () => { + const { fillRect } = stubCanvasPipeline(() => 120_000, { supportsWebp: false }); + + const result = await compressImageForStash(makeFile(4_000_000)); + + expect(result.ok && result.image.recompressed).toBe(true); + expect(result.ok && result.image.mimeType).toBe("image/jpeg"); + // JPEG has no alpha, so transparent regions must be matted white. + expect(fillRect).toHaveBeenCalled(); + }); + + it("steps quality down until the encoded image fits", async () => { + // Only the lowest quality step (0.68) lands under the budget. + const { close } = stubCanvasPipeline((quality) => (quality <= 0.68 ? 400_000 : 3_000_000)); + + const result = await compressImageForStash(makeFile(9_000_000)); + + expect(result.ok && result.image.recompressed).toBe(true); + expect(result.ok && result.image.dataUrl.length <= MAX_STASH_IMAGE_DATA_URL_CHARS).toBe(true); + expect(close).toHaveBeenCalled(); + }); + + it("reports too-large when even the smallest encoding overflows the budget", async () => { + const { close } = stubCanvasPipeline(() => 8_000_000); + + const result = await compressImageForStash(makeFile(9_000_000)); + + expect(result).toEqual({ ok: false, reason: "too-large" }); + // The bitmap must still be released on the give-up path. + expect(close).toHaveBeenCalled(); + }); + + it("reports too-large for an oversized image when the browser cannot re-encode", async () => { + vi.stubGlobal("createImageBitmap", undefined); + vi.stubGlobal("OffscreenCanvas", undefined); + + expect(await compressImageForStash(makeFile(4_000_000))).toEqual({ + ok: false, + reason: "too-large", + }); + }); + + it("reports unreadable when the image fails to decode", async () => { + vi.stubGlobal( + "createImageBitmap", + vi.fn(async () => { + throw new Error("corrupt image"); + }), + ); + vi.stubGlobal( + "OffscreenCanvas", + class { + getContext() { + return null; + } + }, + ); + + expect(await compressImageForStash(makeFile(4_000_000))).toEqual({ + ok: false, + reason: "unreadable", + }); + }); + + it("shrinks below the source size when the image is already under MAX_DIMENSION", async () => { + // A small-but-heavy source (e.g. a dense PNG): only a real downscale can + // get it under budget, since quality alone is stubbed to never suffice. + let smallestRequested = Number.POSITIVE_INFINITY; + const close = vi.fn(); + vi.stubGlobal( + "createImageBitmap", + vi.fn(async () => ({ width: 800, height: 600, close })), + ); + vi.stubGlobal( + "OffscreenCanvas", + class { + constructor( + public width: number, + public height: number, + ) { + smallestRequested = Math.min(smallestRequested, width); + } + getContext() { + return { fillStyle: "", fillRect: vi.fn(), drawImage: vi.fn() }; + } + async convertToBlob({ type }: { type: string; quality: number }) { + // Only a genuinely downscaled pass fits the budget. + const size = smallestRequested < 800 ? 100_000 : 5_000_000; + return new Blob([new Uint8Array(size)], { type }); + } + }, + ); + + const result = await compressImageForStash(makeFile(4_000_000)); + + expect(result.ok).toBe(true); + // Fallback passes must scale off the bitmap, not a fixed 2048 ceiling + // that would never go below an 800px source. + expect(smallestRequested).toBeLessThan(800); + }); +}); diff --git a/apps/web/src/lib/stashImageCompression.ts b/apps/web/src/lib/stashImageCompression.ts new file mode 100644 index 00000000000..7f133fb2926 --- /dev/null +++ b/apps/web/src/lib/stashImageCompression.ts @@ -0,0 +1,250 @@ +/** + * Re-encoding for stashed image attachments. + * + * The composer accepts images up to `PROVIDER_SEND_TURN_MAX_IMAGE_BYTES` + * (10MB), but the stash persists them as base64 in localStorage, where the + * whole origin shares a ~5MB quota. Rather than refuse large screenshots, + * downscale + re-encode them to JPEG so a stashed prompt keeps its images. + * + * Only the *stashed copy* is compressed; the live composer attachment is + * untouched, so sending without stashing still uploads the original file. + */ + +/** + * Longest edge kept when an image has to be re-encoded. Sized so a typical + * retina screenshot (3024px wide) stays legible rather than being halved. + */ +const MAX_DIMENSION = 2048; +/** Base64 budget for a single stashed image (~975KB of binary). */ +export const MAX_STASH_IMAGE_DATA_URL_CHARS = 1_300_000; +/** + * Quality ladder tried in order until the encoded image fits the budget. + * The floor stays high enough to avoid visible blocking on UI screenshots; + * if even that overflows we drop resolution instead of quality. + */ +const QUALITY_STEPS = [0.92, 0.85, 0.78, 0.68] as const; +/** Extra downscale passes applied when even the lowest quality overflows. */ +const FALLBACK_SCALE_STEPS = [0.75, 0.55] as const; + +export interface CompressedStashImage { + dataUrl: string; + mimeType: string; + sizeBytes: number; + /** True when the payload was re-encoded rather than stored verbatim. */ + recompressed: boolean; +} + +/** + * Why an image could not be stashed. Callers report these differently: + * "too large" is a budget outcome, "unreadable" is a decode failure. + */ +export type StashImageFailureReason = "too-large" | "unreadable"; + +export type CompressStashImageResult = + | { ok: true; image: CompressedStashImage } + | { ok: false; reason: StashImageFailureReason }; + +/** Chunked so a large image can't blow the argument limit of `fromCharCode`. */ +const BASE64_CHUNK_SIZE = 0x8000; + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK_SIZE) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + BASE64_CHUNK_SIZE)); + } + return btoa(binary); +} + +/** + * Blob → base64 data URL. Uses `arrayBuffer()` rather than `FileReader` so + * the module works anywhere `Blob` does (including non-DOM test runners). + */ +async function blobToDataUrl(blob: File | Blob, mimeTypeOverride?: string): Promise { + const buffer = await blob.arrayBuffer(); + const mimeType = mimeTypeOverride || blob.type || "application/octet-stream"; + return `data:${mimeType};base64,${bytesToBase64(new Uint8Array(buffer))}`; +} + +/** Approximate decoded byte count for a base64 data URL. */ +function dataUrlByteLength(dataUrl: string): number { + const commaIndex = dataUrl.indexOf(","); + const payload = commaIndex === -1 ? dataUrl : dataUrl.slice(commaIndex + 1); + const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0; + return Math.max(0, Math.floor((payload.length * 3) / 4) - padding); +} + +function canRecompress(): boolean { + return ( + typeof createImageBitmap === "function" && + (typeof OffscreenCanvas === "function" || typeof document !== "undefined") + ); +} + +interface Canvas2D { + canvas: OffscreenCanvas | HTMLCanvasElement; + context: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D; +} + +function createCanvas(width: number, height: number): Canvas2D | null { + if (typeof OffscreenCanvas === "function") { + const canvas = new OffscreenCanvas(width, height); + const context = canvas.getContext("2d"); + if (!context) return null; + return { canvas, context }; + } + if (typeof document === "undefined") return null; + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + if (!context) return null; + return { canvas, context }; +} + +/** + * WebP is preferred: at matched visual quality it lands roughly 25-35% + * smaller than JPEG, so the same budget buys more resolution and detail — + * and it keeps alpha, so screenshots with transparency survive intact. + * Browsers that can't encode it silently fall back to JPEG. + */ +async function encodeToDataUrl( + canvas: OffscreenCanvas | HTMLCanvasElement, + quality: number, + mimeType: string, +): Promise<{ dataUrl: string; mimeType: string } | null> { + if (typeof HTMLCanvasElement !== "undefined" && canvas instanceof HTMLCanvasElement) { + const dataUrl = canvas.toDataURL(mimeType, quality); + // toDataURL silently returns a PNG when the requested type is unsupported. + if (!dataUrl.startsWith(`data:${mimeType}`)) return null; + return { dataUrl, mimeType }; + } + const blob = await (canvas as OffscreenCanvas).convertToBlob({ type: mimeType, quality }); + if (blob.type && blob.type !== mimeType) return null; + return { dataUrl: await blobToDataUrl(blob, mimeType), mimeType }; +} + +/** + * Draws `bitmap` scaled to fit `maxDimension` and encodes it as JPEG, + * stepping quality down until the data URL fits `budgetChars`. Returns the + * smallest encoding produced, even if it still exceeds the budget, so the + * caller can decide whether to keep or drop it. + */ +async function encodeWithinBudget( + bitmap: ImageBitmap, + maxDimension: number, + budgetChars: number, +): Promise<{ dataUrl: string; mimeType: string } | null> { + const scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height)); + const width = Math.max(1, Math.round(bitmap.width * scale)); + const height = Math.max(1, Math.round(bitmap.height * scale)); + const target = createCanvas(width, height); + if (!target) return null; + + // Probe WebP once; JPEG (no alpha) needs a white matte, so the fill has to + // happen before drawing and depends on which codec we end up using. + const probe = await encodeToDataUrl(target.canvas, QUALITY_STEPS[0], "image/webp"); + const mimeType = probe ? "image/webp" : "image/jpeg"; + + if (mimeType === "image/jpeg") { + target.context.fillStyle = "#ffffff"; + target.context.fillRect(0, 0, width, height); + } + target.context.drawImage(bitmap, 0, 0, width, height); + + let smallest: { dataUrl: string; mimeType: string } | null = null; + for (const quality of QUALITY_STEPS) { + const encoded = await encodeToDataUrl(target.canvas, quality, mimeType); + if (!encoded) break; + if (smallest === null || encoded.dataUrl.length < smallest.dataUrl.length) { + smallest = encoded; + } + if (encoded.dataUrl.length <= budgetChars) { + return encoded; + } + } + return smallest; +} + +/** + * Produces the payload to persist for a stashed image. + * + * Small images are stored verbatim (preserving PNG transparency and exact + * pixels). Anything over budget is downscaled and JPEG-encoded; if it still + * doesn't fit after the fallback passes, returns `null` so the caller can + * record it as dropped. + */ +export async function compressImageForStash( + file: File, + budgetChars: number = MAX_STASH_IMAGE_DATA_URL_CHARS, +): Promise { + let originalDataUrl: string; + try { + originalDataUrl = await blobToDataUrl(file); + } catch { + return { ok: false, reason: "unreadable" }; + } + if (originalDataUrl.length <= budgetChars) { + return { + ok: true, + image: { + dataUrl: originalDataUrl, + mimeType: file.type, + sizeBytes: file.size, + recompressed: false, + }, + }; + } + if (!canRecompress()) { + return { ok: false, reason: "too-large" }; + } + + let bitmap: ImageBitmap; + try { + bitmap = await createImageBitmap(file); + } catch { + return { ok: false, reason: "unreadable" }; + } + + try { + // Each pass shrinks relative to the *previous target*, capped by + // MAX_DIMENSION. Scaling a fixed ceiling instead would be a no-op for + // images already smaller than that ceiling — the fallback passes would + // all resolve to the source size and never actually reduce resolution. + const baseDimension = Math.min(MAX_DIMENSION, Math.max(bitmap.width, bitmap.height)); + // Tracks whether the *last* attempt threw, so a run of encoder failures + // is reported as unreadable while a run of merely-too-big results is + // reported as too-large. + let encodeFailed = false; + for (const dimensionScale of [1, ...FALLBACK_SCALE_STEPS]) { + const targetDimension = Math.max(1, Math.round(baseDimension * dimensionScale)); + let encoded: { dataUrl: string; mimeType: string } | null; + try { + encoded = await encodeWithinBudget(bitmap, targetDimension, budgetChars); + } catch { + // Canvas allocation, drawing, or the codec itself can throw — often + // precisely *because* the target is too big (OOM on a large bitmap). + // Keep trying the smaller fallback scales rather than giving up: a + // reduced pass may well succeed. The exception must never escape, + // though, since the caller finalizes the entry after this returns and + // a throw would strand it as permanently "still saving". + encodeFailed = true; + continue; + } + encodeFailed = false; + if (encoded && encoded.dataUrl.length <= budgetChars) { + return { + ok: true, + image: { + dataUrl: encoded.dataUrl, + mimeType: encoded.mimeType, + sizeBytes: dataUrlByteLength(encoded.dataUrl), + recompressed: true, + }, + }; + } + } + return { ok: false, reason: encodeFailed ? "unreadable" : "too-large" }; + } finally { + bitmap.close(); + } +} diff --git a/apps/web/src/promptStashStore.test.ts b/apps/web/src/promptStashStore.test.ts new file mode 100644 index 00000000000..228e1e35714 --- /dev/null +++ b/apps/web/src/promptStashStore.test.ts @@ -0,0 +1,259 @@ +import { ProviderInstanceId } from "@t3tools/contracts"; +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; + +import { removeLocalStorageItem } from "./hooks/useLocalStorage"; + +import { + MAX_STASH_ENTRIES_PER_QUEUE, + PROMPT_STASH_STORAGE_KEY, + MAX_STASH_ENTRY_ATTACHMENT_CHARS, + PROMPT_STASH_UNSCOPED_KEY, + partitionStashAttachments, + promptStashScopeKey, + usePromptStashStore, + writePromptStashStorageForTest, + type PromptStashEntry, +} from "./promptStashStore"; + +const CLAUDE_AGENT_INSTANCE = ProviderInstanceId.make("claudeAgent"); +const CODEX_INSTANCE = ProviderInstanceId.make("codex"); + +function makeEntry(input: { + id: string; + providerInstanceId?: ProviderInstanceId | null; + prompt?: string; + attachmentChars?: number; +}): PromptStashEntry { + return { + id: input.id, + createdAt: "2026-07-24T12:00:00.000Z", + prompt: input.prompt ?? `prompt ${input.id}`, + attachments: + input.attachmentChars !== undefined + ? [ + { + id: `${input.id}-img`, + name: "shot.png", + mimeType: "image/png", + sizeBytes: input.attachmentChars, + dataUrl: "x".repeat(input.attachmentChars), + }, + ] + : [], + providerInstanceId: + input.providerInstanceId === undefined ? CLAUDE_AGENT_INSTANCE : input.providerInstanceId, + modelSelection: null, + droppedImageNames: [], + }; +} + +function resetPromptStashStore() { + usePromptStashStore.setState({ queuesByScopeKey: {} }); + writePromptStashStorageForTest(""); + removeLocalStorageItem(PROMPT_STASH_STORAGE_KEY); +} + +describe("promptStashScopeKey", () => { + it("maps a provider instance to its own bucket and null to the unscoped bucket", () => { + expect(promptStashScopeKey(CLAUDE_AGENT_INSTANCE)).toBe("provider:claudeAgent"); + expect(promptStashScopeKey(null)).toBe(PROMPT_STASH_UNSCOPED_KEY); + expect(promptStashScopeKey(undefined)).toBe(PROMPT_STASH_UNSCOPED_KEY); + }); + + // Provider slugs must match /^[a-zA-Z][a-zA-Z0-9_-]*$/, so no real instance + // id can equal the unscoped sentinel. The namespace prefix makes that + // structural rather than incidental. + it("namespaces provider keys so they can never equal the unscoped sentinel", () => { + expect(promptStashScopeKey(CLAUDE_AGENT_INSTANCE)).not.toBe(PROMPT_STASH_UNSCOPED_KEY); + expect(promptStashScopeKey(CODEX_INSTANCE).startsWith("provider:")).toBe(true); + }); +}); + +describe("partitionStashAttachments", () => { + it("keeps attachments within the budget and reports dropped names in order", () => { + const small = { + id: "a", + name: "small.png", + mimeType: "image/png", + sizeBytes: 10, + dataUrl: "x".repeat(10), + }; + const huge = { + id: "b", + name: "huge.png", + mimeType: "image/png", + sizeBytes: MAX_STASH_ENTRY_ATTACHMENT_CHARS, + dataUrl: "x".repeat(MAX_STASH_ENTRY_ATTACHMENT_CHARS), + }; + const alsoSmall = { + id: "c", + name: "also-small.png", + mimeType: "image/png", + sizeBytes: 10, + dataUrl: "x".repeat(10), + }; + const { kept, droppedNames } = partitionStashAttachments([small, huge, alsoSmall]); + expect(kept.map((attachment) => attachment.id)).toEqual(["a", "c"]); + expect(droppedNames).toEqual(["huge.png"]); + }); + + it("admits a single attachment that exactly fits the budget", () => { + const exact = { + id: "a", + name: "exact.png", + mimeType: "image/png", + sizeBytes: MAX_STASH_ENTRY_ATTACHMENT_CHARS, + dataUrl: "x".repeat(MAX_STASH_ENTRY_ATTACHMENT_CHARS), + }; + const { kept, droppedNames } = partitionStashAttachments([exact]); + expect(kept).toHaveLength(1); + expect(droppedNames).toEqual([]); + }); +}); + +describe("promptStashStore", () => { + beforeEach(() => { + resetPromptStashStore(); + }); + + afterEach(() => { + resetPromptStashStore(); + }); + + it("prepends entries so the newest stash is first", () => { + const store = usePromptStashStore.getState(); + store.stashEntry(makeEntry({ id: "first" })); + store.stashEntry(makeEntry({ id: "second" })); + const queue = + usePromptStashStore.getState().queuesByScopeKey[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)] ?? + []; + expect(queue.map((entry) => entry.id)).toEqual(["second", "first"]); + }); + + it("scopes queues by provider instance, including the unscoped bucket", () => { + const store = usePromptStashStore.getState(); + store.stashEntry(makeEntry({ id: "claude" })); + store.stashEntry(makeEntry({ id: "codex", providerInstanceId: CODEX_INSTANCE })); + store.stashEntry(makeEntry({ id: "none", providerInstanceId: null })); + const queues = usePromptStashStore.getState().queuesByScopeKey; + expect(queues[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)]?.map((entry) => entry.id)).toEqual([ + "claude", + ]); + expect(queues[promptStashScopeKey(CODEX_INSTANCE)]?.map((entry) => entry.id)).toEqual([ + "codex", + ]); + expect(queues[PROMPT_STASH_UNSCOPED_KEY]?.map((entry) => entry.id)).toEqual(["none"]); + }); + + it("evicts the oldest entry past the per-queue cap and returns it", () => { + const store = usePromptStashStore.getState(); + for (let index = 0; index < MAX_STASH_ENTRIES_PER_QUEUE; index += 1) { + expect(store.stashEntry(makeEntry({ id: `entry-${index}` })).evicted).toBeNull(); + } + const { evicted } = store.stashEntry(makeEntry({ id: "overflow" })); + expect(evicted?.id).toBe("entry-0"); + const queue = + usePromptStashStore.getState().queuesByScopeKey[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)] ?? + []; + expect(queue).toHaveLength(MAX_STASH_ENTRIES_PER_QUEUE); + expect(queue[0]?.id).toBe("overflow"); + }); + + it("takeEntry removes and returns the entry; second take returns null", () => { + const store = usePromptStashStore.getState(); + store.stashEntry(makeEntry({ id: "keep" })); + store.stashEntry(makeEntry({ id: "take" })); + expect(store.takeEntry(promptStashScopeKey(CLAUDE_AGENT_INSTANCE), "take").entry?.id).toBe( + "take", + ); + expect(store.takeEntry(promptStashScopeKey(CLAUDE_AGENT_INSTANCE), "take").entry).toBeNull(); + const queue = + usePromptStashStore.getState().queuesByScopeKey[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)] ?? + []; + expect(queue.map((entry) => entry.id)).toEqual(["keep"]); + }); + + // Queue keys are persisted as plain strings, so a hand-edited or corrupted + // localStorage payload can carry a literal `__proto__` key that survives + // JSON.parse as an own property. An unguarded lookup would resolve to + // Object.prototype and throw "not iterable" on spread. + it("tolerates a __proto__ scope key rehydrated from storage", () => { + usePromptStashStore.setState({ + queuesByScopeKey: JSON.parse('{"__proto__":[]}') as Record, + }); + const store = usePromptStashStore.getState(); + expect(() => store.takeEntry("__proto__", "missing")).not.toThrow(); + expect(store.takeEntry("__proto__", "missing").entry).toBeNull(); + }); + + it("finalizeEntryImages attaches images and clears the pending count", () => { + const store = usePromptStashStore.getState(); + const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE); + store.stashEntry({ ...makeEntry({ id: "pending" }), pendingImageCount: 2 }); + + const { attached } = store.finalizeEntryImages(scopeKey, "pending", { + attachments: [ + { + id: "img-1", + name: "a.webp", + mimeType: "image/webp", + sizeBytes: 10, + dataUrl: "data:image/webp;base64,AAAA", + }, + ], + droppedImageNames: ["big.png"], + unreadableImageNames: [], + }); + + expect(attached).toBe(true); + const entry = usePromptStashStore.getState().queuesByScopeKey[scopeKey]?.[0]; + expect(entry?.attachments).toHaveLength(1); + expect(entry?.droppedImageNames).toEqual(["big.png"]); + expect(entry?.pendingImageCount).toBe(0); + }); + + it("finalizeEntryImages reports false when the entry was already taken", () => { + const store = usePromptStashStore.getState(); + const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE); + store.stashEntry({ ...makeEntry({ id: "racing" }), pendingImageCount: 1 }); + // Restored (or deleted) while its images were still encoding. + store.takeEntry(scopeKey, "racing"); + + const { attached } = store.finalizeEntryImages(scopeKey, "racing", { + attachments: [], + droppedImageNames: [], + unreadableImageNames: [], + }); + + expect(attached).toBe(false); + }); + + it("settles a pending count left behind by a crashed or closed session", () => { + const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE); + writePromptStashStorageForTest( + JSON.stringify({ + version: 1, + state: { + queuesByScopeKey: { + [scopeKey]: [{ ...makeEntry({ id: "orphan" }), pendingImageCount: 2 }], + }, + }, + }), + ); + + // Hydration must settle the stale count, or the entry would stay stuck + // showing "saving…" with images that no longer exist anywhere. + const entry = usePromptStashStore.getState().queuesByScopeKey[scopeKey]?.[0]; + expect(entry?.pendingImageCount).toBe(0); + expect(entry?.unreadableImageNames).toHaveLength(2); + }); + + it("drops the scope key entirely when its queue empties", () => { + const store = usePromptStashStore.getState(); + store.stashEntry(makeEntry({ id: "only" })); + store.takeEntry(promptStashScopeKey(CLAUDE_AGENT_INSTANCE), "only"); + expect( + usePromptStashStore.getState().queuesByScopeKey[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)], + ).toBeUndefined(); + }); +}); diff --git a/apps/web/src/promptStashStore.ts b/apps/web/src/promptStashStore.ts new file mode 100644 index 00000000000..e4340935b77 --- /dev/null +++ b/apps/web/src/promptStashStore.ts @@ -0,0 +1,322 @@ +import { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { create } from "zustand"; + +import { PersistedComposerImageAttachment } from "./composerDraftStore"; +import { createMemoryStorage, type StateStorage } from "./lib/storage"; + +export const PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v1"; +const PROMPT_STASH_STORAGE_VERSION = 1; + +/** + * Queue bucket for prompts stashed while no provider instance is selected. + * + * Provider-scoped keys are prefixed (see `promptStashScopeKey`), so this + * sentinel can never collide with a provider literally named `__none__`. + */ +export const PROMPT_STASH_UNSCOPED_KEY = "__none__"; +/** Namespace applied to provider-derived keys to keep them collision-proof. */ +const PROVIDER_SCOPE_PREFIX = "provider:"; + +export const MAX_STASH_ENTRIES_PER_QUEUE = 20; +/** + * Budget for an entry's serialized attachment payload. localStorage is a + * ~5MB origin-wide quota shared with the composer draft store, so oversized + * images are dropped (tracked in `droppedImageNames`) rather than persisted. + * + * Sized to hold two images at the per-image compression budget + * (`MAX_STASH_IMAGE_DATA_URL_CHARS`) so a typical before/after screenshot + * pair survives intact. + */ +export const MAX_STASH_ENTRY_ATTACHMENT_CHARS = 2_700_000; + +const StashEntrySchema = Schema.Struct({ + id: Schema.String, + createdAt: Schema.String, + prompt: Schema.String, + attachments: Schema.Array(PersistedComposerImageAttachment), + providerInstanceId: Schema.NullOr(ProviderInstanceId), + modelSelection: Schema.NullOr(ModelSelection), + /** Names of images that exceeded the attachment budget and were not saved. */ + droppedImageNames: Schema.Array(Schema.String), + /** + * Names of images that could not be decoded or re-encoded at all — a + * distinct failure from exceeding the size budget, so the menu can explain + * which actually happened. Optional: entries written before this field + * existed decode without it. + */ + unreadableImageNames: Schema.optionalKey(Schema.Array(Schema.String)), + /** + * Images still being encoded when the entry was written. The entry is + * persisted before its images so a crash mid-encode cannot lose the prompt; + * this field lets the UI show "N images still saving" until + * `finalizeEntryImages` lands, and flags entries orphaned by a reload. + */ + pendingImageCount: Schema.optionalKey(Schema.Number), +}); +export type PromptStashEntry = typeof StashEntrySchema.Type; + +const PersistedPromptStashState = Schema.Struct({ + queuesByScopeKey: Schema.Record(Schema.String, Schema.Array(StashEntrySchema)), +}); +type PersistedPromptStashState = typeof PersistedPromptStashState.Type; + +const decodePersistedPromptStashState = Schema.decodeUnknownSync(PersistedPromptStashState); + +/** + * `pendingImageCount` only has meaning within the session that wrote it: the + * encode loop that would clear it does not survive a reload. Any entry that + * comes back from storage still pending was orphaned by a closed tab or a + * crash mid-encode, so the count is settled here — otherwise the entry would + * be stuck showing "saving…" and refuse to restore forever. + * + * The images are genuinely gone (they were never written), so they are + * recorded as unreadable to keep the prompt itself restorable. + */ +function clearOrphanedPendingImages( + queues: Record>, +): Record> { + const next: Record> = {}; + for (const [scopeKey, queue] of Object.entries(queues)) { + next[scopeKey] = queue.map((entry) => { + if (!entry.pendingImageCount) return entry; + const lostCount = entry.pendingImageCount; + return { + ...entry, + pendingImageCount: 0, + unreadableImageNames: [ + ...(entry.unreadableImageNames ?? []), + ...Array.from( + { length: lostCount }, + (_, index) => `image ${index + 1} (not saved before reload)`, + ), + ], + }; + }); + } + return next; +} + +/** Maps the composer's active provider instance to a stash queue bucket. */ +export function promptStashScopeKey(instanceId: ProviderInstanceId | null | undefined): string { + return instanceId ? `${PROVIDER_SCOPE_PREFIX}${instanceId}` : PROMPT_STASH_UNSCOPED_KEY; +} + +/** + * Reads a queue without inheriting from `Object.prototype`. Scope keys derive + * from user-authored provider slugs, so a key like `__proto__` must not + * resolve to the prototype chain. + */ +function readQueue( + queues: Record>, + scopeKey: string, +): ReadonlyArray { + return Object.hasOwn(queues, scopeKey) ? (queues[scopeKey] ?? []) : []; +} + +/** + * Splits candidate attachments into a persistable set within the entry + * budget plus the names of any that had to be dropped. Attachments are + * admitted in order so the earliest-added images win. + */ +export function partitionStashAttachments( + attachments: ReadonlyArray, +): { + kept: PersistedComposerImageAttachment[]; + droppedNames: string[]; +} { + const kept: PersistedComposerImageAttachment[] = []; + const droppedNames: string[] = []; + let usedChars = 0; + for (const attachment of attachments) { + if (usedChars + attachment.dataUrl.length > MAX_STASH_ENTRY_ATTACHMENT_CHARS) { + droppedNames.push(attachment.name); + continue; + } + usedChars += attachment.dataUrl.length; + kept.push(attachment); + } + return { kept, droppedNames }; +} + +/** + * Reading the `localStorage` property itself can throw `SecurityError` when + * storage is blocked by policy or the page is a sandboxed iframe — so the + * access has to be guarded, not just the get/set calls on it. Otherwise + * importing this module would crash the app at load. + * + * `durable` is false for the in-memory fallback: writes there "succeed" but + * vanish on reload, and callers clear the composer on the strength of a + * successful stash, so they must be told the difference. + */ +function resolveBaseStorage(): { storage: StateStorage; durable: boolean } { + try { + if (typeof localStorage !== "undefined") { + return { storage: localStorage, durable: true }; + } + } catch { + // Fall through to the in-memory store. + } + return { storage: createMemoryStorage(), durable: false }; +} + +const { storage: baseStashStorage, durable: storageIsDurable } = resolveBaseStorage(); + +/** + * Persists the queues, immediately rather than debounced. Stashing is a + * deliberate, infrequent keystroke — not a per-character autosave — so there + * is nothing to coalesce, and the caller clears the composer on the strength + * of this write landing, which a debounce timer cannot honestly report. + * + * Returns whether the write will survive a reload: false on a quota rejection + * or when only the in-memory fallback is available. + */ +function persistQueues(queues: Record>): { + /** The write succeeded (possibly only into the in-memory fallback). */ + written: boolean; + /** The write will survive a reload. */ + durable: boolean; +} { + try { + baseStashStorage.setItem( + PROMPT_STASH_STORAGE_KEY, + JSON.stringify({ + version: PROMPT_STASH_STORAGE_VERSION, + state: { queuesByScopeKey: queues }, + }), + ); + return { written: true, durable: storageIsDurable }; + } catch (error) { + console.error("[PROMPT-STASH] Could not persist stash (storage quota?).", error); + return { written: false, durable: false }; + } +} + +/** Reads the persisted queues, settling stale pending counts. */ +function readPersistedQueues(): Record> | null { + try { + const raw = baseStashStorage.getItem(PROMPT_STASH_STORAGE_KEY); + if (typeof raw !== "string" || raw.length === 0) return null; + const parsed: unknown = JSON.parse(raw); + const state = (parsed as { state?: unknown } | null)?.state; + if (!state) return null; + return clearOrphanedPendingImages(decodePersistedPromptStashState(state).queuesByScopeKey); + } catch { + return null; + } +} + +interface PromptStashStoreState { + queuesByScopeKey: Record>; + /** + * Prepends an entry to its scope's queue, evicting the oldest entry past + * the per-queue cap. Returns the evicted entry (for messaging) if any. + */ + stashEntry: (entry: PromptStashEntry) => { + evicted: PromptStashEntry | null; + /** False when the write did not reach durable storage; nothing was kept. */ + durable: boolean; + }; + /** + * Removes and returns an entry from a scope's queue (restore + delete). + * `durable` is false when the removal could not be persisted, meaning a + * reload would resurrect the entry. + */ + takeEntry: ( + scopeKey: string, + entryId: string, + ) => { entry: PromptStashEntry | null; durable: boolean }; + /** + * Attaches the encoded images to an entry written earlier by `stashEntry`, + * clearing its pending count. Returns attached=false when the entry is gone + * (restored or deleted while encoding was still running) so the caller can + * tell the user their images did not make it. + */ + finalizeEntryImages: ( + scopeKey: string, + entryId: string, + images: { + attachments: ReadonlyArray; + droppedImageNames: ReadonlyArray; + unreadableImageNames: ReadonlyArray; + }, + ) => { attached: boolean; durable: boolean }; +} + +export const usePromptStashStore = create()((set, get) => ({ + queuesByScopeKey: {}, + stashEntry: (entry) => { + const scopeKey = promptStashScopeKey(entry.providerInstanceId); + const queues = get().queuesByScopeKey; + const nextQueue = [entry, ...readQueue(queues, scopeKey)]; + const evicted = + nextQueue.length > MAX_STASH_ENTRIES_PER_QUEUE ? (nextQueue.pop() ?? null) : null; + const next = { ...queues, [scopeKey]: nextQueue }; + const { written, durable } = persistQueues(next); + // A rejected write must not leave the entry visible either: the caller + // keeps the composer intact on failure, so a stashed copy would + // duplicate the prompt. Eviction likewise only sticks on success. + if (!written) { + return { evicted: null, durable: false }; + } + set(() => ({ queuesByScopeKey: next })); + return { evicted, durable }; + }, + takeEntry: (scopeKey, entryId) => { + const queues = get().queuesByScopeKey; + const queue = readQueue(queues, scopeKey); + const entry = queue.find((candidate) => candidate.id === entryId) ?? null; + if (!entry) return { entry: null, durable: true }; + const nextQueue = queue.filter((candidate) => candidate.id !== entryId); + const next = { ...queues }; + if (nextQueue.length === 0) { + delete next[scopeKey]; + } else { + next[scopeKey] = nextQueue; + } + const { durable } = persistQueues(next); + set(() => ({ queuesByScopeKey: next })); + return { entry, durable }; + }, + finalizeEntryImages: (scopeKey, entryId, images) => { + const queues = get().queuesByScopeKey; + const queue = readQueue(queues, scopeKey); + const index = queue.findIndex((candidate) => candidate.id === entryId); + const existing = index === -1 ? undefined : queue[index]; + // Restored or deleted mid-encode: nothing to attach to. + if (!existing) return { attached: false, durable: true }; + const nextQueue = [...queue]; + nextQueue[index] = { + ...existing, + attachments: images.attachments, + droppedImageNames: images.droppedImageNames, + unreadableImageNames: images.unreadableImageNames, + pendingImageCount: 0, + }; + const next = { ...queues, [scopeKey]: nextQueue }; + const { durable } = persistQueues(next); + set(() => ({ queuesByScopeKey: next })); + return { attached: true, durable }; + }, +})); + +// Hydrate once at startup. Like the app's other persisted stores, tabs are +// last-write-wins: no cross-tab merging or storage-event syncing. +{ + const persisted = readPersistedQueues(); + if (persisted) { + usePromptStashStore.setState({ queuesByScopeKey: persisted }); + } +} + +export const EMPTY_PROMPT_STASH_QUEUE: ReadonlyArray = []; + +/** + * Test seam: seeds the persisted payload through the same storage the store + * reads and rehydrates, without needing a real `localStorage` global. + * Pass an empty string to clear. + */ +export function writePromptStashStorageForTest(raw: string): void { + baseStashStorage.setItem(PROMPT_STASH_STORAGE_KEY, raw); + usePromptStashStore.setState({ queuesByScopeKey: readPersistedQueues() ?? {} }); +} diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index c7cff9943cd..d966c1e7e61 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -63,6 +63,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "preview.zoomOut", "preview.resetZoom", "commandPalette.toggle", + "composer.stash", "chat.new", "chat.newLocal", "editor.openFavorite", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index b6bdd7b4783..0688cf07254 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -35,6 +35,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+-", command: "preview.zoomOut", when: "previewFocus" }, { key: "mod+0", command: "preview.resetZoom", when: "previewFocus" }, { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, + { key: "mod+s", command: "composer.stash", when: "!terminalFocus" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, From e6d3e4507ca446b859f81cccb9e235be89d99eea Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 27 Jul 2026 16:46:09 +0200 Subject: [PATCH 02/35] feat: allow new thread creation through project breadcrumbs (#4638) (cherry picked from commit bdf99c17bf7ae634728481677cf1bca8db65d00e) --- .../web/src/components/ChatView.logic.test.ts | 28 ++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 11 +++++++ apps/web/src/components/ChatView.tsx | 7 ++++ apps/web/src/components/chat/ChatHeader.tsx | 32 +++++++++++++------ 4 files changed, 68 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 57c12959ffb..1b2e537c35d 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -25,6 +25,7 @@ import { reconcileRetainedMountedThreadIds, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + startNewThreadForProject, shouldShowBranchMismatchBanner, shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; @@ -445,6 +446,33 @@ describe("shouldWriteThreadErrorToCurrentServerThread", () => { }); }); +describe("startNewThreadForProject", () => { + it("starts a thread through the supplied shared handler for the active project", () => { + const calls: Array<{ environmentId: EnvironmentId; projectId: ProjectId }> = []; + const projectRef = { environmentId, projectId }; + + expect( + startNewThreadForProject(projectRef, (nextProjectRef) => { + calls.push(nextProjectRef); + return Promise.resolve(); + }), + ).toBe(true); + expect(calls).toEqual([projectRef]); + }); + + it("does nothing when the active project is unavailable", () => { + let called = false; + + expect( + startNewThreadForProject(null, () => { + called = true; + return Promise.resolve(); + }), + ).toBe(false); + expect(called).toBe(false); + }); +}); + describe("hasServerAcknowledgedLocalDispatch", () => { it("does not acknowledge unchanged server state", () => { const localDispatch = createLocalDispatchSnapshot( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 466c9b24c87..4fb007b85b5 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -5,6 +5,7 @@ import { type ModelSelection, type ProviderDriverKind, type ServerProvider, + type ScopedProjectRef, type ScopedThreadRef, type ThreadId, type TurnId, @@ -27,6 +28,16 @@ export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); +export function startNewThreadForProject( + projectRef: ScopedProjectRef | null, + handleNewThread: (projectRef: ScopedProjectRef) => Promise, +): boolean { + if (projectRef === null) return false; + void handleNewThread(projectRef); + + return true; +} + export function resolveThreadMetadataUpdateForNextTurn(input: { currentModelSelection: ModelSelection; nextModelSelection?: ModelSelection; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 328a3fa9423..b5b175cb17f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -168,6 +168,7 @@ import { getProviderModelCapabilities, resolveSelectableProvider } from "../prov import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings"; import { useNowMinute } from "../hooks/useNowMinute"; +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; @@ -271,6 +272,7 @@ import { resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, + startNewThreadForProject, waitForStartedServerThread, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; @@ -1130,6 +1132,7 @@ function ChatViewContent(props: ChatViewProps) { forceExpandedMobileComposer = false, } = props; const draftId = routeKind === "draft" ? props.draftId : null; + const handleNewThread = useNewThreadHandler(); const routeThreadRef = useMemo( () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], @@ -1583,6 +1586,9 @@ function ChatViewContent(props: ChatViewProps) { ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) : null; const activeProject = useProject(activeProjectRef); + const handleNewThreadInActiveProject = useCallback(() => { + startNewThreadForProject(activeProjectRef, handleNewThread); + }, [activeProjectRef, handleNewThread]); const activeEnvironmentShell = useEnvironmentQuery( activeThread ? environmentShell.stateAtom(activeThread.environmentId) : null, ); @@ -5783,6 +5789,7 @@ function ChatViewContent(props: ChatViewProps) { availableEditors={availableEditors} rightPanelOpen={rightPanelOpen} gitCwd={gitCwd} + onNewThreadInProject={handleNewThreadInActiveProject} onRunProjectScript={runProjectScript} onAddProjectScript={saveProjectScript} onUpdateProjectScript={updateProjectScript} diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 3a7d57859a8..0adeed6ffa6 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -34,6 +34,7 @@ interface ChatHeaderProps { availableEditors: ReadonlyArray; rightPanelOpen: boolean; gitCwd: string | null; + onNewThreadInProject: () => void; onRunProjectScript: (script: ProjectScript) => void; onAddProjectScript: (input: NewProjectScriptInput) => Promise; onUpdateProjectScript: ( @@ -69,6 +70,7 @@ export const ChatHeader = memo(function ChatHeader({ availableEditors, rightPanelOpen, gitCwd, + onNewThreadInProject, onRunProjectScript, onAddProjectScript, onUpdateProjectScript, @@ -92,16 +94,26 @@ export const ChatHeader = memo(function ChatHeader({ doesn't answer it. */} {activeProjectName ? ( - - - - {activeProjectName} - - + + + } + > + + {activeProjectName} + + New thread in {activeProjectName} + / From 81d8fac4d275dbd52b8afb86f031fd80bc6c49fe Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 27 Jul 2026 17:00:19 +0200 Subject: [PATCH 03/35] fix(web): scope PR state to the thread branch (#4460) Co-authored-by: codex (cherry picked from commit 724887717f1b5ded5b60bd5abe7e41a9ce5b47e5) --- .../components/BranchToolbar.logic.test.ts | 30 +++++++++++++++++++ .../web/src/components/BranchToolbar.logic.ts | 7 +++++ .../BranchToolbarBranchSelector.tsx | 7 +++-- apps/web/src/components/ChatView.tsx | 1 - apps/web/src/components/Sidebar.tsx | 1 - apps/web/src/components/SidebarV2.tsx | 1 - .../components/ThreadStatusIndicators.test.ts | 24 ++++++++------- .../src/components/ThreadStatusIndicators.tsx | 8 +---- 8 files changed, 57 insertions(+), 22 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index c4ae7b96694..e59ffc8f68b 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -9,6 +9,7 @@ import { resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, resolveEnvModeLabel, + resolveBranchToolbarPrBranch, resolveBranchToolbarValue, resolveLockedWorkspaceLabel, resolveLocalCheckoutBranchMismatch, @@ -173,6 +174,35 @@ describe("resolveBranchToolbarValue", () => { }); }); +describe("resolveBranchToolbarPrBranch", () => { + it("uses the explicit thread branch when it matches the displayed branch", () => { + expect( + resolveBranchToolbarPrBranch({ + activeThreadBranch: "feature/current", + resolvedActiveBranch: "feature/current", + }), + ).toBe("feature/current"); + }); + + it("hides PR state while an optimistic branch switch is in flight", () => { + expect( + resolveBranchToolbarPrBranch({ + activeThreadBranch: "feature/current", + resolvedActiveBranch: "feature/next", + }), + ).toBeNull(); + }); + + it("does not infer PR state without an explicit thread branch", () => { + expect( + resolveBranchToolbarPrBranch({ + activeThreadBranch: null, + resolvedActiveBranch: "feature/current", + }), + ).toBeNull(); + }); +}); + describe("resolveLocalCheckoutBranchMismatch", () => { it("detects when a local thread is associated with a different branch than the checkout", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 8fe35fa464a..3549eff6eea 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -156,6 +156,13 @@ export function resolveBranchToolbarValue(input: { return currentGitBranch ?? activeThreadBranch; } +export function resolveBranchToolbarPrBranch(input: { + activeThreadBranch: string | null; + resolvedActiveBranch: string | null; +}): string | null { + return input.activeThreadBranch === input.resolvedActiveBranch ? input.activeThreadBranch : null; +} + export function resolveLocalCheckoutBranchMismatch(input: { effectiveEnvMode: EnvMode; activeWorktreePath: string | null; diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 7f61c8ebd25..de6f87c88fc 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -36,6 +36,7 @@ import { parsePullRequestReference } from "../pullRequestReference"; import { getSourceControlPresentation } from "../sourceControlPresentation"; import { deriveLocalBranchNameFromRemoteRef, + resolveBranchToolbarPrBranch, resolveBranchSelectionTarget, resolveBranchToolbarValue, resolveDraftEnvModeAfterBranchChange, @@ -595,9 +596,11 @@ export function BranchToolbarBranchSelector({ // PR pill shown next to the branch selector when the active branch has one. const branchPr = resolveThreadPr({ - threadBranch: resolvedActiveBranch, + threadBranch: resolveBranchToolbarPrBranch({ + activeThreadBranch, + resolvedActiveBranch, + }), gitStatus: branchStatusQuery.data ?? null, - hasDedicatedWorktree: activeWorktreePath !== null, }); const branchPrStatus = prStatusIndicator(branchPr, branchStatusQuery.data?.sourceControlProvider); // Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b5b175cb17f..edef8eee1f1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3979,7 +3979,6 @@ function ChatViewContent(props: ChatViewProps) { const activeThreadPr = resolveThreadPr({ threadBranch: activeThread?.branch ?? null, gitStatus: gitStatusQuery.data ?? null, - hasDedicatedWorktree: (activeThread?.worktreePath ?? null) !== null, }); const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a1d95eaa734..b05b3a39d90 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -452,7 +452,6 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr const pr = resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data, - hasDedicatedWorktree: thread.worktreePath !== null, }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index f87e7fbe539..8677ee1149a 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -499,7 +499,6 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { const pr = resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data, - hasDedicatedWorktree: thread.worktreePath !== null, }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 9fb4535f266..3eb8e4f710f 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -36,31 +36,35 @@ describe("resolveThreadPr", () => { resolveThreadPr({ threadBranch: "feature/other", gitStatus: status(), - hasDedicatedWorktree: false, }), ).toBeNull(); }); - it("shows PR indicators for dedicated worktree threads even when branch metadata is stale", () => { - const gitStatus = status(); + it("hides PR indicators when a dedicated worktree has switched away from the thread branch", () => { + expect( + resolveThreadPr({ + threadBranch: "stack/base", + gitStatus: status(), + }), + ).toBeNull(); + }); + it("hides PR indicators when thread branch metadata is missing", () => { expect( resolveThreadPr({ - threadBranch: "feature/old-name", - gitStatus, - hasDedicatedWorktree: true, + threadBranch: null, + gitStatus: status(), }), - ).toBe(gitStatus.pr); + ).toBeNull(); }); - it("shows PR indicators for dedicated worktree threads even when branch metadata is missing", () => { + it("shows the PR when the live checkout matches the stored thread branch", () => { const gitStatus = status(); expect( resolveThreadPr({ - threadBranch: null, + threadBranch: "feature/current", gitStatus, - hasDedicatedWorktree: true, }), ).toBe(gitStatus.pr); }); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 0d3291a9e28..af53d1a78b2 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -113,17 +113,12 @@ export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator } export function resolveThreadPr(input: { threadBranch: string | null; gitStatus: VcsStatusResult | null; - hasDedicatedWorktree: boolean; }): ThreadPr | null { - const { threadBranch, gitStatus, hasDedicatedWorktree } = input; + const { threadBranch, gitStatus } = input; if (gitStatus === null) { return null; } - if (hasDedicatedWorktree) { - return gitStatus.pr ?? null; - } - if (threadBranch === null || gitStatus.refName !== threadBranch) { return null; } @@ -258,7 +253,6 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar const pr = resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data, - hasDedicatedWorktree: thread.worktreePath !== null, }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const threadStatus = resolveThreadStatusPill({ From 53cf8d8b9a57c68167fcf47ec11395cf590b00f3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 27 Jul 2026 18:07:40 +0200 Subject: [PATCH 04/35] Fix live sidebar resize limits and defer Alchemy runtime context (#4655) (cherry picked from commit da11342e03dbf2bf46058d66b9c4f4eab8047a5a) --- apps/web/src/components/AppSidebarLayout.tsx | 23 ++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 3a70390d0c4..53a9ae48784 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,6 +1,12 @@ import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; -import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; +import { + useEffect, + useState, + useSyncExternalStore, + type CSSProperties, + type ReactNode, +} from "react"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { isElectron } from "../env"; @@ -31,6 +37,15 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px"; +function subscribeToViewportWidth(onChange: () => void): () => void { + window.addEventListener("resize", onChange); + return () => window.removeEventListener("resize", onChange); +} + +function readViewportWidth(): number { + return window.innerWidth; +} + function readInitialThreadSidebarWidth(): number { try { return resolveInitialThreadSidebarWidth( @@ -109,7 +124,11 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { const useSidebarV2Theme = useSidebarV2 || isOnSettings; const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); - const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(window.innerWidth); + // Subscribed rather than read once: the clamp must track live window size, + // and a clamped drag ends with an unchanged width, which skips the re-render + // that would otherwise refresh a render-time snapshot. + const viewportWidth = useSyncExternalStore(subscribeToViewportWidth, readViewportWidth); + const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(viewportWidth); const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => { const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState; return isMacosDesktop && typeof getWindowFullscreenState === "function" From 06328813e96320c169d7dd9fd8ecb6dd01488920 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Mon, 27 Jul 2026 20:42:33 +0400 Subject: [PATCH 05/35] fix(web): constrain branch toolbar context (#4657) (cherry picked from commit 32843c2551bd5832d436ef46bff5ab1198cdc39c) --- apps/web/src/components/BranchToolbar.tsx | 2 +- .../BranchToolbarEnvModeSelector.tsx | 9 +++++++-- .../BranchToolbarEnvironmentSelector.tsx | 19 ++++++++++++------- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index e703427d4b6..2a1d0d1ec05 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -321,7 +321,7 @@ export const BranchToolbar = memo(function BranchToolbar({ onUsePreviousWorktree={onUsePreviousWorktree} /> ) : ( -
+
{showEnvironmentIndicator && availableEnvironments && ( <> + {activeWorktreePath ? ( <> @@ -79,7 +79,12 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe }} items={envModeItems} > - + {effectiveEnvMode === "worktree" ? ( ) : activeWorktreePath ? ( diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index dbc742bea5a..e4ed54758ff 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -43,13 +43,13 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir if (envLocked || onEnvironmentChange === undefined) { return ( - + {activeEnvironment?.isPrimary ? ( - + ) : ( - + )} - {activeEnvironment?.label ?? "Run on"} + {activeEnvironment?.label ?? "Run on"} ); } @@ -61,11 +61,16 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir onValueChange={(value) => onEnvironmentChange(value as EnvironmentId)} items={environmentItems} > - + {activeEnvironment?.isPrimary ? ( - + ) : ( - + )} From c2d38851e6299aeb8023836a160322e77d587a99 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 27 Jul 2026 19:59:21 +0200 Subject: [PATCH 06/35] fix: close actions dropdown when editing (#4660) (cherry picked from commit f0121f31d0e7c069f4007480fc13907c28efd4d5) --- .../src/components/ProjectScriptsControl.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 364c60e051d..e80a7381cea 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -138,6 +138,10 @@ export default function ProjectScriptsControl({ }: ProjectScriptsControlProps) { const addScriptFormId = React.useId(); const [editingScriptId, setEditingScriptId] = useState(null); + const [actionsMenuOpen, setActionsMenuOpen] = useState({ + scripts: false, + imports: false, + }); const [dialogOpen, setDialogOpen] = useState(false); const [name, setName] = useState(""); const [command, setCommand] = useState(""); @@ -255,6 +259,7 @@ export default function ProjectScriptsControl({ }; const openEditDialog = (script: ProjectScript) => { + setActionsMenuOpen({ scripts: false, imports: false }); setEditingScriptId(script.id); setName(script.name); setCommand(script.command); @@ -353,7 +358,11 @@ export default function ProjectScriptsControl({ Run {primaryScript.name} - + setActionsMenuOpen({ scripts: open, imports: false })} + > } > @@ -412,7 +421,11 @@ export default function ProjectScriptsControl({ ) : importableScripts.length > 0 ? ( - + setActionsMenuOpen({ scripts: false, imports: open })} + > }> From a45c58f8aabafd4b9d26e7947dac9cae1a5c28e6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 27 Jul 2026 21:52:11 +0200 Subject: [PATCH 07/35] Prevent draft thread detail polling before shell registration (#4670) Co-authored-by: codex (cherry picked from commit e77f42c111dc40e0b208393957ebb23863330e2e) --- apps/web/src/components/BranchToolbar.tsx | 2 +- .../BranchToolbarBranchSelector.tsx | 4 +-- apps/web/src/components/ChatView.tsx | 20 +++++------ apps/web/src/components/GitActionsControl.tsx | 4 ++- apps/web/src/state/entities.test.ts | 36 +++++++++++++++++++ apps/web/src/state/entities.ts | 29 +++++++++++++-- 6 files changed, 79 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/state/entities.test.ts diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 2a1d0d1ec05..3a83f5c9a0f 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -234,10 +234,10 @@ export const BranchToolbar = memo(function BranchToolbar({ () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], ); - const serverThread = useThread(threadRef); const draftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), ); + const serverThread = useThread(threadRef, { waitForShell: draftThread !== null }); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const activeProjectRef = serverThread ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index de6f87c88fc..b60dad2681f 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -130,11 +130,11 @@ export function BranchToolbarBranchSelector({ () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], ); - const serverThread = useThread(threadRef); - const serverSession = serverThread?.session ?? null; const draftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), ); + const serverThread = useThread(threadRef, { waitForShell: draftThread !== null }); + const serverSession = serverThread?.session ?? null; const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const activeProjectRef = serverThread diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index edef8eee1f1..6a13a091daf 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -620,8 +620,8 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); - const serverThread = useThread(threadRef); const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); + const serverThread = useThread(threadRef, { waitForShell: draftThread !== null }); const projectRef = serverThread ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) : draftThread @@ -976,8 +976,8 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane newShortcutLabel, closeShortcutLabel, }: PersistentThreadTerminalPanelProps) { - const serverThread = useThread(threadRef); const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); + const serverThread = useThread(threadRef, { waitForShell: draftThread !== null }); const projectRef = serverThread ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) : draftThread @@ -1181,7 +1181,14 @@ function ChatViewContent(props: ChatViewProps) { ); const composerDraftTarget: ScopedThreadRef | DraftId = routeKind === "server" ? routeThreadRef : props.draftId; - const serverThread = useThread(routeThreadRef); + const draftThread = useComposerDraftStore((store) => + routeKind === "server" + ? store.getDraftSessionByRef(routeThreadRef) + : draftId + ? store.getDraftSession(draftId) + : null, + ); + const serverThread = useThread(routeThreadRef, { waitForShell: draftThread !== null }); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const activeThreadLastVisitedAt = useUiStateStore( (store) => store.threadLastVisitedAtById[routeThreadKey], @@ -1234,13 +1241,6 @@ function ChatViewContent(props: ChatViewProps) { const setLogicalProjectDraftThreadId = useComposerDraftStore( (store) => store.setLogicalProjectDraftThreadId, ); - const draftThread = useComposerDraftStore((store) => - routeKind === "server" - ? store.getDraftSessionByRef(routeThreadRef) - : draftId - ? store.getDraftSession(draftId) - : null, - ); const promptRef = useRef(""); const composerImagesRef = useRef([]); const composerTerminalContextsRef = useRef([]); diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 11716e935ae..4f2bd19952d 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -986,7 +986,6 @@ export default function GitActionsControl({ () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined), [activeThreadRef], ); - const activeServerThread = useThread(activeThreadRef); const activeDraftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) @@ -994,6 +993,9 @@ export default function GitActionsControl({ ? store.getDraftThreadByRef(activeThreadRef) : null, ); + const activeServerThread = useThread(activeThreadRef, { + waitForShell: activeDraftThread !== null, + }); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); diff --git a/apps/web/src/state/entities.test.ts b/apps/web/src/state/entities.test.ts new file mode 100644 index 00000000000..0d7611ec41a --- /dev/null +++ b/apps/web/src/state/entities.test.ts @@ -0,0 +1,36 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveThreadDetailRef } from "./entities"; + +const threadRef = scopeThreadRef(EnvironmentId.make("environment-1"), ThreadId.make("thread-1")); + +describe("resolveThreadDetailRef", () => { + it("does not subscribe to a reserved draft thread before it enters the shell index", () => { + expect( + resolveThreadDetailRef(threadRef, { + shellExists: false, + waitForShell: true, + }), + ).toBeNull(); + }); + + it("subscribes once the reserved draft thread enters the shell index", () => { + expect( + resolveThreadDetailRef(threadRef, { + shellExists: true, + waitForShell: true, + }), + ).toBe(threadRef); + }); + + it("keeps direct server-thread lookups enabled when the shell has not loaded it", () => { + expect( + resolveThreadDetailRef(threadRef, { + shellExists: false, + waitForShell: false, + }), + ).toBe(threadRef); + }); +}); diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 9bd20070c23..552468e04d9 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -152,10 +152,35 @@ export function useThreadStatus(ref: ScopedThreadRef | null): EnvironmentThreadS ); } +export function resolveThreadDetailRef( + ref: ScopedThreadRef | null, + options: { + shellExists: boolean; + waitForShell: boolean; + }, +): ScopedThreadRef | null { + return ref !== null && (!options.waitForShell || options.shellExists) ? ref : null; +} + /** Detail collections composed with shell-authoritative thread/workspace metadata. */ -export function useThread(ref: ScopedThreadRef | null): EnvironmentThread | null { +export function useThread( + ref: ScopedThreadRef | null, + options?: { + /** + * Client-reserved draft thread ids do not exist on the server until the + * first send. Waiting for the shell index avoids polling the detail + * endpoint for an intentionally missing thread during that window. + */ + waitForShell?: boolean; + }, +): EnvironmentThread | null { const shell = useThreadShell(ref); - const detail = useThreadDetail(ref); + const detail = useThreadDetail( + resolveThreadDetailRef(ref, { + shellExists: shell !== null, + waitForShell: options?.waitForShell === true, + }), + ); return useMemo(() => mergeEnvironmentThread(detail, shell), [detail, shell]); } From 209fc7cc4e4dbb4aa62700ace5a9b92760c13a19 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 27 Jul 2026 22:05:15 +0200 Subject: [PATCH 08/35] feat(diff-panel): show total line additions and deletions (#4674) (cherry picked from commit eea3ea4c6ffc4567ab1037cbf53feb33af0bb132) --- apps/web/src/components/DiffPanel.tsx | 11 +++++++++ apps/web/src/lib/diffRendering.test.ts | 32 +++++++++++++++++++++++++- apps/web/src/lib/diffRendering.ts | 19 +++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 3a30fbb2c7e..2142d898269 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -30,6 +30,7 @@ import { useTheme } from "../hooks/useTheme"; import { buildFileDiffRenderKey, getDiffCollapseIconClassName, + getDiffLineStat, getRenderablePatch, resolveDiffThemeName, resolveFileDiffPath, @@ -41,6 +42,7 @@ import { resolveThreadRouteRef } from "../threadRoutes"; import { useClientSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; +import { DiffStatLabel } from "./chat/DiffStatLabel"; import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableCodeView"; import { Button } from "./ui/button"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; @@ -452,6 +454,7 @@ export default function DiffPanel({ ); const diffFileKeys = useMemo(() => codeViewFiles.map((file) => file.fileKey), [codeViewFiles]); const allDiffFilesCollapsed = areAllDiffFilesCollapsed(diffFileKeys, collapsedDiffFileKeys); + const diffLineStat = useMemo(() => getDiffLineStat(renderableFiles), [renderableFiles]); useEffect(() => { if (!selectedFilePath) return; @@ -713,6 +716,14 @@ export default function DiffPanel({ )}
+ {codeViewFiles.length > 0 && ( + + )} {codeViewFiles.length > 0 && ( { it("returns a stable cache key for identical content", () => { @@ -82,3 +82,33 @@ describe("getRenderablePatch", () => { expect(parsed.files[0]?.hunks[0]?.unifiedLineStart).toBe(47); }); }); + +describe("getDiffLineStat", () => { + it("totals additions and deletions across every file and hunk", () => { + const patch = [ + "diff --git a/example.ts b/example.ts", + "--- a/example.ts", + "+++ b/example.ts", + "@@ -1,2 +1,3 @@", + "-before", + "+after", + "+added", + " context", + "@@ -10,2 +11,1 @@", + "-removed", + " context", + "diff --git a/README.md b/README.md", + "--- a/README.md", + "+++ b/README.md", + "@@ -1 +1,2 @@", + " title", + "+description", + ].join("\n"); + + const parsed = getRenderablePatch(patch); + expect(parsed?.kind).toBe("files"); + if (parsed?.kind !== "files") return; + + expect(getDiffLineStat(parsed.files)).toEqual({ additions: 3, deletions: 2 }); + }); +}); diff --git a/apps/web/src/lib/diffRendering.ts b/apps/web/src/lib/diffRendering.ts index cb8318b3d2d..493474d8aa2 100644 --- a/apps/web/src/lib/diffRendering.ts +++ b/apps/web/src/lib/diffRendering.ts @@ -52,6 +52,25 @@ export type RenderablePatch = reason: string; }; +export interface DiffLineStat { + additions: number; + deletions: number; +} + +export function getDiffLineStat(files: ReadonlyArray): DiffLineStat { + return files.reduce( + (total, file) => { + for (const hunk of file.hunks) { + total.additions += hunk.additionLines; + total.deletions += hunk.deletionLines; + } + + return total; + }, + { additions: 0, deletions: 0 }, + ); +} + interface RenderablePatchOptions { /** * Pierre's partial-patch parser keeps hunk render starts in source-file From b3a7104138c554ec207d85d7cc6f5c2b5a78af33 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 27 Jul 2026 22:07:23 +0200 Subject: [PATCH 09/35] Clear provider update actions while updating (#4676) (cherry picked from commit 6afbed3c36bbb731b9551ec60d7d12ed1fd1d2ee) --- apps/web/src/components/ProviderUpdatePrimaryNotification.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx b/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx index a7f7a2c1e69..64399679acf 100644 --- a/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx +++ b/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx @@ -68,6 +68,10 @@ function updateProviderUpdateToast(input: { title: input.view.title, description: input.view.description, timeout: 0, + // Base UI merges toast updates with the existing toast. Explicitly clear + // the prompt action so its guarded Update handler cannot linger as a + // visible no-op while the update is running (or after it succeeds). + actionProps: undefined, data: { hideCopyButton: true, ...(input.view.dismissAfterVisibleMs !== undefined From 3ce73441c2e20b60a1aaa992562aa8596bd9dc32 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 27 Jul 2026 22:48:45 +0200 Subject: [PATCH 10/35] Fix sidebar highlighting for draft threads (#4679) (cherry picked from commit 6a3df51707b67c3435f86b3b83a093aec8b03415) --- apps/web/src/components/SidebarV2.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 8677ee1149a..ed9eadd907b 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -96,7 +96,11 @@ import { threadEnvironment } from "../state/threads"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { useAtomCommand } from "../state/use-atom-command"; -import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; +import { + buildThreadRouteParams, + resolveActiveThreadRouteRef, + resolveThreadRouteTarget, +} from "../threadRoutes"; import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"; import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; @@ -1052,7 +1056,13 @@ export default function SidebarV2() { strict: false, select: (params) => resolveThreadRouteTarget(params), }); - const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; + const routeDraftThread = useComposerDraftStore((store) => + routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, + ); + const routeThreadRef = useMemo( + () => resolveActiveThreadRouteRef(routeTarget, routeDraftThread), + [routeDraftThread, routeTarget], + ); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; const routeTargetRef = useRef(routeTarget); routeTargetRef.current = routeTarget; From aff6758f4f4691ab99e15f7b6a8ed6e019bda91a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 27 Jul 2026 23:06:39 +0200 Subject: [PATCH 11/35] Use glass surfaces for web toasts (#4681) (cherry picked from commit 3957a958184650656dbbab6d73d9882de9855bd5) --- apps/web/src/components/ui/toast.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index e58b2bde7b5..cf3c0b8fefa 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -587,7 +587,7 @@ function Toasts({ position }: { position: ToastPosition }) { return ( Date: Mon, 27 Jul 2026 23:13:11 +0200 Subject: [PATCH 12/35] Show origin ref in branch trigger label (#4680) Co-authored-by: codex (cherry picked from commit a2ffb122e2041731d1efe24d999aefe4e1ff7c03) --- .../components/BranchToolbar.logic.test.ts | 96 +++++++++++++++++++ .../web/src/components/BranchToolbar.logic.ts | 27 ++++++ .../BranchToolbarBranchSelector.tsx | 43 +++++---- 3 files changed, 150 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index e59ffc8f68b..76336f1ef1f 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -9,6 +9,7 @@ import { resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, resolveEnvModeLabel, + resolveBranchTriggerLabel, resolveBranchToolbarPrBranch, resolveBranchToolbarValue, resolveLockedWorkspaceLabel, @@ -174,6 +175,101 @@ describe("resolveBranchToolbarValue", () => { }); }); +describe("resolveBranchTriggerLabel", () => { + it("shows the origin ref when a new worktree will start from origin", () => { + expect( + resolveBranchTriggerLabel({ + activeWorktreePath: null, + effectiveEnvMode: "worktree", + resolvedActiveBranch: "main", + resolvedActiveBranchIsRemote: false, + startFromOrigin: true, + }), + ).toBe("From origin/main"); + }); + + it("shows the origin ref for local branch names that contain slashes", () => { + expect( + resolveBranchTriggerLabel({ + activeWorktreePath: null, + effectiveEnvMode: "worktree", + resolvedActiveBranch: "feature/demo", + resolvedActiveBranchIsRemote: false, + startFromOrigin: true, + }), + ).toBe("From origin/feature/demo"); + }); + + it("shows the local ref when start from origin is disabled", () => { + expect( + resolveBranchTriggerLabel({ + activeWorktreePath: null, + effectiveEnvMode: "worktree", + resolvedActiveBranch: "main", + resolvedActiveBranchIsRemote: false, + startFromOrigin: false, + }), + ).toBe("From main"); + }); + + it("does not duplicate the origin prefix for an explicit remote ref", () => { + expect( + resolveBranchTriggerLabel({ + activeWorktreePath: null, + effectiveEnvMode: "worktree", + resolvedActiveBranch: "origin/feature/demo", + resolvedActiveBranchIsRemote: true, + startFromOrigin: true, + }), + ).toBe("From origin/feature/demo"); + }); + + it("preserves an explicit ref from a non-origin remote", () => { + expect( + resolveBranchTriggerLabel({ + activeWorktreePath: null, + effectiveEnvMode: "worktree", + resolvedActiveBranch: "upstream/feature/demo", + resolvedActiveBranchIsRemote: true, + startFromOrigin: true, + }), + ).toBe("From upstream/feature/demo"); + }); + + it("keeps current-checkout labels and empty state unchanged", () => { + expect( + resolveBranchTriggerLabel({ + activeWorktreePath: null, + effectiveEnvMode: "local", + resolvedActiveBranch: "main", + resolvedActiveBranchIsRemote: false, + startFromOrigin: true, + }), + ).toBe("main"); + expect( + resolveBranchTriggerLabel({ + activeWorktreePath: null, + effectiveEnvMode: "worktree", + resolvedActiveBranch: null, + resolvedActiveBranchIsRemote: null, + startFromOrigin: true, + }), + ).toBe("Select ref"); + }); + + it("does not fabricate an origin ref while branch metadata is loading", () => { + expect( + resolveBranchTriggerLabel({ + activeWorktreePath: null, + effectiveEnvMode: "worktree", + resolvedActiveBranch: "upstream/feature/demo", + resolvedActiveBranchIsRemote: null, + startFromOrigin: true, + }), + ).toBe("From upstream/feature/demo"); + }); +}); + describe("resolveBranchToolbarPrBranch", () => { it("uses the explicit thread branch when it matches the displayed branch", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 3549eff6eea..d9737f17a32 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -156,6 +156,33 @@ export function resolveBranchToolbarValue(input: { return currentGitBranch ?? activeThreadBranch; } +export function resolveBranchTriggerLabel(input: { + activeWorktreePath: string | null; + effectiveEnvMode: EnvMode; + resolvedActiveBranch: string | null; + resolvedActiveBranchIsRemote: boolean | null; + startFromOrigin: boolean; +}): string { + const { + activeWorktreePath, + effectiveEnvMode, + resolvedActiveBranch, + resolvedActiveBranchIsRemote, + startFromOrigin, + } = input; + if (!resolvedActiveBranch) { + return "Select ref"; + } + if (effectiveEnvMode === "worktree" && !activeWorktreePath) { + const baseRef = + startFromOrigin && resolvedActiveBranchIsRemote === false + ? `origin/${resolvedActiveBranch}` + : resolvedActiveBranch; + return `From ${baseRef}`; + } + return resolvedActiveBranch; +} + export function resolveBranchToolbarPrBranch(input: { activeThreadBranch: string | null; resolvedActiveBranch: string | null; diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index b60dad2681f..a57a6dc7a43 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -36,6 +36,7 @@ import { parsePullRequestReference } from "../pullRequestReference"; import { getSourceControlPresentation } from "../sourceControlPresentation"; import { deriveLocalBranchNameFromRemoteRef, + resolveBranchTriggerLabel, resolveBranchToolbarPrBranch, resolveBranchSelectionTarget, resolveBranchToolbarValue, @@ -82,21 +83,6 @@ function toBranchActionErrorMessage(error: unknown): string { return error instanceof Error ? error.message : "An error occurred."; } -function getBranchTriggerLabel(input: { - activeWorktreePath: string | null; - effectiveEnvMode: "local" | "worktree"; - resolvedActiveBranch: string | null; -}): string { - const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch } = input; - if (!resolvedActiveBranch) { - return "Select ref"; - } - if (effectiveEnvMode === "worktree" && !activeWorktreePath) { - return `From ${resolvedActiveBranch}`; - } - return resolvedActiveBranch; -} - export function BranchToolbarBranchSelector({ className, environmentId, @@ -309,6 +295,29 @@ export function BranchToolbarBranchSelector({ canonicalActiveBranch, (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch, ); + const listedActiveBranch = + resolvedActiveBranch === null ? null : (branchByName.get(resolvedActiveBranch) ?? null); + const activeBranchRefQuery = useEnvironmentQuery( + branchCwd !== null && resolvedActiveBranch !== null + ? vcsEnvironment.listRefs({ + environmentId, + input: { + cwd: branchCwd, + query: resolvedActiveBranch, + limit: 10, + }, + }) + : null, + ); + const queriedActiveBranch = activeBranchRefQuery.data?.refs.find( + (refName) => refName.name === resolvedActiveBranch, + ); + const resolvedActiveBranchIsRemote = + listedActiveBranch !== null + ? listedActiveBranch.isRemote === true + : queriedActiveBranch + ? queriedActiveBranch.isRemote === true + : null; const [isBranchActionPending, startBranchActionTransition] = useTransition(); const totalBranchCount = branchRefState.data?.totalCount ?? 0; const branchStatusText = isInitialBranchesLoadPending @@ -588,10 +597,12 @@ export function BranchToolbarBranchSelector({ maybeFetchNextBranchPage(); }, [refs.length, maybeFetchNextBranchPage]); - const triggerLabel = getBranchTriggerLabel({ + const triggerLabel = resolveBranchTriggerLabel({ activeWorktreePath, effectiveEnvMode, resolvedActiveBranch, + resolvedActiveBranchIsRemote, + startFromOrigin, }); // PR pill shown next to the branch selector when the active branch has one. From 5aa2672f5c360104ab1e6160710bb51e0430c577 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 27 Jul 2026 21:41:08 -0700 Subject: [PATCH 13/35] feat: default sidebar v2 on for nightly and dev builds (#4491) Co-authored-by: Claude Opus 5 (1M context) (cherry picked from commit c13a021e432e94728fe88ff983fb9601c6d6b943) --- .../settings/DesktopClientSettings.test.ts | 1 + apps/mobile/src/features/home/HomeHeader.tsx | 24 +++--- apps/mobile/src/features/home/HomeScreen.tsx | 5 +- .../features/settings/SettingsRouteScreen.tsx | 6 +- .../threads/ThreadNavigationSidebar.tsx | 8 +- .../src/features/threads/threadListV2.test.ts | 42 +++++++++++ .../src/features/threads/threadListV2.ts | 31 ++++++++ .../threads/use-thread-list-v2-enabled.ts | 25 +++++++ .../src/persistence/mobile-preferences.ts | 3 +- apps/web/src/branding.logic.ts | 45 ++++++++++++ apps/web/src/branding.test.ts | 73 +++++++++++++++++++ apps/web/src/components/AppSidebarLayout.tsx | 4 +- .../components/settings/BetaSettingsPanel.tsx | 17 ++++- apps/web/src/hooks/useSettings.ts | 28 +++++++ apps/web/src/routes/_chat.tsx | 4 +- packages/contracts/src/settings.test.ts | 25 +++++++ packages/contracts/src/settings.ts | 7 ++ 17 files changed, 312 insertions(+), 36 deletions(-) create mode 100644 apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 8f50aa8f882..a740fbafeee 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -30,6 +30,7 @@ const clientSettings: ClientSettings = { sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, sidebarV2Enabled: false, + sidebarV2ConfiguredByUser: false, timestampFormat: "24-hour", wordWrap: true, }; diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index 36cead9f8cc..4265107912b 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -1,7 +1,5 @@ import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; -import { useAtomValue } from "@effect/atom-react"; -import { AsyncResult } from "effect/unstable/reactivity"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useCallback, useMemo, useRef } from "react"; import { Platform, Pressable, Text as RNText, TextInput, View } from "react-native"; @@ -12,7 +10,7 @@ import { ControlPillMenu } from "../../components/ControlPill"; import { SymbolView } from "../../components/AppSymbol"; import { T3Wordmark } from "../../components/T3Wordmark"; import { useThemeColor } from "../../lib/useThemeColor"; -import { mobilePreferencesAtom } from "../../state/preferences"; +import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; @@ -59,21 +57,14 @@ function checkedMenuState(checked: boolean) { return checked ? ("on" as const) : undefined; } -/** Thread List v2 lays the list out in fixed creation order, so the - sort/group filter controls would be silently ignored — hide them and - key the "customized" icon state off the environment filter alone. */ -function useThreadListV2FilterGate() { - const preferencesResult = useAtomValue(mobilePreferencesAtom); - return ( - AsyncResult.isSuccess(preferencesResult) && preferencesResult.value.threadListV2Enabled === true - ); -} - function AndroidHomeHeader(props: HomeHeaderProps) { const insets = useSafeAreaInsets(); const iconColor = useThemeColor("--color-icon"); const mutedColor = useThemeColor("--color-foreground-muted"); - const threadListV2Enabled = useThreadListV2FilterGate(); + // Thread List v2 lays the list out in fixed creation order, so the + // sort/group filter controls would be silently ignored — hide them and + // key the "customized" icon state off the environment filter alone. + const threadListV2Enabled = useThreadListV2Enabled(); const hasCustomListOptions = threadListV2Enabled ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null : hasCustomHomeListOptions(props); @@ -291,7 +282,10 @@ function AndroidHomeHeader(props: HomeHeaderProps) { function IosHomeHeader(props: HomeHeaderProps) { const searchBarRef = useRef(null); const iconColor = useThemeColor("--color-icon"); - const threadListV2Enabled = useThreadListV2FilterGate(); + // Thread List v2 lays the list out in fixed creation order, so the + // sort/group filter controls would be silently ignored — hide them and + // key the "customized" icon state off the environment filter alone. + const threadListV2Enabled = useThreadListV2Enabled(); const hasCustomListOptions = threadListV2Enabled ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null : hasCustomHomeListOptions(props); diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 82e563a0e7d..10158c17504 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -27,6 +27,7 @@ import type { SavedRemoteConnection } from "../../lib/connection"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { environmentServerConfigsAtom } from "../../state/server"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { @@ -181,9 +182,7 @@ export function HomeScreen(props: HomeScreenProps) { ReadonlyMap >(() => new Map()); const preferencesResult = useAtomValue(mobilePreferencesAtom); - const threadListV2Enabled = - AsyncResult.isSuccess(preferencesResult) && - preferencesResult.value.threadListV2Enabled === true; + const threadListV2Enabled = useThreadListV2Enabled(); const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); const listRef = useRef(null); diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 4c33675f57f..476efe40155 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -37,6 +37,7 @@ import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; import { runtime } from "../../lib/runtime"; import { useThemeColor } from "../../lib/useThemeColor"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; @@ -546,11 +547,8 @@ function GeneralSettingsSection() { * the counterpart of web's Settings → Beta backed by mobile preferences. */ function BetaSettingsSection() { - const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); - const threadListV2Enabled = AsyncResult.isSuccess(preferencesResult) - ? preferencesResult.value.threadListV2Enabled === true - : false; + const threadListV2Enabled = useThreadListV2Enabled(); return ( diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index e8b2c9b1903..a898ccb9af9 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -6,7 +6,6 @@ import type { import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; -import { AsyncResult } from "effect/unstable/reactivity"; import type { EnvironmentId } from "@t3tools/contracts"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native"; @@ -25,7 +24,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useThemeColor } from "../../lib/useThemeColor"; import { useProjects, useThreadShells } from "../../state/entities"; -import { mobilePreferencesAtom } from "../../state/preferences"; +import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; import { environmentServerConfigsAtom } from "../../state/server"; import { usePendingNewTasks, type PendingNewTask } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; @@ -196,10 +195,7 @@ function ThreadNavigationSidebarPane( const sidebarScrollGesture = useMemo(() => Gesture.Native(), []); const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = useThreadListActions(); - const preferencesResult = useAtomValue(mobilePreferencesAtom); - const threadListV2Enabled = - AsyncResult.isSuccess(preferencesResult) && - preferencesResult.value.threadListV2Enabled === true; + const threadListV2Enabled = useThreadListV2Enabled(); const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index e62b9ceda34..b3e9f73bfe1 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildThreadListV2Items, + resolveThreadListV2Enabled, resolveThreadListV2Status, sortThreadsForListV2, } from "./threadListV2"; @@ -38,6 +39,47 @@ function makeThread( const NOW = "2026-06-02T00:00:00.000Z"; +describe("resolveThreadListV2Enabled", () => { + it.each(["development", "preview"])("defaults on for the %s variant", (appVariant) => { + expect( + resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true, appVariant }), + ).toBe(true); + }); + + it.each(["production", undefined])("defaults off for the %s variant", (appVariant) => { + expect( + resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true, appVariant }), + ).toBe(false); + }); + + it("prefers an explicit device choice over the variant default", () => { + expect( + resolveThreadListV2Enabled({ + preference: false, + preferencesLoaded: true, + appVariant: "preview", + }), + ).toBe(false); + expect( + resolveThreadListV2Enabled({ + preference: true, + preferencesLoaded: true, + appVariant: "production", + }), + ).toBe(true); + }); + + it("holds v1 while preferences are still loading so the list does not remount", () => { + expect( + resolveThreadListV2Enabled({ + preference: undefined, + preferencesLoaded: false, + appVariant: "development", + }), + ).toBe(false); + }); +}); + describe("resolveThreadListV2Status", () => { it("prioritizes approval over a running session", () => { const thread = makeThread({ diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index efa68153a3f..62c0b39aeb8 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -18,6 +18,37 @@ export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | " export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10; export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25; +/** + * Whether Thread List v2 is on by default for an app variant. The `development` + * and `preview` variants are mobile's nightly equivalents and opt in; + * `production` stays on v1. Counterpart of web's `resolveSidebarV2Default`. + */ +export function resolveThreadListV2Default(appVariant: unknown): boolean { + return appVariant === "development" || appVariant === "preview"; +} + +/** + * Resolved Thread List v2 state: the device-local preference if the user has + * set one, otherwise the default for this app variant. Preferences persist as + * sparse patches, so `undefined` genuinely means "never chosen". + * + * `preferencesLoaded` guards the startup window: preferences load + * asynchronously, and treating "still loading" as "never chosen" would mount + * v2 on a development build and then flip to v1 once a stored opt-out arrives, + * remounting the whole list. While loading, hold v1 — the state both variants + * already start from. + */ +export function resolveThreadListV2Enabled(input: { + readonly preference: boolean | undefined; + readonly preferencesLoaded: boolean; + readonly appVariant: unknown; +}): boolean { + if (!input.preferencesLoaded) { + return false; + } + return input.preference ?? resolveThreadListV2Default(input.appVariant); +} + export function resolveThreadListV2Status( thread: Pick, ): ThreadListV2Status { diff --git a/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts b/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts new file mode 100644 index 00000000000..bb03b5aa9ad --- /dev/null +++ b/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts @@ -0,0 +1,25 @@ +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; +import Constants from "expo-constants"; + +import { mobilePreferencesAtom } from "../../state/preferences"; +import { resolveThreadListV2Enabled } from "./threadListV2"; + +/** + * Resolved Thread List v2 state: the device-local preference if the user has + * set one, otherwise the default for this app variant (on for development and + * preview, off for production). Every consumer must read through this rather + * than the raw preference, which is undefined until explicitly chosen. + * + * Kept out of `state/preferences.ts` so that module stays importable from node + * test environments, which have no `__DEV__` for expo-constants. + */ +export function useThreadListV2Enabled(): boolean { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const loaded = AsyncResult.isSuccess(preferencesResult); + return resolveThreadListV2Enabled({ + preference: loaded ? preferencesResult.value.threadListV2Enabled : undefined, + preferencesLoaded: loaded, + appVariant: Constants.expoConfig?.extra?.appVariant, + }); +} diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 4e576bb2fe1..bbcf4131f31 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -26,7 +26,8 @@ export interface Preferences { /** * Device-local mirror of the web beta's `sidebarV2Enabled`. Mobile has no * client-settings sync, so the flat v2 thread list is opted into per - * device. + * device. Undefined means the user has never chosen, in which case the app + * variant decides — see `resolveThreadListV2Enabled`. */ readonly threadListV2Enabled?: boolean; } diff --git a/apps/web/src/branding.logic.ts b/apps/web/src/branding.logic.ts index 056fbb76e6a..06d663ca0b4 100644 --- a/apps/web/src/branding.logic.ts +++ b/apps/web/src/branding.logic.ts @@ -11,6 +11,51 @@ export function formatAppDisplayName(input: { return `${input.baseName} (${input.stageLabel})`; } +/** + * Whether the sidebar v2 beta is on by default for a build stage. + * + * Nightly and local dev opt in; Alpha and Latest stay on v1. This is resolved + * from the client's own stage label rather than the connected server's version: + * v2 only exists in the client, so a stable client on a nightly server has + * nothing to turn on. + */ +export function resolveSidebarV2Default(stageLabel: string): boolean { + const stage = stageLabel.trim().toLowerCase(); + return stage === "nightly" || stage === "dev"; +} + +/** + * Resolved sidebar v2 state: an explicit choice if the user has made one, + * otherwise the default for this build stage. + * + * A stored `enabled: true` counts as an explicit choice even without the + * companion flag. `true` was never the schema default, so it can only have come + * from the Settings → Beta toggle — settings written before that flag existed + * would otherwise lose the opt-in and drop such users back to v1 on production. + * Mirrors how `normalizeDesktopSettingsDocument` treats a legacy stored + * `updateChannel: "nightly"` as user-configured. + * + * `settingsHydrated` guards the startup window: client settings load + * asynchronously and the pre-hydration snapshot is just the schema defaults, so + * resolving against it would mount one sidebar and swap it out a tick later, + * remounting the tree. While hydrating, hold v1 — where both paths already + * start. + */ +export function resolveSidebarV2Enabled(input: { + readonly enabled: boolean; + readonly configuredByUser: boolean; + readonly settingsHydrated: boolean; + readonly stageLabel: string; +}): boolean { + if (!input.settingsHydrated) { + return false; + } + + return input.configuredByUser || input.enabled + ? input.enabled + : resolveSidebarV2Default(input.stageLabel); +} + export function resolveServerBackedAppStageLabel(input: { readonly primaryServerVersion: string | null | undefined; readonly fallbackStageLabel: string; diff --git a/apps/web/src/branding.test.ts b/apps/web/src/branding.test.ts index e1c87bcf059..e517d40b04f 100644 --- a/apps/web/src/branding.test.ts +++ b/apps/web/src/branding.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { resolveServerBackedAppDisplayName, resolveServerBackedAppStageLabel, + resolveSidebarV2Default, + resolveSidebarV2Enabled, } from "./branding.logic"; const originalWindow = globalThis.window; @@ -114,3 +116,74 @@ describe("branding logic", () => { ).toBe("T3 Code (Alpha)"); }); }); + +describe("resolveSidebarV2Default", () => { + it.each(["Nightly", "Dev", "nightly", " dev "])("enables the beta for %s builds", (stage) => { + expect(resolveSidebarV2Default(stage)).toBe(true); + }); + + it.each(["Alpha", "Latest", ""])("leaves the beta off for %s builds", (stage) => { + expect(resolveSidebarV2Default(stage)).toBe(false); + }); +}); + +describe("resolveSidebarV2Enabled", () => { + const hydrated = { settingsHydrated: true } as const; + + it.each(["Alpha", "Latest"])( + "keeps a legacy opt-in on %s builds even without the companion flag", + (stageLabel) => { + // `true` was never the schema default, so it can only be an explicit + // opt-in from settings written before `sidebarV2ConfiguredByUser` existed. + expect( + resolveSidebarV2Enabled({ + ...hydrated, + enabled: true, + configuredByUser: false, + stageLabel, + }), + ).toBe(true); + }, + ); + + it("applies the stage default when the beta was never enabled or configured", () => { + expect( + resolveSidebarV2Enabled({ + ...hydrated, + enabled: false, + configuredByUser: false, + stageLabel: "Nightly", + }), + ).toBe(true); + expect( + resolveSidebarV2Enabled({ + ...hydrated, + enabled: false, + configuredByUser: false, + stageLabel: "Latest", + }), + ).toBe(false); + }); + + it("honors an explicit opt-out over the stage default", () => { + expect( + resolveSidebarV2Enabled({ + ...hydrated, + enabled: false, + configuredByUser: true, + stageLabel: "Nightly", + }), + ).toBe(false); + }); + + it("holds v1 until settings hydrate so the sidebar does not remount", () => { + expect( + resolveSidebarV2Enabled({ + enabled: true, + configuredByUser: true, + settingsHydrated: false, + stageLabel: "Nightly", + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 53a9ae48784..90a45e8e25f 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -14,7 +14,7 @@ import { getLocalStorageItem } from "../hooks/useLocalStorage"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; -import { useClientSettings } from "../hooks/useSettings"; +import { useSidebarV2Enabled } from "../hooks/useSettings"; import ThreadSidebar from "./Sidebar"; import ThreadSidebarV2 from "./SidebarV2"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; @@ -115,7 +115,7 @@ function SidebarControl() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); - const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); + const sidebarV2Enabled = useSidebarV2Enabled(); // Settings routes render the settings nav, which lives in the v1 component // and is identical for both sidebars — so v1 stays mounted there. const pathname = useLocation({ select: (location) => location.pathname }); diff --git a/apps/web/src/components/settings/BetaSettingsPanel.tsx b/apps/web/src/components/settings/BetaSettingsPanel.tsx index 942bd374d91..5a1ac036077 100644 --- a/apps/web/src/components/settings/BetaSettingsPanel.tsx +++ b/apps/web/src/components/settings/BetaSettingsPanel.tsx @@ -1,6 +1,10 @@ import { useEffect, useState } from "react"; -import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings"; +import { + useClientSettings, + useSidebarV2Enabled, + useUpdateClientSettings, +} from "../../hooks/useSettings"; import { Input } from "../ui/input"; import { Switch } from "../ui/switch"; import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; @@ -51,7 +55,7 @@ function AutoSettleDaysInput({ } export function BetaSettingsPanel() { - const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); + const sidebarV2Enabled = useSidebarV2Enabled(); const sidebarAutoSettleAfterDays = useClientSettings( (settings) => settings.sidebarAutoSettleAfterDays, ); @@ -66,7 +70,14 @@ export function BetaSettingsPanel() { control={ updateSettings({ sidebarV2Enabled: Boolean(checked) })} + // Touching the switch pins the choice, so a nightly build that + // defaults v2 on does not flip it back after the user opts out. + onCheckedChange={(checked) => + updateSettings({ + sidebarV2Enabled: Boolean(checked), + sidebarV2ConfiguredByUser: true, + }) + } aria-label="Enable the sidebar v2 beta" /> } diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 1c6434f7428..e6ddbf2c0de 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -24,6 +24,8 @@ import { type UnifiedSettings, } from "@t3tools/contracts/settings"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { APP_STAGE_LABEL } from "~/branding"; +import { resolveSidebarV2Enabled } from "~/branding.logic"; import { ensureLocalApi } from "~/localApi"; import * as Struct from "effect/Struct"; import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; @@ -270,6 +272,32 @@ export function useClientSettings( return useMemo(() => (selector ? selector(settings) : (settings as T)), [selector, settings]); } +/** + * Resolved sidebar v2 state: an explicit choice in Settings → Beta if the user + * has made one, otherwise the default for this build stage (on for nightly and + * dev, off for production). Every consumer must read through this rather than + * `settings.sidebarV2Enabled`, which is only meaningful alongside + * `sidebarV2ConfiguredByUser`. + * + * Held at v1 until client settings hydrate. The pre-hydration snapshot is just + * the schema defaults, so resolving against it would mount one sidebar and then + * swap it out once persisted settings land — remounting the whole tree. + */ +export function useSidebarV2Enabled(): boolean { + const settingsHydrated = useClientSettingsHydrated(); + const settings = useClientSettingsValue(); + return useMemo( + () => + resolveSidebarV2Enabled({ + enabled: settings.sidebarV2Enabled, + configuredByUser: settings.sidebarV2ConfiguredByUser, + settingsHydrated, + stageLabel: APP_STAGE_LABEL, + }), + [settings.sidebarV2Enabled, settings.sidebarV2ConfiguredByUser, settingsHydrated], + ); +} + /** Read current settings for one environment, merged with client-local preferences. */ export function useEnvironmentSettings( environmentId: EnvironmentId, diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index d3cf003d99c..75c517dc33f 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -3,7 +3,7 @@ import { useAtomValue } from "@effect/atom-react"; import { useEffect, useMemo } from "react"; import { isCommandPaletteOpen } from "../commandPaletteBus"; -import { useClientSettings } from "../hooks/useSettings"; +import { useClientSettings, useSidebarV2Enabled } from "../hooks/useSettings"; import { openCommandPalette } from "../commandPaletteBus"; import { useProjects } from "../state/entities"; import { usePrimaryEnvironmentId } from "../state/environments"; @@ -28,7 +28,7 @@ function ChatRouteGlobalShortcuts() { const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = useHandleNewThread(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); + const sidebarV2Enabled = useSidebarV2Enabled(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const projects = useProjects(); const primaryEnvironmentId = usePrimaryEnvironmentId(); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 1e2f6b95c74..ed864344e35 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -56,6 +56,31 @@ describe("ClientSettings sidebar v2", () => { expect(settings.sidebarAutoSettleAfterDays).toBe(3); }); + it("treats settings written before the beta had a per-channel default as unconfigured", () => { + // The stored blob always carries `sidebarV2Enabled`, so only the companion + // flag can distinguish "user opted out" from "never touched it". + expect(decodeClientSettings({ sidebarV2Enabled: false }).sidebarV2ConfiguredByUser).toBe(false); + expect(decodeClientSettings({ sidebarV2Enabled: true }).sidebarV2ConfiguredByUser).toBe(false); + }); + + it("preserves an explicit beta choice", () => { + const settings = decodeClientSettings({ + sidebarV2Enabled: false, + sidebarV2ConfiguredByUser: true, + }); + expect(settings.sidebarV2Enabled).toBe(false); + expect(settings.sidebarV2ConfiguredByUser).toBe(true); + }); + + it("carries an explicit beta opt-out through the patch the beta toggle writes", () => { + const patch = decodeClientSettingsPatch({ + sidebarV2Enabled: false, + sidebarV2ConfiguredByUser: true, + }); + expect(patch.sidebarV2Enabled).toBe(false); + expect(patch.sidebarV2ConfiguredByUser).toBe(true); + }); + it("allows auto-settle by inactivity to be disabled", () => { expect( decodeClientSettings({ sidebarAutoSettleAfterDays: null }).sidebarAutoSettleAfterDays, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 885ccda48a0..71e326db5dd 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -115,6 +115,12 @@ export const ClientSettingsSchema = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)), ), sidebarV2Enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + // Whether `sidebarV2Enabled` reflects an explicit choice in Settings → Beta. + // Client settings persist as a whole blob, so every user who has ever touched + // any setting already has `sidebarV2Enabled: false` stored — without this bit + // there is no way to tell that apart from "left alone", and a channel-derived + // default could never reach them. Mirrors `updateChannelConfiguredByUser`. + sidebarV2ConfiguredByUser: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), @@ -833,6 +839,7 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), sidebarV2Enabled: Schema.optionalKey(Schema.Boolean), + sidebarV2ConfiguredByUser: Schema.optionalKey(Schema.Boolean), timestampFormat: Schema.optionalKey(TimestampFormat), wordWrap: Schema.optionalKey(Schema.Boolean), }); From 6144d099a8db5a5cddb5a22202d2cd19be7a8512 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Tue, 28 Jul 2026 08:43:49 +0400 Subject: [PATCH 14/35] fix(web): 33 web UI fixes (#4700) (cherry picked from commit dd5ea324885aea4980a49bb42d28f83023c40ea7) --- .../src/components/CommandPaletteResults.tsx | 6 +- apps/web/src/components/ProjectFavicon.tsx | 9 +- apps/web/src/components/Sidebar.tsx | 56 +-- apps/web/src/components/SidebarV2.tsx | 451 +++++++++--------- apps/web/src/components/chat/ChatComposer.tsx | 33 +- .../components/chat/ComposerBannerStack.tsx | 4 +- .../components/chat/ComposerCommandMenu.tsx | 5 +- .../src/components/chat/ComposerControl.tsx | 71 +++ .../src/components/chat/DraftHeroHeadline.tsx | 6 +- apps/web/src/components/chat/ModelListRow.tsx | 2 +- .../components/chat/ModelPickerContent.tsx | 26 +- .../components/chat/ModelPickerSidebar.tsx | 41 +- .../components/chat/ProviderModelPicker.tsx | 17 +- apps/web/src/components/chat/TraitsPicker.tsx | 18 +- .../clerk/T3ConnectSidebarSignIn.tsx | 8 +- .../settings/SettingsSidebarNav.tsx | 28 +- .../src/components/sidebar/SidebarChrome.tsx | 8 +- apps/web/src/components/ui/command.tsx | 12 +- apps/web/src/components/ui/scroll-area.tsx | 4 +- apps/web/src/components/ui/select.tsx | 6 +- apps/web/src/components/ui/sidebar.test.tsx | 24 +- apps/web/src/components/ui/sidebar.tsx | 47 +- apps/web/src/contextMenuFallback.ts | 4 +- apps/web/src/index.css | 86 ++-- apps/web/src/routes/settings.tsx | 4 +- 25 files changed, 544 insertions(+), 432 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerControl.tsx diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index d4d31af3682..532d546df9a 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -41,7 +41,7 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { {props.groups.map((group) => ( - {group.label} + {group.label} {(item) => item.disabled ? ( @@ -133,13 +133,13 @@ function CommandPaletteResultRow(props: { )} {props.item.titleTrailingContent} {props.item.timestamp ? ( - + {props.item.timestamp} ) : null} {shortcutLabel ? {shortcutLabel} : null} {props.item.kind === "submenu" ? ( - + ) : null} ); diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index bc3e8ee832f..201241731fa 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -4,6 +4,7 @@ import { FolderIcon } from "lucide-react"; import type { ComponentType } from "react"; import { useState } from "react"; import { useAssetUrl } from "../assets/assetUrls"; +import { cn } from "~/lib/utils"; const loadedProjectFaviconSrcs = new Set(); @@ -40,7 +41,7 @@ function ProjectFaviconFallback({ readonly className?: string | undefined; readonly icon: ComponentType<{ className?: string }>; }) { - return ; + return ; } function ProjectFaviconImage({ @@ -64,7 +65,11 @@ function ProjectFaviconImage({ { loadedProjectFaviconSrcs.add(src); setStatus("loaded"); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b05b3a39d90..98b5dcf84ed 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2218,9 +2218,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
- - - - - } - > - - Search - {commandPaletteShortcutLabel ? ( - - {commandPaletteShortcutLabel} - - ) : null} - - - - + + + + + } + > + + Search + {commandPaletteShortcutLabel ? ( + + {commandPaletteShortcutLabel} + + ) : null} + + + + + } + > {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index ed9eadd907b..3d314fd1ac2 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -242,38 +242,40 @@ function SidebarV2ThreadTooltip({ -
-
{thread.title}
-
+
+
+ {thread.title} +
+
{projectTitle ? (
-
{projectTitle}
+
{projectTitle}
) : null} {environmentLabel ? (
- -
{environmentLabel}
+ +
{environmentLabel}
) : null} {thread.branch ? (
- -
{thread.branch}
+ +
{thread.branch}
) : null} {branchMismatch ? (
- +
You're currently checked out on another branch.
@@ -284,15 +286,15 @@ function SidebarV2ThreadTooltip({ -
{modelLabel}
+
{modelLabel}
) : null} {thread.session?.lastError ? (
- -
{thread.session.lastError}
+ +
Error occurred
) : null}
@@ -815,9 +817,9 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { type="button" aria-label="Un-settle thread" onClick={handleUnsettleClick} - className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100" + className="absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100" > - + ) : ( ) : null} @@ -2220,151 +2222,158 @@ export default function SidebarV2() { return ( <> - - -
-
- - } - > - -
Search
- {commandPaletteShortcutLabel ? ( - - {commandPaletteShortcutLabel} - - ) : null} -
-
-
- - +
+
+ } > - -
-
- - {projectGroups.length > 0 ? ( - -
- - - {scopedProjectGroup ? ( - +
Search
+ {commandPaletteShortcutLabel ? ( + + {commandPaletteShortcutLabel} + + ) : null} + +
+
+ + + } + > + + + + {newThreadShortcutLabel + ? `New thread (${newThreadShortcutLabel})` + : "New thread"} + + +
+
+ {projectGroups.length > 0 ? ( +
+ + } > - + {scopedProjectGroup ? ( + + ) : ( - All projects - - {projectGroups.map((project) => { - const scopeKey = project.projectKey; - return ( - - - {project.displayName} - - - ); - })} - - - - - + {project.displayName} + + + ); + })} + + + + + + } + > + + - New project - -
+ + New project + +
+ ) : null}
- ) : null} - + } + > + + Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more - - ({hiddenSettledCount} settled hidden) - ) : null} @@ -2547,7 +2554,7 @@ export default function SidebarV2() { onClick={openAddProjectCommandPalette} className="inline-flex items-center gap-1.5 rounded-md border border-sidebar-border px-2.5 py-1 text-[11px] font-medium text-sidebar-muted-foreground transition-colors hover:bg-sidebar-row-hover hover:text-sidebar-foreground" > - + Add project @@ -2567,48 +2574,52 @@ export default function SidebarV2() { }} > - + Project settings - - {projectActionsTarget && projectActionsTarget.memberProjects.length > 1 - ? `${projectActionsTarget.displayName} has an entry in each environment. Changes apply only to the entry you choose.` - : `Manage ${projectActionsTarget?.displayName ?? "this project"} in this environment.`} + + Manage project names, grouping rules, and environments. +
+ {projectActionsTarget?.memberProjects.map((member) => ( +
+ + + {member.workspaceRoot} + + + + + + {member.environmentLabel ?? "Current environment"} + + +
+ ))} +
{projectActionsTarget?.memberProjects.map((member) => (
-
- -
-
- -

- {member.environmentLabel ?? "Current environment"} -

-
-

- {member.workspaceRoot} -

-
-
-
+
-
- - -
+ {projectActionsTarget.memberProjects.length > 1 ? ( +
+ +
+ ) : null}
))}
@@ -2727,8 +2728,26 @@ export default function SidebarV2() {
) : null} - - + + {projectActionsTarget?.memberProjects.length === 1 ? ( + + ) : null} + diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 67ebb3b50b7..19b1b3bbf1b 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -95,6 +95,7 @@ import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; +import { ComposerControl, ComposerControlIcon, ComposerSelectControl } from "./ComposerControl"; import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; import { searchSlashCommandItems } from "./composerSlashCommandSearch"; import { @@ -167,7 +168,7 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children: ); } import { Button } from "../ui/button"; -import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Select, SelectItem, SelectPopup, SelectValue } from "../ui/select"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; import { BotIcon, CircleAlertIcon, ListTodoIcon, PencilRulerIcon, XIcon } from "lucide-react"; @@ -269,15 +270,13 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop {props.interactionMode === "plan" ? ( - + ) : ( - + )} {props.interactionMode === "plan" ? "Plan" : "Build"} @@ -308,16 +307,9 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop onValueChange={(value) => props.onRuntimeModeChange(value!)} > - } + render={} > - + {runtimeModeOption.label} @@ -353,22 +345,21 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop } > - {props.planSidebarLabel} diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index 3f8f56f041a..699c9cfd9c4 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -93,7 +93,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro {showCollapsedStackCap ? (
{item.icon} diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 9021b6b4609..73fc6348905 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -139,7 +139,10 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { ); }} > -
+
{props.items.length > 0 ? ( {groups.map((group, groupIndex) => ( diff --git a/apps/web/src/components/chat/ComposerControl.tsx b/apps/web/src/components/chat/ComposerControl.tsx new file mode 100644 index 00000000000..8eab75171c8 --- /dev/null +++ b/apps/web/src/components/chat/ComposerControl.tsx @@ -0,0 +1,71 @@ +import type { ComponentProps } from "react"; +import { ChevronDownIcon, type LucideIcon } from "lucide-react"; + +import { cn } from "~/lib/utils"; +import { Button } from "../ui/button"; +import { SelectTrigger } from "../ui/select"; + +const composerControlClassName = + "h-7 min-h-7 gap-1.5 px-2.5 text-muted-foreground/70 transition-none hover:text-foreground/80 [&_svg[data-composer-control-icon]]:mx-0 [&_svg[data-composer-control-chevron]]:-mx-0.5"; + +export function ComposerControl({ + className, + size = "sm", + variant = "ghost", + ...props +}: ComponentProps) { + return ( + diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index 2ec4be70994..e86435561a2 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -50,7 +50,7 @@ export const ModelListRow = memo(function ModelListRow(props: { disabled={Boolean(props.disabledReason)} contentClassName="flex w-full items-center gap-3" className={cn( - "group relative w-full !min-w-0 max-w-full cursor-pointer rounded-md px-2 py-2.5 transition-[background-color,box-shadow,color]", + "group relative w-full !min-w-0 max-w-full cursor-pointer rounded-md px-2 py-2 transition-[background-color,box-shadow,color]", "hover:bg-[color-mix(in_srgb,var(--popover)_90%,var(--foreground))] data-highlighted:bg-[color-mix(in_srgb,var(--popover)_90%,var(--foreground))] data-selected:bg-foreground/[0.08] data-selected:text-foreground data-selected:ring-0 [&[data-highlighted][data-selected]]:bg-[color-mix(in_srgb,var(--popover)_90%,var(--foreground))]", props.disabledReason && "data-disabled:pointer-events-auto data-disabled:cursor-not-allowed data-disabled:hover:bg-transparent", diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index bbdbd8bd9d9..d317ee6061c 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -43,6 +43,10 @@ type ModelPickerItem = { const EMPTY_MODEL_JUMP_LABELS = new Map(); +function ModelListSeparator() { + return
; +} + // Split a `${instanceId}:${slug}` combobox key back into its pieces. Slugs // can contain colons (e.g. some vendor model ids), so we only split on the // first colon — anything after that is the slug. @@ -521,7 +525,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { return (
{/* Sidebar */} @@ -571,12 +575,12 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
{/* Search bar */} -
-
+
+
+ } value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} @@ -618,8 +622,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
{/* Model list */} -
- +
+ ref={modelListRef} data={filteredModelKeys} @@ -656,12 +660,14 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { estimatedItemSize={60} drawDistance={480} recycleItems + contentContainerClassName="pl-2 pr-px" + ItemSeparatorComponent={ModelListSeparator} onLayout={updateModelListScrollFades} onScroll={updateModelListScrollFades} className={cn( - "scrollbar-gutter-both h-full overflow-x-hidden overscroll-y-contain py-1.5 [--fade-size:1.5rem]", - showTopScrollFade && "mask-t-from-[calc(100%-var(--fade-size))]", - showBottomScrollFade && "mask-b-from-[calc(100%-var(--fade-size))]", + "model-picker-list h-full overflow-x-hidden overscroll-y-contain py-1.5 [--fade-size:1.5rem]", + showTopScrollFade && "model-picker-list-scroll-fade-top", + showBottomScrollFade && "model-picker-list-scroll-fade-bottom", )} /> diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index 36a608888b2..24ec66cd614 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -78,34 +78,20 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { if (!content) { return; } - const selectedButton = Array.from( + const selectedItem = Array.from( content.querySelectorAll("[data-model-picker-provider]"), - ).find((button) => button.dataset.modelPickerProvider === props.selectedInstanceId); - if (!selectedButton) { + ).find((item) => item.dataset.modelPickerProvider === props.selectedInstanceId); + if (!selectedItem) { setSelectedIndicatorTop(null); return; } - const contentRect = content.getBoundingClientRect(); - const selectedButtonRect = selectedButton.getBoundingClientRect(); - setSelectedIndicatorTop( - selectedButtonRect.top - - contentRect.top + - content.scrollTop + - selectedButtonRect.height / 2 - - 10, - ); + setSelectedIndicatorTop(selectedItem.offsetTop + selectedItem.offsetHeight / 2 - 10); }, [props.instanceEntries, props.selectedInstanceId, showFavorites]); return ( -
+
-
+
{selectedIndicatorTop !== null ? (
-
+ <> +
handleSelect("favorites")} type="button" - data-model-picker-provider="favorites" aria-label="Favorites" > @@ -146,7 +131,8 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
-
+