From 97295a5bce32c69f75b1195d32e33a29f738b4fd Mon Sep 17 00:00:00 2001 From: SAPHID Date: Tue, 27 Oct 2026 23:43:24 +0000 Subject: [PATCH 1/4] fix(web): threads on an unreachable environment no longer hide in the Working shelf Past a first connection attempt an environment is not answering, so its threads keep a stale running state. The sidebar now lists them in the inbox instead of folding them into the Working shelf until the environment reconnects. Pinned threads stay pinned. Mirrors the mobile fix in #26008. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/Sidebar.logic.test.ts | 13 +++++++++++++ apps/web/src/components/Sidebar.logic.ts | 9 +++++++++ apps/web/src/components/Sidebar.tsx | 14 ++++++++++++++ apps/web/src/state/environments.ts | 4 ++++ 4 files changed, 40 insertions(+) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index e4fa68a65201b..2488725d28cec 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -11,6 +11,7 @@ import { getFallbackThreadIdAfterDelete, hasUnseenCompletion, resolveSidebarRowThreadId, + isSidebarEnvironmentDisconnected, orderItemsByPreferredIds, pinOrderKeyBetween, resolveProjectStatusIndicator, @@ -1082,6 +1083,18 @@ describe("Working shelf (beta)", () => { ).toBe(false); }); + it.each([ + ["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)).toBe(disconnected); + }); + describe("sortInboxThreadsByReturn", () => { const thread = ( id: string, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index efd8244f8c2b2..1cc9e2ca0de76 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -14,6 +14,7 @@ import { type ThreadSearchShell, } from "@t3tools/client-runtime/state/thread-search"; import { foldSearchQuery, rankSearchItems, scoreThreadSearch } from "@t3tools/shared/paletteSearch"; +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import type { ContextMenuItem, EnvironmentId, ThreadId } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import type { AsyncResult } from "effect/reactivity"; @@ -177,6 +178,14 @@ export function resolveSidebarThreadSection(input: { return "active"; } +/** + * Past a first connection attempt the environment is not answering, so its threads' last run + * states are stale. Matches mobile's `threadRouteIsHydrating`. + */ +export function isSidebarEnvironmentDisconnected(phase: EnvironmentConnectionPhase): boolean { + return phase !== "connected" && phase !== "connecting"; +} + const SIDEBAR_MARKER_PREFIX = "sidebar-marker-"; export type SidebarListMarker = diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index c4ba300a030c3..cf13e37bfba30 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -129,6 +129,7 @@ import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironmentIdentities, useConnectedEnvironmentIds, + useEnvironmentConnectionSummaries, useEnvironmentMachines, usePrimaryEnvironmentId, } from "../state/environments"; @@ -175,6 +176,7 @@ import { deleteSelectedThreadEntries, filterSidebarProjectScopeItems, firstValidTimestampMs, + isSidebarEnvironmentDisconnected, isSidebarNestedLinkClick, isTrailingDoubleClick, orderItemsByPreferredIds, @@ -339,6 +341,8 @@ interface SidebarThreadPartitionInput { readonly workingShelfEnabled: boolean; /** Boring mode: folders list active threads by the user's latest message. */ readonly sortActiveBySend: boolean; + /** Environments not answering; their threads' cached run states are stale. */ + readonly disconnectedEnvironmentIds: ReadonlySet; // Snooze wakes, the queued-turn grace and the Working shelf's exit grace depend // on the clock, so the minute tick, the precise snooze-wake tick and the inbox // tracker's expiry version are inputs: a new key repartitions. @@ -418,6 +422,12 @@ 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 disconnectedEnvironmentIds = new Set( + environmentConnections + .filter((environment) => isSidebarEnvironmentDisconnected(environment.connectionState)) + .map((environment) => environment.environmentId), + ); const inboxReturnsVersion = useSyncExternalStore( inboxReturns.subscribe, inboxReturns.getSnapshot, @@ -461,6 +471,7 @@ function useSidebarThreads(input: { optimisticDrop, workingShelfEnabled, sortActiveBySend: folderGroups !== null, + disconnectedEnvironmentIds, clockKey: `${nowMinute}:${snoozeWakeClock}:${inboxReturnsVersion}`, }); @@ -596,6 +607,7 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { optimisticDrop, workingShelfEnabled, sortActiveBySend, + disconnectedEnvironmentIds, } = input; const preciseNow = new Date().toISOString(); const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys, delegatedUnderParent); @@ -605,6 +617,8 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { const working: EnvironmentThreadShell[] = []; const inbox = (thread: EnvironmentThreadShell, manuallyMoved = false) => workingShelfEnabled && + // A shelf the user collapsed must not hide a thread whose progress is unknown. + !disconnectedEnvironmentIds.has(thread.environmentId) && (manuallyMoved ? isThreadWorking(thread) : inboxObservation.isWorking(thread)) ? working : active; diff --git a/apps/web/src/state/environments.ts b/apps/web/src/state/environments.ts index 2fe8f858bf599..b32a78966c0a5 100644 --- a/apps/web/src/state/environments.ts +++ b/apps/web/src/state/environments.ts @@ -106,3 +106,7 @@ export function useEnvironmentMachines() { export function useConnectedEnvironmentIds() { return useAtomValue(environmentSummaries.connectedEnvironmentIdsAtom); } + +export function useEnvironmentConnectionSummaries() { + return useAtomValue(environmentSummaries.environmentsAtom); +} From 9e69aa8ed09b354473e78eb20b97a56237f2acbd Mon Sep 17 00:00:00 2001 From: SAPHID Date: Thu, 29 Oct 2026 06:03:51 +0000 Subject: [PATCH 2/4] test(web): cover Working shelf placement on a disconnected environment Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/Sidebar.logic.test.ts | 16 ++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 16 ++++++++++++++++ apps/web/src/components/Sidebar.tsx | 10 ++++++---- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 2488725d28cec..e06f6caee2102 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -11,6 +11,7 @@ import { getFallbackThreadIdAfterDelete, hasUnseenCompletion, resolveSidebarRowThreadId, + belongsInSidebarWorkingShelf, isSidebarEnvironmentDisconnected, orderItemsByPreferredIds, pinOrderKeyBetween, @@ -1095,6 +1096,21 @@ describe("Working shelf (beta)", () => { expect(isSidebarEnvironmentDisconnected(phase)).toBe(disconnected); }); + 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) => + belongsInSidebarWorkingShelf(thread, { + enabled, + disconnectedEnvironmentIds: new Set(disconnected), + isWorking: isSidebarThreadWorking, + }); + + expect(shelve([])).toBe(true); + expect(shelve([localEnvironmentId])).toBe(false); + expect(shelve([EnvironmentId.make("environment-other")])).toBe(true); + expect(shelve([], false)).toBe(false); + }); + describe("sortInboxThreadsByReturn", () => { const thread = ( id: string, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 1cc9e2ca0de76..9902c74b69dda 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -186,6 +186,22 @@ export function isSidebarEnvironmentDisconnected(phase: EnvironmentConnectionPha return phase !== "connected" && phase !== "connecting"; } +/** A collapsed shelf must not hide a thread whose environment stopped answering. */ +export function belongsInSidebarWorkingShelf( + thread: T, + input: { + readonly enabled: boolean; + readonly disconnectedEnvironmentIds: ReadonlySet; + readonly isWorking: (thread: T) => boolean; + }, +): boolean { + return ( + input.enabled && + !input.disconnectedEnvironmentIds.has(thread.environmentId) && + input.isWorking(thread) + ); +} + const SIDEBAR_MARKER_PREFIX = "sidebar-marker-"; export type SidebarListMarker = diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cf13e37bfba30..13f3cde0097ed 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -176,6 +176,7 @@ import { deleteSelectedThreadEntries, filterSidebarProjectScopeItems, firstValidTimestampMs, + belongsInSidebarWorkingShelf, isSidebarEnvironmentDisconnected, isSidebarNestedLinkClick, isTrailingDoubleClick, @@ -616,10 +617,11 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { const active: EnvironmentThreadShell[] = []; const working: EnvironmentThreadShell[] = []; const inbox = (thread: EnvironmentThreadShell, manuallyMoved = false) => - workingShelfEnabled && - // A shelf the user collapsed must not hide a thread whose progress is unknown. - !disconnectedEnvironmentIds.has(thread.environmentId) && - (manuallyMoved ? isThreadWorking(thread) : inboxObservation.isWorking(thread)) + belongsInSidebarWorkingShelf(thread, { + enabled: workingShelfEnabled, + disconnectedEnvironmentIds, + isWorking: manuallyMoved ? isThreadWorking : inboxObservation.isWorking, + }) ? working : active; const snoozed: EnvironmentThreadShell[] = []; From 74385136c5222ece56f9ef57ad243b1009c5d6ed Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 02:05:49 +0000 Subject: [PATCH 3/4] test(web): verify working shelf recovery after reconnection --- apps/web/src/components/Sidebar.logic.test.ts | 1 + apps/web/src/components/Sidebar.logic.ts | 6 +----- apps/web/src/components/Sidebar.tsx | 1 - 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index e06f6caee2102..4242b01cac7e9 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1107,6 +1107,7 @@ describe("Working shelf (beta)", () => { expect(shelve([])).toBe(true); expect(shelve([localEnvironmentId])).toBe(false); + expect(shelve([])).toBe(true); expect(shelve([EnvironmentId.make("environment-other")])).toBe(true); expect(shelve([], false)).toBe(false); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 9902c74b69dda..7fb5a830690ad 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -178,15 +178,11 @@ export function resolveSidebarThreadSection(input: { return "active"; } -/** - * Past a first connection attempt the environment is not answering, so its threads' last run - * states are stale. Matches mobile's `threadRouteIsHydrating`. - */ +// Keep cached placement on the first connection attempt, matching mobile's threadRouteIsHydrating. export function isSidebarEnvironmentDisconnected(phase: EnvironmentConnectionPhase): boolean { return phase !== "connected" && phase !== "connecting"; } -/** A collapsed shelf must not hide a thread whose environment stopped answering. */ export function belongsInSidebarWorkingShelf( thread: T, input: { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cfd3ad603d1f3..758180bfc435f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -346,7 +346,6 @@ interface SidebarThreadPartitionInput { readonly workingShelfEnabled: boolean; /** Boring mode: folders list active threads by the user's latest message. */ readonly sortActiveBySend: boolean; - /** Environments not answering; their threads' cached run states are stale. */ readonly disconnectedEnvironmentIds: ReadonlySet; // Snooze wakes, the queued-turn grace and the Working shelf's exit grace depend // on the clock, so the minute tick, the precise snooze-wake tick and the inbox From 7818fa9f0513d905e64dbefd9359afb4360bed6a Mon Sep 17 00:00:00 2001 From: SAPHID Date: Sat, 31 Oct 2026 06:03:01 +0000 Subject: [PATCH 4/4] fix(web): keep cached work out of the shelf until shell sync --- apps/web/src/components/Sidebar.logic.test.ts | 24 +++++++++++-------- apps/web/src/components/Sidebar.logic.ts | 7 ++++-- apps/web/src/components/Sidebar.tsx | 9 ++++++- 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 5176437550caa..6c09cd163b6df 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1091,16 +1091,20 @@ describe("Working shelf (beta)", () => { }); it.each([ - ["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)).toBe(disconnected); - }); + ["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); + }, + ); 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 6b86a50fc13dd..4a17737a0ce7c 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -181,8 +181,11 @@ export function resolveSidebarThreadSection(input: { } // Keep cached placement on the first connection attempt, matching mobile's threadRouteIsHydrating. -export function isSidebarEnvironmentDisconnected(phase: EnvironmentConnectionPhase): boolean { - return phase !== "connected" && phase !== "connecting"; +export function isSidebarEnvironmentDisconnected( + phase: EnvironmentConnectionPhase, + shellIsLive: boolean, +): boolean { + return phase !== "connecting" && (phase !== "connected" || !shellIsLive); } export function belongsInSidebarWorkingShelf( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b05ed88c7c3d5..70c63ac20af67 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,3 +1,4 @@ +import { liveShellEnvironmentIdsAtom } from "../state/shell"; import { attachSidebarTree, SIDEBAR_TREE_PROPS } from "../lib/sidebarTree"; import { type EnvironmentId, type ScopedProjectRef } from "@t3tools/contracts"; import type { EnvironmentServerConfig } from "@t3tools/client-runtime/connection"; @@ -431,9 +432,15 @@ function useSidebarThreads(input: { 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)) + .filter((environment) => + isSidebarEnvironmentDisconnected( + environment.connectionState, + liveShellEnvironmentIds.has(environment.environmentId), + ), + ) .map((environment) => environment.environmentId), ); const inboxReturnsVersion = useSyncExternalStore(