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
136 changes: 136 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,18 @@ import {
isContextMenuPointerDown,
isTrailingDoubleClick,
orderItemsByPreferredIds,
prioritizeThreadsByPinnedKeys,
resolveProjectStatusIndicator,
resolveSidebarNewThreadSeedContext,
resolveSidebarNewThreadEnvMode,
resolveSidebarStageBadgeLabel,
resolveThreadRowClassName,
resolveThreadStatusPill,
shouldClearThreadSelectionOnMouseDown,
shouldBlockThreadDragActivation,
shouldShowPinnedThreadDivider,
shouldHideThreadMeta,
resolveThreadPinCrossingAction,
sortProjectsForSidebar,
THREAD_JUMP_HINT_SHOW_DELAY_MS,
} from "./Sidebar.logic";
Expand All @@ -37,6 +42,118 @@ import {

const localEnvironmentId = EnvironmentId.make("environment-local");

describe("shouldBlockThreadDragActivation", () => {
it("blocks drag activation from interactive descendants", () => {
const input = { closest: vi.fn(() => ({})) } as unknown as HTMLElement;
const button = { closest: vi.fn(() => ({})) } as unknown as HTMLElement;
const row = { closest: vi.fn(() => null) } as unknown as HTMLElement;

expect(shouldBlockThreadDragActivation(input)).toBe(true);
expect(shouldBlockThreadDragActivation(button)).toBe(true);
expect(shouldBlockThreadDragActivation(row)).toBe(false);
});
});

describe("shouldHideThreadMeta", () => {
it("keeps mobile metadata visible beside always-visible actions", () => {
expect(
shouldHideThreadMeta({
isConfirmingArchive: false,
isMobile: true,
showRowActions: true,
}),
).toBe(false);
});

it("hides metadata for desktop actions and archive confirmation", () => {
expect(
shouldHideThreadMeta({
isConfirmingArchive: false,
isMobile: false,
showRowActions: true,
}),
).toBe(true);
expect(
shouldHideThreadMeta({
isConfirmingArchive: true,
isMobile: true,
showRowActions: true,
}),
).toBe(true);
});
});

describe("prioritizeThreadsByPinnedKeys", () => {
it("moves only pinned threads to the front while preserving the current sort order", () => {
const threads = [{ id: "newest" }, { id: "pinned" }, { id: "oldest" }];

expect(prioritizeThreadsByPinnedKeys(threads, ["pinned"], (thread) => thread.id)).toEqual([
{ id: "pinned" },
{ id: "newest" },
{ id: "oldest" },
]);
});
});

describe("resolveThreadPinCrossingAction", () => {
it("pins an unpinned thread after its center crosses above the divider", () => {
expect(
resolveThreadPinCrossingAction({
activeIsPinned: false,
draggedCenterY: 90,
dividerCenterY: 100,
}),
).toBe("pin");
expect(
resolveThreadPinCrossingAction({
activeIsPinned: false,
draggedCenterY: 110,
dividerCenterY: 100,
}),
).toBeNull();
});

it("unpins a pinned thread after its center crosses below the divider", () => {
expect(
resolveThreadPinCrossingAction({
activeIsPinned: true,
draggedCenterY: 110,
dividerCenterY: 100,
}),
).toBe("unpin");
expect(
resolveThreadPinCrossingAction({
activeIsPinned: true,
draggedCenterY: 90,
dividerCenterY: 100,
}),
).toBeNull();
});
});

describe("shouldShowPinnedThreadDivider", () => {
it("shows between mixed groups and during edge-case drags", () => {
expect(
shouldShowPinnedThreadDivider({ isDragging: false, pinnedCount: 1, regularCount: 1 }),
).toBe(true);
expect(
shouldShowPinnedThreadDivider({ isDragging: true, pinnedCount: 0, regularCount: 2 }),
).toBe(true);
expect(
shouldShowPinnedThreadDivider({ isDragging: true, pinnedCount: 2, regularCount: 0 }),
).toBe(true);
});

it("stays hidden outside a drag when only one group exists", () => {
expect(
shouldShowPinnedThreadDivider({ isDragging: false, pinnedCount: 0, regularCount: 2 }),
).toBe(false);
expect(
shouldShowPinnedThreadDivider({ isDragging: false, pinnedCount: 2, regularCount: 0 }),
).toBe(false);
});
});

describe("resolveSidebarStageBadgeLabel", () => {
it("returns Nightly for nightly primary server versions", () => {
expect(
Expand Down Expand Up @@ -790,6 +907,25 @@ describe("getVisibleThreadsForProject", () => {
);
expect(result.hiddenThreads).toEqual([]);
});

it("supports scoped identities for grouped projects", () => {
const threads = [
{ id: ThreadId.make("shared"), scopedKey: "env-a:shared" },
{ id: ThreadId.make("shared"), scopedKey: "env-b:shared" },
];

const result = getVisibleThreadsForProject({
threads,
activeThreadId: "env-b:shared",
isThreadListExpanded: false,
previewLimit: 1,
getThreadId: (thread) => thread.scopedKey,
});

expect(result.hasHiddenThreads).toBe(false);
expect(result.hiddenThreads).toEqual([]);
expect(result.visibleThreads).toEqual(threads);
});
});

function makeProject(overrides: Partial<Project> = {}): Project {
Expand Down
77 changes: 67 additions & 10 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,43 @@ type SidebarProject = {

export type ThreadTraversalDirection = "previous" | "next";

export function prioritizeThreadsByPinnedKeys<T>(
threads: readonly T[],
pinnedKeys: readonly string[],
getKey: (thread: T) => string,
): T[] {
if (pinnedKeys.length === 0) return [...threads];
const pinned = new Set(pinnedKeys);
return [
...threads.filter((thread) => pinned.has(getKey(thread))),
...threads.filter((thread) => !pinned.has(getKey(thread))),
];
}

export type ThreadPinDropAction = "pin" | "unpin";

export function resolveThreadPinCrossingAction(input: {
activeIsPinned: boolean;
draggedCenterY: number;
dividerCenterY: number;
}): ThreadPinDropAction | null {
if (input.activeIsPinned) {
return input.draggedCenterY > input.dividerCenterY ? "unpin" : null;
}
return input.draggedCenterY < input.dividerCenterY ? "pin" : null;
}

export function shouldShowPinnedThreadDivider(input: {
isDragging: boolean;
pinnedCount: number;
regularCount: number;
}): boolean {
return (
(input.pinnedCount > 0 && input.regularCount > 0) ||
(input.isDragging && input.pinnedCount + input.regularCount > 0)
);
}

export interface ThreadStatusPill {
label:
| "Working"
Expand Down Expand Up @@ -168,6 +205,21 @@ export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null
return !target.closest(THREAD_SELECTION_SAFE_SELECTOR);
}

const THREAD_DRAG_BLOCKED_SELECTOR =
"input, textarea, select, button, a, [contenteditable='true'], [data-thread-selection-safe]";

export function shouldBlockThreadDragActivation(target: HTMLElement | null): boolean {
return target !== null && target.closest(THREAD_DRAG_BLOCKED_SELECTOR) !== null;
}

export function shouldHideThreadMeta(input: {
isConfirmingArchive: boolean;
isMobile: boolean;
showRowActions: boolean;
}): boolean {
return input.isConfirmingArchive || (!input.isMobile && input.showRowActions);
}

// 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
Expand Down Expand Up @@ -444,35 +496,37 @@ export function resolveProjectStatusIndicator(

export function getVisibleThreadsForProject<T extends Pick<Thread, "id">>(input: {
threads: readonly T[];
activeThreadId: T["id"] | undefined;
activeThreadId: string | undefined;
isThreadListExpanded: boolean;
previewLimit: number;
getThreadId?: (thread: T) => string;
}): {
hasHiddenThreads: boolean;
visibleThreads: T[];
hiddenThreads: T[];
} {
const { activeThreadId, isThreadListExpanded, previewLimit, threads } = input;
const hasHiddenThreads = threads.length > previewLimit;
const getThreadId = input.getThreadId ?? ((thread: T) => thread.id);
const exceedsPreviewLimit = threads.length > previewLimit;

if (!hasHiddenThreads || isThreadListExpanded) {
if (!exceedsPreviewLimit || isThreadListExpanded) {
return {
hasHiddenThreads,
hasHiddenThreads: exceedsPreviewLimit,
hiddenThreads: [],
visibleThreads: [...threads],
};
}

const previewThreads = threads.slice(0, previewLimit);
if (!activeThreadId || previewThreads.some((thread) => thread.id === activeThreadId)) {
if (!activeThreadId || previewThreads.some((thread) => getThreadId(thread) === activeThreadId)) {
return {
hasHiddenThreads: true,
hiddenThreads: threads.slice(previewLimit),
visibleThreads: previewThreads,
};
}

const activeThread = threads.find((thread) => thread.id === activeThreadId);
const activeThread = threads.find((thread) => getThreadId(thread) === activeThreadId);
if (!activeThread) {
return {
hasHiddenThreads: true,
Expand All @@ -481,12 +535,15 @@ export function getVisibleThreadsForProject<T extends Pick<Thread, "id">>(input:
};
}

const visibleThreadIds = new Set([...previewThreads, activeThread].map((thread) => thread.id));
const visibleThreadIds = new Set(
[...previewThreads, activeThread].map((thread) => getThreadId(thread)),
);

const hiddenThreads = threads.filter((thread) => !visibleThreadIds.has(getThreadId(thread)));
return {
hasHiddenThreads: true,
hiddenThreads: threads.filter((thread) => !visibleThreadIds.has(thread.id)),
visibleThreads: threads.filter((thread) => visibleThreadIds.has(thread.id)),
hasHiddenThreads: hiddenThreads.length > 0,
hiddenThreads,
visibleThreads: threads.filter((thread) => visibleThreadIds.has(getThreadId(thread))),
Comment thread
cursor[bot] marked this conversation as resolved.
};
}

Expand Down
Loading
Loading