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..c33b0d1506f8 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,39 @@ 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(); + 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 (present.has(key) && !working.has(key)) observedInboxReturns.set(key, now); + } + lastWorkingThreadKeys = working; +} function compactSidebarTimeLabel(label: string): string { if (label === "just now") return "now"; @@ -639,7 +674,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 +683,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 +727,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 +2224,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 +2602,7 @@ export default function Sidebar() { draggableThreadKeys, activeReorderableThreadKeys, activeThreads, + workingThreads, snoozedThreads, settledThreads, snoozeNow, @@ -2576,8 +2619,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 +2657,7 @@ export default function Sidebar() { ? pinned : optimisticDrop.section === "settled" ? settled - : active + : inbox(projected) ).push( optimisticDrop.clearsSnooze ? projected @@ -2622,7 +2671,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 +2680,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 +2706,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 +2717,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 +2875,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 +3334,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 +3505,7 @@ export default function Sidebar() { if ( pinnedThreads.length + activeThreads.length + + workingThreads.length + snoozedThreads.length + settledThreads.length === 0 @@ -3412,6 +3519,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 +3539,8 @@ export default function Sidebar() { settledThreads.length, snoozedThreads.length, visibleSnoozedThreads, + visibleWorkingThreads, + workingThreads.length, ]); useEffect(() => { if ( @@ -3488,18 +3601,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 +3685,7 @@ export default function Sidebar() { activeOrder: activeKeys, activeKeysById, activeReorderableKeys: activeReorderableThreadKeys, + activeTimeOrdered: workingShelfEnabled, }).kind !== "none" ); }, @@ -3576,6 +3707,7 @@ export default function Sidebar() { pinnedKeys, sidebarListItems, threadByKey, + workingShelfEnabled, ]); const handleThreadDragEnd = useCallback( (event: DragEndEvent) => { @@ -3603,6 +3735,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 +3869,7 @@ export default function Sidebar() { unpinThread, unsettleThread, unsnoozeThread, + workingShelfEnabled, ], ); // One snooze per thread at a time — same double-dispatch guard as settle. @@ -4716,7 +4850,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 ? ( <>