From 172386240074c7282f8e4297652b21d33b68a463 Mon Sep 17 00:00:00 2001 From: Godfrey Hendrix Date: Sat, 7 Nov 2026 12:41:43 -0500 Subject: [PATCH 1/7] feat(web): sidebar sections can list threads by project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Settings → Labs → Sections by project. In the default sidebar, the active list, Working, Snoozed, and Settled list their threads under project labels, in folder order. In the boring sidebar, Snoozed and Settled do. The boring sidebar's title becomes "project folders". Co-Authored-By: Claude Opus 5.5 --- .../settings/DesktopClientSettings.test.ts | 1 + apps/web/src/components/Sidebar.logic.test.ts | 52 ++++++++++++ apps/web/src/components/Sidebar.logic.ts | 71 +++++++++++----- apps/web/src/components/Sidebar.tsx | 81 +++++++++++++++---- .../components/settings/LabsSettingsPanel.tsx | 23 +++++- .../src/components/settings/settingsSearch.ts | 10 ++- .../sidebar/SidebarProjectFolderHeader.tsx | 24 ++++++ .../components/sidebar/SidebarThreadRow.tsx | 12 ++- docs/user/thread-sidebar.md | 12 ++- packages/contracts/src/settings.ts | 2 + 10 files changed, 247 insertions(+), 41 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 1ab9b3159e1f9..c3d28a2206645 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -63,6 +63,7 @@ const clientSettings: ClientSettings = { sidebarThreadSortOrder: "created_at", boringSidebarEnabled: false, boringSidebarMultilineRows: false, + sidebarSectionsByProject: true, threadDetailsUsageEnabled: false, threadDetailsItems: {}, sidebarWorkingShelfEnabled: false, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 9e7489a0ffca1..40030748cc471 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -31,6 +31,7 @@ import { type SidebarListMarker, type SidebarSection, resolveSidebarDropVerb, + groupSidebarSectionByProject, groupSidebarThreadsByProject, resolveSidebarFolderDot, sidebarFolderExpansionKeys, @@ -1583,6 +1584,57 @@ describe("groupSidebarThreadsByProject", () => { }); }); +describe("groupSidebarSectionByProject", () => { + it("lists a section's rows under project labels in folder order, keeping unknown projects", () => { + const otherEnvironmentId = EnvironmentId.make("environment-remote"); + const web = ProjectId.make("web"); + const webRemote = ProjectId.make("web-remote"); + const api = ProjectId.make("api"); + const groups = [ + { + projectKey: "web", + memberProjectRefs: [ + { environmentId: localEnvironmentId, projectId: web }, + { environmentId: otherEnvironmentId, projectId: webRemote }, + ], + }, + { + projectKey: "api", + memberProjectRefs: [{ environmentId: localEnvironmentId, projectId: api }], + }, + { + projectKey: "docs", + memberProjectRefs: [ + { environmentId: localEnvironmentId, projectId: ProjectId.make("docs") }, + ], + }, + ]; + const thread = (id: string, projectId: ProjectId, environmentId = localEnvironmentId) => ({ + id, + environmentId, + projectId, + }); + const section = [ + thread("api-1", api), + thread("web-1", web), + thread("orphan", ProjectId.make("deleted")), + thread("web-2", webRemote, otherEnvironmentId), + thread("api-2", api), + ]; + + expect( + groupSidebarSectionByProject(groups, section).map((entry) => ({ + label: entry.group?.projectKey ?? null, + threads: entry.threads.map((row) => row.id), + })), + ).toEqual([ + { label: "web", threads: ["web-1", "web-2"] }, + { label: "api", threads: ["api-1", "api-2"] }, + { label: null, threads: ["orphan"] }, + ]); + }); +}); + describe("resolveSidebarFolderDot", () => { it("flags the most urgent hidden thread and stays quiet otherwise", () => { const idle = { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index e2c6a60fc33d5..f77d11902056f 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -935,35 +935,70 @@ export function sidebarFolderExpansionKeys(group: { ]; } +type ProjectGroupMembers = { + readonly memberProjectRefs: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly projectId: string; + }>; +}; +type ProjectThread = Pick; + +/** One bucket per group, in `groups` order, and the threads whose project is in no group. */ +function bucketThreadsByProjectGroup( + groups: readonly ProjectGroupMembers[], + threads: readonly TThread[], +): { buckets: TThread[][]; ungrouped: TThread[] } { + const indexByProject = new Map(); + for (const [index, group] of groups.entries()) { + for (const ref of group.memberProjectRefs) { + indexByProject.set(`${ref.environmentId}:${ref.projectId}`, index); + } + } + const buckets = groups.map((): TThread[] => []); + const ungrouped: TThread[] = []; + for (const thread of threads) { + const index = indexByProject.get(`${thread.environmentId}:${thread.projectId}`); + (index === undefined ? ungrouped : buckets[index]!).push(thread); + } + return { buckets, ungrouped }; +} + /** * Boring sidebar folders. Folders follow `groups` order and threads keep their * input order. A project with no threads gets a folder only when `keepEmpty` says so. * A thread whose project is in no group has no folder and is left out. */ export function groupSidebarThreadsByProject< - TGroup extends { - readonly memberProjectRefs: ReadonlyArray<{ - readonly environmentId: EnvironmentId; - readonly projectId: string; - }>; - }, - TThread extends Pick, + TGroup extends ProjectGroupMembers, + TThread extends ProjectThread, >( groups: readonly TGroup[], threads: readonly TThread[], keepEmpty: (group: TGroup) => boolean, ): Array<{ group: TGroup; threads: TThread[] }> { - const folders = groups.map((group) => ({ group, threads: [] as TThread[] })); - const folderByProject = new Map(); - for (const folder of folders) { - for (const ref of folder.group.memberProjectRefs) { - folderByProject.set(`${ref.environmentId}:${ref.projectId}`, folder); - } - } - for (const thread of threads) { - folderByProject.get(`${thread.environmentId}:${thread.projectId}`)?.threads.push(thread); - } - return folders.filter((folder) => folder.threads.length > 0 || keepEmpty(folder.group)); + const { buckets } = bucketThreadsByProjectGroup(groups, threads); + return groups + .map((group, index) => ({ group, threads: buckets[index]! })) + .filter((folder) => folder.threads.length > 0 || keepEmpty(folder.group)); +} + +/** + * A section's rows under project labels. Labels follow `groups` order and rows keep + * the section's order inside each. Rows whose project is in no group come last, under + * a `null` group, so a section never drops a thread. + */ +export function groupSidebarSectionByProject< + TGroup extends ProjectGroupMembers, + TThread extends ProjectThread, +>( + groups: readonly TGroup[], + threads: readonly TThread[], +): Array<{ group: TGroup | null; threads: TThread[] }> { + const { buckets, ungrouped } = bucketThreadsByProjectGroup(groups, threads); + const labeled: Array<{ group: TGroup | null; threads: TThread[] }> = groups.flatMap( + (group, index) => (buckets[index]!.length > 0 ? [{ group, threads: buckets[index]! }] : []), + ); + return ungrouped.length > 0 ? [...labeled, { group: null, threads: ungrouped }] : labeled; } export type SidebarV2TopStatusKind = diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d863e285a80e7..de1259c149bd2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -180,6 +180,7 @@ import { import { applySidebarThreadDrop, filterSidebarV2VisibleThreads, + groupSidebarSectionByProject, groupSidebarThreadsByProject, sidebarFolderExpansionKeys, buildBulkTitleRegenerationContextMenuItem, @@ -264,6 +265,7 @@ import { import { SidebarDraftBlock } from "./sidebar/SidebarDraftBlock"; import { SidebarProjectFolderHeader, + SidebarSectionProjectLabel, sidebarFolderRowId, } from "./sidebar/SidebarProjectFolderHeader"; import { @@ -421,6 +423,8 @@ function useSidebarThreads(input: { readonly routeRowThreadKey: string | null; // Set in boring mode: the project groups to fold active threads into, in order. readonly folderGroups: readonly SidebarProjectSnapshot[] | null; + // Set when sections list threads by project: the groups whose labels order each section. + readonly labelGroups: readonly SidebarProjectSnapshot[] | null; // Keeps folders with no active threads, so a project the user filtered to or pinned can start one. readonly keepEmptyFolders: (group: SidebarProjectSnapshot) => boolean; }) { @@ -434,6 +438,7 @@ function useSidebarThreads(input: { delegatedUnderParent, routeRowThreadKey, folderGroups, + labelGroups, keepEmptyFolders: keepEmpty, } = input; // Folders already separate the work, so boring mode has no Working shelf. @@ -511,15 +516,25 @@ function useSidebarThreads(input: { } return visible.length === settledThreads.length ? settledThreads : visible; })(); + // Labels key each project's first row; a closed shelf shows only the open thread, unlabeled. + const sectionLabels = new Map(); + const labeled = (rows: readonly EnvironmentThreadShell[]) => { + if (labelGroups === null) return rows; + const groups = groupSidebarSectionByProject(labelGroups, rows); + for (const entry of groups) sectionLabels.set(threadShellKey(entry.threads[0]!), entry.group); + return groups.flatMap((entry) => entry.threads); + }; const renderedSettledThreads = settledShelfExpanded - ? visibleSettledThreads + ? labeled(visibleSettledThreads) : routeThreadOnly(visibleSettledThreads, routeRowThreadKey); const visibleSnoozedThreads = snoozedShelfExpanded - ? snoozedThreads + ? labeled(snoozedThreads) : routeThreadOnly(snoozedThreads, routeRowThreadKey); const visibleWorkingThreads = workingShelfExpanded - ? workingThreads + ? labeled(workingThreads) : routeThreadOnly(workingThreads, routeRowThreadKey); + // Boring folders already group the active threads. + const renderedActiveThreads = folderGroups === null ? labeled(activeThreads) : activeThreads; const projectFolders = folderGroups === null ? null @@ -543,7 +558,7 @@ function useSidebarThreads(input: { const orderedThreads = [ ...pinnedThreads, ...(projectFolders === null - ? activeThreads + ? renderedActiveThreads : projectFolders.flatMap((folder) => folder.threads)), ...visibleWorkingThreads, ...visibleSnoozedThreads, @@ -563,6 +578,8 @@ function useSidebarThreads(input: { ...partition, threads, workingShelfEnabled, + sectionLabels, + renderedActiveThreads, searchableThreads: [ ...pinnedThreads, ...activeThreads, @@ -991,6 +1008,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }); const boringSidebarEnabled = useClientSettings((settings) => settings.boringSidebarEnabled); const folderRowsMultiline = useClientSettings((settings) => settings.boringSidebarMultilineRows); + const sectionsByProject = useClientSettings((settings) => settings.sidebarSectionsByProject); const newThreadContext = useHandleNewThread(); // Row callbacks capture only the handler; the rest of the context changes with the route. const { handleNewThread } = newThreadContext; @@ -1291,8 +1309,15 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }, ); + // Folder and label order. An archived project keeps its place for its threads, below the rest. + const projectOrderGroups = + scopedProjectGroups.length === 0 + ? [...scopeGroups, ...projectGroups.filter((project) => project.archived)] + : scopedProjectGroups; const { workingShelfEnabled, + sectionLabels, + renderedActiveThreads, pinnedThreads, draggableThreadKeys, activeReorderableThreadKeys, @@ -1336,12 +1361,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve threads, delegatedUnderParent, routeRowThreadKey, - folderGroups: boringSidebarEnabled - ? scopedProjectGroups.length === 0 - ? // An archived project keeps a folder for its active threads, below the rest. - [...scopeGroups, ...projectGroups.filter((project) => project.archived)] - : scopedProjectGroups - : null, + folderGroups: boringSidebarEnabled ? projectOrderGroups : null, + labelGroups: sectionsByProject ? projectOrderGroups : null, keepEmptyFolders: (group) => scopedProjectGroups.length > 0 || (group.pinned && !group.archived), }); @@ -1709,9 +1730,11 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }; }; - // Folder rows only drag out to the composer; folders own their order, so nothing drops in the list. + // Folder and labeled rows only drag out to the composer: folders and labels own their order, + // so nothing drops in the list. const isContextDrag = projectFolders !== null || + sectionsByProject || dragState?.contextDrag === true || dragState?.activeSection === "working"; const dragTargetSection = isContextDrag ? null : (dragState?.targetSection ?? null); @@ -1945,7 +1968,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const pinnedRows = rowsOf(pinnedThreads, "pinned"); items.push(...pinnedRows); items.push({ kind: "marker", marker: "pinned-divider" }); - const activeRows = rowsOf(activeThreads, "active"); + const activeRows = rowsOf(renderedActiveThreads, "active"); items.push({ kind: "marker", marker: "active-placeholder" }); items.push(...activeRows); if (workingThreads.length > 0) { @@ -2078,7 +2101,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ); })(); const handleThreadDragEnd = (event: DragEndEvent) => { - if (projectFolders !== null) return; + if (projectFolders !== null || sectionsByProject) return; const activeKey = String(event.active.id); const activeSection = sectionByThreadKey.get(activeKey); const target = @@ -3343,13 +3366,29 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ); const isCard = section === "active" || section === "pinned" || section === "working"; + // A closed shelf's open thread has no label above it. + const underProjectLabel = + sectionsByProject && + !folderRow && + (section === "active" + ? projectFolders === null + : section === "working" + ? workingShelfExpanded + : section === "snoozed" + ? snoozedShelfExpanded + : section === "settled" && settledShelfExpanded); + // Parked rows under a project label drop their favicon, like folder rows. + const parkedUnderLabel = + underProjectLabel && (section === "snoozed" || section === "settled"); const rowVariant = folderRow ? folderRowsMultiline ? "folder-multiline" : "folder" - : isCard - ? "card" - : "slim"; + : parkedUnderLabel + ? "folder" + : isCard + ? "card" + : "slim"; return ( , + ); + } items.push(renderThreadRow(threadByKey.get(item.key)!, item.section)); continue; } diff --git a/apps/web/src/components/settings/LabsSettingsPanel.tsx b/apps/web/src/components/settings/LabsSettingsPanel.tsx index 9c71a21c265f5..8b56b8de52c0d 100644 --- a/apps/web/src/components/settings/LabsSettingsPanel.tsx +++ b/apps/web/src/components/settings/LabsSettingsPanel.tsx @@ -19,6 +19,7 @@ export function LabsSettingsPanel() { + {/* Clerk is only mounted when this build has T3 Connect. */} {hasCloudPublicConfig() ? : } @@ -40,7 +41,7 @@ function BoringSidebarRow() { updateSettings({ boringSidebarEnabled: Boolean(checked) })} - aria-label="Boring sidebar (group by project)" + aria-label="Boring sidebar (project folders)" /> } > @@ -53,6 +54,26 @@ function BoringSidebarRow() { ); } +function SectionsByProjectRow() { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + return ( + + updateSettings({ sidebarSectionsByProject: Boolean(checked) }) + } + aria-label="Sections by project" + /> + } + /> + ); +} + function BoringSidebarMultilineRow() { const settings = useScopedSettings(); const updateSettings = useUpdateScopedSettings(); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index f33ccc35a6bad..bb7b14d466c75 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -458,10 +458,18 @@ export const SETTINGS_SEARCH_ITEMS = [ }, { id: "boring-sidebar", - title: "Boring sidebar (group by project)", + title: "Boring sidebar (project folders)", to: "/settings/labs", searchTerms: ["beta folders project tree classic sidebar"], }, + { + id: "sidebar-sections-by-project", + title: "Sections by project", + to: "/settings/labs", + searchTerms: [ + "beta sidebar group organize threads project labels working snoozed settled sections", + ], + }, { id: "boring-sidebar-multiline", title: "Two-line folder rows", diff --git a/apps/web/src/components/sidebar/SidebarProjectFolderHeader.tsx b/apps/web/src/components/sidebar/SidebarProjectFolderHeader.tsx index 38b1e53bf057b..56e4cb3571696 100644 --- a/apps/web/src/components/sidebar/SidebarProjectFolderHeader.tsx +++ b/apps/web/src/components/sidebar/SidebarProjectFolderHeader.tsx @@ -30,6 +30,30 @@ export function sidebarFolderRowId(projectKey: string) { return `folder:${projectKey}`; } +/** + * Names the project of the section rows below it when sections list threads by + * project. It is not a row: rows carry their project in their own labels. + */ +export const SidebarSectionProjectLabel = memo(function SidebarSectionProjectLabel(props: { + group: SidebarProjectSnapshot | null; +}) { + const { group } = props; + return ( +
  • + {group ? ( + + ) : ( + + )} + {group?.displayName ?? "Other"} +
  • + ); +}); + /** * A project folder in the boring sidebar. It reads like a shelf header: a trailing * count and chevron, with the count and a status dot only while it is closed, so a diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index 3a50e76d06bda..cfb76e0123cb2 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -564,6 +564,8 @@ type SidebarThreadRowProps = { scratchMachineLabel: string | null; project: EnvironmentProject | null; projectDisplayName: string | null; + /** A project label above the row names its project, so a card leaves it out. */ + underProjectLabel?: boolean; providerEntryByInstanceId: ReadonlyMap; timestampFormat: TimestampFormat; onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; @@ -1028,7 +1030,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP className={cn( "min-w-0 flex-1 text-start text-sm transition-opacity motion-reduce:transition-none", shouldRecede ? "font-normal" : "font-medium", - variant !== "slim" + // Parked rows keep the shelves' quieter titles in folder rows too. + variant !== "slim" && variantAction === "settle" ? cn( "truncate", shouldRecede @@ -1307,8 +1310,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP /> Dismiss Woke notification - ) : inFolder && topStatus ? ( + ) : inFolder && topStatus && variantAction === "settle" ? ( // Folder rows have no status line, so the status takes the time slot. + // Parked rows keep their time, as on the shelves.
    {draftIndicator} - {props.project ? ( + {props.project && !props.underProjectLabel ? ( ) : null} {/* Left of the status slot, the pin would slide when hover swaps the label for the wider row controls. */} {pinIndicator} - {props.projectDisplayName ? ( + {props.projectDisplayName && !props.underProjectLabel ? ( diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index f5ea773067e89..c1ae978ff2097 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -149,7 +149,7 @@ cannot drag or move threads within it. Your saved order returns when you turn it ### Group threads by project (beta) -Turn on **Settings → Labs → Boring sidebar (group by project)** on web and desktop to put active +Turn on **Settings → Labs → Boring sidebar (project folders)** on web and desktop to put active threads in a folder for each project. Pinned projects come first. A project with no active threads has no folder unless it is pinned or you pick it in the project filter. An archived project's folder comes last and shows only while it has active threads. Click a folder to open or @@ -159,6 +159,16 @@ Pinned threads stay above the folders, and Snoozed and Settled stay below them. can drag a thread onto the composer to mention it, but not within the sidebar, and the Working section is off. Each device keeps its own choice. +### List sections by project (beta) + +Turn on **Settings → Labs → Sections by project** on web and desktop to list each section's +threads under their project's name, in the same project order as the boring sidebar's folders. +In the default sidebar this applies to the active list, Working, Snoozed, and Settled, so finished +work returns to the top of its project and running work waits under its project in Working. In the +boring sidebar, Snoozed and Settled list their threads this way. Threads stay in their order +within each project. While this is on you can drag a thread onto the composer to mention it, but +not within the sidebar; turning it off brings back your arrangement. + ## Settle finished work Choose **Settle thread** from its menu to move finished work out of the active list diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 30f7de8711334..c1def1a01f8ab 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -514,6 +514,7 @@ export const ClientSettingsSchema = Schema.Struct({ boringSidebarMultilineRows: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), ), + sidebarSectionsByProject: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), toolCallVisibility: ToolCallVisibility.pipe( Schema.withDecodingDefault(Effect.succeed("showAll" as const)), ), @@ -1927,6 +1928,7 @@ export const ClientSettingsPatch = Schema.Struct({ showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), boringSidebarEnabled: Schema.optionalKey(Schema.Boolean), boringSidebarMultilineRows: Schema.optionalKey(Schema.Boolean), + sidebarSectionsByProject: Schema.optionalKey(Schema.Boolean), toolCallVisibility: Schema.optionalKey(ToolCallVisibility), threadDetailsUsageEnabled: Schema.optionalKey(Schema.Boolean), threadDetailsItems: Schema.optionalKey(Schema.Record(Schema.String, Schema.Boolean)), From 1e9a7da33229485c593b13e1ecc1790535af499c Mon Sep 17 00:00:00 2001 From: Godfrey Hendrix Date: Sat, 7 Nov 2026 12:46:22 -0500 Subject: [PATCH 2/7] fix(web): sidebar motion follows the Sections by project switch Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/Sidebar.tsx | 2 ++ docs/user/thread-sidebar.md | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index de1259c149bd2..2d1779d0a06cb 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2015,6 +2015,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve .join("\n") ?? "", visibleDraftSessionCount, routeDraftIdForRows ?? "", + // Labels shift rows without changing their order. + sectionsByProject, ].join("\0"); const listMotionAnimate = dragState === null && sidebarListItems.length + visibleDraftSessionCount > 0; diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index c1ae978ff2097..77a004088baf4 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -147,7 +147,7 @@ choice. While this is on, the active list is ordered by when each thread last came back to you, so you cannot drag or move threads within it. Your saved order returns when you turn it off. -### Group threads by project (beta) +### Project folders (beta) Turn on **Settings → Labs → Boring sidebar (project folders)** on web and desktop to put active threads in a folder for each project. Pinned projects come first. A project with no active @@ -167,7 +167,8 @@ In the default sidebar this applies to the active list, Working, Snoozed, and Se work returns to the top of its project and running work waits under its project in Working. In the boring sidebar, Snoozed and Settled list their threads this way. Threads stay in their order within each project. While this is on you can drag a thread onto the composer to mention it, but -not within the sidebar; turning it off brings back your arrangement. +not within the sidebar; turning it off brings back your arrangement. Each device keeps its own +choice. ## Settle finished work From 69642434263b72b12f27f26ff07779a278841b1c Mon Sep 17 00:00:00 2001 From: Godfrey Hendrix Date: Sun, 8 Nov 2026 17:49:44 -0500 Subject: [PATCH 3/7] docs(web): say drops stay off while project labels are on Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/Sidebar.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 2d1779d0a06cb..d6be1d1533c80 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1730,8 +1730,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }; }; - // Folder and labeled rows only drag out to the composer: folders and labels own their order, - // so nothing drops in the list. + // With folders or project labels on, rows only drag out to the composer; nothing drops in the list. const isContextDrag = projectFolders !== null || sectionsByProject || From bf5d5d4f8b284340c2cbd2349e345414318a8689 Mon Sep 17 00:00:00 2001 From: Godfrey Hendrix Date: Sun, 8 Nov 2026 18:02:29 -0500 Subject: [PATCH 4/7] docs: scope Sections by project ordering claims to when they hold Co-Authored-By: Claude Opus 5.5 --- docs/user/thread-sidebar.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 77a004088baf4..9016dc298862b 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -163,12 +163,12 @@ section is off. Each device keeps its own choice. Turn on **Settings → Labs → Sections by project** on web and desktop to list each section's threads under their project's name, in the same project order as the boring sidebar's folders. -In the default sidebar this applies to the active list, Working, Snoozed, and Settled, so finished -work returns to the top of its project and running work waits under its project in Working. In the -boring sidebar, Snoozed and Settled list their threads this way. Threads stay in their order -within each project. While this is on you can drag a thread onto the composer to mention it, but -not within the sidebar; turning it off brings back your arrangement. Each device keeps its own -choice. +In the default sidebar this applies to the active list, Working, Snoozed, and Settled. With the +Working section on, finished work returns to the top of its project and running work waits under +its project in Working. In the boring sidebar, Snoozed and Settled list their threads this way. +Threads keep their section's order within each project. While this is on you can drag a thread +onto the composer to mention it, but not within the sidebar. An order you arranged in the default +sidebar returns when you turn this off. Each device keeps its own choice. ## Settle finished work From 48fd97121d5832823c8b0c8910cc284162aaf175 Mon Sep 17 00:00:00 2001 From: Godfrey Hendrix Date: Sat, 7 Nov 2026 12:46:19 -0500 Subject: [PATCH 5/7] feat(web): boring sidebar folds working threads into each project folder With Boring sidebar and Sections by project both on, each open project folder keeps its working threads under its own collapsible Working row at the bottom. Finished threads and threads that need you return to the top of their folder, ordered by the inbox return tracker. There is no global Working shelf in this mode. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/Sidebar.logic.test.ts | 88 +++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 68 +++++++++++ apps/web/src/components/Sidebar.tsx | 106 ++++++++++++------ .../sidebar/SidebarProjectFolderHeader.tsx | 30 +++++ docs/user/thread-sidebar.md | 4 +- 5 files changed, 263 insertions(+), 33 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 40030748cc471..86325fd4c433f 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -31,6 +31,7 @@ import { type SidebarListMarker, type SidebarSection, resolveSidebarDropVerb, + buildSidebarProjectFolders, groupSidebarSectionByProject, groupSidebarThreadsByProject, resolveSidebarFolderDot, @@ -1635,6 +1636,93 @@ describe("groupSidebarSectionByProject", () => { }); }); +describe("buildSidebarProjectFolders", () => { + const web = ProjectId.make("web"); + const api = ProjectId.make("api"); + const docs = ProjectId.make("docs"); + const groups = [web, api, docs].map((projectId) => ({ + projectKey: projectId as string, + memberProjectRefs: [{ environmentId: localEnvironmentId, projectId }], + })); + const thread = (id: string, projectId: ProjectId) => ({ + id, + environmentId: localEnvironmentId, + projectId, + }); + const ready = [thread("web-done", web), thread("api-done", api)]; + const working = [thread("web-run", web), thread("docs-run", docs)]; + const build = ( + options: { + workingThreads?: typeof working; + closed?: string[]; + workingOpen?: string[]; + route?: string; + } = {}, + ) => + buildSidebarProjectFolders({ + groups, + activeThreads: ready, + workingThreads: options.workingThreads ?? working, + keepEmpty: () => false, + isFolderOpen: (group) => !options.closed?.includes(group.projectKey), + isWorkingOpen: (group) => options.workingOpen?.includes(group.projectKey) ?? false, + isRouteThread: (row) => row.id === options.route, + }).map((folder) => ({ + folder: folder.group.projectKey, + rows: folder.threads.map((row) => row.id), + hidden: folder.hiddenThreads.map((row) => row.id), + working: + folder.working === null + ? null + : { + count: folder.working.count, + expanded: folder.working.expanded, + rows: folder.working.threads.map((row) => row.id), + }, + })); + + it("folds working threads under each folder's Working row, closed until opened", () => { + expect(build()).toEqual([ + { + folder: "web", + rows: ["web-done"], + hidden: [], + working: { count: 1, expanded: false, rows: [] }, + }, + { folder: "api", rows: ["api-done"], hidden: [], working: null }, + { folder: "docs", rows: [], hidden: [], working: { count: 1, expanded: false, rows: [] } }, + ]); + expect(build({ workingOpen: ["web"] })[0]!.working).toEqual({ + count: 1, + expanded: true, + rows: ["web-run"], + }); + // A closed folder has no Working row; its count covers its working threads too. + expect(build({ closed: ["web"] })[0]).toEqual({ + folder: "web", + rows: [], + hidden: ["web-done", "web-run"], + working: null, + }); + // Without separated working threads, folders have no Working row. + expect(build({ workingThreads: [] }).map((folder) => folder.working)).toEqual([null, null]); + }); + + it("still shows the open thread inside a closed folder or Working row", () => { + expect(build({ route: "web-run" })[0]!.working).toEqual({ + count: 1, + expanded: false, + rows: ["web-run"], + }); + expect(build({ closed: ["web"], route: "web-run" })[0]).toEqual({ + folder: "web", + rows: ["web-run"], + hidden: ["web-done"], + working: null, + }); + }); +}); + describe("resolveSidebarFolderDot", () => { it("flags the most urgent hidden thread and stays quiet otherwise", () => { const idle = { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f77d11902056f..a28cdd640fd40 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1001,6 +1001,74 @@ export function groupSidebarSectionByProject< return ungrouped.length > 0 ? [...labeled, { group: null, threads: ungrouped }] : labeled; } +// Stable, so memoized folder headers skip re-rendering while their folder is open. +const NO_HIDDEN_THREADS: readonly never[] = []; + +export interface SidebarProjectFolder { + readonly group: TGroup; + readonly collapsed: boolean; + /** Rows drawn under the folder header. */ + readonly threads: readonly TThread[]; + /** What a closed folder's count and dot stand for. */ + readonly hiddenThreads: readonly TThread[]; + /** The open folder's Working row, while it holds working threads. */ + readonly working: { + readonly expanded: boolean; + readonly count: number; + readonly threads: readonly TThread[]; + } | null; +} + +/** + * Boring sidebar folders. Working threads, when given, go under each open folder's + * Working row. A closed folder or Working row still shows the open thread. + */ +export function buildSidebarProjectFolders< + TGroup extends ProjectGroupMembers, + TThread extends ProjectThread, +>(input: { + readonly groups: readonly TGroup[]; + readonly activeThreads: readonly TThread[]; + readonly workingThreads: readonly TThread[]; + readonly keepEmpty: (group: TGroup) => boolean; + readonly isFolderOpen: (group: TGroup) => boolean; + readonly isWorkingOpen: (group: TGroup) => boolean; + readonly isRouteThread: (thread: TThread) => boolean; +}): Array> { + const working = new Set(input.workingThreads); + return groupSidebarThreadsByProject( + input.groups, + [...input.activeThreads, ...input.workingThreads], + input.keepEmpty, + ).map(({ group, threads }) => { + if (!input.isFolderOpen(group)) { + return { + group, + collapsed: true, + threads: threads.filter(input.isRouteThread), + hiddenThreads: threads.filter((thread) => !input.isRouteThread(thread)), + working: null, + }; + } + const folderWorking = threads.filter((thread) => working.has(thread)); + const expanded = input.isWorkingOpen(group); + return { + group, + collapsed: false, + threads: threads.filter((thread) => !working.has(thread)), + hiddenThreads: NO_HIDDEN_THREADS, + working: + folderWorking.length === 0 + ? null + : { + expanded, + count: folderWorking.length, + threads: expanded ? folderWorking : folderWorking.filter(input.isRouteThread), + }, + }; + }); +} + export type SidebarV2TopStatusKind = | "approval" | "done" diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d6be1d1533c80..9cad5229d2d92 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -180,8 +180,8 @@ import { import { applySidebarThreadDrop, filterSidebarV2VisibleThreads, + buildSidebarProjectFolders, groupSidebarSectionByProject, - groupSidebarThreadsByProject, sidebarFolderExpansionKeys, buildBulkTitleRegenerationContextMenuItem, buildBulkUnpinContextMenuItem, @@ -264,9 +264,11 @@ import { } from "./sidebar/SidebarThreadRow"; import { SidebarDraftBlock } from "./sidebar/SidebarDraftBlock"; import { + SidebarFolderWorkingRow, SidebarProjectFolderHeader, SidebarSectionProjectLabel, sidebarFolderRowId, + sidebarFolderWorkingRowId, } from "./sidebar/SidebarProjectFolderHeader"; import { SidebarDragBoundary, @@ -331,6 +333,10 @@ const SETTLED_TAIL_PAGE_COUNT = 25; const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar:settled-expanded"; const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar:snoozed-expanded"; const WORKING_SHELF_EXPANDED_KEY = "t3code:sidebar:working-expanded"; +// Project keys whose folder Working row is open; closing one drops its key. +const FOLDER_WORKING_EXPANDED_KEY = "t3code:sidebar:folder-working-expanded"; +const FolderWorkingExpandedKeys = Schema.Array(Schema.String); +const NO_FOLDER_WORKING_EXPANDED: readonly string[] = []; const inboxReturns = createInboxReturnTracker(); @@ -354,7 +360,7 @@ interface SidebarThreadPartitionInput { readonly connectedEnvironmentIds: readonly EnvironmentId[]; readonly optimisticDrop: SidebarOptimisticDrop | null; readonly workingShelfEnabled: boolean; - /** Boring mode: folders list active threads by the user's latest message. */ + /** Boring mode with working threads mixed in: folders list them by the user's latest message. */ readonly sortActiveBySend: boolean; // Snooze wakes, the queued-turn grace and the Working shelf's exit grace depend // on the clock, so the minute tick, the precise snooze-wake tick and the inbox @@ -441,9 +447,11 @@ function useSidebarThreads(input: { labelGroups, keepEmptyFolders: keepEmpty, } = input; - // Folders already separate the work, so boring mode has no Working shelf. - const workingShelfEnabled = - useClientSettings((s) => s.sidebarWorkingShelfEnabled) && folderGroups === null; + const defaultWorkingShelfEnabled = useClientSettings((s) => s.sidebarWorkingShelfEnabled); + // Boring folders listed by project tuck working threads under their own Working rows; + // plain boring folders keep them in place, with no Working shelf. + const foldsWorking = folderGroups !== null && labelGroups !== null; + const workingShelfEnabled = folderGroups === null ? defaultWorkingShelfEnabled : foldsWorking; const inboxReturnsVersion = useSyncExternalStore( inboxReturns.subscribe, inboxReturns.getSnapshot, @@ -466,6 +474,11 @@ function useSidebarThreads(input: { false, Schema.Boolean, ); + const [folderWorkingExpandedKeys, setFolderWorkingExpandedKeys] = useLocalStorage( + FOLDER_WORKING_EXPANDED_KEY, + NO_FOLDER_WORKING_EXPANDED, + FolderWorkingExpandedKeys, + ); // Shared with the old per-project sidebar, so folders keep their open state. const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); const setProjectExpanded = useUiStateStore((store) => store.setProjectExpanded); @@ -487,7 +500,7 @@ function useSidebarThreads(input: { connectedEnvironmentIds, optimisticDrop, workingShelfEnabled, - sortActiveBySend: folderGroups !== null, + sortActiveBySend: folderGroups !== null && !foldsWorking, clockKey: `${nowMinute}:${snoozeWakeClock}:${inboxReturnsVersion}`, }); @@ -530,36 +543,34 @@ function useSidebarThreads(input: { const visibleSnoozedThreads = snoozedShelfExpanded ? labeled(snoozedThreads) : routeThreadOnly(snoozedThreads, routeRowThreadKey); - const visibleWorkingThreads = workingShelfExpanded - ? labeled(workingThreads) - : routeThreadOnly(workingThreads, routeRowThreadKey); + const visibleWorkingThreads = foldsWorking + ? EMPTY_THREADS + : workingShelfExpanded + ? labeled(workingThreads) + : routeThreadOnly(workingThreads, routeRowThreadKey); // Boring folders already group the active threads. const renderedActiveThreads = folderGroups === null ? labeled(activeThreads) : activeThreads; const projectFolders = folderGroups === null ? null - : groupSidebarThreadsByProject(folderGroups, activeThreads, keepEmpty).map((folder) => { - const collapsed = !resolveProjectExpanded( - projectExpandedById, - sidebarFolderExpansionKeys(folder.group), - ); - return { - group: folder.group, - collapsed, - // A closed folder still shows the open thread, and hints at the rest. - threads: collapsed - ? routeThreadOnly(folder.threads, routeRowThreadKey) - : folder.threads, - hiddenThreads: collapsed - ? folder.threads.filter((thread) => threadShellKey(thread) !== routeRowThreadKey) - : EMPTY_THREADS, - }; + : buildSidebarProjectFolders({ + groups: folderGroups, + activeThreads, + workingThreads, + keepEmpty, + isFolderOpen: (group) => + resolveProjectExpanded(projectExpandedById, sidebarFolderExpansionKeys(group)), + isWorkingOpen: (group) => folderWorkingExpandedKeys.includes(group.projectKey), + isRouteThread: (thread) => threadShellKey(thread) === routeRowThreadKey, }); const orderedThreads = [ ...pinnedThreads, ...(projectFolders === null ? renderedActiveThreads - : projectFolders.flatMap((folder) => folder.threads)), + : projectFolders.flatMap((folder) => [ + ...folder.threads, + ...(folder.working?.threads ?? EMPTY_THREADS), + ])), ...visibleWorkingThreads, ...visibleSnoozedThreads, ...renderedSettledThreads, @@ -607,6 +618,12 @@ function useSidebarThreads(input: { const keys = sidebarFolderExpansionKeys(group); setProjectExpanded(keys, !resolveProjectExpanded(projectExpandedById, keys)); }, + toggleFolderWorking: (group: SidebarProjectSnapshot) => + setFolderWorkingExpandedKeys((keys) => + keys.includes(group.projectKey) + ? keys.filter((key) => key !== group.projectKey) + : [...keys, group.projectKey], + ), renderedSettledThreads, visibleSnoozedThreads, visibleWorkingThreads, @@ -1340,6 +1357,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve toggleWorkingShelf, projectFolders, toggleProjectFolder, + toggleFolderWorking, renderedSettledThreads, visibleSnoozedThreads, visibleWorkingThreads, @@ -1942,6 +1960,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }); }; const hasProjectFolders = projectFolders !== null && projectFolders.length > 0; + // Boring folders hold their own working threads. + const workingHeaderShown = projectFolders === null && workingThreads.length > 0; const sidebarListItemsFresh = ((): readonly SidebarListItem[] => { const rowsOf = ( list: readonly EnvironmentThreadShell[], @@ -1970,7 +1990,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const activeRows = rowsOf(renderedActiveThreads, "active"); items.push({ kind: "marker", marker: "active-placeholder" }); items.push(...activeRows); - if (workingThreads.length > 0) { + if (workingHeaderShown) { items.push({ kind: "marker", marker: "working-header" }); items.push(...rowsOf(visibleWorkingThreads, "working")); } @@ -2010,7 +2030,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const listMotionLayoutKey = [ sidebarListItemsKey, projectFolders - ?.map((folder) => `${folder.group.projectKey}:${folder.threads.length}`) + ?.map( + (folder) => + `${folder.group.projectKey}:${folder.threads.length}:${folder.working?.threads.map(threadShellKey).join(",") ?? "-"}`, + ) .join("\n") ?? "", visibleDraftSessionCount, routeDraftIdForRows ?? "", @@ -3533,15 +3556,34 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve onContextMenu={handleProjectFolderContextMenu} />, ); + const folderRowId = sidebarFolderRowId(folder.group.projectKey); for (const thread of folder.threads) { items.push( renderThreadRow( thread, - "active", - sidebarFolderRowId(folder.group.projectKey), + // A closed folder's open thread may be working. + sectionByThreadKey.get(threadShellKey(thread)) ?? "active", + folderRowId, ), ); } + if (folder.working !== null) { + items.push( + , + ); + const workingRowId = sidebarFolderWorkingRowId( + folder.group.projectKey, + ); + for (const thread of folder.working.threads) { + items.push(renderThreadRow(thread, "working", workingRowId)); + } + } } continue; } @@ -3606,7 +3648,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve void; +}) { + const { group } = props; + return ( +
  • + props.onToggle(group)} + accessory={{props.count}} + > + Working + +
  • + ); +}); + /** * Names the project of the section rows below it when sections list threads by * project. It is not a row: rows carry their project in their own labels. diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 9016dc298862b..205fe9c4bcde5 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -165,7 +165,9 @@ Turn on **Settings → Labs → Sections by project** on web and desktop to list threads under their project's name, in the same project order as the boring sidebar's folders. In the default sidebar this applies to the active list, Working, Snoozed, and Settled. With the Working section on, finished work returns to the top of its project and running work waits under -its project in Working. In the boring sidebar, Snoozed and Settled list their threads this way. +its project in Working. In the boring sidebar, Snoozed and Settled list their threads this way, +and each folder keeps its running threads under its own **Working** row at the bottom, closed +until you open it. Finished threads, and threads that need you, return to the top of their folder. Threads keep their section's order within each project. While this is on you can drag a thread onto the composer to mention it, but not within the sidebar. An order you arranged in the default sidebar returns when you turn this off. Each device keeps its own choice. From 3e02d2059fe008f64ceda1c35a49b929485bee5e Mon Sep 17 00:00:00 2001 From: Godfrey Hendrix Date: Sat, 7 Nov 2026 13:07:14 -0500 Subject: [PATCH 6/7] docs(web): project folders point to their Working rows Co-Authored-By: Claude Opus 5.5 --- docs/user/thread-sidebar.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 205fe9c4bcde5..32c960c2a10ea 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -157,7 +157,8 @@ close it. A closed folder shows how many threads it holds, and a dot when one of needs you. Use the pen button on a folder, or right-click it, to start a thread in that project. Pinned threads stay above the folders, and Snoozed and Settled stay below them. In this mode you can drag a thread onto the composer to mention it, but not within the sidebar, and the Working -section is off. Each device keeps its own choice. +section is off; with **Sections by project** on, each folder gets its own Working row instead. +Each device keeps its own choice. ### List sections by project (beta) From 80b5fe59764f5ea3a099adfdc9f60fd022396385 Mon Sep 17 00:00:00 2001 From: Godfrey Hendrix Date: Sun, 8 Nov 2026 17:54:07 -0500 Subject: [PATCH 7/7] fix(web): folder Working-row threads drag onto the composer Boring sidebar rows drag onto the composer since #26724, but folder Working-row threads were not sidebar list items, so the effect that cancels a drag whose row left the list cancelled theirs at once. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/Sidebar.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9cad5229d2d92..420bed93a31d5 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1993,6 +1993,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve if (workingHeaderShown) { items.push({ kind: "marker", marker: "working-header" }); items.push(...rowsOf(visibleWorkingThreads, "working")); + } else if (projectFolders !== null) { + // Folders draw their own working rows; listing them keeps their composer drags alive. + items.push(...rowsOf(workingThreads, "working")); } if (snoozedThreads.length > 0) { items.push({ kind: "marker", marker: "snoozed-header" }); @@ -3529,8 +3532,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ]; for (const item of sidebarListItems) { if (item.kind === "thread") { - // Folders draw active rows in their own place below. - if (projectFolders !== null && item.section === "active") continue; + // Folders draw active and working rows in their own place below. + if ( + projectFolders !== null && + (item.section === "active" || item.section === "working") + ) + continue; if (sectionLabels.has(item.key)) { const group = sectionLabels.get(item.key) ?? null; items.push(