authorgravatar for 10465470+TheIcarusWings@users.noreply.github.comIcarus Wings <10465470+TheIcarusWings@users.noreply.github.com> 2026-06-16 19:04:47+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-16 11:04:47-07:00
log60b546cf0111f0a041e9fbac025f4c904961ad75
treeb7cc81ff8d2dad97b9019a48894f2e48623b8fcb
parent4b7382733454ce582d4b27fdc0c14f3ffb519bc3
signature Signed by PGP key B5690EEEBB952194

Double-click a sidebar thread row to rename (#3064)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

4 files changed, 331 insertions(+), 4 deletions(-)

apps/web/src/components/Sidebar.dblclick.browser.tsx created+255
...@@ -0,0 +1,255 @@
1import "../index.css";
2
3import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
4import { useCallback, useRef, useState } from "react";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
6import { page, userEvent } from "vite-plus/test/browser";
7import { cleanup, render } from "vitest-browser-react";
8
9import { AppAtomRegistryProvider } from "../rpc/atomRegistry";
10import { DEFAULT_INTERACTION_MODE } from "../types";
11import type { SidebarThreadSummary } from "../types";
12import { SidebarThreadRow } from "./Sidebar";
13
14// Double-click-to-rename is a desktop affordance; force the non-mobile path so
15// the rename input is reachable regardless of the test browser viewport.
16vi.mock("~/hooks/useMediaQuery", () => ({
17 useIsMobile: () => false,
18 useMediaQuery: () => false,
19}));
20
21const THREAD_ID = ThreadId.make("thread-1");
22const ENVIRONMENT_ID = EnvironmentId.make("environment-local");
23const PROJECT_ID = ProjectId.make("project-1");
24const INITIAL_TITLE = "Original title";
25
26const ROW_TESTID = `thread-row-${THREAD_ID}`;
27const TITLE_TESTID = `thread-title-${THREAD_ID}`;
28
29// Spies live at module scope so their call history survives the row's
30// re-renders; reset between tests.
31const spies = {
32 handleThreadClick: vi.fn(),
33 startThreadRename: vi.fn(),
34 navigateToThread: vi.fn(),
35 handleMultiSelectContextMenu: vi.fn(async () => {}),
36 handleThreadContextMenu: vi.fn(async () => {}),
37 clearSelection: vi.fn(),
38 commitRename: vi.fn(),
39 attemptArchiveThread: vi.fn(async () => {}),
40 openPrLink: vi.fn(),
41};
42
43function buildThread(title: string): SidebarThreadSummary {
44 return {
45 id: THREAD_ID,
46 environmentId: ENVIRONMENT_ID,
47 projectId: PROJECT_ID,
48 title,
49 interactionMode: DEFAULT_INTERACTION_MODE,
50 session: null,
51 createdAt: "2024-01-01T00:00:00.000Z",
52 archivedAt: null,
53 updatedAt: undefined,
54 latestTurn: null,
55 branch: null,
56 worktreePath: null,
57 latestUserMessageAt: null,
58 hasPendingApprovals: false,
59 hasPendingUserInput: false,
60 hasActionableProposedPlan: false,
61 };
62}
63
64// Mirrors the real parent (`SidebarProjectItem`): holds the rename state, wires
65// `startThreadRename`, and commits by clearing the rename state and persisting
66// the new title back onto the thread so the row re-renders with it.
67function Harness() {
68 const [title, setTitle] = useState(INITIAL_TITLE);
69 const [renamingThreadKey, setRenamingThreadKey] = useState<string | null>(null);
70 const [renamingTitle, setRenamingTitle] = useState("");
71 const [confirmingArchiveThreadKey, setConfirmingArchiveThreadKey] = useState<string | null>(null);
72 const renamingInputRef = useRef<HTMLInputElement | null>(null);
73 const renamingCommittedRef = useRef(false);
74 const confirmArchiveButtonRefs = useRef(new Map<string, HTMLButtonElement>());
75
76 const startThreadRename = useCallback((threadKey: string, nextTitle: string) => {
77 spies.startThreadRename(threadKey, nextTitle);
78 setRenamingThreadKey(threadKey);
79 setRenamingTitle(nextTitle);
80 renamingCommittedRef.current = false;
81 }, []);
82
83 const commitRename = useCallback(
84 async (threadRef: unknown, newTitle: string, originalTitle: string) => {
85 spies.commitRename(threadRef, newTitle, originalTitle);
86 const trimmed = newTitle.trim();
87 if (trimmed.length > 0) {
88 setTitle(trimmed);
89 }
90 setRenamingThreadKey(null);
91 renamingInputRef.current = null;
92 },
93 [],
94 );
95
96 const cancelRename = useCallback(() => {
97 setRenamingThreadKey(null);
98 renamingInputRef.current = null;
99 }, []);
100
101 return (
102 <AppAtomRegistryProvider>
103 <ul>
104 <SidebarThreadRow
105 thread={buildThread(title)}
106 projectCwd={null}
107 orderedProjectThreadKeys={[]}
108 isActive={false}
109 jumpLabel={null}
110 appSettingsConfirmThreadArchive={false}
111 renamingThreadKey={renamingThreadKey}
112 renamingTitle={renamingTitle}
113 setRenamingTitle={setRenamingTitle}
114 startThreadRename={startThreadRename}
115 renamingInputRef={renamingInputRef}
116 renamingCommittedRef={renamingCommittedRef}
117 confirmingArchiveThreadKey={confirmingArchiveThreadKey}
118 setConfirmingArchiveThreadKey={setConfirmingArchiveThreadKey}
119 confirmArchiveButtonRefs={confirmArchiveButtonRefs}
120 handleThreadClick={spies.handleThreadClick}
121 navigateToThread={spies.navigateToThread}
122 handleMultiSelectContextMenu={spies.handleMultiSelectContextMenu}
123 handleThreadContextMenu={spies.handleThreadContextMenu}
124 clearSelection={spies.clearSelection}
125 commitRename={commitRename}
126 cancelRename={cancelRename}
127 attemptArchiveThread={spies.attemptArchiveThread}
128 openPrLink={spies.openPrLink}
129 />
130 </ul>
131 </AppAtomRegistryProvider>
132 );
133}
134
135describe("SidebarThreadRow double-click rename", () => {
136 beforeEach(() => {
137 for (const spy of Object.values(spies)) spy.mockClear();
138 });
139
140 afterEach(() => {
141 cleanup();
142 });
143
144 it("double-clicking a row starts the inline rename, focused with text selected", async () => {
145 render(<Harness />);
146
147 await expect.element(page.getByTestId(TITLE_TESTID)).toBeVisible();
148
149 await userEvent.dblClick(page.getByTestId(ROW_TESTID));
150
151 const input = page.getByRole("textbox");
152 await expect.element(input).toBeVisible();
153
154 const element = input.element() as HTMLInputElement;
155 expect(element.value).toBe(INITIAL_TITLE);
156 // The existing rename-input ref focuses + selects the whole title.
157 expect(document.activeElement).toBe(element);
158 expect(element.selectionStart).toBe(0);
159 expect(element.selectionEnd).toBe(INITIAL_TITLE.length);
160 });
161
162 it("Enter commits the rename and the new title persists on the row", async () => {
163 render(<Harness />);
164
165 await userEvent.dblClick(page.getByTestId(ROW_TESTID));
166 const input = page.getByRole("textbox");
167 await expect.element(input).toBeVisible();
168
169 await userEvent.fill(input, "Renamed thread");
170 await userEvent.keyboard("{Enter}");
171
172 // commitRename was invoked with (threadRef, newTitle, originalTitle).
173 expect(spies.commitRename).toHaveBeenCalledTimes(1);
174 expect(spies.commitRename).toHaveBeenCalledWith(
175 expect.anything(),
176 "Renamed thread",
177 INITIAL_TITLE,
178 );
179
180 // Input is gone and the row now shows the persisted title.
181 const title = page.getByTestId(TITLE_TESTID);
182 await expect.element(title).toBeVisible();
183 await expect.element(title).toHaveTextContent("Renamed thread");
184 });
185
186 it("Escape cancels the rename without committing", async () => {
187 render(<Harness />);
188
189 await userEvent.dblClick(page.getByTestId(ROW_TESTID));
190 await expect.element(page.getByRole("textbox")).toBeVisible();
191
192 await userEvent.keyboard("{Escape}");
193
194 expect(spies.commitRename).not.toHaveBeenCalled();
195 const title = page.getByTestId(TITLE_TESTID);
196 await expect.element(title).toBeVisible();
197 await expect.element(title).toHaveTextContent(INITIAL_TITLE);
198 });
199
200 it("double-clicking inside the rename input keeps the edit (does not reset to the title)", async () => {
201 render(<Harness />);
202
203 await userEvent.dblClick(page.getByTestId(ROW_TESTID));
204 const input = page.getByRole("textbox");
205 await expect.element(input).toBeVisible();
206
207 await userEvent.fill(input, "Edited but not committed");
208 // Double-clicking inside the input (e.g. to select a word) must not bubble
209 // to the row and restart the rename, which would wipe the edit.
210 await userEvent.dblClick(input);
211
212 expect((input.element() as HTMLInputElement).value).toBe("Edited but not committed");
213 expect(spies.commitRename).not.toHaveBeenCalled();
214 });
215
216 it("double-clicking the row chrome while already renaming does not restart/reset it", async () => {
217 render(<Harness />);
218
219 await userEvent.dblClick(page.getByTestId(ROW_TESTID));
220 const input = page.getByRole("textbox");
221 await expect.element(input).toBeVisible();
222 await userEvent.fill(input, "Edited");
223 expect(spies.startThreadRename).toHaveBeenCalledTimes(1);
224
225 // Double-click the row element itself (chrome, not the input).
226 const rowEl = page.getByTestId(ROW_TESTID).element();
227 rowEl.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true, detail: 2 }));
228
229 // Guard short-circuits: rename is not restarted and the edit is preserved.
230 expect(spies.startThreadRename).toHaveBeenCalledTimes(1);
231 expect((input.element() as HTMLInputElement).value).toBe("Edited");
232 });
233
234 it("modifier double-click is multi-select intent and does not start a rename", async () => {
235 render(<Harness />);
236
237 await userEvent.keyboard("{Shift>}");
238 await userEvent.dblClick(page.getByTestId(ROW_TESTID));
239 await userEvent.keyboard("{/Shift}");
240
241 await expect.element(page.getByTestId(TITLE_TESTID)).toBeVisible();
242 expect(page.getByRole("textbox").elements()).toHaveLength(0);
243 });
244
245 it("single click routes through the navigation handler and does not start a rename", async () => {
246 render(<Harness />);
247
248 await userEvent.click(page.getByTestId(ROW_TESTID));
249
250 expect(spies.handleThreadClick).toHaveBeenCalledTimes(1);
251 // No rename input: the title span is still shown.
252 await expect.element(page.getByTestId(TITLE_TESTID)).toBeVisible();
253 expect(page.getByRole("textbox").elements()).toHaveLength(0);
254 });
255});
apps/web/src/components/Sidebar.logic.test.ts+19
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11 getProjectSortTimestamp,11 getProjectSortTimestamp,
12 hasUnseenCompletion,12 hasUnseenCompletion,
13 isContextMenuPointerDown,13 isContextMenuPointerDown,
14 isTrailingDoubleClick,
14 orderItemsByPreferredIds,15 orderItemsByPreferredIds,
15 resolveProjectStatusIndicator,16 resolveProjectStatusIndicator,
16 resolveSidebarNewThreadSeedContext,17 resolveSidebarNewThreadSeedContext,
...@@ -171,6 +172,24 @@ describe("shouldClearThreadSelectionOnMouseDown", () => {...@@ -171,6 +172,24 @@ describe("shouldClearThreadSelectionOnMouseDown", () => {
171 });172 });
172});173});
173174
175describe("isTrailingDoubleClick", () => {
176 it("treats a single click as a normal activation", () => {
177 expect(isTrailingDoubleClick(1)).toBe(false);
178 });
179
180 it("treats synthetic/keyboard activations (detail 0) as a normal activation", () => {
181 expect(isTrailingDoubleClick(0)).toBe(false);
182 });
183
184 it("ignores the second click of a double-click so it does not navigate", () => {
185 expect(isTrailingDoubleClick(2)).toBe(true);
186 });
187
188 it("ignores further clicks of a triple-click", () => {
189 expect(isTrailingDoubleClick(3)).toBe(true);
190 });
191});
192
174describe("resolveSidebarNewThreadEnvMode", () => {193describe("resolveSidebarNewThreadEnvMode", () => {
175 it("uses the app default when the caller does not request a specific mode", () => {194 it("uses the app default when the caller does not request a specific mode", () => {
176 expect(195 expect(
apps/web/src/components/Sidebar.logic.ts+9
...@@ -160,6 +160,15 @@ export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null...@@ -160,6 +160,15 @@ export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null
160 return !target.closest(THREAD_SELECTION_SAFE_SELECTOR);160 return !target.closest(THREAD_SELECTION_SAFE_SELECTOR);
161}161}
162162
163// A double-click dispatches two `click` events before `dblclick`: the first has
164// `detail === 1`, the second `detail === 2`. The second click must not run the
165// row's single-click navigation, otherwise double-click-to-rename would also
166// navigate. `MouseEvent.detail` is 0 for synthetic/keyboard activations, which
167// still count as a normal single activation.
168export function isTrailingDoubleClick(detail: number): boolean {
169 return detail > 1;
170}
171
163export function resolveSidebarNewThreadEnvMode(input: {172export function resolveSidebarNewThreadEnvMode(input: {
164 requestedEnvMode?: SidebarNewThreadEnvMode;173 requestedEnvMode?: SidebarNewThreadEnvMode;
165 defaultEnvMode: SidebarNewThreadEnvMode;174 defaultEnvMode: SidebarNewThreadEnvMode;
apps/web/src/components/Sidebar.tsx+48-4
...@@ -164,6 +164,7 @@ import {...@@ -164,6 +164,7 @@ import {
164 getSidebarThreadIdsToPrewarm,164 getSidebarThreadIdsToPrewarm,
165 resolveAdjacentThreadId,165 resolveAdjacentThreadId,
166 isContextMenuPointerDown,166 isContextMenuPointerDown,
167 isTrailingDoubleClick,
167 resolveProjectStatusIndicator,168 resolveProjectStatusIndicator,
168 resolveSidebarNewThreadSeedContext,169 resolveSidebarNewThreadSeedContext,
169 resolveSidebarNewThreadEnvMode,170 resolveSidebarNewThreadEnvMode,
...@@ -178,6 +179,7 @@ import {...@@ -178,6 +179,7 @@ import {
178import { sortThreads } from "../lib/threadSort";179import { sortThreads } from "../lib/threadSort";
179import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";180import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
180import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";181import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
182import { useIsMobile } from "~/hooks/useMediaQuery";
181import { CommandDialogTrigger } from "./ui/command";183import { CommandDialogTrigger } from "./ui/command";
182import { readEnvironmentApi } from "../environmentApi";184import { readEnvironmentApi } from "../environmentApi";
183import { useSettings, useUpdateSettings } from "~/hooks/useSettings";185import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
...@@ -292,6 +294,7 @@ interface SidebarThreadRowProps {...@@ -292,6 +294,7 @@ interface SidebarThreadRowProps {
292 renamingThreadKey: string | null;294 renamingThreadKey: string | null;
293 renamingTitle: string;295 renamingTitle: string;
294 setRenamingTitle: (title: string) => void;296 setRenamingTitle: (title: string) => void;
297 startThreadRename: (threadKey: string, title: string) => void;
295 renamingInputRef: React.RefObject<HTMLInputElement | null>;298 renamingInputRef: React.RefObject<HTMLInputElement | null>;
296 renamingCommittedRef: React.RefObject<boolean>;299 renamingCommittedRef: React.RefObject<boolean>;
297 confirmingArchiveThreadKey: string | null;300 confirmingArchiveThreadKey: string | null;
...@@ -319,7 +322,7 @@ interface SidebarThreadRowProps {...@@ -319,7 +322,7 @@ interface SidebarThreadRowProps {
319 openPrLink: (event: React.MouseEvent<HTMLElement>, prUrl: string) => void;322 openPrLink: (event: React.MouseEvent<HTMLElement>, prUrl: string) => void;
320}323}
321324
322const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) {325export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) {
323 const {326 const {
324 orderedProjectThreadKeys,327 orderedProjectThreadKeys,
325 isActive,328 isActive,
...@@ -328,6 +331,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -328,6 +331,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
328 renamingThreadKey,331 renamingThreadKey,
329 renamingTitle,332 renamingTitle,
330 setRenamingTitle,333 setRenamingTitle,
334 startThreadRename,
331 renamingInputRef,335 renamingInputRef,
332 renamingCommittedRef,336 renamingCommittedRef,
333 confirmingArchiveThreadKey,337 confirmingArchiveThreadKey,
...@@ -352,6 +356,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -352,6 +356,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
352 environmentId: thread.environmentId,356 environmentId: thread.environmentId,
353 threadId: thread.id,357 threadId: thread.id,
354 });358 });
359 const isMobile = useIsMobile();
355 const discoveredPorts = useThreadDiscoveredPorts({360 const discoveredPorts = useThreadDiscoveredPorts({
356 environmentId: thread.environmentId,361 environmentId: thread.environmentId,
357 threadId: thread.id,362 threadId: thread.id,
...@@ -437,6 +442,24 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -437,6 +442,24 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
437 },442 },
438 [discoveredPorts, navigateToThread, threadRef],443 [discoveredPorts, navigateToThread, threadRef],
439 );444 );
445 const handleRowDoubleClick = useCallback(
446 (event: React.MouseEvent) => {
447 // Already renaming this row: a double-click on the row chrome (outside the
448 // input) must not restart and discard the in-progress edit.
449 if (renamingThreadKey === threadKey) return;
450 // On mobile the first tap navigates and closes the sidebar sheet, so the
451 // inline rename can't be shown. Renaming there stays on the context menu.
452 if (isMobile) return;
453 // cmd/ctrl/shift double-clicks are multi-select intent, not rename.
454 if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
455 // Ignore double-clicks bubbling from nested controls (PR status, port,
456 // archive buttons) — only the row body should enter inline rename.
457 if ((event.target as HTMLElement).closest("button, a")) return;
458 event.preventDefault();
459 startThreadRename(threadKey, thread.title);
460 },
461 [isMobile, renamingThreadKey, startThreadRename, threadKey, thread.title],
462 );
440 const handleRowKeyDown = useCallback(463 const handleRowKeyDown = useCallback(
441 (event: React.KeyboardEvent) => {464 (event: React.KeyboardEvent) => {
442 if (event.key !== "Enter" && event.key !== " ") return;465 if (event.key !== "Enter" && event.key !== " ") return;
...@@ -510,6 +533,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -510,6 +533,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
510 void commitRename(threadRef, renamingTitle, thread.title);533 void commitRename(threadRef, renamingTitle, thread.title);
511 }534 }
512 }, [commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef]);535 }, [commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef]);
536 // Keep clicks/double-clicks inside the rename input from bubbling to the row.
537 // Without stopping `dblclick`, double-clicking to select a word would re-fire
538 // the row's rename handler and reset the in-progress edit back to the title.
513 const handleRenameInputClick = useCallback((event: React.MouseEvent<HTMLInputElement>) => {539 const handleRenameInputClick = useCallback((event: React.MouseEvent<HTMLInputElement>) => {
514 event.stopPropagation();540 event.stopPropagation();
515 }, []);541 }, []);
...@@ -576,6 +602,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -576,6 +602,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
576 isSelected,602 isSelected,
577 })} relative isolate`}603 })} relative isolate`}
578 onClick={handleRowClick}604 onClick={handleRowClick}
605 onDoubleClick={handleRowDoubleClick}
579 onKeyDown={handleRowKeyDown}606 onKeyDown={handleRowKeyDown}
580 onContextMenu={handleRowContextMenu}607 onContextMenu={handleRowContextMenu}
581 >608 >
...@@ -607,6 +634,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -607,6 +634,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
607 onKeyDown={handleRenameInputKeyDown}634 onKeyDown={handleRenameInputKeyDown}
608 onBlur={handleRenameInputBlur}635 onBlur={handleRenameInputBlur}
609 onClick={handleRenameInputClick}636 onClick={handleRenameInputClick}
637 onDoubleClick={handleRenameInputClick}
610 />638 />
611 ) : (639 ) : (
612 <Tooltip>640 <Tooltip>
...@@ -789,6 +817,7 @@ interface SidebarProjectThreadListProps {...@@ -789,6 +817,7 @@ interface SidebarProjectThreadListProps {
789 renamingThreadKey: string | null;817 renamingThreadKey: string | null;
790 renamingTitle: string;818 renamingTitle: string;
791 setRenamingTitle: (title: string) => void;819 setRenamingTitle: (title: string) => void;
820 startThreadRename: (threadKey: string, title: string) => void;
792 renamingInputRef: React.RefObject<HTMLInputElement | null>;821 renamingInputRef: React.RefObject<HTMLInputElement | null>;
793 renamingCommittedRef: React.RefObject<boolean>;822 renamingCommittedRef: React.RefObject<boolean>;
794 confirmingArchiveThreadKey: string | null;823 confirmingArchiveThreadKey: string | null;
...@@ -839,6 +868,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList(...@@ -839,6 +868,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList(
839 renamingThreadKey,868 renamingThreadKey,
840 renamingTitle,869 renamingTitle,
841 setRenamingTitle,870 setRenamingTitle,
871 startThreadRename,
842 renamingInputRef,872 renamingInputRef,
843 renamingCommittedRef,873 renamingCommittedRef,
844 confirmingArchiveThreadKey,874 confirmingArchiveThreadKey,
...@@ -890,6 +920,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList(...@@ -890,6 +920,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList(
890 renamingThreadKey={renamingThreadKey}920 renamingThreadKey={renamingThreadKey}
891 renamingTitle={renamingTitle}921 renamingTitle={renamingTitle}
892 setRenamingTitle={setRenamingTitle}922 setRenamingTitle={setRenamingTitle}
923 startThreadRename={startThreadRename}
893 renamingInputRef={renamingInputRef}924 renamingInputRef={renamingInputRef}
894 renamingCommittedRef={renamingCommittedRef}925 renamingCommittedRef={renamingCommittedRef}
895 confirmingArchiveThreadKey={confirmingArchiveThreadKey}926 confirmingArchiveThreadKey={confirmingArchiveThreadKey}
...@@ -1626,6 +1657,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1626,6 +1657,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1626 return;1657 return;
1627 }1658 }
16281659
1660 // Ignore the trailing click of a plain double-click so it doesn't navigate
1661 // while a double-click is starting an inline rename. Placed after the
1662 // modifier branches so cmd/shift selection still processes every click.
1663 if (isTrailingDoubleClick(event.detail)) {
1664 return;
1665 }
1666
1629 if (currentSelectionCount > 0) {1667 if (currentSelectionCount > 0) {
1630 clearSelection();1668 clearSelection();
1631 }1669 }
...@@ -1820,6 +1858,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1820,6 +1858,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1820 renamingInputRef.current = null;1858 renamingInputRef.current = null;
1821 }, []);1859 }, []);
18221860
1861 const startThreadRename = useCallback((threadKey: string, title: string) => {
1862 setRenamingThreadKey(threadKey);
1863 setRenamingTitle(title);
1864 renamingCommittedRef.current = false;
1865 }, []);
1866
1823 const commitRename = useCallback(1867 const commitRename = useCallback(
1824 async (threadRef: ScopedThreadRef, newTitle: string, originalTitle: string) => {1868 async (threadRef: ScopedThreadRef, newTitle: string, originalTitle: string) => {
1825 const threadKey = scopedThreadKey(threadRef);1869 const threadKey = scopedThreadKey(threadRef);
...@@ -1979,9 +2023,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1979,9 +2023,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1979 );2023 );
19802024
1981 if (clicked === "rename") {2025 if (clicked === "rename") {
1982 setRenamingThreadKey(threadKey);2026 startThreadRename(threadKey, thread.title);
1983 setRenamingTitle(thread.title);
1984 renamingCommittedRef.current = false;
1985 return;2027 return;
1986 }2028 }
19872029
...@@ -2029,6 +2071,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -2029,6 +2071,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
2029 markThreadUnread,2071 markThreadUnread,
2030 memberProjectByScopedKey,2072 memberProjectByScopedKey,
2031 project.cwd,2073 project.cwd,
2074 startThreadRename,
2032 ],2075 ],
2033 );2076 );
20342077
...@@ -2151,6 +2194,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -2151,6 +2194,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
2151 renamingThreadKey={renamingThreadKey}2194 renamingThreadKey={renamingThreadKey}
2152 renamingTitle={renamingTitle}2195 renamingTitle={renamingTitle}
2153 setRenamingTitle={setRenamingTitle}2196 setRenamingTitle={setRenamingTitle}
2197 startThreadRename={startThreadRename}
2154 renamingInputRef={renamingInputRef}2198 renamingInputRef={renamingInputRef}
2155 renamingCommittedRef={renamingCommittedRef}2199 renamingCommittedRef={renamingCommittedRef}
2156 confirmingArchiveThreadKey={confirmingArchiveThreadKey}2200 confirmingArchiveThreadKey={confirmingArchiveThreadKey}