feat(web): sidebar and dashboard header UI/UX polish (#1846)

* perf(web): eliminate sidebar re-renders on every SSE tick

Resolves #1844

The SSE hook delivers a new sessions array reference every 5 seconds
even when content is identical, causing sessionsByProject to recompute
and all session rows to re-render on every tick.

Three fixes in ProjectSidebar.tsx:
* sessionsKey + sessionsRef: replace unstable array reference in memo
  deps with a content-derived string; memo only fires on real changes
* SessionRow memoized component: rows skip re-renders when props are
  unchanged; navigate and startRename stabilised with useCallback
* SessionDot memoized: status indicator skips re-renders when level
  prop is unchanged

A quiet SSE tick now touches zero React components in the sidebar.

* fix(web): add displayName, displayNameUserSet, branch to sessionsKey hash

Without these fields, a session rename delivered via SSE did not
trigger sessionsByProject to recompute. The stale session object
held the old displayName, and once the optimistic pendingRename
was cleared the sidebar silently reverted to the pre-rename title.

Addresses review feedback on PR #1846.

* feat(web): sidebar and dashboard header UI/UX polish

Removes state text labels from sidebar session rows so the colored dot
is the sole status indicator, matching the intended design. Fixes the
sidebar compact header height to align with the 48px main header.
Adds session count summary pills to the dashboard project header.
Converts CopyDebugBundleButton to an icon-only compact form so the
actions row stays vertically centered. Fixes the project page wrapper
missing flex-1 which caused a right-side viewport gap in the horizontal
shell layout.

* fix(web): resolve lint errors from UI/UX polish

Remove LEVEL_LABELS, _isLoading prefix for unused loading var, and dead
title variable from ProjectSidebar. Remove unused isDashboardSessionStatus
and isActivityStateValue from the project session page. Remove
react-hooks/exhaustive-deps eslint-disable comments for a rule not in the
ESLint config. Stabilize startRename via pendingRenamesRef so the callback
does not recreate on every rename state change, preventing unnecessary
SessionRow re-renders. Remove non-null assertion in Dashboard.tsx
handleToggleSidebar with a null guard.

* fix(web): replace native Node 25 localStorage stub with full in-memory mock

Node.js 25 exposes a native localStorage via --localstorage-file that lacks
.clear() and .key(), causing all UpdateBanner tests to throw TypeError.
Replace the global with a complete in-memory implementation so test suites
work across all Node versions.

* fix(web): seed sidebar with all sessions on hard refresh and eliminate per-project layout re-render

- Hoist sidebar layout from projects/[projectId]/layout.tsx to projects/layout.tsx so it renders
  once for the entire /projects/* subtree and never re-mounts when switching between projects
- Pass getDashboardPageData("all") so initial sessions cover every project, not just the primary
- Extract ProjectLayoutClient from the old client layout for clean server/client split
- Simplify per-project empty state to "No active sessions" only, removing the second hint line
- Restore accidentally deleted Dashboard empty-state test for zero-projects install
- Fix Reflect.deleteProperty lint error in localStorage mock; drop unused AttentionLevel import
  and dead effectiveDisplayName/pending variables from ProjectSidebar editing block

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web): address PR review — orchestrators prop, sessionsKey, badge count, mobile overlay, tests

* fix(web): remove duplicate skeleton sidebar from project loading state

* fix(web): restore orchestrator button and eliminate Session unavailable flash

Re-add the orchestrator icon + menu item that were removed during the merge
conflict resolution. Convert the icon from a <Link> to an <a> that calls
navigate() with the full session object so ProjectSessionPage gets an
instant sessionStorage cache hit instead of starting with session=null
and briefly showing the "Session unavailable" error card.

* fix(web): eliminate Session unavailable flash on orchestrator navigation

React Strict Mode aborts the first fetchSession() during its unmount/remount
cycle. The aborted finally reset fetchingSessionRef but set loading=false,
briefly showing the error card before the retry completed. Fix: keep loading=true
on abort (no session yet), and immediately retry via fetchSession() once the
ref is clear. mountedRef guards the retry so it only fires on Strict Mode
remounts — not on genuine navigation-away unmounts, which would leak requests.

* fix(web): fix working pill count and layout of error states in project session page

- Dashboard topbar "working" pill now counts only actively working sessions,
  not working + pending (which inflated the number incorrectly)
- Error/loading/missing states in ProjectSessionPage wrapped in
  dashboard-main--desktop so they fill the flex shell beside the sidebar
  instead of shrinking to content width

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Pritom Mazumdar 2026-05-17 23:45:35 +05:30 committed by GitHub
parent 94981dc0fd
commit 406b26e837
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1457 additions and 864 deletions

View File

@ -2,6 +2,22 @@ import { cleanup } from "@testing-library/react";
import * as matchers from "@testing-library/jest-dom/matchers";
import { afterEach, expect } from "vitest";
// Node.js 25 exposes a native localStorage stub via --localstorage-file that
// lacks .clear()/.key()/.length. Replace it with a complete in-memory mock so
// tests that call window.localStorage.clear() work on any Node version.
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (k: string) => store[k] ?? null,
setItem: (k: string, v: string) => { store[k] = String(v); },
removeItem: (k: string) => { Reflect.deleteProperty(store, k); },
clear: () => { store = {}; },
get length() { return Object.keys(store).length; },
key: (i: number) => Object.keys(store)[i] ?? null,
};
})();
Object.defineProperty(window, "localStorage", { value: localStorageMock, writable: true, configurable: true });
expect.extend(matchers);
afterEach(() => {
cleanup();

View File

@ -1230,6 +1230,11 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
color 100ms ease,
transform 120ms cubic-bezier(0.23, 1, 0.32, 1);
}
.dashboard-app-btn--icon {
width: 27px;
padding: 0;
justify-content: center;
}
.dashboard-app-btn:hover {
background: var(--color-bg-subtle);
@ -1328,6 +1333,12 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
.topbar-status-pill__label {
color: inherit;
}
.topbar-status-pill__dot--working {
background: var(--color-status-working);
}
.topbar-status-pill__dot--attention {
background: var(--color-status-attention);
}
/* Branch pill — compact topbar variant */
.topbar-branch-pill {
@ -3846,7 +3857,8 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px 8px;
height: 48px;
padding: 0 12px;
border-bottom: 1px solid var(--sidebar-border);
flex-shrink: 0;
}
@ -4483,6 +4495,8 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
.project-sidebar__sess-row--active {
background: transparent;
border-left: 2px solid var(--color-accent-amber);
padding-left: 24px;
}
/* Session label */
@ -4627,6 +4641,13 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
font-size: 11px;
}
.project-sidebar__empty-hint {
display: block;
font-size: 10px;
margin-top: 3px;
color: var(--color-text-muted);
}
.project-sidebar__footer {
margin-top: auto;
}

View File

@ -0,0 +1,122 @@
import { render, screen, act } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
let mockPathname = "/projects/proj-1";
let mockParams: Record<string, string> = { projectId: "proj-1" };
vi.mock("next/navigation", () => ({
useParams: () => mockParams,
usePathname: () => mockPathname,
}));
vi.mock("next-themes", () => ({
useTheme: () => ({ resolvedTheme: "light", setTheme: vi.fn() }),
}));
vi.mock("@/providers/MuxProvider", () => ({
useMuxOptional: () => ({ status: "connecting", sessions: [], lastError: null }),
}));
vi.mock("@/hooks/useSessionEvents", () => ({
useSessionEvents: ({ initialSessions }: { initialSessions: unknown[] }) => ({
sessions: initialSessions,
liveSessionsResolved: true,
attentionLevels: {},
loadError: null,
}),
}));
vi.mock("@/components/ProjectSidebar", () => ({
ProjectSidebar: (props: { activeProjectId?: string; orchestrators?: unknown[] }) => (
<div data-testid="sidebar" data-project={props.activeProjectId} data-orchestrators={JSON.stringify(props.orchestrators ?? [])} />
),
}));
import { ProjectLayoutClient } from "../project-layout-client";
const projects = [{ id: "proj-1", name: "Project One", sessionPrefix: "proj-1" }];
const orchestrators = [{ id: "proj-1-orchestrator", projectId: "proj-1" }];
beforeEach(() => {
mockPathname = "/projects/proj-1";
mockParams = { projectId: "proj-1" };
});
describe("ProjectLayoutClient", () => {
it("renders children and sidebar", () => {
render(
<ProjectLayoutClient
initialSessions={[]}
initialProjects={projects}
initialOrchestrators={[]}
>
<div data-testid="page-content">Page</div>
</ProjectLayoutClient>,
);
expect(screen.getByTestId("sidebar")).toBeInTheDocument();
expect(screen.getByTestId("page-content")).toBeInTheDocument();
});
it("passes activeProjectId from route params to sidebar", () => {
mockParams = { projectId: "proj-1" };
render(
<ProjectLayoutClient
initialSessions={[]}
initialProjects={projects}
initialOrchestrators={[]}
>
<div />
</ProjectLayoutClient>,
);
expect(screen.getByTestId("sidebar").dataset.project).toBe("proj-1");
});
it("passes initialOrchestrators directly to sidebar", () => {
render(
<ProjectLayoutClient
initialSessions={[]}
initialProjects={projects}
initialOrchestrators={orchestrators}
>
<div />
</ProjectLayoutClient>,
);
const sidebar = screen.getByTestId("sidebar");
expect(JSON.parse(sidebar.dataset.orchestrators ?? "[]")).toEqual(orchestrators);
});
it("resets mobile sidebar when pathname changes", async () => {
const { rerender } = render(
<ProjectLayoutClient
initialSessions={[]}
initialProjects={projects}
initialOrchestrators={[]}
>
<div />
</ProjectLayoutClient>,
);
// Simulate pathname change
mockPathname = "/projects/proj-1/sessions/sess-1";
await act(async () => {
rerender(
<ProjectLayoutClient
initialSessions={[]}
initialProjects={projects}
initialOrchestrators={[]}
>
<div />
</ProjectLayoutClient>,
);
});
// Sidebar wrapper should not have the mobile-open class
const wrapper = document.querySelector(".sidebar-wrapper");
expect(wrapper?.classList.contains("sidebar-wrapper--mobile-open")).toBe(false);
});
});

View File

@ -0,0 +1,5 @@
import type { ReactNode } from "react";
export default function ProjectIdLayout({ children }: { children: ReactNode }) {
return <>{children}</>;
}

View File

@ -8,7 +8,8 @@ describe("ProjectRouteLoading", () => {
expect(screen.getByText("Agent Orchestrator")).toBeInTheDocument();
expect(screen.getByText("Loading project…")).toBeInTheDocument();
expect(screen.getByText("Projects")).toBeInTheDocument();
expect(screen.getByLabelText("Loading project dashboard")).toBeInTheDocument();
// Sidebar is owned by ProjectLayoutClient — no duplicate skeleton sidebar here
expect(screen.queryByText("Projects")).not.toBeInTheDocument();
expect(screen.getByText("Working")).toBeInTheDocument();
});
});

View File

@ -1,101 +1,40 @@
function ProjectLoadingSidebar() {
return (
<aside className="project-sidebar flex h-full flex-col" aria-hidden="true">
<div className="project-sidebar__compact-hdr">
<span className="project-sidebar__sect-label">Projects</span>
</div>
<div className="project-sidebar__tree flex-1 overflow-y-auto overflow-x-hidden">
<div className="py-2">
{["w-28", "w-24", "w-32", "w-20"].map((nameWidth, index) => (
<div
key={`project-loading-row-${index}`}
className="flex items-center gap-3 border-b border-[var(--color-border-subtle)] px-4 py-3"
>
<div className="h-3 w-3 animate-pulse bg-[color-mix(in_srgb,var(--color-text-primary)_8%,transparent)]" />
<div
className={`h-4 animate-pulse bg-[color-mix(in_srgb,var(--color-text-primary)_10%,transparent)] ${nameWidth}`}
/>
<div className="ml-auto h-6 w-6 animate-pulse border border-[var(--color-border-default)] bg-[var(--color-bg-base)]" />
</div>
))}
</div>
</div>
<div className="project-sidebar__footer">
<div className="flex items-center gap-2 border-t border-[var(--color-border-subtle)] px-2 py-2">
<div className="h-7 w-7 animate-pulse border border-[var(--color-border-default)] bg-[var(--color-bg-surface)]" />
<div className="h-7 w-7 animate-pulse border border-[var(--color-border-default)] bg-[var(--color-bg-surface)]" />
<div className="h-7 w-7 animate-pulse border border-[var(--color-border-default)] bg-[var(--color-bg-surface)]" />
<div className="ml-auto h-7 w-7 animate-pulse border border-[var(--color-border-default)] bg-[var(--color-bg-surface)]" />
</div>
</div>
</aside>
);
}
export default function ProjectRouteLoading() {
return (
<div className="min-h-screen bg-[var(--color-bg-canvas)]">
<div className="dashboard-app-shell">
<header className="dashboard-app-header" aria-hidden="true">
<button type="button" className="dashboard-app-sidebar-toggle" aria-label="Toggle sidebar">
<svg
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
viewBox="0 0 24 24"
aria-hidden="true"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" />
</svg>
</button>
<div className="dashboard-app-header__brand">
<span className="dashboard-app-header__brand-dot" aria-hidden="true" />
<span>Agent Orchestrator</span>
</div>
<span className="dashboard-app-header__sep" aria-hidden="true" />
<span className="dashboard-app-header__project">Loading project</span>
<div className="dashboard-app-header__spacer" />
<div className="dashboard-app-header__actions">
<div className="h-9 w-36 animate-pulse border border-[var(--color-border-default)] bg-[var(--color-bg-surface)]" />
</div>
</header>
<div className="sidebar-wrapper">
<ProjectLoadingSidebar />
<div className="dashboard-main--desktop">
<header className="dashboard-app-header" aria-hidden="true">
<button type="button" className="dashboard-app-sidebar-toggle" aria-label="Toggle sidebar">
<svg
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
viewBox="0 0 24 24"
aria-hidden="true"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" />
</svg>
</button>
<div className="dashboard-app-header__brand dashboard-app-header__brand--hide-mobile">
<span>Agent Orchestrator</span>
</div>
<span className="dashboard-app-header__sep topbar-desktop-only" aria-hidden="true" />
<span className="dashboard-app-header__project">Loading project</span>
<div className="dashboard-app-header__spacer" />
</header>
<main className="dashboard-main dashboard-main--desktop overflow-y-auto">
<div className="dashboard-main__subhead">
<div className="h-8 w-40 animate-pulse bg-[color-mix(in_srgb,var(--color-bg-elevated)_88%,transparent)]" />
<div className="mt-3 h-4 w-72 max-w-full animate-pulse bg-[color-mix(in_srgb,var(--color-bg-elevated)_82%,transparent)]" />
</div>
<div className="board-wrapper" aria-hidden="true">
<div className="kanban-ghost">
{["Working", "Pending", "Review", "Respond", "Merge"].map((label) => (
<div key={label} className="kanban-ghost__col">
<div className="kanban-ghost__head">{label}</div>
</div>
))}
</div>
<div className="board-center">
<div
className="empty-state"
role="status"
aria-label="Loading project dashboard"
>
<div className="empty-state__icon" />
<div className="h-5 w-40 animate-pulse bg-[color-mix(in_srgb,var(--color-bg-elevated)_88%,transparent)]" />
<div className="h-4 w-56 animate-pulse bg-[color-mix(in_srgb,var(--color-bg-elevated)_82%,transparent)]" />
<main className="dashboard-main flex-1 min-h-0 overflow-hidden">
<div className="board-wrapper" aria-hidden="true">
<div className="kanban-ghost">
{["Working", "Pending", "Review", "Respond", "Merge"].map((label) => (
<div key={label} className="kanban-ghost__col">
<div className="kanban-ghost__head">{label}</div>
</div>
</div>
))}
</div>
</main>
</div>
</div>
</main>
</div>
);
}

View File

@ -29,7 +29,7 @@ export default async function ProjectPage(props: {
const pageData = await getDashboardPageData(projectId);
return (
<div className="min-h-screen bg-[var(--color-bg-canvas)]">
<div className="flex-1 min-h-screen bg-[var(--color-bg-canvas)]">
<Dashboard
initialSessions={pageData.sessions}
projectId={pageData.selectedProjectId}

View File

@ -0,0 +1,91 @@
"use client";
import { useState, useCallback, useEffect, type ReactNode } from "react";
import { useParams, usePathname } from "next/navigation";
import { useSessionEvents } from "@/hooks/useSessionEvents";
import { useMuxOptional } from "@/providers/MuxProvider";
import { ProjectSidebar, type ProjectSidebarOrchestrator } from "@/components/ProjectSidebar";
import { SidebarContext } from "@/components/workspace/SidebarContext";
import { useMediaQuery, MOBILE_BREAKPOINT } from "@/hooks/useMediaQuery";
import type { DashboardSession } from "@/lib/types";
import type { ProjectInfo } from "@/lib/project-name";
function extractActiveSessionId(pathname: string | null): string | undefined {
if (!pathname) return undefined;
const match = pathname.match(/\/sessions\/([^/]+)/);
return match?.[1] ?? undefined;
}
interface ProjectLayoutClientProps {
children: ReactNode;
initialSessions: DashboardSession[];
initialProjects: ProjectInfo[];
initialOrchestrators: ProjectSidebarOrchestrator[];
}
export function ProjectLayoutClient({
children,
initialSessions,
initialProjects,
initialOrchestrators,
}: ProjectLayoutClientProps) {
const params = useParams();
const pathname = usePathname();
const projectId = typeof params.projectId === "string" ? params.projectId : undefined;
const activeSessionId = extractActiveSessionId(pathname);
const isMobile = useMediaQuery(MOBILE_BREAKPOINT);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
// Close mobile overlay whenever the route changes within the layout.
useEffect(() => {
setMobileSidebarOpen(false);
}, [pathname]);
const mux = useMuxOptional();
const { sessions, liveSessionsResolved } = useSessionEvents({
initialSessions,
muxSessions: mux?.status === "connected" ? mux.sessions : undefined,
muxLastError: mux?.lastError,
attentionZones: "simple",
});
const handleToggleSidebar = useCallback(() => {
if (isMobile) {
setMobileSidebarOpen((v) => !v);
} else {
setSidebarCollapsed((v) => !v);
}
}, [isMobile]);
return (
<SidebarContext.Provider value={{ onToggleSidebar: handleToggleSidebar, mobileSidebarOpen }}>
<div className="dashboard-app-shell">
<div
className={`dashboard-shell--desktop${sidebarCollapsed ? " dashboard-shell--sidebar-collapsed" : ""}`}
>
<div
className={`sidebar-wrapper${mobileSidebarOpen ? " sidebar-wrapper--mobile-open" : ""}`}
>
<ProjectSidebar
projects={initialProjects}
sessions={sessions}
orchestrators={initialOrchestrators}
activeProjectId={projectId}
activeSessionId={activeSessionId}
loading={!liveSessionsResolved}
collapsed={sidebarCollapsed}
onToggleCollapsed={() => setSidebarCollapsed((v) => !v)}
onMobileClose={() => setMobileSidebarOpen(false)}
/>
</div>
{mobileSidebarOpen && (
<div className="sidebar-mobile-backdrop" onClick={() => setMobileSidebarOpen(false)} />
)}
{children}
</div>
</div>
</SidebarContext.Provider>
);
}

View File

@ -1 +1,459 @@
export { default } from "../../../../sessions/[id]/page";
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { isOrchestratorSession } from "@aoagents/ao-core/types";
import { SessionDetail } from "@/components/SessionDetail";
import { ErrorDisplay } from "@/components/ErrorDisplay";
import {
type DashboardSession,
type ActivityState,
getAttentionLevel,
} from "@/lib/types";
import { activityIcon } from "@/lib/activity-icons";
import type { ProjectInfo } from "@/lib/project-name";
import { getSessionTitle } from "@/lib/format";
import { useMuxSessionActivity } from "@/hooks/useMuxSessionActivity";
import { projectSessionPath } from "@/lib/routes";
import { fetchJsonWithTimeout } from "@/lib/client-fetch";
function truncate(s: string, max: number): string {
const codePoints = Array.from(s);
return codePoints.length > max ? codePoints.slice(0, max).join("") + "..." : s;
}
function buildSessionTitle(
session: DashboardSession,
prefixByProject: Map<string, string>,
activityOverride?: ActivityState | null,
): string {
const id = session.id;
const activity = activityOverride !== undefined ? activityOverride : session.activity;
const emoji = activity ? (activityIcon[activity] ?? "") : "";
const allPrefixes = [...prefixByProject.values()];
const isOrchestrator = isOrchestratorSession(
session,
prefixByProject.get(session.projectId),
allPrefixes,
);
const detail = isOrchestrator ? "Orchestrator Terminal" : truncate(getSessionTitle(session), 40);
return emoji ? `${emoji} ${id} | ${detail}` : `${id} | ${detail}`;
}
interface ZoneCounts {
merge: number;
respond: number;
review: number;
pending: number;
working: number;
done: number;
}
interface ProjectSessionsBody {
sessions?: DashboardSession[];
orchestratorId?: string | null;
orchestrators?: Array<{ id: string; projectId: string; projectName: string }>;
}
let cachedProjects: ProjectInfo[] | null = null;
const SESSION_PAGE_REFRESH_INTERVAL_MS = 2000;
const SESSION_FETCH_TIMEOUT_MS = 8000;
const PROJECT_SESSIONS_FETCH_TIMEOUT_MS = 5000;
const PROJECTS_FETCH_TIMEOUT_MS = 5000;
function areProjectsEqual(previous: ProjectInfo[] | null, next: ProjectInfo[]): boolean {
if (!previous || previous.length !== next.length) return false;
return previous.every((p, i) => JSON.stringify(p) === JSON.stringify(next[i]));
}
function isAbortLikeError(error: unknown): boolean {
if (error instanceof DOMException) return error.name === "AbortError";
if (error instanceof Error) {
const msg = error.message.toLowerCase();
return msg.includes("aborted") || msg.includes("aborterror");
}
return false;
}
function getSessionLoadErrorMessage(error: Error): string {
const normalized = error.message.toLowerCase();
if (normalized.includes("timed out"))
return "The session request is taking too long. You can retry, or return to the project and reopen a different session.";
if (normalized.includes("network"))
return "The session request failed before the dashboard got a response. Check the local server connection and try again.";
if (normalized.includes("404"))
return "This session is no longer available. It may have been removed while the page was open.";
if (normalized.includes("500"))
return "The server returned an internal error while loading this session. Try again to re-fetch the latest state.";
return "The dashboard could not load this session cleanly. Try again to re-fetch the latest state.";
}
function LoadingContent() {
return (
<div className="flex h-full min-h-screen items-center justify-center bg-[var(--color-bg-base)]">
<div className="flex flex-col items-center gap-3">
<svg
className="h-5 w-5 animate-spin text-[var(--color-text-tertiary)]"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M12 3a9 9 0 1 0 9 9" />
</svg>
<div className="text-[13px] text-[var(--color-text-tertiary)]">Loading session</div>
</div>
</div>
);
}
export default function ProjectSessionPage() {
const params = useParams();
const pathname = usePathname();
const router = useRouter();
const id = params.id as string;
const expectedProjectId = typeof params.projectId === "string" ? params.projectId : undefined;
// Read optimistic session data written by sidebar navigation
const cachedSession = (() => {
if (typeof sessionStorage === "undefined") return null;
try {
const raw = sessionStorage.getItem(`ao-session-nav:${id}`);
if (raw) {
sessionStorage.removeItem(`ao-session-nav:${id}`);
return JSON.parse(raw) as DashboardSession;
}
} catch {
/* ignore */
}
return null;
})();
const [session, setSession] = useState<DashboardSession | null>(cachedSession);
const [zoneCounts, setZoneCounts] = useState<ZoneCounts | null>(null);
const [projectOrchestratorId, setProjectOrchestratorId] = useState<string | null | undefined>(
undefined,
);
const [projects, setProjects] = useState<ProjectInfo[]>([]);
const [loading, setLoading] = useState(cachedSession === null);
const [routeError, setRouteError] = useState<Error | null>(null);
const [sessionMissing, setSessionMissing] = useState(false);
const [prefixByProject, setPrefixByProject] = useState<Map<string, string>>(new Map());
const sessionProjectId = session?.projectId ?? null;
const allPrefixes = [...prefixByProject.values()];
const sessionIsOrchestrator = session
? isOrchestratorSession(session, prefixByProject.get(session.projectId), allPrefixes)
: false;
const sessionProjectIdRef = useRef<string | null>(null);
const sessionIsOrchestratorRef = useRef(false);
const resolvedProjectSessionsKeyRef = useRef<string | null>(null);
const prefixByProjectRef = useRef<Map<string, string>>(new Map());
const hasLoadedSessionRef = useRef(cachedSession !== null);
const fetchingSessionRef = useRef(false);
const fetchingProjectSessionsRef = useRef(false);
const sessionFetchControllerRef = useRef<AbortController | null>(null);
const projectSessionsFetchControllerRef = useRef<AbortController | null>(null);
const pageUnloadingRef = useRef(false);
const mountedRef = useRef(true);
const sseActivity = useMuxSessionActivity(id);
useEffect(() => {
prefixByProjectRef.current = prefixByProject;
}, [prefixByProject]);
useEffect(() => {
if (session) {
document.title = buildSessionTitle(session, prefixByProject, sseActivity?.activity);
} else {
document.title = `${id} | Session Detail`;
}
}, [session, id, prefixByProject, sseActivity]);
useEffect(() => {
sessionProjectIdRef.current = sessionProjectId;
}, [sessionProjectId]);
useEffect(() => {
sessionIsOrchestratorRef.current = sessionIsOrchestrator;
}, [sessionIsOrchestrator]);
useEffect(() => {
if (!session || !projects.some((p) => p.id === session.projectId)) return;
if (
pathname?.startsWith("/projects/") &&
expectedProjectId &&
session.projectId !== expectedProjectId
) {
router.replace(projectSessionPath(session.projectId, session.id));
}
}, [expectedProjectId, pathname, projects, router, session]);
useEffect(() => {
if (!sessionIsOrchestrator) setZoneCounts(null);
}, [sessionIsOrchestrator]);
const fetchProjects = useCallback(async () => {
if (cachedProjects) {
setProjects(cachedProjects);
setPrefixByProject(new Map(cachedProjects.map((p) => [p.id, p.sessionPrefix ?? p.id])));
}
try {
const data = await fetchJsonWithTimeout<{ projects?: ProjectInfo[] } | null>(
"/api/projects",
{
timeoutMs: PROJECTS_FETCH_TIMEOUT_MS,
timeoutMessage: `Projects request timed out after ${PROJECTS_FETCH_TIMEOUT_MS}ms`,
},
);
if (!data?.projects) return;
if (!areProjectsEqual(cachedProjects, data.projects)) {
cachedProjects = data.projects;
setProjects(data.projects);
setPrefixByProject(new Map(data.projects.map((p) => [p.id, p.sessionPrefix ?? p.id])));
}
} catch (err) {
console.error("Failed to fetch projects:", err);
}
}, []);
const fetchSession = useCallback(async () => {
if (fetchingSessionRef.current) return;
fetchingSessionRef.current = true;
const controller = new AbortController();
sessionFetchControllerRef.current = controller;
try {
const data = await fetchJsonWithTimeout<DashboardSession | { error: string }>(
`/api/sessions/${encodeURIComponent(id)}`,
{
signal: controller.signal,
timeoutMs: SESSION_FETCH_TIMEOUT_MS,
timeoutMessage: `Session request timed out after ${SESSION_FETCH_TIMEOUT_MS}ms`,
},
);
setSession(data as DashboardSession);
setRouteError(null);
setSessionMissing(false);
hasLoadedSessionRef.current = true;
} catch (err) {
if (pageUnloadingRef.current || controller.signal.aborted || isAbortLikeError(err)) return;
const message = err instanceof Error ? err.message : "Failed to load session";
const normalized = message.toLowerCase();
if (normalized.includes("session not found") || normalized.includes("http 404")) {
if (!hasLoadedSessionRef.current) setSessionMissing(true);
setLoading(false);
return;
}
console.error("Failed to fetch session:", err);
if (!hasLoadedSessionRef.current) {
setRouteError(err instanceof Error ? err : new Error("Failed to load session"));
}
} finally {
fetchingSessionRef.current = false;
if (sessionFetchControllerRef.current === controller)
sessionFetchControllerRef.current = null;
if (!controller.signal.aborted || hasLoadedSessionRef.current) {
setLoading(false);
} else if (mountedRef.current) {
// Aborted before any session was loaded and the component is still
// mounted — React Strict Mode fired the cleanup between mount 1 and
// mount 2, aborting the first fetch. Mount 2's fetchSession() was
// blocked by fetchingSessionRef (not yet reset). Retry immediately
// now that the ref is clear. mountedRef guards against the navigation-
// away case where the component is genuinely unmounted and we should
// not start a new request.
void fetchSession();
}
}
}, [id]);
const fetchProjectSessions = useCallback(async () => {
if (fetchingProjectSessionsRef.current) return;
const projectId = sessionProjectIdRef.current;
if (!projectId) return;
const isOrchestrator = sessionIsOrchestratorRef.current;
const projectSessionsKey = `${projectId}:${isOrchestrator ? "orchestrator" : "worker"}`;
if (!isOrchestrator && resolvedProjectSessionsKeyRef.current === projectSessionsKey) return;
fetchingProjectSessionsRef.current = true;
const controller = new AbortController();
projectSessionsFetchControllerRef.current = controller;
try {
const query = isOrchestrator
? `/api/sessions?project=${encodeURIComponent(projectId)}&fresh=true`
: `/api/sessions?project=${encodeURIComponent(projectId)}&orchestratorOnly=true&fresh=true`;
const body = await fetchJsonWithTimeout<ProjectSessionsBody>(query, {
signal: controller.signal,
timeoutMs: PROJECT_SESSIONS_FETCH_TIMEOUT_MS,
timeoutMessage: `Project sessions request timed out after ${PROJECT_SESSIONS_FETCH_TIMEOUT_MS}ms`,
});
const sessions = body.sessions ?? [];
const orchestratorId =
body.orchestratorId ??
body.orchestrators?.find((o) => o.projectId === projectId)?.id ??
null;
setProjectOrchestratorId((current) =>
current === orchestratorId ? current : orchestratorId,
);
if (!isOrchestrator) {
resolvedProjectSessionsKeyRef.current = projectSessionsKey;
return;
}
const counts: ZoneCounts = {
merge: 0,
respond: 0,
review: 0,
pending: 0,
working: 0,
done: 0,
};
const allPfxs = [...prefixByProjectRef.current.values()];
for (const s of sessions) {
if (!isOrchestratorSession(s, prefixByProjectRef.current.get(s.projectId), allPfxs)) {
const level = getAttentionLevel(s);
if (level === "action") continue;
counts[level]++;
}
}
setZoneCounts(counts);
} catch (err) {
if (pageUnloadingRef.current || controller.signal.aborted || isAbortLikeError(err)) return;
console.error("Failed to fetch project sessions:", err);
} finally {
fetchingProjectSessionsRef.current = false;
if (projectSessionsFetchControllerRef.current === controller)
projectSessionsFetchControllerRef.current = null;
}
}, []);
useEffect(() => {
void Promise.all([fetchProjects(), fetchSession()]);
}, [fetchProjects, fetchSession]);
useEffect(() => {
if (!sessionProjectId) return;
void fetchProjectSessions();
}, [fetchProjectSessions, sessionIsOrchestrator, sessionProjectId]);
useEffect(() => {
const interval = setInterval(() => {
void fetchSession();
void fetchProjectSessions();
}, SESSION_PAGE_REFRESH_INTERVAL_MS);
return () => clearInterval(interval);
}, [fetchSession, fetchProjectSessions]);
useEffect(() => {
pageUnloadingRef.current = false;
const mark = () => {
pageUnloadingRef.current = true;
};
window.addEventListener("pagehide", mark);
window.addEventListener("beforeunload", mark);
return () => {
window.removeEventListener("pagehide", mark);
window.removeEventListener("beforeunload", mark);
};
}, []);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
useEffect(() => {
return () => {
sessionFetchControllerRef.current?.abort();
projectSessionsFetchControllerRef.current?.abort();
};
}, []);
if (loading) return <div className="dashboard-main--desktop"><LoadingContent /></div>;
if (sessionMissing) {
return (
<div className="dashboard-main--desktop">
<div className="flex h-full items-center justify-center">
<ErrorDisplay
title="Session not found"
message="This session is no longer available. It may have been removed, renamed, or already cleaned up."
tone="not-found"
primaryAction={{
label: "Back to dashboard",
href: expectedProjectId ? `/projects/${expectedProjectId}` : "/",
}}
secondaryAction={{ label: "Retry", onClick: () => void fetchSession() }}
compact
chrome="card"
/>
</div>
</div>
);
}
if (routeError) {
return (
<div className="dashboard-main--desktop">
<div className="flex h-full items-center justify-center">
<ErrorDisplay
title="Failed to load session"
message={getSessionLoadErrorMessage(routeError)}
tone="error"
primaryAction={{
label: "Try again",
onClick: () => {
setRouteError(null);
setSessionMissing(false);
setLoading(true);
void Promise.all([fetchProjects(), fetchSession()]);
},
}}
secondaryAction={{
label: "Back to dashboard",
href: session?.projectId ? `/projects/${session.projectId}` : "/",
}}
error={routeError}
compact
chrome="card"
/>
</div>
</div>
);
}
if (!session) {
return (
<div className="dashboard-main--desktop">
<div className="flex h-full items-center justify-center">
<ErrorDisplay
title="Session unavailable"
message="The backend has not returned this session yet. This can happen right after spawning an orchestrator; retry once the terminal registers the session."
tone="error"
primaryAction={{ label: "Retry", onClick: () => void fetchSession() }}
secondaryAction={{
label: "Back to dashboard",
href: expectedProjectId ? `/projects/${expectedProjectId}` : "/",
}}
compact
chrome="card"
/>
</div>
</div>
);
}
return (
<SessionDetail
session={session}
isOrchestrator={sessionIsOrchestrator}
orchestratorZones={zoneCounts ?? undefined}
projectOrchestratorId={projectOrchestratorId}
projects={projects}
/>
);
}

View File

@ -0,0 +1,19 @@
import { getDashboardPageData } from "@/lib/dashboard-page-data";
import { ProjectLayoutClient } from "./[projectId]/project-layout-client";
import type { ReactNode } from "react";
export const dynamic = "force-dynamic";
export default async function ProjectLayout({ children }: { children: ReactNode }) {
const pageData = await getDashboardPageData("all");
return (
<ProjectLayoutClient
initialSessions={pageData.sessions}
initialProjects={pageData.projects}
initialOrchestrators={pageData.orchestrators}
>
{children}
</ProjectLayoutClient>
);
}

View File

@ -454,77 +454,6 @@ describe("SessionPage project polling", () => {
expect(screen.getAllByText("Failed to load session").length).toBeGreaterThan(0);
});
it("marks sidebar data as loading until the sessions list resolves", async () => {
const workerSession = makeWorkerSession();
let resolveSidebarSessions: ((value: Response) => void) | null = null;
global.fetch = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/projects") {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
projects: [{ id: "my-app", name: "My App", sessionPrefix: "my-app" }],
}),
} as Response);
}
if (url === "/api/sessions/worker-1") {
return Promise.resolve({
ok: true,
status: 200,
json: async () => workerSession,
} as Response);
}
if (url === "/api/sessions?fresh=true") {
return new Promise<Response>((resolve) => {
resolveSidebarSessions = resolve;
});
}
if (url === "/api/sessions?project=my-app&orchestratorOnly=true&fresh=true") {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ orchestratorId: "my-app-orchestrator" }),
} as Response);
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
}) as typeof fetch;
const { default: SessionPage } = await import("./page");
render(<SessionPage />);
await flushAsyncWork();
const latestBeforeSidebarResolve = sessionDetailSpy.mock.lastCall?.[0] as {
sidebarLoading?: boolean;
sidebarSessions?: DashboardSession[] | null;
};
expect(latestBeforeSidebarResolve.sidebarLoading).toBe(true);
expect(latestBeforeSidebarResolve.sidebarSessions).toBeNull();
await act(async () => {
resolveSidebarSessions?.({
ok: true,
status: 200,
json: async () => ({ sessions: [workerSession] }),
} as Response);
await Promise.resolve();
});
const latestAfterSidebarResolve = sessionDetailSpy.mock.lastCall?.[0] as {
sidebarLoading?: boolean;
sidebarSessions?: DashboardSession[] | null;
};
expect(latestAfterSidebarResolve.sidebarLoading).toBe(false);
expect(latestAfterSidebarResolve.sidebarSessions).toEqual([workerSession]);
});
it("revalidates projects and sidebar sessions on remount even when cache exists", async () => {
const workerSession = makeWorkerSession();
@ -642,145 +571,7 @@ describe("SessionPage project polling", () => {
);
});
it("surfaces sidebar fetch failures instead of leaving the loading skeleton active", async () => {
const workerSession = makeWorkerSession();
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/projects") {
return {
ok: true,
status: 200,
json: async () => ({
projects: [{ id: "my-app", name: "My App", sessionPrefix: "my-app" }],
}),
} as Response;
}
if (url === "/api/sessions/worker-1") {
return {
ok: true,
status: 200,
json: async () => workerSession,
} as Response;
}
if (url === "/api/sessions?fresh=true") {
return {
ok: false,
status: 500,
json: async () => ({}),
} as Response;
}
if (url === "/api/sessions?project=my-app&orchestratorOnly=true&fresh=true") {
return {
ok: true,
status: 200,
json: async () => ({ orchestratorId: "my-app-orchestrator" }),
} as Response;
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof fetch;
const { default: SessionPage } = await import("./page");
render(<SessionPage />);
await flushAsyncWork();
const latestProps = sessionDetailSpy.mock.lastCall?.[0] as {
sidebarError?: boolean;
sidebarLoading?: boolean;
sidebarSessions?: DashboardSession[] | null;
};
expect(latestProps.sidebarLoading).toBe(false);
expect(latestProps.sidebarError).toBe(true);
expect(latestProps.sidebarSessions).toEqual([]);
});
it("applies mux snapshots that arrive before the initial sidebar fetch resolves", async () => {
const workerSession = makeWorkerSession();
const muxPatchedLastActivityAt = "2026-04-14T12:00:00.000Z";
let resolveSidebarSessions: ((value: Response) => void) | null = null;
mockMuxState.current = {
status: "connected",
sessions: [
{
id: "worker-1",
status: "working",
activity: "ready",
attentionLevel: "pending",
lastActivityAt: muxPatchedLastActivityAt,
},
],
};
global.fetch = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/projects") {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
projects: [{ id: "my-app", name: "My App", sessionPrefix: "my-app" }],
}),
} as Response);
}
if (url === "/api/sessions/worker-1") {
return Promise.resolve({
ok: true,
status: 200,
json: async () => workerSession,
} as Response);
}
if (url === "/api/sessions?fresh=true") {
return new Promise<Response>((resolve) => {
resolveSidebarSessions = resolve;
});
}
if (url === "/api/sessions?project=my-app&orchestratorOnly=true&fresh=true") {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ orchestratorId: "my-app-orchestrator" }),
} as Response);
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
}) as typeof fetch;
const { default: SessionPage } = await import("./page");
render(<SessionPage />);
await flushAsyncWork();
await act(async () => {
resolveSidebarSessions?.({
ok: true,
status: 200,
json: async () => ({ sessions: [workerSession] }),
} as Response);
await Promise.resolve();
});
const latestProps = sessionDetailSpy.mock.lastCall?.[0] as {
sidebarSessions?: DashboardSession[] | null;
};
expect(latestProps.sidebarSessions).toEqual([
{
...workerSession,
activity: "ready",
lastActivityAt: muxPatchedLastActivityAt,
},
]);
});
it("redirects the legacy session URL to the project-scoped route for clean projects", async () => {
mockPathname = "/sessions/worker-1";

View File

@ -5,10 +5,7 @@ import { useParams, usePathname, useRouter } from "next/navigation";
import { ACTIVITY_STATE, SESSION_STATUS, isOrchestratorSession } from "@aoagents/ao-core/types";
import { SessionDetail } from "@/components/SessionDetail";
import { ErrorDisplay } from "@/components/ErrorDisplay";
import {
ProjectSidebar,
type ProjectSidebarOrchestrator,
} from "@/components/ProjectSidebar";
import { ProjectSidebar, type ProjectSidebarOrchestrator } from "@/components/ProjectSidebar";
import { useMediaQuery, MOBILE_BREAKPOINT } from "@/hooks/useMediaQuery";
import {
type DashboardSession,
@ -938,11 +935,6 @@ export default function SessionPage() {
orchestratorZones={zoneCounts ?? undefined}
projectOrchestratorId={projectOrchestratorId}
projects={projects}
sidebarSessions={sidebarSessions}
sidebarOrchestrators={sidebarOrchestrators}
sidebarLoading={sidebarSessions === null}
sidebarError={sidebarError}
onRetrySidebar={fetchSidebarSessions}
/>
);
}

View File

@ -108,9 +108,10 @@ export function CopyDebugBundleButton({ projectId }: CopyDebugBundleButtonProps)
return (
<button
type="button"
className="orchestrator-btn flex min-h-[44px] min-w-[44px] items-center gap-2 px-4 py-2 text-[12px] font-semibold text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-bg-hover)] disabled:opacity-50"
className="dashboard-app-btn dashboard-app-btn--icon"
onClick={() => void handleClick()}
disabled={busy}
title="Copy debug bundle"
aria-label="Copy debug bundle for issue reports"
>
<svg
@ -125,7 +126,6 @@ export function CopyDebugBundleButton({ projectId }: CopyDebugBundleButtonProps)
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
Copy debug info
</button>
);
}

View File

@ -18,14 +18,15 @@ import { AttentionZone } from "./AttentionZone";
import { DynamicFavicon, countNeedingAttention } from "./DynamicFavicon";
import { useSessionEvents } from "@/hooks/useSessionEvents";
import { useMuxOptional } from "@/providers/MuxProvider";
import { ProjectSidebar } from "./ProjectSidebar";
import type { ProjectInfo } from "@/lib/project-name";
import { EmptyState } from "./Skeleton";
import { ToastProvider, useToast } from "./Toast";
import { ConnectionBar } from "./ConnectionBar";
import { UpdateBanner } from "./UpdateBanner";
import { CopyDebugBundleButton } from "./CopyDebugBundleButton";
import { SidebarContext } from "./workspace/SidebarContext";
import { SidebarContext, useSidebarContext } from "./workspace/SidebarContext";
import { ProjectSidebar } from "./ProjectSidebar";
import { isOrchestratorSession } from "@aoagents/ao-core/types";
import { projectDashboardPath, projectSessionPath } from "@/lib/routes";
import { BottomSheet } from "./BottomSheet";
@ -175,6 +176,25 @@ function DashboardInner({
if (!projectId) return sessions;
return sessions.filter((s) => s.projectId === projectId);
}, [sessions, projectId]);
const allSessionPrefixes = useMemo(
() => projects.map((p) => p.sessionPrefix ?? p.id),
[projects],
);
const sidebarOrchestrators = useMemo(
() =>
sessions
.filter((s) =>
isOrchestratorSession(
s,
projects.find((p) => p.id === s.projectId)?.sessionPrefix ?? s.projectId,
allSessionPrefixes,
),
)
.map((s) => ({ id: s.id, projectId: s.projectId })),
[sessions, projects, allSessionPrefixes],
);
const connectionStatus: "connected" | "reconnecting" | "disconnected" =
mux?.status === "disconnected"
? "disconnected"
@ -195,9 +215,20 @@ function DashboardInner({
useState<DashboardOrchestratorLink[]>(orchestratorLinks);
const [spawningProjectIds, setSpawningProjectIds] = useState<string[]>([]);
const [spawnErrors, setSpawnErrors] = useState<Record<string, string>>({});
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const isMobile = useMediaQuery(MOBILE_BREAKPOINT);
// Detect if a parent layout already owns the sidebar — if so, skip rendering our own.
const parentCtx = useSidebarContext();
const isInsideLayout = parentCtx !== null;
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const handleToggleSidebar = useCallback(() => {
if (isInsideLayout && parentCtx) { parentCtx.onToggleSidebar(); return; }
if (isMobile) {
setMobileSidebarOpen((v) => !v);
} else {
setSidebarCollapsed((v) => !v);
}
}, [isMobile, isInsideLayout, parentCtx]);
const [collapsedZones, setCollapsedZones] = useState<Set<AttentionLevel>>(
() => new Set<AttentionLevel>(["done", "working"]),
);
@ -250,10 +281,6 @@ function DashboardInner({
document.title = needsAttention > 0 ? `${label} (${needsAttention} need attention)` : label;
}, [attentionLevels, projectName]);
useEffect(() => {
setMobileMenuOpen(false);
}, [searchParams]);
const grouped = useMemo(() => {
const zones: Record<AttentionLevel, DashboardSession[]> = {
merge: [],
@ -516,292 +543,336 @@ function DashboardInner({
return next;
});
};
const handleToggleSidebar = () => {
if (typeof window !== "undefined" && window.innerWidth < 768) {
setMobileMenuOpen((current) => !current);
} else {
setSidebarCollapsed((current) => !current);
}
};
return (
<SidebarContext.Provider
value={{ onToggleSidebar: handleToggleSidebar, mobileSidebarOpen: mobileMenuOpen }}
>
<>
<UpdateBanner />
<ConnectionBar status={connectionStatus} />
<div className="dashboard-app-shell">
<header className="dashboard-app-header">
<button
type="button"
className="dashboard-app-sidebar-toggle"
onClick={handleToggleSidebar}
aria-label="Toggle sidebar"
>
{isMobile ? (
const mainPanel = (
<div className="dashboard-main--desktop">
<header className="dashboard-app-header">
<button
type="button"
className="dashboard-app-sidebar-toggle"
onClick={handleToggleSidebar}
aria-label="Toggle sidebar"
>
{isMobile ? (
<svg
width="16"
height="16"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
) : (
<svg
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
viewBox="0 0 24 24"
aria-hidden="true"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" />
</svg>
)}
</button>
<div className="dashboard-app-header__brand dashboard-app-header__brand--hide-mobile">
<span>Agent Orchestrator</span>
</div>
{showHeaderProjectLabel ? (
<>
<span className="dashboard-app-header__sep topbar-desktop-only" aria-hidden="true" />
<div className="topbar-project-pills-group">
<div className="topbar-project-line">
<span className="dashboard-app-header__project">{headerProjectLabel}</span>
</div>
{!allProjectsView && projectSessions.length > 0 ? (
<div className="topbar-session-pills">
{grouped.working.length > 0 ? (
<div className="topbar-status-pill topbar-status-pill--active">
<span className="topbar-status-pill__dot topbar-status-pill__dot--working" />
<span className="topbar-status-pill__label">
{grouped.working.length} working
</span>
</div>
) : null}
{grouped.merge.length +
grouped.action.length +
grouped.respond.length +
grouped.review.length >
0 ? (
<div className="topbar-status-pill topbar-status-pill--waiting-for-input">
<span className="topbar-status-pill__dot topbar-status-pill__dot--attention" />
<span className="topbar-status-pill__label">
{grouped.merge.length +
grouped.action.length +
grouped.respond.length +
grouped.review.length}{" "}
need attention
</span>
</div>
) : null}
</div>
) : null}
</div>
</>
) : null}
<div className="dashboard-app-header__spacer" />
<div className="dashboard-app-header__actions">
{showDebugBundleButton ? <CopyDebugBundleButton projectId={projectId} /> : null}
{!allProjectsView && orchestratorHref ? (
<Link
href={orchestratorHref}
className="dashboard-app-btn dashboard-app-btn--amber"
aria-label="Orchestrator"
>
<svg
width="16"
height="16"
width="12"
height="12"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle cx="12" cy="5" r="2" fill="currentColor" stroke="none" />
<path d="M12 7v4M12 11H6M12 11h6M6 11v3M12 11v3M18 11v3" />
<circle cx="6" cy="17" r="2" />
<circle cx="12" cy="17" r="2" />
<circle cx="18" cy="17" r="2" />
</svg>
Orchestrator
</Link>
) : canSpawnProjectOrchestrator && activeProject ? (
<button
type="button"
className="dashboard-app-btn dashboard-app-btn--amber"
aria-label="Spawn Orchestrator"
onClick={() => void handleSpawnOrchestrator(activeProject)}
disabled={isSpawningCurrentProject}
>
<svg
width="12"
height="12"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle cx="12" cy="5" r="2" fill="currentColor" stroke="none" />
<path d="M12 7v4M12 11H6M12 11h6M6 11v3M12 11v3M18 11v3" />
<circle cx="6" cy="17" r="2" />
<circle cx="12" cy="17" r="2" />
<circle cx="18" cy="17" r="2" />
</svg>
{isSpawningCurrentProject ? "Spawning..." : "Spawn Orchestrator"}
</button>
) : null}
</div>
</header>
<main className="dashboard-main flex-1 min-h-0 overflow-hidden">
<DynamicFavicon attentionLevels={attentionLevels} projectName={projectName} />
<div className="dashboard-main__subhead">
<h1 className="dashboard-main__title">Dashboard</h1>
<p className="dashboard-main__subtitle">
Live agent sessions, pull requests, and merge status.
</p>
</div>
<div className="dashboard-main__body">
{loadErrorBanner}
{anyRateLimited && !rateLimitDismissed && (
<div className="dashboard-alert mb-4 flex items-center gap-2.5 border border-[color-mix(in_srgb,var(--color-status-attention)_25%,transparent)] bg-[var(--color-tint-yellow)] px-3.5 py-2.5 text-[11px] text-[var(--color-status-attention)]">
<svg
className="h-3.5 w-3.5 shrink-0"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M4 6h16M4 12h16M4 18h16" />
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</svg>
) : (
<svg
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
viewBox="0 0 24 24"
aria-hidden="true"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" />
</svg>
)}
</button>
<div className="dashboard-app-header__brand">
<span className="dashboard-app-header__brand-dot" aria-hidden="true" />
<span>Agent Orchestrator</span>
</div>
{showHeaderProjectLabel ? (
<>
<span className="dashboard-app-header__sep" aria-hidden="true" />
<span className="dashboard-app-header__project">{headerProjectLabel}</span>
</>
) : null}
{showDebugBundleButton ? <CopyDebugBundleButton projectId={projectId} /> : null}
<div className="dashboard-app-header__spacer" />
<div className="dashboard-app-header__actions">
{!allProjectsView && orchestratorHref ? (
<Link
href={orchestratorHref}
className="dashboard-app-btn dashboard-app-btn--amber"
aria-label="Orchestrator"
>
<svg
width="12"
height="12"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle cx="12" cy="5" r="2" fill="currentColor" stroke="none" />
<path d="M12 7v4M12 11H6M12 11h6M6 11v3M12 11v3M18 11v3" />
<circle cx="6" cy="17" r="2" />
<circle cx="12" cy="17" r="2" />
<circle cx="18" cy="17" r="2" />
</svg>
Orchestrator
</Link>
) : canSpawnProjectOrchestrator && activeProject ? (
<span className="flex-1">
GitHub API rate limited PR data (CI status, review state, sizes) may be stale.
Will retry automatically on next refresh.
</span>
<button
type="button"
className="dashboard-app-btn dashboard-app-btn--amber"
aria-label="Spawn Orchestrator"
onClick={() => void handleSpawnOrchestrator(activeProject)}
disabled={isSpawningCurrentProject}
onClick={() => setRateLimitDismissed(true)}
className="ml-1 shrink-0 opacity-60 hover:opacity-100"
aria-label="Dismiss"
>
<svg
width="12"
height="12"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle cx="12" cy="5" r="2" fill="currentColor" stroke="none" />
<path d="M12 7v4M12 11H6M12 11h6M6 11v3M12 11v3M18 11v3" />
<circle cx="6" cy="17" r="2" />
<circle cx="12" cy="17" r="2" />
<circle cx="18" cy="17" r="2" />
<path d="M18 6 6 18M6 6l12 12" />
</svg>
{isSpawningCurrentProject ? "Spawning..." : "Spawn Orchestrator"}
</button>
) : null}
</div>
</header>
<div
className={`dashboard-shell dashboard-shell--desktop${sidebarCollapsed ? " dashboard-shell--sidebar-collapsed" : ""}`}
>
<div
className={`sidebar-wrapper${mobileMenuOpen ? " sidebar-wrapper--mobile-open" : ""}`}
>
<ProjectSidebar
projects={projects}
sessions={sessions}
orchestrators={activeOrchestrators}
activeProjectId={projectId}
activeSessionId={activeSessionId}
collapsed={sidebarCollapsed}
onToggleCollapsed={() => setSidebarCollapsed((current) => !current)}
onMobileClose={() => setMobileMenuOpen(false)}
/>
</div>
{mobileMenuOpen && (
<div className="sidebar-mobile-backdrop" onClick={() => setMobileMenuOpen(false)} />
</div>
)}
<main className="dashboard-main dashboard-main--desktop">
<DynamicFavicon attentionLevels={attentionLevels} projectName={projectName} />
<div className="dashboard-main__subhead">
<h1 className="dashboard-main__title">Dashboard</h1>
<p className="dashboard-main__subtitle">
Live agent sessions, pull requests, and merge status.
</p>
{allProjectsView && (
<ProjectOverviewGrid
overviews={projectOverviews}
onSpawnOrchestrator={handleSpawnOrchestrator}
spawningProjectIds={spawningProjectIds}
spawnErrors={spawnErrors}
attentionZones={attentionZones}
/>
)}
{!allProjectsView && hasAnySessions && (
<div className="kanban-board-wrap">
<div
className="kanban-board"
data-columns={kanbanLevels.length}
style={
{
"--kanban-column-count": kanbanLevels.length,
} as React.CSSProperties
}
>
{kanbanLevels.map((level) => (
<AttentionZone
key={level}
level={level}
sessions={grouped[level]}
onSend={handleSend}
onKill={handleKill}
onMerge={handleMerge}
onRestore={handleRestore}
compactMobile={isMobile}
collapsed={isMobile && collapsedZones.has(level)}
onToggle={isMobile ? handleZoneToggle : undefined}
onPreview={isMobile ? handlePreview : undefined}
/>
))}
</div>
</div>
)}
<div className="dashboard-main__body">
{loadErrorBanner}
{anyRateLimited && !rateLimitDismissed && (
<div className="dashboard-alert mb-4 flex items-center gap-2.5 border border-[color-mix(in_srgb,var(--color-status-attention)_25%,transparent)] bg-[var(--color-tint-yellow)] px-3.5 py-2.5 text-[11px] text-[var(--color-status-attention)]">
<svg
className="h-3.5 w-3.5 shrink-0"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</svg>
<span className="flex-1">
GitHub API rate limited PR data (CI status, review state, sizes) may be
stale. Will retry automatically on next refresh.
</span>
<button
onClick={() => setRateLimitDismissed(true)}
className="ml-1 shrink-0 opacity-60 hover:opacity-100"
aria-label="Dismiss"
>
<svg
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
)}
{allProjectsView && (
<ProjectOverviewGrid
overviews={projectOverviews}
onSpawnOrchestrator={handleSpawnOrchestrator}
spawningProjectIds={spawningProjectIds}
spawnErrors={spawnErrors}
attentionZones={attentionZones}
/>
)}
{!allProjectsView && hasAnySessions && (
<div className="kanban-board-wrap">
<div
className="kanban-board"
data-columns={kanbanLevels.length}
style={
{
"--kanban-column-count": kanbanLevels.length,
} as React.CSSProperties
{showEmptyState ? (
<EmptyState
orchestratorHref={orchestratorHref}
onSpawnOrchestrator={
canSpawnProjectOrchestrator && activeProject
? () => {
void handleSpawnOrchestrator(activeProject);
}
>
{kanbanLevels.map((level) => (
<AttentionZone
key={level}
level={level}
sessions={grouped[level]}
onSend={handleSend}
onKill={handleKill}
onMerge={handleMerge}
onRestore={handleRestore}
compactMobile={isMobile}
collapsed={isMobile && collapsedZones.has(level)}
onToggle={isMobile ? handleZoneToggle : undefined}
onPreview={isMobile ? handlePreview : undefined}
/>
))}
</div>
</div>
)}
: null
}
spawnLabel={isSpawningCurrentProject ? "Spawning..." : "Spawn Orchestrator"}
spawnDisabled={isSpawningCurrentProject}
/>
) : null}
{showEmptyState ? (
<EmptyState
orchestratorHref={orchestratorHref}
onSpawnOrchestrator={
canSpawnProjectOrchestrator && activeProject
? () => {
void handleSpawnOrchestrator(activeProject);
}
: null
}
spawnLabel={isSpawningCurrentProject ? "Spawning..." : "Spawn Orchestrator"}
spawnDisabled={isSpawningCurrentProject}
/>
) : null}
{!allProjectsView && currentProjectSpawnError ? (
<p className="mt-3 text-[11px] text-[var(--color-status-error)]">
{currentProjectSpawnError}
</p>
) : null}
{!allProjectsView && currentProjectSpawnError ? (
<p className="mt-3 text-[11px] text-[var(--color-status-error)]">
{currentProjectSpawnError}
</p>
) : null}
{!allProjectsView && grouped.done.length > 0 && (
<div className="done-bar mt-6">
<button
type="button"
className="done-bar__toggle"
onClick={() => setDoneExpanded((v) => !v)}
aria-expanded={doneExpanded}
>
<svg
className={`done-bar__chevron${doneExpanded ? " done-bar__chevron--open" : ""}`}
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="m9 18 6-6-6-6" />
</svg>
<span className="done-bar__label">Done / Terminated</span>
<span className="done-bar__count">{grouped.done.length}</span>
</button>
{doneExpanded && (
<div className="done-bar__cards">
{grouped.done.map((session) => (
<DoneCard key={session.id} session={session} onRestore={handleRestore} />
))}
</div>
)}
{!allProjectsView && grouped.done.length > 0 && (
<div className="done-bar mt-6">
<button
type="button"
className="done-bar__toggle"
onClick={() => setDoneExpanded((v) => !v)}
aria-expanded={doneExpanded}
>
<svg
className={`done-bar__chevron${doneExpanded ? " done-bar__chevron--open" : ""}`}
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="m9 18 6-6-6-6" />
</svg>
<span className="done-bar__label">Done / Terminated</span>
<span className="done-bar__count">{grouped.done.length}</span>
</button>
{doneExpanded && (
<div className="done-bar__cards">
{grouped.done.map((session) => (
<DoneCard key={session.id} session={session} onRestore={handleRestore} />
))}
</div>
)}
</div>
</main>
)}
</div>
</div>
<BottomSheet
session={previewSession}
mode={bottomSheetMode}
onCancel={handleBottomSheetClose}
onConfirm={handleBottomSheetConfirmKill}
onRequestKill={handleRequestKill}
onMerge={handleMerge}
isMergeReady={previewSession ? attentionLevels[previewSession.id] === "merge" : false}
/>
</main>
</div>
);
const bottomSheet = (
<BottomSheet
session={previewSession}
mode={bottomSheetMode}
onCancel={handleBottomSheetClose}
onConfirm={handleBottomSheetConfirmKill}
onRequestKill={handleRequestKill}
onMerge={handleMerge}
isMergeReady={previewSession ? attentionLevels[previewSession.id] === "merge" : false}
/>
);
if (isInsideLayout) {
return (
<>
<UpdateBanner />
<ConnectionBar status={connectionStatus} />
{mainPanel}
{bottomSheet}
</>
);
}
return (
<SidebarContext.Provider value={{ onToggleSidebar: handleToggleSidebar, mobileSidebarOpen }}>
<UpdateBanner />
<ConnectionBar status={connectionStatus} />
<div className="dashboard-app-shell">
<div
className={`dashboard-shell--desktop${sidebarCollapsed ? " dashboard-shell--sidebar-collapsed" : ""}`}
>
<div
className={`sidebar-wrapper${mobileSidebarOpen ? " sidebar-wrapper--mobile-open" : ""}`}
>
<ProjectSidebar
projects={projects}
sessions={sessions}
orchestrators={sidebarOrchestrators}
activeProjectId={projectId}
activeSessionId={activeSessionId}
loading={!liveSessionsResolved}
collapsed={sidebarCollapsed}
onToggleCollapsed={() => setSidebarCollapsed((v) => !v)}
onMobileClose={() => setMobileSidebarOpen(false)}
/>
</div>
{mobileSidebarOpen && (
<div
className="sidebar-mobile-backdrop"
onClick={() => setMobileSidebarOpen(false)}
/>
)}
{mainPanel}
</div>
</div>
{bottomSheet}
</SidebarContext.Provider>
);
}

View File

@ -1,11 +1,11 @@
"use client";
import Link from "next/link";
import { useState, useEffect, useMemo, useRef } from "react";
import { useState, useEffect, useMemo, useRef, useCallback, memo } from "react";
import { useRouter } from "next/navigation";
import { cn } from "@/lib/cn";
import type { ProjectInfo } from "@/lib/project-name";
import { getAttentionLevel, type DashboardSession, type AttentionLevel } from "@/lib/types";
import { getAttentionLevel, type DashboardSession } from "@/lib/types";
import { isOrchestratorSession } from "@aoagents/ao-core/types";
import { getSessionTitle, humanizeBranch } from "@/lib/format";
import { usePopoverClamp } from "@/hooks/usePopoverClamp";
@ -42,7 +42,7 @@ interface ProjectSidebarProps {
type SessionDotLevel = "respond" | "review" | "action" | "pending" | "working" | "merge" | "done";
function SessionDot({ level }: { level: SessionDotLevel }) {
const SessionDot = memo(function SessionDot({ level }: { level: SessionDotLevel }) {
return (
<div
className={cn(
@ -52,7 +52,7 @@ function SessionDot({ level }: { level: SessionDotLevel }) {
data-level={level}
/>
);
}
});
// ProjectSidebar consumes `getAttentionLevel()` without passing a mode,
// so the function defaults to "detailed" and `action` never appears here
@ -70,15 +70,41 @@ function loadShowSessionId(): boolean {
}
}
const LEVEL_LABELS: Record<AttentionLevel, string> = {
working: "working",
pending: "pending",
review: "review",
respond: "respond",
action: "action",
merge: "merge",
done: "done",
};
const SHOW_KILLED_KEY = "ao:sidebar:show-killed";
const SHOW_DONE_KEY = "ao:sidebar:show-done";
const EXPANDED_PROJECTS_KEY = "ao:sidebar:expanded-projects";
function loadShowKilled(): boolean {
if (typeof window === "undefined") return false;
try {
return window.sessionStorage.getItem(SHOW_KILLED_KEY) === "true";
} catch {
return false;
}
}
function loadShowDone(): boolean {
if (typeof window === "undefined") return false;
try {
return window.sessionStorage.getItem(SHOW_DONE_KEY) === "true";
} catch {
return false;
}
}
function loadExpandedProjects(): Set<string> | null {
if (typeof window === "undefined") return null;
try {
const raw = window.sessionStorage.getItem(EXPANDED_PROJECTS_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return new Set<string>(parsed);
return null;
} catch {
return null;
}
}
export function ProjectSidebar(props: ProjectSidebarProps) {
if (props.projects.length === 0) {
@ -87,6 +113,102 @@ export function ProjectSidebar(props: ProjectSidebarProps) {
return <ProjectSidebarInner {...props} />;
}
interface SessionRowProps {
session: DashboardSession;
level: SessionDotLevel;
isActive: boolean;
showSessionId: boolean;
pendingRename: string | undefined;
onNavigate: (href: string, session: DashboardSession) => void;
onStartRename: (session: DashboardSession, title: string) => void;
}
const SessionRow = memo(function SessionRow({
session,
level,
isActive,
showSessionId,
pendingRename,
onNavigate,
onStartRename,
}: SessionRowProps) {
const effectiveDisplayName =
pendingRename !== undefined
? pendingRename
: session.displayNameUserSet
? (session.displayName ?? "")
: "";
const title =
effectiveDisplayName !== ""
? effectiveDisplayName
: (session.branch ?? getSessionTitle(session));
const sessionHref = projectSessionPath(session.projectId, session.id);
return (
<div
className={cn(
"project-sidebar__sess-row group",
isActive && "project-sidebar__sess-row--active",
)}
>
<a
href={sessionHref}
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
onNavigate(sessionHref, session);
}}
className="project-sidebar__sess-link flex flex-1 min-w-0 items-center gap-[7px]"
aria-current={isActive ? "page" : undefined}
aria-label={`Open ${title}`}
>
<SessionDot level={level} />
<div className="flex-1 min-w-0">
<span
className={cn(
"project-sidebar__sess-label",
isActive && "project-sidebar__sess-label--active",
)}
>
{title}
</span>
{showSessionId ? (
<div className="project-sidebar__sess-meta">
<span className="project-sidebar__sess-id">{session.id}</span>
</div>
) : null}
</div>
</a>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onStartRename(session, title);
}}
className="project-sidebar__sess-rename-btn opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100"
title="Rename session"
aria-label={`Rename ${session.id}`}
>
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
</svg>
</button>
</div>
);
});
function ProjectSidebarEmpty({ collapsed = false }: { collapsed?: boolean }) {
const [addProjectOpen, setAddProjectOpen] = useState(false);
@ -162,13 +284,15 @@ function ProjectSidebarInner({
onMobileClose,
}: ProjectSidebarProps) {
const router = useRouter();
const isLoading = loading || sessions === null;
const _isLoading = loading || sessions === null;
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(
() => new Set(activeProjectId && activeProjectId !== "all" ? [activeProjectId] : []),
() =>
loadExpandedProjects() ??
new Set(activeProjectId && activeProjectId !== "all" ? [activeProjectId] : []),
);
const [showKilled, setShowKilled] = useState(false);
const [showDone, setShowDone] = useState(false);
const [showKilled, setShowKilled] = useState<boolean>(loadShowKilled);
const [showDone, setShowDone] = useState<boolean>(loadShowDone);
const [showSessionId, setShowSessionId] = useState<boolean>(loadShowSessionId);
// Inline session-rename state. Only one row is edited at a time. `pendingRenames`
// mirrors the in-flight / just-saved value so the new label appears immediately
@ -198,6 +322,30 @@ function ProjectSidebarInner({
}
}, [showSessionId]);
useEffect(() => {
try {
window.sessionStorage.setItem(SHOW_KILLED_KEY, String(showKilled));
} catch {
// sessionStorage unavailable — accept in-memory state.
}
}, [showKilled]);
useEffect(() => {
try {
window.sessionStorage.setItem(SHOW_DONE_KEY, String(showDone));
} catch {
// sessionStorage unavailable — accept in-memory state.
}
}, [showDone]);
useEffect(() => {
try {
window.sessionStorage.setItem(EXPANDED_PROJECTS_KEY, JSON.stringify([...expandedProjects]));
} catch {
// sessionStorage unavailable — accept in-memory state.
}
}, [expandedProjects]);
// Close the settings popover on outside click or Escape.
useEffect(() => {
if (!settingsOpen) return;
@ -266,20 +414,37 @@ function ProjectSidebarInner({
[visibleProjects],
);
// The API (selectPreferredOrchestratorId) sends at most one entry per
// project, so collapsing into a Map keyed by projectId is lossless. If a
// future API change starts emitting multiples, the last one wins here.
const orchestratorByProject = useMemo(
() => new Map((orchestrators ?? []).map((o) => [o.projectId, o])),
[orchestrators],
);
// Stable ref so sessionsByProject can read latest sessions without depending
// on the array reference (which changes every SSE tick even when content is unchanged).
const sessionsRef = useRef(sessions);
sessionsRef.current = sessions;
// Content-based key — only changes when session IDs, statuses, or projects change.
// Used as the sole sessions-related dependency of sessionsByProject below.
const sessionsKey = useMemo(
() =>
(sessions ?? [])
.map(
(s) =>
`${s.id}:${s.status}:${s.activity ?? ""}:${s.projectId}:${s.displayName ?? ""}:${s.displayNameUserSet ? "1" : "0"}:${s.branch ?? ""}:${s.issueTitle ?? ""}:${s.pr?.title ?? ""}:${s.summary ?? ""}`,
)
.join("|"),
[sessions],
);
const sessionsByProject = useMemo(() => {
const map = new Map<string, DashboardSession[]>();
// Build a set of valid project IDs to filter sessions strictly
const validProjectIds = new Set(visibleProjects.map((p) => p.id));
for (const s of sessions ?? []) {
// Read via ref so this memo only reruns when sessionsKey changes (content
// changed), not when sessions gets a new array reference with identical data.
for (const s of sessionsRef.current ?? []) {
// Only include sessions whose projectId matches a configured project
if (!validProjectIds.has(s.projectId)) continue;
if (isOrchestratorSession(s, prefixByProject.get(s.projectId), allPrefixes)) continue;
@ -295,7 +460,8 @@ function ProjectSidebarInner({
map.set(s.projectId, list);
}
return map;
}, [sessions, prefixByProject, allPrefixes, visibleProjects, showKilled, showDone]);
}, [sessionsKey, prefixByProject, allPrefixes, visibleProjects, showKilled, showDone]);
// Clear an optimistic rename once the prop session.displayName catches up.
// Without this, we'd keep masking the server value forever after a save.
@ -313,18 +479,23 @@ function ProjectSidebarInner({
if (changed) setPendingRenames(next);
}, [sessions, pendingRenames]);
const startRename = (session: DashboardSession, currentTitle: string) => {
// Prefer the in-flight optimistic value over the prop — if the user opens
// rename while a previous PATCH is still propagating, the prop still shows
// the pre-rename value but we want the input to start from the latest.
// Auto-derived displayName isn't pre-filled (user-set flag absent) — start
// from the live title so the user types over the visible label.
const pending = pendingRenames.get(session.id);
const initial =
pending ?? (session.displayNameUserSet ? (session.displayName ?? "") : "");
setEditingSessionId(session.id);
setEditingValue(initial || currentTitle);
};
const pendingRenamesRef = useRef(pendingRenames);
pendingRenamesRef.current = pendingRenames;
const startRename = useCallback(
(session: DashboardSession, currentTitle: string) => {
// Prefer the in-flight optimistic value over the prop — if the user opens
// rename while a previous PATCH is still propagating, the prop still shows
// the pre-rename value but we want the input to start from the latest.
// Auto-derived displayName isn't pre-filled (user-set flag absent) — start
// from the live title so the user types over the visible label.
const pending = pendingRenamesRef.current.get(session.id);
const initial = pending ?? (session.displayNameUserSet ? (session.displayName ?? "") : "");
setEditingSessionId(session.id);
setEditingValue(initial || currentTitle);
},
[],
);
const cancelRename = () => {
setEditingSessionId(null);
@ -367,17 +538,20 @@ function ProjectSidebarInner({
}
};
const navigate = (url: string, session?: DashboardSession) => {
if (session) {
try {
sessionStorage.setItem(`ao-session-nav:${session.id}`, JSON.stringify(session));
} catch {
// sessionStorage unavailable — silent fallback
const navigate = useCallback(
(url: string, session?: DashboardSession) => {
if (session) {
try {
sessionStorage.setItem(`ao-session-nav:${session.id}`, JSON.stringify(session));
} catch {
// sessionStorage unavailable — silent fallback
}
}
}
router.push(url);
onMobileClose?.();
};
router.push(url);
onMobileClose?.();
},
[router, onMobileClose],
);
const toggleExpand = (projectId: string) => {
setExpandedProjects((prev) => {
@ -544,6 +718,16 @@ function ProjectSidebarInner({
{/* Project tree */}
<div className="project-sidebar__tree flex-1 overflow-y-auto overflow-x-hidden">
{sessions === null ? (
<div className="space-y-1 px-3 py-3" aria-label="Loading projects">
{Array.from({ length: 4 }, (_, i) => (
<div key={i} className="flex items-center gap-2 py-1">
<div className="h-2 w-2 shrink-0 animate-pulse rounded-full bg-[var(--color-border-strong)]" />
<div className="h-3 flex-1 animate-pulse rounded bg-[var(--color-bg-primary)]" />
</div>
))}
</div>
) : null}
{visibleProjects.map((project) => {
const workerSessions = sessionsByProject.get(project.id) ?? [];
const isExpanded = expandedProjects.has(project.id);
@ -553,8 +737,14 @@ function ProjectSidebarInner({
// sessionsByProject already applies the showDone filter consistently.
const visibleSessions = workerSessions;
const hasActiveSessions = visibleSessions.length > 0;
const orchestratorLink = orchestratorByProject.get(project.id) ?? null;
// Look up the full session object so navigate() can cache it in
// sessionStorage — prevents the "Session unavailable" flash on
// first load. Orchestrators are filtered out of sessionsByProject
// but still present in the raw sessions prop.
const orchestratorSession = orchestratorLink
? (sessions?.find((s) => s.id === orchestratorLink.id) ?? null)
: null;
return (
<div key={project.id} className="project-sidebar__project">
@ -625,7 +815,7 @@ function ProjectSidebarInner({
hasActiveSessions && "project-sidebar__proj-badge--active",
)}
>
{workerSessions.length}
{sessionsByProject.get(project.id)?.length ?? 0}
</span>
</button>
)}
@ -656,14 +846,17 @@ function ProjectSidebarInner({
</Link>
) : null}
{/* Orchestrator button */}
{!isDegraded && orchestratorLink && (
<Link
<a
href={projectSessionPath(project.id, orchestratorLink.id)}
prefetch={false}
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
e.stopPropagation();
onMobileClose?.();
navigate(
projectSessionPath(project.id, orchestratorLink.id),
orchestratorSession ?? undefined,
);
}}
className="project-sidebar__proj-action"
aria-label={`Open ${project.name} orchestrator`}
@ -683,7 +876,7 @@ function ProjectSidebarInner({
<circle cx="12" cy="17" r="2" />
<circle cx="18" cy="17" r="2" />
</svg>
</Link>
</a>
)}
<div
@ -730,7 +923,10 @@ function ProjectSidebarInner({
role="menuitem"
onClick={() => {
setProjectMenuOpenId(null);
navigate(projectSessionPath(project.id, orchestratorLink.id));
navigate(
projectSessionPath(project.id, orchestratorLink.id),
orchestratorSession ?? undefined,
);
}}
>
Open orchestrator
@ -768,7 +964,7 @@ function ProjectSidebarInner({
{/* Sessions */}
{!isDegraded && isExpanded && (
<div className="project-sidebar__sessions">
{isLoading ? (
{sessions === null ? (
<div className="space-y-2 px-3 py-2" aria-label="Loading sessions">
{Array.from({ length: 3 }, (_, index) => (
<div
@ -785,24 +981,6 @@ function ProjectSidebarInner({
visibleSessions.map((session) => {
const level = getAttentionLevel(session);
const isSessionActive = activeSessionId === session.id;
// Display precedence: optimistic rename (just-saved value)
// → user-set displayName → branch → fallback chain.
// Auto-derived displayName (displayNameUserSet=false) is
// intentionally skipped here so PR/issue titles surfaced
// by getSessionTitle aren't shadowed — mirrors the gate in
// format.ts:getSessionTitle.
const pending = pendingRenames.get(session.id);
const effectiveDisplayName =
pending !== undefined
? pending
: session.displayNameUserSet
? (session.displayName ?? "")
: "";
const title =
effectiveDisplayName !== ""
? effectiveDisplayName
: (session.branch ?? getSessionTitle(session));
const sessionHref = projectSessionPath(project.id, session.id);
const isEditing = editingSessionId === session.id;
if (isEditing) {
return (
@ -839,76 +1017,16 @@ function ProjectSidebarInner({
);
}
return (
<div
<SessionRow
key={session.id}
className={cn(
"project-sidebar__sess-row group",
isSessionActive && "project-sidebar__sess-row--active",
)}
>
<a
href={sessionHref}
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
navigate(sessionHref, session);
}}
className="project-sidebar__sess-link flex flex-1 min-w-0 items-center gap-[7px]"
aria-current={isSessionActive ? "page" : undefined}
aria-label={`Open ${title}`}
>
<SessionDot level={level} />
<div className="flex-1 min-w-0">
<span
className={cn(
"project-sidebar__sess-label",
isSessionActive && "project-sidebar__sess-label--active",
)}
>
{title}
</span>
{showSessionId ? (
<div className="project-sidebar__sess-meta">
<span className="project-sidebar__sess-id">{session.id}</span>
<span className="project-sidebar__sess-status">
{LEVEL_LABELS[level]}
</span>
</div>
) : null}
</div>
{!showSessionId ? (
<span className="project-sidebar__sess-status project-sidebar__sess-status--inline">
{LEVEL_LABELS[level]}
</span>
) : null}
</a>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
startRename(session, title);
}}
className="project-sidebar__sess-rename-btn opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100"
title="Rename session"
aria-label={`Rename ${session.id}`}
>
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
</svg>
</button>
</div>
session={session}
level={level}
isActive={isSessionActive}
showSessionId={showSessionId}
pendingRename={pendingRenames.get(session.id)}
onNavigate={navigate}
onStartRename={startRename}
/>
);
})
) : error ? (
@ -923,7 +1041,9 @@ function ProjectSidebarInner({
</button>
</div>
) : (
<div className="project-sidebar__empty">No sessions shown</div>
<div className="project-sidebar__empty">
No active sessions
</div>
)}
</div>
)}

View File

@ -11,10 +11,9 @@ import {
import dynamic from "next/dynamic";
import { getSessionTitle } from "@/lib/format";
import type { ProjectInfo } from "@/lib/project-name";
import { SidebarContext } from "./workspace/SidebarContext";
import { useSidebarContext } from "./workspace/SidebarContext";
import { projectDashboardPath, projectSessionPath } from "@/lib/routes";
import { ProjectSidebar, type ProjectSidebarOrchestrator } from "./ProjectSidebar";
import { MobileBottomNav } from "./MobileBottomNav";
import { SessionDetailHeader, type OrchestratorZones } from "./SessionDetailHeader";
import { SessionEndedSummary } from "./SessionEndedSummary";
@ -40,11 +39,6 @@ interface SessionDetailProps {
orchestratorZones?: OrchestratorZones;
projectOrchestratorId?: string | null;
projects?: ProjectInfo[];
sidebarSessions?: DashboardSession[] | null;
sidebarOrchestrators?: ProjectSidebarOrchestrator[];
sidebarLoading?: boolean;
sidebarError?: boolean;
onRetrySidebar?: () => void;
}
export function SessionDetail({
@ -53,18 +47,12 @@ export function SessionDetail({
orchestratorZones,
projectOrchestratorId = null,
projects = [],
sidebarSessions = [],
sidebarOrchestrators,
sidebarLoading = false,
sidebarError = false,
onRetrySidebar,
}: SessionDetailProps) {
const router = useRouter();
const searchParams = useSearchParams();
const isMobile = useMediaQuery(MOBILE_BREAKPOINT);
const sidebarCtx = useSidebarContext();
const startFullscreen = searchParams.get("fullscreen") === "true";
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [showTerminal, setShowTerminal] = useState(false);
const [relaunchError, setRelaunchError] = useState<string | null>(null);
const pr = session.pr;
@ -162,10 +150,10 @@ export function SessionDetail({
}, [session.id, session.projectId]);
const orchestratorHref = useMemo(() => {
if (isOrchestrator) return projectSessionPath(session.projectId, session.id);
if (isOrchestrator) return null;
if (projectOrchestratorId) return projectSessionPath(session.projectId, projectOrchestratorId);
return null;
}, [isOrchestrator, projectOrchestratorId, session.id, session.projectId]);
}, [isOrchestrator, projectOrchestratorId, session.projectId]);
useEffect(() => {
const frame = window.requestAnimationFrame(() => setShowTerminal(true));
@ -175,130 +163,86 @@ export function SessionDetail({
};
}, [session.id]);
const handleToggleSidebar = useCallback(() => {
if (isMobile) {
setMobileSidebarOpen((v) => !v);
} else {
setSidebarCollapsed((v) => !v);
}
}, [isMobile]);
return (
<SidebarContext.Provider value={{ onToggleSidebar: handleToggleSidebar, mobileSidebarOpen }}>
<div className="dashboard-app-shell">
<SessionDetailHeader
session={session}
isOrchestrator={isOrchestrator}
isMobile={isMobile}
terminalEnded={terminalEnded}
isRestorable={isRestorable}
activity={activity}
headline={headline}
projects={projects}
orchestratorHref={orchestratorHref}
orchestratorZones={orchestratorZones}
onToggleSidebar={handleToggleSidebar}
onRestore={handleRestore}
onKill={handleKill}
onRelaunchClean={isOrchestrator ? handleRelaunchClean : undefined}
/>
<div
className={`dashboard-shell dashboard-shell--desktop${
sidebarCollapsed ? " dashboard-shell--sidebar-collapsed" : ""
}`}
>
{projects.length > 0 ? (
<div
className={`sidebar-wrapper${
mobileSidebarOpen ? " sidebar-wrapper--mobile-open" : ""
}`}
>
<ProjectSidebar
projects={projects}
sessions={sidebarSessions}
orchestrators={sidebarOrchestrators}
loading={sidebarLoading}
error={sidebarError}
onRetry={onRetrySidebar}
activeProjectId={session.projectId}
activeSessionId={session.id}
collapsed={sidebarCollapsed}
onToggleCollapsed={() => setSidebarCollapsed((current) => !current)}
onMobileClose={() => setMobileSidebarOpen(false)}
/>
</div>
) : null}
{mobileSidebarOpen && (
<div className="sidebar-mobile-backdrop" onClick={() => setMobileSidebarOpen(false)} />
)}
<div className="dashboard-main dashboard-main--desktop">
<main className="session-detail-page flex-1 min-h-0 flex flex-col bg-[var(--color-bg-base)]">
{relaunchError ? (
<div
className="border-b border-[color-mix(in_srgb,var(--color-status-error)_28%,transparent)] bg-[color-mix(in_srgb,var(--color-status-error)_10%,transparent)] px-4 py-2 text-[12px] text-[var(--color-status-error)]"
role="alert"
aria-live="assertive"
>
<div className="flex items-start justify-between gap-3">
<div>
<span className="font-semibold">Relaunch failed:</span> {relaunchError}
<div className="mt-1 text-[var(--color-text-secondary)]">
The previous orchestrator may already be terminated. Try again from the
project dashboard.
</div>
</div>
<button
type="button"
onClick={() => setRelaunchError(null)}
className="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]"
aria-label="Dismiss"
>
×
</button>
</div>
<div className="dashboard-main--desktop">
<SessionDetailHeader
session={session}
isOrchestrator={isOrchestrator}
isMobile={isMobile}
terminalEnded={terminalEnded}
isRestorable={isRestorable}
activity={activity}
headline={headline}
projects={projects}
orchestratorHref={orchestratorHref}
orchestratorZones={orchestratorZones}
onToggleSidebar={sidebarCtx?.onToggleSidebar ?? (() => {})}
onRestore={handleRestore}
onKill={handleKill}
onRelaunchClean={isOrchestrator ? handleRelaunchClean : undefined}
/>
<main className="session-detail-page flex-1 min-h-0 flex flex-col bg-[var(--color-bg-base)]">
{relaunchError ? (
<div
className="border-b border-[color-mix(in_srgb,var(--color-status-error)_28%,transparent)] bg-[color-mix(in_srgb,var(--color-status-error)_10%,transparent)] px-4 py-2 text-[12px] text-[var(--color-status-error)]"
role="alert"
aria-live="assertive"
>
<div className="flex items-start justify-between gap-3">
<div>
<span className="font-semibold">Relaunch failed:</span> {relaunchError}
<div className="mt-1 text-[var(--color-text-secondary)]">
The previous orchestrator may already be terminated. Try again from the
project dashboard.
</div>
) : null}
<div className="flex-1 min-h-0 flex flex-col">
{!showTerminal ? (
<div className="session-detail-terminal-placeholder h-full" />
) : terminalEnded ? (
<SessionEndedSummary
session={session}
headline={headline}
pr={pr}
dashboardHref={dashboardHref}
/>
) : (
<DirectTerminal
sessionId={session.id}
projectId={session.projectId}
tmuxName={session.metadata?.tmuxName}
startFullscreen={startFullscreen}
variant={terminalVariant}
appearance="dark"
height="100%"
isOpenCodeSession={isOpenCodeSession}
reloadCommand={isOpenCodeSession ? reloadCommand : undefined}
autoFocus
/>
)}
</div>
</main>
<button
type="button"
onClick={() => setRelaunchError(null)}
className="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]"
aria-label="Dismiss"
>
×
</button>
</div>
</div>
) : null}
<div className="flex-1 min-h-0 flex flex-col">
{!showTerminal ? (
<div className="session-detail-terminal-placeholder h-full" />
) : terminalEnded ? (
<SessionEndedSummary
session={session}
headline={headline}
pr={pr}
dashboardHref={dashboardHref}
/>
) : (
<DirectTerminal
sessionId={session.id}
projectId={session.projectId}
tmuxName={session.metadata?.tmuxName}
startFullscreen={startFullscreen}
variant={terminalVariant}
appearance="dark"
height="100%"
isOpenCodeSession={isOpenCodeSession}
reloadCommand={isOpenCodeSession ? reloadCommand : undefined}
autoFocus
/>
)}
</div>
<MobileBottomNav
ariaLabel="Session navigation"
activeTab={isOrchestrator ? "orchestrator" : undefined}
dashboardHref={dashboardHref}
prsHref={
session.projectId ? `/?project=${encodeURIComponent(session.projectId)}&tab=prs` : "/"
}
showOrchestrator={!!orchestratorHref}
orchestratorHref={orchestratorHref}
/>
</div>
</SidebarContext.Provider>
</main>
<MobileBottomNav
ariaLabel="Session navigation"
activeTab={isOrchestrator ? "orchestrator" : undefined}
dashboardHref={dashboardHref}
prsHref={
session.projectId ? `/?project=${encodeURIComponent(session.projectId)}&tab=prs` : "/"
}
showOrchestrator={!!orchestratorHref}
orchestratorHref={orchestratorHref}
/>
</div>
);
}

View File

@ -165,6 +165,7 @@ describe("Dashboard empty state", () => {
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("mounts the sidebar empty state on a fresh install with zero projects", () => {
render(<Dashboard initialSessions={[]} projects={[]} />);

View File

@ -399,6 +399,8 @@ describe("ProjectSidebar", () => {
/>,
);
// Only the killed-but-still-needs-attention session is counted; the merged
// session is filtered out by sessionsByProject (showDone = false by default).
expect(screen.getByRole("button", { name: /^Project One 1$/ })).toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Open Runtime missing but needs review" }),

View File

@ -483,7 +483,7 @@ describe("SessionDetail desktop layout", () => {
expect(routerRefreshMock).not.toHaveBeenCalled();
});
it("keeps the desktop orchestrator button on orchestrator session pages", () => {
it("hides the desktop orchestrator button on orchestrator session pages", () => {
render(
<SessionDetail
session={makeSession({
@ -505,8 +505,8 @@ describe("SessionDetail desktop layout", () => {
);
expect(
within(screen.getByRole("banner")).getByRole("link", { name: "Orchestrator" }),
).toHaveAttribute("href", "/projects/my-app/sessions/my-app-orchestrator");
within(screen.getByRole("banner")).queryByRole("link", { name: "Orchestrator" }),
).not.toBeInTheDocument();
expect(screen.getByText("orchestrator")).toBeInTheDocument();
});
@ -557,8 +557,8 @@ describe("SessionDetail desktop layout", () => {
);
expect(
within(screen.getByRole("banner")).getByRole("link", { name: "Orchestrator" }),
).toHaveAttribute("href", "/projects/my-app/sessions/my-app-orchestrator");
within(screen.getByRole("banner")).queryByRole("link", { name: "Orchestrator" }),
).not.toBeInTheDocument();
});
it("routes to the project orchestrator after killing a worker session", async () => {