From 406b26e8373e3c07db5ad8a48ee8002f4ebf19a7 Mon Sep 17 00:00:00 2001 From: Pritom Mazumdar Date: Sun, 17 May 2026 23:45:35 +0530 Subject: [PATCH] feat(web): sidebar and dashboard header UI/UX polish (#1846) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 to an 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 --------- Co-authored-by: Claude Sonnet 4.6 --- packages/web/src/__tests__/setup.ts | 16 + packages/web/src/app/globals.css | 23 +- .../__tests__/project-layout-client.test.tsx | 122 ++++ .../src/app/projects/[projectId]/layout.tsx | 5 + .../app/projects/[projectId]/loading.test.tsx | 5 +- .../src/app/projects/[projectId]/loading.tsx | 123 +--- .../web/src/app/projects/[projectId]/page.tsx | 2 +- .../[projectId]/project-layout-client.tsx | 91 +++ .../[projectId]/sessions/[id]/page.tsx | 460 +++++++++++++- packages/web/src/app/projects/layout.tsx | 19 + .../web/src/app/sessions/[id]/page.test.tsx | 209 ------ packages/web/src/app/sessions/[id]/page.tsx | 10 +- .../src/components/CopyDebugBundleButton.tsx | 4 +- packages/web/src/components/Dashboard.tsx | 601 ++++++++++-------- .../web/src/components/ProjectSidebar.tsx | 402 ++++++++---- packages/web/src/components/SessionDetail.tsx | 216 +++---- .../__tests__/Dashboard.emptyState.test.tsx | 1 + .../__tests__/ProjectSidebar.test.tsx | 2 + .../__tests__/SessionDetail.desktop.test.tsx | 10 +- 19 files changed, 1457 insertions(+), 864 deletions(-) create mode 100644 packages/web/src/app/projects/[projectId]/__tests__/project-layout-client.test.tsx create mode 100644 packages/web/src/app/projects/[projectId]/layout.tsx create mode 100644 packages/web/src/app/projects/[projectId]/project-layout-client.tsx create mode 100644 packages/web/src/app/projects/layout.tsx diff --git a/packages/web/src/__tests__/setup.ts b/packages/web/src/__tests__/setup.ts index b3258b10f..1ccc65a90 100644 --- a/packages/web/src/__tests__/setup.ts +++ b/packages/web/src/__tests__/setup.ts @@ -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 = {}; + 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(); diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index 8bb4b4016..14f6fcf83 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -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; } diff --git a/packages/web/src/app/projects/[projectId]/__tests__/project-layout-client.test.tsx b/packages/web/src/app/projects/[projectId]/__tests__/project-layout-client.test.tsx new file mode 100644 index 000000000..e94256838 --- /dev/null +++ b/packages/web/src/app/projects/[projectId]/__tests__/project-layout-client.test.tsx @@ -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 = { 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[] }) => ( +
+ ), +})); + +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( + +
Page
+
, + ); + + expect(screen.getByTestId("sidebar")).toBeInTheDocument(); + expect(screen.getByTestId("page-content")).toBeInTheDocument(); + }); + + it("passes activeProjectId from route params to sidebar", () => { + mockParams = { projectId: "proj-1" }; + + render( + +
+ , + ); + + expect(screen.getByTestId("sidebar").dataset.project).toBe("proj-1"); + }); + + it("passes initialOrchestrators directly to sidebar", () => { + render( + +
+ , + ); + + const sidebar = screen.getByTestId("sidebar"); + expect(JSON.parse(sidebar.dataset.orchestrators ?? "[]")).toEqual(orchestrators); + }); + + it("resets mobile sidebar when pathname changes", async () => { + const { rerender } = render( + +
+ , + ); + + // Simulate pathname change + mockPathname = "/projects/proj-1/sessions/sess-1"; + + await act(async () => { + rerender( + +
+ , + ); + }); + + // 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); + }); +}); diff --git a/packages/web/src/app/projects/[projectId]/layout.tsx b/packages/web/src/app/projects/[projectId]/layout.tsx new file mode 100644 index 000000000..330afda9c --- /dev/null +++ b/packages/web/src/app/projects/[projectId]/layout.tsx @@ -0,0 +1,5 @@ +import type { ReactNode } from "react"; + +export default function ProjectIdLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/packages/web/src/app/projects/[projectId]/loading.test.tsx b/packages/web/src/app/projects/[projectId]/loading.test.tsx index 54d978a5b..61726031e 100644 --- a/packages/web/src/app/projects/[projectId]/loading.test.tsx +++ b/packages/web/src/app/projects/[projectId]/loading.test.tsx @@ -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(); }); }); diff --git a/packages/web/src/app/projects/[projectId]/loading.tsx b/packages/web/src/app/projects/[projectId]/loading.tsx index cc385e2fd..ffeee105c 100644 --- a/packages/web/src/app/projects/[projectId]/loading.tsx +++ b/packages/web/src/app/projects/[projectId]/loading.tsx @@ -1,101 +1,40 @@ -function ProjectLoadingSidebar() { - return ( - - ); -} - export default function ProjectRouteLoading() { return ( -
-
- - -
- +
+ -
-
-
-
-
- -
); } diff --git a/packages/web/src/app/projects/[projectId]/page.tsx b/packages/web/src/app/projects/[projectId]/page.tsx index 2c677fb1f..39d317391 100644 --- a/packages/web/src/app/projects/[projectId]/page.tsx +++ b/packages/web/src/app/projects/[projectId]/page.tsx @@ -29,7 +29,7 @@ export default async function ProjectPage(props: { const pageData = await getDashboardPageData(projectId); return ( -
+
{ + 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 ( + +
+
+
+ setSidebarCollapsed((v) => !v)} + onMobileClose={() => setMobileSidebarOpen(false)} + /> +
+ {mobileSidebarOpen && ( +
setMobileSidebarOpen(false)} /> + )} + {children} +
+
+ + ); +} diff --git a/packages/web/src/app/projects/[projectId]/sessions/[id]/page.tsx b/packages/web/src/app/projects/[projectId]/sessions/[id]/page.tsx index 3e7be8541..39c70fcab 100644 --- a/packages/web/src/app/projects/[projectId]/sessions/[id]/page.tsx +++ b/packages/web/src/app/projects/[projectId]/sessions/[id]/page.tsx @@ -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, + 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 ( +
+
+ + + +
Loading session…
+
+
+ ); +} + +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(cachedSession); + const [zoneCounts, setZoneCounts] = useState(null); + const [projectOrchestratorId, setProjectOrchestratorId] = useState( + undefined, + ); + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(cachedSession === null); + const [routeError, setRouteError] = useState(null); + const [sessionMissing, setSessionMissing] = useState(false); + const [prefixByProject, setPrefixByProject] = useState>(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(null); + const sessionIsOrchestratorRef = useRef(false); + const resolvedProjectSessionsKeyRef = useRef(null); + const prefixByProjectRef = useRef>(new Map()); + const hasLoadedSessionRef = useRef(cachedSession !== null); + const fetchingSessionRef = useRef(false); + const fetchingProjectSessionsRef = useRef(false); + const sessionFetchControllerRef = useRef(null); + const projectSessionsFetchControllerRef = useRef(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( + `/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(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
; + + if (sessionMissing) { + return ( +
+
+ void fetchSession() }} + compact + chrome="card" + /> +
+
+ ); + } + + if (routeError) { + return ( +
+
+ { + 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" + /> +
+
+ ); + } + + if (!session) { + return ( +
+
+ void fetchSession() }} + secondaryAction={{ + label: "Back to dashboard", + href: expectedProjectId ? `/projects/${expectedProjectId}` : "/", + }} + compact + chrome="card" + /> +
+
+ ); + } + + return ( + + ); +} diff --git a/packages/web/src/app/projects/layout.tsx b/packages/web/src/app/projects/layout.tsx new file mode 100644 index 000000000..91140bcd1 --- /dev/null +++ b/packages/web/src/app/projects/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/packages/web/src/app/sessions/[id]/page.test.tsx b/packages/web/src/app/sessions/[id]/page.test.tsx index 1f3158e8d..6fa1990e9 100644 --- a/packages/web/src/app/sessions/[id]/page.test.tsx +++ b/packages/web/src/app/sessions/[id]/page.test.tsx @@ -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((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(); - 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(); - 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((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(); - 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"; diff --git a/packages/web/src/app/sessions/[id]/page.tsx b/packages/web/src/app/sessions/[id]/page.tsx index f16c5c365..a69d80e9c 100644 --- a/packages/web/src/app/sessions/[id]/page.tsx +++ b/packages/web/src/app/sessions/[id]/page.tsx @@ -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} /> ); } diff --git a/packages/web/src/components/CopyDebugBundleButton.tsx b/packages/web/src/components/CopyDebugBundleButton.tsx index a58cc9607..847494c67 100644 --- a/packages/web/src/components/CopyDebugBundleButton.tsx +++ b/packages/web/src/components/CopyDebugBundleButton.tsx @@ -108,9 +108,10 @@ export function CopyDebugBundleButton({ projectId }: CopyDebugBundleButtonProps) return ( ); } diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index 3b790b182..c270831f1 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -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(orchestratorLinks); const [spawningProjectIds, setSpawningProjectIds] = useState([]); const [spawnErrors, setSpawnErrors] = useState>({}); - 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>( () => new Set(["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 = { 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 ( - - <> - - -
-
- +
+ Agent Orchestrator +
+ {showHeaderProjectLabel ? ( + <> +
+ +
+ +
+

Dashboard

+

+ Live agent sessions, pull requests, and merge status. +

+
+ +
+ {loadErrorBanner} + {anyRateLimited && !rateLimitDismissed && ( +
+ - ) : ( - - )} - -
-
- {showHeaderProjectLabel ? ( - <> -
+
+ ); + + const bottomSheet = ( + + ); + + if (isInsideLayout) { + return ( + <> + + + {mainPanel} + {bottomSheet} + ); + } + + return ( + + + +
+
+
+ setSidebarCollapsed((v) => !v)} + onMobileClose={() => setMobileSidebarOpen(false)} + /> +
+ {mobileSidebarOpen && ( +
setMobileSidebarOpen(false)} + /> + )} + {mainPanel} +
+
+ {bottomSheet} ); } diff --git a/packages/web/src/components/ProjectSidebar.tsx b/packages/web/src/components/ProjectSidebar.tsx index 2b6b78e1c..c6b29a7f6 100644 --- a/packages/web/src/components/ProjectSidebar.tsx +++ b/packages/web/src/components/ProjectSidebar.tsx @@ -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 (
); -} +}); // 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 = { - 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 | 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(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 ; } +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 ( +
+ { + 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}`} + > + +
+ + {title} + + {showSessionId ? ( +
+ {session.id} +
+ ) : null} +
+
+ +
+ ); +}); + 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>( - () => 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(loadShowKilled); + const [showDone, setShowDone] = useState(loadShowDone); const [showSessionId, setShowSessionId] = useState(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(); // 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 */}
+ {sessions === null ? ( +
+ {Array.from({ length: 4 }, (_, i) => ( +
+
+
+
+ ))} +
+ ) : 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 (
@@ -625,7 +815,7 @@ function ProjectSidebarInner({ hasActiveSessions && "project-sidebar__proj-badge--active", )} > - {workerSessions.length} + {sessionsByProject.get(project.id)?.length ?? 0} )} @@ -656,14 +846,17 @@ function ProjectSidebarInner({ ) : null} - {/* Orchestrator button */} {!isDegraded && orchestratorLink && ( - { + 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({ - + )}
{ 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 && (
- {isLoading ? ( + {sessions === null ? (
{Array.from({ length: 3 }, (_, index) => (
{ 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 ( - + session={session} + level={level} + isActive={isSessionActive} + showSessionId={showSessionId} + pendingRename={pendingRenames.get(session.id)} + onNavigate={navigate} + onStartRename={startRename} + /> ); }) ) : error ? ( @@ -923,7 +1041,9 @@ function ProjectSidebarInner({
) : ( -
No sessions shown
+
+ No active sessions +
)}
)} diff --git a/packages/web/src/components/SessionDetail.tsx b/packages/web/src/components/SessionDetail.tsx index 5cb0d0f35..e94d2d972 100644 --- a/packages/web/src/components/SessionDetail.tsx +++ b/packages/web/src/components/SessionDetail.tsx @@ -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(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 ( - -
- - -
- {projects.length > 0 ? ( -
- setSidebarCollapsed((current) => !current)} - onMobileClose={() => setMobileSidebarOpen(false)} - /> -
- ) : null} - {mobileSidebarOpen && ( -
setMobileSidebarOpen(false)} /> - )} - -
-
- {relaunchError ? ( -
-
-
- Relaunch failed: {relaunchError} -
- The previous orchestrator may already be terminated. Try again from the - project dashboard. -
-
- -
+
+ {})} + onRestore={handleRestore} + onKill={handleKill} + onRelaunchClean={isOrchestrator ? handleRelaunchClean : undefined} + /> +
+ {relaunchError ? ( +
+
+
+ Relaunch failed: {relaunchError} +
+ The previous orchestrator may already be terminated. Try again from the + project dashboard.
- ) : null} -
- {!showTerminal ? ( -
- ) : terminalEnded ? ( - - ) : ( - - )}
-
+ +
+ ) : null} +
+ {!showTerminal ? ( +
+ ) : terminalEnded ? ( + + ) : ( + + )}
- -
- +
+ +
); } diff --git a/packages/web/src/components/__tests__/Dashboard.emptyState.test.tsx b/packages/web/src/components/__tests__/Dashboard.emptyState.test.tsx index 418789a74..8055feced 100644 --- a/packages/web/src/components/__tests__/Dashboard.emptyState.test.tsx +++ b/packages/web/src/components/__tests__/Dashboard.emptyState.test.tsx @@ -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(); diff --git a/packages/web/src/components/__tests__/ProjectSidebar.test.tsx b/packages/web/src/components/__tests__/ProjectSidebar.test.tsx index 13536ee04..6f7150c37 100644 --- a/packages/web/src/components/__tests__/ProjectSidebar.test.tsx +++ b/packages/web/src/components/__tests__/ProjectSidebar.test.tsx @@ -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" }), diff --git a/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx b/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx index e6df9645b..07a77e48f 100644 --- a/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx +++ b/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx @@ -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( { ); 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 () => {