Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
255 changes: 255 additions & 0 deletions apps/web/src/components/Sidebar.dblclick.browser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
import "../index.css";

import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
import { useCallback, useRef, useState } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
import { page, userEvent } from "vite-plus/test/browser";
import { cleanup, render } from "vitest-browser-react";

import { AppAtomRegistryProvider } from "../rpc/atomRegistry";
import { DEFAULT_INTERACTION_MODE } from "../types";
import type { SidebarThreadSummary } from "../types";
import { SidebarThreadRow } from "./Sidebar";

// Double-click-to-rename is a desktop affordance; force the non-mobile path so
// the rename input is reachable regardless of the test browser viewport.
vi.mock("~/hooks/useMediaQuery", () => ({
useIsMobile: () => false,
useMediaQuery: () => false,
}));

const THREAD_ID = ThreadId.make("thread-1");
const ENVIRONMENT_ID = EnvironmentId.make("environment-local");
const PROJECT_ID = ProjectId.make("project-1");
const INITIAL_TITLE = "Original title";

const ROW_TESTID = `thread-row-${THREAD_ID}`;
const TITLE_TESTID = `thread-title-${THREAD_ID}`;

// Spies live at module scope so their call history survives the row's
// re-renders; reset between tests.
const spies = {
handleThreadClick: vi.fn(),
startThreadRename: vi.fn(),
navigateToThread: vi.fn(),
handleMultiSelectContextMenu: vi.fn(async () => {}),
handleThreadContextMenu: vi.fn(async () => {}),
clearSelection: vi.fn(),
commitRename: vi.fn(),
attemptArchiveThread: vi.fn(async () => {}),
openPrLink: vi.fn(),
};

function buildThread(title: string): SidebarThreadSummary {
return {
id: THREAD_ID,
environmentId: ENVIRONMENT_ID,
projectId: PROJECT_ID,
title,
interactionMode: DEFAULT_INTERACTION_MODE,
session: null,
createdAt: "2024-01-01T00:00:00.000Z",
archivedAt: null,
updatedAt: undefined,
latestTurn: null,
branch: null,
worktreePath: null,
latestUserMessageAt: null,
hasPendingApprovals: false,
hasPendingUserInput: false,
hasActionableProposedPlan: false,
};
}

// Mirrors the real parent (`SidebarProjectItem`): holds the rename state, wires
// `startThreadRename`, and commits by clearing the rename state and persisting
// the new title back onto the thread so the row re-renders with it.
function Harness() {
const [title, setTitle] = useState(INITIAL_TITLE);
const [renamingThreadKey, setRenamingThreadKey] = useState<string | null>(null);
const [renamingTitle, setRenamingTitle] = useState("");
const [confirmingArchiveThreadKey, setConfirmingArchiveThreadKey] = useState<string | null>(null);
const renamingInputRef = useRef<HTMLInputElement | null>(null);
const renamingCommittedRef = useRef(false);
const confirmArchiveButtonRefs = useRef(new Map<string, HTMLButtonElement>());

const startThreadRename = useCallback((threadKey: string, nextTitle: string) => {
spies.startThreadRename(threadKey, nextTitle);
setRenamingThreadKey(threadKey);
setRenamingTitle(nextTitle);
renamingCommittedRef.current = false;
}, []);

const commitRename = useCallback(
async (threadRef: unknown, newTitle: string, originalTitle: string) => {
spies.commitRename(threadRef, newTitle, originalTitle);
const trimmed = newTitle.trim();
if (trimmed.length > 0) {
setTitle(trimmed);
}
setRenamingThreadKey(null);
renamingInputRef.current = null;
},
[],
);

const cancelRename = useCallback(() => {
setRenamingThreadKey(null);
renamingInputRef.current = null;
}, []);

return (
<AppAtomRegistryProvider>
<ul>
<SidebarThreadRow
thread={buildThread(title)}
projectCwd={null}
orderedProjectThreadKeys={[]}
isActive={false}
jumpLabel={null}
appSettingsConfirmThreadArchive={false}
renamingThreadKey={renamingThreadKey}
renamingTitle={renamingTitle}
setRenamingTitle={setRenamingTitle}
startThreadRename={startThreadRename}
renamingInputRef={renamingInputRef}
renamingCommittedRef={renamingCommittedRef}
confirmingArchiveThreadKey={confirmingArchiveThreadKey}
setConfirmingArchiveThreadKey={setConfirmingArchiveThreadKey}
confirmArchiveButtonRefs={confirmArchiveButtonRefs}
handleThreadClick={spies.handleThreadClick}
navigateToThread={spies.navigateToThread}
handleMultiSelectContextMenu={spies.handleMultiSelectContextMenu}
handleThreadContextMenu={spies.handleThreadContextMenu}
clearSelection={spies.clearSelection}
commitRename={commitRename}
cancelRename={cancelRename}
attemptArchiveThread={spies.attemptArchiveThread}
openPrLink={spies.openPrLink}
/>
</ul>
</AppAtomRegistryProvider>
);
}

describe("SidebarThreadRow double-click rename", () => {
beforeEach(() => {
for (const spy of Object.values(spies)) spy.mockClear();
});

afterEach(() => {
cleanup();
});

it("double-clicking a row starts the inline rename, focused with text selected", async () => {
render(<Harness />);

await expect.element(page.getByTestId(TITLE_TESTID)).toBeVisible();

await userEvent.dblClick(page.getByTestId(ROW_TESTID));

const input = page.getByRole("textbox");
await expect.element(input).toBeVisible();

const element = input.element() as HTMLInputElement;
expect(element.value).toBe(INITIAL_TITLE);
// The existing rename-input ref focuses + selects the whole title.
expect(document.activeElement).toBe(element);
expect(element.selectionStart).toBe(0);
expect(element.selectionEnd).toBe(INITIAL_TITLE.length);
});

it("Enter commits the rename and the new title persists on the row", async () => {
render(<Harness />);

await userEvent.dblClick(page.getByTestId(ROW_TESTID));
const input = page.getByRole("textbox");
await expect.element(input).toBeVisible();

await userEvent.fill(input, "Renamed thread");
await userEvent.keyboard("{Enter}");

// commitRename was invoked with (threadRef, newTitle, originalTitle).
expect(spies.commitRename).toHaveBeenCalledTimes(1);
expect(spies.commitRename).toHaveBeenCalledWith(
expect.anything(),
"Renamed thread",
INITIAL_TITLE,
);

// Input is gone and the row now shows the persisted title.
const title = page.getByTestId(TITLE_TESTID);
await expect.element(title).toBeVisible();
await expect.element(title).toHaveTextContent("Renamed thread");
});

it("Escape cancels the rename without committing", async () => {
render(<Harness />);

await userEvent.dblClick(page.getByTestId(ROW_TESTID));
await expect.element(page.getByRole("textbox")).toBeVisible();

await userEvent.keyboard("{Escape}");

expect(spies.commitRename).not.toHaveBeenCalled();
const title = page.getByTestId(TITLE_TESTID);
await expect.element(title).toBeVisible();
await expect.element(title).toHaveTextContent(INITIAL_TITLE);
});

it("double-clicking inside the rename input keeps the edit (does not reset to the title)", async () => {
render(<Harness />);

await userEvent.dblClick(page.getByTestId(ROW_TESTID));
const input = page.getByRole("textbox");
await expect.element(input).toBeVisible();

await userEvent.fill(input, "Edited but not committed");
// Double-clicking inside the input (e.g. to select a word) must not bubble
// to the row and restart the rename, which would wipe the edit.
await userEvent.dblClick(input);

expect((input.element() as HTMLInputElement).value).toBe("Edited but not committed");
expect(spies.commitRename).not.toHaveBeenCalled();
});

it("double-clicking the row chrome while already renaming does not restart/reset it", async () => {
render(<Harness />);

await userEvent.dblClick(page.getByTestId(ROW_TESTID));
const input = page.getByRole("textbox");
await expect.element(input).toBeVisible();
await userEvent.fill(input, "Edited");
expect(spies.startThreadRename).toHaveBeenCalledTimes(1);

// Double-click the row element itself (chrome, not the input).
const rowEl = page.getByTestId(ROW_TESTID).element();
rowEl.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true, detail: 2 }));

// Guard short-circuits: rename is not restarted and the edit is preserved.
expect(spies.startThreadRename).toHaveBeenCalledTimes(1);
expect((input.element() as HTMLInputElement).value).toBe("Edited");
});

it("modifier double-click is multi-select intent and does not start a rename", async () => {
render(<Harness />);

await userEvent.keyboard("{Shift>}");
await userEvent.dblClick(page.getByTestId(ROW_TESTID));
await userEvent.keyboard("{/Shift}");

await expect.element(page.getByTestId(TITLE_TESTID)).toBeVisible();
expect(page.getByRole("textbox").elements()).toHaveLength(0);
});

it("single click routes through the navigation handler and does not start a rename", async () => {
render(<Harness />);

await userEvent.click(page.getByTestId(ROW_TESTID));

expect(spies.handleThreadClick).toHaveBeenCalledTimes(1);
// No rename input: the title span is still shown.
await expect.element(page.getByTestId(TITLE_TESTID)).toBeVisible();
expect(page.getByRole("textbox").elements()).toHaveLength(0);
});
});
19 changes: 19 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
getProjectSortTimestamp,
hasUnseenCompletion,
isContextMenuPointerDown,
isTrailingDoubleClick,
orderItemsByPreferredIds,
resolveProjectStatusIndicator,
resolveSidebarNewThreadSeedContext,
Expand Down Expand Up @@ -171,6 +172,24 @@ describe("shouldClearThreadSelectionOnMouseDown", () => {
});
});

describe("isTrailingDoubleClick", () => {
it("treats a single click as a normal activation", () => {
expect(isTrailingDoubleClick(1)).toBe(false);
});

it("treats synthetic/keyboard activations (detail 0) as a normal activation", () => {
expect(isTrailingDoubleClick(0)).toBe(false);
});

it("ignores the second click of a double-click so it does not navigate", () => {
expect(isTrailingDoubleClick(2)).toBe(true);
});

it("ignores further clicks of a triple-click", () => {
expect(isTrailingDoubleClick(3)).toBe(true);
});
});

describe("resolveSidebarNewThreadEnvMode", () => {
it("uses the app default when the caller does not request a specific mode", () => {
expect(
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,15 @@ export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null
return !target.closest(THREAD_SELECTION_SAFE_SELECTOR);
}

// A double-click dispatches two `click` events before `dblclick`: the first has
// `detail === 1`, the second `detail === 2`. The second click must not run the
// row's single-click navigation, otherwise double-click-to-rename would also
// navigate. `MouseEvent.detail` is 0 for synthetic/keyboard activations, which
// still count as a normal single activation.
export function isTrailingDoubleClick(detail: number): boolean {
return detail > 1;
}

export function resolveSidebarNewThreadEnvMode(input: {
requestedEnvMode?: SidebarNewThreadEnvMode;
defaultEnvMode: SidebarNewThreadEnvMode;
Expand Down
Loading
Loading