Skip to content

Commit b33eda1

Browse files
t3dotggclaude
andauthored
feat(web): beta Working section hides busy threads until they need you (#13926)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 0a04cc5 commit b33eda1

10 files changed

Lines changed: 517 additions & 44 deletions

File tree

‎apps/desktop/src/settings/DesktopClientSettings.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ const clientSettings: ClientSettings = {
6565
sidebarThreadSortOrder: "created_at",
6666
sidebarThreadPreviewCount: 6,
6767
legacySidebarEnabled: false,
68+
sidebarWorkingShelfEnabled: false,
6869
loadBalancingEnabled: false,
6970
loadBalancingWeights: { "environment-1": 75, "environment-2": 0 },
7071
pullRequestMergeMethodOverrides: {},

‎apps/web/src/components/Sidebar.drag.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,28 @@ describe("sidebar drag projection", () => {
621621
expect(result.get(sidebarMarkerId("snoozed-header"))).toEqual({ ...stationary, y: -46 });
622622
});
623623

624+
it("previews a time-ordered inbox drop at its time slot, above the Working shelf", () => {
625+
const items = [
626+
pinnedHeader,
627+
thread("p", "pinned"),
628+
divider,
629+
thread("a1", "active"),
630+
thread("a2", "active"),
631+
marker("working-header"),
632+
thread("w", "working"),
633+
settledHeader,
634+
marker("settled-placeholder"),
635+
];
636+
const input = { items, settledOrder: [], settledExpanded: false };
637+
// By pointer, the unpinned row lands between a1 and a2.
638+
expect(preview(input, "p", "a1").get("a2")?.y).toBe(0);
639+
// By time, it lands below a2, and the shelf does not move.
640+
const byTime = preview({ ...input, activeOrder: ["a1", "a2", "p"] }, "p", "a1");
641+
expect(byTime.get("a2")?.y).toBe(-83);
642+
expect(byTime.get(sidebarMarkerId("working-header"))).toEqual(stationary);
643+
expect(byTime.get("w")).toEqual(stationary);
644+
});
645+
624646
it("derives missing card geometry from the measured root scale", () => {
625647
const items = [
626648
pinnedHeader,

‎apps/web/src/components/Sidebar.drag.ts‎

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ const stationary = { x: 0, y: 0, scaleX: 1, scaleY: 1 };
1313
const hidden = { ...stationary, scaleY: 0 };
1414
type ThreadItem = Extract<SidebarListItem, { kind: "thread" }>;
1515
type Layout = Parameters<SortingStrategy>[0];
16+
const isShelfHeader = (item: SidebarListItem | undefined) =>
17+
item?.kind === "marker" &&
18+
(item.marker === "working-header" ||
19+
item.marker === "snoozed-header" ||
20+
item.marker === "settled-header");
1621

1722
/** Keep the lifted card below the Pins label, including when Pins is empty.
1823
* The container rect follows scrolling; the offset is measured once at pickup. */
@@ -57,13 +62,11 @@ export function createSidebarCollisionDetection(
5762
if (pointer.x >= boundary.left && pointer.x <= boundary.right) {
5863
if (pointer.y < previousY && pointer.y <= boundary.bottom) boundarySection = "pinned";
5964
else if (pointer.y > previousY && pointer.y >= boundary.top) boundarySection = "active";
60-
const nextHeader =
61-
args.droppableContainers.find(
62-
(container) => container.id === sidebarMarkerId("snoozed-header"),
63-
) ??
64-
args.droppableContainers.find(
65-
(container) => container.id === sidebarMarkerId("settled-header"),
66-
);
65+
const nextHeader = (["working-header", "snoozed-header", "settled-header"] as const)
66+
.map((marker) =>
67+
args.droppableContainers.find((container) => container.id === sidebarMarkerId(marker)),
68+
)
69+
.find((container) => container !== undefined);
6770
const activeBottom = nextHeader?.node.current?.getBoundingClientRect().top;
6871
if (boundarySection === "pinned" || (activeBottom != null && pointer.y < activeBottom)) {
6972
const target = collisions.find((collision) => {
@@ -97,6 +100,8 @@ export function createSidebarCollisionDetection(
97100
export function createSidebarSortingStrategy(input: {
98101
items: readonly SidebarListItem[];
99102
settledOrder: readonly string[];
103+
/** Time-ordered inbox (Working beta): where the lifted row would land. */
104+
activeOrder?: readonly string[];
100105
settledExpanded: boolean;
101106
settledVisibleCount?: number;
102107
routeThreadKey?: string | null;
@@ -121,6 +126,7 @@ export function createSidebarSortingStrategy(input: {
121126
const groups: Record<SidebarSection, ThreadItem[]> = {
122127
pinned: [],
123128
active: [],
129+
working: [],
124130
snoozed: [],
125131
settled: [],
126132
};
@@ -129,13 +135,13 @@ export function createSidebarSortingStrategy(input: {
129135
let headerScale: number | undefined;
130136
for (const [index, item] of items.entries()) {
131137
if (item.kind === "marker") {
132-
if (item.marker === "settled-header" || item.marker === "snoozed-header") {
138+
if (isShelfHeader(item)) {
133139
const height = rects[index]?.height;
134140
if (height) headerScale ??= height / 32;
135141
}
136142
continue;
137143
}
138-
if (item.section === "pinned" || item.section === "active")
144+
if (item.section === "pinned" || item.section === "active" || item.section === "working")
139145
cardHeight ??= rects[index]?.height;
140146
else slimHeight ??= rects[index]?.height;
141147
if (item.key !== active.key) groups[item.section].push(item);
@@ -152,7 +158,7 @@ export function createSidebarSortingStrategy(input: {
152158
? target.pinnedOrder
153159
: target.section === "settled"
154160
? input.settledOrder
155-
: target.activeOrder;
161+
: (input.activeOrder ?? target.activeOrder);
156162
const ranks = new Map(order.map((key, index) => [key, index]));
157163
const rank = ranks.get(active.key) ?? Number.POSITIVE_INFINITY;
158164
const index = group.findIndex(
@@ -180,6 +186,10 @@ export function createSidebarSortingStrategy(input: {
180186
projected.push(...groups.pinned);
181187
marker("pinned-divider");
182188
section("active");
189+
if (items.some((item) => item.kind === "marker" && item.marker === "working-header")) {
190+
marker("working-header");
191+
projected.push(...groups.working);
192+
}
183193
if (
184194
groups.snoozed.length > 0 ||
185195
((active.section !== "snoozed" || (input.snoozedThreadCount ?? 0) > 1) &&
@@ -194,7 +204,8 @@ export function createSidebarSortingStrategy(input: {
194204
const index = indices.get(sidebarListItemId(item));
195205
const rect = index === undefined ? undefined : rects[index];
196206
const fallback =
197-
item.kind === "thread" && (item.section === "pinned" || item.section === "active")
207+
item.kind === "thread" &&
208+
(item.section === "pinned" || item.section === "active" || item.section === "working")
198209
? cardHeight
199210
: slimHeight;
200211
const moved = item.kind === "thread" && item.key === active.key;
@@ -207,11 +218,7 @@ export function createSidebarSortingStrategy(input: {
207218
? fallback
208219
: (rect?.height ?? fallback);
209220
});
210-
const firstShelf = items.findIndex(
211-
(item) =>
212-
item.kind === "marker" &&
213-
(item.marker === "snoozed-header" || item.marker === "settled-header"),
214-
);
221+
const firstShelf = items.findIndex(isShelfHeader);
215222
const shelfRect = rects[firstShelf];
216223
const beforeShelf = rects[firstShelf - 1];
217224
const lastRect = rects.at(-1);
@@ -227,10 +234,7 @@ export function createSidebarSortingStrategy(input: {
227234
const result = items.map(() => hidden);
228235
let top = rects[0].top;
229236
for (const [projectedIndex, item] of projected.entries()) {
230-
if (
231-
item.kind === "marker" &&
232-
(item.marker === "snoozed-header" || item.marker === "settled-header")
233-
) {
237+
if (isShelfHeader(item)) {
234238
top += shelfSpace;
235239
shelfSpace = 0;
236240
}

‎apps/web/src/components/Sidebar.logic.test.ts‎

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
hasUnseenCompletion,
2121
isContextMenuPointerDown,
2222
isSidebarNestedLinkClick,
23+
isSidebarThreadWorking,
2324
isTrailingDoubleClick,
2425
orderItemsByPreferredIds,
2526
resolveProjectStatusIndicator,
@@ -32,6 +33,7 @@ import {
3233
shouldClearThreadSelectionOnMouseDown,
3334
shouldRecedeSidebarThread,
3435
sortLogicalProjectsForSidebar,
36+
sortInboxThreadsByReturn,
3537
resolveSidebarDropTarget,
3638
pinOrderKeyBetween,
3739
planSidebarThreadDrop,
@@ -2459,3 +2461,168 @@ describe("navigation after parking a thread", () => {
24592461
},
24602462
);
24612463
});
2464+
2465+
describe("Working shelf (beta)", () => {
2466+
const session = {
2467+
threadId: ThreadId.make("thread-1"),
2468+
status: "running" as const,
2469+
providerName: "Codex",
2470+
providerInstanceId: ProviderInstanceId.make("codex"),
2471+
runtimeMode: DEFAULT_RUNTIME_MODE,
2472+
activeTurnId: "turn-1" as never,
2473+
lastError: null,
2474+
updatedAt: "2026-03-09T10:00:00.000Z",
2475+
};
2476+
const idle = {
2477+
hasActionableProposedPlan: false,
2478+
hasPendingApprovals: false,
2479+
hasPendingUserInput: false,
2480+
interactionMode: "default" as const,
2481+
latestTurn: makeLatestTurn(),
2482+
session: { ...session, status: "ready" as const },
2483+
};
2484+
2485+
it("folds away running and monitoring threads only", () => {
2486+
expect(isSidebarThreadWorking({ ...idle, session })).toBe(true);
2487+
expect(isSidebarThreadWorking({ ...idle, backgroundLiveness: "monitoring" })).toBe(true);
2488+
expect(isSidebarThreadWorking(idle)).toBe(false);
2489+
expect(isSidebarThreadWorking({ ...idle, session, hasPendingApprovals: true })).toBe(false);
2490+
expect(isSidebarThreadWorking({ ...idle, session, hasPendingUserInput: true })).toBe(false);
2491+
expect(
2492+
isSidebarThreadWorking({
2493+
...idle,
2494+
backgroundLiveness: "working",
2495+
session: { ...session, status: "error" as const, lastError: "boom" },
2496+
}),
2497+
).toBe(false);
2498+
});
2499+
2500+
it("keeps a ready plan in the inbox while background work runs", () => {
2501+
expect(
2502+
isSidebarThreadWorking({
2503+
...idle,
2504+
interactionMode: "plan",
2505+
hasActionableProposedPlan: true,
2506+
backgroundLiveness: "working",
2507+
}),
2508+
).toBe(false);
2509+
});
2510+
2511+
describe("sortInboxThreadsByReturn", () => {
2512+
const thread = (
2513+
id: string,
2514+
input: { createdAt: string; completedAt?: string | null; unsettledAt?: string },
2515+
) => ({
2516+
id: ThreadId.make(id),
2517+
environmentId: localEnvironmentId,
2518+
createdAt: input.createdAt,
2519+
unsettledAt: input.unsettledAt ?? null,
2520+
latestTurn:
2521+
input.completedAt === undefined
2522+
? null
2523+
: { ...makeLatestTurn({ completedAt: input.completedAt }), requestedAt: input.createdAt },
2524+
});
2525+
2526+
it("puts the thread that finished last on top, whatever its age", () => {
2527+
const sorted = sortInboxThreadsByReturn([
2528+
thread("new", { createdAt: "2026-03-09T11:00:00.000Z" }),
2529+
thread("old-finished-now", {
2530+
createdAt: "2026-03-01T09:00:00.000Z",
2531+
completedAt: "2026-03-09T12:00:00.000Z",
2532+
}),
2533+
thread("reopened", {
2534+
createdAt: "2026-03-02T09:00:00.000Z",
2535+
unsettledAt: "2026-03-09T11:30:00.000Z",
2536+
}),
2537+
]);
2538+
expect(sorted.map((entry) => entry.id)).toEqual(["old-finished-now", "reopened", "new"]);
2539+
});
2540+
2541+
it("counts a return the server does not stamp, like an approval request", () => {
2542+
const waiting = thread("asks-approval", {
2543+
createdAt: "2026-03-09T09:00:00.000Z",
2544+
completedAt: null,
2545+
});
2546+
const finished = thread("finished", {
2547+
createdAt: "2026-03-09T09:30:00.000Z",
2548+
completedAt: "2026-03-09T11:00:00.000Z",
2549+
});
2550+
expect(sortInboxThreadsByReturn([finished, waiting]).map((entry) => entry.id)).toEqual([
2551+
"finished",
2552+
"asks-approval",
2553+
]);
2554+
expect(
2555+
sortInboxThreadsByReturn([finished, waiting], (entry) =>
2556+
entry === waiting ? Date.parse("2026-03-09T11:05:00.000Z") : undefined,
2557+
).map((entry) => entry.id),
2558+
).toEqual(["asks-approval", "finished"]);
2559+
});
2560+
});
2561+
2562+
describe("dragging", () => {
2563+
const marker = (name: SidebarListMarker): SidebarListItem => ({ kind: "marker", marker: name });
2564+
const row = (key: string, section: SidebarSection): SidebarListItem => ({
2565+
kind: "thread",
2566+
key,
2567+
section,
2568+
});
2569+
// Pinned p1 | Active a1 a2 | Working w1 | Settled s1
2570+
const items: readonly SidebarListItem[] = [
2571+
marker("pinned-header"),
2572+
row("p1", "pinned"),
2573+
marker("pinned-divider"),
2574+
row("a1", "active"),
2575+
row("a2", "active"),
2576+
marker("working-header"),
2577+
row("w1", "working"),
2578+
marker("settled-header"),
2579+
row("s1", "settled"),
2580+
];
2581+
2582+
it("never drops into the Working shelf, and keeps it out of the inbox order", () => {
2583+
expect(resolveSidebarDropTarget(items, "a1", "w1")).toBeNull();
2584+
expect(resolveSidebarDropTarget(items, "p1", "a2")).toEqual({
2585+
section: "active",
2586+
pinnedOrder: [],
2587+
activeOrder: ["a1", "a2", "p1"],
2588+
});
2589+
expect(resolveSidebarDropVerb("active", "working")).toBeNull();
2590+
});
2591+
2592+
it("only changes lifecycle when the inbox is time-ordered", () => {
2593+
const base = {
2594+
pinnedOrder: ["p1"],
2595+
pinnedKeysById: new Map([["p1", "m"]]),
2596+
activeOrder: ["a1", "a2"],
2597+
activeKeysById: new Map([
2598+
["a1", "f"],
2599+
["a2", "t"],
2600+
]),
2601+
activeTimeOrdered: true,
2602+
};
2603+
expect(
2604+
planSidebarThreadDrop({
2605+
...base,
2606+
activeKey: "a1",
2607+
activeSection: "active",
2608+
target: { section: "active", pinnedOrder: ["p1"], activeOrder: ["a2", "a1"] },
2609+
}),
2610+
).toEqual({ kind: "none" });
2611+
expect(
2612+
planSidebarThreadDrop({
2613+
...base,
2614+
activeKey: "p1",
2615+
activeSection: "pinned",
2616+
target: { section: "active", pinnedOrder: [], activeOrder: ["a1", "p1", "a2"] },
2617+
}),
2618+
).toEqual({
2619+
kind: "move-active",
2620+
order: null,
2621+
assignments: [],
2622+
unpin: true,
2623+
unsettle: false,
2624+
unsnooze: false,
2625+
});
2626+
});
2627+
});
2628+
});

0 commit comments

Comments
 (0)