From 32c1c96f21465be95528ed002582cde3f7565243 Mon Sep 17 00:00:00 2001 From: Xiaxuan Chen Date: Wed, 2 Sep 2026 09:28:21 -0700 Subject: [PATCH] fix(web): filter sidebar threads by environment --- apps/web/src/components/Sidebar.logic.test.ts | 79 +++ apps/web/src/components/Sidebar.logic.ts | 42 +- apps/web/src/components/Sidebar.tsx | 537 ++++++++++++++---- docs/user/thread-sidebar.md | 4 + 4 files changed, 542 insertions(+), 120 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index dbf8fcf78532..1fc075522145 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -6,10 +6,12 @@ import { buildBulkTitleRegenerationContextMenuItem, buildMultiSelectThreadContextMenuItems, createThreadJumpHintVisibilityController, + filterSidebarProjectGroupsByEnvironment, filterSidebarProjectScopeItems, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, resolveAdjacentThreadId, + resolveSidebarEnvironmentScopeId, reduceSidebarProjectScopeMenuState, getFallbackThreadIdAfterDelete, getVisibleThreadsForProject, @@ -25,6 +27,7 @@ import { resolveThreadStatusPill, resolveWorkingStartedAt, searchSidebarThreadsByTitle, + sidebarEntryMatchesScope, formatWorkingDurationLabel, shouldNavigateAfterProjectRemoval, shouldClearThreadSelectionOnMouseDown, @@ -55,6 +58,7 @@ import { } from "../types"; const localEnvironmentId = EnvironmentId.make("environment-local"); +const remoteEnvironmentId = EnvironmentId.make("environment-remote"); describe("animatePinnedLayoutChanges", () => { const baseArgs: Parameters[0] = { @@ -779,6 +783,81 @@ describe("filterSidebarProjectScopeItems", () => { }); }); +describe("sidebar environment scope", () => { + it("only keeps a selected environment while multiple environments remain available", () => { + expect( + resolveSidebarEnvironmentScopeId({ + selectedEnvironmentId: remoteEnvironmentId, + availableEnvironmentIds: new Set([localEnvironmentId, remoteEnvironmentId]), + }), + ).toBe(remoteEnvironmentId); + expect( + resolveSidebarEnvironmentScopeId({ + selectedEnvironmentId: remoteEnvironmentId, + availableEnvironmentIds: new Set([localEnvironmentId]), + }), + ).toBeNull(); + }); + + it("drops a selection that is no longer in the environment catalog", () => { + expect( + resolveSidebarEnvironmentScopeId({ + selectedEnvironmentId: EnvironmentId.make("environment-missing"), + availableEnvironmentIds: new Set([localEnvironmentId, remoteEnvironmentId]), + }), + ).toBeNull(); + }); + + it("composes environment and project scopes", () => { + const projectScope = new Set([`${remoteEnvironmentId}:project-a`]); + expect( + sidebarEntryMatchesScope({ + environmentId: remoteEnvironmentId, + projectId: "project-a", + selectedEnvironmentId: remoteEnvironmentId, + scopedProjectKeys: projectScope, + }), + ).toBe(true); + expect( + sidebarEntryMatchesScope({ + environmentId: localEnvironmentId, + projectId: "project-a", + selectedEnvironmentId: remoteEnvironmentId, + scopedProjectKeys: projectScope, + }), + ).toBe(false); + expect( + sidebarEntryMatchesScope({ + environmentId: remoteEnvironmentId, + projectId: "project-b", + selectedEnvironmentId: remoteEnvironmentId, + scopedProjectKeys: projectScope, + }), + ).toBe(false); + }); + + it("only offers project groups present in the selected environment", () => { + const localOnly = { + key: "local", + memberProjectRefs: [{ environmentId: localEnvironmentId }], + }; + const shared = { + key: "shared", + memberProjectRefs: [ + { environmentId: localEnvironmentId }, + { environmentId: remoteEnvironmentId }, + ], + }; + expect( + filterSidebarProjectGroupsByEnvironment([localOnly, shared], remoteEnvironmentId), + ).toEqual([shared]); + expect(filterSidebarProjectGroupsByEnvironment([localOnly, shared], null)).toEqual([ + localOnly, + shared, + ]); + }); +}); + describe("reduceSidebarProjectScopeMenuState", () => { const queriedOpenState = { open: true, query: "alpha" }; diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 6e359f85c588..7adbcb33fa9e 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,6 +1,6 @@ import * as React from "react"; import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable"; -import type { ContextMenuItem } from "@t3tools/contracts"; +import type { ContextMenuItem, EnvironmentId } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import { activeThreadAnchorTimestampMs, @@ -997,3 +997,43 @@ export function sortScopedProjectsForSidebar< left.id.localeCompare(right.id), ); } + +export function resolveSidebarEnvironmentScopeId(input: { + selectedEnvironmentId: EnvironmentId | null; + availableEnvironmentIds: ReadonlySet; +}): EnvironmentId | null { + if (input.availableEnvironmentIds.size <= 1 || input.selectedEnvironmentId === null) { + return null; + } + return input.availableEnvironmentIds.has(input.selectedEnvironmentId) + ? input.selectedEnvironmentId + : null; +} + +export function sidebarEntryMatchesScope(input: { + environmentId: EnvironmentId; + projectId: string; + selectedEnvironmentId: EnvironmentId | null; + scopedProjectKeys: ReadonlySet | null; +}): boolean { + if (input.selectedEnvironmentId !== null && input.environmentId !== input.selectedEnvironmentId) { + return false; + } + return ( + input.scopedProjectKeys === null || + input.scopedProjectKeys.has(`${input.environmentId}:${input.projectId}`) + ); +} + +export function filterSidebarProjectGroupsByEnvironment< + TGroup extends { + readonly memberProjectRefs: ReadonlyArray<{ readonly environmentId: EnvironmentId }>; + }, +>(groups: readonly TGroup[], selectedEnvironmentId: EnvironmentId | null): readonly TGroup[] { + if (selectedEnvironmentId === null) return groups; + return groups.filter((group) => + group.memberProjectRefs.some( + (projectRef) => projectRef.environmentId === selectedEnvironmentId, + ), + ); +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a5df7bc53538..4cdf85edb913 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -30,7 +30,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, @@ -126,6 +126,7 @@ import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; import { animatePinnedLayoutChanges, buildBulkTitleRegenerationContextMenuItem, + filterSidebarProjectGroupsByEnvironment, filterSidebarProjectScopeItems, formatWorkingDurationLabel, firstValidTimestampMs, @@ -136,9 +137,11 @@ import { planPinnedReorder, reduceSidebarProjectScopeMenuState, resolveAdjacentThreadId, + resolveSidebarEnvironmentScopeId, resolveSettledTimestamp, resolveSidebarThreadStatus, searchSidebarThreadsByTitle, + sidebarEntryMatchesScope, shouldCreateNewThreadInCurrentProject, resolveWorkingStartedAt, sortLogicalProjectsForSidebar, @@ -190,6 +193,18 @@ import { ComboboxTrigger, useComboboxFilter, } from "./ui/combobox"; +import { + Menu, + MenuGroup, + MenuGroupLabel, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSub, + MenuSubPopup, + MenuSubTrigger, + MenuTrigger, +} from "./ui/menu"; import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; @@ -595,6 +610,7 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { projectDisplayNameByKey: ReadonlyMap; projectCwdByKey: ReadonlyMap; projectFaviconPathByKey: ReadonlyMap; + selectedEnvironmentId: EnvironmentId | null; scopedProjectKeys: ReadonlySet | null; routeDraftId: string | null; onNavigateToDraft: (draftId: DraftId) => void; @@ -636,8 +652,12 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { continue; } if ( - props.scopedProjectKeys !== null && - !props.scopedProjectKeys.has(`${session.environmentId}:${session.projectId}`) + !sidebarEntryMatchesScope({ + environmentId: session.environmentId, + projectId: session.projectId, + selectedEnvironmentId: props.selectedEnvironmentId, + scopedProjectKeys: props.scopedProjectKeys, + }) ) { continue; } @@ -663,6 +683,7 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { draftsByThreadKey, frozenActive, props.routeDraftId, + props.selectedEnvironmentId, props.scopedProjectKeys, ]); const handleDiscard = useCallback( @@ -1950,20 +1971,56 @@ export default function Sidebar() { const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); + // Match mobile's thread-list filter: when several environments are known, + // the sidebar exposes Environment and Project as sibling submenus. Keep the + // configured value separate from the render-safe value so removing the + // selected environment cannot paint an empty list for a frame. + const [configuredEnvironmentScopeId, setConfiguredEnvironmentScopeId] = + useState(null); + const availableEnvironmentIds = useMemo( + () => new Set(environments.map((environment) => environment.environmentId)), + [environments], + ); + const environmentScopeId = resolveSidebarEnvironmentScopeId({ + selectedEnvironmentId: configuredEnvironmentScopeId, + availableEnvironmentIds, + }); + useEffect(() => { + if (configuredEnvironmentScopeId !== environmentScopeId) { + setConfiguredEnvironmentScopeId(environmentScopeId); + } + }, [configuredEnvironmentScopeId, environmentScopeId]); + const scopedEnvironment = useMemo( + () => + environmentScopeId === null + ? null + : (environments.find((environment) => environment.environmentId === environmentScopeId) ?? + null), + [environmentScopeId, environments], + ); + const sortedSidebarEnvironments = useMemo( + () => [...environments].toSorted((left, right) => left.label.localeCompare(right.label)), + [environments], + ); + // Project scope: one menu above the list. Scoping filters the list without // making the header width depend on the number or length of project names. const [projectScopeKey, setProjectScopeKey] = useState(null); + const menuProjectGroups = useMemo( + () => filterSidebarProjectGroupsByEnvironment(projectGroups, environmentScopeId), + [environmentScopeId, projectGroups], + ); // {value, label} items let Base UI drive the combobox selection contract // while the popup search filters the same collection. const projectScopeItems = useMemo( () => [ { value: "all", label: "All projects" }, - ...projectGroups.map((project) => ({ + ...menuProjectGroups.map((project) => ({ value: project.projectKey, label: project.displayName, })), ], - [projectGroups], + [menuProjectGroups], ); const projectGroupByScopeKey = useMemo( () => new Map(projectGroups.map((project) => [project.projectKey, project] as const)), @@ -1980,6 +2037,8 @@ export default function Sidebar() { { open: false, query: "" }, ); const projectScopeFilter = useComboboxFilter(); + const [threadScopeMenuOpen, setThreadScopeMenuOpen] = useState(false); + const [nestedProjectScopeQuery, setNestedProjectScopeQuery] = useState(""); // Filtering derives from the same React state that controls the input, so // the visible query and the visible list can never desync — the peer wiring // in DiffPanel and BranchToolbarBranchSelector. "All projects" is a scope @@ -1998,12 +2057,21 @@ export default function Sidebar() { }), [projectScopeFilter, projectScopeItems, projectScopeKey, projectScopeMenuState.query], ); + const nestedProjectScopeItems = useMemo(() => { + const query = nestedProjectScopeQuery.trim(); + if (query.length === 0) return projectScopeItems; + return projectScopeItems.filter( + (item) => + item.value !== "all" && + projectScopeFilter.contains(item, query, (candidate) => candidate.label), + ); + }, [nestedProjectScopeQuery, projectScopeFilter, projectScopeItems]); const scopedProjectGroup = useMemo( () => projectScopeKey === null ? null - : (projectGroups.find((project) => project.projectKey === projectScopeKey) ?? null), - [projectGroups, projectScopeKey], + : (menuProjectGroups.find((project) => project.projectKey === projectScopeKey) ?? null), + [menuProjectGroups, projectScopeKey], ); const scopedProjectKeys = useMemo( () => @@ -2038,8 +2106,12 @@ export default function Sidebar() { continue; } if ( - scopedProjectKeys !== null && - !scopedProjectKeys.has(`${session.environmentId}:${session.projectId}`) + !sidebarEntryMatchesScope({ + environmentId: session.environmentId, + projectId: session.projectId, + selectedEnvironmentId: environmentScopeId, + scopedProjectKeys, + }) ) { continue; } @@ -2051,13 +2123,14 @@ export default function Sidebar() { // hidden now, and bulk actions must never count or touch invisible rows. useEffect(() => { clearSelection(); - }, [clearSelection, projectScopeKey]); + }, [clearSelection, environmentScopeId, projectScopeKey]); const handleProjectSettings = useCallback( (event: ReactMouseEvent, projectGroup: SidebarProjectSnapshot) => { event.preventDefault(); event.stopPropagation(); dispatchProjectScopeMenu({ type: "project-settings-opened" }); + setThreadScopeMenuOpen(false); if (isMobile) { setOpenMobile(false); } @@ -2091,8 +2164,12 @@ export default function Sidebar() { const visible = threads.filter( (thread) => thread.archivedAt === null && - (scopedProjectKeys === null || - scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), + sidebarEntryMatchesScope({ + environmentId: thread.environmentId, + projectId: thread.projectId, + selectedEnvironmentId: environmentScopeId, + scopedProjectKeys, + }), ); const pinned: EnvironmentThreadShell[] = []; const active: EnvironmentThreadShell[] = []; @@ -2167,6 +2244,7 @@ export default function Sidebar() { autoSettleAfterDays, autoSettleOnMerge, changeRequestSnapshotByKey, + environmentScopeId, nowMinute, scopedProjectKeys, serverConfigs, @@ -2224,7 +2302,7 @@ export default function Sidebar() { // filter context changes so a scope/search flip never inherits a deep // page state. const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT); - const settledResetKey = projectScopeKey ?? "all"; + const settledResetKey = `${environmentScopeId ?? "all"}\0${projectScopeKey ?? "all"}`; const lastSettledResetKeyRef = useRef(settledResetKey); if (lastSettledResetKeyRef.current !== settledResetKey) { lastSettledResetKeyRef.current = settledResetKey; @@ -3547,117 +3625,335 @@ export default function Sidebar() { {projectGroups.length > 0 ? (
- item.label} - isItemEqualToValue={(a, b) => a.value === b.value} - open={projectScopeMenuState.open} - onOpenChange={(open) => { - dispatchProjectScopeMenu({ type: "open-changed", open }); - }} - value={selectedProjectScopeItem} - onValueChange={(item) => { - if (!item) return; - setProjectScopeKey(item.value === "all" ? null : item.value); - }} - > - - } - > - {scopedProjectGroup ? ( - - ) : ( - - )} - - {scopedProjectGroup?.displayName ?? "All projects"} - - - - 1 ? ( + { + setThreadScopeMenuOpen(open); + if (!open) setNestedProjectScopeQuery(""); + }} > -
-
-
-
- No matching projects. - - {(item: (typeof projectScopeItems)[number]) => { - const project = projectGroupByScopeKey.get(item.value) ?? null; - return ( - - {project ? ( - - ) : ( - - )} - {item.label} - {project ? ( - + ) : null} + + + ); + })} + + )} + + + + + Environment + + + Environment + { + const environment = environments.find( + (candidate) => candidate.environmentId === value, + ); + setConfiguredEnvironmentScopeId( + value === "all" ? null : (environment?.environmentId ?? null), + ); }} > - - - ) : null} - - ); - }} - - - + + + + + {sortedSidebarEnvironments.map((environment) => { + const isSelected = + environment.environmentId === environmentScopeId; + return ( + + + + + ); + })} + + + + + + +
+ ) : ( + item.label} + isItemEqualToValue={(a, b) => a.value === b.value} + open={projectScopeMenuState.open} + onOpenChange={(open) => { + dispatchProjectScopeMenu({ type: "open-changed", open }); + }} + value={selectedProjectScopeItem} + onValueChange={(item) => { + if (!item) return; + setProjectScopeKey(item.value === "all" ? null : item.value); + }} + > + + } + > + {scopedProjectGroup ? ( + + ) : ( + + )} + + {scopedProjectGroup?.displayName ?? "All projects"} + + + + +
+
+
+
+ No matching projects. + + {(item: (typeof projectScopeItems)[number]) => { + const project = projectGroupByScopeKey.get(item.value) ?? null; + return ( + + {project ? ( + + ) : ( + + )} + {item.label} + {project ? ( + + ) : null} + + ); + }} + +
+
+ )} ) : scopedProjectGroup ? ( `No threads in ${scopedProjectGroup.displayName} yet` + ) : scopedEnvironment ? ( + `No threads in ${scopedEnvironment.label} yet` ) : ( "No threads yet" )} diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index f0fbfdccbd6c..e5ab38cc071a 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -21,6 +21,10 @@ On web and desktop, drag a pinned thread to change its position. On mobile, open and choose **Move up** or **Move down**. The order is stored by the server and appears on your other connected devices. +Use the filter above the web or desktop thread list to focus on one project. When T3 Code knows +about more than one environment, the same control opens **Project** and **Environment** submenus. +Choose **All projects** or **All environments** to clear that part of the filter. + If reordering is unavailable for one environment, update the T3 Code server running in that environment. Older servers can still pin and unpin threads, but do not understand synced ordering; their pinned threads keep the default newest-first order below the ones you have arranged.