diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 4e446e4f3a3f2..bfdef49b5b5d2 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,10 @@ import { type SidebarListMarker, type SidebarSection, resolveSidebarDropVerb, + sidebarHidesOfflineThreads, + withoutHiddenDelegationParents, + type SidebarHiddenEnvironments, + filterSidebarV2VisibleThreads, groupSidebarThreadsByProject, resolveSidebarFolderDot, sidebarFolderExpansionKeys, @@ -905,6 +910,88 @@ 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 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, + hiddenEnvironments: SidebarHiddenEnvironments | null, + ) => + 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, 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); + }); + + 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, + null, + withoutHiddenDelegationParents(delegated, threads, hiddenEnvironmentIds), + hiddenEnvironmentIds === null + ? null + : { environmentIds: hiddenEnvironmentIds, keptThreadKey: null }, + ).map((thread) => thread.id); + + expect(visibleIds(null)).toEqual(["thread-parent"]); + expect(visibleIds(new Set([parentEnvironmentId]))).toEqual(["thread-task"]); + expect(visibleIds(new Set([taskEnvironmentId]))).toEqual(["thread-parent"]); + }); +}); + describe("sortSidebarV2ProjectGroups", () => { it("does not let a hidden subagent thread reorder projects", () => { const olderProjectId = ProjectId.make("project-older"); @@ -1531,8 +1618,18 @@ describe("groupSidebarThreadsByProject", () => { thread("orphan", localEnvironmentId, ProjectId.make("deleted")), thread("b", remoteEnvironmentId, t3codeRemote), ]; - const summarize = (keepEmpty: boolean) => - groupSidebarThreadsByProject(groups, threads, keepEmpty).map((folder) => ({ + const summarize = ( + keepEmpty: boolean, + hiddenEnvironmentIds: ReadonlySet | null = null, + ) => + groupSidebarThreadsByProject( + groups, + threads, + keepEmpty, + hiddenEnvironmentIds === null + ? null + : { environmentIds: hiddenEnvironmentIds, keptThreadKey: null }, + ).map((folder) => ({ projectKey: folder.group.projectKey, threads: folder.threads.map((entry) => entry.id), })); @@ -1542,6 +1639,14 @@ describe("groupSidebarThreadsByProject", () => { { 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..c99a6b7e38206 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"; @@ -585,6 +585,12 @@ 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. @@ -597,10 +603,15 @@ export function filterSidebarV2VisibleThreads< threads: readonly T[], scopedProjectKeys: ReadonlySet | null, delegatedUnderParent: ReadonlySet, + hidden: SidebarHiddenEnvironments | null, ): T[] { return threads.filter( (thread) => thread.archivedAt === null && + (hidden === null || + !hidden.environmentIds.has(thread.environmentId) || + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === + hidden.keptThreadKey) && !isShownUnderParent( { environmentId: thread.environmentId, threadId: thread.id }, thread.lineage, @@ -611,6 +622,49 @@ export function filterSidebarV2VisibleThreads< ); } +/** 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; + }, +>( + threads: readonly T[], + scopedProjectKeys: ReadonlySet | null, + delegatedUnderParent: ReadonlySet, + 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, + ) + ); +} + export function buildBulkTitleRegenerationContextMenuItem(input: { supportedCount: number; actionableCount: number; @@ -939,7 +993,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 `hidden` leaves listed. * A thread whose project is in no group has no folder and is left out. */ export function groupSidebarThreadsByProject< @@ -954,6 +1009,7 @@ export function groupSidebarThreadsByProject< groups: readonly TGroup[], threads: readonly TThread[], keepEmpty: boolean, + hidden: SidebarHiddenEnvironments | null, ): Array<{ group: TGroup; threads: TThread[] }> { const folders = groups.map((group) => ({ group, threads: [] as TThread[] })); const folderByProject = new Map(); @@ -965,7 +1021,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 && + (hidden === null || + folder.group.memberProjectRefs.some( + (ref) => !hidden.environmentIds.has(ref.environmentId), + ))), + ); } export type SidebarV2TopStatusKind = @@ -1353,7 +1417,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..f0e2a140366e8 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"; @@ -173,6 +174,9 @@ import { import { applySidebarThreadDrop, filterSidebarV2VisibleThreads, + sidebarHidesOfflineThreads, + withoutHiddenDelegationParents, + type SidebarHiddenEnvironments, groupSidebarThreadsByProject, sidebarFolderExpansionKeys, buildBulkTitleRegenerationContextMenuItem, @@ -231,6 +235,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 +343,7 @@ type SidebarOptimisticDrop = { interface SidebarThreadPartitionInput { readonly threads: readonly EnvironmentThreadShell[]; readonly scopedProjectKeys: ReadonlySet | null; + readonly hidden: SidebarHiddenEnvironments | null; /** Tasks linked environments run for a listed thread; they show under it. */ readonly delegatedUnderParent: ReadonlySet; readonly serverConfigs: ReadonlyMap; @@ -400,6 +406,7 @@ function routeThreadOnly( function useSidebarThreads(input: { readonly projectScopeKey: string | null; readonly scopedProjectKeys: ReadonlySet | null; + readonly hidden: SidebarHiddenEnvironments | null; readonly serverConfigs: ReadonlyMap; readonly optimisticDrop: SidebarOptimisticDrop | null; readonly threads: readonly EnvironmentThreadShell[]; @@ -413,6 +420,7 @@ function useSidebarThreads(input: { const { projectScopeKey, scopedProjectKeys, + hidden, serverConfigs, optimisticDrop, threads, @@ -462,6 +470,7 @@ function useSidebarThreads(input: { const partition = partitionSidebarThreads({ threads, scopedProjectKeys, + hidden, delegatedUnderParent, serverConfigs, optimisticDrop, @@ -474,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, }); @@ -507,23 +521,25 @@ function useSidebarThreads(input: { const projectFolders = folderGroups === null ? null - : groupSidebarThreadsByProject(folderGroups, activeThreads, keepEmpty).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 @@ -597,6 +613,7 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { const { threads, scopedProjectKeys, + hidden, delegatedUnderParent, serverConfigs, optimisticDrop, @@ -604,7 +621,12 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { sortActiveBySend, } = input; const preciseNow = new Date().toISOString(); - const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys, delegatedUnderParent); + const visible = filterSidebarV2VisibleThreads( + threads, + scopedProjectKeys, + delegatedUnderParent, + hidden, + ); const inboxObservation = inboxReturns.observe(workingShelfEnabled ? threads : null); const pinned: EnvironmentThreadShell[] = []; const active: EnvironmentThreadShell[] = []; @@ -886,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); @@ -987,6 +1009,15 @@ 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 && offlineEnvironmentIds.length > 0 + ? new Set(offlineEnvironmentIds) + : null; const [projectScopeMenuState, dispatchProjectScopeMenu] = useReducer( reduceSidebarProjectScopeMenuState, { open: false, query: "" }, @@ -1072,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 @@ -1137,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, @@ -1313,6 +1363,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve } = useSidebarThreads({ projectScopeKey, scopedProjectKeys, + hidden: hiddenEnvironments, serverConfigs, optimisticDrop, threads, @@ -1712,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] : []; }); @@ -2977,11 +3029,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 +3054,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ) : ( )} + {offlineHidesThreads ? ( + + ) : 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 }; } 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/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)), diff --git a/packages/client-runtime/src/state/presentation.test.ts b/packages/client-runtime/src/state/presentation.test.ts index 71e9c868cd439..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"; @@ -92,6 +93,39 @@ describe("environment summary subscriptions", () => { }); }); +describe("offline environments", () => { + 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 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([]); + vi.advanceTimersByTime(1); + expect(h.registry.get(h.offlineEnvironmentIdsAtom)).toEqual([FIRST, SECOND]); + // 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(); + } + }); +}); + 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..453ed9564228e 100644 --- a/packages/client-runtime/src/state/presentation.ts +++ b/packages/client-runtime/src/state/presentation.ts @@ -217,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 = 60_000; + export function createEnvironmentSummaryAtoms(input: { readonly catalogValueAtom: Atom.Atom; readonly presentationAtom: ( @@ -295,6 +298,36 @@ export function createEnvironmentSummaryAtoms(input: { ? previous : next; }); + 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 && + next.every((id, index) => id === previous[index]) + ? previous + : next; + }); const machineByIdAtom = Atom.make((get) => { const next = new Map( get(environmentIdsAtom).map( @@ -321,6 +354,7 @@ export function createEnvironmentSummaryAtoms(input: { return { environmentIdsAtom, connectedEnvironmentIdsAtom, + offlineEnvironmentIdsAtom, identitiesAtom, environmentsAtom, machineByIdAtom,