diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 616ff8c4ec05..008441d047b8 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -16,6 +16,7 @@ import { filterSidebarProjectScopeItems, filterSidebarV2VisibleThreads, formatWorkingDurationLabel, + pruneDisabledEnvironmentIds, getFallbackThreadIdAfterDelete, getProjectSortTimestamp, getSidebarForkParentThreadId, @@ -56,6 +57,7 @@ import { shouldCreateNewThreadInCurrentProject, shouldNavigateAfterThreadPark, THREAD_JUMP_HINT_SHOW_DELAY_MS, + toggleDisabledEnvironmentId, type SidebarListItem, type SidebarListMarker, type SidebarSection, @@ -1881,6 +1883,119 @@ describe("sortLogicalProjectsForSidebar", () => { }); }); +describe("toggleDisabledEnvironmentId", () => { + const envA = EnvironmentId.make("env-a"); + const envB = EnvironmentId.make("env-b"); + const all = [envA, envB]; + + it("disables an environment", () => { + const next = toggleDisabledEnvironmentId({ + disabledIds: new Set(), + environmentId: envA, + enabled: false, + allEnvironmentIds: all, + }); + expect([...next]).toEqual([envA]); + }); + + it("re-enables a disabled environment", () => { + const next = toggleDisabledEnvironmentId({ + disabledIds: new Set([envA]), + environmentId: envA, + enabled: true, + allEnvironmentIds: all, + }); + expect(next.size).toBe(0); + }); + + it("refuses to disable the last enabled environment", () => { + const current: ReadonlySet = new Set([envA]); + const next = toggleDisabledEnvironmentId({ + disabledIds: current, + environmentId: envB, + enabled: false, + allEnvironmentIds: all, + }); + expect(next).toBe(current); + }); + + it("returns the same instance when disabling an already-disabled environment", () => { + const current: ReadonlySet = new Set([envA]); + const next = toggleDisabledEnvironmentId({ + disabledIds: current, + environmentId: envA, + enabled: false, + allEnvironmentIds: all, + }); + expect(next).toBe(current); + }); + + it("returns the same instance when enabling an already-enabled environment", () => { + const current: ReadonlySet = new Set([envB]); + const next = toggleDisabledEnvironmentId({ + disabledIds: current, + environmentId: envA, + enabled: true, + allEnvironmentIds: all, + }); + expect(next).toBe(current); + }); +}); + +describe("pruneDisabledEnvironmentIds", () => { + const envA = EnvironmentId.make("env-a"); + const envB = EnvironmentId.make("env-b"); + const envC = EnvironmentId.make("env-c"); + + it("returns the same instance when nothing is disabled", () => { + const current: ReadonlySet = new Set(); + expect( + pruneDisabledEnvironmentIds({ + disabledIds: current, + connectedEnvironmentIds: new Set([envA]), + }), + ).toBe(current); + }); + + it("drops disabled environments that left the catalog", () => { + const next = pruneDisabledEnvironmentIds({ + disabledIds: new Set([envA, envC]), + connectedEnvironmentIds: new Set([envA, envB]), + }); + expect([...next]).toEqual([envA]); + }); + + it("returns the same instance when every disabled environment is still connected", () => { + const current: ReadonlySet = new Set([envA]); + expect( + pruneDisabledEnvironmentIds({ + disabledIds: current, + connectedEnvironmentIds: new Set([envA, envB]), + }), + ).toBe(current); + }); + + it("returns all-enabled for the render where the last enabled environment disconnects", () => { + // envB (the only enabled environment) disconnected; keeping envA disabled + // would hide every thread while the filter button no longer renders. + const current = new Set([envA]); + const next = pruneDisabledEnvironmentIds({ + disabledIds: current, + connectedEnvironmentIds: new Set([envA]), + }); + expect(next).not.toBe(current); + expect(next.size).toBe(0); + }); + + it("clears the filter when the catalog is empty", () => { + const next = pruneDisabledEnvironmentIds({ + disabledIds: new Set([envA]), + connectedEnvironmentIds: new Set(), + }); + expect(next.size).toBe(0); + }); +}); + describe("sortSidebarV2ProjectGroups", () => { it("does not let a hidden subagent thread reorder projects", () => { const olderProjectId = ProjectId.make("project-older"); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 3fe1881332a5..b19e168cf14f 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1455,3 +1455,49 @@ export function sortScopedProjectsForSidebar< left.id.localeCompare(right.id), ); } + +// --- Sidebar environment filter -------------------------------------------- +// The filter stores the DISABLED environments so a newly connected +// environment is visible by default and "all enabled" is the empty set. Both +// helpers return the input set instance when nothing changed so React state +// setters can skip the update entirely. + +export function toggleDisabledEnvironmentId(input: { + disabledIds: ReadonlySet; + environmentId: EnvironmentId; + enabled: boolean; + allEnvironmentIds: ReadonlyArray; +}): ReadonlySet { + if (input.disabledIds.has(input.environmentId) !== input.enabled) { + // Already in the requested state (repeated or stale checkbox event): + // keep the instance so the caller's state update is a no-op. + return input.disabledIds; + } + const next = new Set(input.disabledIds); + if (input.enabled) { + next.delete(input.environmentId); + return next; + } + next.add(input.environmentId); + // Never allow disabling the last enabled environment: an empty thread list + // with no visible reason reads as data loss. (The menu also disables the + // last enabled item; this guards races and stale events.) + const someEnabled = input.allEnvironmentIds.some((id) => !next.has(id)); + return someEnabled ? next : input.disabledIds; +} + +export function pruneDisabledEnvironmentIds(input: { + disabledIds: ReadonlySet; + connectedEnvironmentIds: ReadonlySet; +}): ReadonlySet { + const { connectedEnvironmentIds, disabledIds } = input; + if (disabledIds.size === 0) return disabledIds; + const next = new Set([...disabledIds].filter((id) => connectedEnvironmentIds.has(id))); + // A catalog change can strand the filter with nothing enabled (the last + // enabled environment disconnected) — and on single-environment catalogs + // the filter button no longer renders, so nothing could undo it. Reset to + // all-enabled instead of hiding every thread. + const someEnabled = [...connectedEnvironmentIds].some((id) => !next.has(id)); + if (!someEnabled) return new Set(); + return next.size === disabledIds.size ? disabledIds : next; +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 56e51346b79e..c603ff5ab829 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -78,6 +78,7 @@ import { PinIcon, PinOffIcon, PlusIcon, + ServerIcon, SettingsIcon, ShieldQuestionIcon, SquarePenIcon, @@ -148,6 +149,7 @@ import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { useNowMinute } from "../hooks/useNowMinute"; import { + useEnvironment, useEnvironmentIdentities, useConnectedEnvironmentIds, useEnvironmentMachines, @@ -200,6 +202,7 @@ import { isTrailingDoubleClick, orderItemsByPreferredIds, planSidebarThreadDrop, + pruneDisabledEnvironmentIds, reduceSidebarProjectScopeMenuState, resolveAdjacentThreadId, resolveSidebarSweepKeys, @@ -223,6 +226,7 @@ import { sortSidebarV2ProjectGroups, sortThreadsForSidebar, sortWorkingThreadsBySend, + toggleDisabledEnvironmentId, useThreadJumpHintVisibility, useRetainedValue, useSidebarRowSubscriptionLease, @@ -280,7 +284,15 @@ import { import { SidebarContent, SidebarGroup, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { SidebarHeaderIconButton, SidebarThreadHeader } from "./sidebar/SidebarThreadHeader"; -import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuShortcut, MenuTrigger } from "./ui/menu"; +import { + Menu, + MenuCheckboxItem, + MenuItem, + MenuPopup, + MenuSeparator, + MenuShortcut, + MenuTrigger, +} from "./ui/menu"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; import { MiddleTruncate } from "./ui/middle-truncate"; import { @@ -365,6 +377,15 @@ function JumpHintBadge(props: { label: string }) { ); } +// Rendered only while the environment filter menu is open, so the sidebar +// itself keeps its label-only environment subscription. +function SidebarEnvironmentFilterUrl(props: { environmentId: EnvironmentId }) { + const displayUrl = useEnvironment(props.environmentId)?.displayUrl; + return displayUrl ? ( + {displayUrl} + ) : null; +} + // Self-ticking so only this span re-renders each second, not the whole row. function WorkingDuration(props: { startedAt: string | null }) { const startedMs = props.startedAt !== null ? Date.parse(props.startedAt) : Number.NaN; @@ -969,6 +990,7 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { projectByKey: ReadonlyMap; projectDisplayNameByKey: ReadonlyMap; scopedProjectKeys: ReadonlySet | null; + disabledEnvironmentIds: ReadonlySet; routeDraftId: string | null; onNavigateToDraft: (draftId: DraftId) => void; onDraftContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void; @@ -1014,6 +1036,9 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { ) { continue; } + if (props.disabledEnvironmentIds.has(session.environmentId)) { + continue; + } if (draftKey === props.routeDraftId) { // Open draft: render the frozen entry snapshot, or nothing for a // draft that has never been left. Gated on the LIVE session above so @@ -1035,6 +1060,7 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { draftThreadsByThreadKey, draftsByThreadKey, frozenActive, + props.disabledEnvironmentIds, props.routeDraftId, props.scopedProjectKeys, ]); @@ -2624,6 +2650,53 @@ export default function Sidebar() { // fresh clock whenever it recomputes. const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); + // Environment filter: a checklist of environments whose threads the list + // shows, composing with the project scope. Stored as the DISABLED set so a + // newly connected environment is visible by default and "select all" is the + // natural empty state. Hidden entirely on single-environment setups. + // Session-only; project scope remains persisted. + const [configuredDisabledEnvironmentIds, setConfiguredDisabledEnvironmentIds] = useState< + ReadonlySet + >(() => new Set()); + const connectedEnvironmentIds = useMemo( + () => new Set(environmentLabelById.keys()), + [environmentLabelById], + ); + // Derive the safe set during render so a catalog change can never paint a + // filtered-empty frame before the stored selection is updated. + const disabledEnvironmentIds = useMemo( + () => + pruneDisabledEnvironmentIds({ + disabledIds: configuredDisabledEnvironmentIds, + connectedEnvironmentIds, + }), + [configuredDisabledEnvironmentIds, connectedEnvironmentIds], + ); + const isEnvironmentFilterActive = disabledEnvironmentIds.size > 0; + // An environment that leaves the catalog must not keep filtering from + // beyond the grave: prune it so its threads reappear if it reconnects. + // Pruning returns the same instance when nothing changed, so this + // render-time adjustment settles after one pass. + if (disabledEnvironmentIds !== configuredDisabledEnvironmentIds) { + setConfiguredDisabledEnvironmentIds(disabledEnvironmentIds); + } + const handleToggleEnvironment = useCallback( + (environmentId: EnvironmentId, checked: boolean) => { + setConfiguredDisabledEnvironmentIds((current) => + toggleDisabledEnvironmentId({ + disabledIds: current, + environmentId, + enabled: checked, + allEnvironmentIds: environments.map((environment) => environment.environmentId), + }), + ); + }, + [environments], + ); + const handleEnableAllEnvironments = useCallback(() => { + setConfiguredDisabledEnvironmentIds((current) => (current.size === 0 ? current : new Set())); + }, []); + // Project scope: one menu above the list. Scoping filters the list without // making the header width depend on the number or length of project names. // The selection lives in the persisted UI store next to the other sidebar @@ -2640,17 +2713,33 @@ export default function Sidebar() { }), [persistedProjectScopeKey, projectGroups, projectGroupingSettings], ); + // The project menu only offers projects with a presence in an enabled + // environment; the full group list keeps driving everything else. + const menuProjectGroups = useMemo( + () => + disabledEnvironmentIds.size === 0 + ? projectGroups + : projectGroups.filter((group) => + group.memberProjectRefs.some( + (projectRef) => !disabledEnvironmentIds.has(projectRef.environmentId), + ), + ), + [disabledEnvironmentIds, projectGroups], + ); // {value, label} items let Base UI drive the combobox selection contract // while the popup search filters the same collection. const projectScopeItems = useMemo( () => [ { value: "all", label: "All projects" }, - ...projectGroups.map((project) => ({ + ...menuProjectGroups.map((project) => ({ value: project.projectKey, + // The logical label already represents every grouped clone and never + // needs a redundant environment suffix, even when only one + // environment is enabled. label: project.displayName, })), ], - [projectGroups], + [menuProjectGroups], ); // Same-named projects on two machines are only told apart by where they // live, so rows on another machine carry its icon once the catalog spans @@ -2744,6 +2833,19 @@ export default function Sidebar() { projectGroupingSettings, setProjectScopeKey, ]); + // Disabling every environment the scoped project lives in would pin the + // list empty with no visible reason; widen back to all projects. + useEffect(() => { + if ( + isEnvironmentFilterActive && + scopedProjectGroup !== null && + !scopedProjectGroup.memberProjectRefs.some( + (projectRef) => !disabledEnvironmentIds.has(projectRef.environmentId), + ) + ) { + setProjectScopeKey(null); + } + }, [disabledEnvironmentIds, isEnvironmentFilterActive, scopedProjectGroup, setProjectScopeKey]); // Count-only subscription: the parent needs "are there draft rows" for the // empty state, while SidebarDraftBlock owns the per-keystroke content // subscription. Selecting a number keeps typing in a draft composer from @@ -2766,6 +2868,9 @@ export default function Sidebar() { ) { continue; } + if (disabledEnvironmentIds.has(session.environmentId)) { + continue; + } count += 1; } return count; @@ -2774,7 +2879,7 @@ export default function Sidebar() { // hidden now, and bulk actions must never count or touch invisible rows. useEffect(() => { clearSelection(); - }, [clearSelection, projectScopeKey]); + }, [clearSelection, disabledEnvironmentIds, projectScopeKey]); const openProjectSettings = useCallback( (projectGroup: SidebarProjectSnapshot, projectRef?: ScopedProjectRef) => { @@ -2850,7 +2955,11 @@ export default function Sidebar() { const preciseNow = new Date().toISOString(); // Subagent child threads live in the parent's Agents surface, not the // sidebar roster (v2 models them as real threads with lineage). - const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys); + const scopedVisible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys); + const visible = + disabledEnvironmentIds.size === 0 + ? scopedVisible + : scopedVisible.filter((thread) => !disabledEnvironmentIds.has(thread.environmentId)); inboxReturns.observe(workingShelfEnabled ? threads : null); const pinned: EnvironmentThreadShell[] = []; const active: EnvironmentThreadShell[] = []; @@ -2951,6 +3060,7 @@ export default function Sidebar() { snoozeNow: preciseNow, }; }, [ + disabledEnvironmentIds, nowMinute, optimisticDrop, scopedProjectKeys, @@ -3029,7 +3139,7 @@ export default function Sidebar() { // filter context changes so a scope/search flip never inherits a deep // page state. const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT); - const settledResetKey = projectScopeKey ?? "all"; + const settledResetKey = JSON.stringify([[...disabledEnvironmentIds].sort(), projectScopeKey]); const lastSettledResetKeyRef = useRef(settledResetKey); if (lastSettledResetKeyRef.current !== settledResetKey) { lastSettledResetKeyRef.current = settledResetKey; @@ -5214,6 +5324,85 @@ export default function Sidebar() { searchResultCount={threadSearchResults.length} activeSearchResultIndex={activeSearchResultIndex} onClearSearch={clearThreadSearch} + environmentFilter={ + environments.length > 1 ? ( + + + } + > + + {isEnvironmentFilterActive ? ( + + ) : null} + + + + All environments + + {environments.map((environment) => { + const isEnabled = !disabledEnvironmentIds.has(environment.environmentId); + const isLastEnabled = + isEnabled && environments.length - disabledEnvironmentIds.size === 1; + const item = ( + + handleToggleEnvironment(environment.environmentId, checked) + } + className={cn( + "[&>span:last-child]:min-w-0", + isLastEnabled && "data-disabled:pointer-events-auto", + )} + > + + {environment.label} + + + + ); + if (!isLastEnabled) return item; + return ( + + + + At least one environment must remain selected + + + ); + })} + + + ) : null + } /> } @@ -5476,6 +5665,7 @@ export default function Sidebar() { projectByKey={projectByKey} projectDisplayNameByKey={projectDisplayNameByKey} scopedProjectKeys={scopedProjectKeys} + disabledEnvironmentIds={disabledEnvironmentIds} routeDraftId={routeDraftIdForRows} onNavigateToDraft={navigateToDraft} onDraftContextMenu={handleDraftContextMenu} @@ -5647,6 +5837,8 @@ export default function Sidebar() { ) : scopedProjectGroup ? ( `No threads in ${scopedProjectGroup.displayName} yet` + ) : isEnvironmentFilterActive ? ( + "No threads in the enabled environments yet" ) : ( "No threads yet" )} diff --git a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx index e2bd4bfb74e5..946a60c44f6e 100644 --- a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx @@ -31,6 +31,8 @@ export interface SidebarThreadHeaderProps { hasProjects: boolean; /** The project scope combobox, rendered as the first icon of the group. */ projectScope: ReactNode; + /** The environment filter menu, rendered next to the project scope. */ + environmentFilter?: ReactNode; onNewProject: () => void; /** Receives the click so Shift+click can skip the project picker. */ onNewThread: (event: ReactMouseEvent) => void; @@ -53,6 +55,7 @@ export function SidebarThreadHeader({ searchFieldRef, hasProjects, projectScope, + environmentFilter, onNewProject, onNewThread, newThreadDisabled, @@ -132,6 +135,7 @@ export function SidebarThreadHeader({ ) : null} + {environmentFilter}