Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 58 additions & 19 deletions apps/web/src/components/InboxSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,30 +39,44 @@ export function InboxSidebar({
}) {
const [localPrimaryView, setLocalPrimaryView] = useState<"inbox" | "orchestrator">("inbox");
const [category, setCategory] = useState<"normal" | "orchestrator">("normal");
const [openShelves, setOpenShelves] = useState<ReadonlySet<string>>(() => new Set());
const [openShelves, setOpenShelves] = useState<ReadonlySet<string>>(() => 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 (
<li key={item.id}>
<button
className="w-full truncate rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent"
aria-current={selected ? "page" : undefined}
className={`flex w-full min-w-0 items-center gap-2 rounded-lg text-left transition-colors hover:bg-accent ${
compact
? "px-2.5 py-1.5 text-xs text-muted-foreground"
: "border border-transparent px-2.5 py-2 text-sm aria-[current=page]:border-border aria-[current=page]:bg-accent/70"
}`}
type="button"
onClick={() => item.route && onNavigate(item.route)}
>
{item.title ?? item.id}
{item.status ? (
<span aria-hidden className="size-1.5 shrink-0 rounded-full bg-emerald-500/80" />
) : null}
<span className="min-w-0 flex-1 truncate">{item.title ?? item.id}</span>
{item.status && !compact ? (
<span className="shrink-0 text-[10px] capitalize text-muted-foreground">
{item.status.replaceAll("-", " ")}
</span>
) : null}
</button>
</li>
);
};

return (
<div
className={`flex min-h-0 flex-col gap-3 px-2 py-2 ${
className={`flex min-h-0 flex-col px-2 py-2 ${
primaryView === "inbox" ? "flex-1" : "shrink-0"
}`}
>
Expand Down Expand Up @@ -90,43 +104,59 @@ export function InboxSidebar({
</div>

{primaryView === "orchestrator" ? null : (
<>
<div className="grid grid-cols-2 rounded-lg bg-muted/60 p-1">
<div className="mt-2 flex min-h-0 flex-1 flex-col">
<div className="flex shrink-0 items-center gap-4 border-b border-sidebar-border/70 px-2">
<button
className="rounded-md px-2 py-1 text-xs aria-pressed:bg-background"
className="-mb-px border-b-2 border-transparent px-0.5 py-2 text-xs font-medium text-muted-foreground aria-pressed:border-foreground aria-pressed:text-foreground"
type="button"
aria-pressed={category === "normal"}
onClick={() => setCategory("normal")}
>
Normal tasks
</button>
<button
className="rounded-md px-2 py-1 text-xs aria-pressed:bg-background"
className="-mb-px border-b-2 border-transparent px-0.5 py-2 text-xs font-medium text-muted-foreground aria-pressed:border-foreground aria-pressed:text-foreground"
type="button"
aria-pressed={category === "orchestrator"}
onClick={() => setCategory("orchestrator")}
>
Orchestrator tasks
</button>
</div>
<div className="min-h-0 overflow-y-auto">
<div className="min-h-0 flex-1 overflow-y-auto pt-2">
{category === "orchestrator" ? (
<ul className="space-y-0.5">{entries.orchestrator.map(renderEntry)}</ul>
entries.orchestrator.length === 0 ? (
<p className="px-2.5 py-3 text-xs text-muted-foreground">
No active Orchestrator tasks
</p>
) : (
<ul className="space-y-0.5">
{entries.orchestrator.map((entry) => renderEntry(entry))}
</ul>
)
) : (
<div className="space-y-2">
<ul className="space-y-0.5">{entries.normal.shelves.active.map(renderEntry)}</ul>
<div>
{entries.normal.shelves.active.length === 0 ? (
<p className="px-2.5 py-3 text-xs text-muted-foreground">No active tasks</p>
) : (
<ul className="space-y-0.5">
{entries.normal.shelves.active.map((entry) => renderEntry(entry))}
</ul>
)}
{entries.normal.selected?.shelf !== undefined &&
entries.normal.selected.shelf !== "active" ? (
<ul aria-label="Selected normal task" className="rounded-md bg-accent/50 p-0.5">
<ul aria-label="Selected normal task" className="mt-1 rounded-md bg-accent/40">
{renderEntry(entries.normal.selected)}
</ul>
) : 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 (
<section key={shelf}>
<section className="mt-3" key={shelf}>
<button
className="flex w-full items-center gap-1 rounded px-1 py-1 text-xs font-medium text-muted-foreground"
className="flex w-full items-center gap-2 px-2.5 py-1 text-[11px] font-medium text-muted-foreground"
type="button"
aria-expanded={open}
onClick={() =>
Expand All @@ -141,11 +171,20 @@ export function InboxSidebar({
<ChevronDownIcon
className={`size-3 transition-transform ${open ? "" : "-rotate-90"}`}
/>
{shelf === "snoozed" ? "Snoozed" : "Settled"}
<span>{shelf === "snoozed" ? "Snoozed" : "Settled"}</span>
<span className="tabular-nums text-muted-foreground/60">
{shelfEntries.length}
</span>
<span
aria-hidden
className={`h-px flex-1 ${
shelf === "snoozed" ? "bg-blue-500/35" : "bg-sidebar-border"
}`}
/>
</button>
{open ? (
<ul className="space-y-0.5">
{entries.normal.shelves[shelf].map(renderEntry)}
{shelfEntries.map((entry) => renderEntry(entry, true))}
</ul>
) : null}
</section>
Expand All @@ -154,7 +193,7 @@ export function InboxSidebar({
</div>
)}
</div>
</>
</div>
)}
</div>
);
Expand Down
44 changes: 41 additions & 3 deletions apps/web/src/components/Sidebar.browser.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand All @@ -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 = {
Expand All @@ -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,
Expand All @@ -65,6 +98,9 @@ function seedStore(): void {
},
threadIds: [threadId],
sidebarThreadSummaryById: { [threadId]: thread },
taskIds: [taskId],
taskIdsByProjectId: { [projectId]: [taskId] },
taskById: { [taskId]: task },
bootstrapComplete: true,
},
},
Expand All @@ -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: () => (
Expand Down Expand Up @@ -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")
Expand Down
96 changes: 9 additions & 87 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -159,20 +104,6 @@ const SidebarChromeFooter = memo(function SidebarChromeFooter() {
<SidebarProviderUpdatePill />
<SidebarUpdatePill />
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
size="sm"
className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground"
onClick={handleModeClick}
>
{showOrchestratorMode ? (
<SquarePenIcon className="size-3.5" />
) : (
<WorkflowIcon className="size-3.5" />
)}
<span className="text-xs">{showOrchestratorMode ? "Chat" : "Orchestrator"}</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
size="sm"
Expand All @@ -190,16 +121,7 @@ const SidebarChromeFooter = memo(function SidebarChromeFooter() {

export default function Sidebar() {
const sidebarThreads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments));
const inboxTasks = useStore(
useShallow((state) =>
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({
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]) =>
Expand Down
Loading