diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index f6e66a1ac6506..fde82414afa79 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -12,6 +12,8 @@ import { getFallbackThreadIdAfterDelete, hasUnseenCompletion, resolveSidebarRowThreadId, + belongsInSidebarWorkingShelf, + isSidebarEnvironmentDisconnected, orderItemsByPreferredIds, pinOrderKeyBetween, resolveProjectStatusIndicator, @@ -1089,6 +1091,38 @@ 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); + }, + ); + + 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([])).toBe(true); + 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 730a3508bb8cc..0f51d231d333b 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -16,6 +16,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 { isListedThreadLineage, type ContextMenuItem, @@ -184,6 +185,29 @@ export function resolveSidebarThreadSection(input: { return "active"; } +// Keep cached placement on the first connection attempt, matching mobile's threadRouteIsHydrating. +export function isSidebarEnvironmentDisconnected( + phase: EnvironmentConnectionPhase, + shellIsLive: boolean, +): boolean { + return phase !== "connecting" && (phase !== "connected" || !shellIsLive); +} + +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 e6f23149dfedb..2eea84e86f97f 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"; @@ -138,6 +139,7 @@ import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironmentIdentities, useConnectedEnvironmentIds, + useEnvironmentConnectionSummaries, useEnvironmentMachines, usePrimaryEnvironmentId, } from "../state/environments"; @@ -184,6 +186,8 @@ import { deleteSelectedThreadEntries, filterSidebarProjectScopeItems, firstValidTimestampMs, + belongsInSidebarWorkingShelf, + isSidebarEnvironmentDisconnected, isSidebarNestedLinkClick, isTrailingDoubleClick, orderItemsByPreferredIds, @@ -351,6 +355,7 @@ interface SidebarThreadPartitionInput { readonly workingShelfEnabled: boolean; /** Boring mode: folders list active threads by the user's latest message. */ readonly sortActiveBySend: boolean; + 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. @@ -432,6 +437,18 @@ 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 inboxReturnsVersion = useSyncExternalStore( inboxReturns.subscribe, inboxReturns.getSnapshot, @@ -476,6 +493,7 @@ function useSidebarThreads(input: { optimisticDrop, workingShelfEnabled, sortActiveBySend: folderGroups !== null, + disconnectedEnvironmentIds, clockKey: `${nowMinute}:${snoozeWakeClock}:${inboxReturnsVersion}`, }); @@ -611,6 +629,7 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { optimisticDrop, workingShelfEnabled, sortActiveBySend, + disconnectedEnvironmentIds, } = input; const preciseNow = new Date().toISOString(); const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys, delegatedUnderParent); @@ -619,8 +638,11 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { const active: EnvironmentThreadShell[] = []; const working: EnvironmentThreadShell[] = []; const inbox = (thread: EnvironmentThreadShell, manuallyMoved = false) => - workingShelfEnabled && - (manuallyMoved ? isThreadWorking(thread) : inboxObservation.isWorking(thread)) + belongsInSidebarWorkingShelf(thread, { + enabled: workingShelfEnabled, + disconnectedEnvironmentIds, + isWorking: manuallyMoved ? isThreadWorking : inboxObservation.isWorking, + }) ? working : active; const snoozed: EnvironmentThreadShell[] = []; diff --git a/apps/web/src/state/environments.ts b/apps/web/src/state/environments.ts index 0b2cab287d223..a35af5e094a00 100644 --- a/apps/web/src/state/environments.ts +++ b/apps/web/src/state/environments.ts @@ -114,3 +114,7 @@ export function useEnvironmentMachines() { export function useConnectedEnvironmentIds() { return useAtomValue(environmentSummaries.connectedEnvironmentIdsAtom); } + +export function useEnvironmentConnectionSummaries() { + return useAtomValue(environmentSummaries.environmentsAtom); +}