From 6bc4b73c4205c000b2b777c8c4991c3c0820c2c4 Mon Sep 17 00:00:00 2001 From: Budi <11062155+budivoogt@users.noreply.github.com> Date: Mon, 26 Oct 2026 06:36:51 +0100 Subject: [PATCH] fix(app): keep the sidebar alive when a workspace entry has not hydrated (#7896) ProjectBlock asserted that every placement in a project has an entry in workspaceEntriesByKey. A retained sidebar that is inactive (compact layout with the sidebar closed) or still waiting for sessions keeps an empty map, and Show background stops the project list from being pruned to hydrated entries. From the eighth workspace on, selectSidebarGroupVisibility asked for the status of a missing entry and the app crashed on start. Count a missing entry as Done. It renders no row, so it should not claim a slot past the limit. Closes #7869 Co-authored-by: brucey0x <11062155+brucey0x@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- .../components/sidebar-workspace-list.test.tsx | 15 +++++++++++++++ .../app/src/components/sidebar-workspace-list.tsx | 7 ++----- .../sidebar/sidebar-group-visibility.ts | 12 ++++++++++++ 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/app/src/components/sidebar-workspace-list.test.tsx b/packages/app/src/components/sidebar-workspace-list.test.tsx index 0e1d7d28ab..fc57dc2dce 100644 --- a/packages/app/src/components/sidebar-workspace-list.test.tsx +++ b/packages/app/src/components/sidebar-workspace-list.test.tsx @@ -2,6 +2,7 @@ * @vitest-environment jsdom */ import { act, renderHook } from "@testing-library/react"; +import { createWorkspaceVisibilityPolicy } from "@/components/sidebar/sidebar-group-visibility"; import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages"; @@ -486,6 +487,20 @@ describe("sidebar group visibility", () => { }, ); + it("treats placements without a hydrated entry as Done", () => { + const items = entries([...Array(8).fill("done"), "running", "done"]); + const hydrated = new Map(items.slice(0, 9).map((item) => [item.workspaceKey, item])); + for (const entriesByKey of [new Map(), hydrated]) { + const { result } = renderHook(() => + useLimitedSidebarGroup(items, createWorkspaceVisibilityPolicy(entriesByKey)), + ); + expect(result.current.canToggle).toBe(true); + expect(names(result.current.visibleItems)).toEqual( + entriesByKey.size === 0 ? names(items.slice(0, 7)) : [...names(items.slice(0, 7)), "row-9"], + ); + } + }); + it("shows eight active rows once with no redundant toggle", () => { const items = entries([ "running", diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index c3048e6d9a..16294e0a03 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -99,6 +99,7 @@ import { import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction"; import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header"; import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row"; +import { createWorkspaceVisibilityPolicy } from "@/components/sidebar/sidebar-group-visibility"; import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group"; import { SidebarWorkspaceRowFrame, @@ -1593,11 +1594,7 @@ function ProjectBlock({ onToggleWorkspacePin: ToggleSidebarWorkspacePin; }) { const visibilityPolicy = useMemo( - () => ({ - kind: "workspaces" as const, - getStatusBucket: (workspace: SidebarWorkspacePlacement) => - workspaceEntriesByKey.get(workspace.workspaceKey)!.statusBucket, - }), + () => createWorkspaceVisibilityPolicy(workspaceEntriesByKey), [workspaceEntriesByKey], ); const { diff --git a/packages/app/src/components/sidebar/sidebar-group-visibility.ts b/packages/app/src/components/sidebar/sidebar-group-visibility.ts index ce9f47671c..1bc3018e9a 100644 --- a/packages/app/src/components/sidebar/sidebar-group-visibility.ts +++ b/packages/app/src/components/sidebar/sidebar-group-visibility.ts @@ -4,6 +4,18 @@ export type SidebarGroupVisibilityPolicy = | { kind: "pinned" } | { kind: "workspaces"; getStatusBucket: (item: T) => SidebarStateBucket }; +// A placement can outrun its entry: a retained sidebar that is inactive (compact layout with the +// sidebar closed) or still waiting for sessions keeps an empty entry map. A missing entry renders +// no row, so it counts as Done and claims no slot past the limit. +export function createWorkspaceVisibilityPolicy( + entriesByKey: ReadonlyMap, +): SidebarGroupVisibilityPolicy { + return { + kind: "workspaces", + getStatusBucket: (item) => entriesByKey.get(item.workspaceKey)?.statusBucket ?? "done", + }; +} + export function selectSidebarGroupVisibility( items: readonly T[], expanded: boolean,