authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-01 17:08:35-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-01 17:08:35-07:00
logb33eda13998b5b0571daaf266c87f6f31af41f7d
tree450da523a3fd82916bd6a81cdaa246422db6d9e7
parent0a04cc50def48121b9ece038c7df8b7fa789074b
signature Signed by PGP key B5690EEEBB952194

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>

10 files changed, 517 insertions(+), 44 deletions(-)

apps/desktop/src/settings/DesktopClientSettings.test.ts+1
...@@ -65,6 +65,7 @@ const clientSettings: ClientSettings = {...@@ -65,6 +65,7 @@ const clientSettings: ClientSettings = {
65 sidebarThreadSortOrder: "created_at",65 sidebarThreadSortOrder: "created_at",
66 sidebarThreadPreviewCount: 6,66 sidebarThreadPreviewCount: 6,
67 legacySidebarEnabled: false,67 legacySidebarEnabled: false,
68 sidebarWorkingShelfEnabled: false,
68 loadBalancingEnabled: false,69 loadBalancingEnabled: false,
69 loadBalancingWeights: { "environment-1": 75, "environment-2": 0 },70 loadBalancingWeights: { "environment-1": 75, "environment-2": 0 },
70 pullRequestMergeMethodOverrides: {},71 pullRequestMergeMethodOverrides: {},
apps/web/src/components/Sidebar.drag.test.ts+22
...@@ -621,6 +621,28 @@ describe("sidebar drag projection", () => {...@@ -621,6 +621,28 @@ describe("sidebar drag projection", () => {
621 expect(result.get(sidebarMarkerId("snoozed-header"))).toEqual({ ...stationary, y: -46 });621 expect(result.get(sidebarMarkerId("snoozed-header"))).toEqual({ ...stationary, y: -46 });
622 });622 });
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
624 it("derives missing card geometry from the measured root scale", () => {646 it("derives missing card geometry from the measured root scale", () => {
625 const items = [647 const items = [
626 pinnedHeader,648 pinnedHeader,
apps/web/src/components/Sidebar.drag.ts+24-20
...@@ -13,6 +13,11 @@ const stationary = { x: 0, y: 0, scaleX: 1, scaleY: 1 };...@@ -13,6 +13,11 @@ const stationary = { x: 0, y: 0, scaleX: 1, scaleY: 1 };
13const hidden = { ...stationary, scaleY: 0 };13const hidden = { ...stationary, scaleY: 0 };
14type ThreadItem = Extract<SidebarListItem, { kind: "thread" }>;14type ThreadItem = Extract<SidebarListItem, { kind: "thread" }>;
15type Layout = Parameters<SortingStrategy>[0];15type Layout = Parameters<SortingStrategy>[0];
16const isShelfHeader = (item: SidebarListItem | undefined) =>
17 item?.kind === "marker" &&
18 (item.marker === "working-header" ||
19 item.marker === "snoozed-header" ||
20 item.marker === "settled-header");
1621
17/** Keep the lifted card below the Pins label, including when Pins is empty.22/** Keep the lifted card below the Pins label, including when Pins is empty.
18 * The container rect follows scrolling; the offset is measured once at pickup. */23 * The container rect follows scrolling; the offset is measured once at pickup. */
...@@ -57,13 +62,11 @@ export function createSidebarCollisionDetection(...@@ -57,13 +62,11 @@ export function createSidebarCollisionDetection(
57 if (pointer.x >= boundary.left && pointer.x <= boundary.right) {62 if (pointer.x >= boundary.left && pointer.x <= boundary.right) {
58 if (pointer.y < previousY && pointer.y <= boundary.bottom) boundarySection = "pinned";63 if (pointer.y < previousY && pointer.y <= boundary.bottom) boundarySection = "pinned";
59 else if (pointer.y > previousY && pointer.y >= boundary.top) boundarySection = "active";64 else if (pointer.y > previousY && pointer.y >= boundary.top) boundarySection = "active";
60 const nextHeader =65 const nextHeader = (["working-header", "snoozed-header", "settled-header"] as const)
61 args.droppableContainers.find(66 .map((marker) =>
62 (container) => container.id === sidebarMarkerId("snoozed-header"),67 args.droppableContainers.find((container) => container.id === sidebarMarkerId(marker)),
63 ) ??68 )
64 args.droppableContainers.find(69 .find((container) => container !== undefined);
65 (container) => container.id === sidebarMarkerId("settled-header"),
66 );
67 const activeBottom = nextHeader?.node.current?.getBoundingClientRect().top;70 const activeBottom = nextHeader?.node.current?.getBoundingClientRect().top;
68 if (boundarySection === "pinned" || (activeBottom != null && pointer.y < activeBottom)) {71 if (boundarySection === "pinned" || (activeBottom != null && pointer.y < activeBottom)) {
69 const target = collisions.find((collision) => {72 const target = collisions.find((collision) => {
...@@ -97,6 +100,8 @@ export function createSidebarCollisionDetection(...@@ -97,6 +100,8 @@ export function createSidebarCollisionDetection(
97export function createSidebarSortingStrategy(input: {100export function createSidebarSortingStrategy(input: {
98 items: readonly SidebarListItem[];101 items: readonly SidebarListItem[];
99 settledOrder: readonly string[];102 settledOrder: readonly string[];
103 /** Time-ordered inbox (Working beta): where the lifted row would land. */
104 activeOrder?: readonly string[];
100 settledExpanded: boolean;105 settledExpanded: boolean;
101 settledVisibleCount?: number;106 settledVisibleCount?: number;
102 routeThreadKey?: string | null;107 routeThreadKey?: string | null;
...@@ -121,6 +126,7 @@ export function createSidebarSortingStrategy(input: {...@@ -121,6 +126,7 @@ export function createSidebarSortingStrategy(input: {
121 const groups: Record<SidebarSection, ThreadItem[]> = {126 const groups: Record<SidebarSection, ThreadItem[]> = {
122 pinned: [],127 pinned: [],
123 active: [],128 active: [],
129 working: [],
124 snoozed: [],130 snoozed: [],
125 settled: [],131 settled: [],
126 };132 };
...@@ -129,13 +135,13 @@ export function createSidebarSortingStrategy(input: {...@@ -129,13 +135,13 @@ export function createSidebarSortingStrategy(input: {
129 let headerScale: number | undefined;135 let headerScale: number | undefined;
130 for (const [index, item] of items.entries()) {136 for (const [index, item] of items.entries()) {
131 if (item.kind === "marker") {137 if (item.kind === "marker") {
132 if (item.marker === "settled-header" || item.marker === "snoozed-header") {138 if (isShelfHeader(item)) {
133 const height = rects[index]?.height;139 const height = rects[index]?.height;
134 if (height) headerScale ??= height / 32;140 if (height) headerScale ??= height / 32;
135 }141 }
136 continue;142 continue;
137 }143 }
138 if (item.section === "pinned" || item.section === "active")144 if (item.section === "pinned" || item.section === "active" || item.section === "working")
139 cardHeight ??= rects[index]?.height;145 cardHeight ??= rects[index]?.height;
140 else slimHeight ??= rects[index]?.height;146 else slimHeight ??= rects[index]?.height;
141 if (item.key !== active.key) groups[item.section].push(item);147 if (item.key !== active.key) groups[item.section].push(item);
...@@ -152,7 +158,7 @@ export function createSidebarSortingStrategy(input: {...@@ -152,7 +158,7 @@ export function createSidebarSortingStrategy(input: {
152 ? target.pinnedOrder158 ? target.pinnedOrder
153 : target.section === "settled"159 : target.section === "settled"
154 ? input.settledOrder160 ? input.settledOrder
155 : target.activeOrder;161 : (input.activeOrder ?? target.activeOrder);
156 const ranks = new Map(order.map((key, index) => [key, index]));162 const ranks = new Map(order.map((key, index) => [key, index]));
157 const rank = ranks.get(active.key) ?? Number.POSITIVE_INFINITY;163 const rank = ranks.get(active.key) ?? Number.POSITIVE_INFINITY;
158 const index = group.findIndex(164 const index = group.findIndex(
...@@ -180,6 +186,10 @@ export function createSidebarSortingStrategy(input: {...@@ -180,6 +186,10 @@ export function createSidebarSortingStrategy(input: {
180 projected.push(...groups.pinned);186 projected.push(...groups.pinned);
181 marker("pinned-divider");187 marker("pinned-divider");
182 section("active");188 section("active");
189 if (items.some((item) => item.kind === "marker" && item.marker === "working-header")) {
190 marker("working-header");
191 projected.push(...groups.working);
192 }
183 if (193 if (
184 groups.snoozed.length > 0 ||194 groups.snoozed.length > 0 ||
185 ((active.section !== "snoozed" || (input.snoozedThreadCount ?? 0) > 1) &&195 ((active.section !== "snoozed" || (input.snoozedThreadCount ?? 0) > 1) &&
...@@ -194,7 +204,8 @@ export function createSidebarSortingStrategy(input: {...@@ -194,7 +204,8 @@ export function createSidebarSortingStrategy(input: {
194 const index = indices.get(sidebarListItemId(item));204 const index = indices.get(sidebarListItemId(item));
195 const rect = index === undefined ? undefined : rects[index];205 const rect = index === undefined ? undefined : rects[index];
196 const fallback =206 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")
198 ? cardHeight209 ? cardHeight
199 : slimHeight;210 : slimHeight;
200 const moved = item.kind === "thread" && item.key === active.key;211 const moved = item.kind === "thread" && item.key === active.key;
...@@ -207,11 +218,7 @@ export function createSidebarSortingStrategy(input: {...@@ -207,11 +218,7 @@ export function createSidebarSortingStrategy(input: {
207 ? fallback218 ? fallback
208 : (rect?.height ?? fallback);219 : (rect?.height ?? fallback);
209 });220 });
210 const firstShelf = items.findIndex(221 const firstShelf = items.findIndex(isShelfHeader);
211 (item) =>
212 item.kind === "marker" &&
213 (item.marker === "snoozed-header" || item.marker === "settled-header"),
214 );
215 const shelfRect = rects[firstShelf];222 const shelfRect = rects[firstShelf];
216 const beforeShelf = rects[firstShelf - 1];223 const beforeShelf = rects[firstShelf - 1];
217 const lastRect = rects.at(-1);224 const lastRect = rects.at(-1);
...@@ -227,10 +234,7 @@ export function createSidebarSortingStrategy(input: {...@@ -227,10 +234,7 @@ export function createSidebarSortingStrategy(input: {
227 const result = items.map(() => hidden);234 const result = items.map(() => hidden);
228 let top = rects[0].top;235 let top = rects[0].top;
229 for (const [projectedIndex, item] of projected.entries()) {236 for (const [projectedIndex, item] of projected.entries()) {
230 if (237 if (isShelfHeader(item)) {
231 item.kind === "marker" &&
232 (item.marker === "snoozed-header" || item.marker === "settled-header")
233 ) {
234 top += shelfSpace;238 top += shelfSpace;
235 shelfSpace = 0;239 shelfSpace = 0;
236 }240 }
apps/web/src/components/Sidebar.logic.test.ts+167
...@@ -20,6 +20,7 @@ import {...@@ -20,6 +20,7 @@ import {
20 hasUnseenCompletion,20 hasUnseenCompletion,
21 isContextMenuPointerDown,21 isContextMenuPointerDown,
22 isSidebarNestedLinkClick,22 isSidebarNestedLinkClick,
23 isSidebarThreadWorking,
23 isTrailingDoubleClick,24 isTrailingDoubleClick,
24 orderItemsByPreferredIds,25 orderItemsByPreferredIds,
25 resolveProjectStatusIndicator,26 resolveProjectStatusIndicator,
...@@ -32,6 +33,7 @@ import {...@@ -32,6 +33,7 @@ import {
32 shouldClearThreadSelectionOnMouseDown,33 shouldClearThreadSelectionOnMouseDown,
33 shouldRecedeSidebarThread,34 shouldRecedeSidebarThread,
34 sortLogicalProjectsForSidebar,35 sortLogicalProjectsForSidebar,
36 sortInboxThreadsByReturn,
35 resolveSidebarDropTarget,37 resolveSidebarDropTarget,
36 pinOrderKeyBetween,38 pinOrderKeyBetween,
37 planSidebarThreadDrop,39 planSidebarThreadDrop,
...@@ -2459,3 +2461,168 @@ describe("navigation after parking a thread", () => {...@@ -2459,3 +2461,168 @@ describe("navigation after parking a thread", () => {
2459 },2461 },
2460 );2462 );
2461});2463});
2464
2465describe("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});
apps/web/src/components/Sidebar.logic.ts+74-9
...@@ -125,9 +125,10 @@ export const animateSidebarLayoutChanges: AnimateLayoutChanges = (args) =>...@@ -125,9 +125,10 @@ export const animateSidebarLayoutChanges: AnimateLayoutChanges = (args) =>
125// the lifecycle action; Sidebar.drag previews the resulting layout. Pinned125// the lifecycle action; Sidebar.drag previews the resulting layout. Pinned
126// and active threads keep the dragged position; settled threads use time126// and active threads keep the dragged position; settled threads use time
127// order. Snoozed rows can leave the shelf, but dropping into it is not127// order. Snoozed rows can leave the shelf, but dropping into it is not
128// supported because snoozing requires a wake time.128// supported because snoozing requires a wake time. The Working shelf (beta)
129// follows live status, so it is neither a drag source nor a destination.
129130
130export type SidebarSection = "pinned" | "active" | "snoozed" | "settled";131export type SidebarSection = "pinned" | "active" | "working" | "snoozed" | "settled";
131132
132/** Sortable ids: thread rows use their scoped key; structural items use a133/** Sortable ids: thread rows use their scoped key; structural items use a
133 colon-free prefix: scoped thread keys always contain a colon. */134 colon-free prefix: scoped thread keys always contain a colon. */
...@@ -141,6 +142,7 @@ export type SidebarListMarker =...@@ -141,6 +142,7 @@ export type SidebarListMarker =
141 | "settled-placeholder"142 | "settled-placeholder"
142 /** The boundary between pinned and active rows. */143 /** The boundary between pinned and active rows. */
143 | "pinned-divider"144 | "pinned-divider"
145 | "working-header"
144 | "snoozed-header"146 | "snoozed-header"
145 | "settled-header";147 | "settled-header";
146148
...@@ -158,7 +160,7 @@ export function sidebarListItemId(item: SidebarListItem): string {...@@ -158,7 +160,7 @@ export function sidebarListItemId(item: SidebarListItem): string {
158160
159/** The section a slot belongs to, read off the markers around it: from161/** The section a slot belongs to, read off the markers around it: from
160 the top down, everything before the pinned divider is pinned, then the162 the top down, everything before the pinned divider is pinned, then the
161 inbox until the snoozed header, the shelf until the settled header,163 inbox until the first shelf header, each shelf until the next header,
162 then settled. */164 then settled. */
163function sectionAtSidebarSlot(items: readonly SidebarListItem[], index: number): SidebarSection {165function sectionAtSidebarSlot(items: readonly SidebarListItem[], index: number): SidebarSection {
164 let section: SidebarSection = "pinned";166 let section: SidebarSection = "pinned";
...@@ -166,6 +168,7 @@ function sectionAtSidebarSlot(items: readonly SidebarListItem[], index: number):...@@ -166,6 +168,7 @@ function sectionAtSidebarSlot(items: readonly SidebarListItem[], index: number):
166 const item = items[i]!;168 const item = items[i]!;
167 if (item.kind !== "marker") continue;169 if (item.kind !== "marker") continue;
168 if (item.marker === "pinned-divider") section = "active";170 if (item.marker === "pinned-divider") section = "active";
171 else if (item.marker === "working-header") section = "working";
169 else if (item.marker === "snoozed-header") section = "snoozed";172 else if (item.marker === "snoozed-header") section = "snoozed";
170 else if (item.marker === "settled-header") section = "settled";173 else if (item.marker === "settled-header") section = "settled";
171 }174 }
...@@ -173,7 +176,7 @@ function sectionAtSidebarSlot(items: readonly SidebarListItem[], index: number):...@@ -173,7 +176,7 @@ function sectionAtSidebarSlot(items: readonly SidebarListItem[], index: number):
173}176}
174177
175/** Resolve the destination section and manual order from an arrayMove across178/** Resolve the destination section and manual order from an arrayMove across
176 * the separators. The snoozed shelf is never a destination. */179 * the separators. The working and snoozed shelves are never destinations. */
177export type SidebarDropTarget = {180export type SidebarDropTarget = {
178 readonly section: "pinned" | "active" | "settled";181 readonly section: "pinned" | "active" | "settled";
179 readonly pinnedOrder: readonly string[];182 readonly pinnedOrder: readonly string[];
...@@ -191,14 +194,19 @@ export function resolveSidebarDropTarget(...@@ -191,14 +194,19 @@ export function resolveSidebarDropTarget(
191 const moved = items.filter((_, index) => index !== activeIndex);194 const moved = items.filter((_, index) => index !== activeIndex);
192 moved.splice(overIndex, 0, items[activeIndex]!);195 moved.splice(overIndex, 0, items[activeIndex]!);
193 const section = sectionAtSidebarSlot(moved, overIndex);196 const section = sectionAtSidebarSlot(moved, overIndex);
194 if (section === "snoozed") return null;197 if (section === "working" || section === "snoozed") return null;
195 const pinnedOrder: string[] = [];198 const pinnedOrder: string[] = [];
196 const activeOrder: string[] = [];199 const activeOrder: string[] = [];
197 let currentSection: SidebarSection = "pinned";200 let currentSection: SidebarSection = "pinned";
198 for (const item of moved) {201 for (const item of moved) {
199 if (item.kind === "marker") {202 if (item.kind === "marker") {
200 if (item.marker === "pinned-divider") currentSection = "active";203 if (item.marker === "pinned-divider") currentSection = "active";
201 else if (item.marker === "snoozed-header" || item.marker === "settled-header") break;204 else if (
205 item.marker === "working-header" ||
206 item.marker === "snoozed-header" ||
207 item.marker === "settled-header"
208 )
209 break;
202 } else if (currentSection === "pinned") pinnedOrder.push(item.key);210 } else if (currentSection === "pinned") pinnedOrder.push(item.key);
203 else activeOrder.push(item.key);211 else activeOrder.push(item.key);
204 }212 }
...@@ -224,7 +232,8 @@ export type SidebarThreadDropPlan =...@@ -224,7 +232,8 @@ export type SidebarThreadDropPlan =
224 }232 }
225 | {233 | {
226 readonly kind: "move-active";234 readonly kind: "move-active";
227 readonly order: readonly string[];235 /** Null when the inbox is time-ordered: the drop has no placement. */
236 readonly order: readonly string[] | null;
228 readonly assignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>;237 readonly assignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>;
229 readonly unpin: boolean;238 readonly unpin: boolean;
230 readonly unsettle: boolean;239 readonly unsettle: boolean;
...@@ -234,14 +243,14 @@ export type SidebarThreadDropPlan =...@@ -234,14 +243,14 @@ export type SidebarThreadDropPlan =
234243
235/** What dropping in `to` does to a thread lifted from `from`, for the badge244/** What dropping in `to` does to a thread lifted from `from`, for the badge
236 on the lifted row. Null while reordering inside one section and for the245 on the lifted row. Null while reordering inside one section and for the
237 snoozed shelf, which cannot be a drop target. */246 working and snoozed shelves, which cannot be drop targets. */
238export type SidebarDropVerb = "pin" | "unpin" | "settle" | "unsettle" | "wake";247export type SidebarDropVerb = "pin" | "unpin" | "settle" | "unsettle" | "wake";
239248
240export function resolveSidebarDropVerb(249export function resolveSidebarDropVerb(
241 from: SidebarSection,250 from: SidebarSection,
242 to: SidebarSection | null,251 to: SidebarSection | null,
243): SidebarDropVerb | null {252): SidebarDropVerb | null {
244 if (to === null || to === from || to === "snoozed") return null;253 if (to === null || to === from || to === "working" || to === "snoozed") return null;
245 if (to === "pinned") return "pin";254 if (to === "pinned") return "pin";
246 if (to === "settled") return "settle";255 if (to === "settled") return "settle";
247 if (from === "pinned") return "unpin";256 if (from === "pinned") return "unpin";
...@@ -264,6 +273,8 @@ export function planSidebarThreadDrop(input: {...@@ -264,6 +273,8 @@ export function planSidebarThreadDrop(input: {
264 readonly activeOrder: readonly string[];273 readonly activeOrder: readonly string[];
265 readonly activeKeysById: ReadonlyMap<string, string | null | undefined>;274 readonly activeKeysById: ReadonlyMap<string, string | null | undefined>;
266 readonly activeReorderableKeys?: ReadonlySet<string>;275 readonly activeReorderableKeys?: ReadonlySet<string>;
276 /** Working beta: the inbox sorts by time, so drops only change lifecycle. */
277 readonly activeTimeOrdered?: boolean;
267}): SidebarThreadDropPlan {278}): SidebarThreadDropPlan {
268 const {279 const {
269 activeKey,280 activeKey,
...@@ -283,6 +294,20 @@ export function planSidebarThreadDrop(input: {...@@ -283,6 +294,20 @@ export function planSidebarThreadDrop(input: {
283 }294 }
284 switch (target.section) {295 switch (target.section) {
285 case "active": {296 case "active": {
297 // Like the settled tail: threads can enter a time-ordered inbox, but
298 // not be arranged inside it.
299 if (input.activeTimeOrdered) {
300 return activeSection === "active"
301 ? { kind: "none" }
302 : {
303 kind: "move-active",
304 order: null,
305 assignments: [],
306 unpin: activePinned,
307 unsettle: activeSettled,
308 unsnooze: activeSection === "snoozed",
309 };
310 }
286 const order = target.activeOrder;311 const order = target.activeOrder;
287 if (312 if (
288 activeSection === "active" &&313 activeSection === "active" &&
...@@ -842,6 +867,16 @@ export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): Si...@@ -842,6 +867,16 @@ export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): Si
842 return "ready";867 return "ready";
843}868}
844869
870/** Working beta: threads busy with work that does not need the user fold into
871 the Working shelf. Approvals, questions, plan prompts, and failures stay
872 in the inbox. */
873export function isSidebarThreadWorking(thread: ThreadStatusInput): boolean {
874 const status = resolveSidebarThreadStatus(thread);
875 if (status !== "working" && status !== "monitoring") return false;
876 // A plan prompt outranks lingering background work, as in the status pill.
877 return resolveThreadStatusPill({ thread })?.label !== "Plan Ready";
878}
879
845/** First VALID timestamp wins: `a ?? b` falls through on null, but a present-880/** First VALID timestamp wins: `a ?? b` falls through on null, but a present-
846 yet-malformed string must also fall through to the next candidate rather881 yet-malformed string must also fall through to the next candidate rather
847 than sink the row to the epoch. */882 than sink the row to the epoch. */
...@@ -950,6 +985,36 @@ export function reduceSidebarProjectScopeMenuState(...@@ -950,6 +985,36 @@ export function reduceSidebarProjectScopeMenuState(
950 }985 }
951}986}
952987
988/** Working beta: the inbox lists threads newest first by when each last came
989 back to the user, so a thread that leaves the Working shelf lands on top.
990 `observedReturnAt` adds returns the server does not stamp, such as an
991 approval request mid-turn or background work ending. */
992export function sortInboxThreadsByReturn<
993 T extends Pick<
994 SidebarThreadSummary,
995 "id" | "environmentId" | "createdAt" | "unsettledAt" | "latestTurn"
996 >,
997>(threads: readonly T[], observedReturnAt?: (thread: T) => number | undefined): T[] {
998 const timestamps = new Map(
999 threads.map((thread) => [
1000 thread,
1001 Math.max(
1002 toSortableTimestamp(thread.createdAt) ?? 0,
1003 toSortableTimestamp(thread.unsettledAt ?? undefined) ?? 0,
1004 toSortableTimestamp(thread.latestTurn?.requestedAt) ?? 0,
1005 toSortableTimestamp(thread.latestTurn?.completedAt ?? undefined) ?? 0,
1006 observedReturnAt?.(thread) ?? 0,
1007 ),
1008 ]),
1009 );
1010 return [...threads].sort(
1011 (left, right) =>
1012 timestamps.get(right)! - timestamps.get(left)! ||
1013 left.id.localeCompare(right.id) ||
1014 left.environmentId.localeCompare(right.environmentId),
1015 );
1016}
1017
953/** The timestamp a working thread's elapsed label counts from: the running1018/** The timestamp a working thread's elapsed label counts from: the running
954 turn's start (request time until adoption), falling back to the session's1019 turn's start (request time until adoption), falling back to the session's
955 last transition when the turn projection lags behind. Malformed1020 last transition when the turn projection lags behind. Malformed
apps/web/src/components/Sidebar.tsx+173-15
...@@ -166,6 +166,7 @@ import {...@@ -166,6 +166,7 @@ import {
166 firstValidTimestampMs,166 firstValidTimestampMs,
167 hasUnseenCompletion,167 hasUnseenCompletion,
168 isSidebarNestedLinkClick,168 isSidebarNestedLinkClick,
169 isSidebarThreadWorking,
169 isTrailingDoubleClick,170 isTrailingDoubleClick,
170 orderItemsByPreferredIds,171 orderItemsByPreferredIds,
171 planSidebarThreadDrop,172 planSidebarThreadDrop,
...@@ -183,6 +184,7 @@ import {...@@ -183,6 +184,7 @@ import {
183 resolveWorkingStartedAt,184 resolveWorkingStartedAt,
184 sidebarListItemId,185 sidebarListItemId,
185 sidebarMarkerId,186 sidebarMarkerId,
187 sortInboxThreadsByReturn,
186 sortLogicalProjectsForSidebar,188 sortLogicalProjectsForSidebar,
187 sortPinnedThreadsForSidebar,189 sortPinnedThreadsForSidebar,
188 sortThreadsForSidebar,190 sortThreadsForSidebar,
...@@ -258,6 +260,39 @@ const SETTLED_TAIL_PAGE_COUNT = 25;...@@ -258,6 +260,39 @@ const SETTLED_TAIL_PAGE_COUNT = 25;
258// Fresh keys deliberately reset both shelves to collapsed for existing users.260// Fresh keys deliberately reset both shelves to collapsed for existing users.
259const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar:settled-expanded";261const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar:settled-expanded";
260const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar:snoozed-expanded";262const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar:snoozed-expanded";
263const WORKING_SHELF_EXPANDED_KEY = "t3code:sidebar:working-expanded";
264
265// Working beta: when this client saw each thread leave the Working shelf.
266// Module scope keeps the inbox order across routes that unmount the sidebar.
267let lastWorkingThreadKeys: ReadonlySet<string> | null = null;
268const observedInboxReturns = new Map<string, number>();
269
270/** Stamps threads that stopped working since the last call. The first call
271 only takes a baseline, so mounting never reshuffles the inbox. Pass null
272 to reset when the beta is off. */
273function observeInboxReturns(threads: readonly EnvironmentThreadShell[] | null): void {
274 if (threads === null) {
275 lastWorkingThreadKeys = null;
276 observedInboxReturns.clear();
277 return;
278 }
279 const working = new Set<string>();
280 const present = new Set<string>();
281 for (const thread of threads) {
282 const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
283 present.add(key);
284 if (isSidebarThreadWorking(thread)) working.add(key);
285 }
286 // Drop deleted threads so the map stays bounded by the live thread list.
287 for (const key of observedInboxReturns.keys()) {
288 if (!present.has(key)) observedInboxReturns.delete(key);
289 }
290 const now = Date.now();
291 for (const key of lastWorkingThreadKeys ?? []) {
292 if (present.has(key) && !working.has(key)) observedInboxReturns.set(key, now);
293 }
294 lastWorkingThreadKeys = working;
295}
261296
262function compactSidebarTimeLabel(label: string): string {297function compactSidebarTimeLabel(label: string): string {
263 if (label === "just now") return "now";298 if (label === "just now") return "now";
...@@ -639,7 +674,7 @@ function SidebarDragBoundary(props: {...@@ -639,7 +674,7 @@ function SidebarDragBoundary(props: {
639674
640// Shelf headers stay visible and keep their measured height while dragging.675// Shelf headers stay visible and keep their measured height while dragging.
641function SidebarSectionHeader(props: {676function SidebarSectionHeader(props: {
642 marker: "snoozed-header" | "settled-header";677 marker: "working-header" | "snoozed-header" | "settled-header";
643 label: string;678 label: string;
644 className?: string;679 className?: string;
645 // While dragging, the settled header reads at full strength and takes the680 // While dragging, the settled header reads at full strength and takes the
...@@ -648,7 +683,13 @@ function SidebarSectionHeader(props: {...@@ -648,7 +683,13 @@ function SidebarSectionHeader(props: {
648 isDropTarget?: boolean;683 isDropTarget?: boolean;
649 toggle: { expanded: boolean; onToggle: () => void };684 toggle: { expanded: boolean; onToggle: () => void };
650}) {685}) {
651 const snoozed = props.marker === "snoozed-header";686 const shelf =
687 props.marker === "working-header"
688 ? "working"
689 : props.marker === "snoozed-header"
690 ? "snoozed"
691 : "settled";
692 const snoozed = shelf === "snoozed";
652 const className = cn(693 const className = cn(
653 "flex h-full w-full items-center gap-2 px-2 text-left text-xs font-medium",694 "flex h-full w-full items-center gap-2 px-2 text-left text-xs font-medium",
654 snoozed ? "text-info-foreground" : "text-sidebar-muted-foreground/60",695 snoozed ? "text-info-foreground" : "text-sidebar-muted-foreground/60",
...@@ -686,7 +727,7 @@ function SidebarSectionHeader(props: {...@@ -686,7 +727,7 @@ function SidebarSectionHeader(props: {
686 type="button"727 type="button"
687 onClick={props.toggle.onToggle}728 onClick={props.toggle.onToggle}
688 aria-expanded={props.toggle.expanded}729 aria-expanded={props.toggle.expanded}
689 data-testid={`sidebar-${snoozed ? "snoozed" : "settled"}-shelf-toggle`}730 data-testid={`sidebar-${shelf}-shelf-toggle`}
690 className={cn(className, "cursor-pointer")}731 className={cn(className, "cursor-pointer")}
691 >732 >
692 {content}733 {content}
...@@ -2184,6 +2225,7 @@ export default function Sidebar() {...@@ -2184,6 +2225,7 @@ export default function Sidebar() {
2184 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);2225 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
2185 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);2226 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
2186 const timestampFormat = useClientSettings((s) => s.timestampFormat);2227 const timestampFormat = useClientSettings((s) => s.timestampFormat);
2228 const workingShelfEnabled = useClientSettings((s) => s.sidebarWorkingShelfEnabled);
2187 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);2229 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
2188 const {2230 const {
2189 settleThread,2231 settleThread,
...@@ -2561,6 +2603,7 @@ export default function Sidebar() {...@@ -2561,6 +2603,7 @@ export default function Sidebar() {
2561 draggableThreadKeys,2603 draggableThreadKeys,
2562 activeReorderableThreadKeys,2604 activeReorderableThreadKeys,
2563 activeThreads,2605 activeThreads,
2606 workingThreads,
2564 snoozedThreads,2607 snoozedThreads,
2565 settledThreads,2608 settledThreads,
2566 snoozeNow,2609 snoozeNow,
...@@ -2577,8 +2620,14 @@ export default function Sidebar() {...@@ -2577,8 +2620,14 @@ export default function Sidebar() {
2577 (scopedProjectKeys === null ||2620 (scopedProjectKeys === null ||
2578 scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)),2621 scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)),
2579 );2622 );
2623 observeInboxReturns(workingShelfEnabled ? threads : null);
2580 const pinned: EnvironmentThreadShell[] = [];2624 const pinned: EnvironmentThreadShell[] = [];
2581 const active: EnvironmentThreadShell[] = [];2625 const active: EnvironmentThreadShell[] = [];
2626 const working: EnvironmentThreadShell[] = [];
2627 // Working beta: only inbox threads fold away. Pins stay where the user
2628 // put them, and snoozed or settled threads keep their shelves.
2629 const inbox = (thread: EnvironmentThreadShell) =>
2630 workingShelfEnabled && isSidebarThreadWorking(thread) ? working : active;
2582 const snoozed: EnvironmentThreadShell[] = [];2631 const snoozed: EnvironmentThreadShell[] = [];
2583 const settled: EnvironmentThreadShell[] = [];2632 const settled: EnvironmentThreadShell[] = [];
2584 const draggable = new Set<string>();2633 const draggable = new Set<string>();
...@@ -2609,7 +2658,7 @@ export default function Sidebar() {...@@ -2609,7 +2658,7 @@ export default function Sidebar() {
2609 ? pinned2658 ? pinned
2610 : optimisticDrop.section === "settled"2659 : optimisticDrop.section === "settled"
2611 ? settled2660 ? settled
2612 : active2661 : inbox(projected)
2613 ).push(2662 ).push(
2614 optimisticDrop.clearsSnooze2663 optimisticDrop.clearsSnooze
2615 ? projected2664 ? projected
...@@ -2623,7 +2672,7 @@ export default function Sidebar() {...@@ -2623,7 +2672,7 @@ export default function Sidebar() {
2623 } else if (thread.pinnedAt != null) {2672 } else if (thread.pinnedAt != null) {
2624 pinned.push(thread);2673 pinned.push(thread);
2625 } else {2674 } else {
2626 active.push(thread);2675 inbox(thread).push(thread);
2627 }2676 }
2628 }2677 }
2629 // One shared rule on every platform (see sortPinnedThreadsByOrderKey):2678 // One shared rule on every platform (see sortPinnedThreadsByOrderKey):
...@@ -2632,7 +2681,13 @@ export default function Sidebar() {...@@ -2632,7 +2681,13 @@ export default function Sidebar() {
2632 // sort, or mixed-version fleets would render different pinned orders on2681 // sort, or mixed-version fleets would render different pinned orders on
2633 // web and mobile from the same data.2682 // web and mobile from the same data.
2634 const sortedPinned = sortPinnedThreadsForSidebar(pinned);2683 const sortedPinned = sortPinnedThreadsForSidebar(pinned);
2635 const sortedActive = sortThreadsForSidebar(active);2684 const sortedActive = workingShelfEnabled
2685 ? sortInboxThreadsByReturn(active, (thread) =>
2686 observedInboxReturns.get(
2687 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
2688 ),
2689 )
2690 : sortThreadsForSidebar(active);
2636 return {2691 return {
2637 pinnedThreads:2692 pinnedThreads:
2638 optimisticDrop?.section !== "pinned" || optimisticDrop.order === null2693 optimisticDrop?.section !== "pinned" || optimisticDrop.order === null
...@@ -2652,6 +2707,8 @@ export default function Sidebar() {...@@ -2652,6 +2707,8 @@ export default function Sidebar() {
2652 preferredIds: optimisticDrop.order,2707 preferredIds: optimisticDrop.order,
2653 getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),2708 getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
2654 }),2709 }),
2710 // Newest work first, by the same clock as the inbox.
2711 workingThreads: sortInboxThreadsByReturn(working),
2655 // Soonest wake first: "what comes back next" is the shelf's question.2712 // Soonest wake first: "what comes back next" is the shelf's question.
2656 snoozedThreads: snoozed.toSorted(2713 snoozedThreads: snoozed.toSorted(
2657 (left, right) =>2714 (left, right) =>
...@@ -2661,15 +2718,29 @@ export default function Sidebar() {...@@ -2661,15 +2718,29 @@ export default function Sidebar() {
2661 settledThreads: sortSettledThreads(settled),2718 settledThreads: sortSettledThreads(settled),
2662 snoozeNow: preciseNow,2719 snoozeNow: preciseNow,
2663 };2720 };
2664 }, [nowMinute, optimisticDrop, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]);2721 }, [
2722 nowMinute,
2723 optimisticDrop,
2724 scopedProjectKeys,
2725 serverConfigs,
2726 snoozeWakeTick,
2727 threads,
2728 workingShelfEnabled,
2729 ]);
26652730
2666 const threadSearchInputRef = useRef<HTMLInputElement>(null);2731 const threadSearchInputRef = useRef<HTMLInputElement>(null);
2667 const [threadSearchQuery, setThreadSearchQuery] = useState("");2732 const [threadSearchQuery, setThreadSearchQuery] = useState("");
2668 const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0);2733 const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0);
2669 const isSearchingThreads = threadSearchQuery.trim().length > 0;2734 const isSearchingThreads = threadSearchQuery.trim().length > 0;
2670 const searchableThreads = useMemo(2735 const searchableThreads = useMemo(
2671 () => [...pinnedThreads, ...activeThreads, ...snoozedThreads, ...settledThreads],2736 () => [
2672 [activeThreads, pinnedThreads, settledThreads, snoozedThreads],2737 ...pinnedThreads,
2738 ...activeThreads,
2739 ...workingThreads,
2740 ...snoozedThreads,
2741 ...settledThreads,
2742 ],
2743 [activeThreads, pinnedThreads, settledThreads, snoozedThreads, workingThreads],
2673 );2744 );
2674 const searchEnvironmentIds = useMemo(2745 const searchEnvironmentIds = useMemo(
2675 () =>2746 () =>
...@@ -2805,9 +2876,43 @@ export default function Sidebar() {...@@ -2805,9 +2876,43 @@ export default function Sidebar() {
2805 return routeThread === undefined ? EMPTY_THREADS : [routeThread];2876 return routeThread === undefined ? EMPTY_THREADS : [routeThread];
2806 }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]);2877 }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]);
28072878
2879 // The Working shelf (beta) collapses the same way, with the same route
2880 // exception: sending a message folds the open thread into the shelf, and
2881 // its row must stay visible there.
2882 const [workingShelfExpanded, setWorkingShelfExpanded] = useLocalStorage(
2883 WORKING_SHELF_EXPANDED_KEY,
2884 false,
2885 Schema.Boolean,
2886 );
2887 const toggleWorkingShelf = useCallback(
2888 () => setWorkingShelfExpanded((value) => !value),
2889 [setWorkingShelfExpanded],
2890 );
2891 const visibleWorkingThreads = useMemo(() => {
2892 if (workingShelfExpanded) return workingThreads;
2893 if (routeThreadKey === null) return EMPTY_THREADS;
2894 const routeThread = workingThreads.find(
2895 (thread) =>
2896 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey,
2897 );
2898 return routeThread === undefined ? EMPTY_THREADS : [routeThread];
2899 }, [routeThreadKey, workingShelfExpanded, workingThreads]);
2900
2808 const orderedThreads = useMemo(2901 const orderedThreads = useMemo(
2809 () => [...pinnedThreads, ...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads],2902 () => [
2810 [pinnedThreads, activeThreads, visibleSnoozedThreads, renderedSettledThreads],2903 ...pinnedThreads,
2904 ...activeThreads,
2905 ...visibleWorkingThreads,
2906 ...visibleSnoozedThreads,
2907 ...renderedSettledThreads,
2908 ],
2909 [
2910 pinnedThreads,
2911 activeThreads,
2912 visibleWorkingThreads,
2913 visibleSnoozedThreads,
2914 renderedSettledThreads,
2915 ],
2811 );2916 );
2812 const orderedThreadKeys = useMemo(2917 const orderedThreadKeys = useMemo(
2813 () =>2918 () =>
...@@ -3230,10 +3335,11 @@ export default function Sidebar() {...@@ -3230,10 +3335,11 @@ export default function Sidebar() {
3230 };3335 };
3231 add(pinnedThreads, "pinned");3336 add(pinnedThreads, "pinned");
3232 add(activeThreads, "active");3337 add(activeThreads, "active");
3338 add(workingThreads, "working");
3233 add(snoozedThreads, "snoozed");3339 add(snoozedThreads, "snoozed");
3234 add(settledThreads, "settled");3340 add(settledThreads, "settled");
3235 return map;3341 return map;
3236 }, [activeThreads, pinnedThreads, settledThreads, snoozedThreads]);3342 }, [activeThreads, pinnedThreads, settledThreads, snoozedThreads, workingThreads]);
3237 const pinnedKeys = useMemo(3343 const pinnedKeys = useMemo(
3238 () =>3344 () =>
3239 pinnedThreads.map((thread) =>3345 pinnedThreads.map((thread) =>
...@@ -3400,6 +3506,7 @@ export default function Sidebar() {...@@ -3400,6 +3506,7 @@ export default function Sidebar() {
3400 if (3506 if (
3401 pinnedThreads.length +3507 pinnedThreads.length +
3402 activeThreads.length +3508 activeThreads.length +
3509 workingThreads.length +
3403 snoozedThreads.length +3510 snoozedThreads.length +
3404 settledThreads.length ===3511 settledThreads.length ===
3405 03512 0
...@@ -3413,6 +3520,10 @@ export default function Sidebar() {...@@ -3413,6 +3520,10 @@ export default function Sidebar() {
3413 const activeRows = rowsOf(activeThreads, "active");3520 const activeRows = rowsOf(activeThreads, "active");
3414 items.push({ kind: "marker", marker: "active-placeholder" });3521 items.push({ kind: "marker", marker: "active-placeholder" });
3415 items.push(...activeRows);3522 items.push(...activeRows);
3523 if (workingThreads.length > 0) {
3524 items.push({ kind: "marker", marker: "working-header" });
3525 items.push(...rowsOf(visibleWorkingThreads, "working"));
3526 }
3416 if (snoozedThreads.length > 0) {3527 if (snoozedThreads.length > 0) {
3417 items.push({ kind: "marker", marker: "snoozed-header" });3528 items.push({ kind: "marker", marker: "snoozed-header" });
3418 items.push(...rowsOf(visibleSnoozedThreads, "snoozed"));3529 items.push(...rowsOf(visibleSnoozedThreads, "snoozed"));
...@@ -3429,6 +3540,8 @@ export default function Sidebar() {...@@ -3429,6 +3540,8 @@ export default function Sidebar() {
3429 settledThreads.length,3540 settledThreads.length,
3430 snoozedThreads.length,3541 snoozedThreads.length,
3431 visibleSnoozedThreads,3542 visibleSnoozedThreads,
3543 visibleWorkingThreads,
3544 workingThreads.length,
3432 ]);3545 ]);
3433 useEffect(() => {3546 useEffect(() => {
3434 if (3547 if (
...@@ -3489,18 +3602,35 @@ export default function Sidebar() {...@@ -3489,18 +3602,35 @@ export default function Sidebar() {
3489 applySidebarThreadDrop(thread, "settled", dragState.occurredAt),3602 applySidebarThreadDrop(thread, "settled", dragState.occurredAt),
3490 ]).map(key);3603 ]).map(key);
3491 }, [dragState, settledThreads, threadByKey]);3604 }, [dragState, settledThreads, threadByKey]);
3605 // Working beta: the inbox is time-ordered too, so the preview shows the
3606 // slot a drop will land in, not the slot under the pointer.
3607 const draggedActiveOrder = useMemo(() => {
3608 const thread = dragState === null ? undefined : threadByKey.get(dragState.activeKey);
3609 if (!workingShelfEnabled || dragState === null || thread === undefined) return undefined;
3610 const key = (candidate: EnvironmentThreadShell) =>
3611 scopedThreadKey(scopeThreadRef(candidate.environmentId, candidate.id));
3612 return sortInboxThreadsByReturn(
3613 [
3614 ...activeThreads.filter((candidate) => key(candidate) !== dragState.activeKey),
3615 applySidebarThreadDrop(thread, "active", dragState.occurredAt),
3616 ],
3617 (candidate) => observedInboxReturns.get(key(candidate)),
3618 ).map(key);
3619 }, [activeThreads, dragState, threadByKey, workingShelfEnabled]);
3492 const sidebarSortingStrategy = useMemo(3620 const sidebarSortingStrategy = useMemo(
3493 () =>3621 () =>
3494 createSidebarSortingStrategy({3622 createSidebarSortingStrategy({
3495 items: sidebarListItems,3623 items: sidebarListItems,
3496 boundaryLabelHeight: SIDEBAR_DRAG_LABEL_HEIGHT,3624 boundaryLabelHeight: SIDEBAR_DRAG_LABEL_HEIGHT,
3497 settledOrder: draggedSettledOrder,3625 settledOrder: draggedSettledOrder,
3626 ...(draggedActiveOrder === undefined ? {} : { activeOrder: draggedActiveOrder }),
3498 settledExpanded: settledShelfExpanded,3627 settledExpanded: settledShelfExpanded,
3499 settledVisibleCount,3628 settledVisibleCount,
3500 routeThreadKey,3629 routeThreadKey,
3501 snoozedThreadCount: snoozedThreads.length,3630 snoozedThreadCount: snoozedThreads.length,
3502 }),3631 }),
3503 [3632 [
3633 draggedActiveOrder,
3504 draggedSettledOrder,3634 draggedSettledOrder,
3505 routeThreadKey,3635 routeThreadKey,
3506 settledShelfExpanded,3636 settledShelfExpanded,
...@@ -3556,6 +3686,7 @@ export default function Sidebar() {...@@ -3556,6 +3686,7 @@ export default function Sidebar() {
3556 activeOrder: activeKeys,3686 activeOrder: activeKeys,
3557 activeKeysById,3687 activeKeysById,
3558 activeReorderableKeys: activeReorderableThreadKeys,3688 activeReorderableKeys: activeReorderableThreadKeys,
3689 activeTimeOrdered: workingShelfEnabled,
3559 }).kind !== "none"3690 }).kind !== "none"
3560 );3691 );
3561 },3692 },
...@@ -3577,6 +3708,7 @@ export default function Sidebar() {...@@ -3577,6 +3708,7 @@ export default function Sidebar() {
3577 pinnedKeys,3708 pinnedKeys,
3578 sidebarListItems,3709 sidebarListItems,
3579 threadByKey,3710 threadByKey,
3711 workingShelfEnabled,
3580 ]);3712 ]);
3581 const handleThreadDragEnd = useCallback(3713 const handleThreadDragEnd = useCallback(
3582 (event: DragEndEvent) => {3714 (event: DragEndEvent) => {
...@@ -3604,6 +3736,7 @@ export default function Sidebar() {...@@ -3604,6 +3736,7 @@ export default function Sidebar() {
3604 activeOrder: activeKeys,3736 activeOrder: activeKeys,
3605 activeKeysById,3737 activeKeysById,
3606 activeReorderableKeys: activeReorderableThreadKeys,3738 activeReorderableKeys: activeReorderableThreadKeys,
3739 activeTimeOrdered: workingShelfEnabled,
3607 });3740 });
3608 if (plan.kind === "none") return;3741 if (plan.kind === "none") return;
3609 if (plan.kind === "settle" && settlingThreadKeysRef.current.has(activeKey)) return;3742 if (plan.kind === "settle" && settlingThreadKeysRef.current.has(activeKey)) return;
...@@ -3737,6 +3870,7 @@ export default function Sidebar() {...@@ -3737,6 +3870,7 @@ export default function Sidebar() {
3737 unpinThread,3870 unpinThread,
3738 unsettleThread,3871 unsettleThread,
3739 unsnoozeThread,3872 unsnoozeThread,
3873 workingShelfEnabled,
3740 ],3874 ],
3741 );3875 );
3742 // One snooze per thread at a time — same double-dispatch guard as settle.3876 // One snooze per thread at a time — same double-dispatch guard as settle.
...@@ -4717,7 +4851,9 @@ export default function Sidebar() {...@@ -4717,7 +4851,9 @@ export default function Sidebar() {
4717 // row: every other thread is a full card. Density comes4851 // row: every other thread is a full card. Density comes
4718 // from users (or the auto rules) actually parking work,4852 // from users (or the auto rules) actually parking work,
4719 // not from the sidebar second-guessing what still matters.4853 // not from the sidebar second-guessing what still matters.
4720 const isCard = section === "active" || section === "pinned";4854 // Working rows stay cards so their live status shows.
4855 const isCard =
4856 section === "active" || section === "pinned" || section === "working";
4721 const rowVariant = isCard ? "card" : "slim";4857 const rowVariant = isCard ? "card" : "slim";
4722 return (4858 return (
4723 <SidebarThreadRow4859 <SidebarThreadRow
...@@ -4826,6 +4962,7 @@ export default function Sidebar() {...@@ -4826,6 +4962,7 @@ export default function Sidebar() {
4826 id={threadKey}4962 id={threadKey}
4827 disabled={4963 disabled={
4828 renamingThreadKey === threadKey ||4964 renamingThreadKey === threadKey ||
4965 section === "working" ||
4829 !draggableThreadKeys.has(threadKey) ||4966 !draggableThreadKeys.has(threadKey) ||
4830 optimisticDrop !== null4967 optimisticDrop !== null
4831 }4968 }
...@@ -4891,12 +5028,30 @@ export default function Sidebar() {...@@ -4891,12 +5028,30 @@ export default function Sidebar() {
4891 />,5028 />,
4892 );5029 );
4893 break;5030 break;
5031 case "working-header":
5032 items.push(
5033 <SidebarSectionHeader
5034 key="working-shelf-header"
5035 marker="working-header"
5036 className="mt-auto"
5037 label={
5038 workingShelfExpanded
5039 ? "Working"
5040 : `Working (${workingThreads.length})`
5041 }
5042 toggle={{
5043 expanded: workingShelfExpanded,
5044 onToggle: toggleWorkingShelf,
5045 }}
5046 />,
5047 );
5048 break;
4894 case "snoozed-header":5049 case "snoozed-header":
4895 items.push(5050 items.push(
4896 <SidebarSectionHeader5051 <SidebarSectionHeader
4897 key="snoozed-shelf-header"5052 key="snoozed-shelf-header"
4898 marker="snoozed-header"5053 marker="snoozed-header"
4899 className="mt-auto"5054 className={cn(workingThreads.length === 0 && "mt-auto")}
4900 label={5055 label={
4901 snoozedShelfExpanded5056 snoozedShelfExpanded
4902 ? "Snoozed"5057 ? "Snoozed"
...@@ -4914,7 +5069,9 @@ export default function Sidebar() {...@@ -4914,7 +5069,9 @@ export default function Sidebar() {
4914 <SidebarSectionHeader5069 <SidebarSectionHeader
4915 key="settled-shelf-header"5070 key="settled-shelf-header"
4916 marker="settled-header"5071 marker="settled-header"
4917 className={cn(snoozedThreads.length === 0 && "mt-auto")}5072 className={cn(
5073 workingThreads.length + snoozedThreads.length === 0 && "mt-auto",
5074 )}
4918 label={5075 label={
4919 settledShelfExpanded5076 settledShelfExpanded
4920 ? "Settled"5077 ? "Settled"
...@@ -4972,6 +5129,7 @@ export default function Sidebar() {...@@ -4972,6 +5129,7 @@ export default function Sidebar() {
4972 visibleDraftSessionCount === 0 &&5129 visibleDraftSessionCount === 0 &&
4973 pinnedThreads.length +5130 pinnedThreads.length +
4974 activeThreads.length +5131 activeThreads.length +
5132 workingThreads.length +
4975 snoozedThreads.length +5133 snoozedThreads.length +
4976 settledThreads.length ===5134 settledThreads.length ===
4977 0 ? (5135 0 ? (
apps/web/src/components/settings/SettingsPanels.tsx+33
...@@ -563,6 +563,10 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -563,6 +563,10 @@ export function useSettingsRestore(onRestored?: () => void) {
563 DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode563 DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode
564 ? ["Project Grouping"]564 ? ["Project Grouping"]
565 : []),565 : []),
566 ...(settings.sidebarWorkingShelfEnabled !==
567 DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled
568 ? ["Working section"]
569 : []),
566 ...(settings.sidebarAutoSettleAfterDays !==570 ...(settings.sidebarAutoSettleAfterDays !==
567 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays571 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays
568 ? ["Auto-settle inactive threads"]572 ? ["Auto-settle inactive threads"]
...@@ -684,6 +688,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -684,6 +688,7 @@ export function useSettingsRestore(onRestored?: () => void) {
684 settings.sidebarAutoSettleAfterDays,688 settings.sidebarAutoSettleAfterDays,
685 settings.sidebarAutoSettleOnMerge,689 settings.sidebarAutoSettleOnMerge,
686 settings.sidebarProjectGroupingMode,690 settings.sidebarProjectGroupingMode,
691 settings.sidebarWorkingShelfEnabled,
687 settings.sidebarThreadPreviewCount,692 settings.sidebarThreadPreviewCount,
688 settings.showSkillsInSlashMenu,693 settings.showSkillsInSlashMenu,
689 settings.timestampFormat,694 settings.timestampFormat,
...@@ -781,6 +786,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -781,6 +786,7 @@ export function useSettingsRestore(onRestored?: () => void) {
781 panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs,786 panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs,
782 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,787 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
783 sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,788 sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,
789 sidebarWorkingShelfEnabled: DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled,
784 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,790 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
785 sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,791 sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,
786 responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode,792 responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode,
...@@ -2269,6 +2275,33 @@ export function GeneralSettingsPanel() {...@@ -2269,6 +2275,33 @@ export function GeneralSettingsPanel() {
2269 }2275 }
2270 />2276 />
22712277
2278 <SettingsRow
2279 {...searchableSetting("working-shelf")}
2280 description="Fold working and monitoring threads into a Working section. They return to the top of the inbox when they need you."
2281 resetAction={
2282 settings.sidebarWorkingShelfEnabled !==
2283 DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled ? (
2284 <SettingResetButton
2285 label="working section"
2286 onClick={() =>
2287 updateSettings({
2288 sidebarWorkingShelfEnabled: DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled,
2289 })
2290 }
2291 />
2292 ) : null
2293 }
2294 control={
2295 <Switch
2296 checked={settings.sidebarWorkingShelfEnabled}
2297 onCheckedChange={(checked) =>
2298 updateSettings({ sidebarWorkingShelfEnabled: Boolean(checked) })
2299 }
2300 aria-label="Working section (beta)"
2301 />
2302 }
2303 />
2304
2272 {supportsAutoSettlement ? (2305 {supportsAutoSettlement ? (
2273 <>2306 <>
2274 <SettingsRow2307 <SettingsRow
apps/web/src/components/settings/settingsSearch.ts+6
...@@ -273,6 +273,12 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -273,6 +273,12 @@ export const SETTINGS_SEARCH_ITEMS = [
273 to: "/settings/general",273 to: "/settings/general",
274 searchTerms: ["combine matching repositories environments sidebar"],274 searchTerms: ["combine matching repositories environments sidebar"],
275 },275 },
276 {
277 id: "working-shelf",
278 title: "Working section (beta)",
279 to: "/settings/general",
280 searchTerms: ["hide fold running monitoring threads inbox sidebar shelf"],
281 },
276 {282 {
277 id: "auto-settle-inactive-threads",283 id: "auto-settle-inactive-threads",
278 title: "Auto-settle inactive threads",284 title: "Auto-settle inactive threads",
docs/user/thread-sidebar.md+10
...@@ -100,6 +100,16 @@ If dragging is unavailable for one environment, update the T3 Code server runnin...@@ -100,6 +100,16 @@ If dragging is unavailable for one environment, update the T3 Code server runnin
100environment. Pinned and active reordering require server support. Threads from older servers keep100environment. Pinned and active reordering require server support. Threads from older servers keep
101their default order until the server is updated.101their default order until the server is updated.
102102
103### Fold working threads (beta)
104
105On web and desktop, turn on **Settings → General → Working section (beta)** to move threads that
106are working or monitoring into a collapsed **Working** section at the bottom of the sidebar. A
107thread returns to the top of the active list when it finishes, fails, or needs an approval or
108answer. Pinned threads stay in the pinned section.
109
110While this is on, the active list is ordered by when each thread last came back to you, so you
111cannot drag to reorder it. Your saved order returns when you turn it off.
112
103## Settle finished work113## Settle finished work
104114
105Choose **Settle thread** from its menu to move finished work out of the active list115Choose **Settle thread** from its menu to move finished work out of the active list
packages/contracts/src/settings.ts+7
...@@ -460,6 +460,12 @@ export const ClientSettingsSchema = Schema.Struct({...@@ -460,6 +460,12 @@ export const ClientSettingsSchema = Schema.Struct({
460 // old keys, so everyone, including prior beta opt-outs, resets to the new460 // old keys, so everyone, including prior beta opt-outs, resets to the new
461 // default sidebar.461 // default sidebar.
462 legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),462 legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
463 // Beta: working and monitoring threads fold into a Working shelf and return
464 // to the top of the inbox once they need the user. The inbox then orders by
465 // time, so manual placement there is ignored (and kept) while it is on.
466 sidebarWorkingShelfEnabled: Schema.Boolean.pipe(
467 Schema.withDecodingDefault(Effect.succeed(false)),
468 ),
463 sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe(469 sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe(
464 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)),470 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)),
465 ),471 ),
...@@ -1663,6 +1669,7 @@ export const ClientSettingsPatch = Schema.Struct({...@@ -1663,6 +1669,7 @@ export const ClientSettingsPatch = Schema.Struct({
1663 proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean),1669 proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean),
1664 showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),1670 showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),
1665 legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),1671 legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),
1672 sidebarWorkingShelfEnabled: Schema.optionalKey(Schema.Boolean),
1666 sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),1673 sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),
1667 sidebarProjectGroupingOverrides: Schema.optionalKey(1674 sidebarProjectGroupingOverrides: Schema.optionalKey(
1668 Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode),1675 Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode),