From 07d7d3c7be82e6a08e12d35230453c2495cafe14 Mon Sep 17 00:00:00 2001 From: Eddie Gyarmati <34992044+edgyarmati@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:44:55 +0200 Subject: [PATCH] Fix Inbox sidebar render loop and align work-list styling Subscribe the Sidebar to stable stored Orchestrator task projections instead of cloning tasks during every Zustand snapshot read, preventing React's maximum-update-depth loop in production builds. Refine the Inbox toward the upstream Sidebar V2 visual hierarchy with compact category tabs, conditional count-bearing lifecycle shelves, denser rows, and one primary Inbox/Orchestrator control. Extend the actual Sidebar browser regression with an active Orchestrator task and document the unreleased fix. --- CHANGELOG.md | 5 ++ apps/web/src/components/InboxSidebar.tsx | 77 +++++++++++++---- apps/web/src/components/Sidebar.browser.tsx | 44 +++++++++- apps/web/src/components/Sidebar.tsx | 96 ++------------------- apps/web/src/store.ts | 5 ++ 5 files changed, 118 insertions(+), 109 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4501feec..cf5d08c56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ Release notes are grouped by released version. Add a `## X.Y.Z` section before r ## Unreleased +- Fix: Prevent the new Inbox sidebar from entering a React maximum-update-depth loop when + Orchestrator tasks are present. The sidebar now subscribes to stable stored task projections and + uses a denser upstream-inspired work-list treatment with compact category tabs, conditional + count-bearing Snoozed/Settled shelves, and a single primary Inbox/Orchestrator switch. + - Add: Introduce a durable work Inbox with an animated Inbox/Orchestrator view switch. Normal chat threads now have persisted Active, Snoozed, and Settled lifecycle states, automatically reopen when messaged, and wake from snooze through server-owned reconciliation. Active Orchestrator tasks diff --git a/apps/web/src/components/InboxSidebar.tsx b/apps/web/src/components/InboxSidebar.tsx index 4c479f5d5..54e38dd85 100644 --- a/apps/web/src/components/InboxSidebar.tsx +++ b/apps/web/src/components/InboxSidebar.tsx @@ -39,22 +39,36 @@ export function InboxSidebar({ }) { const [localPrimaryView, setLocalPrimaryView] = useState<"inbox" | "orchestrator">("inbox"); const [category, setCategory] = useState<"normal" | "orchestrator">("normal"); - const [openShelves, setOpenShelves] = useState>(() => new Set()); + const [openShelves, setOpenShelves] = useState>(() => new Set(["settled"])); const primaryView = controlledPrimaryView ?? localPrimaryView; const setPrimaryView = (view: "inbox" | "orchestrator") => { setLocalPrimaryView(view); onPrimaryViewChange?.(view); }; - const renderEntry = (entry: ShelfEntry) => { + const renderEntry = (entry: ShelfEntry, compact = false) => { const item = typeof entry === "string" ? { id: entry } : entry; + const selected = entries.normal.selected?.id === item.id; return (
  • ); @@ -62,7 +76,7 @@ export function InboxSidebar({ return (
    @@ -90,10 +104,10 @@ export function InboxSidebar({
    {primaryView === "orchestrator" ? null : ( - <> -
    +
    +
    -
    +
    {category === "orchestrator" ? ( -
      {entries.orchestrator.map(renderEntry)}
    + entries.orchestrator.length === 0 ? ( +

    + No active Orchestrator tasks +

    + ) : ( +
      + {entries.orchestrator.map((entry) => renderEntry(entry))} +
    + ) ) : ( -
    -
      {entries.normal.shelves.active.map(renderEntry)}
    +
    + {entries.normal.shelves.active.length === 0 ? ( +

    No active tasks

    + ) : ( +
      + {entries.normal.shelves.active.map((entry) => renderEntry(entry))} +
    + )} {entries.normal.selected?.shelf !== undefined && entries.normal.selected.shelf !== "active" ? ( -
      +
        {renderEntry(entries.normal.selected)}
      ) : null} {(["snoozed", "settled"] as const).map((shelf) => { + const shelfEntries = entries.normal.shelves[shelf]; + if (shelfEntries.length === 0) return null; const open = openShelves.has(shelf); return ( -
      +
      {open ? (
        - {entries.normal.shelves[shelf].map(renderEntry)} + {shelfEntries.map((entry) => renderEntry(entry, true))}
      ) : null}
      @@ -154,7 +193,7 @@ export function InboxSidebar({
    )}
    - +
    )}
    ); diff --git a/apps/web/src/components/Sidebar.browser.tsx b/apps/web/src/components/Sidebar.browser.tsx index c092a30b1..41d357eb5 100644 --- a/apps/web/src/components/Sidebar.browser.tsx +++ b/apps/web/src/components/Sidebar.browser.tsx @@ -1,4 +1,11 @@ -import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + TaskId, + TaskTypeId, + ThreadId, +} from "@t3tools/contracts"; import { RouterProvider, createMemoryHistory, @@ -12,7 +19,7 @@ import { page } from "vitest/browser"; import { render } from "vitest-browser-react"; import { initialEnvironmentState, useStore } from "../store"; -import type { SidebarThreadSummary } from "../types"; +import type { OrchestratorTask, SidebarThreadSummary } from "../types"; import { useUiStateStore } from "../uiStateStore"; import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; import Sidebar from "./Sidebar"; @@ -21,6 +28,7 @@ import { SidebarProvider } from "./ui/sidebar"; const environmentId = EnvironmentId.make("env-inbox-sidebar"); const projectId = ProjectId.make("project-inbox-sidebar"); const threadId = ThreadId.make("thread-inbox-sidebar"); +const taskId = TaskId.make("task-inbox-sidebar"); function seedStore(): void { const thread: SidebarThreadSummary = { @@ -41,6 +49,31 @@ function seedStore(): void { hasActionableProposedPlan: false, inboxLifecycle: "active", } as SidebarThreadSummary; + const task: OrchestratorTask = { + id: taskId, + environmentId, + projectId, + type: TaskTypeId.make("feature"), + title: "Running orchestrator task", + status: "working", + branch: null, + worktreePath: null, + prUrl: null, + pmMessageId: null, + stageThreadIds: [], + currentStageThreadId: null, + cancellation: null, + changeReview: null, + verification: null, + noChangesNeeded: null, + landing: null, + archivedAt: null, + deletedAt: null, + roleCapabilityTiers: {}, + playbookVersion: null, + createdAt: "2026-07-01T00:00:00.000Z", + updatedAt: "2026-07-01T00:00:00.000Z", + }; useStore.setState({ activeEnvironmentId: environmentId, @@ -65,6 +98,9 @@ function seedStore(): void { }, threadIds: [threadId], sidebarThreadSummaryById: { [threadId]: thread }, + taskIds: [taskId], + taskIdsByProjectId: { [projectId]: [taskId] }, + taskById: { [taskId]: task }, bootstrapComplete: true, }, }, @@ -80,7 +116,7 @@ afterEach(() => { }); }); -it("switches the actual sidebar from Inbox to the Orchestrator project navigator", async () => { +it("renders inbox entries with an active orchestrator task without a render loop", async () => { seedStore(); const rootRoute = createRootRoute({ component: () => ( @@ -121,6 +157,8 @@ it("switches the actual sidebar from Inbox to the Orchestrator project navigator .element(page.getByTestId("inbox-primary-switch")) .toHaveAttribute("data-animated-long-pill", "true"); await expect.element(page.getByText("Active normal task")).toBeInTheDocument(); + await page.getByRole("button", { name: "Orchestrator tasks" }).click(); + await expect.element(page.getByText("Running orchestrator task")).toBeInTheDocument(); await page .getByTestId("inbox-primary-switch") diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d5cd51ba2..e16daf9cd 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,16 +1,20 @@ -import { SettingsIcon, SquarePenIcon, WorkflowIcon } from "lucide-react"; +import { SettingsIcon } from "lucide-react"; import { memo, useCallback, useMemo } from "react"; import { useShallow } from "zustand/react/shallow"; import { Link, useLocation, useNavigate, useParams } from "@tanstack/react-router"; import { scopeProjectRef } from "@t3tools/client-runtime"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; -import { useComposerDraftStore } from "../composerDraftStore"; import { usePrimaryEnvironmentId } from "../environments/primary"; import { isElectron } from "../env"; -import { selectProjectByRef, selectSidebarThreadsAcrossEnvironments, useStore } from "../store"; +import { + selectProjectByRef, + selectSidebarThreadsAcrossEnvironments, + selectTasksAcrossEnvironments, + useStore, +} from "../store"; import { useUiStateStore } from "../uiStateStore"; -import { buildDraftThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; +import { resolveThreadRouteRef } from "../threadRoutes"; import { selectInboxEntries } from "../inboxSelectors"; import { InboxSidebar } from "./InboxSidebar"; import { OrchestratorSidebarNav } from "./orchestrator/OrchestratorSidebarNav"; @@ -87,66 +91,7 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({ const SidebarChromeFooter = memo(function SidebarChromeFooter() { const navigate = useNavigate(); - const pathname = useLocation({ select: (location) => location.pathname }); const { isMobile, setOpenMobile } = useSidebar(); - const orchestratorMode = useUiStateStore((state) => state.orchestratorMode); - const setOrchestratorMode = useUiStateStore((state) => state.setOrchestratorMode); - const lastOrchestratorProject = useUiStateStore((state) => state.lastOrchestratorProject); - const isOrchestratorRoute = pathname.startsWith("/orch"); - const showOrchestratorMode = orchestratorMode || isOrchestratorRoute; - const handleModeClick = useCallback(() => { - const nextMode = !showOrchestratorMode; - setOrchestratorMode(nextMode); - if (isMobile) { - setOpenMobile(false); - } - if (!nextMode) { - if (lastOrchestratorProject) { - const projectDraft = useComposerDraftStore - .getState() - .getDraftThreadByProjectRef( - scopeProjectRef( - lastOrchestratorProject.environmentId, - lastOrchestratorProject.projectId, - ), - ); - if (projectDraft) { - void navigate({ - to: "/draft/$draftId", - params: buildDraftThreadRouteParams(projectDraft.draftId), - }); - return; - } - } - void navigate({ to: "/" }); - return; - } - // Return to the last-visited orchestrator workspace when it still exists; - // otherwise land on the project grid. - const target = resolveOrchestratorLandingTarget({ - lastProject: lastOrchestratorProject, - projectExists: (ref) => - selectProjectByRef( - useStore.getState(), - scopeProjectRef(ref.environmentId, ref.projectId), - ) !== undefined, - }); - if (target) { - void navigate({ - to: "/orch/$environmentId/$projectId", - params: { environmentId: target.environmentId, projectId: target.projectId }, - }); - return; - } - void navigate({ to: "/orch" }); - }, [ - isMobile, - lastOrchestratorProject, - navigate, - setOpenMobile, - setOrchestratorMode, - showOrchestratorMode, - ]); const handleSettingsClick = useCallback(() => { if (isMobile) { setOpenMobile(false); @@ -159,20 +104,6 @@ const SidebarChromeFooter = memo(function SidebarChromeFooter() { - - - {showOrchestratorMode ? ( - - ) : ( - - )} - {showOrchestratorMode ? "Chat" : "Orchestrator"} - - - Object.entries(state.environmentStateById).flatMap(([environmentId, environment]) => - environment.taskIds.flatMap((taskId) => { - const task = environment.taskById[taskId]; - return task ? [Object.assign({}, task, { environmentId })] : []; - }), - ), - ), - ); + const inboxTasks = useStore(useShallow(selectTasksAcrossEnvironments)); const navigate = useNavigate(); const pathname = useLocation({ select: (location) => location.pathname }); const routeThreadRef = useParams({ diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index af4ad3fa5..257622299 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -3497,6 +3497,11 @@ export function selectThreadsAcrossEnvironments(state: AppState): Thread[] { ); } +/** Returns the stable task projections already stored per environment. */ +export function selectTasksAcrossEnvironments(state: AppState): OrchestratorTask[] { + return getEnvironmentEntries(state).flatMap(([, environmentState]) => getTasks(environmentState)); +} + /** Like `selectThreadsAcrossEnvironments` but returns stable `ThreadShell` references from the store (no derived data). */ export function selectThreadShellsAcrossEnvironments(state: AppState): ThreadShell[] { return getEnvironmentEntries(state).flatMap(([, environmentState]) =>