From 629525fb52dc5d2f9a40958a876d4888d73f49a8 Mon Sep 17 00:00:00 2001 From: Rohan Mukherjee Date: Wed, 2 Sep 2026 16:38:21 +0530 Subject: [PATCH 1/2] feat(sidebar): group threads by environment --- .../src/features/home/HomeRouteScreen.tsx | 1 + apps/mobile/src/features/home/HomeScreen.tsx | 29 +- .../src/features/home/home-list-options.ts | 9 +- .../src/features/home/homeListItems.test.ts | 37 +++ .../mobile/src/features/home/homeListItems.ts | 24 +- .../src/features/home/homeThreadList.test.ts | 38 +++ .../src/features/home/homeThreadList.ts | 54 +++- .../layout/AdaptiveWorkspaceLayout.tsx | 8 +- .../SettingsProjectGroupingRouteScreen.tsx | 14 + .../threads/ThreadNavigationSidebar.tsx | 27 +- .../src/persistence/mobile-preferences.ts | 5 + apps/web/src/components/LegacySidebar.tsx | 145 ++++++---- apps/web/src/components/Sidebar.tsx | 249 ++++++++++++++---- .../components/settings/SettingsPanels.tsx | 38 +++ .../src/components/settings/settingsSearch.ts | 6 + apps/web/src/environmentGrouping.test.ts | 27 ++ apps/web/src/sidebarProjectGrouping.ts | 48 ++++ docs/user/thread-sidebar.md | 14 + packages/contracts/src/settings.test.ts | 13 + packages/contracts/src/settings.ts | 4 + 20 files changed, 677 insertions(+), 113 deletions(-) diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 943303202216..719215e1e105 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -228,6 +228,7 @@ export function HomeRouteScreen() { onThreadSortOrderChange={setThreadSortOrder} pendingTasks={pendingTasks} projectGroupingMode={listOptions.projectGroupingMode} + environmentGroupingEnabled={listOptions.environmentGroupingEnabled} projects={projects} projectSortOrder={listOptions.projectSortOrder} savedConnectionsById={savedConnectionsById} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 34f4f4057a5d..9da5afc5d6ae 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -91,6 +91,7 @@ interface HomeScreenProps { readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly projectGroupingMode: SidebarProjectGroupingMode; + readonly environmentGroupingEnabled: boolean; readonly onSearchQueryChange: (query: string) => void; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; readonly onProjectChange: (projectKey: string | null) => void; @@ -378,9 +379,12 @@ export function HomeScreen(props: HomeScreenProps) { projectSortOrder: props.projectSortOrder, threadSortOrder: props.threadSortOrder, projectGroupingMode: props.projectGroupingMode, + environmentGroupingEnabled: + props.environmentGroupingEnabled && props.selectedEnvironmentId === null, }), [ props.projectGroupingMode, + props.environmentGroupingEnabled, props.projectSortOrder, props.searchQuery, props.selectedEnvironmentId, @@ -399,8 +403,24 @@ export function HomeScreen(props: HomeScreenProps) { groups: projectGroups, displayStates: effectiveGroupDisplayStates, showAllThreads: hasSearchQuery, + environmentLabelById: + props.environmentGroupingEnabled && props.selectedEnvironmentId === null + ? new Map( + props.environments.map((environment) => [ + String(environment.environmentId), + environment.label, + ]), + ) + : undefined, }), - [projectGroups, effectiveGroupDisplayStates, hasSearchQuery], + [ + effectiveGroupDisplayStates, + hasSearchQuery, + projectGroups, + props.environmentGroupingEnabled, + props.environments, + props.selectedEnvironmentId, + ], ); const projectCwdByKey = useMemo(() => { @@ -910,6 +930,13 @@ export function HomeScreen(props: HomeScreenProps) { const renderItem = useCallback( ({ item }: LegendListRenderItemProps) => { switch (item.type) { + case "environment-header": + return ( + + {item.label} + + + ); case "header": return ( >; readonly projectGroupingMode: SidebarProjectGroupingMode; + readonly environmentGroupingEnabled: boolean; } const HomeListOptionsContext = createContext(null); @@ -70,13 +72,15 @@ const HomeListOptionsContext = createContext export function HomeListOptionsProvider({ children, projectGroupingMode, + environmentGroupingEnabled, }: PropsWithChildren<{ readonly projectGroupingMode: SidebarProjectGroupingMode; + readonly environmentGroupingEnabled: boolean; }>) { const [options, setOptions] = useState(defaultHomeListOptions); const value = useMemo( - () => ({ options, setOptions, projectGroupingMode }), - [options, projectGroupingMode], + () => ({ options, setOptions, projectGroupingMode, environmentGroupingEnabled }), + [environmentGroupingEnabled, options, projectGroupingMode], ); return createElement(HomeListOptionsContext, { value }, children); } @@ -115,6 +119,7 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet { diff --git a/apps/mobile/src/features/home/homeListItems.test.ts b/apps/mobile/src/features/home/homeListItems.test.ts index c5a9f2c6bbcb..abe479e86acb 100644 --- a/apps/mobile/src/features/home/homeListItems.test.ts +++ b/apps/mobile/src/features/home/homeListItems.test.ts @@ -87,6 +87,43 @@ function displayStates( } describe("buildHomeListLayout", () => { + it("adds an environment header before each environment's project groups", () => { + const local = makeGroup("local", 1); + const remoteEnvironmentId = EnvironmentId.make("environment-2"); + const remoteProject = { + ...makeProject("remote", "remote"), + environmentId: remoteEnvironmentId, + }; + const remote = { + ...makeGroup("remote", 1), + representative: remoteProject, + projects: [remoteProject], + newThreadTarget: remoteProject, + }; + + const layout = buildHomeListLayout({ + groups: [local, remote], + displayStates: displayStates({}), + environmentLabelById: new Map([ + [String(environmentId), "MacBook Pro"], + [String(remoteEnvironmentId), "Linux VM"], + ]), + }); + + expect(layout.items.filter((item) => item.type === "environment-header")).toEqual([ + { + type: "environment-header", + key: `environment-header:${environmentId}`, + label: "MacBook Pro", + }, + { + type: "environment-header", + key: `environment-header:${remoteEnvironmentId}`, + label: "Linux VM", + }, + ]); + }); + it("renders a header plus all threads for a small group without a show-more row", () => { const layout = buildHomeListLayout({ groups: [makeGroup("alpha", 3)], diff --git a/apps/mobile/src/features/home/homeListItems.ts b/apps/mobile/src/features/home/homeListItems.ts index eb3f2a5de199..971fdd30aae5 100644 --- a/apps/mobile/src/features/home/homeListItems.ts +++ b/apps/mobile/src/features/home/homeListItems.ts @@ -27,6 +27,12 @@ export interface HomeHeaderListItem { readonly isFirst: boolean; } +export interface HomeEnvironmentHeaderListItem { + readonly type: "environment-header"; + readonly key: string; + readonly label: string; +} + export interface HomeThreadListItem { readonly type: "thread"; readonly key: string; @@ -52,6 +58,7 @@ export interface HomeShowMoreListItem { } export type HomeListItem = + | HomeEnvironmentHeaderListItem | HomeHeaderListItem | HomePendingTaskListItem | HomeThreadListItem @@ -87,6 +94,8 @@ export function nextGroupDisplayState( */ export function homeListItemsAreEqual(previous: HomeListItem, item: HomeListItem): boolean { switch (item.type) { + case "environment-header": + return previous.type === "environment-header" && previous.label === item.label; case "header": return ( previous.type === "header" && @@ -123,11 +132,24 @@ export function buildHomeListLayout(input: { * When searching, pagination is suspended so every match stays visible. */ readonly showAllThreads?: boolean; + readonly environmentLabelById?: ReadonlyMap; }): HomeListLayout { const items: HomeListItem[] = []; const stickyHeaderIndices: number[] = []; + let previousEnvironmentId: string | null = null; for (const [groupIndex, group] of input.groups.entries()) { + const environmentId = String(group.representative.environmentId); + const environmentLabel = input.environmentLabelById?.get(environmentId); + const startsEnvironment = Boolean(environmentLabel && environmentId !== previousEnvironmentId); + if (environmentLabel && startsEnvironment) { + items.push({ + type: "environment-header", + key: `environment-header:${environmentId}`, + label: environmentLabel, + }); + previousEnvironmentId = environmentId; + } const display = input.displayStates.get(group.key) ?? DEFAULT_GROUP_DISPLAY_STATE; const collapsed = display.collapsed && input.showAllThreads !== true; @@ -137,7 +159,7 @@ export function buildHomeListLayout(input: { key: `header:${group.key}`, group, collapsed, - isFirst: groupIndex === 0, + isFirst: groupIndex === 0 || startsEnvironment, }); if (collapsed) { diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index 60d3ab2c867a..3aa8ed4aa08e 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -115,6 +115,44 @@ describe("buildHomeThreadGroups", () => { ); }); + it("keeps matching repositories in separate environment sections", () => { + const localEnvironmentId = EnvironmentId.make("environment-local"); + const remoteEnvironmentId = EnvironmentId.make("environment-remote"); + const repositoryIdentity = { + canonicalKey: "github.com/pingdotgg/t3code", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:pingdotgg/t3code.git", + }, + }; + const projects = [ + makeProject({ + environmentId: localEnvironmentId, + id: ProjectId.make("project-local"), + title: "t3code", + repositoryIdentity, + }), + makeProject({ + environmentId: remoteEnvironmentId, + id: ProjectId.make("project-remote"), + title: "t3code", + repositoryIdentity, + }), + ]; + + const scopes = buildHomeProjectScopes({ + projects, + environmentId: null, + projectGroupingMode: "repository", + environmentGroupingEnabled: true, + }); + + expect(scopes).toHaveLength(2); + expect(scopes.map((scope) => scope.projects)).toEqual([[projects[0]], [projects[1]]]); + expect(new Set(scopes.map((scope) => scope.key)).size).toBe(2); + }); + it("routes stale duplicate project refs through the canonical repository group", () => { const localEnvironmentId = EnvironmentId.make("environment-local"); const remoteEnvironmentId = EnvironmentId.make("environment-remote"); diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index 2a9e0ec2cb86..48efdef5f26f 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -52,25 +52,40 @@ export function buildHomeProjectScopes(input: { readonly projects: ReadonlyArray; readonly environmentId: EnvironmentId | null; readonly projectGroupingMode: SidebarProjectGroupingMode; + readonly environmentGroupingEnabled?: boolean; }): ReadonlyArray { const projects = input.projects.filter( (project) => input.environmentId === null || project.environmentId === input.environmentId, ); - return buildProjectGroups({ - projects, - settings: { - sidebarProjectGroupingMode: input.projectGroupingMode, - sidebarProjectGroupingOverrides: {}, - }, - }).map((group) => { - return { - key: group.key, + const projectsByEnvironment = new Map(); + if (input.environmentGroupingEnabled) { + for (const project of projects) { + const environmentProjects = projectsByEnvironment.get(project.environmentId); + if (environmentProjects) { + environmentProjects.push(project); + } else { + projectsByEnvironment.set(project.environmentId, [project]); + } + } + } + const projectSets = input.environmentGroupingEnabled + ? [...projectsByEnvironment] + : ([[null, projects]] as const); + return projectSets.flatMap(([environmentId, environmentProjects]) => + buildProjectGroups({ + projects: environmentProjects, + settings: { + sidebarProjectGroupingMode: input.projectGroupingMode, + sidebarProjectGroupingOverrides: {}, + }, + }).map((group) => ({ + key: environmentId === null ? group.key : JSON.stringify([environmentId, group.key]), title: group.label, representative: group.representative, projects: group.members.map((member) => member.project), projectRefs: group.memberProjectRefs, - }; - }); + })), + ); } export function sortHomeProjectScopes(input: { @@ -210,6 +225,7 @@ export function buildHomeThreadGroups(input: { readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly projectGroupingMode: SidebarProjectGroupingMode; + readonly environmentGroupingEnabled?: boolean; /** Current time used for the recency window; defaults to now. Injectable for tests. */ readonly now?: number; }): ReadonlyArray { @@ -369,7 +385,7 @@ export function buildHomeThreadGroups(input: { }); } - return Arr.sort( + const sortedGroups = Arr.sort( result, Order.mapInput( Order.Struct({ @@ -384,4 +400,18 @@ export function buildHomeThreadGroups(input: { }), ), ); + if (!input.environmentGroupingEnabled) { + return sortedGroups; + } + + const groupsByEnvironment = new Map(); + for (const group of sortedGroups) { + const environmentGroups = groupsByEnvironment.get(group.representative.environmentId); + if (environmentGroups) { + environmentGroups.push(group); + } else { + groupsByEnvironment.set(group.representative.environmentId, [group]); + } + } + return [...groupsByEnvironment.values()].flat(); } diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 9ea8eb88d42a..dac3bd192add 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -198,6 +198,7 @@ export function AdaptiveWorkspaceLayout(props: { ) : null; } @@ -206,6 +207,7 @@ export function AdaptiveWorkspaceLayout(props: { ); } @@ -216,6 +218,7 @@ function AdaptiveWorkspaceLayoutContent( readonly pathname: string; } & { readonly projectGroupingMode: SidebarProjectGroupingMode; + readonly environmentGroupingEnabled: boolean; }, ) { const projectGroupingMode = props.projectGroupingMode; @@ -519,7 +522,10 @@ function AdaptiveWorkspaceLayoutContent( ); return ( - + {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? ( diff --git a/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx index 951168fefcf6..6a44aabc7dbf 100644 --- a/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx @@ -15,6 +15,7 @@ import { } from "../../state/project-grouping"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { SettingsSection } from "./components/SettingsSection"; +import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; const GROUPING_OPTIONS: ReadonlyArray<{ readonly mode: SidebarProjectGroupingMode; @@ -47,6 +48,9 @@ export function SettingsProjectGroupingRouteScreen() { const selectedMode = AsyncResult.isSuccess(preferencesResult) ? resolveMobileProjectGroupingSettings(preferencesResult.value).sidebarProjectGroupingMode : null; + const environmentGroupingEnabled = AsyncResult.isSuccess(preferencesResult) + ? (preferencesResult.value.environmentGroupingEnabled ?? false) + : false; return ( @@ -63,6 +67,16 @@ export function SettingsProjectGroupingRouteScreen() { contentContainerClassName="gap-3 px-5 pt-4" contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} > + + savePreferences({ environmentGroupingEnabled: value })} + /> + {GROUPING_OPTIONS.map((option, index) => ( [ + String(environment.environmentId), + environment.label, + ]), + ) + : undefined, }), - [groups, groupDisplayStates, hasSearchQuery], + [ + environments, + groupDisplayStates, + groups, + hasSearchQuery, + options.environmentGroupingEnabled, + options.selectedEnvironmentId, + ], ); const projectCwdByKey = useMemo(() => { const map = new Map(); @@ -803,6 +821,13 @@ function ThreadNavigationSidebarPane( const renderListItem = useCallback( ({ item }: { readonly item: SidebarListItem }) => { switch (item.type) { + case "environment-header": + return ( + + {item.label} + + + ); case "v2-pending": { const pendingScopeKey = scopedProjectKey( item.pendingTask.message.environmentId, diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index cf4c29c6041c..5bbbe8fcc6dd 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -31,6 +31,7 @@ export interface Preferences { /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */ readonly projectGroupingEnabled?: boolean; readonly projectGroupingMode?: SidebarProjectGroupingMode; + readonly environmentGroupingEnabled?: boolean; /** * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has * no client-settings sync, so the legacy grouped thread list is opted into @@ -100,6 +101,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { collapsedProjectGroups?: readonly string[]; projectGroupingEnabled?: boolean; projectGroupingMode?: SidebarProjectGroupingMode; + environmentGroupingEnabled?: boolean; legacyThreadListEnabled?: boolean; planModeEnabled?: boolean; threadListV2SettledShelfExpanded?: boolean; @@ -165,6 +167,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { ) { preferences.projectGroupingMode = parsed.projectGroupingMode; } + if (typeof parsed.environmentGroupingEnabled === "boolean") { + preferences.environmentGroupingEnabled = parsed.environmentGroupingEnabled; + } if (typeof parsed.legacyThreadListEnabled === "boolean") { preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled; } diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 6d48cf6538b6..098f1e1eef40 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -205,8 +205,7 @@ import { } from "../logicalProject"; import type { SidebarThreadSummary } from "../types"; import { - buildPhysicalToLogicalProjectKeyMap, - buildSidebarProjectSnapshots, + buildSidebarProjectGrouping, type SidebarProjectGroupMember, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; @@ -2650,6 +2649,7 @@ type SortableProjectHandleProps = Pick< function ProjectSortMenu({ projectSortOrder, + environmentGroupingEnabled, threadSortOrder, threadPreviewCount, onProjectSortOrderChange, @@ -2657,6 +2657,7 @@ function ProjectSortMenu({ onThreadPreviewCountChange, }: { projectSortOrder: SidebarProjectSortOrder; + environmentGroupingEnabled: boolean; threadSortOrder: SidebarThreadSortOrder; threadPreviewCount: SidebarThreadPreviewCount; onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; @@ -2702,8 +2703,15 @@ function ProjectSortMenu({ > {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map( ([value, label]) => ( - - {label} + + {environmentGroupingEnabled && value === "manual" + ? "Manual (unavailable with environment grouping)" + : label} ), )} @@ -2828,6 +2836,8 @@ interface SidebarProjectsContentProps { archiveThread: ReturnType["archiveThread"]; deleteThread: ReturnType["deleteThread"]; sortedProjects: readonly SidebarProjectSnapshot[]; + environmentGroupingEnabled: boolean; + environmentLabelById: ReadonlyMap; expandedThreadListsByProject: ReadonlySet; activeRouteProjectKey: string | null; routeThreadKey: string | null; @@ -2870,6 +2880,8 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( archiveThread, deleteThread, sortedProjects, + environmentGroupingEnabled, + environmentLabelById, expandedThreadListsByProject, activeRouteProjectKey, routeThreadKey, @@ -2905,6 +2917,26 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( }, [updateSettings], ); + const projectSections = useMemo(() => { + if (!environmentGroupingEnabled) { + return [{ environmentId: null, label: null, projects: sortedProjects }] as const; + } + + const projectsByEnvironment = new Map(); + for (const project of sortedProjects) { + const projects = projectsByEnvironment.get(project.environmentId); + if (projects) { + projects.push(project); + } else { + projectsByEnvironment.set(project.environmentId, [project]); + } + } + return [...projectsByEnvironment].map(([environmentId, projects]) => ({ + environmentId, + label: environmentLabelById.get(environmentId) ?? "Unknown environment", + projects, + })); + }, [environmentGroupingEnabled, environmentLabelById, sortedProjects]); return ( - {isManualProjectSorting ? ( + {isManualProjectSorting && !environmentGroupingEnabled ? ( ) : ( - {sortedProjects.map((project) => ( - - ))} + {projectSections.flatMap((section) => [ + section.label ? ( +
  • + + {section.label} + + +
  • + ) : null, + ...section.projects.map((project) => ( + + )), + ])}
    )} @@ -3085,6 +3131,9 @@ export default function LegacySidebar() { const sidebarThreadSortOrder = useClientSettings((s) => s.sidebarThreadSortOrder); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const sidebarEnvironmentGroupingEnabled = useClientSettings( + (s) => s.sidebarEnvironmentGroupingEnabled, + ); const sidebarThreadPreviewCount = useClientSettings((s) => s.sidebarThreadPreviewCount); const updateSettings = useUpdateClientSettings(); const handleNewThread = useNewThreadHandler(); @@ -3159,13 +3208,24 @@ export default function LegacySidebar() { // Build a mapping from physical project key → logical project key for // cross-environment grouping. Projects that share a repositoryIdentity // canonicalKey are treated as one logical project in the sidebar. - const physicalToLogicalKey = useMemo(() => { - return buildPhysicalToLogicalProjectKeyMap({ + const sidebarProjectGrouping = useMemo(() => { + return buildSidebarProjectGrouping({ projects: orderedProjects, settings: projectGroupingSettings, primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, + isDesktopLocalEnvironment: (environmentId) => desktopLocalEnvironmentIds.has(environmentId), + groupByEnvironment: sidebarEnvironmentGroupingEnabled, }); - }, [orderedProjects, projectGroupingSettings, primaryEnvironmentId]); + }, [ + desktopLocalEnvironmentIds, + environmentLabelById, + orderedProjects, + primaryEnvironmentId, + projectGroupingSettings, + sidebarEnvironmentGroupingEnabled, + ]); + const physicalToLogicalKey = sidebarProjectGrouping.physicalToLogicalKey; const projectPhysicalKeyByScopedRef = useMemo( () => new Map( @@ -3177,21 +3237,7 @@ export default function LegacySidebar() { [orderedProjects], ); - const sidebarProjects = useMemo(() => { - return buildSidebarProjectSnapshots({ - projects: orderedProjects, - settings: projectGroupingSettings, - primaryEnvironmentId, - resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, - isDesktopLocalEnvironment: (environmentId) => desktopLocalEnvironmentIds.has(environmentId), - }); - }, [ - environmentLabelById, - desktopLocalEnvironmentIds, - orderedProjects, - projectGroupingSettings, - primaryEnvironmentId, - ]); + const sidebarProjects = sidebarProjectGrouping.projects; const sidebarProjectByKey = useMemo( () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), @@ -3383,7 +3429,8 @@ export default function LegacySidebar() { sidebarProjects, visibleThreads, ]); - const isManualProjectSorting = sidebarProjectSortOrder === "manual"; + const isManualProjectSorting = + sidebarProjectSortOrder === "manual" && !sidebarEnvironmentGroupingEnabled; const visibleSidebarThreadKeys = useMemo( () => sortedProjects.flatMap((project) => { @@ -3732,6 +3779,8 @@ export default function LegacySidebar() { archiveThread={archiveThread} deleteThread={deleteThread} sortedProjects={sortedProjects} + environmentGroupingEnabled={sidebarEnvironmentGroupingEnabled} + environmentLabelById={environmentLabelById} expandedThreadListsByProject={expandedThreadListsByProject} activeRouteProjectKey={activeRouteProjectKey} routeThreadKey={routeThreadKey} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 98f2f997875b..137a3242e4c1 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -28,7 +28,7 @@ import { scopeThreadRef, scopedThreadKey, } from "@t3tools/client-runtime/environment"; -import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import type { TimestampFormat } from "@t3tools/contracts/settings"; import { AlarmClockIcon, @@ -1630,6 +1630,42 @@ function latestTurnDiff( return null; } +interface SidebarEnvironmentThreadSection { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly threads: ReadonlyArray; +} + +function groupSidebarThreadsByEnvironment(input: { + readonly threads: ReadonlyArray; + readonly environmentIds: ReadonlyArray; + readonly environmentLabelById: ReadonlyMap; +}): ReadonlyArray { + const threadsByEnvironment = new Map(); + for (const environmentId of input.environmentIds) { + threadsByEnvironment.set(environmentId, []); + } + for (const thread of input.threads) { + const environmentThreads = threadsByEnvironment.get(thread.environmentId); + if (environmentThreads) { + environmentThreads.push(thread); + } else { + threadsByEnvironment.set(thread.environmentId, [thread]); + } + } + return [...threadsByEnvironment].flatMap(([environmentId, threads]) => + threads.length === 0 + ? [] + : [ + { + environmentId, + label: input.environmentLabelById.get(environmentId) ?? "Unknown environment", + threads, + }, + ], + ); +} + const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { thread: SidebarThreadSummary; projectCwd: string | null; @@ -1754,6 +1790,9 @@ export default function Sidebar() { const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); + const sidebarEnvironmentGroupingEnabled = useClientSettings( + (s) => s.sidebarEnvironmentGroupingEnabled, + ); const timestampFormat = useClientSettings((s) => s.timestampFormat); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const { @@ -2283,9 +2322,68 @@ export default function Sidebar() { return routeThread === undefined ? [] : [routeThread]; }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]); + const environmentIds = useMemo( + () => environments.map((environment) => environment.environmentId), + [environments], + ); + const activeThreadSections = useMemo( + () => + groupSidebarThreadsByEnvironment({ + threads: activeThreads, + environmentIds, + environmentLabelById, + }), + [activeThreads, environmentIds, environmentLabelById], + ); + const pinnedCanonicalThreadSections = useMemo( + () => + groupSidebarThreadsByEnvironment({ + threads: pinnedThreads, + environmentIds, + environmentLabelById, + }), + [environmentIds, environmentLabelById, pinnedThreads], + ); + const snoozedThreadSections = useMemo( + () => + groupSidebarThreadsByEnvironment({ + threads: visibleSnoozedThreads, + environmentIds, + environmentLabelById, + }), + [environmentIds, environmentLabelById, visibleSnoozedThreads], + ); + const settledThreadSections = useMemo( + () => + groupSidebarThreadsByEnvironment({ + threads: renderedSettledThreads, + environmentIds, + environmentLabelById, + }), + [environmentIds, environmentLabelById, renderedSettledThreads], + ); + const orderedThreads = useMemo( - () => [...pinnedThreads, ...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads], - [pinnedThreads, activeThreads, visibleSnoozedThreads, renderedSettledThreads], + () => + sidebarEnvironmentGroupingEnabled + ? [ + ...pinnedCanonicalThreadSections.flatMap((section) => section.threads), + ...activeThreadSections.flatMap((section) => section.threads), + ...snoozedThreadSections.flatMap((section) => section.threads), + ...settledThreadSections.flatMap((section) => section.threads), + ] + : [...pinnedThreads, ...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads], + [ + activeThreads, + activeThreadSections, + pinnedThreads, + pinnedCanonicalThreadSections, + renderedSettledThreads, + settledThreadSections, + sidebarEnvironmentGroupingEnabled, + snoozedThreadSections, + visibleSnoozedThreads, + ], ); const orderedThreadKeys = useMemo( () => @@ -2636,6 +2734,15 @@ export default function Sidebar() { getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), }); }, [optimisticPinnedOrder, pinnedThreads]); + const pinnedThreadSections = useMemo( + () => + groupSidebarThreadsByEnvironment({ + threads: orderedPinnedThreads, + environmentIds, + environmentLabelById, + }), + [environmentIds, environmentLabelById, orderedPinnedThreads], + ); useEffect(() => { if (optimisticPinnedOrder === null) return; const canonical = pinnedThreads.filter((thread) => @@ -3853,6 +3960,72 @@ export default function Sidebar() { /> ); }; + const pushThreadSections = ( + items: ReactNode[], + threads: ReadonlyArray, + sections: ReadonlyArray, + section: "active" | "snoozed" | "settled", + ) => { + if (!sidebarEnvironmentGroupingEnabled) { + for (const thread of threads) items.push(renderThreadRow(thread, section)); + return; + } + for (const environment of sections) { + items.push( +
  • + + {environment.label} + + +
  • , + ); + for (const thread of environment.threads) { + items.push(renderThreadRow(thread, section)); + } + } + }; + const renderPinnedRows = ( + sectionThreads: ReadonlyArray, + ) => ( + + + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ) + .filter((threadKey) => reorderablePinnedKeys.has(threadKey))} + strategy={verticalListSortingStrategy} + > +
      + {sectionThreads.map((thread) => { + const threadKey = scopedThreadKey( + scopeThreadRef(thread.environmentId, thread.id), + ); + if (!reorderablePinnedKeys.has(threadKey)) { + return renderThreadRow(thread, "pinned"); + } + return ( + + {(bag) => renderThreadRow(thread, "pinned", bag)} + + ); + })} +
    +
    +
    + ); // Draft block above everything, then the pinned block: // full cards above the inbox, closed by a thin divider (the // pin glyphs carry the meaning, so no header text). Both @@ -3872,41 +4045,19 @@ export default function Sidebar() { />, pinnedThreads.length > 0 ? (
  • - - - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ) - .filter((threadKey) => reorderablePinnedKeys.has(threadKey))} - strategy={verticalListSortingStrategy} - > -
      - {orderedPinnedThreads.map((thread) => { - const threadKey = scopedThreadKey( - scopeThreadRef(thread.environmentId, thread.id), - ); - if (!reorderablePinnedKeys.has(threadKey)) { - return renderThreadRow(thread, "pinned"); - } - return ( - - {(bag) => renderThreadRow(thread, "pinned", bag)} - - ); - })} -
    -
    -
    + {sidebarEnvironmentGroupingEnabled + ? pinnedThreadSections.map((environment) => ( +
    +
    + + {environment.label} + + +
    + {renderPinnedRows(environment.threads)} +
    + )) + : renderPinnedRows(orderedPinnedThreads)}
  • ) : null, ]; @@ -3920,9 +4071,7 @@ export default function Sidebar() { />, ); } - for (const thread of activeThreads) { - items.push(renderThreadRow(thread, "active")); - } + pushThreadSections(items, activeThreads, activeThreadSections, "active"); // Snoozed shelf: between the inbox and Settled — out of the // way, never gone. The header always renders while anything // is snoozed (the count is the whole footprint when @@ -3958,9 +4107,12 @@ export default function Sidebar() { , ); - for (const thread of visibleSnoozedThreads) { - items.push(renderThreadRow(thread, "snoozed")); - } + pushThreadSections( + items, + visibleSnoozedThreads, + snoozedThreadSections, + "snoozed", + ); } if (settledThreads.length > 0) { items.push( @@ -3993,9 +4145,12 @@ export default function Sidebar() { , ); } - for (const thread of renderedSettledThreads) { - items.push(renderThreadRow(thread, "settled")); - } + pushThreadSections( + items, + renderedSettledThreads, + settledThreadSections, + "settled", + ); return items; })()} {settledShelfExpanded && hiddenSettledCount > 0 ? ( diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 8137114dcd93..f158a2eded74 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -507,6 +507,10 @@ export function useSettingsRestore(onRestored?: () => void) { DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode ? ["Project Grouping"] : []), + ...(settings.sidebarEnvironmentGroupingEnabled !== + DEFAULT_UNIFIED_SETTINGS.sidebarEnvironmentGroupingEnabled + ? ["Environment grouping"] + : []), ...(settings.sidebarAutoSettleAfterDays !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ? ["Auto-settle inactive threads"] @@ -599,6 +603,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.sidebarAutoSettleAfterDays, settings.sidebarAutoSettleOnMerge, settings.sidebarProjectGroupingMode, + settings.sidebarEnvironmentGroupingEnabled, settings.sidebarThreadPreviewCount, settings.showSkillsInSlashMenu, settings.timestampFormat, @@ -682,6 +687,7 @@ export function useSettingsRestore(onRestored?: () => void) { glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, + sidebarEnvironmentGroupingEnabled: DEFAULT_UNIFIED_SETTINGS.sidebarEnvironmentGroupingEnabled, sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming, @@ -1993,6 +1999,38 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + sidebarEnvironmentGroupingEnabled: + DEFAULT_UNIFIED_SETTINGS.sidebarEnvironmentGroupingEnabled, + }) + } + /> + ) : null + } + control={ + + updateSettings({ sidebarEnvironmentGroupingEnabled: Boolean(checked) }) + } + aria-label="Environment grouping" + /> + } + /> + {supportsAutoSettlement ? ( <> { expect(projectGroupCount).toBe(1); }); + it("keeps matching repositories in separate environment sections", () => { + const primary = makeProject({ repositoryIdentity }); + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, + repositoryIdentity, + }); + + const result = buildSidebarProjectGrouping({ + projects: [primary, remote], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => + environmentId === primaryEnvironmentId ? "Primary" : "Remote", + groupByEnvironment: true, + }); + + expect(result.projects).toHaveLength(2); + expect(new Set(result.projects.map((project) => project.projectKey)).size).toBe(2); + expect( + result.projects.map((project) => + project.memberProjects.map((member) => [member.environmentId, member.id]), + ), + ).toEqual([[[primary.environmentId, primary.id]], [[remote.environmentId, remote.id]]]); + }); + it("keeps projects without repository identity physically scoped", () => { const primary = makeProject(); const remote = makeProject({ diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 8cf3c5665aca..9262ace9f65c 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -31,6 +31,54 @@ export interface SidebarProjectPickerEntry { isPreferred: boolean; } +export interface SidebarProjectGroupingResult { + readonly projects: ReadonlyArray; + readonly physicalToLogicalKey: ReadonlyMap; +} + +export function buildSidebarProjectGrouping(input: { + projects: ReadonlyArray; + settings: ProjectGroupingSettings; + primaryEnvironmentId: EnvironmentId | null; + resolveEnvironmentLabel: (environmentId: EnvironmentId) => string | null; + isDesktopLocalEnvironment?: (environmentId: EnvironmentId) => boolean; + groupByEnvironment: boolean; +}): SidebarProjectGroupingResult { + if (!input.groupByEnvironment) { + return { + projects: buildSidebarProjectSnapshots(input), + physicalToLogicalKey: buildPhysicalToLogicalProjectKeyMap(input), + }; + } + + const projectsByEnvironment = new Map(); + for (const project of input.projects) { + const projects = projectsByEnvironment.get(project.environmentId); + if (projects) { + projects.push(project); + } else { + projectsByEnvironment.set(project.environmentId, [project]); + } + } + + const projects: SidebarProjectSnapshot[] = []; + const physicalToLogicalKey = new Map(); + for (const [environmentId, environmentProjects] of projectsByEnvironment) { + for (const project of buildSidebarProjectSnapshots({ + ...input, + projects: environmentProjects, + })) { + const projectKey = JSON.stringify([environmentId, project.projectKey]); + projects.push({ ...project, projectKey }); + for (const member of project.memberProjects) { + physicalToLogicalKey.set(member.physicalProjectKey, projectKey); + } + } + } + + return { projects, physicalToLogicalKey }; +} + export function buildPhysicalToLogicalProjectKeyMap(input: { projects: ReadonlyArray; settings: ProjectGroupingSettings; diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index be2c1559b1ac..8553fb3ff0a4 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -1,5 +1,19 @@ # Organizing threads +## Grouping by environment + +On web and desktop, enable **Settings > General > Environment grouping** to section the sidebar by +the computer or VM where each thread runs. The default sidebar adds environment headings within its +pinned, active, snoozed, and settled areas. When the legacy per-project sidebar is enabled, the +hierarchy is environment, then project, then thread. Matching repositories are still grouped within +each environment, but never combined across environment sections. + +On mobile, use **Settings > Project Grouping > Group by environment**. Environment sections apply to +the legacy thread list. + +This preference is stored on the current client. Web browser profiles, desktop installations, and +mobile devices can each use a different grouping choice. + Pin a thread from its context menu to keep it in the pinned section above your active work. `mod+shift+p` pins or unpins the thread you have open. Pinned threads are shown independently of their project, including when you connect to more than one environment. diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 43781429fa04..da58523dc005 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -165,6 +165,7 @@ describe("ClientSettings environment identification", () => { describe("ClientSettings sidebar", () => { it("defaults to the current sidebar", () => { expect(decodeClientSettings({}).legacySidebarEnabled).toBe(false); + expect(decodeClientSettings({}).sidebarEnvironmentGroupingEnabled).toBe(false); }); it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => { @@ -184,6 +185,18 @@ describe("ClientSettings sidebar", () => { ); }); + it("preserves an explicit environment grouping preference", () => { + expect( + decodeClientSettings({ sidebarEnvironmentGroupingEnabled: true }) + .sidebarEnvironmentGroupingEnabled, + ).toBe(true); + expect( + decodeClientSettingsPatch({ sidebarEnvironmentGroupingEnabled: true }) + .sidebarEnvironmentGroupingEnabled, + ).toBe(true); + expect(() => decodeClientSettingsPatch({ sidebarEnvironmentGroupingEnabled: "yes" })).toThrow(); + }); + it("keeps unpin confirmation opt-in and patchable", () => { expect(decodeClientSettings({}).confirmThreadUnpin).toBe(false); expect(decodeClientSettingsPatch({ confirmThreadUnpin: true }).confirmThreadUnpin).toBe(true); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 1b6e8949e32b..c983def9a010 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -277,6 +277,9 @@ export const ClientSettingsSchema = Schema.Struct({ // old keys, so everyone, including prior beta opt-outs, resets to the new // default sidebar. legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + sidebarEnvironmentGroupingEnabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), ), @@ -1000,6 +1003,7 @@ export const ClientSettingsPatch = Schema.Struct({ contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean), showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), + sidebarEnvironmentGroupingEnabled: Schema.optionalKey(Schema.Boolean), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), From a1814284551d9858a96d82cd6f60cb74cff50667 Mon Sep 17 00:00:00 2001 From: Rohan Mukherjee Date: Wed, 2 Sep 2026 17:14:43 +0530 Subject: [PATCH 2/2] fix(sidebar): scope environment grouping interactions --- .../src/features/home/HomeRouteScreen.tsx | 20 ++++- apps/mobile/src/features/home/HomeScreen.tsx | 9 ++- .../threads/ThreadNavigationSidebar.tsx | 21 +++++- apps/web/src/components/LegacySidebar.tsx | 34 +++++---- apps/web/src/components/Sidebar.logic.test.ts | 41 ++++++++++ apps/web/src/components/Sidebar.logic.ts | 20 +++++ apps/web/src/components/Sidebar.tsx | 75 +++++++++++++------ .../sidebar/SidebarSectionHeading.tsx | 17 +++++ apps/web/src/environmentGrouping.test.ts | 4 + apps/web/src/sidebarProjectGrouping.ts | 2 + 10 files changed, 201 insertions(+), 42 deletions(-) create mode 100644 apps/web/src/components/sidebar/SidebarSectionHeading.tsx diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 719215e1e105..f0931bdfe3fb 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -83,17 +83,33 @@ export function HomeRouteScreen() { } = useHomeListOptions(availableEnvironmentIds); const selectedEnvironmentId = listOptions.selectedEnvironmentId; const [selectedProjectKey, setSelectedProjectKey] = useState(null); + const environmentLabelById = useMemo( + () => + new Map(environments.map((environment) => [environment.environmentId, environment.label])), + [environments], + ); const projectFilterOptions = useMemo( () => buildHomeProjectScopes({ projects, environmentId: selectedEnvironmentId, projectGroupingMode: listOptions.projectGroupingMode, + environmentGroupingEnabled: + listOptions.environmentGroupingEnabled && selectedEnvironmentId === null, }).map((scope) => ({ key: scope.key, - label: scope.title, + label: + listOptions.environmentGroupingEnabled && selectedEnvironmentId === null + ? `${scope.title} · ${environmentLabelById.get(scope.representative.environmentId) ?? "Unknown environment"}` + : scope.title, })), - [listOptions.projectGroupingMode, projects, selectedEnvironmentId], + [ + environmentLabelById, + listOptions.environmentGroupingEnabled, + listOptions.projectGroupingMode, + projects, + selectedEnvironmentId, + ], ); useEffect(() => { if ( diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 9da5afc5d6ae..80033130b197 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -308,8 +308,15 @@ export function HomeScreen(props: HomeScreenProps) { projects: props.projects, environmentId: props.selectedEnvironmentId, projectGroupingMode: props.projectGroupingMode, + environmentGroupingEnabled: + props.environmentGroupingEnabled && props.selectedEnvironmentId === null, }), - [props.projectGroupingMode, props.projects, props.selectedEnvironmentId], + [ + props.environmentGroupingEnabled, + props.projectGroupingMode, + props.projects, + props.selectedEnvironmentId, + ], ); const selectedProjectScope = useMemo( () => diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index f25fe264a942..aa88019f2685 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -215,16 +215,31 @@ function ThreadNavigationSidebarPane( projects, environmentId: options.selectedEnvironmentId, projectGroupingMode: options.projectGroupingMode, + environmentGroupingEnabled: + options.environmentGroupingEnabled && options.selectedEnvironmentId === null, }), - [options.projectGroupingMode, options.selectedEnvironmentId, projects], + [ + options.environmentGroupingEnabled, + options.projectGroupingMode, + options.selectedEnvironmentId, + projects, + ], ); const projectFilterOptions = useMemo( () => projectScopes.map((scope) => ({ key: scope.key, - label: scope.title, + label: + options.environmentGroupingEnabled && options.selectedEnvironmentId === null + ? `${scope.title} · ${environments.find((environment) => environment.environmentId === scope.representative.environmentId)?.label ?? "Unknown environment"}` + : scope.title, })), - [projectScopes], + [ + environments, + options.environmentGroupingEnabled, + options.selectedEnvironmentId, + projectScopes, + ], ); const projectTitleByProjectKey = useMemo( () => diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 098f1e1eef40..2c74ab027c0e 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -192,6 +192,7 @@ import { } from "./Sidebar.logic"; import { sortThreads } from "../lib/threadSort"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { SidebarSectionHeading } from "./sidebar/SidebarSectionHeading"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { CommandDialogTrigger } from "./ui/command"; @@ -2191,7 +2192,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (isMobile) setOpenMobile(false); void router.navigate({ to: "/projects/$projectKey", - params: { projectKey: project.projectKey }, + params: { projectKey: project.logicalProjectKey }, }); return; } @@ -2931,11 +2932,20 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( projectsByEnvironment.set(project.environmentId, [project]); } } - return [...projectsByEnvironment].map(([environmentId, projects]) => ({ - environmentId, - label: environmentLabelById.get(environmentId) ?? "Unknown environment", - projects, - })); + const environmentOrder = new Map( + [...environmentLabelById.keys()].map((environmentId, index) => [environmentId, index]), + ); + return [...projectsByEnvironment] + .sort( + ([leftId], [rightId]) => + (environmentOrder.get(leftId) ?? environmentOrder.size) - + (environmentOrder.get(rightId) ?? environmentOrder.size), + ) + .map(([environmentId, projects]) => ({ + environmentId, + label: environmentLabelById.get(environmentId) ?? "Unknown environment", + projects, + })); }, [environmentGroupingEnabled, environmentLabelById, sortedProjects]); return ( @@ -3075,15 +3085,11 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( {projectSections.flatMap((section) => [ section.label ? ( -
  • - - {section.label} - - -
  • + label={section.label} + className="mt-3 px-2 first:mt-0" + /> ) : null, ...section.projects.map((project) => ( { }); }); +describe("selectPinnedReorderSection", () => { + const threads = [ + { key: "local-a", environmentId: "local" }, + { key: "remote-a", environmentId: "remote" }, + { key: "local-b", environmentId: "local" }, + ]; + + it("keeps the reorder plan inside the active environment when grouping is enabled", () => { + expect( + selectPinnedReorderSection({ + threads, + activeKey: "local-b", + groupByEnvironment: true, + getKey: (thread) => thread.key, + }).map((thread) => thread.key), + ).toEqual(["local-a", "local-b"]); + }); + + it("keeps the global reorder plan when grouping is disabled", () => { + expect( + selectPinnedReorderSection({ + threads, + activeKey: "local-b", + groupByEnvironment: false, + getKey: (thread) => thread.key, + }), + ).toEqual(threads); + }); +}); + +describe("shouldApplyOptimisticPinnedOrder", () => { + it("rejects optimistic order from the previous grouping mode", () => { + expect(shouldApplyOptimisticPinnedOrder("local", true)).toBe(true); + expect(shouldApplyOptimisticPinnedOrder(null, false)).toBe(true); + expect(shouldApplyOptimisticPinnedOrder("local", false)).toBe(false); + expect(shouldApplyOptimisticPinnedOrder(null, true)).toBe(false); + }); +}); + describe("planPinnedReorder", () => { it("writes only the moved thread when neighbors are keyed", () => { const assignments = planPinnedReorder({ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index a4a2d57a8e96..fe0134bdc0e2 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -603,6 +603,26 @@ export function sortThreadsForSidebar< ); } +export function selectPinnedReorderSection(input: { + readonly threads: readonly T[]; + readonly activeKey: string; + readonly groupByEnvironment: boolean; + readonly getKey: (thread: T) => string; +}): readonly T[] { + if (!input.groupByEnvironment) return input.threads; + const activeThread = input.threads.find((thread) => input.getKey(thread) === input.activeKey); + return activeThread === undefined + ? [] + : input.threads.filter((thread) => thread.environmentId === activeThread.environmentId); +} + +export function shouldApplyOptimisticPinnedOrder( + environmentId: string | null, + groupByEnvironment: boolean, +): boolean { + return (environmentId !== null) === groupByEnvironment; +} + // Pinned-reorder key math and the keyed sort live in client-runtime // (state/thread-sort) so web and mobile compute identical pinned orders. export { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 137a3242e4c1..3d44b80676a2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -137,6 +137,8 @@ import { resolveSettledTimestamp, resolveSidebarThreadStatus, searchSidebarThreadsByTitle, + selectPinnedReorderSection, + shouldApplyOptimisticPinnedOrder, shouldCreateNewThreadInCurrentProject, resolveWorkingStartedAt, sortLogicalProjectsForSidebar, @@ -192,6 +194,7 @@ import { } from "./ui/combobox"; import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { SidebarSectionHeading } from "./sidebar/SidebarSectionHeading"; import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; import { @@ -2718,6 +2721,7 @@ export default function Sidebar() { useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), ); const [optimisticPinnedOrder, setOptimisticPinnedOrder] = useState<{ + readonly environmentId: EnvironmentId | null; readonly order: readonly string[]; /** pinOrderKey per thread as of the drop — the baseline that tells a concurrent client's write apart from one of our own landing. */ @@ -2727,13 +2731,24 @@ export default function Sidebar() { readonly assignedKeys: ReadonlyMap; } | null>(null); const orderedPinnedThreads = useMemo(() => { - if (optimisticPinnedOrder === null) return pinnedThreads; + if ( + optimisticPinnedOrder === null || + !shouldApplyOptimisticPinnedOrder( + optimisticPinnedOrder.environmentId, + sidebarEnvironmentGroupingEnabled, + ) + ) { + return pinnedThreads; + } return orderItemsByPreferredIds({ items: pinnedThreads, preferredIds: optimisticPinnedOrder.order, getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), }); - }, [optimisticPinnedOrder, pinnedThreads]); + }, [optimisticPinnedOrder, pinnedThreads, sidebarEnvironmentGroupingEnabled]); + useEffect(() => { + setOptimisticPinnedOrder(null); + }, [sidebarEnvironmentGroupingEnabled]); const pinnedThreadSections = useMemo( () => groupSidebarThreadsByEnvironment({ @@ -2745,8 +2760,11 @@ export default function Sidebar() { ); useEffect(() => { if (optimisticPinnedOrder === null) return; - const canonical = pinnedThreads.filter((thread) => - reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), + const canonical = pinnedThreads.filter( + (thread) => + (optimisticPinnedOrder.environmentId === null || + thread.environmentId === optimisticPinnedOrder.environmentId) && + reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), ); const canonicalKeys = canonical.map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), @@ -2827,7 +2845,12 @@ export default function Sidebar() { const activeKey = String(event.active.id); const overKey = event.over === null ? null : String(event.over.id); if (overKey === null || activeKey === overKey) return; - const reorderable = orderedPinnedThreads.filter((thread) => + const reorderable = selectPinnedReorderSection({ + threads: orderedPinnedThreads, + activeKey, + groupByEnvironment: sidebarEnvironmentGroupingEnabled, + getKey: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + }).filter((thread) => reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), ); const keys = reorderable.map((thread) => @@ -2848,6 +2871,9 @@ export default function Sidebar() { }); if (assignments.length === 0) return; setOptimisticPinnedOrder({ + environmentId: sidebarEnvironmentGroupingEnabled + ? (reorderable[0]?.environmentId ?? null) + : null, order: newOrder, keysAtDrop, assignedKeys: new Map( @@ -2886,7 +2912,12 @@ export default function Sidebar() { } })(); }, - [orderedPinnedThreads, reorderPinnedThread, reorderablePinnedKeys], + [ + orderedPinnedThreads, + reorderPinnedThread, + reorderablePinnedKeys, + sidebarEnvironmentGroupingEnabled, + ], ); // One snooze per thread at a time — same double-dispatch guard as settle. const snoozingThreadKeysRef = useRef(new Set()); @@ -3972,15 +4003,11 @@ export default function Sidebar() { } for (const environment of sections) { items.push( -
  • - - {environment.label} - - -
  • , + label={environment.label} + className="mt-3 list-none px-2.5" + />, ); for (const thread of environment.threads) { items.push(renderThreadRow(thread, section)); @@ -3989,6 +4016,7 @@ export default function Sidebar() { }; const renderPinnedRows = ( sectionThreads: ReadonlyArray, + environmentLabel?: string, ) => (
      {sectionThreads.map((thread) => { @@ -4048,13 +4080,12 @@ export default function Sidebar() { {sidebarEnvironmentGroupingEnabled ? pinnedThreadSections.map((environment) => (
      -
      - - {environment.label} - - -
      - {renderPinnedRows(environment.threads)} + + {renderPinnedRows(environment.threads, environment.label)}
      )) : renderPinnedRows(orderedPinnedThreads)} diff --git a/apps/web/src/components/sidebar/SidebarSectionHeading.tsx b/apps/web/src/components/sidebar/SidebarSectionHeading.tsx new file mode 100644 index 000000000000..1b8fd6ab15ae --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarSectionHeading.tsx @@ -0,0 +1,17 @@ +import { cn } from "~/lib/utils"; + +export function SidebarSectionHeading(props: { + readonly as?: "div" | "li"; + readonly className?: string; + readonly label: string; +}) { + const Component = props.as ?? "li"; + return ( + + + {props.label} + + + + ); +} diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index df18bf2fef95..e2877eaa0a07 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -101,6 +101,10 @@ describe("environment grouping", () => { expect(result.projects).toHaveLength(2); expect(new Set(result.projects.map((project) => project.projectKey)).size).toBe(2); + expect(result.projects.map((project) => project.logicalProjectKey)).toEqual([ + repositoryIdentity.canonicalKey, + repositoryIdentity.canonicalKey, + ]); expect( result.projects.map((project) => project.memberProjects.map((member) => [member.environmentId, member.id]), diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 9262ace9f65c..a385f5171db0 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -11,6 +11,7 @@ export interface SidebarProjectGroupMember extends Project { export interface SidebarProjectSnapshot extends Project { projectKey: string; + logicalProjectKey: string; displayName: string; groupedProjectCount: number; environmentPresence: EnvironmentPresence; @@ -150,6 +151,7 @@ export function buildSidebarProjectSnapshots(input: { return { ...representative, projectKey: group.key, + logicalProjectKey: group.key, displayName: group.label, groupedProjectCount: members.length, environmentPresence: