diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 9705bf84e73ea..27631e9eb49bb 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -37,6 +37,7 @@ import { groupSidebarThreadsByProject, resolveSidebarFolderDot, sidebarFolderExpansionKeys, + filterSidebarV2VisibleThreads, } from "./Sidebar.logic"; import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { @@ -62,6 +63,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); @@ -819,6 +879,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"); @@ -1534,7 +1655,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 }, @@ -1542,9 +1663,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 8c0e678ff6672..841dcb37874e0 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -618,6 +618,7 @@ export function filterSidebarV2VisibleThreads< threads: readonly T[], scopedProjectKeys: ReadonlySet | null, delegatedUnderParent: ReadonlySet, + excludedEnvironmentIds?: ReadonlySet, ): T[] { return threads.filter( (thread) => @@ -627,6 +628,7 @@ export function filterSidebarV2VisibleThreads< thread.lineage, delegatedUnderParent, ) && + !excludedEnvironmentIds?.has(thread.environmentId) && (scopedProjectKeys === null || scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), ); @@ -1444,10 +1446,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 3314537973cbe..70a6ae2489bab 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, @@ -67,6 +67,7 @@ import { FolderIcon, ListFilterIcon, PlusIcon, + BanIcon, SettingsIcon, } from "lucide-react"; import { @@ -108,7 +109,10 @@ import { } from "@t3tools/client-runtime/state/project-grouping"; import { projectGroupsSpanEnvironments, + buildSidebarProjectPickerEntries, resolveSidebarProjectScopeKeys, + filterSidebarProjectScopeKeysByEnvironment, + toggleSidebarProjectScopeKey, getSidebarProjectSettingsKey, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; @@ -251,6 +255,8 @@ 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 { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { TooltipProvider } from "./ui/tooltip"; import { composerDraftHasUserContent, @@ -352,6 +358,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; @@ -417,6 +424,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; @@ -431,6 +439,7 @@ function useSidebarThreads(input: { const { projectScopeKey, scopedProjectKeys, + excludedEnvironmentIds, serverConfigs, connectedEnvironmentIds, optimisticDrop, @@ -468,20 +477,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, @@ -494,7 +505,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, }); @@ -624,7 +640,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[] = []; @@ -804,12 +825,12 @@ 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(); const providerEntriesByEnvironment = useAtomValue(sidebarProviderEntriesAtom); - const projectGroups = useSidebarProjectGroups(); + const projectGroups = useSidebarProjectGroups(excludedEnvironmentIds); const environmentLabelById = new Map( environments.map((environment) => [environment.environmentId, environment.label] as const), ); @@ -828,10 +849,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, @@ -843,6 +865,7 @@ function useSidebarLookups() { projectDisplayNameByKey, projectGroupByScopeKey, projectScopeItems, + environments, }; } @@ -900,6 +923,15 @@ function readRouteThreadKey(router: AnyRouter): string | null { } export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReveal }) { + const persistedExcludedEnvironmentIds = useUiStateStore( + (state) => state.sidebarExcludedEnvironmentIds, + ); + const setExcludedEnvironmentIds = useUiStateStore( + (state) => state.setSidebarExcludedEnvironmentIds, + ); + const excludedEnvironmentIds = new Set( + persistedExcludedEnvironmentIds.map((id) => EnvironmentId.make(id)), + ); const { projects, serverConfigs, @@ -910,7 +942,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve projectDisplayNameByKey, projectGroupByScopeKey, projectScopeItems: lookupProjectScopeItems, - } = useSidebarLookups(); + environments, + } = useSidebarLookups(excludedEnvironmentIds); + const selectedEnvironments = environments.filter( + (environment) => !excludedEnvironmentIds.has(environment.environmentId), + ); + const environmentFilterActive = selectedEnvironments.length < environments.length; const delegatedUnderParent = useDelegatedUnderParent(); const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); @@ -1014,6 +1051,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); @@ -1081,6 +1121,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), @@ -1130,15 +1171,21 @@ 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; 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 @@ -1185,8 +1232,24 @@ 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 = filterSidebarProjectScopeKeysByEnvironment({ + groups: projectGroups, + environmentIds: environments.map((environment) => environment.environmentId), + keys: persistedProjectScopeKeys, + settings: projectGroupingSettings, + previousExcludedEnvironmentIds: excludedEnvironmentIds, + excludedEnvironmentIds: excludedIds, + }); + if (visibleScopeKeys.length !== persistedProjectScopeKeys.length) { + setPersistedProjectScopeKeys(visibleScopeKeys); + } + }; const threadSearch = useThreadSearch( - connectedEnvironmentIds, + searchEnvironmentIds, threadSearchQuery, scopedProjectGroups.length === 0 ? {} @@ -1199,6 +1262,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; } @@ -1249,8 +1315,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 }) => { @@ -1279,9 +1353,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"); @@ -1333,6 +1415,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve } = useSidebarThreads({ projectScopeKey, scopedProjectKeys, + excludedEnvironmentIds, serverConfigs, connectedEnvironmentIds, optimisticDrop, @@ -1342,7 +1425,12 @@ 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) => @@ -3005,42 +3093,73 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve 0 + (scopedProjectGroups.length > 0 ? `Filter threads by project: ${scopedProjectGroups.map((project) => project.displayName).join(", ")}` - : "Filter threads by project" + : "Filter threads by project") + + (environmentFilterActive + ? ` (environments: ${selectedEnvironments.map((environment) => environment.label).join(", ") || "none"})` + : "") } /> } > - {scopedProjectGroups.length > 1 ? ( - - {scopedProjectGroups.slice(0, 2).map((project, index) => ( - - - - ))} - - ) : scopedProjectGroups[0] ? ( - - - - ) : ( - - )} + + {scopedProjectGroups.length > 1 && !environmentFilterActive ? ( + + {scopedProjectGroups.slice(0, 2).map((project, index) => ( + + + + ))} + + ) : scopedProjectGroups[0] ? ( + + + + ) : ( + + )} + {environmentFilterActive ? ( + selectedEnvironments.length === 0 ? ( + + ) : ( + <> + {selectedEnvironments.slice(0, 2).map((environment) => ( + + ))} + {selectedEnvironments.length > 2 ? ( + + + + + ) : null} + + ) + ) : 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; - 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 || environmentFilterActive ? ( + + ) : null}
} @@ -3476,6 +3606,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.browser.test.tsx b/apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx index 41cd795013e45..65509cdb9aeb3 100644 --- a/apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx +++ b/apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx @@ -23,20 +23,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 +47,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/SidebarDraftBlock.tsx b/apps/web/src/components/sidebar/SidebarDraftBlock.tsx index 7ac90c0d48219..a18044291aa69 100644 --- a/apps/web/src/components/sidebar/SidebarDraftBlock.tsx +++ b/apps/web/src/components/sidebar/SidebarDraftBlock.tsx @@ -10,6 +10,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..ad5c1bdf9a8df --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarEnvironmentFilter.browser.test.tsx @@ -0,0 +1,111 @@ +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({ + availableEnvironments = environments, +}: { + availableEnvironments?: typeof environments; +}) { + 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 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(); + 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(); + 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..3c05373734e1a --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarEnvironmentFilter.tsx @@ -0,0 +1,103 @@ +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 = selected.length === props.environments.length; + 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 a565cefd069c5..eb1a74f67e6cc 100644 --- a/apps/web/src/components/sidebar/routeReveal.ts +++ b/apps/web/src/components/sidebar/routeReveal.ts @@ -71,6 +71,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 } = @@ -81,7 +82,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/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index b2081b4b8fe9a..112a6379f2216 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -10,6 +10,8 @@ import { buildSidebarProjectSnapshots, resolveSidebarProjectScopeKey, getSidebarProjectSettingsKey, + filterSidebarProjectScopeKeysByEnvironment, + toggleSidebarProjectScopeKey, } from "./sidebarProjectGrouping"; import type { Project } from "./types"; @@ -104,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"]); }, ); @@ -125,6 +138,145 @@ 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 environmentIds = [primaryEnvironmentId, remoteEnvironmentId]; + const scope = { + key, + environmentIds, + previousExcludedEnvironmentIds: new Set(), + excludedEnvironmentIds: new Set([remoteEnvironmentId]), + }; + expect(resolveSidebarProjectScopeKey({ ...scope, groups })).toBe(key); + const persistedKeys = [key, "unloaded-project"]; + expect( + filterSidebarProjectScopeKeysByEnvironment({ + ...scope, + groups, + keys: persistedKeys, + }), + ).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(); + expect( + filterSidebarProjectScopeKeysByEnvironment({ + ...scope, + groups: remainingGroups, + environmentIds, + keys: persistedKeys, + }), + ).toEqual(["unloaded-project"]); + expect( + filterSidebarProjectScopeKeysByEnvironment({ + groups: remainingGroups, + environmentIds, + keys: persistedKeys, + previousExcludedEnvironmentIds: scope.excludedEnvironmentIds, + excludedEnvironmentIds: new Set([ + remoteEnvironmentId, + EnvironmentId.make("unrelated-environment"), + ]), + }), + ).toEqual(persistedKeys); + expect( + filterSidebarProjectScopeKeysByEnvironment({ + groups: remainingGroups, + environmentIds, + keys: persistedKeys, + previousExcludedEnvironmentIds: scope.excludedEnvironmentIds, + excludedEnvironmentIds: new Set(), + }), + ).toEqual(persistedKeys); + expect( + resolveSidebarProjectScopeKey({ groups: remainingGroups, key, canClearMissingScope: true }), + ).toBe(key); + expect( + resolveSidebarProjectScopeKey({ + ...scope, + groups: remainingGroups, + excludedEnvironmentIds: new Set(), + }), + ).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"), @@ -277,6 +429,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/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, + ); } diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 8fb8c4a5a1c6c..01579d01792fa 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,12 +121,47 @@ 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 }) ?? [])), ]; } +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({ + ...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: { projects: ReadonlyArray; settings: ProjectGroupingSettings; @@ -209,32 +253,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 }]; diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 804757e78307a..f4b48b7d7616f 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,15 @@ describe("parsePersistedState", () => { invalid: "no" as unknown as boolean, }, projectOrder: ["physical-b", "", "physical-a", "physical-b"], + sidebarExcludedEnvironmentIds: [ + "studio", + "", + " ", + "\t\n", + "offline-host", + "studio", + 1 as unknown as string, + ], threadLastVisitedAtById: { "environment:thread-1": "2026-02-25T12:35:00.000Z", invalid: "not-a-date", @@ -126,6 +136,7 @@ describe("parsePersistedState", () => { }, defaultAdvertisedEndpointKey: "desktop-core:lan:http", sidebarProjectScopeKeys: [], + sidebarExcludedEnvironmentIds: ["studio", "offline-host"], pullRequestMergeMethod: "merge", threadChangedFilesExpandedById: { "environment:thread-1": { @@ -238,6 +249,7 @@ describe("uiStateStore persistence", () => { defaultAdvertisedEndpointKey: "desktop-core:lan:http", sidebarProjectScopeKeys: [], sidebarProjectScopeKey: null, + sidebarExcludedEnvironmentIds: [], threadChangedFilesExpansionVersion: 2, threadChangedFilesExpandedById: { "environment:thread-1": { @@ -252,16 +264,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 b2ac1a508c7a6..8dfcd450c8635 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -1,10 +1,11 @@ import { Debouncer } from "@tanstack/react-pacer"; -import { PullRequestMergeMethod } from "@t3tools/contracts"; +import { EnvironmentId, 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", @@ -29,6 +30,7 @@ export interface PersistedUiState { defaultAdvertisedEndpointKey?: string | null; sidebarProjectScopeKey?: string | null; sidebarProjectScopeKeys?: string[]; + sidebarExcludedEnvironmentIds?: string[]; threadChangedFilesExpansionVersion?: number; threadChangedFilesExpandedById?: Record>; pullRequestMergeMethod?: string; @@ -38,6 +40,7 @@ export interface UiProjectState { projectExpandedById: Record; projectOrder: string[]; sidebarProjectScopeKeys: string[]; + sidebarExcludedEnvironmentIds: string[]; } export interface UiThreadState { @@ -60,6 +63,7 @@ const initialState: UiState = { projectExpandedById: {}, projectOrder: [], sidebarProjectScopeKeys: [], + sidebarExcludedEnvironmentIds: [], threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, @@ -153,6 +157,9 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { sidebarProjectScopeKeys: sanitizeStringArray( parsed.sidebarProjectScopeKeys ?? [parsed.sidebarProjectScopeKey], ), + sidebarExcludedEnvironmentIds: sanitizeStringArray(parsed.sidebarExcludedEnvironmentIds).filter( + isEnvironmentId, + ), pullRequestMergeMethod: isPullRequestMergeMethod(parsed.pullRequestMergeMethod) ? parsed.pullRequestMergeMethod : initialState.pullRequestMergeMethod, @@ -222,6 +229,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 @@ -428,6 +436,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: ( @@ -449,6 +458,10 @@ export const useUiStateStore = create((set) => ({ set((state) => setDefaultAdvertisedEndpointKey(state, key)), setSidebarProjectScopeKeys: (projectKeys) => set((state) => setSidebarProjectScopeKeys(state, projectKeys)), + setSidebarExcludedEnvironmentIds: (environmentIds) => + set({ + sidebarExcludedEnvironmentIds: sanitizeStringArray(environmentIds).filter(isEnvironmentId), + }), 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 4c02f1c79baee..dbcab4837ef40 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -58,6 +58,20 @@ 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. 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** 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 Pin a thread from its menu to keep it above your active work.