From cdfc6e92062e8e388d23526dfa93b8f6295d0ee6 Mon Sep 17 00:00:00 2001 From: extoci Date: Mon, 2 Nov 2026 20:18:41 +0000 Subject: [PATCH 1/3] fix(web): keep working threads in place when refocusing tabs --- apps/web/src/components/Sidebar.logic.test.ts | 87 ++++++++++++++++--- apps/web/src/components/Sidebar.logic.ts | 6 +- apps/web/src/components/Sidebar.tsx | 37 +++++--- apps/web/src/state/environments.ts | 4 - 4 files changed, 101 insertions(+), 33 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 3e2e8be2ce18d..1b891cf40a0e6 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,3 +1,4 @@ +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"; @@ -1005,20 +1006,80 @@ 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("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..dcc23d9e47579 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -185,12 +185,14 @@ 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 belongsInSidebarWorkingShelf( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 18aa27709e4c0..ffaab1215b449 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"; @@ -367,6 +366,27 @@ function scrollSidebarToTop(content: HTMLElement | null) { content?.closest("[data-slot=scroll-area-viewport]")?.scrollTo({ top: 0 }); } +const disconnectedEnvironmentIdsAtom = Atom.make((get) => { + const previous = Option.getOrNull(get.self>()); + const liveShellEnvironmentIds = get(liveShellEnvironmentIdsAtom); + const next = new Set( + get(environmentSummaries.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.withLabel("web-sidebar-disconnected-environment-ids")); + function threadShellKey(thread: Pick): string { return scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); } @@ -442,18 +462,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); -} From d078c6d8a19f67ab8df4a84ff34118c5a206af83 Mon Sep 17 00:00:00 2001 From: extoci Date: Tue, 3 Nov 2026 22:21:13 +0000 Subject: [PATCH 2/3] ci: rerun checks after PDF preview timeout From 10b4c1582dc7a0816a2034622d42e1af90a24171 Mon Sep 17 00:00:00 2001 From: extoci Date: Tue, 3 Nov 2026 22:27:11 +0000 Subject: [PATCH 3/3] fix(web): track reconnects while the sidebar is closed --- apps/web/src/components/AppSidebarLayout.tsx | 6 ++- apps/web/src/components/Sidebar.logic.test.ts | 49 ++++++++++++++++++- apps/web/src/components/Sidebar.logic.ts | 34 ++++++++++++- apps/web/src/components/Sidebar.tsx | 26 ++-------- 4 files changed, 90 insertions(+), 25 deletions(-) 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 1b891cf40a0e6..6a664a9a9e4de 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -5,7 +5,7 @@ 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, @@ -14,6 +14,7 @@ import { resolveSidebarRowThreadId, belongsInSidebarWorkingShelf, isSidebarEnvironmentDisconnected, + createSidebarDisconnectedEnvironmentIdsAtom, orderItemsByPreferredIds, pinOrderKeyBetween, resolveSidebarSweepKeys, @@ -1081,6 +1082,52 @@ describe("Working shelf (beta)", () => { ).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 }; const shelve = (disconnected: ReadonlyArray, enabled = true) => diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index dcc23d9e47579..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, @@ -195,6 +196,37 @@ export function isSidebarEnvironmentDisconnected( 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( thread: T, input: { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ffaab1215b449..ad2701c823454 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -187,7 +187,7 @@ import { filterSidebarProjectScopeItems, firstValidTimestampMs, belongsInSidebarWorkingShelf, - isSidebarEnvironmentDisconnected, + createSidebarDisconnectedEnvironmentIdsAtom, isSidebarNestedLinkClick, isTrailingDoubleClick, orderItemsByPreferredIds, @@ -366,26 +366,10 @@ function scrollSidebarToTop(content: HTMLElement | null) { content?.closest("[data-slot=scroll-area-viewport]")?.scrollTo({ top: 0 }); } -const disconnectedEnvironmentIdsAtom = Atom.make((get) => { - const previous = Option.getOrNull(get.self>()); - const liveShellEnvironmentIds = get(liveShellEnvironmentIdsAtom); - const next = new Set( - get(environmentSummaries.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.withLabel("web-sidebar-disconnected-environment-ids")); +export const disconnectedEnvironmentIdsAtom = createSidebarDisconnectedEnvironmentIdsAtom( + environmentSummaries.environmentsAtom, + liveShellEnvironmentIdsAtom, +); function threadShellKey(thread: Pick): string { return scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));