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

feat(web): beta Working section hides busy threads until they need you (#13926)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

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

apps/desktop/src/settings/DesktopClientSettings.test.ts+1
......@@ -65,6 +65,7 @@ const clientSettings: ClientSettings = {
6565 sidebarThreadSortOrder: "created_at",
6666 sidebarThreadPreviewCount: 6,
6767 legacySidebarEnabled: false,
68 sidebarWorkingShelfEnabled: false,
6869 loadBalancingEnabled: false,
6970 loadBalancingWeights: { "environment-1": 75, "environment-2": 0 },
7071 pullRequestMergeMethodOverrides: {},
apps/web/src/components/Sidebar.drag.test.ts+22
......@@ -621,6 +621,28 @@ describe("sidebar drag projection", () => {
621621 expect(result.get(sidebarMarkerId("snoozed-header"))).toEqual({ ...stationary, y: -46 });
622622 });
623623
624 it("previews a time-ordered inbox drop at its time slot, above the Working shelf", () => {
625 const items = [
626 pinnedHeader,
627 thread("p", "pinned"),
628 divider,
629 thread("a1", "active"),
630 thread("a2", "active"),
631 marker("working-header"),
632 thread("w", "working"),
633 settledHeader,
634 marker("settled-placeholder"),
635 ];
636 const input = { items, settledOrder: [], settledExpanded: false };
637 // By pointer, the unpinned row lands between a1 and a2.
638 expect(preview(input, "p", "a1").get("a2")?.y).toBe(0);
639 // By time, it lands below a2, and the shelf does not move.
640 const byTime = preview({ ...input, activeOrder: ["a1", "a2", "p"] }, "p", "a1");
641 expect(byTime.get("a2")?.y).toBe(-83);
642 expect(byTime.get(sidebarMarkerId("working-header"))).toEqual(stationary);
643 expect(byTime.get("w")).toEqual(stationary);
644 });
645
624646 it("derives missing card geometry from the measured root scale", () => {
625647 const items = [
626648 pinnedHeader,
apps/web/src/components/Sidebar.drag.ts+24-20
......@@ -13,6 +13,11 @@ const stationary = { x: 0, y: 0, scaleX: 1, scaleY: 1 };
1313const hidden = { ...stationary, scaleY: 0 };
1414type ThreadItem = Extract<SidebarListItem, { kind: "thread" }>;
1515type Layout = Parameters<SortingStrategy>[0];
16const isShelfHeader = (item: SidebarListItem | undefined) =>
17 item?.kind === "marker" &&
18 (item.marker === "working-header" ||
19 item.marker === "snoozed-header" ||
20 item.marker === "settled-header");
1621
1722/** Keep the lifted card below the Pins label, including when Pins is empty.
1823 * The container rect follows scrolling; the offset is measured once at pickup. */
......@@ -57,13 +62,11 @@ export function createSidebarCollisionDetection(
5762 if (pointer.x >= boundary.left && pointer.x <= boundary.right) {
5863 if (pointer.y < previousY && pointer.y <= boundary.bottom) boundarySection = "pinned";
5964 else if (pointer.y > previousY && pointer.y >= boundary.top) boundarySection = "active";
60 const nextHeader =
61 args.droppableContainers.find(
62 (container) => container.id === sidebarMarkerId("snoozed-header"),
63 ) ??
64 args.droppableContainers.find(
65 (container) => container.id === sidebarMarkerId("settled-header"),
66 );
65 const nextHeader = (["working-header", "snoozed-header", "settled-header"] as const)
66 .map((marker) =>
67 args.droppableContainers.find((container) => container.id === sidebarMarkerId(marker)),
68 )
69 .find((container) => container !== undefined);
6770 const activeBottom = nextHeader?.node.current?.getBoundingClientRect().top;
6871 if (boundarySection === "pinned" || (activeBottom != null && pointer.y < activeBottom)) {
6972 const target = collisions.find((collision) => {
......@@ -97,6 +100,8 @@ export function createSidebarCollisionDetection(
97100export function createSidebarSortingStrategy(input: {
98101 items: readonly SidebarListItem[];
99102 settledOrder: readonly string[];
103 /** Time-ordered inbox (Working beta): where the lifted row would land. */
104 activeOrder?: readonly string[];
100105 settledExpanded: boolean;
101106 settledVisibleCount?: number;
102107 routeThreadKey?: string | null;
......@@ -121,6 +126,7 @@ export function createSidebarSortingStrategy(input: {
121126 const groups: Record<SidebarSection, ThreadItem[]> = {
122127 pinned: [],
123128 active: [],
129 working: [],
124130 snoozed: [],
125131 settled: [],
126132 };
......@@ -129,13 +135,13 @@ export function createSidebarSortingStrategy(input: {
129135 let headerScale: number | undefined;
130136 for (const [index, item] of items.entries()) {
131137 if (item.kind === "marker") {
132 if (item.marker === "settled-header" || item.marker === "snoozed-header") {
138 if (isShelfHeader(item)) {
133139 const height = rects[index]?.height;
134140 if (height) headerScale ??= height / 32;
135141 }
136142 continue;
137143 }
138 if (item.section === "pinned" || item.section === "active")
144 if (item.section === "pinned" || item.section === "active" || item.section === "working")
139145 cardHeight ??= rects[index]?.height;
140146 else slimHeight ??= rects[index]?.height;
141147 if (item.key !== active.key) groups[item.section].push(item);
......@@ -152,7 +158,7 @@ export function createSidebarSortingStrategy(input: {
152158 ? target.pinnedOrder
153159 : target.section === "settled"
154160 ? input.settledOrder
155 : target.activeOrder;
161 : (input.activeOrder ?? target.activeOrder);
156162 const ranks = new Map(order.map((key, index) => [key, index]));
157163 const rank = ranks.get(active.key) ?? Number.POSITIVE_INFINITY;
158164 const index = group.findIndex(
......@@ -180,6 +186,10 @@ export function createSidebarSortingStrategy(input: {
180186 projected.push(...groups.pinned);
181187 marker("pinned-divider");
182188 section("active");
189 if (items.some((item) => item.kind === "marker" && item.marker === "working-header")) {
190 marker("working-header");
191 projected.push(...groups.working);
192 }
183193 if (
184194 groups.snoozed.length > 0 ||
185195 ((active.section !== "snoozed" || (input.snoozedThreadCount ?? 0) > 1) &&
......@@ -194,7 +204,8 @@ export function createSidebarSortingStrategy(input: {
194204 const index = indices.get(sidebarListItemId(item));
195205 const rect = index === undefined ? undefined : rects[index];
196206 const fallback =
197 item.kind === "thread" && (item.section === "pinned" || item.section === "active")
207 item.kind === "thread" &&
208 (item.section === "pinned" || item.section === "active" || item.section === "working")
198209 ? cardHeight
199210 : slimHeight;
200211 const moved = item.kind === "thread" && item.key === active.key;
......@@ -207,11 +218,7 @@ export function createSidebarSortingStrategy(input: {
207218 ? fallback
208219 : (rect?.height ?? fallback);
209220 });
210 const firstShelf = items.findIndex(
211 (item) =>
212 item.kind === "marker" &&
213 (item.marker === "snoozed-header" || item.marker === "settled-header"),
214 );
221 const firstShelf = items.findIndex(isShelfHeader);
215222 const shelfRect = rects[firstShelf];
216223 const beforeShelf = rects[firstShelf - 1];
217224 const lastRect = rects.at(-1);
......@@ -227,10 +234,7 @@ export function createSidebarSortingStrategy(input: {
227234 const result = items.map(() => hidden);
228235 let top = rects[0].top;
229236 for (const [projectedIndex, item] of projected.entries()) {
230 if (
231 item.kind === "marker" &&
232 (item.marker === "snoozed-header" || item.marker === "settled-header")
233 ) {
237 if (isShelfHeader(item)) {
234238 top += shelfSpace;
235239 shelfSpace = 0;
236240 }
apps/web/src/components/Sidebar.logic.test.ts+167
......@@ -20,6 +20,7 @@ import {
2020 hasUnseenCompletion,
2121 isContextMenuPointerDown,
2222 isSidebarNestedLinkClick,
23 isSidebarThreadWorking,
2324 isTrailingDoubleClick,
2425 orderItemsByPreferredIds,
2526 resolveProjectStatusIndicator,
......@@ -32,6 +33,7 @@ import {
3233 shouldClearThreadSelectionOnMouseDown,
3334 shouldRecedeSidebarThread,
3435 sortLogicalProjectsForSidebar,
36 sortInboxThreadsByReturn,
3537 resolveSidebarDropTarget,
3638 pinOrderKeyBetween,
3739 planSidebarThreadDrop,
......@@ -2459,3 +2461,168 @@ describe("navigation after parking a thread", () => {
24592461 },
24602462 );
24612463});
2464
2465describe("Working shelf (beta)", () => {
2466 const session = {
2467 threadId: ThreadId.make("thread-1"),
2468 status: "running" as const,
2469 providerName: "Codex",
2470 providerInstanceId: ProviderInstanceId.make("codex"),
2471 runtimeMode: DEFAULT_RUNTIME_MODE,
2472 activeTurnId: "turn-1" as never,
2473 lastError: null,
2474 updatedAt: "2026-03-09T10:00:00.000Z",
2475 };
2476 const idle = {
2477 hasActionableProposedPlan: false,
2478 hasPendingApprovals: false,
2479 hasPendingUserInput: false,
2480 interactionMode: "default" as const,
2481 latestTurn: makeLatestTurn(),
2482 session: { ...session, status: "ready" as const },
2483 };
2484
2485 it("folds away running and monitoring threads only", () => {
2486 expect(isSidebarThreadWorking({ ...idle, session })).toBe(true);
2487 expect(isSidebarThreadWorking({ ...idle, backgroundLiveness: "monitoring" })).toBe(true);
2488 expect(isSidebarThreadWorking(idle)).toBe(false);
2489 expect(isSidebarThreadWorking({ ...idle, session, hasPendingApprovals: true })).toBe(false);
2490 expect(isSidebarThreadWorking({ ...idle, session, hasPendingUserInput: true })).toBe(false);
2491 expect(
2492 isSidebarThreadWorking({
2493 ...idle,
2494 backgroundLiveness: "working",
2495 session: { ...session, status: "error" as const, lastError: "boom" },
2496 }),
2497 ).toBe(false);
2498 });
2499
2500 it("keeps a ready plan in the inbox while background work runs", () => {
2501 expect(
2502 isSidebarThreadWorking({
2503 ...idle,
2504 interactionMode: "plan",
2505 hasActionableProposedPlan: true,
2506 backgroundLiveness: "working",
2507 }),
2508 ).toBe(false);
2509 });
2510
2511 describe("sortInboxThreadsByReturn", () => {
2512 const thread = (
2513 id: string,
2514 input: { createdAt: string; completedAt?: string | null; unsettledAt?: string },
2515 ) => ({
2516 id: ThreadId.make(id),
2517 environmentId: localEnvironmentId,
2518 createdAt: input.createdAt,
2519 unsettledAt: input.unsettledAt ?? null,
2520 latestTurn:
2521 input.completedAt === undefined
2522 ? null
2523 : { ...makeLatestTurn({ completedAt: input.completedAt }), requestedAt: input.createdAt },
2524 });
2525
2526 it("puts the thread that finished last on top, whatever its age", () => {
2527 const sorted = sortInboxThreadsByReturn([
2528 thread("new", { createdAt: "2026-03-09T11:00:00.000Z" }),
2529 thread("old-finished-now", {
2530 createdAt: "2026-03-01T09:00:00.000Z",
2531 completedAt: "2026-03-09T12:00:00.000Z",
2532 }),
2533 thread("reopened", {
2534 createdAt: "2026-03-02T09:00:00.000Z",
2535 unsettledAt: "2026-03-09T11:30:00.000Z",
2536 }),
2537 ]);
2538 expect(sorted.map((entry) => entry.id)).toEqual(["old-finished-now", "reopened", "new"]);
2539 });
2540
2541 it("counts a return the server does not stamp, like an approval request", () => {
2542 const waiting = thread("asks-approval", {
2543 createdAt: "2026-03-09T09:00:00.000Z",
2544 completedAt: null,
2545 });
2546 const finished = thread("finished", {
2547 createdAt: "2026-03-09T09:30:00.000Z",
2548 completedAt: "2026-03-09T11:00:00.000Z",
2549 });
2550 expect(sortInboxThreadsByReturn([finished, waiting]).map((entry) => entry.id)).toEqual([
2551 "finished",
2552 "asks-approval",
2553 ]);
2554 expect(
2555 sortInboxThreadsByReturn([finished, waiting], (entry) =>
2556 entry === waiting ? Date.parse("2026-03-09T11:05:00.000Z") : undefined,
2557 ).map((entry) => entry.id),
2558 ).toEqual(["asks-approval", "finished"]);
2559 });
2560 });
2561
2562 describe("dragging", () => {
2563 const marker = (name: SidebarListMarker): SidebarListItem => ({ kind: "marker", marker: name });
2564 const row = (key: string, section: SidebarSection): SidebarListItem => ({
2565 kind: "thread",
2566 key,
2567 section,
2568 });
2569 // Pinned p1 | Active a1 a2 | Working w1 | Settled s1
2570 const items: readonly SidebarListItem[] = [
2571 marker("pinned-header"),
2572 row("p1", "pinned"),
2573 marker("pinned-divider"),
2574 row("a1", "active"),
2575 row("a2", "active"),
2576 marker("working-header"),
2577 row("w1", "working"),
2578 marker("settled-header"),
2579 row("s1", "settled"),
2580 ];
2581
2582 it("never drops into the Working shelf, and keeps it out of the inbox order", () => {
2583 expect(resolveSidebarDropTarget(items, "a1", "w1")).toBeNull();
2584 expect(resolveSidebarDropTarget(items, "p1", "a2")).toEqual({
2585 section: "active",
2586 pinnedOrder: [],
2587 activeOrder: ["a1", "a2", "p1"],
2588 });
2589 expect(resolveSidebarDropVerb("active", "working")).toBeNull();
2590 });
2591
2592 it("only changes lifecycle when the inbox is time-ordered", () => {
2593 const base = {
2594 pinnedOrder: ["p1"],
2595 pinnedKeysById: new Map([["p1", "m"]]),
2596 activeOrder: ["a1", "a2"],
2597 activeKeysById: new Map([
2598 ["a1", "f"],
2599 ["a2", "t"],
2600 ]),
2601 activeTimeOrdered: true,
2602 };
2603 expect(
2604 planSidebarThreadDrop({
2605 ...base,
2606 activeKey: "a1",
2607 activeSection: "active",
2608 target: { section: "active", pinnedOrder: ["p1"], activeOrder: ["a2", "a1"] },
2609 }),
2610 ).toEqual({ kind: "none" });
2611 expect(
2612 planSidebarThreadDrop({
2613 ...base,
2614 activeKey: "p1",
2615 activeSection: "pinned",
2616 target: { section: "active", pinnedOrder: [], activeOrder: ["a1", "p1", "a2"] },
2617 }),
2618 ).toEqual({
2619 kind: "move-active",
2620 order: null,
2621 assignments: [],
2622 unpin: true,
2623 unsettle: false,
2624 unsnooze: false,
2625 });
2626 });
2627 });
2628});
apps/web/src/components/Sidebar.logic.ts+74-9
......@@ -125,9 +125,10 @@ export const animateSidebarLayoutChanges: AnimateLayoutChanges = (args) =>
125125// the lifecycle action; Sidebar.drag previews the resulting layout. Pinned
126126// and active threads keep the dragged position; settled threads use time
127127// order. Snoozed rows can leave the shelf, but dropping into it is not
128// supported because snoozing requires a wake time.
128// supported because snoozing requires a wake time. The Working shelf (beta)
129// follows live status, so it is neither a drag source nor a destination.
129130
130export type SidebarSection = "pinned" | "active" | "snoozed" | "settled";
131export type SidebarSection = "pinned" | "active" | "working" | "snoozed" | "settled";
131132
132133/** Sortable ids: thread rows use their scoped key; structural items use a
133134 colon-free prefix: scoped thread keys always contain a colon. */
......@@ -141,6 +142,7 @@ export type SidebarListMarker =
141142 | "settled-placeholder"
142143 /** The boundary between pinned and active rows. */
143144 | "pinned-divider"
145 | "working-header"
144146 | "snoozed-header"
145147 | "settled-header";
146148
......@@ -158,7 +160,7 @@ export function sidebarListItemId(item: SidebarListItem): string {
158160
159161/** The section a slot belongs to, read off the markers around it: from
160162 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,
162164 then settled. */
163165function sectionAtSidebarSlot(items: readonly SidebarListItem[], index: number): SidebarSection {
164166 let section: SidebarSection = "pinned";
......@@ -166,6 +168,7 @@ function sectionAtSidebarSlot(items: readonly SidebarListItem[], index: number):
166168 const item = items[i]!;
167169 if (item.kind !== "marker") continue;
168170 if (item.marker === "pinned-divider") section = "active";
171 else if (item.marker === "working-header") section = "working";
169172 else if (item.marker === "snoozed-header") section = "snoozed";
170173 else if (item.marker === "settled-header") section = "settled";
171174 }
......@@ -173,7 +176,7 @@ function sectionAtSidebarSlot(items: readonly SidebarListItem[], index: number):
173176}
174177
175178/** 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. */
177180export type SidebarDropTarget = {
178181 readonly section: "pinned" | "active" | "settled";
179182 readonly pinnedOrder: readonly string[];
......@@ -191,14 +194,19 @@ export function resolveSidebarDropTarget(
191194 const moved = items.filter((_, index) => index !== activeIndex);
192195 moved.splice(overIndex, 0, items[activeIndex]!);
193196 const section = sectionAtSidebarSlot(moved, overIndex);
194 if (section === "snoozed") return null;
197 if (section === "working" || section === "snoozed") return null;
195198 const pinnedOrder: string[] = [];
196199 const activeOrder: string[] = [];
197200 let currentSection: SidebarSection = "pinned";
198201 for (const item of moved) {
199202 if (item.kind === "marker") {
200203 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;
202210 } else if (currentSection === "pinned") pinnedOrder.push(item.key);
203211 else activeOrder.push(item.key);
204212 }
......@@ -224,7 +232,8 @@ export type SidebarThreadDropPlan =
224232 }
225233 | {
226234 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;
228237 readonly assignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>;
229238 readonly unpin: boolean;
230239 readonly unsettle: boolean;
......@@ -234,14 +243,14 @@ export type SidebarThreadDropPlan =
234243
235244/** What dropping in `to` does to a thread lifted from `from`, for the badge
236245 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. */
238247export type SidebarDropVerb = "pin" | "unpin" | "settle" | "unsettle" | "wake";
239248
240249export function resolveSidebarDropVerb(
241250 from: SidebarSection,
242251 to: SidebarSection | null,
243252): SidebarDropVerb | null {
244 if (to === null || to === from || to === "snoozed") return null;
253 if (to === null || to === from || to === "working" || to === "snoozed") return null;
245254 if (to === "pinned") return "pin";
246255 if (to === "settled") return "settle";
247256 if (from === "pinned") return "unpin";
......@@ -264,6 +273,8 @@ export function planSidebarThreadDrop(input: {
264273 readonly activeOrder: readonly string[];
265274 readonly activeKeysById: ReadonlyMap<string, string | null | undefined>;
266275 readonly activeReorderableKeys?: ReadonlySet<string>;
276 /** Working beta: the inbox sorts by time, so drops only change lifecycle. */
277 readonly activeTimeOrdered?: boolean;
267278}): SidebarThreadDropPlan {
268279 const {
269280 activeKey,
......@@ -283,6 +294,20 @@ export function planSidebarThreadDrop(input: {
283294 }
284295 switch (target.section) {
285296 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 }
286311 const order = target.activeOrder;
287312 if (
288313 activeSection === "active" &&
......@@ -842,6 +867,16 @@ export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): Si
842867 return "ready";
843868}
844869
870/** Working beta: threads busy with work that does not need the user fold into
871 the Working shelf. Approvals, questions, plan prompts, and failures stay
872 in the inbox. */
873export function isSidebarThreadWorking(thread: ThreadStatusInput): boolean {
874 const status = resolveSidebarThreadStatus(thread);
875 if (status !== "working" && status !== "monitoring") return false;
876 // A plan prompt outranks lingering background work, as in the status pill.
877 return resolveThreadStatusPill({ thread })?.label !== "Plan Ready";
878}
879
845880/** First VALID timestamp wins: `a ?? b` falls through on null, but a present-
846881 yet-malformed string must also fall through to the next candidate rather
847882 than sink the row to the epoch. */
......@@ -950,6 +985,36 @@ export function reduceSidebarProjectScopeMenuState(
950985 }
951986}
952987
988/** Working beta: the inbox lists threads newest first by when each last came
989 back to the user, so a thread that leaves the Working shelf lands on top.
990 `observedReturnAt` adds returns the server does not stamp, such as an
991 approval request mid-turn or background work ending. */
992export function sortInboxThreadsByReturn<
993 T extends Pick<
994 SidebarThreadSummary,
995 "id" | "environmentId" | "createdAt" | "unsettledAt" | "latestTurn"
996 >,
997>(threads: readonly T[], observedReturnAt?: (thread: T) => number | undefined): T[] {
998 const timestamps = new Map(
999 threads.map((thread) => [
1000 thread,
1001 Math.max(
1002 toSortableTimestamp(thread.createdAt) ?? 0,
1003 toSortableTimestamp(thread.unsettledAt ?? undefined) ?? 0,
1004 toSortableTimestamp(thread.latestTurn?.requestedAt) ?? 0,
1005 toSortableTimestamp(thread.latestTurn?.completedAt ?? undefined) ?? 0,
1006 observedReturnAt?.(thread) ?? 0,
1007 ),
1008 ]),
1009 );
1010 return [...threads].sort(
1011 (left, right) =>
1012 timestamps.get(right)! - timestamps.get(left)! ||
1013 left.id.localeCompare(right.id) ||
1014 left.environmentId.localeCompare(right.environmentId),
1015 );
1016}
1017
9531018/** The timestamp a working thread's elapsed label counts from: the running
9541019 turn's start (request time until adoption), falling back to the session's
9551020 last transition when the turn projection lags behind. Malformed
apps/web/src/components/Sidebar.tsx+173-15
......@@ -166,6 +166,7 @@ import {
166166 firstValidTimestampMs,
167167 hasUnseenCompletion,
168168 isSidebarNestedLinkClick,
169 isSidebarThreadWorking,
169170 isTrailingDoubleClick,
170171 orderItemsByPreferredIds,
171172 planSidebarThreadDrop,
......@@ -183,6 +184,7 @@ import {
183184 resolveWorkingStartedAt,
184185 sidebarListItemId,
185186 sidebarMarkerId,
187 sortInboxThreadsByReturn,
186188 sortLogicalProjectsForSidebar,
187189 sortPinnedThreadsForSidebar,
188190 sortThreadsForSidebar,
......@@ -258,6 +260,39 @@ const SETTLED_TAIL_PAGE_COUNT = 25;
258260// Fresh keys deliberately reset both shelves to collapsed for existing users.
259261const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar:settled-expanded";
260262const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar:snoozed-expanded";
263const WORKING_SHELF_EXPANDED_KEY = "t3code:sidebar:working-expanded";
264
265// Working beta: when this client saw each thread leave the Working shelf.
266// Module scope keeps the inbox order across routes that unmount the sidebar.
267let lastWorkingThreadKeys: ReadonlySet<string> | null = null;
268const observedInboxReturns = new Map<string, number>();
269
270/** Stamps threads that stopped working since the last call. The first call
271 only takes a baseline, so mounting never reshuffles the inbox. Pass null
272 to reset when the beta is off. */
273function observeInboxReturns(threads: readonly EnvironmentThreadShell[] | null): void {
274 if (threads === null) {
275 lastWorkingThreadKeys = null;
276 observedInboxReturns.clear();
277 return;
278 }
279 const working = new Set<string>();
280 const present = new Set<string>();
281 for (const thread of threads) {
282 const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
283 present.add(key);
284 if (isSidebarThreadWorking(thread)) working.add(key);
285 }
286 // Drop deleted threads so the map stays bounded by the live thread list.
287 for (const key of observedInboxReturns.keys()) {
288 if (!present.has(key)) observedInboxReturns.delete(key);
289 }
290 const now = Date.now();
291 for (const key of lastWorkingThreadKeys ?? []) {
292 if (present.has(key) && !working.has(key)) observedInboxReturns.set(key, now);
293 }
294 lastWorkingThreadKeys = working;
295}
261296
262297function compactSidebarTimeLabel(label: string): string {
263298 if (label === "just now") return "now";
......@@ -639,7 +674,7 @@ function SidebarDragBoundary(props: {
639674
640675// Shelf headers stay visible and keep their measured height while dragging.
641676function SidebarSectionHeader(props: {
642 marker: "snoozed-header" | "settled-header";
677 marker: "working-header" | "snoozed-header" | "settled-header";
643678 label: string;
644679 className?: string;
645680 // While dragging, the settled header reads at full strength and takes the
......@@ -648,7 +683,13 @@ function SidebarSectionHeader(props: {
648683 isDropTarget?: boolean;
649684 toggle: { expanded: boolean; onToggle: () => void };
650685}) {
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";
652693 const className = cn(
653694 "flex h-full w-full items-center gap-2 px-2 text-left text-xs font-medium",
654695 snoozed ? "text-info-foreground" : "text-sidebar-muted-foreground/60",
......@@ -686,7 +727,7 @@ function SidebarSectionHeader(props: {
686727 type="button"
687728 onClick={props.toggle.onToggle}
688729 aria-expanded={props.toggle.expanded}
689 data-testid={`sidebar-${snoozed ? "snoozed" : "settled"}-shelf-toggle`}
730 data-testid={`sidebar-${shelf}-shelf-toggle`}
690731 className={cn(className, "cursor-pointer")}
691732 >
692733 {content}
......@@ -2184,6 +2225,7 @@ export default function Sidebar() {
21842225 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
21852226 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
21862227 const timestampFormat = useClientSettings((s) => s.timestampFormat);
2228 const workingShelfEnabled = useClientSettings((s) => s.sidebarWorkingShelfEnabled);
21872229 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
21882230 const {
21892231 settleThread,
......@@ -2561,6 +2603,7 @@ export default function Sidebar() {
25612603 draggableThreadKeys,
25622604 activeReorderableThreadKeys,
25632605 activeThreads,
2606 workingThreads,
25642607 snoozedThreads,
25652608 settledThreads,
25662609 snoozeNow,
......@@ -2577,8 +2620,14 @@ export default function Sidebar() {
25772620 (scopedProjectKeys === null ||
25782621 scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)),
25792622 );
2623 observeInboxReturns(workingShelfEnabled ? threads : null);
25802624 const pinned: EnvironmentThreadShell[] = [];
25812625 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;
25822631 const snoozed: EnvironmentThreadShell[] = [];
25832632 const settled: EnvironmentThreadShell[] = [];
25842633 const draggable = new Set<string>();
......@@ -2609,7 +2658,7 @@ export default function Sidebar() {
26092658 ? pinned
26102659 : optimisticDrop.section === "settled"
26112660 ? settled
2612 : active
2661 : inbox(projected)
26132662 ).push(
26142663 optimisticDrop.clearsSnooze
26152664 ? projected
......@@ -2623,7 +2672,7 @@ export default function Sidebar() {
26232672 } else if (thread.pinnedAt != null) {
26242673 pinned.push(thread);
26252674 } else {
2626 active.push(thread);
2675 inbox(thread).push(thread);
26272676 }
26282677 }
26292678 // One shared rule on every platform (see sortPinnedThreadsByOrderKey):
......@@ -2632,7 +2681,13 @@ export default function Sidebar() {
26322681 // sort, or mixed-version fleets would render different pinned orders on
26332682 // web and mobile from the same data.
26342683 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);
26362691 return {
26372692 pinnedThreads:
26382693 optimisticDrop?.section !== "pinned" || optimisticDrop.order === null
......@@ -2652,6 +2707,8 @@ export default function Sidebar() {
26522707 preferredIds: optimisticDrop.order,
26532708 getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
26542709 }),
2710 // Newest work first, by the same clock as the inbox.
2711 workingThreads: sortInboxThreadsByReturn(working),
26552712 // Soonest wake first: "what comes back next" is the shelf's question.
26562713 snoozedThreads: snoozed.toSorted(
26572714 (left, right) =>
......@@ -2661,15 +2718,29 @@ export default function Sidebar() {
26612718 settledThreads: sortSettledThreads(settled),
26622719 snoozeNow: preciseNow,
26632720 };
2664 }, [nowMinute, optimisticDrop, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]);
2721 }, [
2722 nowMinute,
2723 optimisticDrop,
2724 scopedProjectKeys,
2725 serverConfigs,
2726 snoozeWakeTick,
2727 threads,
2728 workingShelfEnabled,
2729 ]);
26652730
26662731 const threadSearchInputRef = useRef<HTMLInputElement>(null);
26672732 const [threadSearchQuery, setThreadSearchQuery] = useState("");
26682733 const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0);
26692734 const isSearchingThreads = threadSearchQuery.trim().length > 0;
26702735 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],
26732744 );
26742745 const searchEnvironmentIds = useMemo(
26752746 () =>
......@@ -2805,9 +2876,43 @@ export default function Sidebar() {
28052876 return routeThread === undefined ? EMPTY_THREADS : [routeThread];
28062877 }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]);
28072878
2879 // The Working shelf (beta) collapses the same way, with the same route
2880 // exception: sending a message folds the open thread into the shelf, and
2881 // its row must stay visible there.
2882 const [workingShelfExpanded, setWorkingShelfExpanded] = useLocalStorage(
2883 WORKING_SHELF_EXPANDED_KEY,
2884 false,
2885 Schema.Boolean,
2886 );
2887 const toggleWorkingShelf = useCallback(
2888 () => setWorkingShelfExpanded((value) => !value),
2889 [setWorkingShelfExpanded],
2890 );
2891 const visibleWorkingThreads = useMemo(() => {
2892 if (workingShelfExpanded) return workingThreads;
2893 if (routeThreadKey === null) return EMPTY_THREADS;
2894 const routeThread = workingThreads.find(
2895 (thread) =>
2896 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey,
2897 );
2898 return routeThread === undefined ? EMPTY_THREADS : [routeThread];
2899 }, [routeThreadKey, workingShelfExpanded, workingThreads]);
2900
28082901 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 ],
28112916 );
28122917 const orderedThreadKeys = useMemo(
28132918 () =>
......@@ -3230,10 +3335,11 @@ export default function Sidebar() {
32303335 };
32313336 add(pinnedThreads, "pinned");
32323337 add(activeThreads, "active");
3338 add(workingThreads, "working");
32333339 add(snoozedThreads, "snoozed");
32343340 add(settledThreads, "settled");
32353341 return map;
3236 }, [activeThreads, pinnedThreads, settledThreads, snoozedThreads]);
3342 }, [activeThreads, pinnedThreads, settledThreads, snoozedThreads, workingThreads]);
32373343 const pinnedKeys = useMemo(
32383344 () =>
32393345 pinnedThreads.map((thread) =>
......@@ -3400,6 +3506,7 @@ export default function Sidebar() {
34003506 if (
34013507 pinnedThreads.length +
34023508 activeThreads.length +
3509 workingThreads.length +
34033510 snoozedThreads.length +
34043511 settledThreads.length ===
34053512 0
......@@ -3413,6 +3520,10 @@ export default function Sidebar() {
34133520 const activeRows = rowsOf(activeThreads, "active");
34143521 items.push({ kind: "marker", marker: "active-placeholder" });
34153522 items.push(...activeRows);
3523 if (workingThreads.length > 0) {
3524 items.push({ kind: "marker", marker: "working-header" });
3525 items.push(...rowsOf(visibleWorkingThreads, "working"));
3526 }
34163527 if (snoozedThreads.length > 0) {
34173528 items.push({ kind: "marker", marker: "snoozed-header" });
34183529 items.push(...rowsOf(visibleSnoozedThreads, "snoozed"));
......@@ -3429,6 +3540,8 @@ export default function Sidebar() {
34293540 settledThreads.length,
34303541 snoozedThreads.length,
34313542 visibleSnoozedThreads,
3543 visibleWorkingThreads,
3544 workingThreads.length,
34323545 ]);
34333546 useEffect(() => {
34343547 if (
......@@ -3489,18 +3602,35 @@ export default function Sidebar() {
34893602 applySidebarThreadDrop(thread, "settled", dragState.occurredAt),
34903603 ]).map(key);
34913604 }, [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]);
34923620 const sidebarSortingStrategy = useMemo(
34933621 () =>
34943622 createSidebarSortingStrategy({
34953623 items: sidebarListItems,
34963624 boundaryLabelHeight: SIDEBAR_DRAG_LABEL_HEIGHT,
34973625 settledOrder: draggedSettledOrder,
3626 ...(draggedActiveOrder === undefined ? {} : { activeOrder: draggedActiveOrder }),
34983627 settledExpanded: settledShelfExpanded,
34993628 settledVisibleCount,
35003629 routeThreadKey,
35013630 snoozedThreadCount: snoozedThreads.length,
35023631 }),
35033632 [
3633 draggedActiveOrder,
35043634 draggedSettledOrder,
35053635 routeThreadKey,
35063636 settledShelfExpanded,
......@@ -3556,6 +3686,7 @@ export default function Sidebar() {
35563686 activeOrder: activeKeys,
35573687 activeKeysById,
35583688 activeReorderableKeys: activeReorderableThreadKeys,
3689 activeTimeOrdered: workingShelfEnabled,
35593690 }).kind !== "none"
35603691 );
35613692 },
......@@ -3577,6 +3708,7 @@ export default function Sidebar() {
35773708 pinnedKeys,
35783709 sidebarListItems,
35793710 threadByKey,
3711 workingShelfEnabled,
35803712 ]);
35813713 const handleThreadDragEnd = useCallback(
35823714 (event: DragEndEvent) => {
......@@ -3604,6 +3736,7 @@ export default function Sidebar() {
36043736 activeOrder: activeKeys,
36053737 activeKeysById,
36063738 activeReorderableKeys: activeReorderableThreadKeys,
3739 activeTimeOrdered: workingShelfEnabled,
36073740 });
36083741 if (plan.kind === "none") return;
36093742 if (plan.kind === "settle" && settlingThreadKeysRef.current.has(activeKey)) return;
......@@ -3737,6 +3870,7 @@ export default function Sidebar() {
37373870 unpinThread,
37383871 unsettleThread,
37393872 unsnoozeThread,
3873 workingShelfEnabled,
37403874 ],
37413875 );
37423876 // One snooze per thread at a time — same double-dispatch guard as settle.
......@@ -4717,7 +4851,9 @@ export default function Sidebar() {
47174851 // row: every other thread is a full card. Density comes
47184852 // from users (or the auto rules) actually parking work,
47194853 // 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";
47214857 const rowVariant = isCard ? "card" : "slim";
47224858 return (
47234859 <SidebarThreadRow
......@@ -4826,6 +4962,7 @@ export default function Sidebar() {
48264962 id={threadKey}
48274963 disabled={
48284964 renamingThreadKey === threadKey ||
4965 section === "working" ||
48294966 !draggableThreadKeys.has(threadKey) ||
48304967 optimisticDrop !== null
48314968 }
......@@ -4891,12 +5028,30 @@ export default function Sidebar() {
48915028 />,
48925029 );
48935030 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;
48945049 case "snoozed-header":
48955050 items.push(
48965051 <SidebarSectionHeader
48975052 key="snoozed-shelf-header"
48985053 marker="snoozed-header"
4899 className="mt-auto"
5054 className={cn(workingThreads.length === 0 && "mt-auto")}
49005055 label={
49015056 snoozedShelfExpanded
49025057 ? "Snoozed"
......@@ -4914,7 +5069,9 @@ export default function Sidebar() {
49145069 <SidebarSectionHeader
49155070 key="settled-shelf-header"
49165071 marker="settled-header"
4917 className={cn(snoozedThreads.length === 0 && "mt-auto")}
5072 className={cn(
5073 workingThreads.length + snoozedThreads.length === 0 && "mt-auto",
5074 )}
49185075 label={
49195076 settledShelfExpanded
49205077 ? "Settled"
......@@ -4972,6 +5129,7 @@ export default function Sidebar() {
49725129 visibleDraftSessionCount === 0 &&
49735130 pinnedThreads.length +
49745131 activeThreads.length +
5132 workingThreads.length +
49755133 snoozedThreads.length +
49765134 settledThreads.length ===
49775135 0 ? (
apps/web/src/components/settings/SettingsPanels.tsx+33
......@@ -563,6 +563,10 @@ export function useSettingsRestore(onRestored?: () => void) {
563563 DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode
564564 ? ["Project Grouping"]
565565 : []),
566 ...(settings.sidebarWorkingShelfEnabled !==
567 DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled
568 ? ["Working section"]
569 : []),
566570 ...(settings.sidebarAutoSettleAfterDays !==
567571 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays
568572 ? ["Auto-settle inactive threads"]
......@@ -684,6 +688,7 @@ export function useSettingsRestore(onRestored?: () => void) {
684688 settings.sidebarAutoSettleAfterDays,
685689 settings.sidebarAutoSettleOnMerge,
686690 settings.sidebarProjectGroupingMode,
691 settings.sidebarWorkingShelfEnabled,
687692 settings.sidebarThreadPreviewCount,
688693 settings.showSkillsInSlashMenu,
689694 settings.timestampFormat,
......@@ -781,6 +786,7 @@ export function useSettingsRestore(onRestored?: () => void) {
781786 panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs,
782787 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
783788 sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,
789 sidebarWorkingShelfEnabled: DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled,
784790 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
785791 sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,
786792 responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode,
......@@ -2269,6 +2275,33 @@ export function GeneralSettingsPanel() {
22692275 }
22702276 />
22712277
2278 <SettingsRow
2279 {...searchableSetting("working-shelf")}
2280 description="Fold working and monitoring threads into a Working section. They return to the top of the inbox when they need you."
2281 resetAction={
2282 settings.sidebarWorkingShelfEnabled !==
2283 DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled ? (
2284 <SettingResetButton
2285 label="working section"
2286 onClick={() =>
2287 updateSettings({
2288 sidebarWorkingShelfEnabled: DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled,
2289 })
2290 }
2291 />
2292 ) : null
2293 }
2294 control={
2295 <Switch
2296 checked={settings.sidebarWorkingShelfEnabled}
2297 onCheckedChange={(checked) =>
2298 updateSettings({ sidebarWorkingShelfEnabled: Boolean(checked) })
2299 }
2300 aria-label="Working section (beta)"
2301 />
2302 }
2303 />
2304
22722305 {supportsAutoSettlement ? (
22732306 <>
22742307 <SettingsRow
apps/web/src/components/settings/settingsSearch.ts+6
......@@ -273,6 +273,12 @@ export const SETTINGS_SEARCH_ITEMS = [
273273 to: "/settings/general",
274274 searchTerms: ["combine matching repositories environments sidebar"],
275275 },
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 },
276282 {
277283 id: "auto-settle-inactive-threads",
278284 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
100100environment. Pinned and active reordering require server support. Threads from older servers keep
101101their default order until the server is updated.
102102
103### Fold working threads (beta)
104
105On web and desktop, turn on **Settings → General → Working section (beta)** to move threads that
106are working or monitoring into a collapsed **Working** section at the bottom of the sidebar. A
107thread returns to the top of the active list when it finishes, fails, or needs an approval or
108answer. Pinned threads stay in the pinned section.
109
110While this is on, the active list is ordered by when each thread last came back to you, so you
111cannot drag to reorder it. Your saved order returns when you turn it off.
112
103113## Settle finished work
104114
105115Choose **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({
460460 // old keys, so everyone, including prior beta opt-outs, resets to the new
461461 // default sidebar.
462462 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 ),
463469 sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe(
464470 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)),
465471 ),
......@@ -1663,6 +1669,7 @@ export const ClientSettingsPatch = Schema.Struct({
16631669 proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean),
16641670 showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),
16651671 legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),
1672 sidebarWorkingShelfEnabled: Schema.optionalKey(Schema.Boolean),
16661673 sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),
16671674 sidebarProjectGroupingOverrides: Schema.optionalKey(
16681675 Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode),