From fa74aa8386a259a6d5df4a9284257527874e73a4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 14 Sep 2026 16:52:31 -0700 Subject: [PATCH 1/2] feat(web): add the thread action menu and inline rename to the chat header --- apps/web/src/components/ChatView.tsx | 3 + .../src/components/chat/ChatHeader.test.ts | 23 ++ apps/web/src/components/chat/ChatHeader.tsx | 328 +++++++++++++--- apps/web/src/hooks/useThreadActionMenu.ts | 364 ++++++++++++++++++ apps/web/src/state/entities.ts | 7 + 5 files changed, 677 insertions(+), 48 deletions(-) create mode 100644 apps/web/src/components/chat/ChatHeader.test.ts create mode 100644 apps/web/src/hooks/useThreadActionMenu.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 35e73730c6fb..fe57dbe6643b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -9755,6 +9755,9 @@ export default function ChatView(props: ChatViewProps) { {!rightPanelControlsAtRoot && !rightPanelControlsInPanel ? panelLayoutControls : null} {inlineRightPanelOwnsTitleBar ? threadPanelHeaderControl : null} { + it("commits a trimmed changed title", () => { + expect(resolveRenameCommit({ title: " New title ", originalTitle: "Old" })).toEqual({ + action: "commit", + title: "New title", + }); + }); + + it("rejects empty and whitespace-only titles", () => { + expect(resolveRenameCommit({ title: " ", originalTitle: "Old" })).toEqual({ + action: "reject-empty", + }); + }); + + it("no-ops when the trimmed title is unchanged", () => { + expect(resolveRenameCommit({ title: " Old ", originalTitle: "Old" })).toEqual({ + action: "noop", + }); + }); +}); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 621970d9afc1..90b5eb40c928 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -1,91 +1,323 @@ +import { type EnvironmentId, type ThreadId } from "@t3tools/contracts"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; -import { memo } from "react"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { ChevronDownIcon } from "lucide-react"; +import { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, +} from "react"; +import { isTrailingDoubleClick } from "../Sidebar.logic"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { toastManager } from "../ui/toast"; +import { useThreadActionMenu } from "~/hooks/useThreadActionMenu"; import { readLocalApi } from "~/localApi"; - -import { cn } from "~/lib/utils"; +import { threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; import { ProjectFavicon } from "../ProjectFavicon"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + WorkspaceBreadcrumb, + WorkspaceBreadcrumbItem, + WorkspaceBreadcrumbSeparator, +} from "../WorkspaceBreadcrumb"; +import { cn } from "~/lib/utils"; interface ChatHeaderProps { + activeThreadEnvironmentId: EnvironmentId; + activeThreadId: ThreadId; activeThreadTitle: string; + /** Drafts have no server thread yet, so the title carries no action menu. */ + isServerThread: boolean; activeProject: EnvironmentProject | null; rightPanelOpen: boolean; onNewThreadInProject: () => void; onOpenProjectSettings?: (() => void) | undefined; } +/** + * Rename commit rule shared with the sidebar's inline rename: trim, reject + * empty (the caller toasts), and skip the mutation when nothing changed. + */ +export function resolveRenameCommit(input: { + readonly title: string; + readonly originalTitle: string; +}): { action: "commit"; title: string } | { action: "reject-empty" } | { action: "noop" } { + const trimmed = input.title.trim(); + if (trimmed.length === 0) return { action: "reject-empty" }; + if (trimmed === input.originalTitle) return { action: "noop" }; + return { action: "commit", title: trimmed }; +} + +// How long a click on the thread title waits before opening the action menu, +// so a double-click-to-rename can cancel it first. Only the native desktop +// menu needs this: it swallows input while open, so the wait must cover the +// OS double-click interval. The browser fallback menu keeps seeing DOM +// events (the second click dismisses it and dblclick still fires), so it +// opens immediately. +const TITLE_MENU_OPEN_DELAY_MS = 500; export const ChatHeader = memo(function ChatHeader({ + activeThreadEnvironmentId, + activeThreadId, activeThreadTitle, + isServerThread, activeProject, rightPanelOpen, onNewThreadInProject, onOpenProjectSettings, }: ChatHeaderProps) { + const activeProjectName = activeProject?.title; + const activeProjectCwd = activeProject?.workspaceRoot ?? null; + const activeThreadRef = useMemo( + () => scopeThreadRef(activeThreadEnvironmentId, activeThreadId), + [activeThreadEnvironmentId, activeThreadId], + ); + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); + // Inline rename, keyed by thread: navigating away drops an in-progress + // rename instead of committing stale text. Cleared on thread change (not + // just hidden) so returning to the thread doesn't revive the old draft. + const [renaming, setRenaming] = useState<{ + threadId: ThreadId; + environmentId: EnvironmentId; + title: string; + } | null>(null); + if ( + renaming !== null && + (renaming.threadId !== activeThreadId || renaming.environmentId !== activeThreadEnvironmentId) + ) { + setRenaming(null); + } + const renamingTitle = renaming?.threadId === activeThreadId ? renaming.title : null; + const renameCommittedRef = useRef(false); + const startRename = useCallback(() => { + renameCommittedRef.current = false; + setRenaming({ + threadId: activeThreadId, + environmentId: activeThreadEnvironmentId, + title: activeThreadTitle, + }); + }, [activeThreadEnvironmentId, activeThreadId, activeThreadTitle]); + const commitRename = useCallback( + (title: string) => { + setRenaming(null); + const resolution = resolveRenameCommit({ title, originalTitle: activeThreadTitle }); + if (resolution.action === "reject-empty") { + toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); + return; + } + if (resolution.action === "noop") return; + 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.", + }); + } + }); + }, + [activeThreadEnvironmentId, activeThreadId, activeThreadTitle, updateThreadMetadata], + ); + const { openMenu, closeMenu } = useThreadActionMenu({ + threadRef: isServerThread ? activeThreadRef : null, + projectCwd: activeProjectCwd, + onStartRename: startRename, + }); + const titleButtonRef = useRef(null); + const titleMenuTimerRef = useRef(null); + const cancelPendingTitleMenu = useCallback(() => { + if (titleMenuTimerRef.current === null) return; + clearTimeout(titleMenuTimerRef.current); + titleMenuTimerRef.current = null; + }, []); + // Drop a pending menu-open when the thread changes or the header unmounts, + // so it can never fire for a thread the user already left. + useEffect( + () => () => { + cancelPendingTitleMenu(); + }, + [activeThreadEnvironmentId, activeThreadId, cancelPendingTitleMenu], + ); + const openTitleMenuNow = useCallback(() => { + cancelPendingTitleMenu(); + const rect = titleButtonRef.current?.getBoundingClientRect(); + if (!rect) return; + openMenu({ x: rect.left, y: rect.bottom + 4 }); + }, [cancelPendingTitleMenu, openMenu]); + const openMenuFromTitle = useCallback( + (event: ReactMouseEvent) => { + // The trailing click of a double-click belongs to rename, not the menu. + if (isTrailingDoubleClick(event.detail)) return; + // Keyboard activation and the explicit chevron affordance can never be + // the first half of a double-click, so they open without waiting. + const clickedChevron = + (event.target as HTMLElement).closest("[data-thread-title-chevron]") !== null; + if (event.detail === 0 || clickedChevron || window.desktopBridge === undefined) { + openTitleMenuNow(); + return; + } + // Stay pending long enough for dblclick to cancel the open before the + // native menu appears and swallows the second click. + cancelPendingTitleMenu(); + titleMenuTimerRef.current = window.setTimeout(() => { + titleMenuTimerRef.current = null; + openTitleMenuNow(); + }, TITLE_MENU_OPEN_DELAY_MS); + }, + [cancelPendingTitleMenu, openTitleMenuNow], + ); + const handleTitleDoubleClick = useCallback( + (event: ReactMouseEvent) => { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + // The chevron is the explicit menu affordance; only the title text renames. + if ((event.target as HTMLElement).closest("[data-thread-title-chevron]") !== null) return; + cancelPendingTitleMenu(); + closeMenu(); + startRename(); + }, + [cancelPendingTitleMenu, closeMenu, startRename], + ); + const handleHeaderContextMenu = useCallback( + (event: ReactMouseEvent) => { + if (renamingTitle !== null) return; + if (!isServerThread && onOpenProjectSettings === undefined) return; + cancelPendingTitleMenu(); + event.preventDefault(); + if (!isServerThread) { + const api = readLocalApi(); + if (!api) return; + void api.contextMenu + .show([{ id: "project-settings", label: "Project settings", icon: "settings" }], { + x: event.clientX, + y: event.clientY, + }) + .then((action) => { + if (action === "project-settings") onOpenProjectSettings?.(); + }); + return; + } + openMenu({ x: event.clientX, y: event.clientY }); + }, + [cancelPendingTitleMenu, isServerThread, onOpenProjectSettings, openMenu, renamingTitle], + ); + const handleRenameKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + if (event.key === "Enter") { + renameCommittedRef.current = true; + commitRename(event.currentTarget.value); + } else if (event.key === "Escape") { + renameCommittedRef.current = true; + setRenaming(null); + } + }, + [commitRename], + ); return (
{ - event.preventDefault(); - const api = readLocalApi(); - if (!api) return; - void api.contextMenu - .show([{ id: "project-settings", label: "Project settings", icon: "settings" }], { - x: event.clientX, - y: event.clientY, - }) - .then((action) => { - if (action === "project-settings") onOpenProjectSettings(); - }); - } - } className={cn( "flex min-w-0 flex-1 items-center gap-2 sm:gap-3", rightPanelOpen ? "pr-10" : "pr-24", )} + onContextMenu={handleHeaderContextMenu} > -
+ {/* The project always leads the header: knowing which project a thread lives in is priority zero, and the thread title alone doesn't answer it. */} {activeProject ? ( - + <> + + + + } + > + + {activeProjectName} + + New thread in {activeProjectName} + + + + + ) : null} + + {renamingTitle !== null ? ( + { + if (renameCommittedRef.current) return; + commitRename(event.currentTarget.value); + }} + onFocus={(event) => event.currentTarget.select()} + onKeyDown={handleRenameKeyDown} + /> + ) : isServerThread ? ( } > - - {activeProject.title} +

{activeThreadTitle}

+
- New thread in {activeProject.title} + {activeThreadTitle}
- - / - -
- ) : null} - - - {activeThreadTitle} - - } - /> - {activeThreadTitle} - -
+ ) : ( + + + {activeThreadTitle} + + } + /> + {activeThreadTitle} + + )} + +
); }); diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts new file mode 100644 index 000000000000..b35f8c2de610 --- /dev/null +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -0,0 +1,364 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { + type AtomCommandResult, + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { useRouter } from "@tanstack/react-router"; +import { useCallback, useMemo } from "react"; + +import { resolveSnoozePresets, snoozeWakeDescription } from "../components/Sidebar.snooze"; +import { + buildThreadActionMenuItems, + type ThreadActionMenuId, +} from "../components/threadActionMenu.logic"; +import { stackedThreadToast, toastManager } from "../components/ui/toast"; +import { threadEnvironment } from "../state/threads"; +import { useAtomCommand } from "../state/use-atom-command"; +import { + readEnvironmentSupportsPinning, + readEnvironmentSupportsSettlement, + readEnvironmentSupportsSnooze, + readEnvironmentSupportsTitleRegeneration, + readThreadShell, + useProjects, +} from "../state/entities"; +import { usePrimaryEnvironmentId } from "../state/environments"; +import { readLocalApi } from "../localApi"; +import { + deriveLogicalProjectKeyFromSettings, + derivePhysicalProjectKey, + selectProjectGroupingSettings, +} from "../logicalProject"; +import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; +import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models"; +import { useCopyToClipboard } from "./useCopyToClipboard"; +import { useNewThreadHandler } from "./useHandleNewThread"; +import { useClientSettings } from "./useSettings"; +import { useThreadActions } from "./useThreadActions"; + +function failureToast(title: string, error: unknown) { + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); +} + +/** + * The per-thread action menu (pin, settle, snooze, rename, copy, delete…) as + * a self-contained hook, for surfaces other than the sidebar row — today the + * chat header. Renders through the native context-menu bridge and dispatches + * through the same mutations the sidebar uses. + * + * Unlike the sidebar, settle and snooze here never navigate away: the caller + * is acting on the thread they are reading, and ChatView's parked-thread + * banner already offers the way back. + */ +export function useThreadActionMenu(input: { + readonly threadRef: ScopedThreadRef | null; + /** Fallback for "Copy path" when the thread has no worktree. */ + readonly projectCwd: string | null; + readonly onStartRename: () => void; +}) { + const { threadRef, projectCwd, onStartRename } = input; + const router = useRouter(); + const projects = useProjects(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const logicalProjectKeyByPhysicalKey = useMemo( + () => + buildPhysicalToLogicalProjectKeyMap({ + projects, + settings: projectGroupingSettings, + primaryEnvironmentId, + }), + [primaryEnvironmentId, projectGroupingSettings, projects], + ); + const { + settleThread, + unsettleThread, + snoozeThread, + unsnoozeThread, + pinThread, + confirmAndUnpinThread, + archiveThread, + deleteThread, + markThreadUnread, + } = useThreadActions(); + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); + const handleNewThread = useNewThreadHandler(); + const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); + const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); + const timestampFormat = useClientSettings((s) => s.timestampFormat); + const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ + onCopy: ({ path }) => { + toastManager.add({ type: "success", title: "Path copied", description: path }); + }, + onError: (error) => failureToast("Failed to copy path", error), + }); + const { copyToClipboard: copyBranchToClipboard } = useCopyToClipboard<{ branch: string }>({ + target: "branch name", + onCopy: ({ branch }) => { + toastManager.add({ type: "success", title: "Branch copied", description: branch }); + }, + onError: (error) => failureToast("Failed to copy branch", error), + }); + const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ threadId: ThreadId }>({ + onCopy: ({ threadId }) => { + toastManager.add({ type: "success", title: "Thread ID copied", description: threadId }); + }, + onError: (error) => failureToast("Failed to copy thread ID", error), + }); + + const openMenu = useCallback( + (position: { x: number; y: number }) => { + if (threadRef === null) return; + void (async () => { + const api = readLocalApi(); + if (!api) return; + // Snapshot at open time — the menu is modal, so state read now is + // what the user is looking at. + const thread = readThreadShell(threadRef); + if (!thread) return; + const now = new Date(); + const supports = { + settlement: readEnvironmentSupportsSettlement(threadRef.environmentId), + snooze: readEnvironmentSupportsSnooze(threadRef.environmentId), + pinning: readEnvironmentSupportsPinning(threadRef.environmentId), + titleRegeneration: readEnvironmentSupportsTitleRegeneration(threadRef.environmentId), + }; + const isRegeneratingTitle = thread.titleRegeneration != null; + const snoozePresets = resolveSnoozePresets(now, timestampFormat); + const items = buildThreadActionMenuItems({ + branch: thread.branch ?? null, + isPinned: thread.pinnedAt != null, + isSettled: supports.settlement && thread.settledOverride === "settled", + isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), + canSnoozeNow: canSnooze(thread, { now: now.toISOString() }), + isRegeneratingTitle, + isRunning: !threadRuntimeCanArchive(thread.runtime), + supports, + snoozePresets, + }); + const clicked = await settlePromise(() => api.contextMenu.show(items, position)); + if (clicked._tag === "Failure" || clicked.value === null) return; + const action: ThreadActionMenuId = clicked.value; + if (action.startsWith("snooze:")) { + const preset = snoozePresets.find((candidate) => `snooze:${candidate.id}` === action); + if (!preset) return; + const result = await snoozeThread(threadRef, preset.snoozedUntil); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + failureToast("Failed to snooze thread", squashAtomCommandFailure(result)); + } + return; + } + toastManager.add( + stackedThreadToast({ + type: "success", + title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`, + timeout: 5_000, + actionProps: { + children: "Undo", + onClick: () => { + void unsnoozeThread(threadRef).then((undone) => { + if (undone._tag === "Failure" && !isAtomCommandInterrupted(undone)) { + failureToast("Failed to wake thread", squashAtomCommandFailure(undone)); + } + }); + }, + }, + }), + ); + return; + } + const reportFailure = async ( + title: string, + run: () => Promise>, + ) => { + const result = await run(); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + failureToast(title, squashAtomCommandFailure(result)); + } + }; + switch (action) { + case "project-settings": { + const project = projects.find( + (candidate) => + candidate.environmentId === thread.environmentId && + candidate.id === thread.projectId, + ); + if (!project) return; + const projectKey = + logicalProjectKeyByPhysicalKey.get(derivePhysicalProjectKey(project)) ?? + deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings); + void router.navigate({ + to: "/projects/$projectKey", + params: { projectKey }, + }); + return; + } + case "new-thread-on-branch": { + // Explicit branch carry-over: reuse the thread's worktree when it + // has one, otherwise its branch on the local checkout. + const result = await settlePromise(() => + handleNewThread(scopeProjectRef(threadRef.environmentId, thread.projectId), { + branch: thread.branch, + worktreePath: thread.worktreePath, + envMode: thread.worktreePath ? "worktree" : "local", + startFromOrigin: false, + }), + ); + if (result._tag === "Failure") { + failureToast("Could not create thread", squashAtomCommandFailure(result)); + } + return; + } + case "settle": + await reportFailure("Failed to settle thread", () => settleThread(threadRef)); + return; + case "unsettle": + await reportFailure("Failed to un-settle thread", () => unsettleThread(threadRef)); + return; + case "unsnooze": + await reportFailure("Failed to wake thread", () => unsnoozeThread(threadRef)); + return; + case "pin": + await reportFailure("Failed to pin thread", () => pinThread(threadRef)); + return; + case "unpin": { + await reportFailure("Failed to unpin thread", () => confirmAndUnpinThread(threadRef)); + return; + } + case "rename": + onStartRename(); + return; + case "regenerate-title": + if (isRegeneratingTitle) return; + await reportFailure("Failed to regenerate thread title", () => + updateThreadMetadata({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, regenerateTitle: true }, + }), + ); + return; + case "mark-unread": + markThreadUnread(threadRef); + return; + case "copy-path": { + const workspacePath = thread.worktreePath ?? projectCwd; + if (!workspacePath) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Path unavailable", + description: "This thread does not have a workspace path to copy.", + }), + ); + return; + } + copyPathToClipboard(workspacePath, { path: workspacePath }); + return; + } + case "copy-branch": + if (thread.branch) { + copyBranchToClipboard(thread.branch, { branch: thread.branch }); + } + return; + case "copy-thread-id": + copyThreadIdToClipboard(thread.id, { threadId: thread.id }); + return; + case "archive": { + if (confirmThreadArchive) { + const confirmed = await settlePromise(() => + api.dialogs.confirm(`Archive thread "${thread.title}"?`), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + let didArchive = false; + const result = await archiveThread(threadRef, { + onArchived: () => { + didArchive = true; + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + failureToast( + didArchive ? "Thread archived, but navigation failed" : "Failed to archive thread", + squashAtomCommandFailure(result), + ); + } + return; + } + case "delete": { + if (confirmThreadDelete) { + const confirmed = await settlePromise(() => + api.dialogs.confirm( + [ + `Delete thread "${thread.title}"?`, + "This permanently clears conversation history for this thread.", + ].join("\n"), + { variant: "destructive" }, + ), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + const deleted = await deleteThread(threadRef); + if ( + deleted._tag === "Failure" && + !isAtomCommandInterrupted(deleted) && + // A failure with the thread already gone is worktree cleanup + // failing after a successful delete — deleteThread has toasted + // that itself, and "Failed to delete thread" would be a lie. + readThreadShell(threadRef) !== null + ) { + failureToast("Failed to delete thread", squashAtomCommandFailure(deleted)); + } + return; + } + default: + return; + } + })(); + }, + [ + archiveThread, + confirmThreadArchive, + confirmThreadDelete, + confirmAndUnpinThread, + copyBranchToClipboard, + copyPathToClipboard, + copyThreadIdToClipboard, + deleteThread, + handleNewThread, + logicalProjectKeyByPhysicalKey, + markThreadUnread, + onStartRename, + pinThread, + projectCwd, + projectGroupingSettings, + projects, + router, + settleThread, + snoozeThread, + threadRef, + timestampFormat, + unsettleThread, + unsnoozeThread, + updateThreadMetadata, + ], + ); + + const closeMenu = useCallback(() => { + void readLocalApi()?.contextMenu.close(); + }, []); + + return { openMenu, closeMenu }; +} diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index c6de859dcfab..ec6bb06b8631 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -194,6 +194,13 @@ export function waitForThreadShell(ref: ScopedThreadRef, timeoutMs = 5_000): Pro }); } +export function readEnvironmentSupportsTitleRegeneration(environmentId: EnvironmentId): boolean { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadTitleRegeneration === true + ); +} + /** Whether the environment's server understands thread.pin/unpin. Same version-skew contract as settlement. */ export function readEnvironmentSupportsPinning(environmentId: EnvironmentId): boolean { From cf0fefcdbdfc8d91251559130d4d7d97412de88b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 14 Sep 2026 17:09:22 -0700 Subject: [PATCH 2/2] fix(web): discard an in-progress header rename when focus jumps to a navigation button --- apps/web/src/components/chat/ChatHeader.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 90b5eb40c928..4a62c393d7db 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -274,6 +274,15 @@ export const ChatHeader = memo(function ChatHeader({ defaultValue={renamingTitle} onBlur={(event) => { if (renameCommittedRef.current) return; + // Focus landing on a navigation button means the rename was + // abandoned — discard it rather than persisting a half-draft. + if ( + event.relatedTarget instanceof HTMLElement && + event.relatedTarget.closest("button") + ) { + setRenaming(null); + return; + } commitRename(event.currentTarget.value); }} onFocus={(event) => event.currentTarget.select()}