Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e9c8f50
fix(web): keep rename editor until the server confirms the new title
UtkarshUsername Aug 21, 2026
11b94da
fix(web): stale rename responses no longer close a newer editor
UtkarshUsername Aug 21, 2026
fec63dc
fix(web): show renamed title optimistically instead of holding the ed…
UtkarshUsername Aug 21, 2026
95217ae
fix(web): retire optimistic rename titles when any newer title lands
UtkarshUsername Aug 21, 2026
7de3548
fix(web): keep optimistic rename titles up while the store walks the …
UtkarshUsername Aug 21, 2026
ee675b9
fix(web): absorb rename-back cycles and stale dialog titles
UtkarshUsername Aug 21, 2026
0ef381e
refactor(web): hold the rename editor until the store catches up
UtkarshUsername Aug 21, 2026
ec44a92
fix(web): interrupted renames close the editor and blur cannot resubmit
UtkarshUsername Aug 21, 2026
2e99cc9
refactor(web): close the rename editor on confirm and shadow the titl…
UtkarshUsername Aug 21, 2026
656a84e
fix(web): sidebar search and quick re-renames read the newest title
UtkarshUsername Aug 21, 2026
f12fbc0
fix(web): header confirm dialogs name the shown title
UtkarshUsername Aug 21, 2026
ff8eb75
Merge branch 'main' into fix/rename-flash-old-title
UtkarshUsername Aug 21, 2026
7e68265
refactor: centralize optimistic title in threadShell
UtkarshUsername Aug 21, 2026
390ef55
fix(web): prune optimistic title after fulfillment and support chaine…
UtkarshUsername Aug 21, 2026
093c1a9
fix(web): prune optimistic title from backing store and dedup registr…
UtkarshUsername Aug 21, 2026
77c5150
fix(web): use threadKey for optimistic titles
UtkarshUsername Aug 21, 2026
811ff68
chore(client-runtime): use AtomRegistry type for optimistic title hel…
UtkarshUsername Aug 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 39 additions & 24 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renaming back to the still-stored title inside the coalescing window retires the entry immediately, so the intermediate title still flashes.

Stored A, rename to B → entry {title:"B", chain:[A,B]}, row shows B. Rename back to A before B's shell frame lands: the row passes its displayed title (B) as originalTitle, so nextOptimisticThreadTitles produces {title:"A", chain:[A,B,A]} — but the filtered view (packages/client-runtime/src/state/threadShell.ts:116) prunes on source.title === entry.title, and the store is still at A. The entry dies on the next read, and when B's frame arrives every shell reader (row, header, search, menu confirmations) renders B until A's frame lands.

The retire rule can't distinguish "store hasn't moved yet" from "store reached the final title". Smallest fix is to make fulfillment depend on the rename actually having settled — e.g. carry a pending-commit count on the entry, incremented here and decremented when updateThreadMetadata settles (success as well as failure), and only apply the source.title === entry.title prune when that count is zero. ChatHeader.tsx:191 goes through the same helper, so one change covers both surfaces.

Posted via Macroscope — UI Consistency

appAtomRegistry,
threadRef,
trimmed,
originalTitle,
);
void updateThreadMetadata({
environmentId: threadRef.environmentId,
input: { threadId: threadRef.threadId, title: trimmed },
}).then((result) => {
if (result._tag !== "Failure") return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Success returns without touching the map, so the entry survives forever and its baseline freezes at the first pre-rename title — every later rename of the same thread in the session gets no optimism at all.

Stored A, rename → B: entry {title:"B", baseline:"A"}, row shadows to B, B lands, entry stays. Rename BC: nextOptimisticThreadTitles inherits current.get(key).baseline = "A" (threadShell.ts:44), so the entry is {title:"C", baseline:"A"} while the store reads "B"; the shadow condition source.title === optimistic.baseline (threadShell.ts:157) is false, the row keeps rendering B until the coalesced shell frame arrives — the flash this PR removes. The dead entry is also a latent wrong title: if the stored title ever returns to "A" (rename from another client, regenerate, undo), the shadow re-activates and pins "C" indefinitely, and the map grows one permanent entry per renamed thread.

Smallest fix is to baseline on the raw stored title rather than the displayed one (e.g. read environmentThreadShells.environmentThreadIndexAtom(threadRef.environmentId) for the unshadowed title), or prune the entry once the store has moved off baseline so the next rename starts clean. Same call shape in ChatHeader.tsx.

Posted via Macroscope — UI Consistency

Comment thread
macroscopeapp[bot] marked this conversation as resolved.
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],
);
Expand Down Expand Up @@ -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 (
<SidebarThreadRow
// Keyed per variant on purpose: when a thread settles,
Expand All @@ -3665,7 +3680,7 @@ export default function Sidebar() {
// are translucent, so a crossing row reads as text
// painted over text).
key={`${threadKey}:${rowVariant}`}
thread={thread}
thread={rowThread}
variant={rowVariant}
// Snoozed rows wake; settled rows un-settle (explicit
// settles clear the override, auto-settled rows get
Expand Down
47 changes: 35 additions & 12 deletions apps/web/src/components/chat/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";

import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled";
import { ChevronDownIcon } from "lucide-react";
import {
Expand All @@ -36,7 +37,8 @@ import { useRemoteOpenState, type RemoteOpenMode } from "../../remoteOpen";
import { usePrimaryEnvironmentId } from "../../state/environments";
import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts";
import { useThreadActionMenu } from "~/hooks/useThreadActionMenu";
import { threadEnvironment } from "../../state/threads";
import { appAtomRegistry } from "../../rpc/atomRegistry";
import { environmentThreadShells, threadEnvironment } from "../../state/threads";
import { useAtomCommand } from "../../state/use-atom-command";
import { ProjectFavicon } from "../ProjectFavicon";
import {
Expand Down Expand Up @@ -175,28 +177,49 @@ 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;
}
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;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
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,
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/hooks/useThreadActionMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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;
}
Expand All @@ -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" },
Expand Down
4 changes: 4 additions & 0 deletions packages/client-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
144 changes: 141 additions & 3 deletions packages/client-runtime/src/state/threadShell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -29,6 +29,43 @@ const EMPTY_THREAD_REFS_BY_PROJECT: ReadonlyMap<
ReadonlyArray<ScopedThreadRef>
> = 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<string>;
};

export function nextOptimisticThreadTitles(
current: ReadonlyMap<string, OptimisticThreadTitle>,
key: string,
title: string,
displayedTitle: string,
): ReadonlyMap<string, OptimisticThreadTitle> {
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<string, OptimisticThreadTitle>,
key: string,
title: string,
): ReadonlyMap<string, OptimisticThreadTitle> {
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<EnvironmentCatalogState>;
readonly snapshotAtom: (
Expand All @@ -52,6 +89,79 @@ export function createEnvironmentThreadShellAtoms(input: {
}).pipe(Atom.withLabel(`environment-thread-index:${environmentId}`)),
);

const rawOptimisticTitlesAtom = Atom.make<ReadonlyMap<string, OptimisticThreadTitle>>(
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<string, OptimisticThreadTitle> => {
const raw = get(rawOptimisticTitlesAtom);
if (raw.size === 0) return raw;
let pruned: Map<string, OptimisticThreadTitle> | 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<string, OptimisticThreadTitle>) =>
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<ScopedThreadRef> = [];
return Atom.make((get) => {
Expand Down Expand Up @@ -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<string> | 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}`));
});
Expand Down Expand Up @@ -182,5 +317,8 @@ export function createEnvironmentThreadShellAtoms(input: {
threadShellsForProjectRefsAtom: (refs: ReadonlyArray<ScopedProjectRef>) =>
threadShellsForProjectRefsAtomFamily(projectRefCollectionKey(refs)),
threadShellAtom: (ref: ScopedThreadRef) => threadShellAtomFamily(threadKey(ref)),
optimisticTitlesAtom,
setOptimisticThreadTitle,
clearOptimisticThreadTitle,
};
}
Loading