From b071db74cb72346af1bdb33c8953427ee1a07491 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Wed, 4 Nov 2026 12:44:00 +0530 Subject: [PATCH] fix(web): keep working threads shelved during reconnects --- .../src/components/Sidebar.browser.test.tsx | 103 +++++++++++++++++- apps/web/src/components/Sidebar.logic.test.ts | 34 ------ apps/web/src/components/Sidebar.logic.ts | 24 ---- apps/web/src/components/Sidebar.tsx | 26 +---- 4 files changed, 103 insertions(+), 84 deletions(-) diff --git a/apps/web/src/components/Sidebar.browser.test.tsx b/apps/web/src/components/Sidebar.browser.test.tsx index 875a2225d70a1..378df7cb97e5c 100644 --- a/apps/web/src/components/Sidebar.browser.test.tsx +++ b/apps/web/src/components/Sidebar.browser.test.tsx @@ -1,4 +1,10 @@ -import { DEFAULT_CLIENT_SETTINGS, EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { + DEFAULT_CLIENT_SETTINGS, + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; @@ -6,12 +12,15 @@ import { render } from "../../test/render"; import { openCommandPalette } from "../commandPaletteBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { __setClientSettingsForTests, getClientSettings } from "../hooks/useSettings"; -import { useProjects } from "../state/entities"; +import { useProjects, useQuietThreadShells } from "../state/entities"; +import { useEnvironmentConnectionSummaries } from "../state/environments"; +import { makeThreadFixture } from "../test-fixtures"; import type { Project } from "../types"; import Sidebar from "./Sidebar"; import { SidebarProvider } from "./ui/sidebar"; vi.mock("../state/entities", { spy: true }); +vi.mock("../state/environments", { spy: true }); vi.mock("../hooks/useHandleNewThread", { spy: true }); vi.mock("../commandPaletteBus", () => ({ openCommandPalette: vi.fn(), @@ -87,3 +96,93 @@ describe("sidebar header new thread", () => { } }); }); + +describe("Working shelf connections", () => { + it("keeps running and waiting threads shelved through reconnects and resubscription", async () => { + using stack = new DisposableStack(); + const previousSettings = getClientSettings(); + stack.defer(() => { + __setClientSettingsForTests(previousSettings); + vi.mocked(useQuietThreadShells).mockRestore(); + vi.mocked(useEnvironmentConnectionSummaries).mockRestore(); + }); + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + sidebarWorkingShelfEnabled: true, + }); + const remote = project("remote"); + const runtime = { + status: "running" as const, + activeRunId: null, + providerInstanceId: ProviderInstanceId.make("codex"), + providerName: "Codex", + lastError: null, + updatedAt: "2026-11-04T10:00:00.000Z", + }; + const thread = (id: string) => + makeThreadFixture({ + id: ThreadId.make(id), + title: id, + projectId: remote.id, + environmentId: remote.environmentId, + }); + vi.mocked(useProjects).mockReturnValue([remote]); + vi.mocked(useQuietThreadShells).mockReturnValue([ + { ...thread("Running remotely"), runtime }, + { + ...thread("Waiting remotely"), + runtime: { ...runtime, status: "idle" }, + pendingBackgroundTasks: [ + { taskId: "background", description: "Watch build", kind: "monitor", awaited: true }, + ], + }, + thread("Ready for input"), + ]); + vi.mocked(useHandleNewThread).mockReturnValue({ + activeDraftThread: null, + activeThread: null, + defaultProjectRef: null, + handleNewThread: vi.fn(), + routeDraftId: null, + routeThreadRef: null, + }); + const connection = { + environmentId: remote.environmentId, + environmentLabel: "Remote", + displayUrl: "https://remote.example", + isRelayManaged: true, + isEnabled: true, + connectionError: null, + connectionErrorTraceId: null, + }; + vi.mocked(useEnvironmentConnectionSummaries).mockReturnValue([ + { ...connection, connectionState: "connecting" }, + ]); + const sidebar = () => ( + + + + ); + await using screen = await render(sidebar()); + await expect.element(screen.getByRole("button", { name: /^Working\s*2$/ })).toBeInTheDocument(); + + for (const connectionState of ["reconnecting", "connected", "offline", "connecting"] as const) { + vi.mocked(useEnvironmentConnectionSummaries).mockReturnValue([ + { ...connection, connectionState }, + ]); + await screen.rerender(sidebar()); + await expect + .element(screen.getByRole("button", { name: /^Working\s*2$/ })) + .toBeInTheDocument(); + await expect + .element(screen.getByRole("button", { name: /^Running remotely,/ })) + .not.toBeInTheDocument(); + await expect + .element(screen.getByRole("button", { name: /^Waiting remotely,/ })) + .not.toBeInTheDocument(); + await expect + .element(screen.getByRole("button", { name: "Ready for input, remote", exact: true })) + .toBeInTheDocument(); + } + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 3e2e8be2ce18d..8a6a05c9975a1 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -11,8 +11,6 @@ import { getFallbackThreadIdAfterDelete, hasUnseenCompletion, resolveSidebarRowThreadId, - belongsInSidebarWorkingShelf, - isSidebarEnvironmentDisconnected, orderItemsByPreferredIds, pinOrderKeyBetween, resolveSidebarSweepKeys, @@ -1004,38 +1002,6 @@ 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 c20789e2a9160..6acb1438778ca 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -16,7 +16,6 @@ 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, @@ -185,29 +184,6 @@ 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 18aa27709e4c0..a10183a32d8f0 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,4 +1,3 @@ -import { liveShellEnvironmentIdsAtom } from "../state/shell"; import { attachSidebarTree } from "../lib/sidebarTree"; import { type EnvironmentId, type ScopedProjectRef } from "@t3tools/contracts"; import type { EnvironmentServerConfig } from "@t3tools/client-runtime/connection"; @@ -140,7 +139,6 @@ import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironmentIdentities, useConnectedEnvironmentIds, - useEnvironmentConnectionSummaries, useEnvironmentMachines, usePrimaryEnvironmentId, } from "../state/environments"; @@ -187,8 +185,6 @@ import { deleteSelectedThreadEntries, filterSidebarProjectScopeItems, firstValidTimestampMs, - belongsInSidebarWorkingShelf, - isSidebarEnvironmentDisconnected, isSidebarNestedLinkClick, isTrailingDoubleClick, orderItemsByPreferredIds, @@ -356,7 +352,6 @@ 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. @@ -442,18 +437,6 @@ 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, @@ -498,7 +481,6 @@ function useSidebarThreads(input: { optimisticDrop, workingShelfEnabled, sortActiveBySend: folderGroups !== null, - disconnectedEnvironmentIds, clockKey: `${nowMinute}:${snoozeWakeClock}:${inboxReturnsVersion}`, }); @@ -634,7 +616,6 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { optimisticDrop, workingShelfEnabled, sortActiveBySend, - disconnectedEnvironmentIds, } = input; const preciseNow = new Date().toISOString(); const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys, delegatedUnderParent); @@ -643,11 +624,8 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { const active: EnvironmentThreadShell[] = []; const working: EnvironmentThreadShell[] = []; const inbox = (thread: EnvironmentThreadShell, manuallyMoved = false) => - belongsInSidebarWorkingShelf(thread, { - enabled: workingShelfEnabled, - disconnectedEnvironmentIds, - isWorking: manuallyMoved ? isThreadWorking : inboxObservation.isWorking, - }) + workingShelfEnabled && + (manuallyMoved ? isThreadWorking(thread) : inboxObservation.isWorking(thread)) ? working : active; const snoozed: EnvironmentThreadShell[] = [];