From cc37ae2aeb768ef65242b18cb7691ff9806fb371 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 26 Sep 2026 23:07:42 -0700 Subject: [PATCH 1/2] feat(web): beta Working section folds busy threads out of the inbox Threads that are working or monitoring move into a collapsed Working section at the bottom of the sidebar, and return to the top of the inbox when they finish, fail, or need an approval or answer. While the beta is on, the inbox orders by when each thread last came back to the user. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../settings/DesktopClientSettings.test.ts | 1 + apps/web/src/components/Sidebar.drag.test.ts | 22 +++ apps/web/src/components/Sidebar.drag.ts | 44 +++-- apps/web/src/components/Sidebar.logic.test.ts | 167 ++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 83 +++++++- apps/web/src/components/Sidebar.tsx | 182 ++++++++++++++++-- .../components/settings/SettingsPanels.tsx | 33 ++++ .../src/components/settings/settingsSearch.ts | 6 + docs/user/thread-sidebar.md | 10 + packages/contracts/src/settings.ts | 7 + 10 files changed, 511 insertions(+), 44 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 53044dcf7e5d..235f3dd1d7d5 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -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: {}, diff --git a/apps/web/src/components/Sidebar.drag.test.ts b/apps/web/src/components/Sidebar.drag.test.ts index c757eca87aa4..4c1c006e91b6 100644 --- a/apps/web/src/components/Sidebar.drag.test.ts +++ b/apps/web/src/components/Sidebar.drag.test.ts @@ -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, diff --git a/apps/web/src/components/Sidebar.drag.ts b/apps/web/src/components/Sidebar.drag.ts index bfd112d18998..4bc7bc6a8367 100644 --- a/apps/web/src/components/Sidebar.drag.ts +++ b/apps/web/src/components/Sidebar.drag.ts @@ -13,6 +13,11 @@ const stationary = { x: 0, y: 0, scaleX: 1, scaleY: 1 }; const hidden = { ...stationary, scaleY: 0 }; type ThreadItem = Extract; type Layout = Parameters[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. */ @@ -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) => { @@ -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; @@ -121,6 +126,7 @@ export function createSidebarSortingStrategy(input: { const groups: Record = { pinned: [], active: [], + working: [], snoozed: [], settled: [], }; @@ -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); @@ -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( @@ -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) && @@ -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; @@ -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); @@ -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; } diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index fede3c183448..e9579ab4f524 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -20,6 +20,7 @@ import { hasUnseenCompletion, isContextMenuPointerDown, isSidebarNestedLinkClick, + isSidebarThreadWorking, isTrailingDoubleClick, orderItemsByPreferredIds, resolveProjectStatusIndicator, @@ -32,6 +33,7 @@ import { shouldClearThreadSelectionOnMouseDown, shouldRecedeSidebarThread, sortLogicalProjectsForSidebar, + sortInboxThreadsByReturn, resolveSidebarDropTarget, pinOrderKeyBetween, planPinnedReorder, @@ -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, + }); + }); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 2796b2f7f885..c81ae414da49 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -125,9 +125,10 @@ export const animateSidebarLayoutChanges: AnimateLayoutChanges = (args) => // the lifecycle action; Sidebar.drag previews the resulting layout. Pinned // and active threads keep the dragged position; settled threads use time // order. Snoozed rows can leave the shelf, but dropping into it is not -// supported because snoozing requires a wake time. +// supported because snoozing requires a wake time. The Working shelf (beta) +// follows live status, so it is neither a drag source nor a destination. -export type SidebarSection = "pinned" | "active" | "snoozed" | "settled"; +export type SidebarSection = "pinned" | "active" | "working" | "snoozed" | "settled"; /** Sortable ids: thread rows use their scoped key; structural items use a colon-free prefix: scoped thread keys always contain a colon. */ @@ -141,6 +142,7 @@ export type SidebarListMarker = | "settled-placeholder" /** The boundary between pinned and active rows. */ | "pinned-divider" + | "working-header" | "snoozed-header" | "settled-header"; @@ -158,7 +160,7 @@ export function sidebarListItemId(item: SidebarListItem): string { /** The section a slot belongs to, read off the markers around it: from the top down, everything before the pinned divider is pinned, then the - inbox until the snoozed header, the shelf until the settled header, + inbox until the first shelf header, each shelf until the next header, then settled. */ function sectionAtSidebarSlot(items: readonly SidebarListItem[], index: number): SidebarSection { let section: SidebarSection = "pinned"; @@ -166,6 +168,7 @@ function sectionAtSidebarSlot(items: readonly SidebarListItem[], index: number): const item = items[i]!; if (item.kind !== "marker") continue; if (item.marker === "pinned-divider") section = "active"; + else if (item.marker === "working-header") section = "working"; else if (item.marker === "snoozed-header") section = "snoozed"; else if (item.marker === "settled-header") section = "settled"; } @@ -173,7 +176,7 @@ function sectionAtSidebarSlot(items: readonly SidebarListItem[], index: number): } /** Resolve the destination section and manual order from an arrayMove across - * the separators. The snoozed shelf is never a destination. */ + * the separators. The working and snoozed shelves are never destinations. */ export type SidebarDropTarget = { readonly section: "pinned" | "active" | "settled"; readonly pinnedOrder: readonly string[]; @@ -191,14 +194,19 @@ export function resolveSidebarDropTarget( const moved = items.filter((_, index) => index !== activeIndex); moved.splice(overIndex, 0, items[activeIndex]!); const section = sectionAtSidebarSlot(moved, overIndex); - if (section === "snoozed") return null; + if (section === "working" || section === "snoozed") return null; const pinnedOrder: string[] = []; const activeOrder: string[] = []; let currentSection: SidebarSection = "pinned"; for (const item of moved) { if (item.kind === "marker") { if (item.marker === "pinned-divider") currentSection = "active"; - else if (item.marker === "snoozed-header" || item.marker === "settled-header") break; + else if ( + item.marker === "working-header" || + item.marker === "snoozed-header" || + item.marker === "settled-header" + ) + break; } else if (currentSection === "pinned") pinnedOrder.push(item.key); else activeOrder.push(item.key); } @@ -224,7 +232,8 @@ export type SidebarThreadDropPlan = } | { readonly kind: "move-active"; - readonly order: readonly string[]; + /** Null when the inbox is time-ordered: the drop has no placement. */ + readonly order: readonly string[] | null; readonly assignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>; readonly unpin: boolean; readonly unsettle: boolean; @@ -234,14 +243,14 @@ export type SidebarThreadDropPlan = /** What dropping in `to` does to a thread lifted from `from`, for the badge on the lifted row. Null while reordering inside one section and for the - snoozed shelf, which cannot be a drop target. */ + working and snoozed shelves, which cannot be drop targets. */ export type SidebarDropVerb = "pin" | "unpin" | "settle" | "unsettle" | "wake"; export function resolveSidebarDropVerb( from: SidebarSection, to: SidebarSection | null, ): SidebarDropVerb | null { - if (to === null || to === from || to === "snoozed") return null; + if (to === null || to === from || to === "working" || to === "snoozed") return null; if (to === "pinned") return "pin"; if (to === "settled") return "settle"; if (from === "pinned") return "unpin"; @@ -264,6 +273,8 @@ export function planSidebarThreadDrop(input: { readonly activeOrder: readonly string[]; readonly activeKeysById: ReadonlyMap; readonly activeReorderableKeys?: ReadonlySet; + /** Working beta: the inbox sorts by time, so drops only change lifecycle. */ + readonly activeTimeOrdered?: boolean; }): SidebarThreadDropPlan { const { activeKey, @@ -283,6 +294,20 @@ export function planSidebarThreadDrop(input: { } switch (target.section) { case "active": { + // Like the settled tail: threads can enter a time-ordered inbox, but + // not be arranged inside it. + if (input.activeTimeOrdered) { + return activeSection === "active" + ? { kind: "none" } + : { + kind: "move-active", + order: null, + assignments: [], + unpin: activePinned, + unsettle: activeSettled, + unsnooze: activeSection === "snoozed", + }; + } const order = target.activeOrder; if ( activeSection === "active" && @@ -842,6 +867,16 @@ export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): Si return "ready"; } +/** Working beta: threads busy with work that does not need the user fold into + the Working shelf. Approvals, questions, plan prompts, and failures stay + in the inbox. */ +export function isSidebarThreadWorking(thread: ThreadStatusInput): boolean { + const status = resolveSidebarThreadStatus(thread); + if (status !== "working" && status !== "monitoring") return false; + // A plan prompt outranks lingering background work, as in the status pill. + return resolveThreadStatusPill({ thread })?.label !== "Plan Ready"; +} + /** First VALID timestamp wins: `a ?? b` falls through on null, but a present- yet-malformed string must also fall through to the next candidate rather than sink the row to the epoch. */ @@ -950,6 +985,36 @@ export function reduceSidebarProjectScopeMenuState( } } +/** Working beta: the inbox lists threads newest first by when each last came + back to the user, so a thread that leaves the Working shelf lands on top. + `observedReturnAt` adds returns the server does not stamp, such as an + approval request mid-turn or background work ending. */ +export function sortInboxThreadsByReturn< + T extends Pick< + SidebarThreadSummary, + "id" | "environmentId" | "createdAt" | "unsettledAt" | "latestTurn" + >, +>(threads: readonly T[], observedReturnAt?: (thread: T) => number | undefined): T[] { + const timestamps = new Map( + threads.map((thread) => [ + thread, + Math.max( + toSortableTimestamp(thread.createdAt) ?? 0, + toSortableTimestamp(thread.unsettledAt ?? undefined) ?? 0, + toSortableTimestamp(thread.latestTurn?.requestedAt) ?? 0, + toSortableTimestamp(thread.latestTurn?.completedAt ?? undefined) ?? 0, + observedReturnAt?.(thread) ?? 0, + ), + ]), + ); + return [...threads].sort( + (left, right) => + timestamps.get(right)! - timestamps.get(left)! || + left.id.localeCompare(right.id) || + left.environmentId.localeCompare(right.environmentId), + ); +} + /** The timestamp a working thread's elapsed label counts from: the running turn's start (request time until adoption), falling back to the session's last transition when the turn projection lags behind. Malformed diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 90f65ea2b941..cf25f07e4258 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -166,6 +166,7 @@ import { firstValidTimestampMs, hasUnseenCompletion, isSidebarNestedLinkClick, + isSidebarThreadWorking, isTrailingDoubleClick, orderItemsByPreferredIds, planSidebarThreadDrop, @@ -183,6 +184,7 @@ import { resolveWorkingStartedAt, sidebarListItemId, sidebarMarkerId, + sortInboxThreadsByReturn, sortLogicalProjectsForSidebar, sortPinnedThreadsForSidebar, sortThreadsForSidebar, @@ -258,6 +260,33 @@ const SETTLED_TAIL_PAGE_COUNT = 25; // Fresh keys deliberately reset both shelves to collapsed for existing users. const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar:settled-expanded"; const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar:snoozed-expanded"; +const WORKING_SHELF_EXPANDED_KEY = "t3code:sidebar:working-expanded"; + +// Working beta: when this client saw each thread leave the Working shelf. +// Module scope keeps the inbox order across routes that unmount the sidebar. +let lastWorkingThreadKeys: ReadonlySet | null = null; +const observedInboxReturns = new Map(); + +/** Stamps threads that stopped working since the last call. The first call + only takes a baseline, so mounting never reshuffles the inbox. Pass null + to reset when the beta is off. */ +function observeInboxReturns(threads: readonly EnvironmentThreadShell[] | null): void { + if (threads === null) { + lastWorkingThreadKeys = null; + observedInboxReturns.clear(); + return; + } + const working = new Set( + threads + .filter(isSidebarThreadWorking) + .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), + ); + const now = Date.now(); + for (const key of lastWorkingThreadKeys ?? []) { + if (!working.has(key)) observedInboxReturns.set(key, now); + } + lastWorkingThreadKeys = working; +} function compactSidebarTimeLabel(label: string): string { if (label === "just now") return "now"; @@ -639,7 +668,7 @@ function SidebarDragBoundary(props: { // Shelf headers stay visible and keep their measured height while dragging. function SidebarSectionHeader(props: { - marker: "snoozed-header" | "settled-header"; + marker: "working-header" | "snoozed-header" | "settled-header"; label: string; className?: string; // While dragging, the settled header reads at full strength and takes the @@ -648,7 +677,13 @@ function SidebarSectionHeader(props: { isDropTarget?: boolean; toggle: { expanded: boolean; onToggle: () => void }; }) { - const snoozed = props.marker === "snoozed-header"; + const shelf = + props.marker === "working-header" + ? "working" + : props.marker === "snoozed-header" + ? "snoozed" + : "settled"; + const snoozed = shelf === "snoozed"; const className = cn( "flex h-full w-full items-center gap-2 px-2 text-left text-xs font-medium", snoozed ? "text-info-foreground" : "text-sidebar-muted-foreground/60", @@ -686,7 +721,7 @@ function SidebarSectionHeader(props: { type="button" onClick={props.toggle.onToggle} aria-expanded={props.toggle.expanded} - data-testid={`sidebar-${snoozed ? "snoozed" : "settled"}-shelf-toggle`} + data-testid={`sidebar-${shelf}-shelf-toggle`} className={cn(className, "cursor-pointer")} > {content} @@ -2183,6 +2218,7 @@ export default function Sidebar() { const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const timestampFormat = useClientSettings((s) => s.timestampFormat); + const workingShelfEnabled = useClientSettings((s) => s.sidebarWorkingShelfEnabled); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const { settleThread, @@ -2560,6 +2596,7 @@ export default function Sidebar() { draggableThreadKeys, activeReorderableThreadKeys, activeThreads, + workingThreads, snoozedThreads, settledThreads, snoozeNow, @@ -2576,8 +2613,14 @@ export default function Sidebar() { (scopedProjectKeys === null || scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), ); + observeInboxReturns(workingShelfEnabled ? threads : null); const pinned: EnvironmentThreadShell[] = []; const active: EnvironmentThreadShell[] = []; + const working: EnvironmentThreadShell[] = []; + // Working beta: only inbox threads fold away. Pins stay where the user + // put them, and snoozed or settled threads keep their shelves. + const inbox = (thread: EnvironmentThreadShell) => + workingShelfEnabled && isSidebarThreadWorking(thread) ? working : active; const snoozed: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; const draggable = new Set(); @@ -2608,7 +2651,7 @@ export default function Sidebar() { ? pinned : optimisticDrop.section === "settled" ? settled - : active + : inbox(projected) ).push( optimisticDrop.clearsSnooze ? projected @@ -2622,7 +2665,7 @@ export default function Sidebar() { } else if (thread.pinnedAt != null) { pinned.push(thread); } else { - active.push(thread); + inbox(thread).push(thread); } } // One shared rule on every platform (see sortPinnedThreadsByOrderKey): @@ -2631,7 +2674,13 @@ export default function Sidebar() { // sort, or mixed-version fleets would render different pinned orders on // web and mobile from the same data. const sortedPinned = sortPinnedThreadsForSidebar(pinned); - const sortedActive = sortThreadsForSidebar(active); + const sortedActive = workingShelfEnabled + ? sortInboxThreadsByReturn(active, (thread) => + observedInboxReturns.get( + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + ) + : sortThreadsForSidebar(active); return { pinnedThreads: optimisticDrop?.section !== "pinned" || optimisticDrop.order === null @@ -2651,6 +2700,8 @@ export default function Sidebar() { preferredIds: optimisticDrop.order, getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), }), + // Newest work first, by the same clock as the inbox. + workingThreads: sortInboxThreadsByReturn(working), // Soonest wake first: "what comes back next" is the shelf's question. snoozedThreads: snoozed.toSorted( (left, right) => @@ -2660,15 +2711,29 @@ export default function Sidebar() { settledThreads: sortSettledThreads(settled), snoozeNow: preciseNow, }; - }, [nowMinute, optimisticDrop, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); + }, [ + nowMinute, + optimisticDrop, + scopedProjectKeys, + serverConfigs, + snoozeWakeTick, + threads, + workingShelfEnabled, + ]); const threadSearchInputRef = useRef(null); const [threadSearchQuery, setThreadSearchQuery] = useState(""); const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0); const isSearchingThreads = threadSearchQuery.trim().length > 0; const searchableThreads = useMemo( - () => [...pinnedThreads, ...activeThreads, ...snoozedThreads, ...settledThreads], - [activeThreads, pinnedThreads, settledThreads, snoozedThreads], + () => [ + ...pinnedThreads, + ...activeThreads, + ...workingThreads, + ...snoozedThreads, + ...settledThreads, + ], + [activeThreads, pinnedThreads, settledThreads, snoozedThreads, workingThreads], ); const searchEnvironmentIds = useMemo( () => @@ -2804,9 +2869,43 @@ export default function Sidebar() { return routeThread === undefined ? EMPTY_THREADS : [routeThread]; }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]); + // The Working shelf (beta) collapses the same way, with the same route + // exception: sending a message folds the open thread into the shelf, and + // its row must stay visible there. + const [workingShelfExpanded, setWorkingShelfExpanded] = useLocalStorage( + WORKING_SHELF_EXPANDED_KEY, + false, + Schema.Boolean, + ); + const toggleWorkingShelf = useCallback( + () => setWorkingShelfExpanded((value) => !value), + [setWorkingShelfExpanded], + ); + const visibleWorkingThreads = useMemo(() => { + if (workingShelfExpanded) return workingThreads; + if (routeThreadKey === null) return EMPTY_THREADS; + const routeThread = workingThreads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, + ); + return routeThread === undefined ? EMPTY_THREADS : [routeThread]; + }, [routeThreadKey, workingShelfExpanded, workingThreads]); + const orderedThreads = useMemo( - () => [...pinnedThreads, ...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads], - [pinnedThreads, activeThreads, visibleSnoozedThreads, renderedSettledThreads], + () => [ + ...pinnedThreads, + ...activeThreads, + ...visibleWorkingThreads, + ...visibleSnoozedThreads, + ...renderedSettledThreads, + ], + [ + pinnedThreads, + activeThreads, + visibleWorkingThreads, + visibleSnoozedThreads, + renderedSettledThreads, + ], ); const orderedThreadKeys = useMemo( () => @@ -3229,10 +3328,11 @@ export default function Sidebar() { }; add(pinnedThreads, "pinned"); add(activeThreads, "active"); + add(workingThreads, "working"); add(snoozedThreads, "snoozed"); add(settledThreads, "settled"); return map; - }, [activeThreads, pinnedThreads, settledThreads, snoozedThreads]); + }, [activeThreads, pinnedThreads, settledThreads, snoozedThreads, workingThreads]); const pinnedKeys = useMemo( () => pinnedThreads.map((thread) => @@ -3399,6 +3499,7 @@ export default function Sidebar() { if ( pinnedThreads.length + activeThreads.length + + workingThreads.length + snoozedThreads.length + settledThreads.length === 0 @@ -3412,6 +3513,10 @@ export default function Sidebar() { const activeRows = rowsOf(activeThreads, "active"); items.push({ kind: "marker", marker: "active-placeholder" }); items.push(...activeRows); + if (workingThreads.length > 0) { + items.push({ kind: "marker", marker: "working-header" }); + items.push(...rowsOf(visibleWorkingThreads, "working")); + } if (snoozedThreads.length > 0) { items.push({ kind: "marker", marker: "snoozed-header" }); items.push(...rowsOf(visibleSnoozedThreads, "snoozed")); @@ -3428,6 +3533,8 @@ export default function Sidebar() { settledThreads.length, snoozedThreads.length, visibleSnoozedThreads, + visibleWorkingThreads, + workingThreads.length, ]); useEffect(() => { if ( @@ -3488,18 +3595,35 @@ export default function Sidebar() { applySidebarThreadDrop(thread, "settled", dragState.occurredAt), ]).map(key); }, [dragState, settledThreads, threadByKey]); + // Working beta: the inbox is time-ordered too, so the preview shows the + // slot a drop will land in, not the slot under the pointer. + const draggedActiveOrder = useMemo(() => { + const thread = dragState === null ? undefined : threadByKey.get(dragState.activeKey); + if (!workingShelfEnabled || dragState === null || thread === undefined) return undefined; + const key = (candidate: EnvironmentThreadShell) => + scopedThreadKey(scopeThreadRef(candidate.environmentId, candidate.id)); + return sortInboxThreadsByReturn( + [ + ...activeThreads.filter((candidate) => key(candidate) !== dragState.activeKey), + applySidebarThreadDrop(thread, "active", dragState.occurredAt), + ], + (candidate) => observedInboxReturns.get(key(candidate)), + ).map(key); + }, [activeThreads, dragState, threadByKey, workingShelfEnabled]); const sidebarSortingStrategy = useMemo( () => createSidebarSortingStrategy({ items: sidebarListItems, boundaryLabelHeight: SIDEBAR_DRAG_LABEL_HEIGHT, settledOrder: draggedSettledOrder, + ...(draggedActiveOrder === undefined ? {} : { activeOrder: draggedActiveOrder }), settledExpanded: settledShelfExpanded, settledVisibleCount, routeThreadKey, snoozedThreadCount: snoozedThreads.length, }), [ + draggedActiveOrder, draggedSettledOrder, routeThreadKey, settledShelfExpanded, @@ -3555,6 +3679,7 @@ export default function Sidebar() { activeOrder: activeKeys, activeKeysById, activeReorderableKeys: activeReorderableThreadKeys, + activeTimeOrdered: workingShelfEnabled, }).kind !== "none" ); }, @@ -3576,6 +3701,7 @@ export default function Sidebar() { pinnedKeys, sidebarListItems, threadByKey, + workingShelfEnabled, ]); const handleThreadDragEnd = useCallback( (event: DragEndEvent) => { @@ -3603,6 +3729,7 @@ export default function Sidebar() { activeOrder: activeKeys, activeKeysById, activeReorderableKeys: activeReorderableThreadKeys, + activeTimeOrdered: workingShelfEnabled, }); if (plan.kind === "none") return; if (plan.kind === "settle" && settlingThreadKeysRef.current.has(activeKey)) return; @@ -3736,6 +3863,7 @@ export default function Sidebar() { unpinThread, unsettleThread, unsnoozeThread, + workingShelfEnabled, ], ); // One snooze per thread at a time — same double-dispatch guard as settle. @@ -4716,7 +4844,9 @@ export default function Sidebar() { // row: every other thread is a full card. Density comes // from users (or the auto rules) actually parking work, // not from the sidebar second-guessing what still matters. - const isCard = section === "active" || section === "pinned"; + // Working rows stay cards so their live status shows. + const isCard = + section === "active" || section === "pinned" || section === "working"; const rowVariant = isCard ? "card" : "slim"; return ( , ); break; + case "working-header": + items.push( + , + ); + break; case "snoozed-header": items.push( void) { DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode ? ["Project Grouping"] : []), + ...(settings.sidebarWorkingShelfEnabled !== + DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled + ? ["Working section"] + : []), ...(settings.sidebarAutoSettleAfterDays !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ? ["Auto-settle inactive threads"] @@ -684,6 +688,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.sidebarAutoSettleAfterDays, settings.sidebarAutoSettleOnMerge, settings.sidebarProjectGroupingMode, + settings.sidebarWorkingShelfEnabled, settings.sidebarThreadPreviewCount, settings.showSkillsInSlashMenu, settings.timestampFormat, @@ -781,6 +786,7 @@ export function useSettingsRestore(onRestored?: () => void) { panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, + sidebarWorkingShelfEnabled: DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled, sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode, @@ -2269,6 +2275,33 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + sidebarWorkingShelfEnabled: DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled, + }) + } + /> + ) : null + } + control={ + + updateSettings({ sidebarWorkingShelfEnabled: Boolean(checked) }) + } + aria-label="Working section (beta)" + /> + } + /> + {supportsAutoSettlement ? ( <> Date: Sat, 26 Sep 2026 23:17:25 -0700 Subject: [PATCH 2/2] fix(web): drop deleted threads from observed inbox returns Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/Sidebar.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cf25f07e4258..c33b0d1506f8 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -276,14 +276,20 @@ function observeInboxReturns(threads: readonly EnvironmentThreadShell[] | null): observedInboxReturns.clear(); return; } - const working = new Set( - threads - .filter(isSidebarThreadWorking) - .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), - ); + const working = new Set(); + const present = new Set(); + for (const thread of threads) { + const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + present.add(key); + if (isSidebarThreadWorking(thread)) working.add(key); + } + // Drop deleted threads so the map stays bounded by the live thread list. + for (const key of observedInboxReturns.keys()) { + if (!present.has(key)) observedInboxReturns.delete(key); + } const now = Date.now(); for (const key of lastWorkingThreadKeys ?? []) { - if (!working.has(key)) observedInboxReturns.set(key, now); + if (present.has(key) && !working.has(key)) observedInboxReturns.set(key, now); } lastWorkingThreadKeys = working; }