| 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 | 11 | getProjectSortTimestamp, |
| 12 | 12 | hasUnseenCompletion, |
| 13 | 13 | isContextMenuPointerDown, |
| 14 | isTrailingDoubleClick, | |
| 14 | 15 | orderItemsByPreferredIds, |
| 15 | 16 | resolveProjectStatusIndicator, |
| 16 | 17 | resolveSidebarNewThreadSeedContext, |
| ... | ... | @@ -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 | 193 | describe("resolveSidebarNewThreadEnvMode", () => { |
| 175 | 194 | it("uses the app default when the caller does not request a specific mode", () => { |
| 176 | 195 | expect( |
apps/web/src/components/Sidebar.logic.ts+9| ... | ... | @@ -160,6 +160,15 @@ export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null |
| 160 | 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 | 172 | export function resolveSidebarNewThreadEnvMode(input: { |
| 164 | 173 | requestedEnvMode?: SidebarNewThreadEnvMode; |
| 165 | 174 | defaultEnvMode: SidebarNewThreadEnvMode; |
apps/web/src/components/Sidebar.tsx+48-4| ... | ... | @@ -164,6 +164,7 @@ import { |
| 164 | 164 | getSidebarThreadIdsToPrewarm, |
| 165 | 165 | resolveAdjacentThreadId, |
| 166 | 166 | isContextMenuPointerDown, |
| 167 | isTrailingDoubleClick, | |
| 167 | 168 | resolveProjectStatusIndicator, |
| 168 | 169 | resolveSidebarNewThreadSeedContext, |
| 169 | 170 | resolveSidebarNewThreadEnvMode, |
| ... | ... | @@ -178,6 +179,7 @@ import { |
| 178 | 179 | import { sortThreads } from "../lib/threadSort"; |
| 179 | 180 | import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; |
| 180 | 181 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 182 | import { useIsMobile } from "~/hooks/useMediaQuery"; | |
| 181 | 183 | import { CommandDialogTrigger } from "./ui/command"; |
| 182 | 184 | import { readEnvironmentApi } from "../environmentApi"; |
| 183 | 185 | import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; |
| ... | ... | @@ -292,6 +294,7 @@ interface SidebarThreadRowProps { |
| 292 | 294 | renamingThreadKey: string | null; |
| 293 | 295 | renamingTitle: string; |
| 294 | 296 | setRenamingTitle: (title: string) => void; |
| 297 | startThreadRename: (threadKey: string, title: string) => void; | |
| 295 | 298 | renamingInputRef: React.RefObject<HTMLInputElement | null>; |
| 296 | 299 | renamingCommittedRef: React.RefObject<boolean>; |
| 297 | 300 | confirmingArchiveThreadKey: string | null; |
| ... | ... | @@ -319,7 +322,7 @@ interface SidebarThreadRowProps { |
| 319 | 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 | 326 | const { |
| 324 | 327 | orderedProjectThreadKeys, |
| 325 | 328 | isActive, |
| ... | ... | @@ -328,6 +331,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 328 | 331 | renamingThreadKey, |
| 329 | 332 | renamingTitle, |
| 330 | 333 | setRenamingTitle, |
| 334 | startThreadRename, | |
| 331 | 335 | renamingInputRef, |
| 332 | 336 | renamingCommittedRef, |
| 333 | 337 | confirmingArchiveThreadKey, |
| ... | ... | @@ -352,6 +356,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 352 | 356 | environmentId: thread.environmentId, |
| 353 | 357 | threadId: thread.id, |
| 354 | 358 | }); |
| 359 | const isMobile = useIsMobile(); | |
| 355 | 360 | const discoveredPorts = useThreadDiscoveredPorts({ |
| 356 | 361 | environmentId: thread.environmentId, |
| 357 | 362 | threadId: thread.id, |
| ... | ... | @@ -437,6 +442,24 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 437 | 442 | }, |
| 438 | 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 | 463 | const handleRowKeyDown = useCallback( |
| 441 | 464 | (event: React.KeyboardEvent) => { |
| 442 | 465 | if (event.key !== "Enter" && event.key !== " ") return; |
| ... | ... | @@ -510,6 +533,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 510 | 533 | void commitRename(threadRef, renamingTitle, thread.title); |
| 511 | 534 | } |
| 512 | 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 | 539 | const handleRenameInputClick = useCallback((event: React.MouseEvent<HTMLInputElement>) => { |
| 514 | 540 | event.stopPropagation(); |
| 515 | 541 | }, []); |
| ... | ... | @@ -576,6 +602,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 576 | 602 | isSelected, |
| 577 | 603 | })} relative isolate`} |
| 578 | 604 | onClick={handleRowClick} |
| 605 | onDoubleClick={handleRowDoubleClick} | |
| 579 | 606 | onKeyDown={handleRowKeyDown} |
| 580 | 607 | onContextMenu={handleRowContextMenu} |
| 581 | 608 | > |
| ... | ... | @@ -607,6 +634,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 607 | 634 | onKeyDown={handleRenameInputKeyDown} |
| 608 | 635 | onBlur={handleRenameInputBlur} |
| 609 | 636 | onClick={handleRenameInputClick} |
| 637 | onDoubleClick={handleRenameInputClick} | |
| 610 | 638 | /> |
| 611 | 639 | ) : ( |
| 612 | 640 | <Tooltip> |
| ... | ... | @@ -789,6 +817,7 @@ interface SidebarProjectThreadListProps { |
| 789 | 817 | renamingThreadKey: string | null; |
| 790 | 818 | renamingTitle: string; |
| 791 | 819 | setRenamingTitle: (title: string) => void; |
| 820 | startThreadRename: (threadKey: string, title: string) => void; | |
| 792 | 821 | renamingInputRef: React.RefObject<HTMLInputElement | null>; |
| 793 | 822 | renamingCommittedRef: React.RefObject<boolean>; |
| 794 | 823 | confirmingArchiveThreadKey: string | null; |
| ... | ... | @@ -839,6 +868,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( |
| 839 | 868 | renamingThreadKey, |
| 840 | 869 | renamingTitle, |
| 841 | 870 | setRenamingTitle, |
| 871 | startThreadRename, | |
| 842 | 872 | renamingInputRef, |
| 843 | 873 | renamingCommittedRef, |
| 844 | 874 | confirmingArchiveThreadKey, |
| ... | ... | @@ -890,6 +920,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( |
| 890 | 920 | renamingThreadKey={renamingThreadKey} |
| 891 | 921 | renamingTitle={renamingTitle} |
| 892 | 922 | setRenamingTitle={setRenamingTitle} |
| 923 | startThreadRename={startThreadRename} | |
| 893 | 924 | renamingInputRef={renamingInputRef} |
| 894 | 925 | renamingCommittedRef={renamingCommittedRef} |
| 895 | 926 | confirmingArchiveThreadKey={confirmingArchiveThreadKey} |
| ... | ... | @@ -1626,6 +1657,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1626 | 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 | 1667 | if (currentSelectionCount > 0) { |
| 1630 | 1668 | clearSelection(); |
| 1631 | 1669 | } |
| ... | ... | @@ -1820,6 +1858,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1820 | 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 | 1867 | const commitRename = useCallback( |
| 1824 | 1868 | async (threadRef: ScopedThreadRef, newTitle: string, originalTitle: string) => { |
| 1825 | 1869 | const threadKey = scopedThreadKey(threadRef); |
| ... | ... | @@ -1979,9 +2023,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1979 | 2023 | ); |
| 1980 | 2024 | |
| 1981 | 2025 | if (clicked === "rename") { |
| 1982 | setRenamingThreadKey(threadKey); | |
| 1983 | setRenamingTitle(thread.title); | |
| 1984 | renamingCommittedRef.current = false; | |
| 2026 | startThreadRename(threadKey, thread.title); | |
| 1985 | 2027 | return; |
| 1986 | 2028 | } |
| 1987 | 2029 | |
| ... | ... | @@ -2029,6 +2071,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2029 | 2071 | markThreadUnread, |
| 2030 | 2072 | memberProjectByScopedKey, |
| 2031 | 2073 | project.cwd, |
| 2074 | startThreadRename, | |
| 2032 | 2075 | ], |
| 2033 | 2076 | ); |
| 2034 | 2077 | |
| ... | ... | @@ -2151,6 +2194,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2151 | 2194 | renamingThreadKey={renamingThreadKey} |
| 2152 | 2195 | renamingTitle={renamingTitle} |
| 2153 | 2196 | setRenamingTitle={setRenamingTitle} |
| 2197 | startThreadRename={startThreadRename} | |
| 2154 | 2198 | renamingInputRef={renamingInputRef} |
| 2155 | 2199 | renamingCommittedRef={renamingCommittedRef} |
| 2156 | 2200 | confirmingArchiveThreadKey={confirmingArchiveThreadKey} |