| author | |
| committer | |
| log | b33eda13998b5b0571daaf266c87f6f31af41f7d |
| tree | 450da523a3fd82916bd6a81cdaa246422db6d9e7 |
| parent | 0a04cc50def48121b9ece038c7df8b7fa789074b |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 65 | sidebarThreadSortOrder: "created_at", |
| 66 | 66 | sidebarThreadPreviewCount: 6, |
| 67 | 67 | legacySidebarEnabled: false, |
| 68 | sidebarWorkingShelfEnabled: false, | |
| 68 | 69 | loadBalancingEnabled: false, |
| 69 | 70 | loadBalancingWeights: { "environment-1": 75, "environment-2": 0 }, |
| 70 | 71 | pullRequestMergeMethodOverrides: {}, |
apps/web/src/components/Sidebar.drag.test.ts+22| ... | ... | @@ -621,6 +621,28 @@ describe("sidebar drag projection", () => { |
| 621 | 621 | expect(result.get(sidebarMarkerId("snoozed-header"))).toEqual({ ...stationary, y: -46 }); |
| 622 | 622 | }); |
| 623 | 623 | |
| 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 | 646 | it("derives missing card geometry from the measured root scale", () => { |
| 625 | 647 | const items = [ |
| 626 | 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 | 13 | const hidden = { ...stationary, scaleY: 0 }; |
| 14 | 14 | type ThreadItem = Extract<SidebarListItem, { kind: "thread" }>; |
| 15 | 15 | type Layout = Parameters<SortingStrategy>[0]; |
| 16 | const isShelfHeader = (item: SidebarListItem | undefined) => | |
| 17 | item?.kind === "marker" && | |
| 18 | (item.marker === "working-header" || | |
| 19 | item.marker === "snoozed-header" || | |
| 20 | item.marker === "settled-header"); | |
| 16 | 21 | |
| 17 | 22 | /** Keep the lifted card below the Pins label, including when Pins is empty. |
| 18 | 23 | * The container rect follows scrolling; the offset is measured once at pickup. */ |
| ... | ... | @@ -57,13 +62,11 @@ export function createSidebarCollisionDetection( |
| 57 | 62 | if (pointer.x >= boundary.left && pointer.x <= boundary.right) { |
| 58 | 63 | if (pointer.y < previousY && pointer.y <= boundary.bottom) boundarySection = "pinned"; |
| 59 | 64 | else if (pointer.y > previousY && pointer.y >= boundary.top) boundarySection = "active"; |
| 60 | const nextHeader = | |
| 61 | args.droppableContainers.find( | |
| 62 | (container) => container.id === sidebarMarkerId("snoozed-header"), | |
| 63 | ) ?? | |
| 64 | args.droppableContainers.find( | |
| 65 | (container) => container.id === sidebarMarkerId("settled-header"), | |
| 66 | ); | |
| 65 | const nextHeader = (["working-header", "snoozed-header", "settled-header"] as const) | |
| 66 | .map((marker) => | |
| 67 | args.droppableContainers.find((container) => container.id === sidebarMarkerId(marker)), | |
| 68 | ) | |
| 69 | .find((container) => container !== undefined); | |
| 67 | 70 | const activeBottom = nextHeader?.node.current?.getBoundingClientRect().top; |
| 68 | 71 | if (boundarySection === "pinned" || (activeBottom != null && pointer.y < activeBottom)) { |
| 69 | 72 | const target = collisions.find((collision) => { |
| ... | ... | @@ -97,6 +100,8 @@ export function createSidebarCollisionDetection( |
| 97 | 100 | export function createSidebarSortingStrategy(input: { |
| 98 | 101 | items: readonly SidebarListItem[]; |
| 99 | 102 | settledOrder: readonly string[]; |
| 103 | /** Time-ordered inbox (Working beta): where the lifted row would land. */ | |
| 104 | activeOrder?: readonly string[]; | |
| 100 | 105 | settledExpanded: boolean; |
| 101 | 106 | settledVisibleCount?: number; |
| 102 | 107 | routeThreadKey?: string | null; |
| ... | ... | @@ -121,6 +126,7 @@ export function createSidebarSortingStrategy(input: { |
| 121 | 126 | const groups: Record<SidebarSection, ThreadItem[]> = { |
| 122 | 127 | pinned: [], |
| 123 | 128 | active: [], |
| 129 | working: [], | |
| 124 | 130 | snoozed: [], |
| 125 | 131 | settled: [], |
| 126 | 132 | }; |
| ... | ... | @@ -129,13 +135,13 @@ export function createSidebarSortingStrategy(input: { |
| 129 | 135 | let headerScale: number | undefined; |
| 130 | 136 | for (const [index, item] of items.entries()) { |
| 131 | 137 | if (item.kind === "marker") { |
| 132 | if (item.marker === "settled-header" || item.marker === "snoozed-header") { | |
| 138 | if (isShelfHeader(item)) { | |
| 133 | 139 | const height = rects[index]?.height; |
| 134 | 140 | if (height) headerScale ??= height / 32; |
| 135 | 141 | } |
| 136 | 142 | continue; |
| 137 | 143 | } |
| 138 | if (item.section === "pinned" || item.section === "active") | |
| 144 | if (item.section === "pinned" || item.section === "active" || item.section === "working") | |
| 139 | 145 | cardHeight ??= rects[index]?.height; |
| 140 | 146 | else slimHeight ??= rects[index]?.height; |
| 141 | 147 | if (item.key !== active.key) groups[item.section].push(item); |
| ... | ... | @@ -152,7 +158,7 @@ export function createSidebarSortingStrategy(input: { |
| 152 | 158 | ? target.pinnedOrder |
| 153 | 159 | : target.section === "settled" |
| 154 | 160 | ? input.settledOrder |
| 155 | : target.activeOrder; | |
| 161 | : (input.activeOrder ?? target.activeOrder); | |
| 156 | 162 | const ranks = new Map(order.map((key, index) => [key, index])); |
| 157 | 163 | const rank = ranks.get(active.key) ?? Number.POSITIVE_INFINITY; |
| 158 | 164 | const index = group.findIndex( |
| ... | ... | @@ -180,6 +186,10 @@ export function createSidebarSortingStrategy(input: { |
| 180 | 186 | projected.push(...groups.pinned); |
| 181 | 187 | marker("pinned-divider"); |
| 182 | 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 | 193 | if ( |
| 184 | 194 | groups.snoozed.length > 0 || |
| 185 | 195 | ((active.section !== "snoozed" || (input.snoozedThreadCount ?? 0) > 1) && |
| ... | ... | @@ -194,7 +204,8 @@ export function createSidebarSortingStrategy(input: { |
| 194 | 204 | const index = indices.get(sidebarListItemId(item)); |
| 195 | 205 | const rect = index === undefined ? undefined : rects[index]; |
| 196 | 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 | 209 | ? cardHeight |
| 199 | 210 | : slimHeight; |
| 200 | 211 | const moved = item.kind === "thread" && item.key === active.key; |
| ... | ... | @@ -207,11 +218,7 @@ export function createSidebarSortingStrategy(input: { |
| 207 | 218 | ? fallback |
| 208 | 219 | : (rect?.height ?? fallback); |
| 209 | 220 | }); |
| 210 | const firstShelf = items.findIndex( | |
| 211 | (item) => | |
| 212 | item.kind === "marker" && | |
| 213 | (item.marker === "snoozed-header" || item.marker === "settled-header"), | |
| 214 | ); | |
| 221 | const firstShelf = items.findIndex(isShelfHeader); | |
| 215 | 222 | const shelfRect = rects[firstShelf]; |
| 216 | 223 | const beforeShelf = rects[firstShelf - 1]; |
| 217 | 224 | const lastRect = rects.at(-1); |
| ... | ... | @@ -227,10 +234,7 @@ export function createSidebarSortingStrategy(input: { |
| 227 | 234 | const result = items.map(() => hidden); |
| 228 | 235 | let top = rects[0].top; |
| 229 | 236 | for (const [projectedIndex, item] of projected.entries()) { |
| 230 | if ( | |
| 231 | item.kind === "marker" && | |
| 232 | (item.marker === "snoozed-header" || item.marker === "settled-header") | |
| 233 | ) { | |
| 237 | if (isShelfHeader(item)) { | |
| 234 | 238 | top += shelfSpace; |
| 235 | 239 | shelfSpace = 0; |
| 236 | 240 | } |
apps/web/src/components/Sidebar.logic.test.ts+167| ... | ... | @@ -20,6 +20,7 @@ import { |
| 20 | 20 | hasUnseenCompletion, |
| 21 | 21 | isContextMenuPointerDown, |
| 22 | 22 | isSidebarNestedLinkClick, |
| 23 | isSidebarThreadWorking, | |
| 23 | 24 | isTrailingDoubleClick, |
| 24 | 25 | orderItemsByPreferredIds, |
| 25 | 26 | resolveProjectStatusIndicator, |
| ... | ... | @@ -32,6 +33,7 @@ import { |
| 32 | 33 | shouldClearThreadSelectionOnMouseDown, |
| 33 | 34 | shouldRecedeSidebarThread, |
| 34 | 35 | sortLogicalProjectsForSidebar, |
| 36 | sortInboxThreadsByReturn, | |
| 35 | 37 | resolveSidebarDropTarget, |
| 36 | 38 | pinOrderKeyBetween, |
| 37 | 39 | planSidebarThreadDrop, |
| ... | ... | @@ -2459,3 +2461,168 @@ describe("navigation after parking a thread", () => { |
| 2459 | 2461 | }, |
| 2460 | 2462 | ); |
| 2461 | 2463 | }); |
| 2464 | ||
| 2465 | describe("Working shelf (beta)", () => { | |
| 2466 | const session = { | |
| 2467 | threadId: ThreadId.make("thread-1"), | |
| 2468 | status: "running" as const, | |
| 2469 | providerName: "Codex", | |
| 2470 | providerInstanceId: ProviderInstanceId.make("codex"), | |
| 2471 | runtimeMode: DEFAULT_RUNTIME_MODE, | |
| 2472 | activeTurnId: "turn-1" as never, | |
| 2473 | lastError: null, | |
| 2474 | updatedAt: "2026-03-09T10:00:00.000Z", | |
| 2475 | }; | |
| 2476 | const idle = { | |
| 2477 | hasActionableProposedPlan: false, | |
| 2478 | hasPendingApprovals: false, | |
| 2479 | hasPendingUserInput: false, | |
| 2480 | interactionMode: "default" as const, | |
| 2481 | latestTurn: makeLatestTurn(), | |
| 2482 | session: { ...session, status: "ready" as const }, | |
| 2483 | }; | |
| 2484 | ||
| 2485 | it("folds away running and monitoring threads only", () => { | |
| 2486 | expect(isSidebarThreadWorking({ ...idle, session })).toBe(true); | |
| 2487 | expect(isSidebarThreadWorking({ ...idle, backgroundLiveness: "monitoring" })).toBe(true); | |
| 2488 | expect(isSidebarThreadWorking(idle)).toBe(false); | |
| 2489 | expect(isSidebarThreadWorking({ ...idle, session, hasPendingApprovals: true })).toBe(false); | |
| 2490 | expect(isSidebarThreadWorking({ ...idle, session, hasPendingUserInput: true })).toBe(false); | |
| 2491 | expect( | |
| 2492 | isSidebarThreadWorking({ | |
| 2493 | ...idle, | |
| 2494 | backgroundLiveness: "working", | |
| 2495 | session: { ...session, status: "error" as const, lastError: "boom" }, | |
| 2496 | }), | |
| 2497 | ).toBe(false); | |
| 2498 | }); | |
| 2499 | ||
| 2500 | it("keeps a ready plan in the inbox while background work runs", () => { | |
| 2501 | expect( | |
| 2502 | isSidebarThreadWorking({ | |
| 2503 | ...idle, | |
| 2504 | interactionMode: "plan", | |
| 2505 | hasActionableProposedPlan: true, | |
| 2506 | backgroundLiveness: "working", | |
| 2507 | }), | |
| 2508 | ).toBe(false); | |
| 2509 | }); | |
| 2510 | ||
| 2511 | describe("sortInboxThreadsByReturn", () => { | |
| 2512 | const thread = ( | |
| 2513 | id: string, | |
| 2514 | input: { createdAt: string; completedAt?: string | null; unsettledAt?: string }, | |
| 2515 | ) => ({ | |
| 2516 | id: ThreadId.make(id), | |
| 2517 | environmentId: localEnvironmentId, | |
| 2518 | createdAt: input.createdAt, | |
| 2519 | unsettledAt: input.unsettledAt ?? null, | |
| 2520 | latestTurn: | |
| 2521 | input.completedAt === undefined | |
| 2522 | ? null | |
| 2523 | : { ...makeLatestTurn({ completedAt: input.completedAt }), requestedAt: input.createdAt }, | |
| 2524 | }); | |
| 2525 | ||
| 2526 | it("puts the thread that finished last on top, whatever its age", () => { | |
| 2527 | const sorted = sortInboxThreadsByReturn([ | |
| 2528 | thread("new", { createdAt: "2026-03-09T11:00:00.000Z" }), | |
| 2529 | thread("old-finished-now", { | |
| 2530 | createdAt: "2026-03-01T09:00:00.000Z", | |
| 2531 | completedAt: "2026-03-09T12:00:00.000Z", | |
| 2532 | }), | |
| 2533 | thread("reopened", { | |
| 2534 | createdAt: "2026-03-02T09:00:00.000Z", | |
| 2535 | unsettledAt: "2026-03-09T11:30:00.000Z", | |
| 2536 | }), | |
| 2537 | ]); | |
| 2538 | expect(sorted.map((entry) => entry.id)).toEqual(["old-finished-now", "reopened", "new"]); | |
| 2539 | }); | |
| 2540 | ||
| 2541 | it("counts a return the server does not stamp, like an approval request", () => { | |
| 2542 | const waiting = thread("asks-approval", { | |
| 2543 | createdAt: "2026-03-09T09:00:00.000Z", | |
| 2544 | completedAt: null, | |
| 2545 | }); | |
| 2546 | const finished = thread("finished", { | |
| 2547 | createdAt: "2026-03-09T09:30:00.000Z", | |
| 2548 | completedAt: "2026-03-09T11:00:00.000Z", | |
| 2549 | }); | |
| 2550 | expect(sortInboxThreadsByReturn([finished, waiting]).map((entry) => entry.id)).toEqual([ | |
| 2551 | "finished", | |
| 2552 | "asks-approval", | |
| 2553 | ]); | |
| 2554 | expect( | |
| 2555 | sortInboxThreadsByReturn([finished, waiting], (entry) => | |
| 2556 | entry === waiting ? Date.parse("2026-03-09T11:05:00.000Z") : undefined, | |
| 2557 | ).map((entry) => entry.id), | |
| 2558 | ).toEqual(["asks-approval", "finished"]); | |
| 2559 | }); | |
| 2560 | }); | |
| 2561 | ||
| 2562 | describe("dragging", () => { | |
| 2563 | const marker = (name: SidebarListMarker): SidebarListItem => ({ kind: "marker", marker: name }); | |
| 2564 | const row = (key: string, section: SidebarSection): SidebarListItem => ({ | |
| 2565 | kind: "thread", | |
| 2566 | key, | |
| 2567 | section, | |
| 2568 | }); | |
| 2569 | // Pinned p1 | Active a1 a2 | Working w1 | Settled s1 | |
| 2570 | const items: readonly SidebarListItem[] = [ | |
| 2571 | marker("pinned-header"), | |
| 2572 | row("p1", "pinned"), | |
| 2573 | marker("pinned-divider"), | |
| 2574 | row("a1", "active"), | |
| 2575 | row("a2", "active"), | |
| 2576 | marker("working-header"), | |
| 2577 | row("w1", "working"), | |
| 2578 | marker("settled-header"), | |
| 2579 | row("s1", "settled"), | |
| 2580 | ]; | |
| 2581 | ||
| 2582 | it("never drops into the Working shelf, and keeps it out of the inbox order", () => { | |
| 2583 | expect(resolveSidebarDropTarget(items, "a1", "w1")).toBeNull(); | |
| 2584 | expect(resolveSidebarDropTarget(items, "p1", "a2")).toEqual({ | |
| 2585 | section: "active", | |
| 2586 | pinnedOrder: [], | |
| 2587 | activeOrder: ["a1", "a2", "p1"], | |
| 2588 | }); | |
| 2589 | expect(resolveSidebarDropVerb("active", "working")).toBeNull(); | |
| 2590 | }); | |
| 2591 | ||
| 2592 | it("only changes lifecycle when the inbox is time-ordered", () => { | |
| 2593 | const base = { | |
| 2594 | pinnedOrder: ["p1"], | |
| 2595 | pinnedKeysById: new Map([["p1", "m"]]), | |
| 2596 | activeOrder: ["a1", "a2"], | |
| 2597 | activeKeysById: new Map([ | |
| 2598 | ["a1", "f"], | |
| 2599 | ["a2", "t"], | |
| 2600 | ]), | |
| 2601 | activeTimeOrdered: true, | |
| 2602 | }; | |
| 2603 | expect( | |
| 2604 | planSidebarThreadDrop({ | |
| 2605 | ...base, | |
| 2606 | activeKey: "a1", | |
| 2607 | activeSection: "active", | |
| 2608 | target: { section: "active", pinnedOrder: ["p1"], activeOrder: ["a2", "a1"] }, | |
| 2609 | }), | |
| 2610 | ).toEqual({ kind: "none" }); | |
| 2611 | expect( | |
| 2612 | planSidebarThreadDrop({ | |
| 2613 | ...base, | |
| 2614 | activeKey: "p1", | |
| 2615 | activeSection: "pinned", | |
| 2616 | target: { section: "active", pinnedOrder: [], activeOrder: ["a1", "p1", "a2"] }, | |
| 2617 | }), | |
| 2618 | ).toEqual({ | |
| 2619 | kind: "move-active", | |
| 2620 | order: null, | |
| 2621 | assignments: [], | |
| 2622 | unpin: true, | |
| 2623 | unsettle: false, | |
| 2624 | unsnooze: false, | |
| 2625 | }); | |
| 2626 | }); | |
| 2627 | }); | |
| 2628 | }); |
apps/web/src/components/Sidebar.logic.ts+74-9| ... | ... | @@ -125,9 +125,10 @@ export const animateSidebarLayoutChanges: AnimateLayoutChanges = (args) => |
| 125 | 125 | // the lifecycle action; Sidebar.drag previews the resulting layout. Pinned |
| 126 | 126 | // and active threads keep the dragged position; settled threads use time |
| 127 | 127 | // 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. | |
| 129 | 130 | |
| 130 | export type SidebarSection = "pinned" | "active" | "snoozed" | "settled"; | |
| 131 | export type SidebarSection = "pinned" | "active" | "working" | "snoozed" | "settled"; | |
| 131 | 132 | |
| 132 | 133 | /** Sortable ids: thread rows use their scoped key; structural items use a |
| 133 | 134 | colon-free prefix: scoped thread keys always contain a colon. */ |
| ... | ... | @@ -141,6 +142,7 @@ export type SidebarListMarker = |
| 141 | 142 | | "settled-placeholder" |
| 142 | 143 | /** The boundary between pinned and active rows. */ |
| 143 | 144 | | "pinned-divider" |
| 145 | | "working-header" | |
| 144 | 146 | | "snoozed-header" |
| 145 | 147 | | "settled-header"; |
| 146 | 148 | |
| ... | ... | @@ -158,7 +160,7 @@ export function sidebarListItemId(item: SidebarListItem): string { |
| 158 | 160 | |
| 159 | 161 | /** The section a slot belongs to, read off the markers around it: from |
| 160 | 162 | 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 | 164 | then settled. */ |
| 163 | 165 | function sectionAtSidebarSlot(items: readonly SidebarListItem[], index: number): SidebarSection { |
| 164 | 166 | let section: SidebarSection = "pinned"; |
| ... | ... | @@ -166,6 +168,7 @@ function sectionAtSidebarSlot(items: readonly SidebarListItem[], index: number): |
| 166 | 168 | const item = items[i]!; |
| 167 | 169 | if (item.kind !== "marker") continue; |
| 168 | 170 | if (item.marker === "pinned-divider") section = "active"; |
| 171 | else if (item.marker === "working-header") section = "working"; | |
| 169 | 172 | else if (item.marker === "snoozed-header") section = "snoozed"; |
| 170 | 173 | else if (item.marker === "settled-header") section = "settled"; |
| 171 | 174 | } |
| ... | ... | @@ -173,7 +176,7 @@ function sectionAtSidebarSlot(items: readonly SidebarListItem[], index: number): |
| 173 | 176 | } |
| 174 | 177 | |
| 175 | 178 | /** 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. */ | |
| 177 | 180 | export type SidebarDropTarget = { |
| 178 | 181 | readonly section: "pinned" | "active" | "settled"; |
| 179 | 182 | readonly pinnedOrder: readonly string[]; |
| ... | ... | @@ -191,14 +194,19 @@ export function resolveSidebarDropTarget( |
| 191 | 194 | const moved = items.filter((_, index) => index !== activeIndex); |
| 192 | 195 | moved.splice(overIndex, 0, items[activeIndex]!); |
| 193 | 196 | const section = sectionAtSidebarSlot(moved, overIndex); |
| 194 | if (section === "snoozed") return null; | |
| 197 | if (section === "working" || section === "snoozed") return null; | |
| 195 | 198 | const pinnedOrder: string[] = []; |
| 196 | 199 | const activeOrder: string[] = []; |
| 197 | 200 | let currentSection: SidebarSection = "pinned"; |
| 198 | 201 | for (const item of moved) { |
| 199 | 202 | if (item.kind === "marker") { |
| 200 | 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 | 210 | } else if (currentSection === "pinned") pinnedOrder.push(item.key); |
| 203 | 211 | else activeOrder.push(item.key); |
| 204 | 212 | } |
| ... | ... | @@ -224,7 +232,8 @@ export type SidebarThreadDropPlan = |
| 224 | 232 | } |
| 225 | 233 | | { |
| 226 | 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 | 237 | readonly assignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>; |
| 229 | 238 | readonly unpin: boolean; |
| 230 | 239 | readonly unsettle: boolean; |
| ... | ... | @@ -234,14 +243,14 @@ export type SidebarThreadDropPlan = |
| 234 | 243 | |
| 235 | 244 | /** What dropping in `to` does to a thread lifted from `from`, for the badge |
| 236 | 245 | 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. */ | |
| 238 | 247 | export type SidebarDropVerb = "pin" | "unpin" | "settle" | "unsettle" | "wake"; |
| 239 | 248 | |
| 240 | 249 | export function resolveSidebarDropVerb( |
| 241 | 250 | from: SidebarSection, |
| 242 | 251 | to: SidebarSection | null, |
| 243 | 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 | 254 | if (to === "pinned") return "pin"; |
| 246 | 255 | if (to === "settled") return "settle"; |
| 247 | 256 | if (from === "pinned") return "unpin"; |
| ... | ... | @@ -264,6 +273,8 @@ export function planSidebarThreadDrop(input: { |
| 264 | 273 | readonly activeOrder: readonly string[]; |
| 265 | 274 | readonly activeKeysById: ReadonlyMap<string, string | null | undefined>; |
| 266 | 275 | readonly activeReorderableKeys?: ReadonlySet<string>; |
| 276 | /** Working beta: the inbox sorts by time, so drops only change lifecycle. */ | |
| 277 | readonly activeTimeOrdered?: boolean; | |
| 267 | 278 | }): SidebarThreadDropPlan { |
| 268 | 279 | const { |
| 269 | 280 | activeKey, |
| ... | ... | @@ -283,6 +294,20 @@ export function planSidebarThreadDrop(input: { |
| 283 | 294 | } |
| 284 | 295 | switch (target.section) { |
| 285 | 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 | 311 | const order = target.activeOrder; |
| 287 | 312 | if ( |
| 288 | 313 | activeSection === "active" && |
| ... | ... | @@ -842,6 +867,16 @@ export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): Si |
| 842 | 867 | return "ready"; |
| 843 | 868 | } |
| 844 | 869 | |
| 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. */ | |
| 873 | export 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 | 880 | /** First VALID timestamp wins: `a ?? b` falls through on null, but a present- |
| 846 | 881 | yet-malformed string must also fall through to the next candidate rather |
| 847 | 882 | than sink the row to the epoch. */ |
| ... | ... | @@ -950,6 +985,36 @@ export function reduceSidebarProjectScopeMenuState( |
| 950 | 985 | } |
| 951 | 986 | } |
| 952 | 987 | |
| 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. */ | |
| 992 | export 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 | 1018 | /** The timestamp a working thread's elapsed label counts from: the running |
| 954 | 1019 | turn's start (request time until adoption), falling back to the session's |
| 955 | 1020 | last transition when the turn projection lags behind. Malformed |
apps/web/src/components/Sidebar.tsx+173-15| ... | ... | @@ -166,6 +166,7 @@ import { |
| 166 | 166 | firstValidTimestampMs, |
| 167 | 167 | hasUnseenCompletion, |
| 168 | 168 | isSidebarNestedLinkClick, |
| 169 | isSidebarThreadWorking, | |
| 169 | 170 | isTrailingDoubleClick, |
| 170 | 171 | orderItemsByPreferredIds, |
| 171 | 172 | planSidebarThreadDrop, |
| ... | ... | @@ -183,6 +184,7 @@ import { |
| 183 | 184 | resolveWorkingStartedAt, |
| 184 | 185 | sidebarListItemId, |
| 185 | 186 | sidebarMarkerId, |
| 187 | sortInboxThreadsByReturn, | |
| 186 | 188 | sortLogicalProjectsForSidebar, |
| 187 | 189 | sortPinnedThreadsForSidebar, |
| 188 | 190 | sortThreadsForSidebar, |
| ... | ... | @@ -258,6 +260,39 @@ const SETTLED_TAIL_PAGE_COUNT = 25; |
| 258 | 260 | // Fresh keys deliberately reset both shelves to collapsed for existing users. |
| 259 | 261 | const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar:settled-expanded"; |
| 260 | 262 | const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar:snoozed-expanded"; |
| 263 | const 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. | |
| 267 | let lastWorkingThreadKeys: ReadonlySet<string> | null = null; | |
| 268 | const 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. */ | |
| 273 | function 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 | } | |
| 261 | 296 | |
| 262 | 297 | function compactSidebarTimeLabel(label: string): string { |
| 263 | 298 | if (label === "just now") return "now"; |
| ... | ... | @@ -639,7 +674,7 @@ function SidebarDragBoundary(props: { |
| 639 | 674 | |
| 640 | 675 | // Shelf headers stay visible and keep their measured height while dragging. |
| 641 | 676 | function SidebarSectionHeader(props: { |
| 642 | marker: "snoozed-header" | "settled-header"; | |
| 677 | marker: "working-header" | "snoozed-header" | "settled-header"; | |
| 643 | 678 | label: string; |
| 644 | 679 | className?: string; |
| 645 | 680 | // While dragging, the settled header reads at full strength and takes the |
| ... | ... | @@ -648,7 +683,13 @@ function SidebarSectionHeader(props: { |
| 648 | 683 | isDropTarget?: boolean; |
| 649 | 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 | 693 | const className = cn( |
| 653 | 694 | "flex h-full w-full items-center gap-2 px-2 text-left text-xs font-medium", |
| 654 | 695 | snoozed ? "text-info-foreground" : "text-sidebar-muted-foreground/60", |
| ... | ... | @@ -686,7 +727,7 @@ function SidebarSectionHeader(props: { |
| 686 | 727 | type="button" |
| 687 | 728 | onClick={props.toggle.onToggle} |
| 688 | 729 | aria-expanded={props.toggle.expanded} |
| 689 | data-testid={`sidebar-${snoozed ? "snoozed" : "settled"}-shelf-toggle`} | |
| 730 | data-testid={`sidebar-${shelf}-shelf-toggle`} | |
| 690 | 731 | className={cn(className, "cursor-pointer")} |
| 691 | 732 | > |
| 692 | 733 | {content} |
| ... | ... | @@ -2184,6 +2225,7 @@ export default function Sidebar() { |
| 2184 | 2225 | const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); |
| 2185 | 2226 | const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); |
| 2186 | 2227 | const timestampFormat = useClientSettings((s) => s.timestampFormat); |
| 2228 | const workingShelfEnabled = useClientSettings((s) => s.sidebarWorkingShelfEnabled); | |
| 2187 | 2229 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); |
| 2188 | 2230 | const { |
| 2189 | 2231 | settleThread, |
| ... | ... | @@ -2561,6 +2603,7 @@ export default function Sidebar() { |
| 2561 | 2603 | draggableThreadKeys, |
| 2562 | 2604 | activeReorderableThreadKeys, |
| 2563 | 2605 | activeThreads, |
| 2606 | workingThreads, | |
| 2564 | 2607 | snoozedThreads, |
| 2565 | 2608 | settledThreads, |
| 2566 | 2609 | snoozeNow, |
| ... | ... | @@ -2577,8 +2620,14 @@ export default function Sidebar() { |
| 2577 | 2620 | (scopedProjectKeys === null || |
| 2578 | 2621 | scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), |
| 2579 | 2622 | ); |
| 2623 | observeInboxReturns(workingShelfEnabled ? threads : null); | |
| 2580 | 2624 | const pinned: EnvironmentThreadShell[] = []; |
| 2581 | 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 | 2631 | const snoozed: EnvironmentThreadShell[] = []; |
| 2583 | 2632 | const settled: EnvironmentThreadShell[] = []; |
| 2584 | 2633 | const draggable = new Set<string>(); |
| ... | ... | @@ -2609,7 +2658,7 @@ export default function Sidebar() { |
| 2609 | 2658 | ? pinned |
| 2610 | 2659 | : optimisticDrop.section === "settled" |
| 2611 | 2660 | ? settled |
| 2612 | : active | |
| 2661 | : inbox(projected) | |
| 2613 | 2662 | ).push( |
| 2614 | 2663 | optimisticDrop.clearsSnooze |
| 2615 | 2664 | ? projected |
| ... | ... | @@ -2623,7 +2672,7 @@ export default function Sidebar() { |
| 2623 | 2672 | } else if (thread.pinnedAt != null) { |
| 2624 | 2673 | pinned.push(thread); |
| 2625 | 2674 | } else { |
| 2626 | active.push(thread); | |
| 2675 | inbox(thread).push(thread); | |
| 2627 | 2676 | } |
| 2628 | 2677 | } |
| 2629 | 2678 | // One shared rule on every platform (see sortPinnedThreadsByOrderKey): |
| ... | ... | @@ -2632,7 +2681,13 @@ export default function Sidebar() { |
| 2632 | 2681 | // sort, or mixed-version fleets would render different pinned orders on |
| 2633 | 2682 | // web and mobile from the same data. |
| 2634 | 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 | 2691 | return { |
| 2637 | 2692 | pinnedThreads: |
| 2638 | 2693 | optimisticDrop?.section !== "pinned" || optimisticDrop.order === null |
| ... | ... | @@ -2652,6 +2707,8 @@ export default function Sidebar() { |
| 2652 | 2707 | preferredIds: optimisticDrop.order, |
| 2653 | 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 | 2712 | // Soonest wake first: "what comes back next" is the shelf's question. |
| 2656 | 2713 | snoozedThreads: snoozed.toSorted( |
| 2657 | 2714 | (left, right) => |
| ... | ... | @@ -2661,15 +2718,29 @@ export default function Sidebar() { |
| 2661 | 2718 | settledThreads: sortSettledThreads(settled), |
| 2662 | 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 | ]); | |
| 2665 | 2730 | |
| 2666 | 2731 | const threadSearchInputRef = useRef<HTMLInputElement>(null); |
| 2667 | 2732 | const [threadSearchQuery, setThreadSearchQuery] = useState(""); |
| 2668 | 2733 | const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0); |
| 2669 | 2734 | const isSearchingThreads = threadSearchQuery.trim().length > 0; |
| 2670 | 2735 | const searchableThreads = useMemo( |
| 2671 | () => [...pinnedThreads, ...activeThreads, ...snoozedThreads, ...settledThreads], | |
| 2672 | [activeThreads, pinnedThreads, settledThreads, snoozedThreads], | |
| 2736 | () => [ | |
| 2737 | ...pinnedThreads, | |
| 2738 | ...activeThreads, | |
| 2739 | ...workingThreads, | |
| 2740 | ...snoozedThreads, | |
| 2741 | ...settledThreads, | |
| 2742 | ], | |
| 2743 | [activeThreads, pinnedThreads, settledThreads, snoozedThreads, workingThreads], | |
| 2673 | 2744 | ); |
| 2674 | 2745 | const searchEnvironmentIds = useMemo( |
| 2675 | 2746 | () => |
| ... | ... | @@ -2805,9 +2876,43 @@ export default function Sidebar() { |
| 2805 | 2876 | return routeThread === undefined ? EMPTY_THREADS : [routeThread]; |
| 2806 | 2877 | }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]); |
| 2807 | 2878 | |
| 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 | 2901 | const orderedThreads = useMemo( |
| 2809 | () => [...pinnedThreads, ...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads], | |
| 2810 | [pinnedThreads, activeThreads, visibleSnoozedThreads, renderedSettledThreads], | |
| 2902 | () => [ | |
| 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 | 2917 | const orderedThreadKeys = useMemo( |
| 2813 | 2918 | () => |
| ... | ... | @@ -3230,10 +3335,11 @@ export default function Sidebar() { |
| 3230 | 3335 | }; |
| 3231 | 3336 | add(pinnedThreads, "pinned"); |
| 3232 | 3337 | add(activeThreads, "active"); |
| 3338 | add(workingThreads, "working"); | |
| 3233 | 3339 | add(snoozedThreads, "snoozed"); |
| 3234 | 3340 | add(settledThreads, "settled"); |
| 3235 | 3341 | return map; |
| 3236 | }, [activeThreads, pinnedThreads, settledThreads, snoozedThreads]); | |
| 3342 | }, [activeThreads, pinnedThreads, settledThreads, snoozedThreads, workingThreads]); | |
| 3237 | 3343 | const pinnedKeys = useMemo( |
| 3238 | 3344 | () => |
| 3239 | 3345 | pinnedThreads.map((thread) => |
| ... | ... | @@ -3400,6 +3506,7 @@ export default function Sidebar() { |
| 3400 | 3506 | if ( |
| 3401 | 3507 | pinnedThreads.length + |
| 3402 | 3508 | activeThreads.length + |
| 3509 | workingThreads.length + | |
| 3403 | 3510 | snoozedThreads.length + |
| 3404 | 3511 | settledThreads.length === |
| 3405 | 3512 | 0 |
| ... | ... | @@ -3413,6 +3520,10 @@ export default function Sidebar() { |
| 3413 | 3520 | const activeRows = rowsOf(activeThreads, "active"); |
| 3414 | 3521 | items.push({ kind: "marker", marker: "active-placeholder" }); |
| 3415 | 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 | 3527 | if (snoozedThreads.length > 0) { |
| 3417 | 3528 | items.push({ kind: "marker", marker: "snoozed-header" }); |
| 3418 | 3529 | items.push(...rowsOf(visibleSnoozedThreads, "snoozed")); |
| ... | ... | @@ -3429,6 +3540,8 @@ export default function Sidebar() { |
| 3429 | 3540 | settledThreads.length, |
| 3430 | 3541 | snoozedThreads.length, |
| 3431 | 3542 | visibleSnoozedThreads, |
| 3543 | visibleWorkingThreads, | |
| 3544 | workingThreads.length, | |
| 3432 | 3545 | ]); |
| 3433 | 3546 | useEffect(() => { |
| 3434 | 3547 | if ( |
| ... | ... | @@ -3489,18 +3602,35 @@ export default function Sidebar() { |
| 3489 | 3602 | applySidebarThreadDrop(thread, "settled", dragState.occurredAt), |
| 3490 | 3603 | ]).map(key); |
| 3491 | 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 | 3620 | const sidebarSortingStrategy = useMemo( |
| 3493 | 3621 | () => |
| 3494 | 3622 | createSidebarSortingStrategy({ |
| 3495 | 3623 | items: sidebarListItems, |
| 3496 | 3624 | boundaryLabelHeight: SIDEBAR_DRAG_LABEL_HEIGHT, |
| 3497 | 3625 | settledOrder: draggedSettledOrder, |
| 3626 | ...(draggedActiveOrder === undefined ? {} : { activeOrder: draggedActiveOrder }), | |
| 3498 | 3627 | settledExpanded: settledShelfExpanded, |
| 3499 | 3628 | settledVisibleCount, |
| 3500 | 3629 | routeThreadKey, |
| 3501 | 3630 | snoozedThreadCount: snoozedThreads.length, |
| 3502 | 3631 | }), |
| 3503 | 3632 | [ |
| 3633 | draggedActiveOrder, | |
| 3504 | 3634 | draggedSettledOrder, |
| 3505 | 3635 | routeThreadKey, |
| 3506 | 3636 | settledShelfExpanded, |
| ... | ... | @@ -3556,6 +3686,7 @@ export default function Sidebar() { |
| 3556 | 3686 | activeOrder: activeKeys, |
| 3557 | 3687 | activeKeysById, |
| 3558 | 3688 | activeReorderableKeys: activeReorderableThreadKeys, |
| 3689 | activeTimeOrdered: workingShelfEnabled, | |
| 3559 | 3690 | }).kind !== "none" |
| 3560 | 3691 | ); |
| 3561 | 3692 | }, |
| ... | ... | @@ -3577,6 +3708,7 @@ export default function Sidebar() { |
| 3577 | 3708 | pinnedKeys, |
| 3578 | 3709 | sidebarListItems, |
| 3579 | 3710 | threadByKey, |
| 3711 | workingShelfEnabled, | |
| 3580 | 3712 | ]); |
| 3581 | 3713 | const handleThreadDragEnd = useCallback( |
| 3582 | 3714 | (event: DragEndEvent) => { |
| ... | ... | @@ -3604,6 +3736,7 @@ export default function Sidebar() { |
| 3604 | 3736 | activeOrder: activeKeys, |
| 3605 | 3737 | activeKeysById, |
| 3606 | 3738 | activeReorderableKeys: activeReorderableThreadKeys, |
| 3739 | activeTimeOrdered: workingShelfEnabled, | |
| 3607 | 3740 | }); |
| 3608 | 3741 | if (plan.kind === "none") return; |
| 3609 | 3742 | if (plan.kind === "settle" && settlingThreadKeysRef.current.has(activeKey)) return; |
| ... | ... | @@ -3737,6 +3870,7 @@ export default function Sidebar() { |
| 3737 | 3870 | unpinThread, |
| 3738 | 3871 | unsettleThread, |
| 3739 | 3872 | unsnoozeThread, |
| 3873 | workingShelfEnabled, | |
| 3740 | 3874 | ], |
| 3741 | 3875 | ); |
| 3742 | 3876 | // One snooze per thread at a time — same double-dispatch guard as settle. |
| ... | ... | @@ -4717,7 +4851,9 @@ export default function Sidebar() { |
| 4717 | 4851 | // row: every other thread is a full card. Density comes |
| 4718 | 4852 | // from users (or the auto rules) actually parking work, |
| 4719 | 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 | 4857 | const rowVariant = isCard ? "card" : "slim"; |
| 4722 | 4858 | return ( |
| 4723 | 4859 | <SidebarThreadRow |
| ... | ... | @@ -4826,6 +4962,7 @@ export default function Sidebar() { |
| 4826 | 4962 | id={threadKey} |
| 4827 | 4963 | disabled={ |
| 4828 | 4964 | renamingThreadKey === threadKey || |
| 4965 | section === "working" || | |
| 4829 | 4966 | !draggableThreadKeys.has(threadKey) || |
| 4830 | 4967 | optimisticDrop !== null |
| 4831 | 4968 | } |
| ... | ... | @@ -4891,12 +5028,30 @@ export default function Sidebar() { |
| 4891 | 5028 | />, |
| 4892 | 5029 | ); |
| 4893 | 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 | 5049 | case "snoozed-header": |
| 4895 | 5050 | items.push( |
| 4896 | 5051 | <SidebarSectionHeader |
| 4897 | 5052 | key="snoozed-shelf-header" |
| 4898 | 5053 | marker="snoozed-header" |
| 4899 | className="mt-auto" | |
| 5054 | className={cn(workingThreads.length === 0 && "mt-auto")} | |
| 4900 | 5055 | label={ |
| 4901 | 5056 | snoozedShelfExpanded |
| 4902 | 5057 | ? "Snoozed" |
| ... | ... | @@ -4914,7 +5069,9 @@ export default function Sidebar() { |
| 4914 | 5069 | <SidebarSectionHeader |
| 4915 | 5070 | key="settled-shelf-header" |
| 4916 | 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 | 5075 | label={ |
| 4919 | 5076 | settledShelfExpanded |
| 4920 | 5077 | ? "Settled" |
| ... | ... | @@ -4972,6 +5129,7 @@ export default function Sidebar() { |
| 4972 | 5129 | visibleDraftSessionCount === 0 && |
| 4973 | 5130 | pinnedThreads.length + |
| 4974 | 5131 | activeThreads.length + |
| 5132 | workingThreads.length + | |
| 4975 | 5133 | snoozedThreads.length + |
| 4976 | 5134 | settledThreads.length === |
| 4977 | 5135 | 0 ? ( |
apps/web/src/components/settings/SettingsPanels.tsx+33| ... | ... | @@ -563,6 +563,10 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 563 | 563 | DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode |
| 564 | 564 | ? ["Project Grouping"] |
| 565 | 565 | : []), |
| 566 | ...(settings.sidebarWorkingShelfEnabled !== | |
| 567 | DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled | |
| 568 | ? ["Working section"] | |
| 569 | : []), | |
| 566 | 570 | ...(settings.sidebarAutoSettleAfterDays !== |
| 567 | 571 | DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays |
| 568 | 572 | ? ["Auto-settle inactive threads"] |
| ... | ... | @@ -684,6 +688,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 684 | 688 | settings.sidebarAutoSettleAfterDays, |
| 685 | 689 | settings.sidebarAutoSettleOnMerge, |
| 686 | 690 | settings.sidebarProjectGroupingMode, |
| 691 | settings.sidebarWorkingShelfEnabled, | |
| 687 | 692 | settings.sidebarThreadPreviewCount, |
| 688 | 693 | settings.showSkillsInSlashMenu, |
| 689 | 694 | settings.timestampFormat, |
| ... | ... | @@ -781,6 +786,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 781 | 786 | panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs, |
| 782 | 787 | sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, |
| 783 | 788 | sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, |
| 789 | sidebarWorkingShelfEnabled: DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled, | |
| 784 | 790 | sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, |
| 785 | 791 | sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, |
| 786 | 792 | responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode, |
| ... | ... | @@ -2269,6 +2275,33 @@ export function GeneralSettingsPanel() { |
| 2269 | 2275 | } |
| 2270 | 2276 | /> |
| 2271 | 2277 | |
| 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 | 2305 | {supportsAutoSettlement ? ( |
| 2273 | 2306 | <> |
| 2274 | 2307 | <SettingsRow |
apps/web/src/components/settings/settingsSearch.ts+6| ... | ... | @@ -273,6 +273,12 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 273 | 273 | to: "/settings/general", |
| 274 | 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 | 283 | id: "auto-settle-inactive-threads", |
| 278 | 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 | 100 | environment. Pinned and active reordering require server support. Threads from older servers keep |
| 101 | 101 | their default order until the server is updated. |
| 102 | 102 | |
| 103 | ### Fold working threads (beta) | |
| 104 | ||
| 105 | On web and desktop, turn on **Settings → General → Working section (beta)** to move threads that | |
| 106 | are working or monitoring into a collapsed **Working** section at the bottom of the sidebar. A | |
| 107 | thread returns to the top of the active list when it finishes, fails, or needs an approval or | |
| 108 | answer. Pinned threads stay in the pinned section. | |
| 109 | ||
| 110 | While this is on, the active list is ordered by when each thread last came back to you, so you | |
| 111 | cannot drag to reorder it. Your saved order returns when you turn it off. | |
| 112 | ||
| 103 | 113 | ## Settle finished work |
| 104 | 114 | |
| 105 | 115 | Choose **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 | 460 | // old keys, so everyone, including prior beta opt-outs, resets to the new |
| 461 | 461 | // default sidebar. |
| 462 | 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 | 469 | sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( |
| 464 | 470 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), |
| 465 | 471 | ), |
| ... | ... | @@ -1663,6 +1669,7 @@ export const ClientSettingsPatch = Schema.Struct({ |
| 1663 | 1669 | proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), |
| 1664 | 1670 | showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), |
| 1665 | 1671 | legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), |
| 1672 | sidebarWorkingShelfEnabled: Schema.optionalKey(Schema.Boolean), | |
| 1666 | 1673 | sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), |
| 1667 | 1674 | sidebarProjectGroupingOverrides: Schema.optionalKey( |
| 1668 | 1675 | Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), |