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 () => {