diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 91df5616f7f50..42688b1faf319 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,4 +1,4 @@ -import { useAtomValue } from "@effect/atom-react"; +import { useAtomMount, useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { useEffect, @@ -39,7 +39,7 @@ import { } from "../panelAnimations"; import { BackgroundThreadAnnouncer } from "./BackgroundThreadAnnouncer"; import { HistorySwipeNavigation } from "./HistorySwipeNavigation"; -import ThreadSidebar from "./Sidebar"; +import ThreadSidebar, { disconnectedEnvironmentIdsAtom } from "./Sidebar"; import { SidebarBrandWidthProbe } from "./sidebar/SidebarChrome"; import { isAppModalPage } from "./sidebar/mainAppLocation"; import type { SidebarRouteReveal } from "./sidebar/routeReveal"; @@ -402,6 +402,8 @@ function SidebarControl() { } export function AppSidebarLayout({ children }: { children: ReactNode }) { + // The narrow sidebar unmounts while closed; connection history must keep updating. + useAtomMount(disconnectedEnvironmentIdsAtom); const sidebarArea = workspaceAreaProps("sidebar", 0, "Threads"); const [sidebarRouteReveal] = useState(() => ({ observedKey: null, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 3e2e8be2ce18d..6a664a9a9e4de 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,10 +1,11 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import { presentThreadShell } from "@t3tools/client-runtime/state/models"; import { isThreadWorking as isSidebarThreadWorking } from "@t3tools/client-runtime/state/thread-inbox"; import * as DateTime from "effect/DateTime"; import { deriveActiveWorkStartedAt } from "../session-logic.ts"; import { describe, expect, it } from "vite-plus/test"; import * as Cause from "effect/Cause"; -import { AsyncResult } from "effect/reactivity"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import { shouldCreateNewThreadInCurrentProject, deleteSelectedThreadEntries, @@ -13,6 +14,7 @@ import { resolveSidebarRowThreadId, belongsInSidebarWorkingShelf, isSidebarEnvironmentDisconnected, + createSidebarDisconnectedEnvironmentIdsAtom, orderItemsByPreferredIds, pinOrderKeyBetween, resolveSidebarSweepKeys, @@ -1005,20 +1007,126 @@ describe("Working shelf (beta)", () => { }); 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); + ["connected", false], + ["connecting", false], + ["reconnecting", true], + ["offline", true], + ["error", true], + ["unsupported", true], + ["available", true], + ] as const)("lists threads outside the shelf on a %s environment: %s", (phase, disconnected) => { + expect(isSidebarEnvironmentDisconnected(phase, true, false)).toBe(disconnected); + }); + + it.each([ + { + name: "foreground refresh", + states: [ + ["connected", true], + ["connected", false], + ["connected", true], + ], + expected: [false, false, false], }, - ); + { + name: "reconnect before synchronization", + states: [ + ["connected", true], + ["reconnecting", false], + ["connected", false], + ["connected", true], + ], + expected: [false, true, true, false], + }, + { + name: "connecting again after going offline", + states: [ + ["connected", true], + ["offline", false], + ["connecting", false], + ["connected", false], + ["connected", true], + ], + expected: [false, true, true, true, false], + }, + { + name: "explicit connection from available", + states: [ + ["available", false], + ["connecting", false], + ["connected", false], + ["connected", true], + ], + expected: [true, true, true, false], + }, + { + name: "cold start", + states: [ + ["connecting", false], + ["connected", false], + ["connected", true], + ], + expected: [false, false, false], + }, + ] satisfies ReadonlyArray<{ + name: string; + states: ReadonlyArray; + expected: ReadonlyArray; + }>)("preserves shelf placement through $name", ({ states, expected }) => { + let disconnected = false; + expect( + states.map(([phase, shellIsLive]) => { + disconnected = isSidebarEnvironmentDisconnected(phase, shellIsLive, disconnected); + return disconnected; + }), + ).toEqual(expected); + }); + + it("tracks reconnects before the sidebar first reads and while its reader is unmounted", () => { + const registry = AtomRegistry.make(); + try { + const environments = Atom.make< + ReadonlyArray<{ + environmentId: EnvironmentId; + connectionState: EnvironmentConnectionPhase; + }> + >([{ environmentId: localEnvironmentId, connectionState: "connected" }]); + const live = Atom.make>(new Set([localEnvironmentId])); + const disconnected = createSidebarDisconnectedEnvironmentIdsAtom(environments, live); + registry.mount(disconnected); + + registry.set(environments, [ + { environmentId: localEnvironmentId, connectionState: "reconnecting" }, + ]); + registry.set(live, new Set()); + registry.set(environments, [ + { environmentId: localEnvironmentId, connectionState: "connected" }, + ]); + const unmountSidebar = registry.mount(disconnected); + expect([...registry.get(disconnected)]).toEqual([localEnvironmentId]); + unmountSidebar(); + + registry.set(live, new Set([localEnvironmentId])); + const synchronized = registry.get(disconnected); + expect([...synchronized]).toEqual([]); + registry.set(live, new Set()); + expect(registry.get(disconnected)).toBe(synchronized); + registry.set(live, new Set([localEnvironmentId])); + expect(registry.get(disconnected)).toBe(synchronized); + registry.set(environments, [ + { environmentId: localEnvironmentId, connectionState: "offline" }, + ]); + registry.set(live, new Set()); + registry.set(environments, [ + { environmentId: localEnvironmentId, connectionState: "connected" }, + ]); + expect([...registry.get(disconnected)]).toEqual([localEnvironmentId]); + registry.set(live, new Set([localEnvironmentId])); + expect([...registry.get(disconnected)]).toEqual([]); + } finally { + registry.dispose(); + } + }); 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 c20789e2a9160..4b25d70d7fa8b 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -24,7 +24,8 @@ import { type ThreadId, } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; -import type { AsyncResult } from "effect/reactivity"; +import { Atom, type AsyncResult } from "effect/reactivity"; +import * as Option from "effect/Option"; import { effectiveSnoozed, type ThreadSnoozeShell, @@ -185,12 +186,45 @@ export function resolveSidebarThreadSection(input: { return "active"; } -// Keep cached placement on the first connection attempt, matching mobile's threadRouteIsHydrating. +// A healthy refresh keeps its shelf; a reconnect waits for authoritative shell data. 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 createSidebarDisconnectedEnvironmentIdsAtom( + environmentsAtom: Atom.Atom< + ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly connectionState: EnvironmentConnectionPhase; + }> + >, + liveEnvironmentIdsAtom: Atom.Atom>, +) { + return Atom.make((get) => { + const previous = Option.getOrNull(get.self>()); + const liveShellEnvironmentIds = get(liveEnvironmentIdsAtom); + const next = new Set( + get(environmentsAtom) + .filter((environment) => + isSidebarEnvironmentDisconnected( + environment.connectionState, + liveShellEnvironmentIds.has(environment.environmentId), + previous?.has(environment.environmentId) ?? false, + ), + ) + .map((environment) => environment.environmentId), + ); + return previous !== null && + next.size === previous.size && + [...next].every((environmentId) => previous.has(environmentId)) + ? previous + : next; + }).pipe(Atom.keepAlive, Atom.withLabel("web-sidebar-disconnected-environment-ids")); } export function belongsInSidebarWorkingShelf( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 18aa27709e4c0..ad2701c823454 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -140,7 +140,6 @@ import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironmentIdentities, useConnectedEnvironmentIds, - useEnvironmentConnectionSummaries, useEnvironmentMachines, usePrimaryEnvironmentId, } from "../state/environments"; @@ -152,7 +151,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"; @@ -188,7 +187,7 @@ import { filterSidebarProjectScopeItems, firstValidTimestampMs, belongsInSidebarWorkingShelf, - isSidebarEnvironmentDisconnected, + createSidebarDisconnectedEnvironmentIdsAtom, isSidebarNestedLinkClick, isTrailingDoubleClick, orderItemsByPreferredIds, @@ -367,6 +366,11 @@ function scrollSidebarToTop(content: HTMLElement | null) { content?.closest("[data-slot=scroll-area-viewport]")?.scrollTo({ top: 0 }); } +export const disconnectedEnvironmentIdsAtom = createSidebarDisconnectedEnvironmentIdsAtom( + environmentSummaries.environmentsAtom, + liveShellEnvironmentIdsAtom, +); + function threadShellKey(thread: Pick): string { return scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); } @@ -442,18 +446,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); -}