| author | |
| committer | |
| log | 60b546cf0111f0a041e9fbac025f4c904961ad75 |
| tree | b7cc81ff8d2dad97b9019a48894f2e48623b8fcb |
| parent | 4b7382733454ce582d4b27fdc0c14f3ffb519bc3 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ | ||
| 1 | import "../index.css"; | ||
| 2 | |||
| 3 | import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; | ||
| 4 | import { useCallback, useRef, useState } from "react"; | ||
| 5 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 6 | import { page, userEvent } from "vite-plus/test/browser"; | ||
| 7 | import { cleanup, render } from "vitest-browser-react"; | ||
| 8 | |||
| 9 | import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; | ||
| 10 | import { DEFAULT_INTERACTION_MODE } from "../types"; | ||
| 11 | import type { SidebarThreadSummary } from "../types"; | ||
| 12 | import { 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. | ||
| 16 | vi.mock("~/hooks/useMediaQuery", () => ({ | ||
| 17 | useIsMobile: () => false, | ||
| 18 | useMediaQuery: () => false, | ||
| 19 | })); | ||
| 20 | |||
| 21 | const THREAD_ID = ThreadId.make("thread-1"); | ||
| 22 | const ENVIRONMENT_ID = EnvironmentId.make("environment-local"); | ||
| 23 | const PROJECT_ID = ProjectId.make("project-1"); | ||
| 24 | const INITIAL_TITLE = "Original title"; | ||
| 25 | |||
| 26 | const ROW_TESTID = `thread-row-${THREAD_ID}`; | ||
| 27 | const 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. | ||
| 31 | const 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 | |||
| 43 | function 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. | ||
| 67 | function 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 | |||
| 135 | describe("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 | }); |
| 173 | 174 | ||
| 175 | describe("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 | |||
| 174 | describe("resolveSidebarNewThreadEnvMode", () => { | 193 | describe("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 | } |
| 162 | 162 | ||
| 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. | ||
| 168 | export function isTrailingDoubleClick(detail: number): boolean { | ||
| 169 | return detail > 1; | ||
| 170 | } | ||
| 171 | |||
| 163 | export function resolveSidebarNewThreadEnvMode(input: { | 172 | export 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 { |
| 178 | import { sortThreads } from "../lib/threadSort"; | 179 | import { sortThreads } from "../lib/threadSort"; |
| 179 | import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; | 180 | import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; |
| 180 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; | 181 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 182 | import { useIsMobile } from "~/hooks/useMediaQuery"; | ||
| 181 | import { CommandDialogTrigger } from "./ui/command"; | 183 | import { CommandDialogTrigger } from "./ui/command"; |
| 182 | import { readEnvironmentApi } from "../environmentApi"; | 184 | import { readEnvironmentApi } from "../environmentApi"; |
| 183 | import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; | 185 | import { 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 | } |
| 321 | 324 | ||
| 322 | const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { | 325 | export 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 | } |
| 1628 | 1659 | ||
| 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 | }, []); |
| 1822 | 1860 | ||
| 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 | ); |
| 1980 | 2024 | ||
| 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 | } |
| 1987 | 2029 | ||
| ... | @@ -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 | ); |
| 2034 | 2077 | ||
| ... | @@ -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} |