From d23b81e07a236c9294893ac46e206117135e5109 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:28:03 -0700 Subject: [PATCH 1/6] feat(client-runtime): derive the set of offline environments Co-Authored-By: Claude Sonnet 5.5 --- apps/web/src/state/environments.ts | 4 ++++ .../src/connection/presentation.test.ts | 19 +++++++++++++++- .../src/connection/presentation.ts | 8 +++++++ .../src/state/presentation.test.ts | 22 +++++++++++++++++++ .../client-runtime/src/state/presentation.ts | 14 ++++++++++++ 5 files changed, 66 insertions(+), 1 deletion(-) diff --git a/apps/web/src/state/environments.ts b/apps/web/src/state/environments.ts index 2fe8f858bf599..586ac44a3b052 100644 --- a/apps/web/src/state/environments.ts +++ b/apps/web/src/state/environments.ts @@ -103,6 +103,10 @@ export function useEnvironmentMachines() { return useAtomValue(environmentSummaries.machineByIdAtom); } +export function useOfflineEnvironmentIds() { + return useAtomValue(environmentSummaries.offlineEnvironmentIdsAtom); +} + export function useConnectedEnvironmentIds() { return useAtomValue(environmentSummaries.connectedEnvironmentIdsAtom); } diff --git a/packages/client-runtime/src/connection/presentation.test.ts b/packages/client-runtime/src/connection/presentation.test.ts index 2a27148fcd613..5cdd3c7bf8cfb 100644 --- a/packages/client-runtime/src/connection/presentation.test.ts +++ b/packages/client-runtime/src/connection/presentation.test.ts @@ -13,7 +13,12 @@ import { RelayConnectionTarget, type SupervisorConnectionState, } from "./model.ts"; -import { environmentMcpUrl, presentConnectionState } from "./presentation.ts"; +import { + type EnvironmentConnectionPhase, + environmentMcpUrl, + isEnvironmentOffline, + presentConnectionState, +} from "./presentation.ts"; const TARGET = new BearerConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -107,6 +112,18 @@ describe("connection presentation", () => { ).toBe("https://tunnel.example.test/mcp"); }); + it.each<[EnvironmentConnectionPhase, boolean]>([ + ["connected", false], + ["connecting", false], + ["reconnecting", true], + ["offline", true], + ["available", true], + ["error", true], + ["unsupported", true], + ])("treats %s as offline: %s", (phase, offline) => { + expect(isEnvironmentOffline(phase)).toBe(offline); + }); + it("distinguishes initial connection, reconnect, and retry errors", () => { expect(presentConnectionState(supervisorState({ phase: "connecting", attempt: 1 }))).toEqual({ phase: "connecting", diff --git a/packages/client-runtime/src/connection/presentation.ts b/packages/client-runtime/src/connection/presentation.ts index c579f9587a3df..2a8ae86a692dc 100644 --- a/packages/client-runtime/src/connection/presentation.ts +++ b/packages/client-runtime/src/connection/presentation.ts @@ -64,6 +64,14 @@ export function presentConnectionState( } } +/** + * "connecting" counts as online so a first attempt at startup does not hide rows that return + * moments later; "reconnecting" counts as offline so retries do not flicker them. + */ +export function isEnvironmentOffline(phase: EnvironmentConnectionPhase): boolean { + return phase !== "connected" && phase !== "connecting"; +} + export function connectionStatusText(connection: EnvironmentConnectionPresentation): string { switch (connection.phase) { case "available": diff --git a/packages/client-runtime/src/state/presentation.test.ts b/packages/client-runtime/src/state/presentation.test.ts index 71e9c868cd439..f40106f398a87 100644 --- a/packages/client-runtime/src/state/presentation.test.ts +++ b/packages/client-runtime/src/state/presentation.test.ts @@ -92,6 +92,28 @@ describe("environment summary subscriptions", () => { }); }); +describe("offline environments", () => { + it("lists environments whose connection is neither connected nor first-attempt connecting", () => { + const h = harness(); + const stop = h.registry.subscribe(h.offlineEnvironmentIdsAtom, () => {}); + const supervisor = (phase: "connected" | "connecting" | "backoff") => + AsyncResult.success({ ...AVAILABLE_CONNECTION_STATE, phase, attempt: 1 }); + try { + expect(h.registry.get(h.offlineEnvironmentIdsAtom)).toEqual([]); + h.registry.set(h.state, supervisor("backoff")); + expect(h.registry.get(h.offlineEnvironmentIdsAtom)).toEqual([FIRST, SECOND]); + h.registry.set(h.state, supervisor("connecting")); + expect(h.registry.get(h.offlineEnvironmentIdsAtom)).toEqual([]); + h.registry.set(h.state, supervisor("backoff")); + h.registry.set(h.state, supervisor("connected")); + expect(h.registry.get(h.offlineEnvironmentIdsAtom)).toEqual([]); + } finally { + stop(); + h.registry.dispose(); + } + }); +}); + describe("presentations with providers", () => { it("leaves out disabled connections until they are re-enabled", () => { const h = harness(); diff --git a/packages/client-runtime/src/state/presentation.ts b/packages/client-runtime/src/state/presentation.ts index f3ffb03b0bcfe..54cfc8f246143 100644 --- a/packages/client-runtime/src/state/presentation.ts +++ b/packages/client-runtime/src/state/presentation.ts @@ -11,6 +11,7 @@ import { AsyncResult, Atom } from "effect/reactivity"; import { AVAILABLE_CONNECTION_STATE, type SupervisorConnectionState } from "../connection/model.ts"; import { connectionCatalogDisplayUrl, + isEnvironmentOffline, presentConnectionState, type EnvironmentConnectionPhase, type EnvironmentConnectionPresentation, @@ -295,6 +296,18 @@ export function createEnvironmentSummaryAtoms(input: { ? previous : next; }); + const offlineEnvironmentIdsAtom = Atom.make((get) => { + const next = get(environmentIdsAtom).filter((id) => { + const connectionState = get(environmentAtom(id))?.connectionState; + return connectionState !== undefined && isEnvironmentOffline(connectionState); + }); + const previous = Option.getOrNull(get.self>()); + return previous !== null && + previous.length === next.length && + next.every((id, index) => id === previous[index]) + ? previous + : next; + }); const machineByIdAtom = Atom.make((get) => { const next = new Map( get(environmentIdsAtom).map( @@ -321,6 +334,7 @@ export function createEnvironmentSummaryAtoms(input: { return { environmentIdsAtom, connectedEnvironmentIdsAtom, + offlineEnvironmentIdsAtom, identitiesAtom, environmentsAtom, machineByIdAtom, From fba76039a0f4dc32a256ed6977d0cddd2ce3bfd4 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:29:07 -0700 Subject: [PATCH 2/6] feat(web): persist a preference to hide offline environments in the sidebar Co-Authored-By: Claude Sonnet 5.5 --- apps/web/src/uiStateStore.test.ts | 8 +++++++- apps/web/src/uiStateStore.ts | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index dbfb60d15bd20..2e186ec98dff0 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: [], + sidebarHideOfflineEnvironments: false, threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, @@ -126,6 +127,7 @@ describe("parsePersistedState", () => { }, defaultAdvertisedEndpointKey: "desktop-core:lan:http", sidebarProjectScopeKeys: [], + sidebarHideOfflineEnvironments: false, pullRequestMergeMethod: "merge", threadChangedFilesExpandedById: { "environment:thread-1": { @@ -232,6 +234,7 @@ describe("uiStateStore persistence", () => { defaultAdvertisedEndpointKey: "desktop-core:lan:http", sidebarProjectScopeKeys: [], sidebarProjectScopeKey: null, + sidebarHideOfflineEnvironments: false, threadChangedFilesExpansionVersion: 2, threadChangedFilesExpandedById: { "environment:thread-1": { @@ -248,13 +251,16 @@ 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 })); + persistState( + makeUiState({ sidebarProjectScopeKeys: keys, sidebarHideOfflineEnvironments: true }), + ); const persisted = JSON.parse( localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", ) as PersistedUiState; expect(parsePersistedState(persisted).sidebarProjectScopeKeys).toEqual(keys); + expect(parsePersistedState(persisted).sidebarHideOfflineEnvironments).toBe(true); }); it("keeps a single-project scope readable by older clients", () => { diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 328e1a6477132..f7b73474d771b 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[]; + sidebarHideOfflineEnvironments?: boolean; threadChangedFilesExpansionVersion?: number; threadChangedFilesExpandedById?: Record>; pullRequestMergeMethod?: string; @@ -37,6 +38,7 @@ export interface UiProjectState { projectExpandedById: Record; projectOrder: string[]; sidebarProjectScopeKeys: string[]; + sidebarHideOfflineEnvironments: boolean; } export interface UiThreadState { @@ -59,6 +61,7 @@ const initialState: UiState = { projectExpandedById: {}, projectOrder: [], sidebarProjectScopeKeys: [], + sidebarHideOfflineEnvironments: false, threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, @@ -154,6 +157,7 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { sidebarProjectScopeKeys: sanitizeStringArray( parsed.sidebarProjectScopeKeys ?? [parsed.sidebarProjectScopeKey], ), + sidebarHideOfflineEnvironments: parsed.sidebarHideOfflineEnvironments === true, pullRequestMergeMethod: isPullRequestMergeMethod(parsed.pullRequestMergeMethod) ? parsed.pullRequestMergeMethod : initialState.pullRequestMergeMethod, @@ -233,6 +237,7 @@ export function persistState(state: UiState): void { state.sidebarProjectScopeKeys.length === 1 ? (state.sidebarProjectScopeKeys[0] ?? null) : null, + sidebarHideOfflineEnvironments: state.sidebarHideOfflineEnvironments, threadChangedFilesExpansionVersion: THREAD_CHANGED_FILES_EXPANSION_VERSION, threadChangedFilesExpandedById: state.threadChangedFilesExpandedById, pullRequestMergeMethod: state.pullRequestMergeMethod, @@ -345,6 +350,12 @@ function setSidebarProjectScopeKeys(state: UiState, projectKeys: readonly string }; } +function setSidebarHideOfflineEnvironments(state: UiState, hide: boolean): UiState { + return state.sidebarHideOfflineEnvironments === hide + ? state + : { ...state, sidebarHideOfflineEnvironments: hide }; +} + function setPullRequestMergeMethod(state: UiState, method: PullRequestMergeMethod): UiState { return state.pullRequestMergeMethod === method ? state @@ -434,6 +445,7 @@ interface UiStateStore extends UiState { setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; setDefaultAdvertisedEndpointKey: (key: string | null) => void; setSidebarProjectScopeKeys: (projectKeys: readonly string[]) => void; + setSidebarHideOfflineEnvironments: (hide: boolean) => void; setPullRequestMergeMethod: (method: PullRequestMergeMethod) => void; setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void; reorderProjects: ( @@ -455,6 +467,8 @@ export const useUiStateStore = create((set) => ({ set((state) => setDefaultAdvertisedEndpointKey(state, key)), setSidebarProjectScopeKeys: (projectKeys) => set((state) => setSidebarProjectScopeKeys(state, projectKeys)), + setSidebarHideOfflineEnvironments: (hide) => + set((state) => setSidebarHideOfflineEnvironments(state, hide)), setPullRequestMergeMethod: (method) => set((state) => setPullRequestMergeMethod(state, method)), setProjectExpanded: (projectIds, expanded) => set((state) => setProjectExpanded(state, projectIds, expanded)), From ccc752c21ef4ae8e1735706ae2357cdbef6e07d2 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:33:28 -0700 Subject: [PATCH 3/6] feat(web): hide offline environment threads from the sidebar Adds a Hide offline environments toggle to the project filter. Threads of offline environments leave the sidebar and return when the environment connects. The open thread and folders that can still start a thread stay. Co-Authored-By: Claude Sonnet 5.5 --- apps/web/src/components/Sidebar.logic.test.ts | 67 +++++++++++++++++-- apps/web/src/components/Sidebar.logic.ts | 58 ++++++++++++++-- apps/web/src/components/Sidebar.tsx | 60 +++++++++++++++-- .../web/src/components/sidebar/routeReveal.ts | 6 +- 4 files changed, 175 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 4e446e4f3a3f2..825552a975e44 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,5 +1,6 @@ import { presentThreadShell } from "@t3tools/client-runtime/state/models"; import { threadKey } from "@t3tools/client-runtime/state/entities"; +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { isThreadWorking as isSidebarThreadWorking } from "@t3tools/client-runtime/state/thread-inbox"; import * as DateTime from "effect/DateTime"; import { deriveActiveWorkStartedAt } from "../session-logic.ts"; @@ -34,6 +35,8 @@ import { type SidebarListMarker, type SidebarSection, resolveSidebarDropVerb, + filterSidebarV2ListedThreads, + filterSidebarV2VisibleThreads, groupSidebarThreadsByProject, resolveSidebarFolderDot, sidebarFolderExpansionKeys, @@ -905,6 +908,47 @@ describe("sortScopedProjectsForSidebar", () => { }); }); +describe("filterSidebarV2VisibleThreads", () => { + it("hides threads of hidden environments alongside the project scope", () => { + const remoteEnvironmentId = EnvironmentId.make("environment-remote"); + const project = ProjectId.make("project-a"); + const other = ProjectId.make("project-b"); + const local = makeThreadFixture({ id: ThreadId.make("local"), projectId: project }); + const remote = makeThreadFixture({ + id: ThreadId.make("remote"), + environmentId: remoteEnvironmentId, + projectId: project, + }); + const localOther = makeThreadFixture({ id: ThreadId.make("local-other"), projectId: other }); + const threads = [local, remote, localOther]; + const visibleIds = ( + scope: ReadonlySet | null, + hidden: ReadonlySet | null, + ) => + filterSidebarV2VisibleThreads(threads, scope, new Set(), hidden).map((thread) => thread.id); + + expect(visibleIds(null, null)).toEqual(["local", "remote", "local-other"]); + expect(visibleIds(null, new Set([remoteEnvironmentId]))).toEqual(["local", "local-other"]); + expect( + visibleIds(new Set([`${local.environmentId}:${project}`]), new Set([remoteEnvironmentId])), + ).toEqual(["local"]); + expect( + visibleIds(new Set([`${remoteEnvironmentId}:${project}`]), new Set([remoteEnvironmentId])), + ).toEqual([]); + + const listedIds = (scope: ReadonlySet | null, routeThread: typeof remote) => + filterSidebarV2ListedThreads( + threads, + scope, + new Set(), + new Set([remoteEnvironmentId]), + scopedThreadKey(scopeThreadRef(routeThread.environmentId, routeThread.id)), + ).map((thread) => thread.id); + expect(listedIds(null, remote)).toEqual(["local", "local-other", "remote"]); + expect(listedIds(new Set([`${local.environmentId}:${project}`]), remote)).toEqual(["local"]); + }); +}); + describe("sortSidebarV2ProjectGroups", () => { it("does not let a hidden subagent thread reorder projects", () => { const olderProjectId = ProjectId.make("project-older"); @@ -1531,17 +1575,30 @@ describe("groupSidebarThreadsByProject", () => { thread("orphan", localEnvironmentId, ProjectId.make("deleted")), thread("b", remoteEnvironmentId, t3codeRemote), ]; - const summarize = (keepEmpty: boolean) => - groupSidebarThreadsByProject(groups, threads, keepEmpty).map((folder) => ({ - projectKey: folder.group.projectKey, - threads: folder.threads.map((entry) => entry.id), - })); + const summarize = ( + keepEmpty: boolean, + hiddenEnvironmentIds: ReadonlySet | null = null, + ) => + groupSidebarThreadsByProject(groups, threads, keepEmpty, hiddenEnvironmentIds).map( + (folder) => ({ + projectKey: folder.group.projectKey, + threads: folder.threads.map((entry) => entry.id), + }), + ); expect(summarize(true)).toEqual([ { projectKey: "t3code", threads: ["a", "b"] }, { projectKey: "fleet", threads: [] }, ]); expect(summarize(false)).toEqual([{ projectKey: "t3code", threads: ["a", "b"] }]); + // fleet lives only in the hidden environment, so it cannot start a thread; t3code spans two. + expect(summarize(true, new Set([localEnvironmentId]))).toEqual([ + { projectKey: "t3code", threads: ["a", "b"] }, + ]); + expect(summarize(true, new Set([remoteEnvironmentId]))).toEqual([ + { projectKey: "t3code", threads: ["a", "b"] }, + { projectKey: "fleet", threads: [] }, + ]); }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 49e44ea3306da..1d21701d5069e 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,5 +1,5 @@ import { resolveThreadWorkingStartedAt } from "@t3tools/client-runtime/state/models"; -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { threadKey } from "@t3tools/client-runtime/state/entities"; import { isShownUnderParent } from "@t3tools/client-runtime/state/threads"; import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; @@ -588,6 +588,7 @@ export function resolveSidebarRowThreadRef( /** * `delegatedUnderParent` holds the tasks linked environments run for a thread * the sidebar lists, by thread key; they show under that thread, as subagents do. + * `hiddenEnvironmentIds` hides every thread of those environments; null hides none. */ export function filterSidebarV2VisibleThreads< T extends Pick & { @@ -597,10 +598,12 @@ export function filterSidebarV2VisibleThreads< threads: readonly T[], scopedProjectKeys: ReadonlySet | null, delegatedUnderParent: ReadonlySet, + hiddenEnvironmentIds: ReadonlySet | null, ): T[] { return threads.filter( (thread) => thread.archivedAt === null && + (hiddenEnvironmentIds === null || !hiddenEnvironmentIds.has(thread.environmentId)) && !isShownUnderParent( { environmentId: thread.environmentId, threadId: thread.id }, thread.lineage, @@ -611,6 +614,43 @@ export function filterSidebarV2VisibleThreads< ); } +/** Like the filter above, but the open thread's row stays listed when only its environment is hidden. */ +export function filterSidebarV2ListedThreads< + T extends Pick & { + projectId: string; + }, +>( + threads: readonly T[], + scopedProjectKeys: ReadonlySet | null, + delegatedUnderParent: ReadonlySet, + hiddenEnvironmentIds: ReadonlySet | null, + routeRowThreadKey: string | null, +): T[] { + const listed = filterSidebarV2VisibleThreads( + threads, + scopedProjectKeys, + delegatedUnderParent, + hiddenEnvironmentIds, + ); + if (hiddenEnvironmentIds === null || routeRowThreadKey === null) return listed; + const routeThread = threads.find( + (thread) => + hiddenEnvironmentIds.has(thread.environmentId) && + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeRowThreadKey, + ); + if (routeThread !== undefined) { + listed.push( + ...filterSidebarV2VisibleThreads( + [routeThread], + scopedProjectKeys, + delegatedUnderParent, + null, + ), + ); + } + return listed; +} + export function buildBulkTitleRegenerationContextMenuItem(input: { supportedCount: number; actionableCount: number; @@ -939,7 +979,8 @@ export function sidebarFolderExpansionKeys(group: { /** * Boring sidebar folders. Folders follow `groups` order and threads keep their - * input order. A project with no threads gets a folder only with `keepEmpty`. + * input order. A project with no threads gets a folder only with `keepEmpty` + * and a member environment outside `hiddenEnvironmentIds`. * A thread whose project is in no group has no folder and is left out. */ export function groupSidebarThreadsByProject< @@ -954,6 +995,7 @@ export function groupSidebarThreadsByProject< groups: readonly TGroup[], threads: readonly TThread[], keepEmpty: boolean, + hiddenEnvironmentIds: ReadonlySet | null, ): Array<{ group: TGroup; threads: TThread[] }> { const folders = groups.map((group) => ({ group, threads: [] as TThread[] })); const folderByProject = new Map(); @@ -965,7 +1007,15 @@ export function groupSidebarThreadsByProject< for (const thread of threads) { folderByProject.get(`${thread.environmentId}:${thread.projectId}`)?.threads.push(thread); } - return keepEmpty ? folders : folders.filter((folder) => folder.threads.length > 0); + return folders.filter( + (folder) => + folder.threads.length > 0 || + (keepEmpty && + (hiddenEnvironmentIds === null || + folder.group.memberProjectRefs.some( + (ref) => !hiddenEnvironmentIds.has(ref.environmentId), + ))), + ); } export type SidebarV2TopStatusKind = @@ -1353,7 +1403,7 @@ export function sortSidebarV2ProjectGroups< ): TProject[] { return sortLogicalProjectsForSidebar( projects, - filterSidebarV2VisibleThreads(threads, null, delegatedUnderParent), + filterSidebarV2VisibleThreads(threads, null, delegatedUnderParent, null), sortOrder, ); } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 715d120e4e46f..1dddcd656498a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -134,6 +134,7 @@ import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironmentIdentities, useConnectedEnvironmentIds, + useOfflineEnvironmentIds, useEnvironmentMachines, usePrimaryEnvironmentId, } from "../state/environments"; @@ -172,7 +173,7 @@ import { } from "./threadActionMenu.logic"; import { applySidebarThreadDrop, - filterSidebarV2VisibleThreads, + filterSidebarV2ListedThreads, groupSidebarThreadsByProject, sidebarFolderExpansionKeys, buildBulkTitleRegenerationContextMenuItem, @@ -231,6 +232,7 @@ import { stackedThreadToast, toastManager } from "./ui/toast"; import { copyThreadReference } from "../lib/copyThreadReference"; import { threadChipCopyTarget } from "@t3tools/client-runtime/thread-reference"; import { Button } from "./ui/button"; +import { Switch } from "./ui/switch"; import { Combobox, ComboboxEmpty, @@ -338,6 +340,9 @@ type SidebarOptimisticDrop = { interface SidebarThreadPartitionInput { readonly threads: readonly EnvironmentThreadShell[]; readonly scopedProjectKeys: ReadonlySet | null; + readonly hiddenEnvironmentIds: ReadonlySet | null; + /** The open thread's row stays listed even when its environment is hidden. */ + readonly routeRowThreadKey: string | null; /** Tasks linked environments run for a listed thread; they show under it. */ readonly delegatedUnderParent: ReadonlySet; readonly serverConfigs: ReadonlyMap; @@ -400,6 +405,7 @@ function routeThreadOnly( function useSidebarThreads(input: { readonly projectScopeKey: string | null; readonly scopedProjectKeys: ReadonlySet | null; + readonly hiddenEnvironmentIds: ReadonlySet | null; readonly serverConfigs: ReadonlyMap; readonly optimisticDrop: SidebarOptimisticDrop | null; readonly threads: readonly EnvironmentThreadShell[]; @@ -413,6 +419,7 @@ function useSidebarThreads(input: { const { projectScopeKey, scopedProjectKeys, + hiddenEnvironmentIds, serverConfigs, optimisticDrop, threads, @@ -462,6 +469,8 @@ function useSidebarThreads(input: { const partition = partitionSidebarThreads({ threads, scopedProjectKeys, + hiddenEnvironmentIds, + routeRowThreadKey, delegatedUnderParent, serverConfigs, optimisticDrop, @@ -507,7 +516,12 @@ function useSidebarThreads(input: { const projectFolders = folderGroups === null ? null - : groupSidebarThreadsByProject(folderGroups, activeThreads, keepEmpty).map((folder) => { + : groupSidebarThreadsByProject( + folderGroups, + activeThreads, + keepEmpty, + hiddenEnvironmentIds, + ).map((folder) => { const collapsed = !resolveProjectExpanded( projectExpandedById, sidebarFolderExpansionKeys(folder.group), @@ -597,6 +611,8 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { const { threads, scopedProjectKeys, + hiddenEnvironmentIds, + routeRowThreadKey, delegatedUnderParent, serverConfigs, optimisticDrop, @@ -604,7 +620,13 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { sortActiveBySend, } = input; const preciseNow = new Date().toISOString(); - const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys, delegatedUnderParent); + const visible = filterSidebarV2ListedThreads( + threads, + scopedProjectKeys, + delegatedUnderParent, + hiddenEnvironmentIds, + routeRowThreadKey, + ); const inboxObservation = inboxReturns.observe(workingShelfEnabled ? threads : null); const pinned: EnvironmentThreadShell[] = []; const active: EnvironmentThreadShell[] = []; @@ -987,6 +1009,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const projectGroupsRef = useRef(projectGroups); const persistedProjectScopeKeys = useUiStateStore((store) => store.sidebarProjectScopeKeys); const setPersistedProjectScopeKeys = useUiStateStore((store) => store.setSidebarProjectScopeKeys); + const hideOfflineEnvironments = useUiStateStore((store) => store.sidebarHideOfflineEnvironments); + const setHideOfflineEnvironments = useUiStateStore( + (store) => store.setSidebarHideOfflineEnvironments, + ); + const offlineEnvironmentIds = useOfflineEnvironmentIds(); + const hiddenEnvironmentIds = hideOfflineEnvironments ? new Set(offlineEnvironmentIds) : null; const [projectScopeMenuState, dispatchProjectScopeMenu] = useReducer( reduceSidebarProjectScopeMenuState, { open: false, query: "" }, @@ -1313,6 +1341,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve } = useSidebarThreads({ projectScopeKey, scopedProjectKeys, + hiddenEnvironmentIds, serverConfigs, optimisticDrop, threads, @@ -2977,11 +3006,11 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve 0 ? `Filter threads by project: ${scopedProjectGroups.map((project) => project.displayName).join(", ")}` : "Filter threads by project" - } + }${hideOfflineEnvironments ? ", hiding offline environments" : ""}`} /> } > @@ -3002,6 +3031,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ) : ( )} + {hiddenEnvironmentIds !== null && hiddenEnvironmentIds.size > 0 ? ( + + ) : null} + {showProjectEnvironments || hideOfflineEnvironments ? ( + + ) : null} } diff --git a/apps/web/src/components/sidebar/routeReveal.ts b/apps/web/src/components/sidebar/routeReveal.ts index df9d45dc9d33e..a2caf200b771e 100644 --- a/apps/web/src/components/sidebar/routeReveal.ts +++ b/apps/web/src/components/sidebar/routeReveal.ts @@ -44,6 +44,7 @@ export function useActiveRowReveal(reveal: SidebarRouteReveal, routeKey: string type RevealableThread = Parameters[0][number]; +// Environments hidden as offline never widen the scope: the open row is kept in place instead. // Render records the route before deciding, so the first render of a new route // already widens the scope. Only a route reveal widens: the once-per-mount // scroll never changes a scope the user chose. @@ -63,7 +64,8 @@ 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, null) + .length === 0 && + filterSidebarV2VisibleThreads([thread], null, delegatedUnderParent, null).length === 1; return { pendingKey, widensScope }; } From bad5cecd7df3e01fa6a0222ce71e9361088971e0 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:36:56 -0700 Subject: [PATCH 4/6] fix(web): hidden offline environments leave every sidebar surface The Settled shelf no longer counts or pages threads from hidden environments, the filter dot only shows when rows are actually hidden, a delegated task whose parent's environment is hidden lists on its own, and context drags skip rows that are no longer listed. The open thread's row is kept by a key that only exists when its environment is hidden, so ordinary navigation no longer re-partitions the sidebar. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/Sidebar.logic.test.ts | 96 ++++++++++++---- apps/web/src/components/Sidebar.logic.ts | 82 ++++++++------ apps/web/src/components/Sidebar.tsx | 107 +++++++++++------- 3 files changed, 185 insertions(+), 100 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 825552a975e44..bfdef49b5b5d2 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -35,7 +35,9 @@ import { type SidebarListMarker, type SidebarSection, resolveSidebarDropVerb, - filterSidebarV2ListedThreads, + sidebarHidesOfflineThreads, + withoutHiddenDelegationParents, + type SidebarHiddenEnvironments, filterSidebarV2VisibleThreads, groupSidebarThreadsByProject, resolveSidebarFolderDot, @@ -921,31 +923,72 @@ describe("filterSidebarV2VisibleThreads", () => { }); const localOther = makeThreadFixture({ id: ThreadId.make("local-other"), projectId: other }); const threads = [local, remote, localOther]; + const localScope = new Set([`${local.environmentId}:${project}`]); + const remoteScope = new Set([`${remoteEnvironmentId}:${project}`]); + const hidden = (keptThreadKey: string | null = null) => ({ + environmentIds: new Set([remoteEnvironmentId]), + keptThreadKey, + }); + const remoteKey = scopedThreadKey(scopeThreadRef(remote.environmentId, remote.id)); const visibleIds = ( scope: ReadonlySet | null, - hidden: ReadonlySet | null, + hiddenEnvironments: SidebarHiddenEnvironments | null, ) => - filterSidebarV2VisibleThreads(threads, scope, new Set(), hidden).map((thread) => thread.id); + filterSidebarV2VisibleThreads(threads, scope, new Set(), hiddenEnvironments).map( + (thread) => thread.id, + ); + const hidesOffline = ( + scope: ReadonlySet | null, + hiddenEnvironments: SidebarHiddenEnvironments | null, + ) => sidebarHidesOfflineThreads(threads, scope, new Set(), hiddenEnvironments); expect(visibleIds(null, null)).toEqual(["local", "remote", "local-other"]); - expect(visibleIds(null, new Set([remoteEnvironmentId]))).toEqual(["local", "local-other"]); - expect( - visibleIds(new Set([`${local.environmentId}:${project}`]), new Set([remoteEnvironmentId])), - ).toEqual(["local"]); - expect( - visibleIds(new Set([`${remoteEnvironmentId}:${project}`]), new Set([remoteEnvironmentId])), - ).toEqual([]); + expect(visibleIds(null, hidden())).toEqual(["local", "local-other"]); + expect(visibleIds(localScope, hidden())).toEqual(["local"]); + expect(visibleIds(remoteScope, hidden())).toEqual([]); + // The open thread's row stays listed, but only within the project scope. + expect(visibleIds(null, hidden(remoteKey))).toEqual(["local", "remote", "local-other"]); + expect(visibleIds(localScope, hidden(remoteKey))).toEqual(["local"]); + + expect(hidesOffline(null, null)).toBe(false); + expect(hidesOffline(null, hidden())).toBe(true); + expect(hidesOffline(localScope, hidden())).toBe(false); + expect(hidesOffline(null, hidden(remoteKey))).toBe(false); + }); - const listedIds = (scope: ReadonlySet | null, routeThread: typeof remote) => - filterSidebarV2ListedThreads( + it("lists a task on its own when its parent's environment is hidden", () => { + const parentEnvironmentId = EnvironmentId.make("environment-parent"); + const taskEnvironmentId = EnvironmentId.make("environment-task"); + const parent = makeThreadFixture({ + id: ThreadId.make("thread-parent"), + environmentId: parentEnvironmentId, + }); + const task = makeThreadFixture({ + id: ThreadId.make("thread-task"), + environmentId: taskEnvironmentId, + delegatedFrom: { + environmentId: parentEnvironmentId, + threadId: parent.id, + title: parent.title, + }, + }); + const threads = [parent, task]; + const delegated = new Set([ + threadKey({ environmentId: task.environmentId, threadId: task.id }), + ]); + const visibleIds = (hiddenEnvironmentIds: ReadonlySet | null) => + filterSidebarV2VisibleThreads( threads, - scope, - new Set(), - new Set([remoteEnvironmentId]), - scopedThreadKey(scopeThreadRef(routeThread.environmentId, routeThread.id)), + null, + withoutHiddenDelegationParents(delegated, threads, hiddenEnvironmentIds), + hiddenEnvironmentIds === null + ? null + : { environmentIds: hiddenEnvironmentIds, keptThreadKey: null }, ).map((thread) => thread.id); - expect(listedIds(null, remote)).toEqual(["local", "local-other", "remote"]); - expect(listedIds(new Set([`${local.environmentId}:${project}`]), remote)).toEqual(["local"]); + + expect(visibleIds(null)).toEqual(["thread-parent"]); + expect(visibleIds(new Set([parentEnvironmentId]))).toEqual(["thread-task"]); + expect(visibleIds(new Set([taskEnvironmentId]))).toEqual(["thread-parent"]); }); }); @@ -1579,12 +1622,17 @@ describe("groupSidebarThreadsByProject", () => { keepEmpty: boolean, hiddenEnvironmentIds: ReadonlySet | null = null, ) => - groupSidebarThreadsByProject(groups, threads, keepEmpty, hiddenEnvironmentIds).map( - (folder) => ({ - projectKey: folder.group.projectKey, - threads: folder.threads.map((entry) => entry.id), - }), - ); + groupSidebarThreadsByProject( + groups, + threads, + keepEmpty, + hiddenEnvironmentIds === null + ? null + : { environmentIds: hiddenEnvironmentIds, keptThreadKey: null }, + ).map((folder) => ({ + projectKey: folder.group.projectKey, + threads: folder.threads.map((entry) => entry.id), + })); expect(summarize(true)).toEqual([ { projectKey: "t3code", threads: ["a", "b"] }, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 1d21701d5069e..c99a6b7e38206 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -585,10 +585,15 @@ export function resolveSidebarRowThreadRef( } } +/** Offline environments the sidebar hides, and the open thread's row it lists anyway. */ +export interface SidebarHiddenEnvironments { + readonly environmentIds: ReadonlySet; + readonly keptThreadKey: string | null; +} + /** * `delegatedUnderParent` holds the tasks linked environments run for a thread * the sidebar lists, by thread key; they show under that thread, as subagents do. - * `hiddenEnvironmentIds` hides every thread of those environments; null hides none. */ export function filterSidebarV2VisibleThreads< T extends Pick & { @@ -598,12 +603,15 @@ export function filterSidebarV2VisibleThreads< threads: readonly T[], scopedProjectKeys: ReadonlySet | null, delegatedUnderParent: ReadonlySet, - hiddenEnvironmentIds: ReadonlySet | null, + hidden: SidebarHiddenEnvironments | null, ): T[] { return threads.filter( (thread) => thread.archivedAt === null && - (hiddenEnvironmentIds === null || !hiddenEnvironmentIds.has(thread.environmentId)) && + (hidden === null || + !hidden.environmentIds.has(thread.environmentId) || + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === + hidden.keptThreadKey) && !isShownUnderParent( { environmentId: thread.environmentId, threadId: thread.id }, thread.lineage, @@ -614,8 +622,27 @@ export function filterSidebarV2VisibleThreads< ); } -/** Like the filter above, but the open thread's row stays listed when only its environment is hidden. */ -export function filterSidebarV2ListedThreads< +/** A task whose parent's environment is hidden lists on its own, as under an archived parent. */ +export function withoutHiddenDelegationParents( + delegatedUnderParent: ReadonlySet, + threads: readonly Pick[], + hiddenEnvironmentIds: ReadonlySet | null, +): ReadonlySet { + if (hiddenEnvironmentIds === null) return delegatedUnderParent; + const orphanKeys = threads.flatMap((thread) => { + const parentEnvironmentId = thread.delegatedFrom?.environmentId; + return parentEnvironmentId !== undefined && hiddenEnvironmentIds.has(parentEnvironmentId) + ? [threadKey({ environmentId: thread.environmentId, threadId: thread.id })] + : []; + }); + if (!orphanKeys.some((key) => delegatedUnderParent.has(key))) return delegatedUnderParent; + const next = new Set(delegatedUnderParent); + for (const key of orphanKeys) next.delete(key); + return next; +} + +/** Whether hiding offline environments removes a row the sidebar would otherwise list. */ +export function sidebarHidesOfflineThreads< T extends Pick & { projectId: string; }, @@ -623,32 +650,19 @@ export function filterSidebarV2ListedThreads< threads: readonly T[], scopedProjectKeys: ReadonlySet | null, delegatedUnderParent: ReadonlySet, - hiddenEnvironmentIds: ReadonlySet | null, - routeRowThreadKey: string | null, -): T[] { - const listed = filterSidebarV2VisibleThreads( - threads, - scopedProjectKeys, - delegatedUnderParent, - hiddenEnvironmentIds, - ); - if (hiddenEnvironmentIds === null || routeRowThreadKey === null) return listed; - const routeThread = threads.find( - (thread) => - hiddenEnvironmentIds.has(thread.environmentId) && - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeRowThreadKey, + hidden: SidebarHiddenEnvironments | null, +): boolean { + return ( + hidden !== null && + threads.some( + (thread) => + hidden.environmentIds.has(thread.environmentId) && + filterSidebarV2VisibleThreads([thread], scopedProjectKeys, delegatedUnderParent, null) + .length === 1 && + filterSidebarV2VisibleThreads([thread], scopedProjectKeys, delegatedUnderParent, hidden) + .length === 0, + ) ); - if (routeThread !== undefined) { - listed.push( - ...filterSidebarV2VisibleThreads( - [routeThread], - scopedProjectKeys, - delegatedUnderParent, - null, - ), - ); - } - return listed; } export function buildBulkTitleRegenerationContextMenuItem(input: { @@ -980,7 +994,7 @@ export function sidebarFolderExpansionKeys(group: { /** * Boring sidebar folders. Folders follow `groups` order and threads keep their * input order. A project with no threads gets a folder only with `keepEmpty` - * and a member environment outside `hiddenEnvironmentIds`. + * and a member environment `hidden` leaves listed. * A thread whose project is in no group has no folder and is left out. */ export function groupSidebarThreadsByProject< @@ -995,7 +1009,7 @@ export function groupSidebarThreadsByProject< groups: readonly TGroup[], threads: readonly TThread[], keepEmpty: boolean, - hiddenEnvironmentIds: ReadonlySet | null, + hidden: SidebarHiddenEnvironments | null, ): Array<{ group: TGroup; threads: TThread[] }> { const folders = groups.map((group) => ({ group, threads: [] as TThread[] })); const folderByProject = new Map(); @@ -1011,9 +1025,9 @@ export function groupSidebarThreadsByProject< (folder) => folder.threads.length > 0 || (keepEmpty && - (hiddenEnvironmentIds === null || + (hidden === null || folder.group.memberProjectRefs.some( - (ref) => !hiddenEnvironmentIds.has(ref.environmentId), + (ref) => !hidden.environmentIds.has(ref.environmentId), ))), ); } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 1dddcd656498a..f0e2a140366e8 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -173,7 +173,10 @@ import { } from "./threadActionMenu.logic"; import { applySidebarThreadDrop, - filterSidebarV2ListedThreads, + filterSidebarV2VisibleThreads, + sidebarHidesOfflineThreads, + withoutHiddenDelegationParents, + type SidebarHiddenEnvironments, groupSidebarThreadsByProject, sidebarFolderExpansionKeys, buildBulkTitleRegenerationContextMenuItem, @@ -340,9 +343,7 @@ type SidebarOptimisticDrop = { interface SidebarThreadPartitionInput { readonly threads: readonly EnvironmentThreadShell[]; readonly scopedProjectKeys: ReadonlySet | null; - readonly hiddenEnvironmentIds: ReadonlySet | null; - /** The open thread's row stays listed even when its environment is hidden. */ - readonly routeRowThreadKey: string | null; + readonly hidden: SidebarHiddenEnvironments | null; /** Tasks linked environments run for a listed thread; they show under it. */ readonly delegatedUnderParent: ReadonlySet; readonly serverConfigs: ReadonlyMap; @@ -405,7 +406,7 @@ function routeThreadOnly( function useSidebarThreads(input: { readonly projectScopeKey: string | null; readonly scopedProjectKeys: ReadonlySet | null; - readonly hiddenEnvironmentIds: ReadonlySet | null; + readonly hidden: SidebarHiddenEnvironments | null; readonly serverConfigs: ReadonlyMap; readonly optimisticDrop: SidebarOptimisticDrop | null; readonly threads: readonly EnvironmentThreadShell[]; @@ -419,7 +420,7 @@ function useSidebarThreads(input: { const { projectScopeKey, scopedProjectKeys, - hiddenEnvironmentIds, + hidden, serverConfigs, optimisticDrop, threads, @@ -469,8 +470,7 @@ function useSidebarThreads(input: { const partition = partitionSidebarThreads({ threads, scopedProjectKeys, - hiddenEnvironmentIds, - routeRowThreadKey, + hidden, delegatedUnderParent, serverConfigs, optimisticDrop, @@ -483,7 +483,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) => hidden === null || !hidden.environmentIds.has(environmentId), + ), + scopedProjectKeys, + ), expanded: settledShelfExpanded, visibleCount: settledVisibleCount, }); @@ -516,28 +521,25 @@ function useSidebarThreads(input: { const projectFolders = folderGroups === null ? null - : groupSidebarThreadsByProject( - folderGroups, - activeThreads, - keepEmpty, - hiddenEnvironmentIds, - ).map((folder) => { - const collapsed = !resolveProjectExpanded( - projectExpandedById, - sidebarFolderExpansionKeys(folder.group), - ); - return { - group: folder.group, - collapsed, - // A closed folder still shows the open thread, and hints at the rest. - threads: collapsed - ? routeThreadOnly(folder.threads, routeRowThreadKey) - : folder.threads, - hiddenThreads: collapsed - ? folder.threads.filter((thread) => threadShellKey(thread) !== routeRowThreadKey) - : EMPTY_THREADS, - }; - }); + : groupSidebarThreadsByProject(folderGroups, activeThreads, keepEmpty, hidden).map( + (folder) => { + const collapsed = !resolveProjectExpanded( + projectExpandedById, + sidebarFolderExpansionKeys(folder.group), + ); + return { + group: folder.group, + collapsed, + // A closed folder still shows the open thread, and hints at the rest. + threads: collapsed + ? routeThreadOnly(folder.threads, routeRowThreadKey) + : folder.threads, + hiddenThreads: collapsed + ? folder.threads.filter((thread) => threadShellKey(thread) !== routeRowThreadKey) + : EMPTY_THREADS, + }; + }, + ); const orderedThreads = [ ...pinnedThreads, ...(projectFolders === null @@ -611,8 +613,7 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { const { threads, scopedProjectKeys, - hiddenEnvironmentIds, - routeRowThreadKey, + hidden, delegatedUnderParent, serverConfigs, optimisticDrop, @@ -620,12 +621,11 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { sortActiveBySend, } = input; const preciseNow = new Date().toISOString(); - const visible = filterSidebarV2ListedThreads( + const visible = filterSidebarV2VisibleThreads( threads, scopedProjectKeys, delegatedUnderParent, - hiddenEnvironmentIds, - routeRowThreadKey, + hidden, ); const inboxObservation = inboxReturns.observe(workingShelfEnabled ? threads : null); const pinned: EnvironmentThreadShell[] = []; @@ -908,7 +908,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve projectGroupByScopeKey, projectScopeItems, } = useSidebarLookups(); - const delegatedUnderParent = useDelegatedUnderParent(); + const allDelegatedUnderParent = useDelegatedUnderParent(); const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); @@ -1014,7 +1014,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve (store) => store.setSidebarHideOfflineEnvironments, ); const offlineEnvironmentIds = useOfflineEnvironmentIds(); - const hiddenEnvironmentIds = hideOfflineEnvironments ? new Set(offlineEnvironmentIds) : null; + const hiddenEnvironmentIds = + hideOfflineEnvironments && offlineEnvironmentIds.length > 0 + ? new Set(offlineEnvironmentIds) + : null; const [projectScopeMenuState, dispatchProjectScopeMenu] = useReducer( reduceSidebarProjectScopeMenuState, { open: false, query: "" }, @@ -1100,10 +1103,23 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ), ); const threads = useQuietThreadShells(); - const routeRowThreadKey = + const delegatedUnderParent = withoutHiddenDelegationParents( + allDelegatedUnderParent, + threads, + hiddenEnvironmentIds, + ); + const routeRowThreadRef = routeThreadRef === null ? null - : scopedThreadKey(resolveSidebarRowThreadRef(routeThreadRef, threads, delegatedUnderParent)); + : resolveSidebarRowThreadRef(routeThreadRef, threads, delegatedUnderParent); + const routeRowThreadKey = routeRowThreadRef === null ? null : scopedThreadKey(routeRowThreadRef); + // Only a hidden environment's open row is kept, so other navigation leaves the partition memoized. + const keptThreadKey = + routeRowThreadRef !== null && hiddenEnvironmentIds?.has(routeRowThreadRef.environmentId) + ? routeRowThreadKey + : null; + const hiddenEnvironments = + hiddenEnvironmentIds === null ? null : { environmentIds: hiddenEnvironmentIds, keptThreadKey }; const isSearchingThreads = threadSearchQuery.trim().length > 0; // History, the palette and links can open a thread the scope hides or the list @@ -1165,6 +1181,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const scopedProjectGroups = revealWidensScope ? [] : chosenProjectGroups; const scopedProjectRefs = scopedProjectGroups.flatMap((project) => project.memberProjectRefs); const scopedProjectKeys = revealWidensScope ? null : chosenScopeProjectKeys; + const offlineHidesThreads = sidebarHidesOfflineThreads( + threads, + scopedProjectKeys, + delegatedUnderParent, + hiddenEnvironments, + ); const threadSearch = useThreadSearch( searchEnvironmentIds, threadSearchQuery, @@ -1341,7 +1363,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve } = useSidebarThreads({ projectScopeKey, scopedProjectKeys, - hiddenEnvironmentIds, + hidden: hiddenEnvironments, serverConfigs, optimisticDrop, threads, @@ -1741,6 +1763,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const selected = useThreadSelectionStore.getState().selectedThreadKeys; const keys = selected.has(key) ? [...selected] : [key]; return keys.flatMap((candidate) => { + if (!threadByKeyRef.current.has(candidate)) return []; const ref = parseScopedThreadKey(candidate); return ref ? [ref] : []; }); @@ -3031,7 +3054,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ) : ( )} - {hiddenEnvironmentIds !== null && hiddenEnvironmentIds.size > 0 ? ( + {offlineHidesThreads ? ( Date: Fri, 30 Oct 2026 13:58:59 -0700 Subject: [PATCH 5/6] fix(client-runtime): offline environments get a grace period before hiding A disconnected environment now counts as offline only after 30 seconds without reconnecting, so server restarts and network blips keep its threads listed. Blocked connections (auth errors, unsupported servers) still count at once, since retrying cannot fix them. Co-Authored-By: Claude Opus 5.5 --- .../src/connection/presentation.test.ts | 19 +---------- .../src/connection/presentation.ts | 8 ----- .../src/state/presentation.test.ts | 32 +++++++++++++------ .../client-runtime/src/state/presentation.ts | 30 ++++++++++++++--- 4 files changed, 48 insertions(+), 41 deletions(-) diff --git a/packages/client-runtime/src/connection/presentation.test.ts b/packages/client-runtime/src/connection/presentation.test.ts index 5cdd3c7bf8cfb..2a27148fcd613 100644 --- a/packages/client-runtime/src/connection/presentation.test.ts +++ b/packages/client-runtime/src/connection/presentation.test.ts @@ -13,12 +13,7 @@ import { RelayConnectionTarget, type SupervisorConnectionState, } from "./model.ts"; -import { - type EnvironmentConnectionPhase, - environmentMcpUrl, - isEnvironmentOffline, - presentConnectionState, -} from "./presentation.ts"; +import { environmentMcpUrl, presentConnectionState } from "./presentation.ts"; const TARGET = new BearerConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -112,18 +107,6 @@ describe("connection presentation", () => { ).toBe("https://tunnel.example.test/mcp"); }); - it.each<[EnvironmentConnectionPhase, boolean]>([ - ["connected", false], - ["connecting", false], - ["reconnecting", true], - ["offline", true], - ["available", true], - ["error", true], - ["unsupported", true], - ])("treats %s as offline: %s", (phase, offline) => { - expect(isEnvironmentOffline(phase)).toBe(offline); - }); - it("distinguishes initial connection, reconnect, and retry errors", () => { expect(presentConnectionState(supervisorState({ phase: "connecting", attempt: 1 }))).toEqual({ phase: "connecting", diff --git a/packages/client-runtime/src/connection/presentation.ts b/packages/client-runtime/src/connection/presentation.ts index 2a8ae86a692dc..c579f9587a3df 100644 --- a/packages/client-runtime/src/connection/presentation.ts +++ b/packages/client-runtime/src/connection/presentation.ts @@ -64,14 +64,6 @@ export function presentConnectionState( } } -/** - * "connecting" counts as online so a first attempt at startup does not hide rows that return - * moments later; "reconnecting" counts as offline so retries do not flicker them. - */ -export function isEnvironmentOffline(phase: EnvironmentConnectionPhase): boolean { - return phase !== "connected" && phase !== "connecting"; -} - export function connectionStatusText(connection: EnvironmentConnectionPresentation): string { switch (connection.phase) { case "available": diff --git a/packages/client-runtime/src/state/presentation.test.ts b/packages/client-runtime/src/state/presentation.test.ts index f40106f398a87..d94a4fe1e423e 100644 --- a/packages/client-runtime/src/state/presentation.test.ts +++ b/packages/client-runtime/src/state/presentation.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "@effect/vitest"; +import { describe, expect, it, vi } from "@effect/vitest"; import { EnvironmentId, type ServerConfig } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; @@ -8,6 +8,7 @@ import { AVAILABLE_CONNECTION_STATE, PrimaryConnectionTarget } from "../connecti import { createEnvironmentPresentationAtoms, createEnvironmentSummaryAtoms, + OFFLINE_ENVIRONMENT_GRACE_MS, } from "./presentation.ts"; import type { EnvironmentCatalogState } from "./connections.ts"; @@ -93,23 +94,34 @@ describe("environment summary subscriptions", () => { }); describe("offline environments", () => { - it("lists environments whose connection is neither connected nor first-attempt connecting", () => { + it("lists an environment once it stays disconnected past the grace period", () => { + vi.useFakeTimers(); const h = harness(); const stop = h.registry.subscribe(h.offlineEnvironmentIdsAtom, () => {}); - const supervisor = (phase: "connected" | "connecting" | "backoff") => - AsyncResult.success({ ...AVAILABLE_CONNECTION_STATE, phase, attempt: 1 }); + const offlineAfter = (phase: "connected" | "connecting" | "backoff" | "blocked") => { + h.registry.set( + h.state, + AsyncResult.success({ ...AVAILABLE_CONNECTION_STATE, phase, attempt: 1 }), + ); + return h.registry.get(h.offlineEnvironmentIdsAtom); + }; try { + expect(offlineAfter("connected")).toEqual([]); + expect(offlineAfter("backoff")).toEqual([]); + vi.advanceTimersByTime(OFFLINE_ENVIRONMENT_GRACE_MS - 1); expect(h.registry.get(h.offlineEnvironmentIdsAtom)).toEqual([]); - h.registry.set(h.state, supervisor("backoff")); + vi.advanceTimersByTime(1); expect(h.registry.get(h.offlineEnvironmentIdsAtom)).toEqual([FIRST, SECOND]); - h.registry.set(h.state, supervisor("connecting")); - expect(h.registry.get(h.offlineEnvironmentIdsAtom)).toEqual([]); - h.registry.set(h.state, supervisor("backoff")); - h.registry.set(h.state, supervisor("connected")); - expect(h.registry.get(h.offlineEnvironmentIdsAtom)).toEqual([]); + // A retry does not restart the grace period; only a connection does. + expect(offlineAfter("connecting")).toEqual([FIRST, SECOND]); + expect(offlineAfter("connected")).toEqual([]); + expect(offlineAfter("backoff")).toEqual([]); + // Retrying cannot fix a blocked connection, so it is offline at once. + expect(offlineAfter("blocked")).toEqual([FIRST, SECOND]); } finally { stop(); h.registry.dispose(); + vi.useRealTimers(); } }); }); diff --git a/packages/client-runtime/src/state/presentation.ts b/packages/client-runtime/src/state/presentation.ts index 54cfc8f246143..8c4f3049f4cd6 100644 --- a/packages/client-runtime/src/state/presentation.ts +++ b/packages/client-runtime/src/state/presentation.ts @@ -11,7 +11,6 @@ import { AsyncResult, Atom } from "effect/reactivity"; import { AVAILABLE_CONNECTION_STATE, type SupervisorConnectionState } from "../connection/model.ts"; import { connectionCatalogDisplayUrl, - isEnvironmentOffline, presentConnectionState, type EnvironmentConnectionPhase, type EnvironmentConnectionPresentation, @@ -218,6 +217,9 @@ export function projectEnvironmentConnectionSummary( }; } +/** A disconnected environment counts as offline only after retrying this long, so restarts and blips keep its threads listed. */ +export const OFFLINE_ENVIRONMENT_GRACE_MS = 30_000; + export function createEnvironmentSummaryAtoms(input: { readonly catalogValueAtom: Atom.Atom; readonly presentationAtom: ( @@ -296,11 +298,29 @@ export function createEnvironmentSummaryAtoms(input: { ? previous : next; }); - const offlineEnvironmentIdsAtom = Atom.make((get) => { - const next = get(environmentIdsAtom).filter((id) => { - const connectionState = get(environmentAtom(id))?.connectionState; - return connectionState !== undefined && isEnvironmentOffline(connectionState); + const offlineEnvironmentAtom = Atom.family((environmentId: EnvironmentId) => { + let disconnectedSince: number | null = null; + return Atom.make((get) => { + const connectionState = get(environmentAtom(environmentId))?.connectionState; + if (connectionState === undefined || connectionState === "connected") { + disconnectedSince = null; + return false; + } + // Retrying cannot fix a blocked connection, so it skips the grace period. + if (connectionState === "error" || connectionState === "unsupported") return true; + // @effect-diagnostics-next-line globalDate:off - Atoms run outside an Effect runtime. + const now = Date.now(); + disconnectedSince ??= now; + const remainingMs = disconnectedSince + OFFLINE_ENVIRONMENT_GRACE_MS - now; + if (remainingMs <= 0) return true; + // @effect-diagnostics-next-line globalTimers:off - Mirrors `Atom.withRefresh`, outside an Effect runtime. + const handle = setTimeout(() => get.refreshSelf(), remainingMs); + get.addFinalizer(() => clearTimeout(handle)); + return false; }); + }); + const offlineEnvironmentIdsAtom = Atom.make((get) => { + const next = get(environmentIdsAtom).filter((id) => get(offlineEnvironmentAtom(id))); const previous = Option.getOrNull(get.self>()); return previous !== null && previous.length === next.length && From 4568c51bf32ed621a11ea724dc23f569e55d2ad6 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:05:36 -0700 Subject: [PATCH 6/6] fix(client-runtime): offline environments wait 60 seconds before hiding Co-Authored-By: Claude Opus 5.5 --- packages/client-runtime/src/state/presentation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client-runtime/src/state/presentation.ts b/packages/client-runtime/src/state/presentation.ts index 8c4f3049f4cd6..453ed9564228e 100644 --- a/packages/client-runtime/src/state/presentation.ts +++ b/packages/client-runtime/src/state/presentation.ts @@ -218,7 +218,7 @@ export function projectEnvironmentConnectionSummary( } /** A disconnected environment counts as offline only after retrying this long, so restarts and blips keep its threads listed. */ -export const OFFLINE_ENVIRONMENT_GRACE_MS = 30_000; +export const OFFLINE_ENVIRONMENT_GRACE_MS = 60_000; export function createEnvironmentSummaryAtoms(input: { readonly catalogValueAtom: Atom.Atom;