authorgravatar for 73941998+UtkarshUsername@users.noreply.github.comUtkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> 2026-08-22 01:17:07+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-21 15:47:07-04:00
log837f6b871a88db0986c98a5a5ebc6bd80b4f2546
tree5c679ffb15213df83b6e5d618bb7ac9b5afdd49d
parent0a46daaf63e78cdd188f6eb944ce5ee463e63216
signature Signed by PGP key B5690EEEBB952194

feat(web): double-click chat header title to rename thread (#7817)


2 files changed, 71 insertions(+), 5 deletions(-)

apps/web/src/components/chat/ChatHeader.tsx+66-4
......@@ -15,6 +15,7 @@ import { ChevronDownIcon } from "lucide-react";
1515import {
1616 memo,
1717 useCallback,
18 useEffect,
1819 useMemo,
1920 useRef,
2021 useState,
......@@ -22,6 +23,7 @@ import {
2223 type MouseEvent as ReactMouseEvent,
2324} from "react";
2425import GitActionsControl from "../GitActionsControl";
26import { isTrailingDoubleClick } from "../Sidebar.logic";
2527import { type DraftId } from "~/composerDraftStore";
2628import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
2729import { toastManager } from "../ui/toast";
......@@ -88,6 +90,14 @@ export function resolveRenameCommit(input: {
8890 return { action: "commit", title: trimmed };
8991}
9092
93// How long a click on the thread title waits before opening the action menu,
94// so a double-click-to-rename can cancel it first. Only the native desktop
95// menu needs this: it swallows input while open, so the wait must cover the
96// OS double-click interval. The browser fallback menu keeps seeing DOM
97// events (the second click dismisses it and dblclick still fires), so it
98// opens immediately.
99const TITLE_MENU_OPEN_DELAY_MS = 500;
100
91101export function shouldShowOpenInPicker(input: {
92102 readonly activeProjectName: string | undefined;
93103 readonly activeThreadEnvironmentId: EnvironmentId;
......@@ -188,28 +198,77 @@ export const ChatHeader = memo(function ChatHeader({
188198 },
189199 [activeThreadEnvironmentId, activeThreadId, activeThreadTitle, updateThreadMetadata],
190200 );
191 const { openMenu } = useThreadActionMenu({
201 const { openMenu, closeMenu } = useThreadActionMenu({
192202 threadRef: isServerThread ? activeThreadRef : null,
193203 projectCwd: activeProjectCwd,
194204 changeRequest,
195205 onStartRename: startRename,
196206 });
197207 const titleButtonRef = useRef<HTMLButtonElement | null>(null);
198 const openMenuFromTitle = useCallback(() => {
208 const titleMenuTimerRef = useRef<number | null>(null);
209 const cancelPendingTitleMenu = useCallback(() => {
210 if (titleMenuTimerRef.current === null) return;
211 clearTimeout(titleMenuTimerRef.current);
212 titleMenuTimerRef.current = null;
213 }, []);
214 // Drop a pending menu-open when the thread changes or the header unmounts,
215 // so it can never fire for a thread the user already left.
216 useEffect(
217 () => () => {
218 cancelPendingTitleMenu();
219 },
220 [activeThreadId, cancelPendingTitleMenu],
221 );
222 const openTitleMenuNow = useCallback(() => {
223 cancelPendingTitleMenu();
199224 const rect = titleButtonRef.current?.getBoundingClientRect();
200225 if (!rect) return;
201226 openMenu({ x: rect.left, y: rect.bottom + 4 });
202 }, [openMenu]);
227 }, [cancelPendingTitleMenu, openMenu]);
228 const openMenuFromTitle = useCallback(
229 (event: ReactMouseEvent<HTMLButtonElement>) => {
230 // The trailing click of a double-click belongs to rename, not the menu.
231 if (isTrailingDoubleClick(event.detail)) return;
232 // Keyboard activation and the explicit chevron affordance can never be
233 // the first half of a double-click, so they open without waiting.
234 const clickedChevron =
235 (event.target as HTMLElement).closest("[data-thread-title-chevron]") !== null;
236 if (event.detail === 0 || clickedChevron || window.desktopBridge === undefined) {
237 openTitleMenuNow();
238 return;
239 }
240 // Stay pending long enough for dblclick to cancel the open before the
241 // native menu appears and swallows the second click.
242 cancelPendingTitleMenu();
243 titleMenuTimerRef.current = window.setTimeout(() => {
244 titleMenuTimerRef.current = null;
245 openTitleMenuNow();
246 }, TITLE_MENU_OPEN_DELAY_MS);
247 },
248 [cancelPendingTitleMenu, openTitleMenuNow],
249 );
250 const handleTitleDoubleClick = useCallback(
251 (event: ReactMouseEvent) => {
252 if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
253 // The chevron is the explicit menu affordance; only the title text renames.
254 if ((event.target as HTMLElement).closest("[data-thread-title-chevron]") !== null) return;
255 cancelPendingTitleMenu();
256 closeMenu();
257 startRename();
258 },
259 [cancelPendingTitleMenu, closeMenu, startRename],
260 );
203261 const handleHeaderContextMenu = useCallback(
204262 (event: ReactMouseEvent) => {
205263 if (!isServerThread || renamingTitle !== null) return;
206264 // The right-side controls (git, scripts, open-in) keep their own
207265 // behavior; only the breadcrumb area opens the thread menu.
208266 if ((event.target as HTMLElement).closest("[data-chat-header-actions]")) return;
267 cancelPendingTitleMenu();
209268 event.preventDefault();
210269 openMenu({ x: event.clientX, y: event.clientY });
211270 },
212 [isServerThread, openMenu, renamingTitle],
271 [cancelPendingTitleMenu, isServerThread, openMenu, renamingTitle],
213272 );
214273 const handleRenameKeyDown = useCallback(
215274 (event: ReactKeyboardEvent<HTMLInputElement>) => {
......@@ -285,6 +344,8 @@ export const ChatHeader = memo(function ChatHeader({
285344 aria-label={`Thread actions for ${activeThreadTitle}`}
286345 aria-haspopup="menu"
287346 onClick={openMenuFromTitle}
347 onDoubleClick={handleTitleDoubleClick}
348 onBlur={cancelPendingTitleMenu}
288349 className="group/thread-title inline-flex min-w-0 max-w-full cursor-pointer items-center gap-1 rounded-sm text-left focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
289350 />
290351 }
......@@ -292,6 +353,7 @@ export const ChatHeader = memo(function ChatHeader({
292353 <h2 className="min-w-0 truncate">{activeThreadTitle}</h2>
293354 <ChevronDownIcon
294355 aria-hidden
356 data-thread-title-chevron
295357 className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/thread-title:opacity-100 group-focus-visible/thread-title:opacity-100"
296358 />
297359 </TooltipTrigger>
apps/web/src/hooks/useThreadActionMenu.ts+5-1
......@@ -335,5 +335,9 @@ export function useThreadActionMenu(input: {
335335 ],
336336 );
337337
338 return { openMenu };
338 const closeMenu = useCallback(() => {
339 void readLocalApi()?.contextMenu.close();
340 }, []);
341
342 return { openMenu, closeMenu };
339343}