diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a88bc9ce4bb7..a090b55d436e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -109,7 +109,8 @@ import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments" import { useProjects, useThreadShells } from "../state/entities"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; import { vcsEnvironment } from "../state/vcs"; -import { threadEnvironment } from "../state/threads"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { environmentThreadShells, threadEnvironment } from "../state/threads"; import { useEnvironmentQuery } from "../state/query"; import { useAtomCommand } from "../state/use-atom-command"; import { @@ -1022,6 +1023,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ); const handleRenameBlur = useCallback(() => { if (!renameCommittedRef.current) { + // Mark committed so the blur-commit path cannot resubmit if the editor + // is refocused while it waits for the store to catch up. + renameCommittedRef.current = true; onCommitRename(threadRef, renamingTitle, thread.title); } }, [onCommitRename, renamingTitle, thread.title, threadRef]); @@ -2389,29 +2393,39 @@ export default function Sidebar() { const cancelThreadRename = useCallback(() => setRenamingThreadKey(null), []); const commitThreadRename = useCallback( (threadRef: ScopedThreadRef, title: string, originalTitle: string) => { - void (async () => { - const trimmed = title.trim(); + const trimmed = title.trim(); + if (trimmed.length === 0) { setRenamingThreadKey(null); - if (trimmed.length === 0) { - 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.", - }), - ); - } - })(); + toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); + return; + } + if (trimmed === originalTitle) { + setRenamingThreadKey(null); + return; + } + setRenamingThreadKey(null); + environmentThreadShells.setOptimisticThreadTitle( + appAtomRegistry, + threadRef, + trimmed, + originalTitle, + ); + void updateThreadMetadata({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, title: trimmed }, + }).then((result) => { + if (result._tag !== "Failure") return; + environmentThreadShells.clearOptimisticThreadTitle(appAtomRegistry, threadRef, trimmed); + if (isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to rename thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); }, [updateThreadMetadata], ); @@ -3656,6 +3670,7 @@ export default function Sidebar() { // not from the sidebar second-guessing what still matters. const isCard = section === "active" || section === "pinned"; const rowVariant = isCard ? "card" : "slim"; + const rowThread = thread; return ( { - 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; + } + setRenaming(null); + environmentThreadShells.setOptimisticThreadTitle( + appAtomRegistry, + activeThreadRef, + resolution.title, + activeThreadTitle, + ); 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.", - }); - } + if (result._tag !== "Failure") return; + environmentThreadShells.clearOptimisticThreadTitle( + appAtomRegistry, + activeThreadRef, + resolution.title, + ); + if (isAtomCommandInterrupted(result)) return; + 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, + activeThreadRef, + activeThreadTitle, + updateThreadMetadata, + ], ); const { openMenu, closeMenu } = useThreadActionMenu({ threadRef: isServerThread ? activeThreadRef : null, diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 4024fb6b7b7b..151492e33455 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -115,6 +115,7 @@ export function useThreadActionMenu(input: { // what the user is looking at. const thread = readThreadShell(threadRef); if (!thread) return; + const shownTitle = thread.title; const now = new Date(); const supports = { settlement: readEnvironmentSupportsSettlement(threadRef.environmentId), @@ -259,7 +260,7 @@ export function useThreadActionMenu(input: { case "archive": { if (confirmThreadArchive) { const confirmed = await settlePromise(() => - api.dialogs.confirm(`Archive thread "${thread.title}"?`), + api.dialogs.confirm(`Archive thread "${shownTitle}"?`), ); if (confirmed._tag === "Failure" || !confirmed.value) return; } @@ -282,7 +283,7 @@ export function useThreadActionMenu(input: { const confirmed = await settlePromise(() => api.dialogs.confirm( [ - `Delete thread "${thread.title}"?`, + `Delete thread "${shownTitle}"?`, "This permanently clears conversation history for this thread.", ].join("\n"), { variant: "destructive" }, diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index f75be5bc44bb..7e29f85adcf9 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -143,6 +143,10 @@ "types": "./src/state/threadSettled.ts", "default": "./src/state/threadSettled.ts" }, + "./state/threadShell": { + "types": "./src/state/threadShell.ts", + "default": "./src/state/threadShell.ts" + }, "./state/thread-search": { "types": "./src/state/threadSearch.ts", "default": "./src/state/threadSearch.ts" diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index 65cee0427eb0..1f41a45747a5 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -7,7 +7,7 @@ import type { ScopedThreadRef, ThreadId, } from "@t3tools/contracts"; -import { Atom } from "effect/unstable/reactivity"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; import type { EnvironmentThreadShell } from "./models.ts"; import { scopeThreadShell } from "./models.ts"; @@ -29,6 +29,43 @@ const EMPTY_THREAD_REFS_BY_PROJECT: ReadonlyMap< ReadonlyArray > = new Map(); +// Single fridge note for rename optimism. Every shell reader shadows through +// it, so sidebar rows, search, header, and dialogs all show the just-committed +// title without per surface plumbing. Chain tracks every committed title from +// the baseline through the final value so an intermediate store frame (A -> B +// -> C) does not flash B. Entry retires when the store reaches the final +// title or leaves the chain (racing regeneration). +export type OptimisticThreadTitle = { + readonly title: string; + readonly baseline: string; + readonly chain: ReadonlyArray; +}; + +export function nextOptimisticThreadTitles( + current: ReadonlyMap, + key: string, + title: string, + displayedTitle: string, +): ReadonlyMap { + const existing = current.get(key); + const chain = existing ? [...existing.chain, title] : [displayedTitle, title]; + const baseline = chain[0] ?? displayedTitle; + const next = new Map(current); + next.set(key, { title, baseline, chain }); + return next; +} + +export function withoutOptimisticThreadTitle( + current: ReadonlyMap, + key: string, + title: string, +): ReadonlyMap { + if (current.get(key)?.title !== title) return current; + const next = new Map(current); + next.delete(key); + return next; +} + export function createEnvironmentThreadShellAtoms(input: { readonly catalogValueAtom: Atom.Atom; readonly snapshotAtom: ( @@ -52,6 +89,79 @@ export function createEnvironmentThreadShellAtoms(input: { }).pipe(Atom.withLabel(`environment-thread-index:${environmentId}`)), ); + const rawOptimisticTitlesAtom = Atom.make>( + new Map(), + ).pipe(Atom.withLabel("optimistic-thread-titles:raw")); + + // Filter out entries that have fulfilled (store reached final title) or + // diverged (store left the chain via regenerate-title or another client). + // Derived view hides stale entries immediately, and schedules a write-back + // so the raw map is actually pruned. Without the write-back a later store + // title that re-enters the chain (e.g. another client renaming back to the + // baseline) would revive a stale optimistic value. + const filteredOptimisticTitlesAtom = Atom.make( + (get): ReadonlyMap => { + const raw = get(rawOptimisticTitlesAtom); + if (raw.size === 0) return raw; + let pruned: Map | null = null; + for (const [key, entry] of raw) { + const ref = parseThreadKey(key); + const source = get(environmentThreadIndexAtom(ref.environmentId)).get(ref.threadId); + if (source === undefined) { + pruned ??= new Map(raw); + pruned.delete(key); + continue; + } + const chain = entry.chain ?? [entry.baseline, entry.title]; + if (!chain.includes(source.title) || source.title === entry.title) { + pruned ??= new Map(raw); + pruned.delete(key); + } + } + if (pruned !== null) { + const { registry } = get; + const next = pruned; + queueMicrotask(() => registry.set(rawOptimisticTitlesAtom, next)); + return pruned; + } + return raw; + }, + ).pipe(Atom.withLabel("optimistic-thread-titles:filtered")); + + const optimisticTitlesAtom = Atom.writable( + (get) => get(filteredOptimisticTitlesAtom), + (ctx, value: ReadonlyMap) => + ctx.set(rawOptimisticTitlesAtom, value), + ).pipe(Atom.withLabel("optimistic-thread-titles")); + + // Named helpers so call sites do not duplicate registry plumbing. + // Helpers take ScopedThreadRef and derive the atom-family key with + // threadKey() so writer and reader cannot drift (threadKey uses \u0000, + // while scopedThreadKey uses ":"). + const setOptimisticThreadTitle = ( + registry: AtomRegistry.AtomRegistry, + ref: ScopedThreadRef, + title: string, + displayedTitle: string, + ): void => { + const key = threadKey(ref); + const current = registry.get(optimisticTitlesAtom); + registry.set( + optimisticTitlesAtom, + nextOptimisticThreadTitles(current, key, title, displayedTitle), + ); + }; + + const clearOptimisticThreadTitle = ( + registry: AtomRegistry.AtomRegistry, + ref: ScopedThreadRef, + title: string, + ): void => { + const key = threadKey(ref); + const current = registry.get(optimisticTitlesAtom); + registry.set(optimisticTitlesAtom, withoutOptimisticThreadTitle(current, key, title)); + }; + const environmentThreadRefsAtom = Atom.family((environmentId: EnvironmentId) => { let previous: ReadonlyArray = []; return Atom.make((get) => { @@ -103,14 +213,39 @@ export function createEnvironmentThreadShellAtoms(input: { const threadShellAtomFamily = Atom.family((key: string) => { const ref = parseThreadKey(key); let previousSource: OrchestrationThreadShell | null = null; + let previousOptimisticTitle: string | undefined = undefined; + let previousOptimisticBaseline: string | undefined = undefined; + let previousOptimisticChain: ReadonlyArray | undefined = undefined; let previousValue: EnvironmentThreadShell | null = null; return Atom.make((get) => { const source = get(environmentThreadIndexAtom(ref.environmentId)).get(ref.threadId) ?? null; - if (source === previousSource) { + const optimistic = get(optimisticTitlesAtom).get(key); + const chain = optimistic?.chain; + if ( + source === previousSource && + optimistic?.title === previousOptimisticTitle && + optimistic?.baseline === previousOptimisticBaseline && + chain === previousOptimisticChain + ) { return previousValue; } previousSource = source; - previousValue = source === null ? null : scopeThreadShell(ref.environmentId, source); + previousOptimisticTitle = optimistic?.title; + previousOptimisticBaseline = optimistic?.baseline; + previousOptimisticChain = chain; + if (source === null) { + previousValue = null; + } else if (optimistic !== undefined) { + // Filtered view guarantees this entry is still pending or + // is an intermediate of a chained rename (A->B->C while store + // still at A or B). Show the final title. + previousValue = scopeThreadShell(ref.environmentId, { + ...source, + title: optimistic.title, + }); + } else { + previousValue = scopeThreadShell(ref.environmentId, source); + } return previousValue; }).pipe(Atom.withLabel(`environment-thread-shell:${key}`)); }); @@ -182,5 +317,8 @@ export function createEnvironmentThreadShellAtoms(input: { threadShellsForProjectRefsAtom: (refs: ReadonlyArray) => threadShellsForProjectRefsAtomFamily(projectRefCollectionKey(refs)), threadShellAtom: (ref: ScopedThreadRef) => threadShellAtomFamily(threadKey(ref)), + optimisticTitlesAtom, + setOptimisticThreadTitle, + clearOptimisticThreadTitle, }; }