diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 669c805492bcc..71a4927d29f6e 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -38,6 +38,7 @@ import { sidebarFolderExpansionKeys, } from "./Sidebar.logic"; import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import { sortActiveThreadsByOrderKey, sortPinnedThreadsByOrderKey, @@ -969,21 +970,68 @@ describe("Working shelf (beta)", () => { ).toBe(false); }); - it.each([ - ["connected", true, false], - ["connected", false, true], - ["connecting", false, false], - ["reconnecting", false, true], - ["offline", false, true], - ["error", false, true], - ["unsupported", false, true], - ["available", false, true], - ] as const)( - "lists threads outside the shelf on a %s environment: %s", - (phase, shellIsLive, disconnected) => { - expect(isSidebarEnvironmentDisconnected(phase, shellIsLive)).toBe(disconnected); - }, - ); + describe("isSidebarEnvironmentDisconnected", () => { + const follow = ( + states: ReadonlyArray, + ): boolean[] => { + let disconnected = false; + return states.map(([phase, shellIsLive]) => { + disconnected = isSidebarEnvironmentDisconnected(phase, shellIsLive, disconnected); + return disconnected; + }); + }; + + it.each(["reconnecting", "offline", "error", "unsupported", "available"] as const)( + "lists threads outside the shelf on a %s environment", + (phase) => { + expect( + follow([ + ["connected", true], + [phase, false], + ]), + ).toEqual([false, true]); + }, + ); + + it("keeps the shelf through the resync a window refocus starts on a live socket", () => { + expect( + follow([ + ["connected", true], + ["connected", false], + ["connected", true], + ]), + ).toEqual([false, false, false]); + }); + + it("keeps threads out of the shelf after a reconnect until the shell is live", () => { + expect( + follow([ + ["connected", true], + ["reconnecting", false], + ["connected", false], + ["connected", true], + ]), + ).toEqual([false, true, true, false]); + expect( + follow([ + ["available", false], + ["connecting", false], + ["connected", false], + ["connected", true], + ]), + ).toEqual([true, true, true, false]); + }); + + it("keeps cached placement through a cold start", () => { + expect( + follow([ + ["connecting", false], + ["connected", false], + ["connected", true], + ]), + ).toEqual([false, false, false]); + }); + }); it("lists a running thread outside the shelf while its environment is disconnected", () => { const thread = { ...idle, runtime, environmentId: localEnvironmentId }; diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 55cf694c2b79e..946c53ad6e42c 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -185,12 +185,16 @@ export function resolveSidebarThreadSection(input: { return "active"; } -// Keep cached placement on the first connection attempt, matching mobile's threadRouteIsHydrating. +// Between a dropped connection and a live shell, placement holds. A first connection attempt keeps +// the cached shelf, a reconnect keeps threads out until its resync lands, and the resync that +// every window refocus starts on a healthy socket moves nothing. export function isSidebarEnvironmentDisconnected( phase: EnvironmentConnectionPhase, shellIsLive: boolean, + wasDisconnected: boolean, ): boolean { - return phase !== "connecting" && (phase !== "connected" || !shellIsLive); + if (phase === "connected" && shellIsLive) return false; + return phase === "connecting" || phase === "connected" ? wasDisconnected : true; } export function belongsInSidebarWorkingShelf( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index deede26763fdd..f7bab0d1dc7bb 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -139,7 +139,6 @@ import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironmentIdentities, useConnectedEnvironmentIds, - useEnvironmentConnectionSummaries, useEnvironmentMachines, usePrimaryEnvironmentId, } from "../state/environments"; @@ -151,7 +150,7 @@ import { useServerConfigs, useQuietThreadShells, } from "../state/entities"; -import { environmentPresentations } from "../state/presentation"; +import { environmentPresentations, environmentSummaries } from "../state/presentation"; import { primaryServerKeybindingsAtom } from "../state/server"; import { environmentProjects } from "../state/projects"; import { threadEnvironment } from "../state/threads"; @@ -366,6 +365,30 @@ function scrollSidebarToTop(content: HTMLElement | null) { content?.closest("[data-slot=scroll-area-viewport]")?.scrollTo({ top: 0 }); } +let previousDisconnectedEnvironmentIds: ReadonlySet = new Set(); +const disconnectedEnvironmentIdsAtom = Atom.make((get) => { + const liveShellEnvironmentIds = get(liveShellEnvironmentIdsAtom); + const next = new Set( + get(environmentSummaries.environmentsAtom) + .filter((environment) => + isSidebarEnvironmentDisconnected( + environment.connectionState, + liveShellEnvironmentIds.has(environment.environmentId), + previousDisconnectedEnvironmentIds.has(environment.environmentId), + ), + ) + .map((environment) => environment.environmentId), + ); + if ( + next.size === previousDisconnectedEnvironmentIds.size && + [...next].every((environmentId) => previousDisconnectedEnvironmentIds.has(environmentId)) + ) { + return previousDisconnectedEnvironmentIds; + } + previousDisconnectedEnvironmentIds = next; + return next; +}).pipe(Atom.withLabel("web-sidebar-disconnected-environment-ids")); + function threadShellKey(thread: Pick): string { return scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); } @@ -441,18 +464,7 @@ function useSidebarThreads(input: { // Folders already separate the work, so boring mode has no Working shelf. const workingShelfEnabled = useClientSettings((s) => s.sidebarWorkingShelfEnabled) && folderGroups === null; - const environmentConnections = useEnvironmentConnectionSummaries(); - const liveShellEnvironmentIds = useAtomValue(liveShellEnvironmentIdsAtom); - const disconnectedEnvironmentIds = new Set( - environmentConnections - .filter((environment) => - isSidebarEnvironmentDisconnected( - environment.connectionState, - liveShellEnvironmentIds.has(environment.environmentId), - ), - ) - .map((environment) => environment.environmentId), - ); + const disconnectedEnvironmentIds = useAtomValue(disconnectedEnvironmentIdsAtom); const inboxReturnsVersion = useSyncExternalStore( inboxReturns.subscribe, inboxReturns.getSnapshot, diff --git a/apps/web/src/state/environments.ts b/apps/web/src/state/environments.ts index a35af5e094a00..0b2cab287d223 100644 --- a/apps/web/src/state/environments.ts +++ b/apps/web/src/state/environments.ts @@ -114,7 +114,3 @@ export function useEnvironmentMachines() { export function useConnectedEnvironmentIds() { return useAtomValue(environmentSummaries.connectedEnvironmentIdsAtom); } - -export function useEnvironmentConnectionSummaries() { - return useAtomValue(environmentSummaries.environmentsAtom); -}