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
166 changes: 137 additions & 29 deletions apps/web/src/components/Sidebar.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ import { userEvent } from "vite-plus/test/browser";
import { act, fakeTimers, render } from "../../test/render";
import { openCommandPalette } from "../commandPaletteBus";
import { makeThreadFixture } from "../test-fixtures";
import { useThreadActions } from "../hooks/useThreadActions";
import { useHandleNewThread } from "../hooks/useHandleNewThread";
import { useNowMinute } from "../hooks/useNowMinute";
import { __setClientSettingsForTests, getClientSettings } from "../hooks/useSettings";
import { useThreadRouteTarget } from "../hooks/useThreadRouteTarget";
import { useProjects, useServerConfigs, useQuietThreadShells } from "../state/entities";
import { useConnectedEnvironmentIds } from "../state/environments";
import { readEnvironmentScope, useEnvironmentScope } from "../state/session";
import { useOrchestrationCommand } from "../state/use-orchestration-command";
import { useTerminalUiStateStore } from "../terminalUiStateStore";
Expand All @@ -36,7 +38,12 @@ vi.mock(import("../state/olderSettledShelf"), async (original) => {
return { ...actual, useOlderSettledShelf: vi.fn(actual.useOlderSettledShelf) };
});
vi.mock("../state/entities", { spy: true });
vi.mock("../state/environments", { spy: true });
vi.mock("../hooks/useHandleNewThread", { spy: true });
vi.mock(import("../hooks/useThreadActions"), async (original) => {
const actual = await original();
return { ...actual, useThreadActions: vi.fn(actual.useThreadActions) };
});
vi.mock("../hooks/useNowMinute", { spy: true });
vi.mock("../hooks/useThreadRouteTarget", { spy: true });
vi.mock("./sidebar/SidebarThreadRow", { spy: true });
Expand All @@ -61,10 +68,12 @@ vi.mock("./sidebar/SidebarChrome", () => ({
SidebarChromeFooter: () => null,
}));

const environmentId = EnvironmentId.make("local");

function project(id: string): Project {
return {
id: ProjectId.make(id),
environmentId: EnvironmentId.make("local"),
environmentId,
title: id,
workspaceRoot: `/projects/${id}`,
repositoryIdentity: null,
Expand All @@ -75,42 +84,54 @@ function project(id: string): Project {
};
}

function clientSettings(mode: "normal" | "boring"): Disposable {
const previous = getClientSettings();
__setClientSettingsForTests({
...DEFAULT_CLIENT_SETTINGS,
boringSidebarEnabled: mode === "boring",
});
return { [Symbol.dispose]: () => __setClientSettingsForTests(previous) };
}

function mockNewThread(projects: readonly Project[]) {
const defaultProjectRef = {
environmentId: projects[0]!.environmentId,
projectId: projects[0]!.id,
};
const startThread = vi.fn().mockResolvedValue(null);
vi.mocked(useProjects).mockReturnValue(projects);
vi.mocked(useHandleNewThread).mockReturnValue({
activeDraftThread: null,
activeThread: null,
defaultProjectRef,
defaultProjectFallbackReady: true,
handleNewThread: startThread,
routeDraftId: null,
routeThreadRef: null,
});
return { defaultProjectRef, startThread };
}

function renderSidebar() {
return render(
<SidebarProvider>
<Sidebar routeReveal={{ observedKey: null, pendingKey: null }} />
</SidebarProvider>,
);
}

describe("sidebar header new thread", () => {
it.each([
{ mode: "normal", count: 2, shift: false, picker: true },
{ mode: "boring", count: 2, shift: false, picker: true },
{ mode: "boring", count: 2, shift: true, picker: false },
{ mode: "boring", count: 1, shift: false, picker: false },
] as const)("$mode sidebar, $count projects, shift=$shift: picker=$picker", async (scenario) => {
using stack = new DisposableStack();
const previousSettings = getClientSettings();
stack.defer(() => __setClientSettingsForTests(previousSettings));
__setClientSettingsForTests({
...DEFAULT_CLIENT_SETTINGS,
boringSidebarEnabled: scenario.mode === "boring",
});
using _settings = clientSettings(scenario.mode);
const projects = [project("active"), project("idle")].slice(0, scenario.count);
const currentProjectRef = {
environmentId: projects[0]!.environmentId,
projectId: projects[0]!.id,
};
const startThread = vi.fn().mockResolvedValue(null);
vi.mocked(useProjects).mockReturnValue(projects);
vi.mocked(useHandleNewThread).mockReturnValue({
activeDraftThread: null,
activeThread: null,
defaultProjectRef: currentProjectRef,
defaultProjectFallbackReady: true,
handleNewThread: startThread,
routeDraftId: null,
routeThreadRef: null,
});
const { defaultProjectRef, startThread } = mockNewThread(projects);
vi.mocked(openCommandPalette).mockClear();
await using screen = await render(
<SidebarProvider>
<Sidebar routeReveal={{ observedKey: null, pendingKey: null }} />
</SidebarProvider>,
);
await using screen = await renderSidebar();

await userEvent.click(screen.getByRole("button", { name: "New thread", exact: true }), {
modifiers: scenario.shift ? ["Shift"] : [],
Expand All @@ -121,11 +142,98 @@ describe("sidebar header new thread", () => {
expect(startThread).not.toHaveBeenCalled();
} else {
expect(openCommandPalette).not.toHaveBeenCalled();
expect(startThread).toHaveBeenCalledExactlyOnceWith(currentProjectRef);
expect(startThread).toHaveBeenCalledExactlyOnceWith(defaultProjectRef);
}
});
});

describe("sidebar drag reorder", () => {
// Folders own the active order, so rows reorder unless one renders; then they only drag out.
it.each([
{ mode: "normal", folders: false, reorders: true },
{ mode: "boring", folders: false, reorders: true },
{ mode: "boring", folders: true, reorders: false },
] as const)(
"$mode sidebar, folders=$folders: pinned rows reorder=$reorders",
async (scenario) => {
using _settings = clientSettings(scenario.mode);
using stack = new DisposableStack();
const originalActions = vi.mocked(useThreadActions).getMockImplementation()!;
stack.defer(() => vi.mocked(useThreadActions).mockImplementation(originalActions));
const reorderPinnedThread = vi.fn().mockResolvedValue({ _tag: "Success" });

vi.mocked(useThreadActions).mockImplementation(() => ({
...originalActions(),
reorderPinnedThread,
}));
vi.mocked(readEnvironmentScope).mockReturnValue(true);
const alpha = project("alpha");
const thread = (id: string, pinnedAt: string | null) =>
makeThreadFixture({
id: ThreadId.make(id),
environmentId,
projectId: alpha.id,
title: id,
pinnedAt,
pinOrderKey: pinnedAt === null ? null : id === "first" ? "a0" : "a1",
});
mockNewThread([alpha]);
vi.mocked(useQuietThreadShells).mockReturnValue([
thread("first", "2026-01-01T00:00:00.000Z"),
thread("second", "2026-01-01T00:01:00.000Z"),
...(scenario.folders ? [thread("open", null)] : []),
]);
vi.mocked(useServerConfigs).mockReturnValue(new Map([[environmentId, {} as never]]));
vi.mocked(useConnectedEnvironmentIds).mockReturnValue([environmentId]);
vi.mocked(useEnvironmentScope).mockReturnValue(true);
await using screen = await renderSidebar();

const row = screen.container.querySelector<HTMLElement>(
`[data-thread-item="${environmentId}:first"]`,
);
expect(row).not.toBeNull();
const second = screen.container.querySelector<HTMLElement>(
`[data-thread-item="${environmentId}:second"]`,
)!;
const firstRect = row!.getBoundingClientRect();
const secondRect = second.getBoundingClientRect();
const startY = firstRect.top + firstRect.height / 2;
const endY = secondRect.top + secondRect.height / 2;
const pointer = (type: string, clientY: number) =>
new PointerEvent(type, {
bubbles: true,
cancelable: true,
pointerId: 1,
isPrimary: true,
button: 0,
buttons: 1,
clientX: firstRect.left + firstRect.width / 2,
clientY,
});
await act(() => row!.dispatchEvent(pointer("pointerdown", startY)));
await act(() => document.dispatchEvent(pointer("pointermove", startY + 10)));

expect(row!.classList.contains("z-20")).toBe(scenario.reorders);
expect(
screen.container.querySelector('[data-testid="sidebar-active-placeholder"]') !== null,
).toBe(!scenario.folders);
await act(() => document.dispatchEvent(pointer("pointermove", endY)));
await act(() => document.dispatchEvent(pointer("pointerup", endY)));
if (scenario.reorders) {
await vi.waitFor(() =>
expect(reorderPinnedThread).toHaveBeenCalledWith(
{ environmentId, threadId: ThreadId.make("first") },
expect.any(String),
),
);
expect(reorderPinnedThread.mock.calls[0]![1] > "a1").toBe(true);
} else {
expect(reorderPinnedThread).not.toHaveBeenCalled();
}
},
);
});

function threadShell(projectId: ProjectId, index: number): EnvironmentThreadShell {
return makeThreadFixture({
environmentId: EnvironmentId.make("local"),
Expand Down
10 changes: 4 additions & 6 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1720,11 +1720,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
};
};

const hasProjectFolders = projectFolders !== null && projectFolders.length > 0;
// Folder rows only drag out to the composer; folders own their order, so nothing drops in the list.
const isContextDrag =
projectFolders !== null ||
dragState?.contextDrag === true ||
dragState?.activeSection === "working";
hasProjectFolders || dragState?.contextDrag === true || dragState?.activeSection === "working";
const dragTargetSection = isContextDrag ? null : (dragState?.targetSection ?? null);
const finishThreadDrag = (started: boolean) => {
dragSensorRef.current = null;
Expand Down Expand Up @@ -1931,7 +1930,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
event.activatorEvent instanceof PointerEvent ? event.activatorEvent.clientY : null,
});
};
const hasProjectFolders = projectFolders !== null && projectFolders.length > 0;
const sidebarListItemsFresh = ((): readonly SidebarListItem[] => {
const rowsOf = (
list: readonly EnvironmentThreadShell[],
Expand Down Expand Up @@ -2091,7 +2089,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
);
})();
const handleThreadDragEnd = (event: DragEndEvent) => {
if (projectFolders !== null) return;
if (hasProjectFolders) return;
const activeKey = String(event.active.id);
const activeSection = sectionByThreadKey.get(activeKey);
const target =
Expand Down Expand Up @@ -3488,7 +3486,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
items.push(renderThreadRow(threadByKey.get(item.key)!, item.section));
continue;
}
if (projectFolders !== null && item.marker === "active-placeholder") {
if (hasProjectFolders && item.marker === "active-placeholder") {
for (const folder of projectFolders) {
items.push(
<SidebarProjectFolderHeader
Expand Down
6 changes: 3 additions & 3 deletions docs/user/thread-sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,9 @@ threads has no folder unless it is pinned or you pick it in the project filter.
project's folder comes last and shows only while it has active threads. Click a folder to open or
close it. A closed folder shows how many threads it holds, and a dot when one of them
needs you. Use the pen button on a folder, or right-click it, to start a thread in that project.
Pinned threads stay above the folders, and Snoozed and Settled stay below them. In this mode you
can drag a thread onto the composer to mention it, but not within the sidebar, and the Working
section is off. Each device keeps its own choice.
Pinned threads stay above the folders, and Snoozed and Settled stay below them. You can drag a
thread onto the composer to mention it, but not within the sidebar while a folder is shown. The
Working section is off in this mode. Each device keeps its own choice.

## Settle finished work

Expand Down
Loading