From f52d805c405bdea6b47b7c86dc2d7596982ab1af Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Sun, 25 Oct 2026 15:23:04 -0700 Subject: [PATCH 01/11] feat(web): filter sidebar projects and threads by environment --- apps/web/src/components/Sidebar.logic.test.ts | 60 +++++++ apps/web/src/components/Sidebar.logic.ts | 2 + apps/web/src/components/Sidebar.tsx | 146 +++++++++++++----- .../components/sidebar/SidebarDraftBlock.tsx | 5 + .../SidebarEnvironmentFilter.browser.test.tsx | 96 ++++++++++++ .../sidebar/SidebarEnvironmentFilter.tsx | 100 ++++++++++++ .../web/src/components/sidebar/routeReveal.ts | 15 +- apps/web/src/components/ui/combobox.tsx | 10 +- docs/user/thread-sidebar.md | 11 ++ 9 files changed, 404 insertions(+), 41 deletions(-) create mode 100644 apps/web/src/components/sidebar/SidebarEnvironmentFilter.browser.test.tsx create mode 100644 apps/web/src/components/sidebar/SidebarEnvironmentFilter.tsx diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 0e3f47b7f9ed8..ab193234ea1e5 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -34,6 +34,7 @@ import { groupSidebarThreadsByProject, resolveSidebarFolderDot, sidebarFolderExpansionKeys, + filterSidebarV2VisibleThreads, } from "./Sidebar.logic"; import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { @@ -59,6 +60,65 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); +describe("sidebar environment filtering", () => { + const remoteEnvironmentId = EnvironmentId.make("environment-remote"); + const local = { + ...makeThreadFixture({ id: ThreadId.make("local") }), + environmentId: localEnvironmentId, + }; + const remote = { + ...makeThreadFixture({ id: ThreadId.make("remote") }), + environmentId: remoteEnvironmentId, + }; + + it("excludes an environment independently of project scope", () => { + expect( + filterSidebarV2VisibleThreads( + [local, remote], + null, + new Set(), + new Set([remoteEnvironmentId]), + ), + ).toEqual([local]); + expect( + filterSidebarV2VisibleThreads( + [local, remote], + new Set([`${remoteEnvironmentId}:${remote.projectId}`]), + new Set(), + new Set([remoteEnvironmentId]), + ), + ).toEqual([]); + }); + + it("keeps newly connected environments visible and clears back to all environments", () => { + const added = { ...remote, environmentId: EnvironmentId.make("environment-new") }; + expect( + filterSidebarV2VisibleThreads( + [local, remote, added], + null, + new Set(), + new Set([remoteEnvironmentId]), + ), + ).toEqual([local, added]); + expect(filterSidebarV2VisibleThreads([local, remote], null, new Set())).toEqual([ + local, + remote, + ]); + }); + + it("can hide every environment, including threads without a listed project", () => { + const scratch = { ...local, projectId: ProjectId.make("scratch") }; + expect( + filterSidebarV2VisibleThreads( + [scratch, remote], + null, + new Set(), + new Set([localEnvironmentId, remoteEnvironmentId]), + ), + ).toEqual([]); + }); +}); + describe("deleteSelectedThreadEntries", () => { const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const; const success = AsyncResult.success(undefined); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 5ed1320b59e55..8e85704685f5c 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -609,6 +609,7 @@ export function filterSidebarV2VisibleThreads< threads: readonly T[], scopedProjectKeys: ReadonlySet | null, delegatedUnderParent: ReadonlySet, + excludedEnvironmentIds?: ReadonlySet, ): T[] { return threads.filter( (thread) => @@ -618,6 +619,7 @@ export function filterSidebarV2VisibleThreads< thread.lineage, delegatedUnderParent, ) && + !excludedEnvironmentIds?.has(thread.environmentId) && (scopedProjectKeys === null || scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), ); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8fa58d8456f75..e5f52bf0c1174 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -67,6 +67,7 @@ import { FolderIcon, ListFilterIcon, PlusIcon, + ServerIcon, SettingsIcon, } from "lucide-react"; import { @@ -248,6 +249,7 @@ import { import { SidebarContent, SidebarGroup, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { SidebarHeaderIconButton, SidebarThreadHeader } from "./sidebar/SidebarThreadHeader"; +import { SidebarEnvironmentFilter } from "./sidebar/SidebarEnvironmentFilter"; import { TooltipProvider } from "./ui/tooltip"; import { composerDraftHasUserContent, @@ -349,6 +351,7 @@ interface SidebarThreadPartitionInput { readonly scopedProjectKeys: ReadonlySet | null; /** Tasks linked environments run for a listed thread; they show under it. */ readonly delegatedUnderParent: ReadonlySet; + readonly excludedEnvironmentIds: ReadonlySet; readonly serverConfigs: ReadonlyMap; readonly connectedEnvironmentIds: readonly EnvironmentId[]; readonly optimisticDrop: SidebarOptimisticDrop | null; @@ -414,6 +417,7 @@ function routeThreadOnly( function useSidebarThreads(input: { readonly projectScopeKey: string | null; readonly scopedProjectKeys: ReadonlySet | null; + readonly excludedEnvironmentIds: ReadonlySet; readonly serverConfigs: ReadonlyMap; readonly connectedEnvironmentIds: readonly EnvironmentId[]; readonly optimisticDrop: SidebarOptimisticDrop | null; @@ -428,6 +432,7 @@ function useSidebarThreads(input: { const { projectScopeKey, scopedProjectKeys, + excludedEnvironmentIds, serverConfigs, connectedEnvironmentIds, optimisticDrop, @@ -465,20 +470,22 @@ function useSidebarThreads(input: { // 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); + const scopeKey = JSON.stringify([projectScopeKey, [...excludedEnvironmentIds].toSorted()]); const [settledTail, setSettledTail] = useState({ - scopeKey: projectScopeKey, + scopeKey, count: SETTLED_TAIL_INITIAL_COUNT, }); - if (settledTail.scopeKey !== projectScopeKey) { - setSettledTail({ scopeKey: projectScopeKey, count: SETTLED_TAIL_INITIAL_COUNT }); + if (settledTail.scopeKey !== scopeKey) { + setSettledTail({ scopeKey, count: SETTLED_TAIL_INITIAL_COUNT }); } const settledVisibleCount = - settledTail.scopeKey === projectScopeKey ? settledTail.count : SETTLED_TAIL_INITIAL_COUNT; + settledTail.scopeKey === scopeKey ? settledTail.count : SETTLED_TAIL_INITIAL_COUNT; const partition = partitionSidebarThreads({ threads, scopedProjectKeys, delegatedUnderParent, + excludedEnvironmentIds, serverConfigs, connectedEnvironmentIds, optimisticDrop, @@ -491,7 +498,12 @@ function useSidebarThreads(input: { partition; // Older settled threads load in pages; the shelf lists them only as far as they are loaded. const olderSettledShelf = useOlderSettledShelf({ - scopes: olderSettledScopes([...serverConfigs.keys()], scopedProjectKeys), + scopes: olderSettledScopes( + [...serverConfigs.keys()].filter( + (environmentId) => !excludedEnvironmentIds.has(environmentId), + ), + scopedProjectKeys, + ), expanded: settledShelfExpanded, visibleCount: settledVisibleCount, }); @@ -621,7 +633,12 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { sortActiveBySend, } = input; const preciseNow = new Date().toISOString(); - const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys, delegatedUnderParent); + const visible = filterSidebarV2VisibleThreads( + threads, + scopedProjectKeys, + delegatedUnderParent, + input.excludedEnvironmentIds, + ); const inboxObservation = inboxReturns.observe(workingShelfEnabled ? threads : null); const pinned: EnvironmentThreadShell[] = []; const active: EnvironmentThreadShell[] = []; @@ -801,7 +818,7 @@ function isOptimisticDropReleased(input: { } // Build maps in a hook so the compiler can prove they are frozen and cache them. -function useSidebarLookups() { +function useSidebarLookups(excludedEnvironmentIds: ReadonlySet) { const projects = useProjects(); const environments = useEnvironmentIdentities(); const serverConfigs = useServerConfigs(); @@ -825,10 +842,11 @@ function useSidebarLookups() { ); const projectScopeItems = [ { value: "all", label: "All projects" }, - ...pickableProjectGroups(projectGroups).map((project) => ({ - value: project.projectKey, - label: project.displayName, - })), + ...pickableProjectGroups(projectGroups) + .filter((project) => + project.memberProjectRefs.some((ref) => !excludedEnvironmentIds.has(ref.environmentId)), + ) + .map((project) => ({ value: project.projectKey, label: project.displayName })), ]; return { projects, @@ -840,6 +858,7 @@ function useSidebarLookups() { projectDisplayNameByKey, projectGroupByScopeKey, projectScopeItems, + environments, }; } @@ -897,6 +916,9 @@ function readRouteThreadKey(router: AnyRouter): string | null { } export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReveal }) { + const [excludedEnvironmentIds, setExcludedEnvironmentIds] = useState>( + () => new Set(), + ); const { projects, serverConfigs, @@ -907,7 +929,20 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve projectDisplayNameByKey, projectGroupByScopeKey, projectScopeItems: lookupProjectScopeItems, - } = useSidebarLookups(); + environments, + } = useSidebarLookups(excludedEnvironmentIds); + const knownEnvironmentIds = new Set(environments.map((environment) => environment.environmentId)); + if ( + [...excludedEnvironmentIds].some((environmentId) => !knownEnvironmentIds.has(environmentId)) + ) { + setExcludedEnvironmentIds( + new Set( + [...excludedEnvironmentIds].filter((environmentId) => + knownEnvironmentIds.has(environmentId), + ), + ), + ); + } const delegatedUnderParent = useDelegatedUnderParent(); const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); @@ -1025,6 +1060,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const [threadSearchQuery, setThreadSearchQuery] = useState(""); const [searchHighlightKey, setSearchHighlightKey] = useState(null); const connectedEnvironmentIds = useConnectedEnvironmentIds(); + const searchEnvironmentIds = connectedEnvironmentIds.filter( + (environmentId) => !excludedEnvironmentIds.has(environmentId), + ); const queuePendingFileDrop = useSidebarPendingFileDropStore((s) => s.queuePendingFileDrop); const clearPendingFileDrop = useSidebarPendingFileDropStore((s) => s.clearPendingFileDrop); const [renamingThreadRef, setRenamingThreadRef] = useState(null); @@ -1135,6 +1173,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve isSearching: isSearchingThreads, scopedProjectKeys: chosenScopeProjectKeys, delegatedUnderParent, + excludedEnvironmentIds, findThread: (key) => threads.find((candidate) => threadShellKey(candidate) === key), }); const projectScopeKeys = revealWidensScope ? [] : chosenProjectScopeKeys; @@ -1190,6 +1229,19 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const scopedProjectGroups = revealWidensScope ? [] : chosenProjectGroups; const scopedProjectRefs = scopedProjectGroups.flatMap((project) => project.memberProjectRefs); const scopedProjectKeys = revealWidensScope ? null : chosenScopeProjectKeys; + const changeEnvironmentFilter = (excludedIds: ReadonlySet) => { + clearSelection(); + setExcludedEnvironmentIds(excludedIds); + setSearchHighlightKey(null); + const visibleScopeKeys = chosenProjectGroups + .filter((project) => + project.memberProjectRefs.some((ref) => !excludedIds.has(ref.environmentId)), + ) + .map((project) => project.projectKey); + if (visibleScopeKeys.length !== chosenProjectScopeKeys.length) { + setPersistedProjectScopeKeys(visibleScopeKeys); + } + }; const threadSearch = useThreadSearch( connectedEnvironmentIds, threadSearchQuery, @@ -1204,6 +1256,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve if (session.promotedTo != null) { continue; } + if (excludedEnvironmentIds.has(session.environmentId)) { + continue; + } if (!composerDraftHasUserContent(store.draftsByThreadKey[draftKey])) { continue; } @@ -1338,6 +1393,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve } = useSidebarThreads({ projectScopeKey, scopedProjectKeys, + excludedEnvironmentIds, serverConfigs, connectedEnvironmentIds, optimisticDrop, @@ -3014,42 +3070,46 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve 0 ? "w-11" : undefined} label={ - scopedProjectGroups.length > 0 + (scopedProjectGroups.length > 0 ? `Filter threads by project: ${scopedProjectGroups.map((project) => project.displayName).join(", ")}` - : "Filter threads by project" + : "Filter threads by project") + + (excludedEnvironmentIds.size > 0 ? " (environment filter active)" : "") } /> } > - {scopedProjectGroups.length > 1 ? ( - - {scopedProjectGroups.slice(0, 2).map((project, index) => ( - - - - ))} - - ) : scopedProjectGroups[0] ? ( - - - - ) : ( - - )} + + {scopedProjectGroups.length > 1 ? ( + + {scopedProjectGroups.slice(0, 2).map((project) => ( + + ))} + + ) : scopedProjectGroups[0] ? ( + + + + ) : ( + + )} + {excludedEnvironmentIds.size > 0 ? ( + + ) : null} + {/* Refocusing the trigger after a pointer close shows its focus ring and opens its tooltip. */} closeType === "keyboard"} > No matching projects. +
{(item: (typeof projectScopeItems)[number]) => { const project = projectGroupByScopeKey.get(item.value) ?? null; @@ -3172,6 +3233,16 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ); }} +
+ {environments.length > 1 || excludedEnvironmentIds.size > 0 ? ( + + ) : null}
} @@ -3440,6 +3511,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve projectByKey={projectByKey} projectDisplayNameByKey={projectDisplayNameByKey} scopedProjectKeys={scopedProjectKeys} + excludedEnvironmentIds={excludedEnvironmentIds} routeDraftId={routeDraftIdForRows} onNavigateToDraft={navigateToDraft} onDraftContextMenu={handleDraftContextMenu} diff --git a/apps/web/src/components/sidebar/SidebarDraftBlock.tsx b/apps/web/src/components/sidebar/SidebarDraftBlock.tsx index bc15ca5945972..926943642f290 100644 --- a/apps/web/src/components/sidebar/SidebarDraftBlock.tsx +++ b/apps/web/src/components/sidebar/SidebarDraftBlock.tsx @@ -9,6 +9,7 @@ import { } from "react"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { useShallow } from "zustand/react/shallow"; +import type { EnvironmentId } from "@t3tools/contracts"; import { cn } from "~/lib/utils"; import { resolveSidebarRowAccessibility } from "../Sidebar.logic"; import { ProjectFavicon, type ProjectFaviconProject } from "../ProjectFavicon"; @@ -143,6 +144,7 @@ export const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { projectByKey: ReadonlyMap; projectDisplayNameByKey: ReadonlyMap; scopedProjectKeys: ReadonlySet | null; + excludedEnvironmentIds: ReadonlySet; routeDraftId: string | null; onNavigateToDraft: (draftId: DraftId) => void; onDraftContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void; @@ -180,6 +182,9 @@ export const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { if (session.promotedTo != null) { continue; } + if (props.excludedEnvironmentIds.has(session.environmentId)) { + continue; + } if ( props.scopedProjectKeys !== null && !props.scopedProjectKeys.has(`${session.environmentId}:${session.projectId}`) diff --git a/apps/web/src/components/sidebar/SidebarEnvironmentFilter.browser.test.tsx b/apps/web/src/components/sidebar/SidebarEnvironmentFilter.browser.test.tsx new file mode 100644 index 0000000000000..8636e603a4103 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarEnvironmentFilter.browser.test.tsx @@ -0,0 +1,96 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { useState } from "react"; +import { describe, expect, it } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import { render } from "../../../test/render"; +import { + Combobox, + ComboboxItem, + ComboboxList, + ComboboxPopup, + ComboboxSearchInput, + ComboboxTrigger, +} from "../ui/combobox"; +import { SidebarEnvironmentFilter } from "./SidebarEnvironmentFilter"; + +const laptop = EnvironmentId.make("laptop"); +const studio = EnvironmentId.make("studio"); +const travel = EnvironmentId.make("travel"); +const environments = [ + { environmentId: laptop, label: "Laptop" }, + { environmentId: studio, label: "Studio" }, + { environmentId: travel, label: "Travel" }, +]; +const projects = ["All projects", "Notes"]; + +function FilterMenu() { + const [excludedIds, setExcludedIds] = useState>(() => new Set()); + const [project, setProject] = useState("All projects"); + return ( + value && setProject(value)} + > + Filter: {project} + + + + {(item: string) => {item}} + + + + + ); +} + +describe("sidebar environment filter menu", () => { + it("keeps multi-selection open and retains it when the footer collapses", async () => { + await using screen = await render(); + await screen.getByLabelText("Filter projects").click(); + await page.getByRole("button", { name: "All environments" }).click(); + await page.getByRole("checkbox", { name: "Travel" }).click(); + await expect.element(page.getByRole("checkbox", { name: "Travel" })).not.toBeChecked(); + await expect.element(page.getByRole("checkbox", { name: "Studio" })).toBeChecked(); + await page.getByRole("checkbox", { name: "Studio" }).click(); + await page.getByRole("button", { name: "Laptop" }).click(); + await expect + .element(page.getByRole("group", { name: "Filter environments" })) + .not.toBeInTheDocument(); + await page.getByRole("button", { name: "Laptop" }).click(); + await expect.element(page.getByRole("checkbox", { name: "Travel" })).not.toBeChecked(); + await expect.element(page.getByRole("checkbox", { name: "Studio" })).not.toBeChecked(); + await page.getByRole("checkbox", { name: "All environments" }).click(); + await expect.element(page.getByRole("checkbox", { name: "Travel" })).toBeChecked(); + await page.getByRole("option", { name: "Notes" }).click(); + await expect.element(page.getByRole("dialog")).not.toBeInTheDocument(); + await expect.element(screen.getByText("Filter: Notes")).toBeVisible(); + }); + + it("keeps the footer available for an empty project search and keyboard interaction", async () => { + await using screen = await render(); + await screen.getByLabelText("Filter projects").click(); + page.getByRole("button", { name: "All environments" }).element().focus(); + await userEvent.keyboard("{Enter}"); + await expect.element(page.getByRole("checkbox", { name: "Studio" })).toBeVisible(); + await page.getByRole("button", { name: "All environments" }).click(); + await userEvent.type(page.getByRole("combobox", { name: "Search projects" }), "missing"); + await userEvent.tab(); + await userEvent.keyboard("{Enter}"); + await expect.element(page.getByRole("checkbox", { name: "Studio" })).toBeVisible(); + await userEvent.tab(); + await userEvent.keyboard(" "); + await expect.element(page.getByRole("button", { name: "No environments" })).toBeVisible(); + await userEvent.keyboard("{Escape}"); + await expect.element(page.getByRole("dialog")).not.toBeInTheDocument(); + await expect.element(screen.getByLabelText("Filter projects")).toHaveFocus(); + }); +}); diff --git a/apps/web/src/components/sidebar/SidebarEnvironmentFilter.tsx b/apps/web/src/components/sidebar/SidebarEnvironmentFilter.tsx new file mode 100644 index 0000000000000..f3dda898dbca2 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarEnvironmentFilter.tsx @@ -0,0 +1,100 @@ +import type { EnvironmentId, EnvironmentMachineKind } from "@t3tools/contracts"; +import { ChevronDownIcon, ChevronUpIcon, ServerIcon } from "lucide-react"; +import { useId, useState } from "react"; + +import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; +import { Checkbox } from "../ui/checkbox"; + +export function SidebarEnvironmentFilter(props: { + readonly environments: readonly { + readonly environmentId: EnvironmentId; + readonly label: string; + }[]; + readonly excludedEnvironmentIds: ReadonlySet; + readonly machineByEnvironmentId: ReadonlyMap; + readonly primaryEnvironmentId: EnvironmentId | null; + readonly onChange: (excludedIds: ReadonlySet) => void; +}) { + const [expanded, setExpanded] = useState(false); + const panelId = useId(); + const selected = props.environments.filter( + (environment) => !props.excludedEnvironmentIds.has(environment.environmentId), + ); + const allSelected = props.excludedEnvironmentIds.size === 0; + const label = allSelected + ? "All environments" + : selected.length === 0 + ? "No environments" + : selected.length === 1 + ? selected[0]!.label + : `${selected.length} environments`; + + return ( +
+ + {expanded ? ( +
+ +
+ {props.environments.map((environment) => ( + + ))} +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/sidebar/routeReveal.ts b/apps/web/src/components/sidebar/routeReveal.ts index f88433577581b..e49abd29330a3 100644 --- a/apps/web/src/components/sidebar/routeReveal.ts +++ b/apps/web/src/components/sidebar/routeReveal.ts @@ -56,6 +56,7 @@ export function resolveSidebarRouteReveal(input: { readonly isSearching: boolean; readonly scopedProjectKeys: ReadonlySet | null; readonly delegatedUnderParent: ReadonlySet; + readonly excludedEnvironmentIds?: ReadonlySet; readonly findThread: (key: string) => T | undefined; }): { readonly pendingKey: string | null; readonly widensScope: boolean } { const { reveal, routeKey, isSearching, scopedProjectKeys, delegatedUnderParent, findThread } = @@ -66,7 +67,17 @@ export function resolveSidebarRouteReveal(input: { const widensScope = thread !== undefined && scopedProjectKeys !== null && - filterSidebarV2VisibleThreads([thread], scopedProjectKeys, delegatedUnderParent).length === 0 && - filterSidebarV2VisibleThreads([thread], null, delegatedUnderParent).length === 1; + filterSidebarV2VisibleThreads( + [thread], + scopedProjectKeys, + delegatedUnderParent, + input.excludedEnvironmentIds, + ).length === 0 && + filterSidebarV2VisibleThreads( + [thread], + null, + delegatedUnderParent, + input.excludedEnvironmentIds, + ).length === 1; return { pendingKey, widensScope }; } diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index f26862cdfb40a..ea85416aa201b 100644 --- a/apps/web/src/components/ui/combobox.tsx +++ b/apps/web/src/components/ui/combobox.tsx @@ -141,6 +141,7 @@ function ComboboxPopup({ align = "start", collisionAvoidance, anchor: anchorProp, + maxHeight = "default", ...props }: ComboboxPrimitive.Popup.Props & { align?: ComboboxPrimitive.Positioner.Props["align"]; @@ -149,6 +150,7 @@ function ComboboxPopup({ side?: ComboboxPrimitive.Positioner.Props["side"]; anchor?: ComboboxPrimitive.Positioner.Props["anchor"]; collisionAvoidance?: ComboboxPrimitive.Positioner.Props["collisionAvoidance"]; + maxHeight?: "default" | "available"; }) { const { chipsRef, multiple } = React.use(ComboboxContext); const anchor = anchorProp ?? (multiple ? chipsRef : undefined); @@ -172,8 +174,12 @@ function ComboboxPopup({ )} > diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 09ce63e2a3e65..84a3d3096880f 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -57,6 +57,17 @@ in a new thread's model picker to add or remove them. A regular click returns to single model. Choose a base branch and send. Each selection starts a separate thread and worktree while you stay in the new thread composer. This requires a Git project. +## Filter the thread list + +On web and desktop, open the filter beside Search to choose a project. Expand +**All environments** at the bottom to select one or more environments. The +project list and threads follow both filters, including drafts and sidebar search. +Collapsing the environment choices keeps the filter active; **All environments** +restores every environment. Environment choices last until you reload the client, +and newly connected environments are shown by default. +If you deselect every environment for the selected project, the project filter +resets to **All projects**. + ## Pin and reorder threads Pin a thread from its menu to keep it above your active work. From a8d964f41466fabdc3ef4dda799710e0a86315a3 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Sun, 25 Oct 2026 16:04:28 -0700 Subject: [PATCH 02/11] fix(web): show selected host icons in sidebar filter --- apps/web/src/components/Sidebar.tsx | 38 ++++++++++++++++--- .../sidebar/SidebarEnvironmentFilter.tsx | 2 +- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index e5f52bf0c1174..775154ea8692b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -67,7 +67,7 @@ import { FolderIcon, ListFilterIcon, PlusIcon, - ServerIcon, + BanIcon, SettingsIcon, } from "lucide-react"; import { @@ -250,6 +250,7 @@ import { SidebarContent, SidebarGroup, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { SidebarHeaderIconButton, SidebarThreadHeader } from "./sidebar/SidebarThreadHeader"; import { SidebarEnvironmentFilter } from "./sidebar/SidebarEnvironmentFilter"; +import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { TooltipProvider } from "./ui/tooltip"; import { composerDraftHasUserContent, @@ -931,6 +932,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve projectScopeItems: lookupProjectScopeItems, environments, } = useSidebarLookups(excludedEnvironmentIds); + const selectedEnvironments = environments.filter( + (environment) => !excludedEnvironmentIds.has(environment.environmentId), + ); const knownEnvironmentIds = new Set(environments.map((environment) => environment.environmentId)); if ( [...excludedEnvironmentIds].some((environmentId) => !knownEnvironmentIds.has(environmentId)) @@ -3070,18 +3074,20 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve 0 ? "w-11" : undefined} + className={excludedEnvironmentIds.size > 0 ? "w-auto" : undefined} label={ (scopedProjectGroups.length > 0 ? `Filter threads by project: ${scopedProjectGroups.map((project) => project.displayName).join(", ")}` : "Filter threads by project") + - (excludedEnvironmentIds.size > 0 ? " (environment filter active)" : "") + (excludedEnvironmentIds.size > 0 + ? ` (environments: ${selectedEnvironments.map((environment) => environment.label).join(", ") || "none"})` + : "") } /> } > - - {scopedProjectGroups.length > 1 ? ( + + {scopedProjectGroups.length > 1 && excludedEnvironmentIds.size === 0 ? ( {scopedProjectGroups.slice(0, 2).map((project) => ( )} {excludedEnvironmentIds.size > 0 ? ( - + selectedEnvironments.length === 0 ? ( + + ) : ( + <> + {selectedEnvironments.slice(0, 2).map((environment) => ( + + ))} + {selectedEnvironments.length > 2 ? ( + + + + + ) : null} + + ) ) : null} diff --git a/apps/web/src/components/sidebar/SidebarEnvironmentFilter.tsx b/apps/web/src/components/sidebar/SidebarEnvironmentFilter.tsx index f3dda898dbca2..b0c869c5dd70c 100644 --- a/apps/web/src/components/sidebar/SidebarEnvironmentFilter.tsx +++ b/apps/web/src/components/sidebar/SidebarEnvironmentFilter.tsx @@ -89,7 +89,7 @@ export function SidebarEnvironmentFilter(props: { /> {environment.label} {environment.environmentId === props.primaryEnvironmentId ? ( - Primary + This machine ) : null} ))} From 50ba7fe3efff8afd46f8c1a42666344a476f8534 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Sun, 25 Oct 2026 16:16:06 -0700 Subject: [PATCH 03/11] fix(web): suspend sidebar project scope when its environments are hidden --- apps/web/src/components/Sidebar.tsx | 1 + apps/web/src/environmentGrouping.test.ts | 32 ++++++++++++++++++++++++ apps/web/src/sidebarProjectGrouping.ts | 10 ++++++++ 3 files changed, 43 insertions(+) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 775154ea8692b..dcc0e171e329d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1128,6 +1128,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve keys: persistedProjectScopeKeys, settings: projectGroupingSettings, canClearMissingScope: allProjectSnapshotsReady && allProjectConfigsReady, + excludedEnvironmentIds, }).filter((key) => !archivedProjectKeys.has(key)); const chosenProjectGroups = scopeGroups.filter((project) => chosenProjectScopeKeys.includes(project.projectKey), diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index b2081b4b8fe9a..28a1cb2d14d20 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -125,6 +125,38 @@ describe("environment grouping", () => { expect(resolveSidebarProjectScopeKey({ groups, key, canClearMissingScope: true })).toBe(key); }); + it("suspends a project scope when its last visible environment member disappears", () => { + const local = makeProject({ repositoryIdentity }); + const remote = makeProject({ + id: ProjectId.make("remote-project"), + environmentId: remoteEnvironmentId, + repositoryIdentity, + }); + const input = { + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }; + const groups = buildSidebarProjectSnapshots({ ...input, projects: [local, remote] }); + const key = groups[0]!.projectKey; + const scope = { key, excludedEnvironmentIds: new Set([remoteEnvironmentId]) }; + expect(resolveSidebarProjectScopeKey({ ...scope, groups })).toBe(key); + + const remainingGroups = buildSidebarProjectSnapshots({ ...input, projects: [remote] }); + expect(remainingGroups[0]!.projectKey).toBe(key); + expect(resolveSidebarProjectScopeKey({ ...scope, groups: remainingGroups })).toBeNull(); + expect( + resolveSidebarProjectScopeKey({ groups: remainingGroups, key, canClearMissingScope: true }), + ).toBe(key); + expect( + resolveSidebarProjectScopeKey({ + ...scope, + groups: remainingGroups, + excludedEnvironmentIds: new Set(), + }), + ).toBe(key); + }); + it("dedupes stale project rows with the same environment and workspace path", () => { const duplicate = makeProject({ id: ProjectId.make("project-duplicate"), diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 8fb8c4a5a1c6c..8951031a7023d 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -94,6 +94,7 @@ export function resolveSidebarProjectScopeKey(input: { readonly key: string | null; readonly settings?: ProjectGroupingSettings; readonly canClearMissingScope?: boolean; + readonly excludedEnvironmentIds?: ReadonlySet; }): string | null { if (input.key === null) return null; const group = @@ -104,6 +105,14 @@ export function resolveSidebarProjectScopeKey(input: { resolveScratchProjectScopeKey(input.key, candidate.memberProjects, input.settings) === candidate.projectKey, ); + const excludedIds = input.excludedEnvironmentIds; + if ( + group && + excludedIds && + group.memberProjectRefs.every((ref) => excludedIds.has(ref.environmentId)) + ) { + return null; + } return group?.projectKey ?? (input.canClearMissingScope ? null : input.key); } @@ -112,6 +121,7 @@ export function resolveSidebarProjectScopeKeys(input: { readonly keys: ReadonlyArray; readonly settings?: ProjectGroupingSettings; readonly canClearMissingScope?: boolean; + readonly excludedEnvironmentIds?: ReadonlySet; }): string[] { return [ ...new Set(input.keys.flatMap((key) => resolveSidebarProjectScopeKey({ ...input, key }) ?? [])), From 6291068d8e3b4fd209ccf5e395abce9700d1d617 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Mon, 26 Oct 2026 02:33:54 -0700 Subject: [PATCH 04/11] fix(web): exclude hidden environments from project activity order --- apps/web/src/components/Sidebar.logic.test.ts | 66 ++++++++++++++++++- apps/web/src/components/Sidebar.logic.ts | 3 +- apps/web/src/components/Sidebar.tsx | 6 +- apps/web/src/hooks/useSidebarProjectGroups.ts | 10 ++- 4 files changed, 78 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index ab193234ea1e5..b52eb50dfccaa 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -821,6 +821,67 @@ describe("sortScopedProjectsForSidebar", () => { }); describe("sortSidebarV2ProjectGroups", () => { + it.each(["created_at", "updated_at"] as const)( + "ignores excluded environments when sorting project activity by %s", + (sortOrder) => { + const remoteEnvironmentId = EnvironmentId.make("environment-remote"); + const olderProjectId = ProjectId.make("project-older"); + const newerProjectId = ProjectId.make("project-newer"); + const remoteProjectId = ProjectId.make("project-remote"); + const olderProject = { + ...makeProject({ id: olderProjectId }), + projectKey: "logical-older", + memberProjectRefs: [ + { environmentId: localEnvironmentId, projectId: olderProjectId }, + { environmentId: remoteEnvironmentId, projectId: remoteProjectId }, + ], + }; + const newerProject = { + ...makeProject({ id: newerProjectId }), + projectKey: "logical-newer", + memberProjectRefs: [{ environmentId: localEnvironmentId, projectId: newerProjectId }], + }; + const projects = [olderProject, newerProject]; + const threads = [ + makeThread({ + projectId: olderProjectId, + createdAt: "2026-03-09T10:01:00.000Z", + updatedAt: "2026-03-09T10:01:00.000Z", + }), + makeThread({ + projectId: newerProjectId, + createdAt: "2026-03-09T10:05:00.000Z", + updatedAt: "2026-03-09T10:05:00.000Z", + }), + { + ...makeThread({ + projectId: remoteProjectId, + createdAt: "2026-03-09T10:10:00.000Z", + updatedAt: "2026-03-09T10:10:00.000Z", + }), + environmentId: remoteEnvironmentId, + }, + ]; + + expect( + sortSidebarV2ProjectGroups( + projects, + threads, + sortOrder, + new Set(), + new Set([remoteEnvironmentId]), + ), + ).toEqual([newerProject, olderProject]); + expect(sortSidebarV2ProjectGroups(projects, threads, sortOrder, new Set())).toEqual([ + olderProject, + newerProject, + ]); + expect( + sortSidebarV2ProjectGroups(projects, threads, sortOrder, new Set(), new Set()), + ).toEqual([olderProject, newerProject]); + }, + ); + it("does not let a hidden subagent thread reorder projects", () => { const olderProjectId = ProjectId.make("project-older"); const newerProjectId = ProjectId.make("project-newer"); @@ -1536,7 +1597,7 @@ describe("resolveSidebarRouteReveal", () => { const hiddenKey = "environment-test:thread-test"; const findThread = (key: string) => (key === hiddenKey ? hidden : undefined); - it("widens the scope for a newly opened thread the scope hides", () => { + it.each([false, true])("only widens for a visible environment (excluded: %s)", (excluded) => { expect( resolveSidebarRouteReveal({ reveal: { observedKey: "environment-test:other", pendingKey: null }, @@ -1544,9 +1605,10 @@ describe("resolveSidebarRouteReveal", () => { isSearching: false, scopedProjectKeys, delegatedUnderParent: new Set(), + excludedEnvironmentIds: excluded ? new Set([hidden.environmentId]) : new Set(), findThread, }), - ).toEqual({ pendingKey: hiddenKey, widensScope: true }); + ).toEqual({ pendingKey: hiddenKey, widensScope: !excluded }); }); it("never widens for a thread whose reveal already completed", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 8e85704685f5c..9535899b98103 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1411,10 +1411,11 @@ export function sortSidebarV2ProjectGroups< threads: readonly TThread[], sortOrder: SidebarProjectSortOrder, delegatedUnderParent: ReadonlySet, + excludedEnvironmentIds?: ReadonlySet, ): TProject[] { return sortLogicalProjectsForSidebar( projects, - filterSidebarV2VisibleThreads(threads, null, delegatedUnderParent), + filterSidebarV2VisibleThreads(threads, null, delegatedUnderParent, excludedEnvironmentIds), sortOrder, ); } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index dcc0e171e329d..a04e97049f4ed 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -824,7 +824,7 @@ function useSidebarLookups(excludedEnvironmentIds: ReadonlySet) { const environments = useEnvironmentIdentities(); const serverConfigs = useServerConfigs(); const providerEntriesByEnvironment = useAtomValue(sidebarProviderEntriesAtom); - const projectGroups = useSidebarProjectGroups(); + const projectGroups = useSidebarProjectGroups(excludedEnvironmentIds); const environmentLabelById = new Map( environments.map((environment) => [environment.environmentId, environment.label] as const), ); @@ -1408,7 +1408,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve folderGroups: boringSidebarEnabled ? scopedProjectGroups.length === 0 ? // An archived project keeps a folder for its active threads, below the rest. - [...scopeGroups, ...projectGroups.filter((project) => project.archived)] + [...scopeGroups, ...projectGroups.filter((project) => project.archived)].filter((project) => + project.memberProjectRefs.some((ref) => !excludedEnvironmentIds.has(ref.environmentId)), + ) : scopedProjectGroups : null, keepEmptyFolders: (group) => diff --git a/apps/web/src/hooks/useSidebarProjectGroups.ts b/apps/web/src/hooks/useSidebarProjectGroups.ts index 88210360d2a7f..7c4de677e823c 100644 --- a/apps/web/src/hooks/useSidebarProjectGroups.ts +++ b/apps/web/src/hooks/useSidebarProjectGroups.ts @@ -9,7 +9,7 @@ import { useDelegatedUnderParent, useProjects, useQuietThreadShells } from "../s import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useClientSettings } from "./useSettings"; -export function useSidebarProjectGroups() { +export function useSidebarProjectGroups(excludedEnvironmentIds?: ReadonlySet) { const projects = useProjects(); // Activity order lags a running thread's streaming ticks until its next // real change; reading every tick re-rendered the whole sidebar with it. @@ -39,5 +39,11 @@ export function useSidebarProjectGroups() { primaryEnvironmentId, resolveEnvironmentLabel: (environmentId) => labels.get(environmentId) ?? null, }); - return sortSidebarV2ProjectGroups(unsortedGroups, threads, sortOrder, delegatedUnderParent); + return sortSidebarV2ProjectGroups( + unsortedGroups, + threads, + sortOrder, + delegatedUnderParent, + excludedEnvironmentIds, + ); } From ec00f391e326505db838a2e3508105544c598dbd Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Fri, 30 Oct 2026 02:56:24 -0700 Subject: [PATCH 05/11] fix(web): preserve unloaded project scopes when filtering environments --- apps/web/src/components/Sidebar.tsx | 14 ++++++++------ apps/web/src/environmentGrouping.test.ts | 23 +++++++++++++++++++++++ apps/web/src/sidebarProjectGrouping.ts | 9 +++++++++ 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a04e97049f4ed..a80f04b6255c3 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -109,6 +109,7 @@ import { import { projectGroupsSpanEnvironments, resolveSidebarProjectScopeKeys, + filterSidebarProjectScopeKeysByEnvironment, getSidebarProjectSettingsKey, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; @@ -1238,12 +1239,13 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve clearSelection(); setExcludedEnvironmentIds(excludedIds); setSearchHighlightKey(null); - const visibleScopeKeys = chosenProjectGroups - .filter((project) => - project.memberProjectRefs.some((ref) => !excludedIds.has(ref.environmentId)), - ) - .map((project) => project.projectKey); - if (visibleScopeKeys.length !== chosenProjectScopeKeys.length) { + const visibleScopeKeys = filterSidebarProjectScopeKeysByEnvironment({ + groups: projectGroups, + keys: persistedProjectScopeKeys, + settings: projectGroupingSettings, + excludedEnvironmentIds: excludedIds, + }); + if (visibleScopeKeys.length !== persistedProjectScopeKeys.length) { setPersistedProjectScopeKeys(visibleScopeKeys); } }; diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index 28a1cb2d14d20..b80a394149e14 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -10,6 +10,7 @@ import { buildSidebarProjectSnapshots, resolveSidebarProjectScopeKey, getSidebarProjectSettingsKey, + filterSidebarProjectScopeKeysByEnvironment, } from "./sidebarProjectGrouping"; import type { Project } from "./types"; @@ -141,10 +142,32 @@ describe("environment grouping", () => { const key = groups[0]!.projectKey; const scope = { key, excludedEnvironmentIds: new Set([remoteEnvironmentId]) }; expect(resolveSidebarProjectScopeKey({ ...scope, groups })).toBe(key); + const persistedKeys = [key, "unloaded-project"]; + expect( + filterSidebarProjectScopeKeysByEnvironment({ + ...scope, + groups, + keys: persistedKeys, + }), + ).toEqual(persistedKeys); const remainingGroups = buildSidebarProjectSnapshots({ ...input, projects: [remote] }); expect(remainingGroups[0]!.projectKey).toBe(key); expect(resolveSidebarProjectScopeKey({ ...scope, groups: remainingGroups })).toBeNull(); + expect( + filterSidebarProjectScopeKeysByEnvironment({ + ...scope, + groups: remainingGroups, + keys: persistedKeys, + }), + ).toEqual(["unloaded-project"]); + expect( + filterSidebarProjectScopeKeysByEnvironment({ + groups: remainingGroups, + keys: persistedKeys, + excludedEnvironmentIds: new Set(), + }), + ).toEqual(persistedKeys); expect( resolveSidebarProjectScopeKey({ groups: remainingGroups, key, canClearMissingScope: true }), ).toBe(key); diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 8951031a7023d..070d145288c76 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -128,6 +128,15 @@ export function resolveSidebarProjectScopeKeys(input: { ]; } +export function filterSidebarProjectScopeKeysByEnvironment(input: { + readonly groups: ReadonlyArray; + readonly keys: readonly string[]; + readonly settings?: ProjectGroupingSettings; + readonly excludedEnvironmentIds: ReadonlySet; +}): string[] { + return input.keys.filter((key) => resolveSidebarProjectScopeKey({ ...input, key }) !== null); +} + export function buildPhysicalToLogicalProjectKeyMap(input: { projects: ReadonlyArray; settings: ProjectGroupingSettings; From 00749abddf724b2df5f8c5c91f42ffc43299e463 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Fri, 30 Oct 2026 03:11:16 -0700 Subject: [PATCH 06/11] fix(web): preserve suspended sidebar project scopes --- apps/web/src/components/Sidebar.tsx | 13 +++-- apps/web/src/environmentGrouping.test.ts | 61 +++++++++++++++++++++++- apps/web/src/sidebarProjectGrouping.ts | 23 ++++++++- 3 files changed, 92 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a80f04b6255c3..f483ec9e40021 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -110,6 +110,7 @@ import { projectGroupsSpanEnvironments, resolveSidebarProjectScopeKeys, filterSidebarProjectScopeKeysByEnvironment, + toggleSidebarProjectScopeKey, getSidebarProjectSettingsKey, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; @@ -1186,9 +1187,14 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const projectScopeKey = projectScopeKeys.length === 0 ? null : projectScopeKeys.join("\n"); const toggleProjectScopeKey = (key: string) => setProjectScopeKeys( - projectScopeKeys.includes(key) - ? projectScopeKeys.filter((candidate) => candidate !== key) - : [...projectScopeKeys, key], + toggleSidebarProjectScopeKey({ + groups: projectGroups, + keys: persistedProjectScopeKeys, + activeKeys: projectScopeKeys, + key, + settings: projectGroupingSettings, + excludedEnvironmentIds, + }), ); // The widened scope outlives the reveal, so it is written back to the persisted // scope. Route changes arrive from history, links and the palette, and only this @@ -1243,6 +1249,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve groups: projectGroups, keys: persistedProjectScopeKeys, settings: projectGroupingSettings, + previousExcludedEnvironmentIds: excludedEnvironmentIds, excludedEnvironmentIds: excludedIds, }); if (visibleScopeKeys.length !== persistedProjectScopeKeys.length) { diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index b80a394149e14..70ee9d888f509 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -11,6 +11,7 @@ import { resolveSidebarProjectScopeKey, getSidebarProjectSettingsKey, filterSidebarProjectScopeKeysByEnvironment, + toggleSidebarProjectScopeKey, } from "./sidebarProjectGrouping"; import type { Project } from "./types"; @@ -105,6 +106,17 @@ describe("environment grouping", () => { expect( resolveSidebarProjectScopeKey({ groups, key, settings, canClearMissingScope: true }), ).toBe(groups[0]?.projectKey); + const mergedKey = groups[0]!.projectKey; + expect( + toggleSidebarProjectScopeKey({ + groups, + keys: [key, mergedKey, "unloaded-project"], + activeKeys: [mergedKey], + key: mergedKey, + settings, + excludedEnvironmentIds: new Set(), + }), + ).toEqual(["unloaded-project"]); }, ); @@ -140,7 +152,11 @@ describe("environment grouping", () => { }; const groups = buildSidebarProjectSnapshots({ ...input, projects: [local, remote] }); const key = groups[0]!.projectKey; - const scope = { key, excludedEnvironmentIds: new Set([remoteEnvironmentId]) }; + const scope = { + key, + previousExcludedEnvironmentIds: new Set(), + excludedEnvironmentIds: new Set([remoteEnvironmentId]), + }; expect(resolveSidebarProjectScopeKey({ ...scope, groups })).toBe(key); const persistedKeys = [key, "unloaded-project"]; expect( @@ -165,6 +181,18 @@ describe("environment grouping", () => { filterSidebarProjectScopeKeysByEnvironment({ groups: remainingGroups, keys: persistedKeys, + previousExcludedEnvironmentIds: scope.excludedEnvironmentIds, + excludedEnvironmentIds: new Set([ + remoteEnvironmentId, + EnvironmentId.make("unrelated-environment"), + ]), + }), + ).toEqual(persistedKeys); + expect( + filterSidebarProjectScopeKeysByEnvironment({ + groups: remainingGroups, + keys: persistedKeys, + previousExcludedEnvironmentIds: scope.excludedEnvironmentIds, excludedEnvironmentIds: new Set(), }), ).toEqual(persistedKeys); @@ -180,6 +208,37 @@ describe("environment grouping", () => { ).toBe(key); }); + it("preserves suspended and unloaded scopes when toggling another project", () => { + const remote = makeProject({ environmentId: remoteEnvironmentId, repositoryIdentity }); + const local = makeProject({ id: ProjectId.make("other-project"), workspaceRoot: "/other" }); + const groups = buildSidebarProjectSnapshots({ + projects: [remote, local], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }); + const suspendedKey = groups.find( + (group) => group.environmentId === remoteEnvironmentId, + )!.projectKey; + const key = groups.find((group) => group.environmentId === primaryEnvironmentId)!.projectKey; + const savedKeys = [suspendedKey, "unloaded-project"]; + const input = { + groups, + key, + settings: defaultGroupingSettings, + excludedEnvironmentIds: new Set([remoteEnvironmentId]), + }; + const selectedKeys = toggleSidebarProjectScopeKey({ + ...input, + keys: savedKeys, + activeKeys: [], + }); + expect(selectedKeys).toEqual([...savedKeys, key]); + expect( + toggleSidebarProjectScopeKey({ ...input, keys: selectedKeys, activeKeys: [key] }), + ).toEqual(savedKeys); + }); + it("dedupes stale project rows with the same environment and workspace path", () => { const duplicate = makeProject({ id: ProjectId.make("project-duplicate"), diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 070d145288c76..a519a743fb661 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -132,9 +132,30 @@ export function filterSidebarProjectScopeKeysByEnvironment(input: { readonly groups: ReadonlyArray; readonly keys: readonly string[]; readonly settings?: ProjectGroupingSettings; + readonly previousExcludedEnvironmentIds: ReadonlySet; readonly excludedEnvironmentIds: ReadonlySet; }): string[] { - return input.keys.filter((key) => resolveSidebarProjectScopeKey({ ...input, key }) !== null); + return input.keys.filter( + (key) => + resolveSidebarProjectScopeKey({ + ...input, + key, + excludedEnvironmentIds: input.previousExcludedEnvironmentIds, + }) === null || resolveSidebarProjectScopeKey({ ...input, key }) !== null, + ); +} + +export function toggleSidebarProjectScopeKey(input: { + readonly groups: ReadonlyArray; + readonly keys: readonly string[]; + readonly activeKeys: readonly string[]; + readonly key: string; + readonly settings?: ProjectGroupingSettings; + readonly excludedEnvironmentIds: ReadonlySet; +}): string[] { + return input.activeKeys.includes(input.key) + ? input.keys.filter((key) => resolveSidebarProjectScopeKey({ ...input, key }) !== input.key) + : [...new Set([...input.keys, input.key])]; } export function buildPhysicalToLogicalProjectKeyMap(input: { From 4c0003e9c941616dc94bc2f04bdd8eecea508a1b Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Fri, 30 Oct 2026 03:25:44 -0700 Subject: [PATCH 07/11] fix(web): create folder threads in a visible environment --- apps/web/src/components/Sidebar.tsx | 11 +++++++++- apps/web/src/environmentGrouping.test.ts | 26 ++++++++++++++++++++++++ apps/web/src/sidebarProjectGrouping.ts | 16 ++++++++++----- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index f483ec9e40021..37085424d33b8 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -108,6 +108,7 @@ import { } from "@t3tools/client-runtime/state/project-grouping"; import { projectGroupsSpanEnvironments, + buildSidebarProjectPickerEntries, resolveSidebarProjectScopeKeys, filterSidebarProjectScopeKeysByEnvironment, toggleSidebarProjectScopeKey, @@ -1323,8 +1324,16 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }, ); const startThreadInProjectFolder = useStableCallback((group: SidebarProjectSnapshot) => { + const [entry] = buildSidebarProjectPickerEntries({ + groups: [group], + preferredProjectRef: null, + excludedEnvironmentIds, + }); + if (!entry) return; if (isMobile) setOpenMobile(false); - void handleNewThread(scopeProjectRef(group.environmentId, group.id)); + void handleNewThread( + scopeProjectRef(entry.targetProject.environmentId, entry.targetProject.id), + ); }); const handleProjectFolderContextMenu = useStableCallback( (group: SidebarProjectSnapshot, position: { x: number; y: number }) => { diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index 70ee9d888f509..c7bdbb4d72c52 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -391,6 +391,32 @@ describe("environment grouping", () => { environmentId: primaryEnvironmentId, id: fallbackPrimary.id, }); + const filtered = buildSidebarProjectPickerEntries({ + groups, + preferredProjectRef: null, + excludedEnvironmentIds: new Set([primaryEnvironmentId]), + }); + expect( + filtered.find( + (entry) => entry.group.projectKey === destinationRepositoryIdentity.canonicalKey, + )?.targetProject, + ).toMatchObject({ environmentId: remoteEnvironmentId, id: destinationRemote.id }); + expect(filtered.some((entry) => entry.group.displayName === "fallback")).toBe(false); + expect( + buildSidebarProjectPickerEntries({ + groups, + preferredProjectRef: null, + excludedEnvironmentIds: new Set([remoteEnvironmentId]), + }).find((entry) => entry.group.projectKey === destinationRepositoryIdentity.canonicalKey) + ?.targetProject, + ).toMatchObject({ environmentId: primaryEnvironmentId, id: destinationPrimary.id }); + expect( + buildSidebarProjectPickerEntries({ + groups, + preferredProjectRef: null, + excludedEnvironmentIds: new Set([primaryEnvironmentId, remoteEnvironmentId]), + }), + ).toEqual([]); }); it("puts the preferred project before pinned projects", () => { diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index a519a743fb661..34edfcabe256f 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -249,32 +249,38 @@ export function buildSidebarProjectSnapshots(input: { export function buildSidebarProjectPickerEntries(input: { groups: ReadonlyArray; preferredProjectRef: ScopedProjectRef | null; + excludedEnvironmentIds?: ReadonlySet; }) { const preferredProjectRef = input.preferredProjectRef; const entries = input.groups.flatMap((group): SidebarProjectPickerEntry[] => { + const excludedIds = input.excludedEnvironmentIds; + const memberProjects = excludedIds + ? group.memberProjects.filter((project) => !excludedIds.has(project.environmentId)) + : group.memberProjects; const isPreferred = preferredProjectRef - ? group.memberProjectRefs.some( + ? !excludedIds?.has(preferredProjectRef.environmentId) && + group.memberProjectRefs.some( (projectRef) => projectRef.environmentId === preferredProjectRef.environmentId && projectRef.projectId === preferredProjectRef.projectId, ) : false; const preferredProject = preferredProjectRef - ? (group.memberProjects.find( + ? (memberProjects.find( (project) => project.environmentId === preferredProjectRef.environmentId && project.id === preferredProjectRef.projectId, ) ?? - group.memberProjects.find( + memberProjects.find( (project) => project.environmentId === preferredProjectRef.environmentId, )) : null; const targetProject = preferredProject ?? - group.memberProjects.find( + memberProjects.find( (project) => project.environmentId === group.environmentId && project.id === group.id, ) ?? - group.memberProjects[0]; + memberProjects[0]; if (!targetProject) return []; return [{ group, targetProject, isPreferred }]; From 0a1e01723117190cd64baac51390932c465b7675 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Fri, 30 Oct 2026 03:42:39 -0700 Subject: [PATCH 08/11] fix(web): copy folder paths from visible environments --- apps/web/src/components/Sidebar.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 37085424d33b8..9831b6980f50a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1258,7 +1258,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve } }; const threadSearch = useThreadSearch( - connectedEnvironmentIds, + searchEnvironmentIds, threadSearchQuery, scopedProjectGroups.length === 0 ? {} @@ -1362,9 +1362,17 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve case "new-thread": startThreadInProjectFolder(group); return; - case "copy-path": - copyPathToClipboard(group.workspaceRoot, { path: group.workspaceRoot }); + case "copy-path": { + const [entry] = buildSidebarProjectPickerEntries({ + groups: [group], + preferredProjectRef: null, + excludedEnvironmentIds, + }); + if (!entry) return; + const path = entry.targetProject.workspaceRoot; + copyPathToClipboard(path, { path }); return; + } case "pin": case "unpin": setProjectGroupPinned(group, clicked.value === "pin"); From e12fe819b400a5e948f20be7e2413767203deec0 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Wed, 11 Nov 2026 10:20:11 -0800 Subject: [PATCH 09/11] feat(web): remember sidebar environment filters across reloads --- apps/web/src/components/Sidebar.tsx | 201 +++++++++--------- .../SidebarDraftBlock.browser.test.tsx | 1 + .../SidebarEnvironmentFilter.browser.test.tsx | 13 +- .../sidebar/SidebarEnvironmentFilter.tsx | 2 +- apps/web/src/uiStateStore.test.ts | 42 +++- apps/web/src/uiStateStore.ts | 8 + docs/user/thread-sidebar.md | 5 +- 7 files changed, 158 insertions(+), 114 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9831b6980f50a..b3d9e9a3a71b9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,5 +1,5 @@ import { attachSidebarTree } from "../lib/sidebarTree"; -import { type EnvironmentId, type ScopedProjectRef } from "@t3tools/contracts"; +import { EnvironmentId, type ScopedProjectRef } from "@t3tools/contracts"; import type { EnvironmentServerConfig } from "@t3tools/client-runtime/connection"; import { dropThreadContext, @@ -920,8 +920,14 @@ function readRouteThreadKey(router: AnyRouter): string | null { } export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReveal }) { - const [excludedEnvironmentIds, setExcludedEnvironmentIds] = useState>( - () => new Set(), + const persistedExcludedEnvironmentIds = useUiStateStore( + (state) => state.sidebarExcludedEnvironmentIds, + ); + const setExcludedEnvironmentIds = useUiStateStore( + (state) => state.setSidebarExcludedEnvironmentIds, + ); + const excludedEnvironmentIds = new Set( + persistedExcludedEnvironmentIds.map((id) => EnvironmentId.make(id)), ); const { projects, @@ -938,18 +944,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const selectedEnvironments = environments.filter( (environment) => !excludedEnvironmentIds.has(environment.environmentId), ); - const knownEnvironmentIds = new Set(environments.map((environment) => environment.environmentId)); - if ( - [...excludedEnvironmentIds].some((environmentId) => !knownEnvironmentIds.has(environmentId)) - ) { - setExcludedEnvironmentIds( - new Set( - [...excludedEnvironmentIds].filter((environmentId) => - knownEnvironmentIds.has(environmentId), - ), - ), - ); - } + const environmentFilterActive = selectedEnvironments.length < environments.length; const delegatedUnderParent = useDelegatedUnderParent(); const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); @@ -1244,7 +1239,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const scopedProjectKeys = revealWidensScope ? null : chosenScopeProjectKeys; const changeEnvironmentFilter = (excludedIds: ReadonlySet) => { clearSelection(); - setExcludedEnvironmentIds(excludedIds); + setExcludedEnvironmentIds([...excludedIds]); setSearchHighlightKey(null); const visibleScopeKeys = filterSidebarProjectScopeKeysByEnvironment({ groups: projectGroups, @@ -1434,8 +1429,11 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve folderGroups: boringSidebarEnabled ? scopedProjectGroups.length === 0 ? // An archived project keeps a folder for its active threads, below the rest. - [...scopeGroups, ...projectGroups.filter((project) => project.archived)].filter((project) => - project.memberProjectRefs.some((ref) => !excludedEnvironmentIds.has(ref.environmentId)), + [...scopeGroups, ...projectGroups.filter((project) => project.archived)].filter( + (project) => + project.memberProjectRefs.some( + (ref) => !excludedEnvironmentIds.has(ref.environmentId), + ), ) : scopedProjectGroups : null, @@ -3103,12 +3101,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve 0 ? "w-auto" : undefined} + className={environmentFilterActive ? "w-auto" : undefined} label={ (scopedProjectGroups.length > 0 ? `Filter threads by project: ${scopedProjectGroups.map((project) => project.displayName).join(", ")}` : "Filter threads by project") + - (excludedEnvironmentIds.size > 0 + (environmentFilterActive ? ` (environments: ${selectedEnvironments.map((environment) => environment.label).join(", ") || "none"})` : "") } @@ -3116,14 +3114,19 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve } > - {scopedProjectGroups.length > 1 && excludedEnvironmentIds.size === 0 ? ( - - {scopedProjectGroups.slice(0, 2).map((project) => ( - 1 && !environmentFilterActive ? ( + + {scopedProjectGroups.slice(0, 2).map((project, index) => ( + + className={ + index === 0 + ? "flex" + : "-ml-1 flex rounded-[25%] bg-sidebar ring-2 ring-sidebar" + } + > + + ))} ) : scopedProjectGroups[0] ? ( @@ -3133,7 +3136,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ) : ( )} - {excludedEnvironmentIds.size > 0 ? ( + {environmentFilterActive ? ( selectedEnvironments.length === 0 ? ( ) : ( @@ -3161,7 +3164,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve {/* Refocusing the trigger after a pointer close shows its focus ring and opens its tooltip. */} No matching projects.
- - {(item: (typeof projectScopeItems)[number]) => { - const project = projectGroupByScopeKey.get(item.value) ?? null; - const isSelected = project - ? projectScopeKeys.includes(item.value) - : projectScopeKeys.length === 0; - return ( - { - if (project) handleProjectSettings(event, project); - }} - > - + + {(item: (typeof projectScopeItems)[number]) => { + const project = projectGroupByScopeKey.get(item.value) ?? null; + const isSelected = project + ? projectScopeKeys.includes(item.value) + : projectScopeKeys.length === 0; + return ( + { + if (project) handleProjectSettings(event, project); + }} + > + + {project ? ( + + ) : ( + + )} + + {item.label} + {project && showProjectEnvironments ? ( + + ) : null} {project ? ( - + toggleProjectGroupPin(project)} + /> + ) : null + } + settings={ + + } + /> ) : ( - + // Keeps "All projects" as tall as the project rows. + )} - - {item.label} - {project && showProjectEnvironments ? ( - - ) : null} - {project ? ( - toggleProjectGroupPin(project)} - /> - ) : null - } - settings={ - - } - /> - ) : ( - // Keeps "All projects" as tall as the project rows. - - )} - - ); - }} - + + ); + }} +
- {environments.length > 1 || excludedEnvironmentIds.size > 0 ? ( + {environments.length > 1 || environmentFilterActive ? ( >(() => new Set()); const [project, setProject] = useState("All projects"); return ( @@ -41,7 +45,7 @@ function FilterMenu() { {(item: string) => {item}} { await page.getByRole("button", { name: "Laptop" }).click(); await expect.element(page.getByRole("checkbox", { name: "Travel" })).not.toBeChecked(); await expect.element(page.getByRole("checkbox", { name: "Studio" })).not.toBeChecked(); + await screen.rerender(); + await expect.element(page.getByRole("checkbox", { name: "All environments" })).toBeChecked(); + await screen.rerender(); + await expect.element(page.getByRole("checkbox", { name: "Travel" })).not.toBeChecked(); + await expect.element(page.getByRole("checkbox", { name: "Studio" })).not.toBeChecked(); await page.getByRole("checkbox", { name: "All environments" }).click(); await expect.element(page.getByRole("checkbox", { name: "Travel" })).toBeChecked(); await page.getByRole("option", { name: "Notes" }).click(); diff --git a/apps/web/src/components/sidebar/SidebarEnvironmentFilter.tsx b/apps/web/src/components/sidebar/SidebarEnvironmentFilter.tsx index b0c869c5dd70c..843cb4ada5719 100644 --- a/apps/web/src/components/sidebar/SidebarEnvironmentFilter.tsx +++ b/apps/web/src/components/sidebar/SidebarEnvironmentFilter.tsx @@ -20,7 +20,7 @@ export function SidebarEnvironmentFilter(props: { const selected = props.environments.filter( (environment) => !props.excludedEnvironmentIds.has(environment.environmentId), ); - const allSelected = props.excludedEnvironmentIds.size === 0; + const allSelected = selected.length === props.environments.length; const label = allSelected ? "All environments" : selected.length === 0 diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index dbfb60d15bd20..ff08cf0042366 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -20,6 +20,7 @@ function makeUiState(overrides: Partial = {}): UiState { projectExpandedById: {}, projectOrder: [], sidebarProjectScopeKeys: [], + sidebarExcludedEnvironmentIds: [], threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, @@ -102,6 +103,13 @@ describe("parsePersistedState", () => { invalid: "no" as unknown as boolean, }, projectOrder: ["physical-b", "", "physical-a", "physical-b"], + sidebarExcludedEnvironmentIds: [ + "studio", + "", + "offline-host", + "studio", + 1 as unknown as string, + ], threadLastVisitedAtById: { "environment:thread-1": "2026-02-25T12:35:00.000Z", invalid: "not-a-date", @@ -126,6 +134,7 @@ describe("parsePersistedState", () => { }, defaultAdvertisedEndpointKey: "desktop-core:lan:http", sidebarProjectScopeKeys: [], + sidebarExcludedEnvironmentIds: ["studio", "offline-host"], pullRequestMergeMethod: "merge", threadChangedFilesExpandedById: { "environment:thread-1": { @@ -232,6 +241,7 @@ describe("uiStateStore persistence", () => { defaultAdvertisedEndpointKey: "desktop-core:lan:http", sidebarProjectScopeKeys: [], sidebarProjectScopeKey: null, + sidebarExcludedEnvironmentIds: [], threadChangedFilesExpansionVersion: 2, threadChangedFilesExpandedById: { "environment:thread-1": { @@ -246,16 +256,28 @@ describe("uiStateStore persistence", () => { }); }); - it("restores the sidebar project scope across reloads", () => { - const keys = ["github.com/pingdotgg/t3code", "github.com/pingdotgg/uploadthing"]; - persistState(makeUiState({ sidebarProjectScopeKeys: keys })); - - const persisted = JSON.parse( - localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", - ) as PersistedUiState; - - expect(parsePersistedState(persisted).sidebarProjectScopeKeys).toEqual(keys); - }); + it.each([{ excludedIds: [] }, { excludedIds: ["studio", "offline-host"] }])( + "restores sidebar scopes across reloads (%j)", + ({ excludedIds }) => { + const keys = ["github.com/pingdotgg/t3code", "github.com/pingdotgg/uploadthing"]; + persistState( + makeUiState({ sidebarProjectScopeKeys: keys, sidebarExcludedEnvironmentIds: excludedIds }), + ); + + const persisted = JSON.parse( + localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", + ) as PersistedUiState; + + const restored = parsePersistedState(persisted); + expect(restored.sidebarProjectScopeKeys).toEqual(keys); + expect(restored.sidebarExcludedEnvironmentIds).toEqual(excludedIds); + expect(parsePersistedState({}).sidebarExcludedEnvironmentIds).toEqual([]); + expect( + parsePersistedState({ sidebarExcludedEnvironmentIds: "invalid" as unknown as string[] }) + .sidebarExcludedEnvironmentIds, + ).toEqual([]); + }, + ); it("keeps a single-project scope readable by older clients", () => { persistState(makeUiState({ sidebarProjectScopeKeys: ["github.com/pingdotgg/t3code"] })); diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 328e1a6477132..957238ccbf2a2 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -28,6 +28,7 @@ export interface PersistedUiState { defaultAdvertisedEndpointKey?: string | null; sidebarProjectScopeKey?: string | null; sidebarProjectScopeKeys?: string[]; + sidebarExcludedEnvironmentIds?: string[]; threadChangedFilesExpansionVersion?: number; threadChangedFilesExpandedById?: Record>; pullRequestMergeMethod?: string; @@ -37,6 +38,7 @@ export interface UiProjectState { projectExpandedById: Record; projectOrder: string[]; sidebarProjectScopeKeys: string[]; + sidebarExcludedEnvironmentIds: string[]; } export interface UiThreadState { @@ -59,6 +61,7 @@ const initialState: UiState = { projectExpandedById: {}, projectOrder: [], sidebarProjectScopeKeys: [], + sidebarExcludedEnvironmentIds: [], threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, @@ -154,6 +157,7 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { sidebarProjectScopeKeys: sanitizeStringArray( parsed.sidebarProjectScopeKeys ?? [parsed.sidebarProjectScopeKey], ), + sidebarExcludedEnvironmentIds: sanitizeStringArray(parsed.sidebarExcludedEnvironmentIds), pullRequestMergeMethod: isPullRequestMergeMethod(parsed.pullRequestMergeMethod) ? parsed.pullRequestMergeMethod : initialState.pullRequestMergeMethod, @@ -228,6 +232,7 @@ export function persistState(state: UiState): void { threadLastVisitedAtById: state.threadLastVisitedAtById, defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, sidebarProjectScopeKeys: state.sidebarProjectScopeKeys, + sidebarExcludedEnvironmentIds: state.sidebarExcludedEnvironmentIds, // Older clients read only this field. sidebarProjectScopeKey: state.sidebarProjectScopeKeys.length === 1 @@ -434,6 +439,7 @@ interface UiStateStore extends UiState { setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; setDefaultAdvertisedEndpointKey: (key: string | null) => void; setSidebarProjectScopeKeys: (projectKeys: readonly string[]) => void; + setSidebarExcludedEnvironmentIds: (environmentIds: readonly string[]) => void; setPullRequestMergeMethod: (method: PullRequestMergeMethod) => void; setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void; reorderProjects: ( @@ -455,6 +461,8 @@ export const useUiStateStore = create((set) => ({ set((state) => setDefaultAdvertisedEndpointKey(state, key)), setSidebarProjectScopeKeys: (projectKeys) => set((state) => setSidebarProjectScopeKeys(state, projectKeys)), + setSidebarExcludedEnvironmentIds: (environmentIds) => + set({ sidebarExcludedEnvironmentIds: sanitizeStringArray(environmentIds) }), setPullRequestMergeMethod: (method) => set((state) => setPullRequestMergeMethod(state, method)), setProjectExpanded: (projectIds, expanded) => set((state) => setProjectExpanded(state, projectIds, expanded)), diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 84a3d3096880f..b09199bc42e6e 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -63,8 +63,9 @@ On web and desktop, open the filter beside Search to choose a project. Expand **All environments** at the bottom to select one or more environments. The project list and threads follow both filters, including drafts and sidebar search. Collapsing the environment choices keeps the filter active; **All environments** -restores every environment. Environment choices last until you reload the client, -and newly connected environments are shown by default. +restores every environment. Your choices are saved on this client across reloads, +including when an environment goes offline. Newly connected environments are shown +by default. If you deselect every environment for the selected project, the project filter resets to **All projects**. From 416b00d644080721919d254fed814dd26122dddb Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Wed, 11 Nov 2026 10:40:00 -0800 Subject: [PATCH 10/11] fix(web): preserve and validate saved environment exclusions --- .../sidebar/SidebarDraftBlock.browser.test.tsx | 11 ++++++++--- .../SidebarEnvironmentFilter.browser.test.tsx | 10 ++++++++-- .../components/sidebar/SidebarEnvironmentFilter.tsx | 5 ++++- apps/web/src/uiStateStore.test.ts | 2 ++ apps/web/src/uiStateStore.ts | 12 +++++++++--- 5 files changed, 31 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx b/apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx index 4e02995df284f..2ab7ece8c5a2c 100644 --- a/apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx +++ b/apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx @@ -22,21 +22,22 @@ it("keeps draft previews current through edits, promotion and removal", async () const draftId = DraftId.make("draft-preview"); const threadId = ThreadId.make("draft-preview-thread"); const onRender = vi.fn(); - await using screen = await render( + const draftBlock = (excludedIds: ReadonlySet = new Set()) => (
-
, + ); + await using screen = await render(draftBlock()); const row = screen.getByTestId("sidebar-draft-row"); await expect.element(row).not.toBeInTheDocument(); @@ -45,6 +46,10 @@ it("keeps draft previews current through edits, promotion and removal", async () initial.setPrompt(draftId, "First draft preview"); }); await expect.element(row).toHaveAccessibleName("First draft preview, Unsent draft"); + await screen.rerender(draftBlock(new Set([environmentId]))); + await expect.element(row).not.toBeInTheDocument(); + await screen.rerender(draftBlock()); + await expect.element(row).toHaveAccessibleName("First draft preview, Unsent draft"); const rendersBeforeOtherComposer = onRender.mock.calls.length; await act(() => diff --git a/apps/web/src/components/sidebar/SidebarEnvironmentFilter.browser.test.tsx b/apps/web/src/components/sidebar/SidebarEnvironmentFilter.browser.test.tsx index e21cceb61cf08..ad5c1bdf9a8df 100644 --- a/apps/web/src/components/sidebar/SidebarEnvironmentFilter.browser.test.tsx +++ b/apps/web/src/components/sidebar/SidebarEnvironmentFilter.browser.test.tsx @@ -42,7 +42,11 @@ function FilterMenu({ - {(item: string) => {item}} + {(item: string) => ( + + {item} + + )} { await expect.element(page.getByRole("checkbox", { name: "Studio" })).not.toBeChecked(); await screen.rerender(); await expect.element(page.getByRole("checkbox", { name: "All environments" })).toBeChecked(); + await page.getByRole("checkbox", { name: "All environments" }).click(); await screen.rerender(); await expect.element(page.getByRole("checkbox", { name: "Travel" })).not.toBeChecked(); await expect.element(page.getByRole("checkbox", { name: "Studio" })).not.toBeChecked(); - await page.getByRole("checkbox", { name: "All environments" }).click(); + page.getByRole("checkbox", { name: "All environments" }).element().focus(); + await userEvent.keyboard(" "); await expect.element(page.getByRole("checkbox", { name: "Travel" })).toBeChecked(); await page.getByRole("option", { name: "Notes" }).click(); await expect.element(page.getByRole("dialog")).not.toBeInTheDocument(); diff --git a/apps/web/src/components/sidebar/SidebarEnvironmentFilter.tsx b/apps/web/src/components/sidebar/SidebarEnvironmentFilter.tsx index 843cb4ada5719..3c05373734e1a 100644 --- a/apps/web/src/components/sidebar/SidebarEnvironmentFilter.tsx +++ b/apps/web/src/components/sidebar/SidebarEnvironmentFilter.tsx @@ -61,7 +61,10 @@ export function SidebarEnvironmentFilter(props: { props.onChange( checked ? new Set() - : new Set(props.environments.map((environment) => environment.environmentId)), + : new Set([ + ...props.excludedEnvironmentIds, + ...props.environments.map((environment) => environment.environmentId), + ]), ) } /> diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index ff08cf0042366..c1d294a683f13 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -106,6 +106,8 @@ describe("parsePersistedState", () => { sidebarExcludedEnvironmentIds: [ "studio", "", + " ", + "\t\n", "offline-host", "studio", 1 as unknown as string, diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 957238ccbf2a2..7e2b5def328dc 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -1,9 +1,11 @@ import { Debouncer } from "@tanstack/react-pacer"; -import type { PullRequestMergeMethod } from "@t3tools/contracts"; +import { EnvironmentId, type PullRequestMergeMethod } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { create } from "zustand"; import { normalizeProjectPathForComparison } from "@t3tools/client-runtime/state/projects"; export const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; +const isEnvironmentId = Schema.is(EnvironmentId); const THREAD_CHANGED_FILES_EXPANSION_VERSION = 2; const LEGACY_PERSISTED_STATE_KEYS = [ "t3code:renderer-state:v8", @@ -157,7 +159,9 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { sidebarProjectScopeKeys: sanitizeStringArray( parsed.sidebarProjectScopeKeys ?? [parsed.sidebarProjectScopeKey], ), - sidebarExcludedEnvironmentIds: sanitizeStringArray(parsed.sidebarExcludedEnvironmentIds), + sidebarExcludedEnvironmentIds: sanitizeStringArray(parsed.sidebarExcludedEnvironmentIds).filter( + isEnvironmentId, + ), pullRequestMergeMethod: isPullRequestMergeMethod(parsed.pullRequestMergeMethod) ? parsed.pullRequestMergeMethod : initialState.pullRequestMergeMethod, @@ -462,7 +466,9 @@ export const useUiStateStore = create((set) => ({ setSidebarProjectScopeKeys: (projectKeys) => set((state) => setSidebarProjectScopeKeys(state, projectKeys)), setSidebarExcludedEnvironmentIds: (environmentIds) => - set({ sidebarExcludedEnvironmentIds: sanitizeStringArray(environmentIds) }), + set({ + sidebarExcludedEnvironmentIds: sanitizeStringArray(environmentIds).filter(isEnvironmentId), + }), setPullRequestMergeMethod: (method) => set((state) => setPullRequestMergeMethod(state, method)), setProjectExpanded: (projectIds, expanded) => set((state) => setProjectExpanded(state, projectIds, expanded)), From bfd90943fa8e50a22ad705ffb2124f369495b628 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Wed, 11 Nov 2026 17:31:23 -0800 Subject: [PATCH 11/11] fix(web): keep project filters through empty environment selection --- apps/web/src/components/Sidebar.tsx | 1 + apps/web/src/environmentGrouping.test.ts | 38 ++++++++++++++++++++++++ apps/web/src/sidebarProjectGrouping.ts | 4 +++ docs/user/thread-sidebar.md | 4 ++- 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b3d9e9a3a71b9..ced73af19cd6e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1243,6 +1243,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve setSearchHighlightKey(null); const visibleScopeKeys = filterSidebarProjectScopeKeysByEnvironment({ groups: projectGroups, + environmentIds: environments.map((environment) => environment.environmentId), keys: persistedProjectScopeKeys, settings: projectGroupingSettings, previousExcludedEnvironmentIds: excludedEnvironmentIds, diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index c7bdbb4d72c52..112a6379f2216 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -152,8 +152,10 @@ describe("environment grouping", () => { }; const groups = buildSidebarProjectSnapshots({ ...input, projects: [local, remote] }); const key = groups[0]!.projectKey; + const environmentIds = [primaryEnvironmentId, remoteEnvironmentId]; const scope = { key, + environmentIds, previousExcludedEnvironmentIds: new Set(), excludedEnvironmentIds: new Set([remoteEnvironmentId]), }; @@ -167,6 +169,39 @@ describe("environment grouping", () => { }), ).toEqual(persistedKeys); + for (const steps of [ + [new Set(environmentIds), new Set([primaryEnvironmentId])], + [new Set(), new Set([primaryEnvironmentId])], + ]) { + let keys = persistedKeys; + let previousExcludedEnvironmentIds = scope.excludedEnvironmentIds; + for (const excludedEnvironmentIds of steps) { + keys = filterSidebarProjectScopeKeysByEnvironment({ + ...scope, + groups, + keys, + previousExcludedEnvironmentIds, + excludedEnvironmentIds, + }); + expect(keys).toEqual(persistedKeys); + previousExcludedEnvironmentIds = excludedEnvironmentIds; + } + expect( + resolveSidebarProjectScopeKey({ + groups, + key, + excludedEnvironmentIds: previousExcludedEnvironmentIds, + }), + ).toBe(key); + } + expect( + resolveSidebarProjectScopeKey({ + groups, + key, + excludedEnvironmentIds: new Set(environmentIds), + }), + ).toBeNull(); + const remainingGroups = buildSidebarProjectSnapshots({ ...input, projects: [remote] }); expect(remainingGroups[0]!.projectKey).toBe(key); expect(resolveSidebarProjectScopeKey({ ...scope, groups: remainingGroups })).toBeNull(); @@ -174,12 +209,14 @@ describe("environment grouping", () => { filterSidebarProjectScopeKeysByEnvironment({ ...scope, groups: remainingGroups, + environmentIds, keys: persistedKeys, }), ).toEqual(["unloaded-project"]); expect( filterSidebarProjectScopeKeysByEnvironment({ groups: remainingGroups, + environmentIds, keys: persistedKeys, previousExcludedEnvironmentIds: scope.excludedEnvironmentIds, excludedEnvironmentIds: new Set([ @@ -191,6 +228,7 @@ describe("environment grouping", () => { expect( filterSidebarProjectScopeKeysByEnvironment({ groups: remainingGroups, + environmentIds, keys: persistedKeys, previousExcludedEnvironmentIds: scope.excludedEnvironmentIds, excludedEnvironmentIds: new Set(), diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 34edfcabe256f..01579d01792fa 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -130,11 +130,15 @@ export function resolveSidebarProjectScopeKeys(input: { export function filterSidebarProjectScopeKeysByEnvironment(input: { readonly groups: ReadonlyArray; + readonly environmentIds: readonly EnvironmentId[]; readonly keys: readonly string[]; readonly settings?: ProjectGroupingSettings; readonly previousExcludedEnvironmentIds: ReadonlySet; readonly excludedEnvironmentIds: ReadonlySet; }): string[] { + if (input.environmentIds.every((id) => input.excludedEnvironmentIds.has(id))) { + return [...input.keys]; + } return input.keys.filter( (key) => resolveSidebarProjectScopeKey({ diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index b09199bc42e6e..e126d096718e4 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -67,7 +67,9 @@ restores every environment. Your choices are saved on this client across reloads including when an environment goes offline. Newly connected environments are shown by default. If you deselect every environment for the selected project, the project filter -resets to **All projects**. +resets to **All projects** while other environments remain selected. With no +environments selected, your project choice is kept and returns when you check +an environment that contains it. ## Pin and reorder threads