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
1 change: 1 addition & 0 deletions apps/server/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {

assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+[");
assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]");
assert.equal(defaultsByCommand.get("thread.settle"), "mod+shift+x");
assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1");
assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9");
assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m");
Expand Down
293 changes: 293 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,26 @@ import {
getSidebarThreadIdsToPrewarm,
getVisibleSidebarThreadIds,
resolveAdjacentThreadId,
resolveNextActiveThreadIdAfterSettle,
getFallbackThreadIdAfterDelete,
getVisibleThreadsForProject,
getProjectSortTimestamp,
getSidebarThreadKeysNeedingChangeRequestReporter,
hasUnseenCompletion,
isContextMenuPointerDown,
isSidebarThreadEffectivelySettled,
isTrailingDoubleClick,
orderItemsByPreferredIds,
resolveProjectStatusIndicator,
resolveSidebarNewThreadSeedContext,
resolveSidebarNewThreadEnvMode,
resolveSidebarThreadGitCwd,
resolveSidebarStageBadgeLabel,
resolveThreadRowClassName,
resolveSidebarV2Status,
resolveThreadStatusPill,
shouldClearThreadSelectionOnMouseDown,
shouldDismissThreadSettleConfirmation,
sortThreadsForSidebarV2,
sortProjectsForSidebar,
sortScopedProjectsForSidebar,
Expand All @@ -37,6 +42,7 @@ import {
DEFAULT_INTERACTION_MODE,
DEFAULT_RUNTIME_MODE,
type Project,
type SidebarThreadSummary,
type Thread,
} from "../types";

Expand Down Expand Up @@ -564,6 +570,282 @@ describe("resolveAdjacentThreadId", () => {
});
});

describe("resolveNextActiveThreadIdAfterSettle", () => {
it("wraps to the next active thread while skipping settled threads", () => {
expect(
resolveNextActiveThreadIdAfterSettle({
threadIds: ["thread-1", "thread-2", "thread-3"],
settledThreadId: "thread-1",
isActive: (threadId) => threadId === "thread-3",
}),
).toBe("thread-3");
expect(
resolveNextActiveThreadIdAfterSettle({
threadIds: ["thread-1", "thread-2", "thread-3"],
settledThreadId: "thread-3",
isActive: (threadId) => threadId === "thread-1",
}),
).toBe("thread-1");
});

it("returns null when settling leaves no active thread", () => {
expect(
resolveNextActiveThreadIdAfterSettle({
threadIds: ["thread-1", "thread-2"],
settledThreadId: "thread-1",
isActive: () => false,
}),
).toBeNull();
});

it("falls back to an active thread hidden outside the visible keyboard order", () => {
expect(
resolveNextActiveThreadIdAfterSettle({
threadIds: ["visible-current", "visible-settled"],
fallbackThreadIds: ["visible-current", "preview-hidden-active", "collapsed-project-active"],
settledThreadId: "visible-current",
isActive: (threadId) => threadId.endsWith("active"),
}),
).toBe("preview-hidden-active");
});

it("prefers the next visible active thread before hidden fallbacks", () => {
expect(
resolveNextActiveThreadIdAfterSettle({
threadIds: ["visible-current", "visible-next"],
fallbackThreadIds: ["visible-current", "hidden-active"],
settledThreadId: "visible-current",
isActive: (threadId) => threadId !== "visible-current",
}),
).toBe("visible-next");
});

it("rotates the full logical order when the settled thread is hidden", () => {
expect(
resolveNextActiveThreadIdAfterSettle({
threadIds: ["visible-earlier", "visible-later"],
fallbackThreadIds: [
"visible-earlier",
"hidden-current",
"logical-next-active",
"earlier-active",
],
settledThreadId: "hidden-current",
isActive: (threadId) => threadId.endsWith("active"),
}),
).toBe("logical-next-active");
});

it("finds an active full-list target when V2's scoped order excludes the current route", () => {
expect(
resolveNextActiveThreadIdAfterSettle({
threadIds: ["scoped-settled"],
fallbackThreadIds: [
"full-list-before",
"hidden-current",
"hidden-settled",
"hidden-active",
],
settledThreadId: "hidden-current",
isActive: (threadId) => threadId === "hidden-active",
}),
).toBe("hidden-active");
});

it("rotates hidden fallbacks after exhausting visible active threads", () => {
expect(
resolveNextActiveThreadIdAfterSettle({
threadIds: ["visible-current", "visible-settled"],
fallbackThreadIds: ["earlier-active", "visible-current", "hidden-next-active"],
settledThreadId: "visible-current",
isActive: (threadId) => threadId.endsWith("active"),
}),
).toBe("hidden-next-active");
});
});

describe("isSidebarThreadEffectivelySettled", () => {
const now = "2026-03-15T12:00:00.000Z";

it("classifies inactivity and closed change requests as settled", () => {
const thread = makeThreadShell({
latestUserMessageAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});

expect(
isSidebarThreadEffectivelySettled({
thread,
settlementSupported: true,
now,
autoSettleAfterDays: 3,
}),
).toBe(true);
expect(
isSidebarThreadEffectivelySettled({
thread: makeThreadShell(),
settlementSupported: true,
now,
autoSettleAfterDays: null,
changeRequestState: "closed",
}),
).toBe(true);
});

it("keeps auto-settlement disabled on servers without lifecycle support", () => {
expect(
isSidebarThreadEffectivelySettled({
thread: makeThreadShell(),
settlementSupported: false,
now,
autoSettleAfterDays: null,
changeRequestState: "merged",
}),
).toBe(false);
});

it("keeps auto-settled threads out of post-settle navigation targets", () => {
const threads = new Map([
["current", makeThreadShell({ id: ThreadId.make("current") })],
[
"inactive",
makeThreadShell({
id: ThreadId.make("inactive"),
latestUserMessageAt: "2026-03-01T12:00:00.000Z",
}),
],
["active", makeThreadShell({ id: ThreadId.make("active") })],
]);

expect(
resolveNextActiveThreadIdAfterSettle({
threadIds: ["current", "inactive", "active"],
settledThreadId: "current",
isActive: (threadId) => {
const thread = threads.get(threadId);
return (
thread !== undefined &&
!isSidebarThreadEffectivelySettled({
thread,
settlementSupported: true,
now,
autoSettleAfterDays: 3,
})
);
},
}),
).toBe("active");
});
});

describe("shouldDismissThreadSettleConfirmation", () => {
it("dismisses a confirmation when the route changes away from its target", () => {
expect(
shouldDismissThreadSettleConfirmation({
confirmationThreadKey: "thread-1",
routeThreadKey: "thread-2",
targetExists: true,
targetExplicitlySettled: false,
}),
).toBe(true);
});

it("keeps a confirmation open while its unsettled target remains the route", () => {
expect(
shouldDismissThreadSettleConfirmation({
confirmationThreadKey: "thread-1",
routeThreadKey: "thread-1",
targetExists: true,
targetExplicitlySettled: false,
}),
).toBe(false);
});

it("keeps a confirmation open when inactivity only makes the target effectively settled", () => {
const target = makeThreadShell({
latestUserMessageAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
settledOverride: null,
});

expect(
isSidebarThreadEffectivelySettled({
thread: target,
settlementSupported: true,
now: "2026-03-15T12:00:00.000Z",
autoSettleAfterDays: 3,
}),
).toBe(true);
expect(
shouldDismissThreadSettleConfirmation({
confirmationThreadKey: "thread-1",
routeThreadKey: "thread-1",
targetExists: true,
targetExplicitlySettled: target.settledOverride === "settled",
}),
).toBe(false);
});

it("dismisses a confirmation after the server explicitly settles its target", () => {
expect(
shouldDismissThreadSettleConfirmation({
confirmationThreadKey: "thread-1",
routeThreadKey: "thread-1",
targetExists: true,
targetExplicitlySettled: true,
}),
).toBe(true);
});
});

describe("getSidebarThreadKeysNeedingChangeRequestReporter", () => {
it("keeps live PR-state reporters for collapsed and preview-hidden rows", () => {
expect(
getSidebarThreadKeysNeedingChangeRequestReporter(
["visible", "preview-hidden", "collapsed"],
new Set(["visible"]),
true,
),
).toEqual(["preview-hidden", "collapsed"]);
});

it("reports every thread when settings replaces the sidebar thread rows", () => {
expect(
getSidebarThreadKeysNeedingChangeRequestReporter(
["would-be-visible", "preview-hidden", "collapsed"],
new Set(["would-be-visible"]),
false,
),
).toEqual(["would-be-visible", "preview-hidden", "collapsed"]);
});
});

describe("resolveSidebarThreadGitCwd", () => {
it("uses the same fallback order for visible and hidden thread reporters", () => {
expect(
resolveSidebarThreadGitCwd({
worktreePath: "/worktree",
threadProjectCwd: "/thread-project",
sidebarProjectCwd: "/sidebar-project",
}),
).toBe("/worktree");
expect(
resolveSidebarThreadGitCwd({
worktreePath: null,
threadProjectCwd: "/thread-project",
sidebarProjectCwd: "/sidebar-project",
}),
).toBe("/thread-project");
expect(
resolveSidebarThreadGitCwd({
worktreePath: null,
threadProjectCwd: null,
sidebarProjectCwd: "/sidebar-project",
}),
).toBe("/sidebar-project");
});
});

describe("getVisibleSidebarThreadIds", () => {
it("returns only the rendered visible thread order across projects", () => {
expect(
Expand Down Expand Up @@ -1009,6 +1291,17 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
};
}

function makeThreadShell(overrides: Partial<SidebarThreadSummary> = {}): SidebarThreadSummary {
return {
...makeThread(),
latestUserMessageAt: null,
hasPendingApprovals: false,
hasPendingUserInput: false,
hasActionableProposedPlan: false,
...overrides,
};
}

describe("getFallbackThreadIdAfterDelete", () => {
it("returns the top remaining thread in the deleted thread's project sidebar order", () => {
const fallbackThreadId = getFallbackThreadIdAfterDelete({
Expand Down
Loading
Loading