Skip to content
Merged
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
54 changes: 34 additions & 20 deletions apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
}),
);

it.effect("disables SSH askpass for background upstream status fetches", () =>
it.effect("makes background upstream status fetches non-interactive", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
const tempDir = yield* makeTmpDir("git-vcs-driver-ssh-env-");
Expand All @@ -225,15 +225,26 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
const pathService = yield* Path.Path;
const sshLogPath = pathService.join(tempDir, "ssh-env.txt");
const sshWrapperPath = pathService.join(tempDir, "ssh-wrapper.sh");
const previousGitSsh = process.env.GIT_SSH;
const previousAskpassRequire = process.env.SSH_ASKPASS_REQUIRE;
const previousAskpassLog = process.env.T3_TEST_SSH_ASKPASS_LOG;
const envKeys = [
"GCM_INTERACTIVE",
"GIT_ASKPASS",
"GIT_SSH",
"GIT_TERMINAL_PROMPT",
"SSH_ASKPASS",
"SSH_ASKPASS_REQUIRE",
"T3_TEST_SSH_ASKPASS_LOG",
] as const;
const previousEnv = new Map(envKeys.map((key) => [key, process.env[key]]));

yield* fileSystem.writeFileString(
sshWrapperPath,
[
"#!/bin/sh",
'printf "%s\\n" "${SSH_ASKPASS_REQUIRE:-}" > "$T3_TEST_SSH_ASKPASS_LOG"',
'printf "GCM_INTERACTIVE=%s\\n" "${GCM_INTERACTIVE:-}" > "$T3_TEST_SSH_ASKPASS_LOG"',
'printf "GIT_ASKPASS=%s\\n" "${GIT_ASKPASS:-}" >> "$T3_TEST_SSH_ASKPASS_LOG"',
'printf "GIT_TERMINAL_PROMPT=%s\\n" "${GIT_TERMINAL_PROMPT:-}" >> "$T3_TEST_SSH_ASKPASS_LOG"',
'printf "SSH_ASKPASS=%s\\n" "${SSH_ASKPASS:-}" >> "$T3_TEST_SSH_ASKPASS_LOG"',
'printf "SSH_ASKPASS_REQUIRE=%s\\n" "${SSH_ASKPASS_REQUIRE:-}" >> "$T3_TEST_SSH_ASKPASS_LOG"',
"exit 1",
"",
].join("\n"),
Expand All @@ -245,29 +256,32 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {

yield* Effect.gen(function* () {
process.env.GIT_SSH = sshWrapperPath;
process.env.GCM_INTERACTIVE = "always";
process.env.GIT_ASKPASS = "git-askpass";
process.env.GIT_TERMINAL_PROMPT = "1";
process.env.SSH_ASKPASS = "ssh-askpass";
process.env.SSH_ASKPASS_REQUIRE = "force";
process.env.T3_TEST_SSH_ASKPASS_LOG = sshLogPath;

yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd);

assert.equal((yield* fileSystem.readFileString(sshLogPath)).trim(), "never");
assert.deepEqual((yield* fileSystem.readFileString(sshLogPath)).trim().split(/\r?\n/), [
"GCM_INTERACTIVE=never",
"GIT_ASKPASS=",
"GIT_TERMINAL_PROMPT=0",
"SSH_ASKPASS=",
"SSH_ASKPASS_REQUIRE=never",
]);
}).pipe(
Effect.ensuring(
Effect.sync(() => {
if (previousGitSsh === undefined) {
delete process.env.GIT_SSH;
} else {
process.env.GIT_SSH = previousGitSsh;
}
if (previousAskpassRequire === undefined) {
delete process.env.SSH_ASKPASS_REQUIRE;
} else {
process.env.SSH_ASKPASS_REQUIRE = previousAskpassRequire;
}
if (previousAskpassLog === undefined) {
delete process.env.T3_TEST_SSH_ASKPASS_LOG;
} else {
process.env.T3_TEST_SSH_ASKPASS_LOG = previousAskpassLog;
for (const key of envKeys) {
const previous = previousEnv.get(key);
if (previous === undefined) {
delete process.env[key];
} else {
process.env[key] = previous;
}
}
}),
),
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5);
const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;
const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({
GCM_INTERACTIVE: "never",
GIT_ASKPASS: "",
GIT_TERMINAL_PROMPT: "0",
SSH_ASKPASS: "",
SSH_ASKPASS_REQUIRE: "never",
} satisfies NodeJS.ProcessEnv);
const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;
Expand Down
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
Loading
Loading