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
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ const clientSettings: ClientSettings = {
sidebarThreadSortOrder: "created_at",
sidebarThreadPreviewCount: 6,
legacySidebarEnabled: false,
sidebarWorkingShelfEnabled: false,
loadBalancingEnabled: false,
loadBalancingWeights: { "environment-1": 75, "environment-2": 0 },
pullRequestMergeMethodOverrides: {},
Expand Down
22 changes: 22 additions & 0 deletions apps/web/src/components/Sidebar.drag.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,28 @@ describe("sidebar drag projection", () => {
expect(result.get(sidebarMarkerId("snoozed-header"))).toEqual({ ...stationary, y: -46 });
});

it("previews a time-ordered inbox drop at its time slot, above the Working shelf", () => {
const items = [
pinnedHeader,
thread("p", "pinned"),
divider,
thread("a1", "active"),
thread("a2", "active"),
marker("working-header"),
thread("w", "working"),
settledHeader,
marker("settled-placeholder"),
];
const input = { items, settledOrder: [], settledExpanded: false };
// By pointer, the unpinned row lands between a1 and a2.
expect(preview(input, "p", "a1").get("a2")?.y).toBe(0);
// By time, it lands below a2, and the shelf does not move.
const byTime = preview({ ...input, activeOrder: ["a1", "a2", "p"] }, "p", "a1");
expect(byTime.get("a2")?.y).toBe(-83);
expect(byTime.get(sidebarMarkerId("working-header"))).toEqual(stationary);
expect(byTime.get("w")).toEqual(stationary);
});

it("derives missing card geometry from the measured root scale", () => {
const items = [
pinnedHeader,
Expand Down
44 changes: 24 additions & 20 deletions apps/web/src/components/Sidebar.drag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ const stationary = { x: 0, y: 0, scaleX: 1, scaleY: 1 };
const hidden = { ...stationary, scaleY: 0 };
type ThreadItem = Extract<SidebarListItem, { kind: "thread" }>;
type Layout = Parameters<SortingStrategy>[0];
const isShelfHeader = (item: SidebarListItem | undefined) =>
item?.kind === "marker" &&
(item.marker === "working-header" ||
item.marker === "snoozed-header" ||
item.marker === "settled-header");

/** Keep the lifted card below the Pins label, including when Pins is empty.
* The container rect follows scrolling; the offset is measured once at pickup. */
Expand Down Expand Up @@ -57,13 +62,11 @@ export function createSidebarCollisionDetection(
if (pointer.x >= boundary.left && pointer.x <= boundary.right) {
if (pointer.y < previousY && pointer.y <= boundary.bottom) boundarySection = "pinned";
else if (pointer.y > previousY && pointer.y >= boundary.top) boundarySection = "active";
const nextHeader =
args.droppableContainers.find(
(container) => container.id === sidebarMarkerId("snoozed-header"),
) ??
args.droppableContainers.find(
(container) => container.id === sidebarMarkerId("settled-header"),
);
const nextHeader = (["working-header", "snoozed-header", "settled-header"] as const)
.map((marker) =>
args.droppableContainers.find((container) => container.id === sidebarMarkerId(marker)),
)
.find((container) => container !== undefined);
const activeBottom = nextHeader?.node.current?.getBoundingClientRect().top;
if (boundarySection === "pinned" || (activeBottom != null && pointer.y < activeBottom)) {
const target = collisions.find((collision) => {
Expand Down Expand Up @@ -97,6 +100,8 @@ export function createSidebarCollisionDetection(
export function createSidebarSortingStrategy(input: {
items: readonly SidebarListItem[];
settledOrder: readonly string[];
/** Time-ordered inbox (Working beta): where the lifted row would land. */
activeOrder?: readonly string[];
settledExpanded: boolean;
settledVisibleCount?: number;
routeThreadKey?: string | null;
Expand All @@ -121,6 +126,7 @@ export function createSidebarSortingStrategy(input: {
const groups: Record<SidebarSection, ThreadItem[]> = {
pinned: [],
active: [],
working: [],
snoozed: [],
settled: [],
};
Expand All @@ -129,13 +135,13 @@ export function createSidebarSortingStrategy(input: {
let headerScale: number | undefined;
for (const [index, item] of items.entries()) {
if (item.kind === "marker") {
if (item.marker === "settled-header" || item.marker === "snoozed-header") {
if (isShelfHeader(item)) {
const height = rects[index]?.height;
if (height) headerScale ??= height / 32;
}
continue;
}
if (item.section === "pinned" || item.section === "active")
if (item.section === "pinned" || item.section === "active" || item.section === "working")
cardHeight ??= rects[index]?.height;
else slimHeight ??= rects[index]?.height;
if (item.key !== active.key) groups[item.section].push(item);
Expand All @@ -152,7 +158,7 @@ export function createSidebarSortingStrategy(input: {
? target.pinnedOrder
: target.section === "settled"
? input.settledOrder
: target.activeOrder;
: (input.activeOrder ?? target.activeOrder);
const ranks = new Map(order.map((key, index) => [key, index]));
const rank = ranks.get(active.key) ?? Number.POSITIVE_INFINITY;
const index = group.findIndex(
Expand Down Expand Up @@ -180,6 +186,10 @@ export function createSidebarSortingStrategy(input: {
projected.push(...groups.pinned);
marker("pinned-divider");
section("active");
if (items.some((item) => item.kind === "marker" && item.marker === "working-header")) {
marker("working-header");
projected.push(...groups.working);
}
if (
groups.snoozed.length > 0 ||
((active.section !== "snoozed" || (input.snoozedThreadCount ?? 0) > 1) &&
Expand All @@ -194,7 +204,8 @@ export function createSidebarSortingStrategy(input: {
const index = indices.get(sidebarListItemId(item));
const rect = index === undefined ? undefined : rects[index];
const fallback =
item.kind === "thread" && (item.section === "pinned" || item.section === "active")
item.kind === "thread" &&
(item.section === "pinned" || item.section === "active" || item.section === "working")
? cardHeight
: slimHeight;
const moved = item.kind === "thread" && item.key === active.key;
Expand All @@ -207,11 +218,7 @@ export function createSidebarSortingStrategy(input: {
? fallback
: (rect?.height ?? fallback);
});
const firstShelf = items.findIndex(
(item) =>
item.kind === "marker" &&
(item.marker === "snoozed-header" || item.marker === "settled-header"),
);
const firstShelf = items.findIndex(isShelfHeader);
const shelfRect = rects[firstShelf];
const beforeShelf = rects[firstShelf - 1];
const lastRect = rects.at(-1);
Expand All @@ -227,10 +234,7 @@ export function createSidebarSortingStrategy(input: {
const result = items.map(() => hidden);
let top = rects[0].top;
for (const [projectedIndex, item] of projected.entries()) {
if (
item.kind === "marker" &&
(item.marker === "snoozed-header" || item.marker === "settled-header")
) {
if (isShelfHeader(item)) {
top += shelfSpace;
shelfSpace = 0;
}
Expand Down
167 changes: 167 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
hasUnseenCompletion,
isContextMenuPointerDown,
isSidebarNestedLinkClick,
isSidebarThreadWorking,
isTrailingDoubleClick,
orderItemsByPreferredIds,
resolveProjectStatusIndicator,
Expand All @@ -32,6 +33,7 @@ import {
shouldClearThreadSelectionOnMouseDown,
shouldRecedeSidebarThread,
sortLogicalProjectsForSidebar,
sortInboxThreadsByReturn,
resolveSidebarDropTarget,
pinOrderKeyBetween,
planPinnedReorder,
Expand Down Expand Up @@ -2559,3 +2561,168 @@ describe("navigation after parking a thread", () => {
},
);
});

describe("Working shelf (beta)", () => {
const session = {
threadId: ThreadId.make("thread-1"),
status: "running" as const,
providerName: "Codex",
providerInstanceId: ProviderInstanceId.make("codex"),
runtimeMode: DEFAULT_RUNTIME_MODE,
activeTurnId: "turn-1" as never,
lastError: null,
updatedAt: "2026-03-09T10:00:00.000Z",
};
const idle = {
hasActionableProposedPlan: false,
hasPendingApprovals: false,
hasPendingUserInput: false,
interactionMode: "default" as const,
latestTurn: makeLatestTurn(),
session: { ...session, status: "ready" as const },
};

it("folds away running and monitoring threads only", () => {
expect(isSidebarThreadWorking({ ...idle, session })).toBe(true);
expect(isSidebarThreadWorking({ ...idle, backgroundLiveness: "monitoring" })).toBe(true);
expect(isSidebarThreadWorking(idle)).toBe(false);
expect(isSidebarThreadWorking({ ...idle, session, hasPendingApprovals: true })).toBe(false);
expect(isSidebarThreadWorking({ ...idle, session, hasPendingUserInput: true })).toBe(false);
expect(
isSidebarThreadWorking({
...idle,
backgroundLiveness: "working",
session: { ...session, status: "error" as const, lastError: "boom" },
}),
).toBe(false);
});

it("keeps a ready plan in the inbox while background work runs", () => {
expect(
isSidebarThreadWorking({
...idle,
interactionMode: "plan",
hasActionableProposedPlan: true,
backgroundLiveness: "working",
}),
).toBe(false);
});

describe("sortInboxThreadsByReturn", () => {
const thread = (
id: string,
input: { createdAt: string; completedAt?: string | null; unsettledAt?: string },
) => ({
id: ThreadId.make(id),
environmentId: localEnvironmentId,
createdAt: input.createdAt,
unsettledAt: input.unsettledAt ?? null,
latestTurn:
input.completedAt === undefined
? null
: { ...makeLatestTurn({ completedAt: input.completedAt }), requestedAt: input.createdAt },
});

it("puts the thread that finished last on top, whatever its age", () => {
const sorted = sortInboxThreadsByReturn([
thread("new", { createdAt: "2026-03-09T11:00:00.000Z" }),
thread("old-finished-now", {
createdAt: "2026-03-01T09:00:00.000Z",
completedAt: "2026-03-09T12:00:00.000Z",
}),
thread("reopened", {
createdAt: "2026-03-02T09:00:00.000Z",
unsettledAt: "2026-03-09T11:30:00.000Z",
}),
]);
expect(sorted.map((entry) => entry.id)).toEqual(["old-finished-now", "reopened", "new"]);
});

it("counts a return the server does not stamp, like an approval request", () => {
const waiting = thread("asks-approval", {
createdAt: "2026-03-09T09:00:00.000Z",
completedAt: null,
});
const finished = thread("finished", {
createdAt: "2026-03-09T09:30:00.000Z",
completedAt: "2026-03-09T11:00:00.000Z",
});
expect(sortInboxThreadsByReturn([finished, waiting]).map((entry) => entry.id)).toEqual([
"finished",
"asks-approval",
]);
expect(
sortInboxThreadsByReturn([finished, waiting], (entry) =>
entry === waiting ? Date.parse("2026-03-09T11:05:00.000Z") : undefined,
).map((entry) => entry.id),
).toEqual(["asks-approval", "finished"]);
});
});

describe("dragging", () => {
const marker = (name: SidebarListMarker): SidebarListItem => ({ kind: "marker", marker: name });
const row = (key: string, section: SidebarSection): SidebarListItem => ({
kind: "thread",
key,
section,
});
// Pinned p1 | Active a1 a2 | Working w1 | Settled s1
const items: readonly SidebarListItem[] = [
marker("pinned-header"),
row("p1", "pinned"),
marker("pinned-divider"),
row("a1", "active"),
row("a2", "active"),
marker("working-header"),
row("w1", "working"),
marker("settled-header"),
row("s1", "settled"),
];

it("never drops into the Working shelf, and keeps it out of the inbox order", () => {
expect(resolveSidebarDropTarget(items, "a1", "w1")).toBeNull();
expect(resolveSidebarDropTarget(items, "p1", "a2")).toEqual({
section: "active",
pinnedOrder: [],
activeOrder: ["a1", "a2", "p1"],
});
expect(resolveSidebarDropVerb("active", "working")).toBeNull();
});

it("only changes lifecycle when the inbox is time-ordered", () => {
const base = {
pinnedOrder: ["p1"],
pinnedKeysById: new Map([["p1", "m"]]),
activeOrder: ["a1", "a2"],
activeKeysById: new Map([
["a1", "f"],
["a2", "t"],
]),
activeTimeOrdered: true,
};
expect(
planSidebarThreadDrop({
...base,
activeKey: "a1",
activeSection: "active",
target: { section: "active", pinnedOrder: ["p1"], activeOrder: ["a2", "a1"] },
}),
).toEqual({ kind: "none" });
expect(
planSidebarThreadDrop({
...base,
activeKey: "p1",
activeSection: "pinned",
target: { section: "active", pinnedOrder: [], activeOrder: ["a1", "p1", "a2"] },
}),
).toEqual({
kind: "move-active",
order: null,
assignments: [],
unpin: true,
unsettle: false,
unsnooze: false,
});
});
});
});
Loading
Loading