From e9c8f502e7bef01b4cf338aeb951f59e5139298d Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Fri, 21 Aug 2026 20:57:02 +0530 Subject: [PATCH 01/16] fix(web): keep rename editor until the server confirms the new title --- apps/web/src/components/Sidebar.tsx | 39 +++++++++++++-------- apps/web/src/components/chat/ChatHeader.tsx | 31 +++++++++------- 2 files changed, 43 insertions(+), 27 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a88bc9ce4bb7..439e9192655e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2391,25 +2391,34 @@ export default function Sidebar() { (threadRef: ScopedThreadRef, title: string, originalTitle: string) => { void (async () => { const trimmed = title.trim(); - setRenamingThreadKey(null); if (trimmed.length === 0) { + setRenamingThreadKey(null); toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); return; } - if (trimmed === originalTitle) return; - const result = await updateThreadMetadata({ - environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId, title: trimmed }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to rename thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); + if (trimmed === originalTitle) { + setRenamingThreadKey(null); + return; + } + // Keep the row's editor showing the typed title until the server + // confirms; closing early flashes the stale store title. + try { + const result = await updateThreadMetadata({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, title: trimmed }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to rename thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + } finally { + setRenamingThreadKey(null); } })(); }, diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index d032b16a186b..2436fb114b64 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -165,26 +165,33 @@ export const ChatHeader = memo(function ChatHeader({ }, [activeThreadId, activeThreadTitle]); const commitRename = useCallback( (title: string) => { - setRenaming(null); const resolution = resolveRenameCommit({ title, originalTitle: activeThreadTitle }); if (resolution.action === "reject-empty") { + setRenaming(null); toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); return; } - if (resolution.action === "noop") return; + if (resolution.action === "noop") { + setRenaming(null); + return; + } + // Keep the editor showing the typed title until the server confirms; + // closing early flashes the stale store title for the whole round-trip. void updateThreadMetadata({ environmentId: activeThreadEnvironmentId, input: { threadId: activeThreadId, title: resolution.title }, - }).then((result) => { - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add({ - type: "error", - title: "Failed to rename thread", - description: error instanceof Error ? error.message : "An error occurred.", - }); - } - }); + }) + .then((result) => { + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Failed to rename thread", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + }) + .finally(() => setRenaming(null)); }, [activeThreadEnvironmentId, activeThreadId, activeThreadTitle, updateThreadMetadata], ); From 11b94da64e3e2c0604ae765c0e741895a5e7b66b Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Fri, 21 Aug 2026 21:10:22 +0530 Subject: [PATCH 02/16] fix(web): stale rename responses no longer close a newer editor --- apps/web/src/components/Sidebar.tsx | 11 +++++++++-- apps/web/src/components/chat/ChatHeader.tsx | 9 ++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 439e9192655e..1828a784c457 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2382,7 +2382,11 @@ export default function Sidebar() { const [renamingThreadKey, setRenamingThreadKey] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); + // Bumped whenever a new rename session or submission starts, so a settled + // response can tell it is stale and leave a newer editor alone. + const renameEpochRef = useRef(0); const startThreadRename = useCallback((threadRef: ScopedThreadRef, title: string) => { + renameEpochRef.current += 1; setRenamingThreadKey(scopedThreadKey(threadRef)); setRenamingTitle(title); }, []); @@ -2401,7 +2405,10 @@ export default function Sidebar() { return; } // Keep the row's editor showing the typed title until the server - // confirms; closing early flashes the stale store title. + // confirms; closing early flashes the stale store title. Only the + // latest submission may close it, so a slower earlier response can + // never clobber a newer rename. + const epoch = ++renameEpochRef.current; try { const result = await updateThreadMetadata({ environmentId: threadRef.environmentId, @@ -2418,7 +2425,7 @@ export default function Sidebar() { ); } } finally { - setRenamingThreadKey(null); + if (renameEpochRef.current === epoch) setRenamingThreadKey(null); } })(); }, diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 2436fb114b64..a7e01e5b0262 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -159,7 +159,11 @@ export const ChatHeader = memo(function ChatHeader({ } const renamingTitle = renaming?.threadId === activeThreadId ? renaming.title : null; const renameCommittedRef = useRef(false); + // Bumped whenever a new rename session or submission starts, so a settled + // response can tell it is stale and leave a newer editor alone. + const renameEpochRef = useRef(0); const startRename = useCallback(() => { + renameEpochRef.current += 1; renameCommittedRef.current = false; setRenaming({ threadId: activeThreadId, title: activeThreadTitle }); }, [activeThreadId, activeThreadTitle]); @@ -177,6 +181,7 @@ export const ChatHeader = memo(function ChatHeader({ } // Keep the editor showing the typed title until the server confirms; // closing early flashes the stale store title for the whole round-trip. + const epoch = ++renameEpochRef.current; void updateThreadMetadata({ environmentId: activeThreadEnvironmentId, input: { threadId: activeThreadId, title: resolution.title }, @@ -191,7 +196,9 @@ export const ChatHeader = memo(function ChatHeader({ }); } }) - .finally(() => setRenaming(null)); + .finally(() => { + if (renameEpochRef.current === epoch) setRenaming(null); + }); }, [activeThreadEnvironmentId, activeThreadId, activeThreadTitle, updateThreadMetadata], ); From fec63dca3423cbc7df42b0b76ea520266d725bb8 Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Fri, 21 Aug 2026 21:35:54 +0530 Subject: [PATCH 03/16] fix(web): show renamed title optimistically instead of holding the editor open The rename response resolves before clients receive the updated title (the shell stream coalesces on the server), so closing on the response still flashed the old title for a beat. Close the editor immediately and render the committed title until the store catches up; revert it if the server rejects the rename. --- apps/web/src/components/Sidebar.tsx | 115 +++++++++++++------- apps/web/src/components/chat/ChatHeader.tsx | 80 ++++++++------ 2 files changed, 125 insertions(+), 70 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 1828a784c457..e141e343867f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2382,55 +2382,84 @@ export default function Sidebar() { const [renamingThreadKey, setRenamingThreadKey] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); - // Bumped whenever a new rename session or submission starts, so a settled - // response can tell it is stale and leave a newer editor alone. - const renameEpochRef = useRef(0); + // Titles committed to the server but not yet reflected by the thread shells + // (the store learns about renames via a coalesced server-side stream). + // Shown in place of the stored title so confirming a rename never flashes + // the old one; dropped once the store catches up, or reverted if the + // server rejected the rename. + const [optimisticTitles, setOptimisticTitles] = useState>( + () => new Map(), + ); const startThreadRename = useCallback((threadRef: ScopedThreadRef, title: string) => { - renameEpochRef.current += 1; setRenamingThreadKey(scopedThreadKey(threadRef)); setRenamingTitle(title); }, []); const cancelThreadRename = useCallback(() => setRenamingThreadKey(null), []); const commitThreadRename = useCallback( (threadRef: ScopedThreadRef, title: string, originalTitle: string) => { - void (async () => { - const trimmed = title.trim(); - if (trimmed.length === 0) { - setRenamingThreadKey(null); - toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); - return; - } - if (trimmed === originalTitle) { - setRenamingThreadKey(null); - return; - } - // Keep the row's editor showing the typed title until the server - // confirms; closing early flashes the stale store title. Only the - // latest submission may close it, so a slower earlier response can - // never clobber a newer rename. - const epoch = ++renameEpochRef.current; - try { - const result = await updateThreadMetadata({ - environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId, title: trimmed }, + const trimmed = title.trim(); + if (trimmed.length === 0) { + setRenamingThreadKey(null); + toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); + return; + } + if (trimmed === originalTitle) { + setRenamingThreadKey(null); + return; + } + const threadKey = scopedThreadKey(threadRef); + // Close immediately and show the committed title optimistically: + // waiting for the store would flash the old title for a beat. A + // rejection reverts to the stored title. + setRenamingThreadKey(null); + setOptimisticTitles((current) => new Map(current).set(threadKey, trimmed)); + void updateThreadMetadata({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, title: trimmed }, + }).then((result) => { + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + setOptimisticTitles((current) => { + if (current.get(threadKey) !== trimmed) return current; + const next = new Map(current); + next.delete(threadKey); + return next; }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to rename thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - } finally { - if (renameEpochRef.current === epoch) setRenamingThreadKey(null); + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to rename thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); } - })(); + }); }, [updateThreadMetadata], ); + // Stored title for every known thread, used to retire optimistic rename + // titles once the coalesced stream catches up. + const threadTitlesByKey = useMemo(() => { + const titles = new Map(); + for (const thread of threads) { + titles.set(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread.title); + } + return titles; + }, [threads]); + useEffect(() => { + if (optimisticTitles.size === 0) return; + setOptimisticTitles((current) => { + let next: Map | null = null; + for (const [key, title] of current) { + const storedTitle = threadTitlesByKey.get(key); + if (storedTitle === undefined || storedTitle === title) { + next ??= new Map(current); + next.delete(key); + } + } + return next ?? current; + }); + }, [optimisticTitles, threadTitlesByKey]); const handleThreadClick = useCallback( (event: ReactMouseEvent, threadRef: ScopedThreadRef) => { @@ -3144,7 +3173,7 @@ export default function Sidebar() { attemptUnpin(threadRef); return; case "rename": - startThreadRename(threadRef, thread.title); + startThreadRename(threadRef, optimisticTitles.get(threadKey) ?? thread.title); return; case "regenerate-title": { if (isRegeneratingTitle) return; @@ -3264,6 +3293,7 @@ export default function Sidebar() { deleteThread, handleMultiSelectContextMenu, markThreadUnread, + optimisticTitles, projectCwdByKey, serverConfigs, startThreadRename, @@ -3672,6 +3702,13 @@ export default function Sidebar() { // not from the sidebar second-guessing what still matters. const isCard = section === "active" || section === "pinned"; const rowVariant = isCard ? "card" : "slim"; + // Optimistic renames shadow the stored title until the + // coalesced stream catches up (see optimisticTitles). + const optimisticTitle = optimisticTitles.get(threadKey); + const rowThread = + optimisticTitle === undefined || optimisticTitle === thread.title + ? thread + : { ...thread, title: optimisticTitle }; return ( ( + null, + ); + useEffect(() => { + if (pendingTitle === null) return; + if (pendingTitle.threadId !== activeThreadId || activeThreadTitle === pendingTitle.title) { + setPendingTitle(null); + } + }, [pendingTitle, activeThreadId, activeThreadTitle]); + const displayTitle = + pendingTitle?.threadId === activeThreadId ? pendingTitle.title : activeThreadTitle; const startRename = useCallback(() => { - renameEpochRef.current += 1; renameCommittedRef.current = false; - setRenaming({ threadId: activeThreadId, title: activeThreadTitle }); - }, [activeThreadId, activeThreadTitle]); + setRenaming({ threadId: activeThreadId, title: displayTitle }); + }, [activeThreadId, displayTitle]); const commitRename = useCallback( (title: string) => { - const resolution = resolveRenameCommit({ title, originalTitle: activeThreadTitle }); + const resolution = resolveRenameCommit({ title, originalTitle: displayTitle }); if (resolution.action === "reject-empty") { setRenaming(null); toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); @@ -179,28 +192,33 @@ export const ChatHeader = memo(function ChatHeader({ setRenaming(null); return; } - // Keep the editor showing the typed title until the server confirms; - // closing early flashes the stale store title for the whole round-trip. - const epoch = ++renameEpochRef.current; + // Close immediately and show the committed title optimistically: the + // store title arrives via a coalesced server stream, so waiting for it + // would flash the old title for a beat. A rejection reverts to it. + setRenaming(null); + setPendingTitle({ threadId: activeThreadId, title: resolution.title }); void updateThreadMetadata({ environmentId: activeThreadEnvironmentId, input: { threadId: activeThreadId, title: resolution.title }, - }) - .then((result) => { - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add({ - type: "error", - title: "Failed to rename thread", - description: error instanceof Error ? error.message : "An error occurred.", - }); - } - }) - .finally(() => { - if (renameEpochRef.current === epoch) setRenaming(null); - }); + }).then((result) => { + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + setPendingTitle((current) => + current !== null && + current.threadId === activeThreadId && + current.title === resolution.title + ? null + : current, + ); + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Failed to rename thread", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + }); }, - [activeThreadEnvironmentId, activeThreadId, activeThreadTitle, updateThreadMetadata], + [activeThreadEnvironmentId, activeThreadId, displayTitle, updateThreadMetadata], ); const { openMenu } = useThreadActionMenu({ threadRef: isServerThread ? activeThreadRef : null, @@ -296,31 +314,31 @@ export const ChatHeader = memo(function ChatHeader({