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";...@@ -15,6 +15,7 @@ import { ChevronDownIcon } from "lucide-react";
15import {15import {
16 memo,16 memo,
17 useCallback,17 useCallback,
18 useEffect,
18 useMemo,19 useMemo,
19 useRef,20 useRef,
20 useState,21 useState,
...@@ -22,6 +23,7 @@ import {...@@ -22,6 +23,7 @@ import {
22 type MouseEvent as ReactMouseEvent,23 type MouseEvent as ReactMouseEvent,
23} from "react";24} from "react";
24import GitActionsControl from "../GitActionsControl";25import GitActionsControl from "../GitActionsControl";
26import { isTrailingDoubleClick } from "../Sidebar.logic";
25import { type DraftId } from "~/composerDraftStore";27import { type DraftId } from "~/composerDraftStore";
26import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";28import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
27import { toastManager } from "../ui/toast";29import { toastManager } from "../ui/toast";
...@@ -88,6 +90,14 @@ export function resolveRenameCommit(input: {...@@ -88,6 +90,14 @@ export function resolveRenameCommit(input: {
88 return { action: "commit", title: trimmed };90 return { action: "commit", title: trimmed };
89}91}
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
91export function shouldShowOpenInPicker(input: {101export function shouldShowOpenInPicker(input: {
92 readonly activeProjectName: string | undefined;102 readonly activeProjectName: string | undefined;
93 readonly activeThreadEnvironmentId: EnvironmentId;103 readonly activeThreadEnvironmentId: EnvironmentId;
...@@ -188,28 +198,77 @@ export const ChatHeader = memo(function ChatHeader({...@@ -188,28 +198,77 @@ export const ChatHeader = memo(function ChatHeader({
188 },198 },
189 [activeThreadEnvironmentId, activeThreadId, activeThreadTitle, updateThreadMetadata],199 [activeThreadEnvironmentId, activeThreadId, activeThreadTitle, updateThreadMetadata],
190 );200 );
191 const { openMenu } = useThreadActionMenu({201 const { openMenu, closeMenu } = useThreadActionMenu({
192 threadRef: isServerThread ? activeThreadRef : null,202 threadRef: isServerThread ? activeThreadRef : null,
193 projectCwd: activeProjectCwd,203 projectCwd: activeProjectCwd,
194 changeRequest,204 changeRequest,
195 onStartRename: startRename,205 onStartRename: startRename,
196 });206 });
197 const titleButtonRef = useRef<HTMLButtonElement | null>(null);207 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();
199 const rect = titleButtonRef.current?.getBoundingClientRect();224 const rect = titleButtonRef.current?.getBoundingClientRect();
200 if (!rect) return;225 if (!rect) return;
201 openMenu({ x: rect.left, y: rect.bottom + 4 });226 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 );
203 const handleHeaderContextMenu = useCallback(261 const handleHeaderContextMenu = useCallback(
204 (event: ReactMouseEvent) => {262 (event: ReactMouseEvent) => {
205 if (!isServerThread || renamingTitle !== null) return;263 if (!isServerThread || renamingTitle !== null) return;
206 // The right-side controls (git, scripts, open-in) keep their own264 // The right-side controls (git, scripts, open-in) keep their own
207 // behavior; only the breadcrumb area opens the thread menu.265 // behavior; only the breadcrumb area opens the thread menu.
208 if ((event.target as HTMLElement).closest("[data-chat-header-actions]")) return;266 if ((event.target as HTMLElement).closest("[data-chat-header-actions]")) return;
267 cancelPendingTitleMenu();
209 event.preventDefault();268 event.preventDefault();
210 openMenu({ x: event.clientX, y: event.clientY });269 openMenu({ x: event.clientX, y: event.clientY });
211 },270 },
212 [isServerThread, openMenu, renamingTitle],271 [cancelPendingTitleMenu, isServerThread, openMenu, renamingTitle],
213 );272 );
214 const handleRenameKeyDown = useCallback(273 const handleRenameKeyDown = useCallback(
215 (event: ReactKeyboardEvent<HTMLInputElement>) => {274 (event: ReactKeyboardEvent<HTMLInputElement>) => {
...@@ -285,6 +344,8 @@ export const ChatHeader = memo(function ChatHeader({...@@ -285,6 +344,8 @@ export const ChatHeader = memo(function ChatHeader({
285 aria-label={`Thread actions for ${activeThreadTitle}`}344 aria-label={`Thread actions for ${activeThreadTitle}`}
286 aria-haspopup="menu"345 aria-haspopup="menu"
287 onClick={openMenuFromTitle}346 onClick={openMenuFromTitle}
347 onDoubleClick={handleTitleDoubleClick}
348 onBlur={cancelPendingTitleMenu}
288 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"349 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"
289 />350 />
290 }351 }
...@@ -292,6 +353,7 @@ export const ChatHeader = memo(function ChatHeader({...@@ -292,6 +353,7 @@ export const ChatHeader = memo(function ChatHeader({
292 <h2 className="min-w-0 truncate">{activeThreadTitle}</h2>353 <h2 className="min-w-0 truncate">{activeThreadTitle}</h2>
293 <ChevronDownIcon354 <ChevronDownIcon
294 aria-hidden355 aria-hidden
356 data-thread-title-chevron
295 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"357 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"
296 />358 />
297 </TooltipTrigger>359 </TooltipTrigger>
apps/web/src/hooks/useThreadActionMenu.ts+5-1
...@@ -335,5 +335,9 @@ export function useThreadActionMenu(input: {...@@ -335,5 +335,9 @@ export function useThreadActionMenu(input: {
335 ],335 ],
336 );336 );
337337
338 return { openMenu };338 const closeMenu = useCallback(() => {
339 void readLocalApi()?.contextMenu.close();
340 }, []);
341
342 return { openMenu, closeMenu };
339}343}