feat(web): sidebar and dashboard header UI/UX polish (#1846)
* perf(web): eliminate sidebar re-renders on every SSE tick Resolves #1844 The SSE hook delivers a new sessions array reference every 5 seconds even when content is identical, causing sessionsByProject to recompute and all session rows to re-render on every tick. Three fixes in ProjectSidebar.tsx: * sessionsKey + sessionsRef: replace unstable array reference in memo deps with a content-derived string; memo only fires on real changes * SessionRow memoized component: rows skip re-renders when props are unchanged; navigate and startRename stabilised with useCallback * SessionDot memoized: status indicator skips re-renders when level prop is unchanged A quiet SSE tick now touches zero React components in the sidebar. * fix(web): add displayName, displayNameUserSet, branch to sessionsKey hash Without these fields, a session rename delivered via SSE did not trigger sessionsByProject to recompute. The stale session object held the old displayName, and once the optimistic pendingRename was cleared the sidebar silently reverted to the pre-rename title. Addresses review feedback on PR #1846. * feat(web): sidebar and dashboard header UI/UX polish Removes state text labels from sidebar session rows so the colored dot is the sole status indicator, matching the intended design. Fixes the sidebar compact header height to align with the 48px main header. Adds session count summary pills to the dashboard project header. Converts CopyDebugBundleButton to an icon-only compact form so the actions row stays vertically centered. Fixes the project page wrapper missing flex-1 which caused a right-side viewport gap in the horizontal shell layout. * fix(web): resolve lint errors from UI/UX polish Remove LEVEL_LABELS, _isLoading prefix for unused loading var, and dead title variable from ProjectSidebar. Remove unused isDashboardSessionStatus and isActivityStateValue from the project session page. Remove react-hooks/exhaustive-deps eslint-disable comments for a rule not in the ESLint config. Stabilize startRename via pendingRenamesRef so the callback does not recreate on every rename state change, preventing unnecessary SessionRow re-renders. Remove non-null assertion in Dashboard.tsx handleToggleSidebar with a null guard. * fix(web): replace native Node 25 localStorage stub with full in-memory mock Node.js 25 exposes a native localStorage via --localstorage-file that lacks .clear() and .key(), causing all UpdateBanner tests to throw TypeError. Replace the global with a complete in-memory implementation so test suites work across all Node versions. * fix(web): seed sidebar with all sessions on hard refresh and eliminate per-project layout re-render - Hoist sidebar layout from projects/[projectId]/layout.tsx to projects/layout.tsx so it renders once for the entire /projects/* subtree and never re-mounts when switching between projects - Pass getDashboardPageData("all") so initial sessions cover every project, not just the primary - Extract ProjectLayoutClient from the old client layout for clean server/client split - Simplify per-project empty state to "No active sessions" only, removing the second hint line - Restore accidentally deleted Dashboard empty-state test for zero-projects install - Fix Reflect.deleteProperty lint error in localStorage mock; drop unused AttentionLevel import and dead effectiveDisplayName/pending variables from ProjectSidebar editing block Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(web): address PR review — orchestrators prop, sessionsKey, badge count, mobile overlay, tests * fix(web): remove duplicate skeleton sidebar from project loading state * fix(web): restore orchestrator button and eliminate Session unavailable flash Re-add the orchestrator icon + menu item that were removed during the merge conflict resolution. Convert the icon from a <Link> to an <a> that calls navigate() with the full session object so ProjectSessionPage gets an instant sessionStorage cache hit instead of starting with session=null and briefly showing the "Session unavailable" error card. * fix(web): eliminate Session unavailable flash on orchestrator navigation React Strict Mode aborts the first fetchSession() during its unmount/remount cycle. The aborted finally reset fetchingSessionRef but set loading=false, briefly showing the error card before the retry completed. Fix: keep loading=true on abort (no session yet), and immediately retry via fetchSession() once the ref is clear. mountedRef guards the retry so it only fires on Strict Mode remounts — not on genuine navigation-away unmounts, which would leak requests. * fix(web): fix working pill count and layout of error states in project session page - Dashboard topbar "working" pill now counts only actively working sessions, not working + pending (which inflated the number incorrectly) - Error/loading/missing states in ProjectSessionPage wrapped in dashboard-main--desktop so they fill the flex shell beside the sidebar instead of shrinking to content width Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
94981dc0fd
commit
406b26e837
|
|
@ -2,6 +2,22 @@ import { cleanup } from "@testing-library/react";
|
|||
import * as matchers from "@testing-library/jest-dom/matchers";
|
||||
import { afterEach, expect } from "vitest";
|
||||
|
||||
// Node.js 25 exposes a native localStorage stub via --localstorage-file that
|
||||
// lacks .clear()/.key()/.length. Replace it with a complete in-memory mock so
|
||||
// tests that call window.localStorage.clear() work on any Node version.
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: (k: string) => store[k] ?? null,
|
||||
setItem: (k: string, v: string) => { store[k] = String(v); },
|
||||
removeItem: (k: string) => { Reflect.deleteProperty(store, k); },
|
||||
clear: () => { store = {}; },
|
||||
get length() { return Object.keys(store).length; },
|
||||
key: (i: number) => Object.keys(store)[i] ?? null,
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(window, "localStorage", { value: localStorageMock, writable: true, configurable: true });
|
||||
|
||||
expect.extend(matchers);
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
import { render, screen, act } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let mockPathname = "/projects/proj-1";
|
||||
let mockParams: Record<string, string> = { projectId: "proj-1" };
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useParams: () => mockParams,
|
||||
usePathname: () => mockPathname,
|
||||
}));
|
||||
|
||||
vi.mock("next-themes", () => ({
|
||||
useTheme: () => ({ resolvedTheme: "light", setTheme: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/providers/MuxProvider", () => ({
|
||||
useMuxOptional: () => ({ status: "connecting", sessions: [], lastError: null }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useSessionEvents", () => ({
|
||||
useSessionEvents: ({ initialSessions }: { initialSessions: unknown[] }) => ({
|
||||
sessions: initialSessions,
|
||||
liveSessionsResolved: true,
|
||||
attentionLevels: {},
|
||||
loadError: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ProjectSidebar", () => ({
|
||||
ProjectSidebar: (props: { activeProjectId?: string; orchestrators?: unknown[] }) => (
|
||||
<div data-testid="sidebar" data-project={props.activeProjectId} data-orchestrators={JSON.stringify(props.orchestrators ?? [])} />
|
||||
),
|
||||
}));
|
||||
|
||||
import { ProjectLayoutClient } from "../project-layout-client";
|
||||
|
||||
const projects = [{ id: "proj-1", name: "Project One", sessionPrefix: "proj-1" }];
|
||||
const orchestrators = [{ id: "proj-1-orchestrator", projectId: "proj-1" }];
|
||||
|
||||
beforeEach(() => {
|
||||
mockPathname = "/projects/proj-1";
|
||||
mockParams = { projectId: "proj-1" };
|
||||
});
|
||||
|
||||
describe("ProjectLayoutClient", () => {
|
||||
it("renders children and sidebar", () => {
|
||||
render(
|
||||
<ProjectLayoutClient
|
||||
initialSessions={[]}
|
||||
initialProjects={projects}
|
||||
initialOrchestrators={[]}
|
||||
>
|
||||
<div data-testid="page-content">Page</div>
|
||||
</ProjectLayoutClient>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("sidebar")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("page-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes activeProjectId from route params to sidebar", () => {
|
||||
mockParams = { projectId: "proj-1" };
|
||||
|
||||
render(
|
||||
<ProjectLayoutClient
|
||||
initialSessions={[]}
|
||||
initialProjects={projects}
|
||||
initialOrchestrators={[]}
|
||||
>
|
||||
<div />
|
||||
</ProjectLayoutClient>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("sidebar").dataset.project).toBe("proj-1");
|
||||
});
|
||||
|
||||
it("passes initialOrchestrators directly to sidebar", () => {
|
||||
render(
|
||||
<ProjectLayoutClient
|
||||
initialSessions={[]}
|
||||
initialProjects={projects}
|
||||
initialOrchestrators={orchestrators}
|
||||
>
|
||||
<div />
|
||||
</ProjectLayoutClient>,
|
||||
);
|
||||
|
||||
const sidebar = screen.getByTestId("sidebar");
|
||||
expect(JSON.parse(sidebar.dataset.orchestrators ?? "[]")).toEqual(orchestrators);
|
||||
});
|
||||
|
||||
it("resets mobile sidebar when pathname changes", async () => {
|
||||
const { rerender } = render(
|
||||
<ProjectLayoutClient
|
||||
initialSessions={[]}
|
||||
initialProjects={projects}
|
||||
initialOrchestrators={[]}
|
||||
>
|
||||
<div />
|
||||
</ProjectLayoutClient>,
|
||||
);
|
||||
|
||||
// Simulate pathname change
|
||||
mockPathname = "/projects/proj-1/sessions/sess-1";
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<ProjectLayoutClient
|
||||
initialSessions={[]}
|
||||
initialProjects={projects}
|
||||
initialOrchestrators={[]}
|
||||
>
|
||||
<div />
|
||||
</ProjectLayoutClient>,
|
||||
);
|
||||
});
|
||||
|
||||
// Sidebar wrapper should not have the mobile-open class
|
||||
const wrapper = document.querySelector(".sidebar-wrapper");
|
||||
expect(wrapper?.classList.contains("sidebar-wrapper--mobile-open")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
export default function ProjectIdLayout({ children }: { children: ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,101 +1,40 @@
|
|||
function ProjectLoadingSidebar() {
|
||||
return (
|
||||
<aside className="project-sidebar flex h-full flex-col" aria-hidden="true">
|
||||
<div className="project-sidebar__compact-hdr">
|
||||
<span className="project-sidebar__sect-label">Projects</span>
|
||||
</div>
|
||||
<div className="project-sidebar__tree flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<div className="py-2">
|
||||
{["w-28", "w-24", "w-32", "w-20"].map((nameWidth, index) => (
|
||||
<div
|
||||
key={`project-loading-row-${index}`}
|
||||
className="flex items-center gap-3 border-b border-[var(--color-border-subtle)] px-4 py-3"
|
||||
>
|
||||
<div className="h-3 w-3 animate-pulse bg-[color-mix(in_srgb,var(--color-text-primary)_8%,transparent)]" />
|
||||
<div
|
||||
className={`h-4 animate-pulse bg-[color-mix(in_srgb,var(--color-text-primary)_10%,transparent)] ${nameWidth}`}
|
||||
/>
|
||||
<div className="ml-auto h-6 w-6 animate-pulse border border-[var(--color-border-default)] bg-[var(--color-bg-base)]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="project-sidebar__footer">
|
||||
<div className="flex items-center gap-2 border-t border-[var(--color-border-subtle)] px-2 py-2">
|
||||
<div className="h-7 w-7 animate-pulse border border-[var(--color-border-default)] bg-[var(--color-bg-surface)]" />
|
||||
<div className="h-7 w-7 animate-pulse border border-[var(--color-border-default)] bg-[var(--color-bg-surface)]" />
|
||||
<div className="h-7 w-7 animate-pulse border border-[var(--color-border-default)] bg-[var(--color-bg-surface)]" />
|
||||
<div className="ml-auto h-7 w-7 animate-pulse border border-[var(--color-border-default)] bg-[var(--color-bg-surface)]" />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProjectRouteLoading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--color-bg-canvas)]">
|
||||
<div className="dashboard-app-shell">
|
||||
<header className="dashboard-app-header" aria-hidden="true">
|
||||
<button type="button" className="dashboard-app-sidebar-toggle" aria-label="Toggle sidebar">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<path d="M9 3v18" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="dashboard-app-header__brand">
|
||||
<span className="dashboard-app-header__brand-dot" aria-hidden="true" />
|
||||
<span>Agent Orchestrator</span>
|
||||
</div>
|
||||
<span className="dashboard-app-header__sep" aria-hidden="true" />
|
||||
<span className="dashboard-app-header__project">Loading project…</span>
|
||||
<div className="dashboard-app-header__spacer" />
|
||||
<div className="dashboard-app-header__actions">
|
||||
<div className="h-9 w-36 animate-pulse border border-[var(--color-border-default)] bg-[var(--color-bg-surface)]" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="sidebar-wrapper">
|
||||
<ProjectLoadingSidebar />
|
||||
<div className="dashboard-main--desktop">
|
||||
<header className="dashboard-app-header" aria-hidden="true">
|
||||
<button type="button" className="dashboard-app-sidebar-toggle" aria-label="Toggle sidebar">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<path d="M9 3v18" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="dashboard-app-header__brand dashboard-app-header__brand--hide-mobile">
|
||||
<span>Agent Orchestrator</span>
|
||||
</div>
|
||||
<span className="dashboard-app-header__sep topbar-desktop-only" aria-hidden="true" />
|
||||
<span className="dashboard-app-header__project">Loading project…</span>
|
||||
<div className="dashboard-app-header__spacer" />
|
||||
</header>
|
||||
|
||||
<main className="dashboard-main dashboard-main--desktop overflow-y-auto">
|
||||
<div className="dashboard-main__subhead">
|
||||
<div className="h-8 w-40 animate-pulse bg-[color-mix(in_srgb,var(--color-bg-elevated)_88%,transparent)]" />
|
||||
<div className="mt-3 h-4 w-72 max-w-full animate-pulse bg-[color-mix(in_srgb,var(--color-bg-elevated)_82%,transparent)]" />
|
||||
</div>
|
||||
|
||||
<div className="board-wrapper" aria-hidden="true">
|
||||
<div className="kanban-ghost">
|
||||
{["Working", "Pending", "Review", "Respond", "Merge"].map((label) => (
|
||||
<div key={label} className="kanban-ghost__col">
|
||||
<div className="kanban-ghost__head">{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="board-center">
|
||||
<div
|
||||
className="empty-state"
|
||||
role="status"
|
||||
aria-label="Loading project dashboard"
|
||||
>
|
||||
<div className="empty-state__icon" />
|
||||
<div className="h-5 w-40 animate-pulse bg-[color-mix(in_srgb,var(--color-bg-elevated)_88%,transparent)]" />
|
||||
<div className="h-4 w-56 animate-pulse bg-[color-mix(in_srgb,var(--color-bg-elevated)_82%,transparent)]" />
|
||||
<main className="dashboard-main flex-1 min-h-0 overflow-hidden">
|
||||
<div className="board-wrapper" aria-hidden="true">
|
||||
<div className="kanban-ghost">
|
||||
{["Working", "Pending", "Review", "Respond", "Merge"].map((label) => (
|
||||
<div key={label} className="kanban-ghost__col">
|
||||
<div className="kanban-ghost__head">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export default async function ProjectPage(props: {
|
|||
const pageData = await getDashboardPageData(projectId);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--color-bg-canvas)]">
|
||||
<div className="flex-1 min-h-screen bg-[var(--color-bg-canvas)]">
|
||||
<Dashboard
|
||||
initialSessions={pageData.sessions}
|
||||
projectId={pageData.selectedProjectId}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, type ReactNode } from "react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { useSessionEvents } from "@/hooks/useSessionEvents";
|
||||
import { useMuxOptional } from "@/providers/MuxProvider";
|
||||
import { ProjectSidebar, type ProjectSidebarOrchestrator } from "@/components/ProjectSidebar";
|
||||
import { SidebarContext } from "@/components/workspace/SidebarContext";
|
||||
import { useMediaQuery, MOBILE_BREAKPOINT } from "@/hooks/useMediaQuery";
|
||||
import type { DashboardSession } from "@/lib/types";
|
||||
import type { ProjectInfo } from "@/lib/project-name";
|
||||
|
||||
function extractActiveSessionId(pathname: string | null): string | undefined {
|
||||
if (!pathname) return undefined;
|
||||
const match = pathname.match(/\/sessions\/([^/]+)/);
|
||||
return match?.[1] ?? undefined;
|
||||
}
|
||||
|
||||
interface ProjectLayoutClientProps {
|
||||
children: ReactNode;
|
||||
initialSessions: DashboardSession[];
|
||||
initialProjects: ProjectInfo[];
|
||||
initialOrchestrators: ProjectSidebarOrchestrator[];
|
||||
}
|
||||
|
||||
export function ProjectLayoutClient({
|
||||
children,
|
||||
initialSessions,
|
||||
initialProjects,
|
||||
initialOrchestrators,
|
||||
}: ProjectLayoutClientProps) {
|
||||
const params = useParams();
|
||||
const pathname = usePathname();
|
||||
const projectId = typeof params.projectId === "string" ? params.projectId : undefined;
|
||||
const activeSessionId = extractActiveSessionId(pathname);
|
||||
const isMobile = useMediaQuery(MOBILE_BREAKPOINT);
|
||||
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
|
||||
// Close mobile overlay whenever the route changes within the layout.
|
||||
useEffect(() => {
|
||||
setMobileSidebarOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
const mux = useMuxOptional();
|
||||
const { sessions, liveSessionsResolved } = useSessionEvents({
|
||||
initialSessions,
|
||||
muxSessions: mux?.status === "connected" ? mux.sessions : undefined,
|
||||
muxLastError: mux?.lastError,
|
||||
attentionZones: "simple",
|
||||
});
|
||||
|
||||
const handleToggleSidebar = useCallback(() => {
|
||||
if (isMobile) {
|
||||
setMobileSidebarOpen((v) => !v);
|
||||
} else {
|
||||
setSidebarCollapsed((v) => !v);
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={{ onToggleSidebar: handleToggleSidebar, mobileSidebarOpen }}>
|
||||
<div className="dashboard-app-shell">
|
||||
<div
|
||||
className={`dashboard-shell--desktop${sidebarCollapsed ? " dashboard-shell--sidebar-collapsed" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`sidebar-wrapper${mobileSidebarOpen ? " sidebar-wrapper--mobile-open" : ""}`}
|
||||
>
|
||||
<ProjectSidebar
|
||||
projects={initialProjects}
|
||||
sessions={sessions}
|
||||
orchestrators={initialOrchestrators}
|
||||
activeProjectId={projectId}
|
||||
activeSessionId={activeSessionId}
|
||||
loading={!liveSessionsResolved}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggleCollapsed={() => setSidebarCollapsed((v) => !v)}
|
||||
onMobileClose={() => setMobileSidebarOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
{mobileSidebarOpen && (
|
||||
<div className="sidebar-mobile-backdrop" onClick={() => setMobileSidebarOpen(false)} />
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
@ -1 +1,459 @@
|
|||
export { default } from "../../../../sessions/[id]/page";
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import { isOrchestratorSession } from "@aoagents/ao-core/types";
|
||||
import { SessionDetail } from "@/components/SessionDetail";
|
||||
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||
import {
|
||||
type DashboardSession,
|
||||
type ActivityState,
|
||||
getAttentionLevel,
|
||||
} from "@/lib/types";
|
||||
import { activityIcon } from "@/lib/activity-icons";
|
||||
import type { ProjectInfo } from "@/lib/project-name";
|
||||
import { getSessionTitle } from "@/lib/format";
|
||||
import { useMuxSessionActivity } from "@/hooks/useMuxSessionActivity";
|
||||
import { projectSessionPath } from "@/lib/routes";
|
||||
import { fetchJsonWithTimeout } from "@/lib/client-fetch";
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
const codePoints = Array.from(s);
|
||||
return codePoints.length > max ? codePoints.slice(0, max).join("") + "..." : s;
|
||||
}
|
||||
|
||||
function buildSessionTitle(
|
||||
session: DashboardSession,
|
||||
prefixByProject: Map<string, string>,
|
||||
activityOverride?: ActivityState | null,
|
||||
): string {
|
||||
const id = session.id;
|
||||
const activity = activityOverride !== undefined ? activityOverride : session.activity;
|
||||
const emoji = activity ? (activityIcon[activity] ?? "") : "";
|
||||
const allPrefixes = [...prefixByProject.values()];
|
||||
const isOrchestrator = isOrchestratorSession(
|
||||
session,
|
||||
prefixByProject.get(session.projectId),
|
||||
allPrefixes,
|
||||
);
|
||||
const detail = isOrchestrator ? "Orchestrator Terminal" : truncate(getSessionTitle(session), 40);
|
||||
return emoji ? `${emoji} ${id} | ${detail}` : `${id} | ${detail}`;
|
||||
}
|
||||
|
||||
interface ZoneCounts {
|
||||
merge: number;
|
||||
respond: number;
|
||||
review: number;
|
||||
pending: number;
|
||||
working: number;
|
||||
done: number;
|
||||
}
|
||||
|
||||
interface ProjectSessionsBody {
|
||||
sessions?: DashboardSession[];
|
||||
orchestratorId?: string | null;
|
||||
orchestrators?: Array<{ id: string; projectId: string; projectName: string }>;
|
||||
}
|
||||
|
||||
let cachedProjects: ProjectInfo[] | null = null;
|
||||
|
||||
const SESSION_PAGE_REFRESH_INTERVAL_MS = 2000;
|
||||
const SESSION_FETCH_TIMEOUT_MS = 8000;
|
||||
const PROJECT_SESSIONS_FETCH_TIMEOUT_MS = 5000;
|
||||
const PROJECTS_FETCH_TIMEOUT_MS = 5000;
|
||||
function areProjectsEqual(previous: ProjectInfo[] | null, next: ProjectInfo[]): boolean {
|
||||
if (!previous || previous.length !== next.length) return false;
|
||||
return previous.every((p, i) => JSON.stringify(p) === JSON.stringify(next[i]));
|
||||
}
|
||||
|
||||
function isAbortLikeError(error: unknown): boolean {
|
||||
if (error instanceof DOMException) return error.name === "AbortError";
|
||||
if (error instanceof Error) {
|
||||
const msg = error.message.toLowerCase();
|
||||
return msg.includes("aborted") || msg.includes("aborterror");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getSessionLoadErrorMessage(error: Error): string {
|
||||
const normalized = error.message.toLowerCase();
|
||||
if (normalized.includes("timed out"))
|
||||
return "The session request is taking too long. You can retry, or return to the project and reopen a different session.";
|
||||
if (normalized.includes("network"))
|
||||
return "The session request failed before the dashboard got a response. Check the local server connection and try again.";
|
||||
if (normalized.includes("404"))
|
||||
return "This session is no longer available. It may have been removed while the page was open.";
|
||||
if (normalized.includes("500"))
|
||||
return "The server returned an internal error while loading this session. Try again to re-fetch the latest state.";
|
||||
return "The dashboard could not load this session cleanly. Try again to re-fetch the latest state.";
|
||||
}
|
||||
|
||||
function LoadingContent() {
|
||||
return (
|
||||
<div className="flex h-full min-h-screen items-center justify-center bg-[var(--color-bg-base)]">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-[var(--color-text-tertiary)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 3a9 9 0 1 0 9 9" />
|
||||
</svg>
|
||||
<div className="text-[13px] text-[var(--color-text-tertiary)]">Loading session…</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProjectSessionPage() {
|
||||
const params = useParams();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const id = params.id as string;
|
||||
const expectedProjectId = typeof params.projectId === "string" ? params.projectId : undefined;
|
||||
|
||||
// Read optimistic session data written by sidebar navigation
|
||||
const cachedSession = (() => {
|
||||
if (typeof sessionStorage === "undefined") return null;
|
||||
try {
|
||||
const raw = sessionStorage.getItem(`ao-session-nav:${id}`);
|
||||
if (raw) {
|
||||
sessionStorage.removeItem(`ao-session-nav:${id}`);
|
||||
return JSON.parse(raw) as DashboardSession;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
const [session, setSession] = useState<DashboardSession | null>(cachedSession);
|
||||
const [zoneCounts, setZoneCounts] = useState<ZoneCounts | null>(null);
|
||||
const [projectOrchestratorId, setProjectOrchestratorId] = useState<string | null | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [projects, setProjects] = useState<ProjectInfo[]>([]);
|
||||
const [loading, setLoading] = useState(cachedSession === null);
|
||||
const [routeError, setRouteError] = useState<Error | null>(null);
|
||||
const [sessionMissing, setSessionMissing] = useState(false);
|
||||
const [prefixByProject, setPrefixByProject] = useState<Map<string, string>>(new Map());
|
||||
|
||||
const sessionProjectId = session?.projectId ?? null;
|
||||
const allPrefixes = [...prefixByProject.values()];
|
||||
const sessionIsOrchestrator = session
|
||||
? isOrchestratorSession(session, prefixByProject.get(session.projectId), allPrefixes)
|
||||
: false;
|
||||
|
||||
const sessionProjectIdRef = useRef<string | null>(null);
|
||||
const sessionIsOrchestratorRef = useRef(false);
|
||||
const resolvedProjectSessionsKeyRef = useRef<string | null>(null);
|
||||
const prefixByProjectRef = useRef<Map<string, string>>(new Map());
|
||||
const hasLoadedSessionRef = useRef(cachedSession !== null);
|
||||
const fetchingSessionRef = useRef(false);
|
||||
const fetchingProjectSessionsRef = useRef(false);
|
||||
const sessionFetchControllerRef = useRef<AbortController | null>(null);
|
||||
const projectSessionsFetchControllerRef = useRef<AbortController | null>(null);
|
||||
const pageUnloadingRef = useRef(false);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const sseActivity = useMuxSessionActivity(id);
|
||||
|
||||
useEffect(() => {
|
||||
prefixByProjectRef.current = prefixByProject;
|
||||
}, [prefixByProject]);
|
||||
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
document.title = buildSessionTitle(session, prefixByProject, sseActivity?.activity);
|
||||
} else {
|
||||
document.title = `${id} | Session Detail`;
|
||||
}
|
||||
}, [session, id, prefixByProject, sseActivity]);
|
||||
|
||||
useEffect(() => {
|
||||
sessionProjectIdRef.current = sessionProjectId;
|
||||
}, [sessionProjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
sessionIsOrchestratorRef.current = sessionIsOrchestrator;
|
||||
}, [sessionIsOrchestrator]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session || !projects.some((p) => p.id === session.projectId)) return;
|
||||
if (
|
||||
pathname?.startsWith("/projects/") &&
|
||||
expectedProjectId &&
|
||||
session.projectId !== expectedProjectId
|
||||
) {
|
||||
router.replace(projectSessionPath(session.projectId, session.id));
|
||||
}
|
||||
}, [expectedProjectId, pathname, projects, router, session]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionIsOrchestrator) setZoneCounts(null);
|
||||
}, [sessionIsOrchestrator]);
|
||||
|
||||
const fetchProjects = useCallback(async () => {
|
||||
if (cachedProjects) {
|
||||
setProjects(cachedProjects);
|
||||
setPrefixByProject(new Map(cachedProjects.map((p) => [p.id, p.sessionPrefix ?? p.id])));
|
||||
}
|
||||
try {
|
||||
const data = await fetchJsonWithTimeout<{ projects?: ProjectInfo[] } | null>(
|
||||
"/api/projects",
|
||||
{
|
||||
timeoutMs: PROJECTS_FETCH_TIMEOUT_MS,
|
||||
timeoutMessage: `Projects request timed out after ${PROJECTS_FETCH_TIMEOUT_MS}ms`,
|
||||
},
|
||||
);
|
||||
if (!data?.projects) return;
|
||||
if (!areProjectsEqual(cachedProjects, data.projects)) {
|
||||
cachedProjects = data.projects;
|
||||
setProjects(data.projects);
|
||||
setPrefixByProject(new Map(data.projects.map((p) => [p.id, p.sessionPrefix ?? p.id])));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch projects:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchSession = useCallback(async () => {
|
||||
if (fetchingSessionRef.current) return;
|
||||
fetchingSessionRef.current = true;
|
||||
const controller = new AbortController();
|
||||
sessionFetchControllerRef.current = controller;
|
||||
try {
|
||||
const data = await fetchJsonWithTimeout<DashboardSession | { error: string }>(
|
||||
`/api/sessions/${encodeURIComponent(id)}`,
|
||||
{
|
||||
signal: controller.signal,
|
||||
timeoutMs: SESSION_FETCH_TIMEOUT_MS,
|
||||
timeoutMessage: `Session request timed out after ${SESSION_FETCH_TIMEOUT_MS}ms`,
|
||||
},
|
||||
);
|
||||
setSession(data as DashboardSession);
|
||||
setRouteError(null);
|
||||
setSessionMissing(false);
|
||||
hasLoadedSessionRef.current = true;
|
||||
} catch (err) {
|
||||
if (pageUnloadingRef.current || controller.signal.aborted || isAbortLikeError(err)) return;
|
||||
const message = err instanceof Error ? err.message : "Failed to load session";
|
||||
const normalized = message.toLowerCase();
|
||||
if (normalized.includes("session not found") || normalized.includes("http 404")) {
|
||||
if (!hasLoadedSessionRef.current) setSessionMissing(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
console.error("Failed to fetch session:", err);
|
||||
if (!hasLoadedSessionRef.current) {
|
||||
setRouteError(err instanceof Error ? err : new Error("Failed to load session"));
|
||||
}
|
||||
} finally {
|
||||
fetchingSessionRef.current = false;
|
||||
if (sessionFetchControllerRef.current === controller)
|
||||
sessionFetchControllerRef.current = null;
|
||||
if (!controller.signal.aborted || hasLoadedSessionRef.current) {
|
||||
setLoading(false);
|
||||
} else if (mountedRef.current) {
|
||||
// Aborted before any session was loaded and the component is still
|
||||
// mounted — React Strict Mode fired the cleanup between mount 1 and
|
||||
// mount 2, aborting the first fetch. Mount 2's fetchSession() was
|
||||
// blocked by fetchingSessionRef (not yet reset). Retry immediately
|
||||
// now that the ref is clear. mountedRef guards against the navigation-
|
||||
// away case where the component is genuinely unmounted and we should
|
||||
// not start a new request.
|
||||
void fetchSession();
|
||||
}
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const fetchProjectSessions = useCallback(async () => {
|
||||
if (fetchingProjectSessionsRef.current) return;
|
||||
const projectId = sessionProjectIdRef.current;
|
||||
if (!projectId) return;
|
||||
const isOrchestrator = sessionIsOrchestratorRef.current;
|
||||
const projectSessionsKey = `${projectId}:${isOrchestrator ? "orchestrator" : "worker"}`;
|
||||
if (!isOrchestrator && resolvedProjectSessionsKeyRef.current === projectSessionsKey) return;
|
||||
fetchingProjectSessionsRef.current = true;
|
||||
const controller = new AbortController();
|
||||
projectSessionsFetchControllerRef.current = controller;
|
||||
try {
|
||||
const query = isOrchestrator
|
||||
? `/api/sessions?project=${encodeURIComponent(projectId)}&fresh=true`
|
||||
: `/api/sessions?project=${encodeURIComponent(projectId)}&orchestratorOnly=true&fresh=true`;
|
||||
const body = await fetchJsonWithTimeout<ProjectSessionsBody>(query, {
|
||||
signal: controller.signal,
|
||||
timeoutMs: PROJECT_SESSIONS_FETCH_TIMEOUT_MS,
|
||||
timeoutMessage: `Project sessions request timed out after ${PROJECT_SESSIONS_FETCH_TIMEOUT_MS}ms`,
|
||||
});
|
||||
const sessions = body.sessions ?? [];
|
||||
const orchestratorId =
|
||||
body.orchestratorId ??
|
||||
body.orchestrators?.find((o) => o.projectId === projectId)?.id ??
|
||||
null;
|
||||
setProjectOrchestratorId((current) =>
|
||||
current === orchestratorId ? current : orchestratorId,
|
||||
);
|
||||
|
||||
if (!isOrchestrator) {
|
||||
resolvedProjectSessionsKeyRef.current = projectSessionsKey;
|
||||
return;
|
||||
}
|
||||
|
||||
const counts: ZoneCounts = {
|
||||
merge: 0,
|
||||
respond: 0,
|
||||
review: 0,
|
||||
pending: 0,
|
||||
working: 0,
|
||||
done: 0,
|
||||
};
|
||||
const allPfxs = [...prefixByProjectRef.current.values()];
|
||||
for (const s of sessions) {
|
||||
if (!isOrchestratorSession(s, prefixByProjectRef.current.get(s.projectId), allPfxs)) {
|
||||
const level = getAttentionLevel(s);
|
||||
if (level === "action") continue;
|
||||
counts[level]++;
|
||||
}
|
||||
}
|
||||
setZoneCounts(counts);
|
||||
} catch (err) {
|
||||
if (pageUnloadingRef.current || controller.signal.aborted || isAbortLikeError(err)) return;
|
||||
console.error("Failed to fetch project sessions:", err);
|
||||
} finally {
|
||||
fetchingProjectSessionsRef.current = false;
|
||||
if (projectSessionsFetchControllerRef.current === controller)
|
||||
projectSessionsFetchControllerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void Promise.all([fetchProjects(), fetchSession()]);
|
||||
}, [fetchProjects, fetchSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionProjectId) return;
|
||||
void fetchProjectSessions();
|
||||
}, [fetchProjectSessions, sessionIsOrchestrator, sessionProjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
void fetchSession();
|
||||
void fetchProjectSessions();
|
||||
}, SESSION_PAGE_REFRESH_INTERVAL_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchSession, fetchProjectSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
pageUnloadingRef.current = false;
|
||||
const mark = () => {
|
||||
pageUnloadingRef.current = true;
|
||||
};
|
||||
window.addEventListener("pagehide", mark);
|
||||
window.addEventListener("beforeunload", mark);
|
||||
return () => {
|
||||
window.removeEventListener("pagehide", mark);
|
||||
window.removeEventListener("beforeunload", mark);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
sessionFetchControllerRef.current?.abort();
|
||||
projectSessionsFetchControllerRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (loading) return <div className="dashboard-main--desktop"><LoadingContent /></div>;
|
||||
|
||||
if (sessionMissing) {
|
||||
return (
|
||||
<div className="dashboard-main--desktop">
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<ErrorDisplay
|
||||
title="Session not found"
|
||||
message="This session is no longer available. It may have been removed, renamed, or already cleaned up."
|
||||
tone="not-found"
|
||||
primaryAction={{
|
||||
label: "Back to dashboard",
|
||||
href: expectedProjectId ? `/projects/${expectedProjectId}` : "/",
|
||||
}}
|
||||
secondaryAction={{ label: "Retry", onClick: () => void fetchSession() }}
|
||||
compact
|
||||
chrome="card"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (routeError) {
|
||||
return (
|
||||
<div className="dashboard-main--desktop">
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<ErrorDisplay
|
||||
title="Failed to load session"
|
||||
message={getSessionLoadErrorMessage(routeError)}
|
||||
tone="error"
|
||||
primaryAction={{
|
||||
label: "Try again",
|
||||
onClick: () => {
|
||||
setRouteError(null);
|
||||
setSessionMissing(false);
|
||||
setLoading(true);
|
||||
void Promise.all([fetchProjects(), fetchSession()]);
|
||||
},
|
||||
}}
|
||||
secondaryAction={{
|
||||
label: "Back to dashboard",
|
||||
href: session?.projectId ? `/projects/${session.projectId}` : "/",
|
||||
}}
|
||||
error={routeError}
|
||||
compact
|
||||
chrome="card"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="dashboard-main--desktop">
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<ErrorDisplay
|
||||
title="Session unavailable"
|
||||
message="The backend has not returned this session yet. This can happen right after spawning an orchestrator; retry once the terminal registers the session."
|
||||
tone="error"
|
||||
primaryAction={{ label: "Retry", onClick: () => void fetchSession() }}
|
||||
secondaryAction={{
|
||||
label: "Back to dashboard",
|
||||
href: expectedProjectId ? `/projects/${expectedProjectId}` : "/",
|
||||
}}
|
||||
compact
|
||||
chrome="card"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionDetail
|
||||
session={session}
|
||||
isOrchestrator={sessionIsOrchestrator}
|
||||
orchestratorZones={zoneCounts ?? undefined}
|
||||
projectOrchestratorId={projectOrchestratorId}
|
||||
projects={projects}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
import { getDashboardPageData } from "@/lib/dashboard-page-data";
|
||||
import { ProjectLayoutClient } from "./[projectId]/project-layout-client";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ProjectLayout({ children }: { children: ReactNode }) {
|
||||
const pageData = await getDashboardPageData("all");
|
||||
|
||||
return (
|
||||
<ProjectLayoutClient
|
||||
initialSessions={pageData.sessions}
|
||||
initialProjects={pageData.projects}
|
||||
initialOrchestrators={pageData.orchestrators}
|
||||
>
|
||||
{children}
|
||||
</ProjectLayoutClient>
|
||||
);
|
||||
}
|
||||
|
|
@ -454,77 +454,6 @@ describe("SessionPage project polling", () => {
|
|||
expect(screen.getAllByText("Failed to load session").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("marks sidebar data as loading until the sessions list resolves", async () => {
|
||||
const workerSession = makeWorkerSession();
|
||||
let resolveSidebarSessions: ((value: Response) => void) | null = null;
|
||||
|
||||
global.fetch = vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/projects") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
projects: [{ id: "my-app", name: "My App", sessionPrefix: "my-app" }],
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
|
||||
if (url === "/api/sessions/worker-1") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => workerSession,
|
||||
} as Response);
|
||||
}
|
||||
|
||||
if (url === "/api/sessions?fresh=true") {
|
||||
return new Promise<Response>((resolve) => {
|
||||
resolveSidebarSessions = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
if (url === "/api/sessions?project=my-app&orchestratorOnly=true&fresh=true") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ orchestratorId: "my-app-orchestrator" }),
|
||||
} as Response);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
|
||||
}) as typeof fetch;
|
||||
|
||||
const { default: SessionPage } = await import("./page");
|
||||
|
||||
render(<SessionPage />);
|
||||
await flushAsyncWork();
|
||||
|
||||
const latestBeforeSidebarResolve = sessionDetailSpy.mock.lastCall?.[0] as {
|
||||
sidebarLoading?: boolean;
|
||||
sidebarSessions?: DashboardSession[] | null;
|
||||
};
|
||||
|
||||
expect(latestBeforeSidebarResolve.sidebarLoading).toBe(true);
|
||||
expect(latestBeforeSidebarResolve.sidebarSessions).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
resolveSidebarSessions?.({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ sessions: [workerSession] }),
|
||||
} as Response);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const latestAfterSidebarResolve = sessionDetailSpy.mock.lastCall?.[0] as {
|
||||
sidebarLoading?: boolean;
|
||||
sidebarSessions?: DashboardSession[] | null;
|
||||
};
|
||||
|
||||
expect(latestAfterSidebarResolve.sidebarLoading).toBe(false);
|
||||
expect(latestAfterSidebarResolve.sidebarSessions).toEqual([workerSession]);
|
||||
});
|
||||
|
||||
it("revalidates projects and sidebar sessions on remount even when cache exists", async () => {
|
||||
const workerSession = makeWorkerSession();
|
||||
|
|
@ -642,145 +571,7 @@ describe("SessionPage project polling", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("surfaces sidebar fetch failures instead of leaving the loading skeleton active", async () => {
|
||||
const workerSession = makeWorkerSession();
|
||||
|
||||
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/projects") {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
projects: [{ id: "my-app", name: "My App", sessionPrefix: "my-app" }],
|
||||
}),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
if (url === "/api/sessions/worker-1") {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => workerSession,
|
||||
} as Response;
|
||||
}
|
||||
|
||||
if (url === "/api/sessions?fresh=true") {
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({}),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
if (url === "/api/sessions?project=my-app&orchestratorOnly=true&fresh=true") {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ orchestratorId: "my-app-orchestrator" }),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
const { default: SessionPage } = await import("./page");
|
||||
|
||||
render(<SessionPage />);
|
||||
await flushAsyncWork();
|
||||
|
||||
const latestProps = sessionDetailSpy.mock.lastCall?.[0] as {
|
||||
sidebarError?: boolean;
|
||||
sidebarLoading?: boolean;
|
||||
sidebarSessions?: DashboardSession[] | null;
|
||||
};
|
||||
|
||||
expect(latestProps.sidebarLoading).toBe(false);
|
||||
expect(latestProps.sidebarError).toBe(true);
|
||||
expect(latestProps.sidebarSessions).toEqual([]);
|
||||
});
|
||||
|
||||
it("applies mux snapshots that arrive before the initial sidebar fetch resolves", async () => {
|
||||
const workerSession = makeWorkerSession();
|
||||
const muxPatchedLastActivityAt = "2026-04-14T12:00:00.000Z";
|
||||
let resolveSidebarSessions: ((value: Response) => void) | null = null;
|
||||
|
||||
mockMuxState.current = {
|
||||
status: "connected",
|
||||
sessions: [
|
||||
{
|
||||
id: "worker-1",
|
||||
status: "working",
|
||||
activity: "ready",
|
||||
attentionLevel: "pending",
|
||||
lastActivityAt: muxPatchedLastActivityAt,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
global.fetch = vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/projects") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
projects: [{ id: "my-app", name: "My App", sessionPrefix: "my-app" }],
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
|
||||
if (url === "/api/sessions/worker-1") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => workerSession,
|
||||
} as Response);
|
||||
}
|
||||
|
||||
if (url === "/api/sessions?fresh=true") {
|
||||
return new Promise<Response>((resolve) => {
|
||||
resolveSidebarSessions = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
if (url === "/api/sessions?project=my-app&orchestratorOnly=true&fresh=true") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ orchestratorId: "my-app-orchestrator" }),
|
||||
} as Response);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
|
||||
}) as typeof fetch;
|
||||
|
||||
const { default: SessionPage } = await import("./page");
|
||||
|
||||
render(<SessionPage />);
|
||||
await flushAsyncWork();
|
||||
|
||||
await act(async () => {
|
||||
resolveSidebarSessions?.({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ sessions: [workerSession] }),
|
||||
} as Response);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const latestProps = sessionDetailSpy.mock.lastCall?.[0] as {
|
||||
sidebarSessions?: DashboardSession[] | null;
|
||||
};
|
||||
|
||||
expect(latestProps.sidebarSessions).toEqual([
|
||||
{
|
||||
...workerSession,
|
||||
activity: "ready",
|
||||
lastActivityAt: muxPatchedLastActivityAt,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("redirects the legacy session URL to the project-scoped route for clean projects", async () => {
|
||||
mockPathname = "/sessions/worker-1";
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,9 +108,10 @@ export function CopyDebugBundleButton({ projectId }: CopyDebugBundleButtonProps)
|
|||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="orchestrator-btn flex min-h-[44px] min-w-[44px] items-center gap-2 px-4 py-2 text-[12px] font-semibold text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-bg-hover)] disabled:opacity-50"
|
||||
className="dashboard-app-btn dashboard-app-btn--icon"
|
||||
onClick={() => void handleClick()}
|
||||
disabled={busy}
|
||||
title="Copy debug bundle"
|
||||
aria-label="Copy debug bundle for issue reports"
|
||||
>
|
||||
<svg
|
||||
|
|
@ -125,7 +126,6 @@ export function CopyDebugBundleButton({ projectId }: CopyDebugBundleButtonProps)
|
|||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
Copy debug info
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,14 +18,15 @@ import { AttentionZone } from "./AttentionZone";
|
|||
import { DynamicFavicon, countNeedingAttention } from "./DynamicFavicon";
|
||||
import { useSessionEvents } from "@/hooks/useSessionEvents";
|
||||
import { useMuxOptional } from "@/providers/MuxProvider";
|
||||
import { ProjectSidebar } from "./ProjectSidebar";
|
||||
import type { ProjectInfo } from "@/lib/project-name";
|
||||
import { EmptyState } from "./Skeleton";
|
||||
import { ToastProvider, useToast } from "./Toast";
|
||||
import { ConnectionBar } from "./ConnectionBar";
|
||||
import { UpdateBanner } from "./UpdateBanner";
|
||||
import { CopyDebugBundleButton } from "./CopyDebugBundleButton";
|
||||
import { SidebarContext } from "./workspace/SidebarContext";
|
||||
import { SidebarContext, useSidebarContext } from "./workspace/SidebarContext";
|
||||
import { ProjectSidebar } from "./ProjectSidebar";
|
||||
import { isOrchestratorSession } from "@aoagents/ao-core/types";
|
||||
import { projectDashboardPath, projectSessionPath } from "@/lib/routes";
|
||||
import { BottomSheet } from "./BottomSheet";
|
||||
|
||||
|
|
@ -175,6 +176,25 @@ function DashboardInner({
|
|||
if (!projectId) return sessions;
|
||||
return sessions.filter((s) => s.projectId === projectId);
|
||||
}, [sessions, projectId]);
|
||||
|
||||
const allSessionPrefixes = useMemo(
|
||||
() => projects.map((p) => p.sessionPrefix ?? p.id),
|
||||
[projects],
|
||||
);
|
||||
|
||||
const sidebarOrchestrators = useMemo(
|
||||
() =>
|
||||
sessions
|
||||
.filter((s) =>
|
||||
isOrchestratorSession(
|
||||
s,
|
||||
projects.find((p) => p.id === s.projectId)?.sessionPrefix ?? s.projectId,
|
||||
allSessionPrefixes,
|
||||
),
|
||||
)
|
||||
.map((s) => ({ id: s.id, projectId: s.projectId })),
|
||||
[sessions, projects, allSessionPrefixes],
|
||||
);
|
||||
const connectionStatus: "connected" | "reconnecting" | "disconnected" =
|
||||
mux?.status === "disconnected"
|
||||
? "disconnected"
|
||||
|
|
@ -195,9 +215,20 @@ function DashboardInner({
|
|||
useState<DashboardOrchestratorLink[]>(orchestratorLinks);
|
||||
const [spawningProjectIds, setSpawningProjectIds] = useState<string[]>([]);
|
||||
const [spawnErrors, setSpawnErrors] = useState<Record<string, string>>({});
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const isMobile = useMediaQuery(MOBILE_BREAKPOINT);
|
||||
// Detect if a parent layout already owns the sidebar — if so, skip rendering our own.
|
||||
const parentCtx = useSidebarContext();
|
||||
const isInsideLayout = parentCtx !== null;
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
const handleToggleSidebar = useCallback(() => {
|
||||
if (isInsideLayout && parentCtx) { parentCtx.onToggleSidebar(); return; }
|
||||
if (isMobile) {
|
||||
setMobileSidebarOpen((v) => !v);
|
||||
} else {
|
||||
setSidebarCollapsed((v) => !v);
|
||||
}
|
||||
}, [isMobile, isInsideLayout, parentCtx]);
|
||||
const [collapsedZones, setCollapsedZones] = useState<Set<AttentionLevel>>(
|
||||
() => new Set<AttentionLevel>(["done", "working"]),
|
||||
);
|
||||
|
|
@ -250,10 +281,6 @@ function DashboardInner({
|
|||
document.title = needsAttention > 0 ? `${label} (${needsAttention} need attention)` : label;
|
||||
}, [attentionLevels, projectName]);
|
||||
|
||||
useEffect(() => {
|
||||
setMobileMenuOpen(false);
|
||||
}, [searchParams]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const zones: Record<AttentionLevel, DashboardSession[]> = {
|
||||
merge: [],
|
||||
|
|
@ -516,292 +543,336 @@ function DashboardInner({
|
|||
return next;
|
||||
});
|
||||
};
|
||||
const handleToggleSidebar = () => {
|
||||
if (typeof window !== "undefined" && window.innerWidth < 768) {
|
||||
setMobileMenuOpen((current) => !current);
|
||||
} else {
|
||||
setSidebarCollapsed((current) => !current);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider
|
||||
value={{ onToggleSidebar: handleToggleSidebar, mobileSidebarOpen: mobileMenuOpen }}
|
||||
>
|
||||
<>
|
||||
<UpdateBanner />
|
||||
<ConnectionBar status={connectionStatus} />
|
||||
<div className="dashboard-app-shell">
|
||||
<header className="dashboard-app-header">
|
||||
<button
|
||||
type="button"
|
||||
className="dashboard-app-sidebar-toggle"
|
||||
onClick={handleToggleSidebar}
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
{isMobile ? (
|
||||
const mainPanel = (
|
||||
<div className="dashboard-main--desktop">
|
||||
<header className="dashboard-app-header">
|
||||
<button
|
||||
type="button"
|
||||
className="dashboard-app-sidebar-toggle"
|
||||
onClick={handleToggleSidebar}
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
{isMobile ? (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<path d="M9 3v18" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<div className="dashboard-app-header__brand dashboard-app-header__brand--hide-mobile">
|
||||
<span>Agent Orchestrator</span>
|
||||
</div>
|
||||
{showHeaderProjectLabel ? (
|
||||
<>
|
||||
<span className="dashboard-app-header__sep topbar-desktop-only" aria-hidden="true" />
|
||||
<div className="topbar-project-pills-group">
|
||||
<div className="topbar-project-line">
|
||||
<span className="dashboard-app-header__project">{headerProjectLabel}</span>
|
||||
</div>
|
||||
{!allProjectsView && projectSessions.length > 0 ? (
|
||||
<div className="topbar-session-pills">
|
||||
{grouped.working.length > 0 ? (
|
||||
<div className="topbar-status-pill topbar-status-pill--active">
|
||||
<span className="topbar-status-pill__dot topbar-status-pill__dot--working" />
|
||||
<span className="topbar-status-pill__label">
|
||||
{grouped.working.length} working
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{grouped.merge.length +
|
||||
grouped.action.length +
|
||||
grouped.respond.length +
|
||||
grouped.review.length >
|
||||
0 ? (
|
||||
<div className="topbar-status-pill topbar-status-pill--waiting-for-input">
|
||||
<span className="topbar-status-pill__dot topbar-status-pill__dot--attention" />
|
||||
<span className="topbar-status-pill__label">
|
||||
{grouped.merge.length +
|
||||
grouped.action.length +
|
||||
grouped.respond.length +
|
||||
grouped.review.length}{" "}
|
||||
need attention
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className="dashboard-app-header__spacer" />
|
||||
<div className="dashboard-app-header__actions">
|
||||
{showDebugBundleButton ? <CopyDebugBundleButton projectId={projectId} /> : null}
|
||||
{!allProjectsView && orchestratorHref ? (
|
||||
<Link
|
||||
href={orchestratorHref}
|
||||
className="dashboard-app-btn dashboard-app-btn--amber"
|
||||
aria-label="Orchestrator"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
width="12"
|
||||
height="12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="5" r="2" fill="currentColor" stroke="none" />
|
||||
<path d="M12 7v4M12 11H6M12 11h6M6 11v3M12 11v3M18 11v3" />
|
||||
<circle cx="6" cy="17" r="2" />
|
||||
<circle cx="12" cy="17" r="2" />
|
||||
<circle cx="18" cy="17" r="2" />
|
||||
</svg>
|
||||
Orchestrator
|
||||
</Link>
|
||||
) : canSpawnProjectOrchestrator && activeProject ? (
|
||||
<button
|
||||
type="button"
|
||||
className="dashboard-app-btn dashboard-app-btn--amber"
|
||||
aria-label="Spawn Orchestrator"
|
||||
onClick={() => void handleSpawnOrchestrator(activeProject)}
|
||||
disabled={isSpawningCurrentProject}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="5" r="2" fill="currentColor" stroke="none" />
|
||||
<path d="M12 7v4M12 11H6M12 11h6M6 11v3M12 11v3M18 11v3" />
|
||||
<circle cx="6" cy="17" r="2" />
|
||||
<circle cx="12" cy="17" r="2" />
|
||||
<circle cx="18" cy="17" r="2" />
|
||||
</svg>
|
||||
{isSpawningCurrentProject ? "Spawning..." : "Spawn Orchestrator"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="dashboard-main flex-1 min-h-0 overflow-hidden">
|
||||
<DynamicFavicon attentionLevels={attentionLevels} projectName={projectName} />
|
||||
<div className="dashboard-main__subhead">
|
||||
<h1 className="dashboard-main__title">Dashboard</h1>
|
||||
<p className="dashboard-main__subtitle">
|
||||
Live agent sessions, pull requests, and merge status.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-main__body">
|
||||
{loadErrorBanner}
|
||||
{anyRateLimited && !rateLimitDismissed && (
|
||||
<div className="dashboard-alert mb-4 flex items-center gap-2.5 border border-[color-mix(in_srgb,var(--color-status-attention)_25%,transparent)] bg-[var(--color-tint-yellow)] px-3.5 py-2.5 text-[11px] text-[var(--color-status-attention)]">
|
||||
<svg
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4M12 16h.01" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<path d="M9 3v18" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<div className="dashboard-app-header__brand">
|
||||
<span className="dashboard-app-header__brand-dot" aria-hidden="true" />
|
||||
<span>Agent Orchestrator</span>
|
||||
</div>
|
||||
{showHeaderProjectLabel ? (
|
||||
<>
|
||||
<span className="dashboard-app-header__sep" aria-hidden="true" />
|
||||
<span className="dashboard-app-header__project">{headerProjectLabel}</span>
|
||||
</>
|
||||
) : null}
|
||||
{showDebugBundleButton ? <CopyDebugBundleButton projectId={projectId} /> : null}
|
||||
<div className="dashboard-app-header__spacer" />
|
||||
<div className="dashboard-app-header__actions">
|
||||
{!allProjectsView && orchestratorHref ? (
|
||||
<Link
|
||||
href={orchestratorHref}
|
||||
className="dashboard-app-btn dashboard-app-btn--amber"
|
||||
aria-label="Orchestrator"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="5" r="2" fill="currentColor" stroke="none" />
|
||||
<path d="M12 7v4M12 11H6M12 11h6M6 11v3M12 11v3M18 11v3" />
|
||||
<circle cx="6" cy="17" r="2" />
|
||||
<circle cx="12" cy="17" r="2" />
|
||||
<circle cx="18" cy="17" r="2" />
|
||||
</svg>
|
||||
Orchestrator
|
||||
</Link>
|
||||
) : canSpawnProjectOrchestrator && activeProject ? (
|
||||
<span className="flex-1">
|
||||
GitHub API rate limited — PR data (CI status, review state, sizes) may be stale.
|
||||
Will retry automatically on next refresh.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="dashboard-app-btn dashboard-app-btn--amber"
|
||||
aria-label="Spawn Orchestrator"
|
||||
onClick={() => void handleSpawnOrchestrator(activeProject)}
|
||||
disabled={isSpawningCurrentProject}
|
||||
onClick={() => setRateLimitDismissed(true)}
|
||||
className="ml-1 shrink-0 opacity-60 hover:opacity-100"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="5" r="2" fill="currentColor" stroke="none" />
|
||||
<path d="M12 7v4M12 11H6M12 11h6M6 11v3M12 11v3M18 11v3" />
|
||||
<circle cx="6" cy="17" r="2" />
|
||||
<circle cx="12" cy="17" r="2" />
|
||||
<circle cx="18" cy="17" r="2" />
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
{isSpawningCurrentProject ? "Spawning..." : "Spawn Orchestrator"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className={`dashboard-shell dashboard-shell--desktop${sidebarCollapsed ? " dashboard-shell--sidebar-collapsed" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`sidebar-wrapper${mobileMenuOpen ? " sidebar-wrapper--mobile-open" : ""}`}
|
||||
>
|
||||
<ProjectSidebar
|
||||
projects={projects}
|
||||
sessions={sessions}
|
||||
orchestrators={activeOrchestrators}
|
||||
activeProjectId={projectId}
|
||||
activeSessionId={activeSessionId}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggleCollapsed={() => setSidebarCollapsed((current) => !current)}
|
||||
onMobileClose={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
{mobileMenuOpen && (
|
||||
<div className="sidebar-mobile-backdrop" onClick={() => setMobileMenuOpen(false)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="dashboard-main dashboard-main--desktop">
|
||||
<DynamicFavicon attentionLevels={attentionLevels} projectName={projectName} />
|
||||
<div className="dashboard-main__subhead">
|
||||
<h1 className="dashboard-main__title">Dashboard</h1>
|
||||
<p className="dashboard-main__subtitle">
|
||||
Live agent sessions, pull requests, and merge status.
|
||||
</p>
|
||||
{allProjectsView && (
|
||||
<ProjectOverviewGrid
|
||||
overviews={projectOverviews}
|
||||
onSpawnOrchestrator={handleSpawnOrchestrator}
|
||||
spawningProjectIds={spawningProjectIds}
|
||||
spawnErrors={spawnErrors}
|
||||
attentionZones={attentionZones}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!allProjectsView && hasAnySessions && (
|
||||
<div className="kanban-board-wrap">
|
||||
<div
|
||||
className="kanban-board"
|
||||
data-columns={kanbanLevels.length}
|
||||
style={
|
||||
{
|
||||
"--kanban-column-count": kanbanLevels.length,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{kanbanLevels.map((level) => (
|
||||
<AttentionZone
|
||||
key={level}
|
||||
level={level}
|
||||
sessions={grouped[level]}
|
||||
onSend={handleSend}
|
||||
onKill={handleKill}
|
||||
onMerge={handleMerge}
|
||||
onRestore={handleRestore}
|
||||
compactMobile={isMobile}
|
||||
collapsed={isMobile && collapsedZones.has(level)}
|
||||
onToggle={isMobile ? handleZoneToggle : undefined}
|
||||
onPreview={isMobile ? handlePreview : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dashboard-main__body">
|
||||
{loadErrorBanner}
|
||||
{anyRateLimited && !rateLimitDismissed && (
|
||||
<div className="dashboard-alert mb-4 flex items-center gap-2.5 border border-[color-mix(in_srgb,var(--color-status-attention)_25%,transparent)] bg-[var(--color-tint-yellow)] px-3.5 py-2.5 text-[11px] text-[var(--color-status-attention)]">
|
||||
<svg
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4M12 16h.01" />
|
||||
</svg>
|
||||
<span className="flex-1">
|
||||
GitHub API rate limited — PR data (CI status, review state, sizes) may be
|
||||
stale. Will retry automatically on next refresh.
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setRateLimitDismissed(true)}
|
||||
className="ml-1 shrink-0 opacity-60 hover:opacity-100"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allProjectsView && (
|
||||
<ProjectOverviewGrid
|
||||
overviews={projectOverviews}
|
||||
onSpawnOrchestrator={handleSpawnOrchestrator}
|
||||
spawningProjectIds={spawningProjectIds}
|
||||
spawnErrors={spawnErrors}
|
||||
attentionZones={attentionZones}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!allProjectsView && hasAnySessions && (
|
||||
<div className="kanban-board-wrap">
|
||||
<div
|
||||
className="kanban-board"
|
||||
data-columns={kanbanLevels.length}
|
||||
style={
|
||||
{
|
||||
"--kanban-column-count": kanbanLevels.length,
|
||||
} as React.CSSProperties
|
||||
{showEmptyState ? (
|
||||
<EmptyState
|
||||
orchestratorHref={orchestratorHref}
|
||||
onSpawnOrchestrator={
|
||||
canSpawnProjectOrchestrator && activeProject
|
||||
? () => {
|
||||
void handleSpawnOrchestrator(activeProject);
|
||||
}
|
||||
>
|
||||
{kanbanLevels.map((level) => (
|
||||
<AttentionZone
|
||||
key={level}
|
||||
level={level}
|
||||
sessions={grouped[level]}
|
||||
onSend={handleSend}
|
||||
onKill={handleKill}
|
||||
onMerge={handleMerge}
|
||||
onRestore={handleRestore}
|
||||
compactMobile={isMobile}
|
||||
collapsed={isMobile && collapsedZones.has(level)}
|
||||
onToggle={isMobile ? handleZoneToggle : undefined}
|
||||
onPreview={isMobile ? handlePreview : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
: null
|
||||
}
|
||||
spawnLabel={isSpawningCurrentProject ? "Spawning..." : "Spawn Orchestrator"}
|
||||
spawnDisabled={isSpawningCurrentProject}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showEmptyState ? (
|
||||
<EmptyState
|
||||
orchestratorHref={orchestratorHref}
|
||||
onSpawnOrchestrator={
|
||||
canSpawnProjectOrchestrator && activeProject
|
||||
? () => {
|
||||
void handleSpawnOrchestrator(activeProject);
|
||||
}
|
||||
: null
|
||||
}
|
||||
spawnLabel={isSpawningCurrentProject ? "Spawning..." : "Spawn Orchestrator"}
|
||||
spawnDisabled={isSpawningCurrentProject}
|
||||
/>
|
||||
) : null}
|
||||
{!allProjectsView && currentProjectSpawnError ? (
|
||||
<p className="mt-3 text-[11px] text-[var(--color-status-error)]">
|
||||
{currentProjectSpawnError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!allProjectsView && currentProjectSpawnError ? (
|
||||
<p className="mt-3 text-[11px] text-[var(--color-status-error)]">
|
||||
{currentProjectSpawnError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!allProjectsView && grouped.done.length > 0 && (
|
||||
<div className="done-bar mt-6">
|
||||
<button
|
||||
type="button"
|
||||
className="done-bar__toggle"
|
||||
onClick={() => setDoneExpanded((v) => !v)}
|
||||
aria-expanded={doneExpanded}
|
||||
>
|
||||
<svg
|
||||
className={`done-bar__chevron${doneExpanded ? " done-bar__chevron--open" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
<span className="done-bar__label">Done / Terminated</span>
|
||||
<span className="done-bar__count">{grouped.done.length}</span>
|
||||
</button>
|
||||
{doneExpanded && (
|
||||
<div className="done-bar__cards">
|
||||
{grouped.done.map((session) => (
|
||||
<DoneCard key={session.id} session={session} onRestore={handleRestore} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!allProjectsView && grouped.done.length > 0 && (
|
||||
<div className="done-bar mt-6">
|
||||
<button
|
||||
type="button"
|
||||
className="done-bar__toggle"
|
||||
onClick={() => setDoneExpanded((v) => !v)}
|
||||
aria-expanded={doneExpanded}
|
||||
>
|
||||
<svg
|
||||
className={`done-bar__chevron${doneExpanded ? " done-bar__chevron--open" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
<span className="done-bar__label">Done / Terminated</span>
|
||||
<span className="done-bar__count">{grouped.done.length}</span>
|
||||
</button>
|
||||
{doneExpanded && (
|
||||
<div className="done-bar__cards">
|
||||
{grouped.done.map((session) => (
|
||||
<DoneCard key={session.id} session={session} onRestore={handleRestore} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<BottomSheet
|
||||
session={previewSession}
|
||||
mode={bottomSheetMode}
|
||||
onCancel={handleBottomSheetClose}
|
||||
onConfirm={handleBottomSheetConfirmKill}
|
||||
onRequestKill={handleRequestKill}
|
||||
onMerge={handleMerge}
|
||||
isMergeReady={previewSession ? attentionLevels[previewSession.id] === "merge" : false}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
const bottomSheet = (
|
||||
<BottomSheet
|
||||
session={previewSession}
|
||||
mode={bottomSheetMode}
|
||||
onCancel={handleBottomSheetClose}
|
||||
onConfirm={handleBottomSheetConfirmKill}
|
||||
onRequestKill={handleRequestKill}
|
||||
onMerge={handleMerge}
|
||||
isMergeReady={previewSession ? attentionLevels[previewSession.id] === "merge" : false}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isInsideLayout) {
|
||||
return (
|
||||
<>
|
||||
<UpdateBanner />
|
||||
<ConnectionBar status={connectionStatus} />
|
||||
{mainPanel}
|
||||
{bottomSheet}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={{ onToggleSidebar: handleToggleSidebar, mobileSidebarOpen }}>
|
||||
<UpdateBanner />
|
||||
<ConnectionBar status={connectionStatus} />
|
||||
<div className="dashboard-app-shell">
|
||||
<div
|
||||
className={`dashboard-shell--desktop${sidebarCollapsed ? " dashboard-shell--sidebar-collapsed" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`sidebar-wrapper${mobileSidebarOpen ? " sidebar-wrapper--mobile-open" : ""}`}
|
||||
>
|
||||
<ProjectSidebar
|
||||
projects={projects}
|
||||
sessions={sessions}
|
||||
orchestrators={sidebarOrchestrators}
|
||||
activeProjectId={projectId}
|
||||
activeSessionId={activeSessionId}
|
||||
loading={!liveSessionsResolved}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggleCollapsed={() => setSidebarCollapsed((v) => !v)}
|
||||
onMobileClose={() => setMobileSidebarOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
{mobileSidebarOpen && (
|
||||
<div
|
||||
className="sidebar-mobile-backdrop"
|
||||
onClick={() => setMobileSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{mainPanel}
|
||||
</div>
|
||||
</div>
|
||||
{bottomSheet}
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState, useEffect, useMemo, useRef } from "react";
|
||||
import { useState, useEffect, useMemo, useRef, useCallback, memo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { cn } from "@/lib/cn";
|
||||
import type { ProjectInfo } from "@/lib/project-name";
|
||||
import { getAttentionLevel, type DashboardSession, type AttentionLevel } from "@/lib/types";
|
||||
import { getAttentionLevel, type DashboardSession } from "@/lib/types";
|
||||
import { isOrchestratorSession } from "@aoagents/ao-core/types";
|
||||
import { getSessionTitle, humanizeBranch } from "@/lib/format";
|
||||
import { usePopoverClamp } from "@/hooks/usePopoverClamp";
|
||||
|
|
@ -42,7 +42,7 @@ interface ProjectSidebarProps {
|
|||
|
||||
type SessionDotLevel = "respond" | "review" | "action" | "pending" | "working" | "merge" | "done";
|
||||
|
||||
function SessionDot({ level }: { level: SessionDotLevel }) {
|
||||
const SessionDot = memo(function SessionDot({ level }: { level: SessionDotLevel }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -52,7 +52,7 @@ function SessionDot({ level }: { level: SessionDotLevel }) {
|
|||
data-level={level}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ProjectSidebar consumes `getAttentionLevel()` without passing a mode,
|
||||
// so the function defaults to "detailed" and `action` never appears here
|
||||
|
|
@ -70,15 +70,41 @@ function loadShowSessionId(): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
const LEVEL_LABELS: Record<AttentionLevel, string> = {
|
||||
working: "working",
|
||||
pending: "pending",
|
||||
review: "review",
|
||||
respond: "respond",
|
||||
action: "action",
|
||||
merge: "merge",
|
||||
done: "done",
|
||||
};
|
||||
const SHOW_KILLED_KEY = "ao:sidebar:show-killed";
|
||||
const SHOW_DONE_KEY = "ao:sidebar:show-done";
|
||||
const EXPANDED_PROJECTS_KEY = "ao:sidebar:expanded-projects";
|
||||
|
||||
function loadShowKilled(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.sessionStorage.getItem(SHOW_KILLED_KEY) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadShowDone(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.sessionStorage.getItem(SHOW_DONE_KEY) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadExpandedProjects(): Set<string> | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(EXPANDED_PROJECTS_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) return new Set<string>(parsed);
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function ProjectSidebar(props: ProjectSidebarProps) {
|
||||
if (props.projects.length === 0) {
|
||||
|
|
@ -87,6 +113,102 @@ export function ProjectSidebar(props: ProjectSidebarProps) {
|
|||
return <ProjectSidebarInner {...props} />;
|
||||
}
|
||||
|
||||
interface SessionRowProps {
|
||||
session: DashboardSession;
|
||||
level: SessionDotLevel;
|
||||
isActive: boolean;
|
||||
showSessionId: boolean;
|
||||
pendingRename: string | undefined;
|
||||
onNavigate: (href: string, session: DashboardSession) => void;
|
||||
onStartRename: (session: DashboardSession, title: string) => void;
|
||||
}
|
||||
|
||||
const SessionRow = memo(function SessionRow({
|
||||
session,
|
||||
level,
|
||||
isActive,
|
||||
showSessionId,
|
||||
pendingRename,
|
||||
onNavigate,
|
||||
onStartRename,
|
||||
}: SessionRowProps) {
|
||||
const effectiveDisplayName =
|
||||
pendingRename !== undefined
|
||||
? pendingRename
|
||||
: session.displayNameUserSet
|
||||
? (session.displayName ?? "")
|
||||
: "";
|
||||
const title =
|
||||
effectiveDisplayName !== ""
|
||||
? effectiveDisplayName
|
||||
: (session.branch ?? getSessionTitle(session));
|
||||
const sessionHref = projectSessionPath(session.projectId, session.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"project-sidebar__sess-row group",
|
||||
isActive && "project-sidebar__sess-row--active",
|
||||
)}
|
||||
>
|
||||
<a
|
||||
href={sessionHref}
|
||||
onClick={(e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
|
||||
e.preventDefault();
|
||||
onNavigate(sessionHref, session);
|
||||
}}
|
||||
className="project-sidebar__sess-link flex flex-1 min-w-0 items-center gap-[7px]"
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
aria-label={`Open ${title}`}
|
||||
>
|
||||
<SessionDot level={level} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
"project-sidebar__sess-label",
|
||||
isActive && "project-sidebar__sess-label--active",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{showSessionId ? (
|
||||
<div className="project-sidebar__sess-meta">
|
||||
<span className="project-sidebar__sess-id">{session.id}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onStartRename(session, title);
|
||||
}}
|
||||
className="project-sidebar__sess-rename-btn opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100"
|
||||
title="Rename session"
|
||||
aria-label={`Rename ${session.id}`}
|
||||
>
|
||||
<svg
|
||||
width="11"
|
||||
height="11"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function ProjectSidebarEmpty({ collapsed = false }: { collapsed?: boolean }) {
|
||||
const [addProjectOpen, setAddProjectOpen] = useState(false);
|
||||
|
||||
|
|
@ -162,13 +284,15 @@ function ProjectSidebarInner({
|
|||
onMobileClose,
|
||||
}: ProjectSidebarProps) {
|
||||
const router = useRouter();
|
||||
const isLoading = loading || sessions === null;
|
||||
const _isLoading = loading || sessions === null;
|
||||
|
||||
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(
|
||||
() => new Set(activeProjectId && activeProjectId !== "all" ? [activeProjectId] : []),
|
||||
() =>
|
||||
loadExpandedProjects() ??
|
||||
new Set(activeProjectId && activeProjectId !== "all" ? [activeProjectId] : []),
|
||||
);
|
||||
const [showKilled, setShowKilled] = useState(false);
|
||||
const [showDone, setShowDone] = useState(false);
|
||||
const [showKilled, setShowKilled] = useState<boolean>(loadShowKilled);
|
||||
const [showDone, setShowDone] = useState<boolean>(loadShowDone);
|
||||
const [showSessionId, setShowSessionId] = useState<boolean>(loadShowSessionId);
|
||||
// Inline session-rename state. Only one row is edited at a time. `pendingRenames`
|
||||
// mirrors the in-flight / just-saved value so the new label appears immediately
|
||||
|
|
@ -198,6 +322,30 @@ function ProjectSidebarInner({
|
|||
}
|
||||
}, [showSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.sessionStorage.setItem(SHOW_KILLED_KEY, String(showKilled));
|
||||
} catch {
|
||||
// sessionStorage unavailable — accept in-memory state.
|
||||
}
|
||||
}, [showKilled]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.sessionStorage.setItem(SHOW_DONE_KEY, String(showDone));
|
||||
} catch {
|
||||
// sessionStorage unavailable — accept in-memory state.
|
||||
}
|
||||
}, [showDone]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.sessionStorage.setItem(EXPANDED_PROJECTS_KEY, JSON.stringify([...expandedProjects]));
|
||||
} catch {
|
||||
// sessionStorage unavailable — accept in-memory state.
|
||||
}
|
||||
}, [expandedProjects]);
|
||||
|
||||
// Close the settings popover on outside click or Escape.
|
||||
useEffect(() => {
|
||||
if (!settingsOpen) return;
|
||||
|
|
@ -266,20 +414,37 @@ function ProjectSidebarInner({
|
|||
[visibleProjects],
|
||||
);
|
||||
|
||||
// The API (selectPreferredOrchestratorId) sends at most one entry per
|
||||
// project, so collapsing into a Map keyed by projectId is lossless. If a
|
||||
// future API change starts emitting multiples, the last one wins here.
|
||||
const orchestratorByProject = useMemo(
|
||||
() => new Map((orchestrators ?? []).map((o) => [o.projectId, o])),
|
||||
[orchestrators],
|
||||
);
|
||||
|
||||
// Stable ref so sessionsByProject can read latest sessions without depending
|
||||
// on the array reference (which changes every SSE tick even when content is unchanged).
|
||||
const sessionsRef = useRef(sessions);
|
||||
sessionsRef.current = sessions;
|
||||
|
||||
// Content-based key — only changes when session IDs, statuses, or projects change.
|
||||
// Used as the sole sessions-related dependency of sessionsByProject below.
|
||||
const sessionsKey = useMemo(
|
||||
() =>
|
||||
(sessions ?? [])
|
||||
.map(
|
||||
(s) =>
|
||||
`${s.id}:${s.status}:${s.activity ?? ""}:${s.projectId}:${s.displayName ?? ""}:${s.displayNameUserSet ? "1" : "0"}:${s.branch ?? ""}:${s.issueTitle ?? ""}:${s.pr?.title ?? ""}:${s.summary ?? ""}`,
|
||||
)
|
||||
.join("|"),
|
||||
[sessions],
|
||||
);
|
||||
|
||||
const sessionsByProject = useMemo(() => {
|
||||
const map = new Map<string, DashboardSession[]>();
|
||||
// Build a set of valid project IDs to filter sessions strictly
|
||||
const validProjectIds = new Set(visibleProjects.map((p) => p.id));
|
||||
|
||||
for (const s of sessions ?? []) {
|
||||
// Read via ref so this memo only reruns when sessionsKey changes (content
|
||||
// changed), not when sessions gets a new array reference with identical data.
|
||||
for (const s of sessionsRef.current ?? []) {
|
||||
// Only include sessions whose projectId matches a configured project
|
||||
if (!validProjectIds.has(s.projectId)) continue;
|
||||
if (isOrchestratorSession(s, prefixByProject.get(s.projectId), allPrefixes)) continue;
|
||||
|
|
@ -295,7 +460,8 @@ function ProjectSidebarInner({
|
|||
map.set(s.projectId, list);
|
||||
}
|
||||
return map;
|
||||
}, [sessions, prefixByProject, allPrefixes, visibleProjects, showKilled, showDone]);
|
||||
}, [sessionsKey, prefixByProject, allPrefixes, visibleProjects, showKilled, showDone]);
|
||||
|
||||
|
||||
// Clear an optimistic rename once the prop session.displayName catches up.
|
||||
// Without this, we'd keep masking the server value forever after a save.
|
||||
|
|
@ -313,18 +479,23 @@ function ProjectSidebarInner({
|
|||
if (changed) setPendingRenames(next);
|
||||
}, [sessions, pendingRenames]);
|
||||
|
||||
const startRename = (session: DashboardSession, currentTitle: string) => {
|
||||
// Prefer the in-flight optimistic value over the prop — if the user opens
|
||||
// rename while a previous PATCH is still propagating, the prop still shows
|
||||
// the pre-rename value but we want the input to start from the latest.
|
||||
// Auto-derived displayName isn't pre-filled (user-set flag absent) — start
|
||||
// from the live title so the user types over the visible label.
|
||||
const pending = pendingRenames.get(session.id);
|
||||
const initial =
|
||||
pending ?? (session.displayNameUserSet ? (session.displayName ?? "") : "");
|
||||
setEditingSessionId(session.id);
|
||||
setEditingValue(initial || currentTitle);
|
||||
};
|
||||
const pendingRenamesRef = useRef(pendingRenames);
|
||||
pendingRenamesRef.current = pendingRenames;
|
||||
|
||||
const startRename = useCallback(
|
||||
(session: DashboardSession, currentTitle: string) => {
|
||||
// Prefer the in-flight optimistic value over the prop — if the user opens
|
||||
// rename while a previous PATCH is still propagating, the prop still shows
|
||||
// the pre-rename value but we want the input to start from the latest.
|
||||
// Auto-derived displayName isn't pre-filled (user-set flag absent) — start
|
||||
// from the live title so the user types over the visible label.
|
||||
const pending = pendingRenamesRef.current.get(session.id);
|
||||
const initial = pending ?? (session.displayNameUserSet ? (session.displayName ?? "") : "");
|
||||
setEditingSessionId(session.id);
|
||||
setEditingValue(initial || currentTitle);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const cancelRename = () => {
|
||||
setEditingSessionId(null);
|
||||
|
|
@ -367,17 +538,20 @@ function ProjectSidebarInner({
|
|||
}
|
||||
};
|
||||
|
||||
const navigate = (url: string, session?: DashboardSession) => {
|
||||
if (session) {
|
||||
try {
|
||||
sessionStorage.setItem(`ao-session-nav:${session.id}`, JSON.stringify(session));
|
||||
} catch {
|
||||
// sessionStorage unavailable — silent fallback
|
||||
const navigate = useCallback(
|
||||
(url: string, session?: DashboardSession) => {
|
||||
if (session) {
|
||||
try {
|
||||
sessionStorage.setItem(`ao-session-nav:${session.id}`, JSON.stringify(session));
|
||||
} catch {
|
||||
// sessionStorage unavailable — silent fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
router.push(url);
|
||||
onMobileClose?.();
|
||||
};
|
||||
router.push(url);
|
||||
onMobileClose?.();
|
||||
},
|
||||
[router, onMobileClose],
|
||||
);
|
||||
|
||||
const toggleExpand = (projectId: string) => {
|
||||
setExpandedProjects((prev) => {
|
||||
|
|
@ -544,6 +718,16 @@ function ProjectSidebarInner({
|
|||
|
||||
{/* Project tree */}
|
||||
<div className="project-sidebar__tree flex-1 overflow-y-auto overflow-x-hidden">
|
||||
{sessions === null ? (
|
||||
<div className="space-y-1 px-3 py-3" aria-label="Loading projects">
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<div key={i} className="flex items-center gap-2 py-1">
|
||||
<div className="h-2 w-2 shrink-0 animate-pulse rounded-full bg-[var(--color-border-strong)]" />
|
||||
<div className="h-3 flex-1 animate-pulse rounded bg-[var(--color-bg-primary)]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{visibleProjects.map((project) => {
|
||||
const workerSessions = sessionsByProject.get(project.id) ?? [];
|
||||
const isExpanded = expandedProjects.has(project.id);
|
||||
|
|
@ -553,8 +737,14 @@ function ProjectSidebarInner({
|
|||
// sessionsByProject already applies the showDone filter consistently.
|
||||
const visibleSessions = workerSessions;
|
||||
const hasActiveSessions = visibleSessions.length > 0;
|
||||
|
||||
const orchestratorLink = orchestratorByProject.get(project.id) ?? null;
|
||||
// Look up the full session object so navigate() can cache it in
|
||||
// sessionStorage — prevents the "Session unavailable" flash on
|
||||
// first load. Orchestrators are filtered out of sessionsByProject
|
||||
// but still present in the raw sessions prop.
|
||||
const orchestratorSession = orchestratorLink
|
||||
? (sessions?.find((s) => s.id === orchestratorLink.id) ?? null)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div key={project.id} className="project-sidebar__project">
|
||||
|
|
@ -625,7 +815,7 @@ function ProjectSidebarInner({
|
|||
hasActiveSessions && "project-sidebar__proj-badge--active",
|
||||
)}
|
||||
>
|
||||
{workerSessions.length}
|
||||
{sessionsByProject.get(project.id)?.length ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -656,14 +846,17 @@ function ProjectSidebarInner({
|
|||
</Link>
|
||||
) : null}
|
||||
|
||||
{/* Orchestrator button */}
|
||||
{!isDegraded && orchestratorLink && (
|
||||
<Link
|
||||
<a
|
||||
href={projectSessionPath(project.id, orchestratorLink.id)}
|
||||
prefetch={false}
|
||||
onClick={(e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onMobileClose?.();
|
||||
navigate(
|
||||
projectSessionPath(project.id, orchestratorLink.id),
|
||||
orchestratorSession ?? undefined,
|
||||
);
|
||||
}}
|
||||
className="project-sidebar__proj-action"
|
||||
aria-label={`Open ${project.name} orchestrator`}
|
||||
|
|
@ -683,7 +876,7 @@ function ProjectSidebarInner({
|
|||
<circle cx="12" cy="17" r="2" />
|
||||
<circle cx="18" cy="17" r="2" />
|
||||
</svg>
|
||||
</Link>
|
||||
</a>
|
||||
)}
|
||||
|
||||
<div
|
||||
|
|
@ -730,7 +923,10 @@ function ProjectSidebarInner({
|
|||
role="menuitem"
|
||||
onClick={() => {
|
||||
setProjectMenuOpenId(null);
|
||||
navigate(projectSessionPath(project.id, orchestratorLink.id));
|
||||
navigate(
|
||||
projectSessionPath(project.id, orchestratorLink.id),
|
||||
orchestratorSession ?? undefined,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Open orchestrator
|
||||
|
|
@ -768,7 +964,7 @@ function ProjectSidebarInner({
|
|||
{/* Sessions */}
|
||||
{!isDegraded && isExpanded && (
|
||||
<div className="project-sidebar__sessions">
|
||||
{isLoading ? (
|
||||
{sessions === null ? (
|
||||
<div className="space-y-2 px-3 py-2" aria-label="Loading sessions">
|
||||
{Array.from({ length: 3 }, (_, index) => (
|
||||
<div
|
||||
|
|
@ -785,24 +981,6 @@ function ProjectSidebarInner({
|
|||
visibleSessions.map((session) => {
|
||||
const level = getAttentionLevel(session);
|
||||
const isSessionActive = activeSessionId === session.id;
|
||||
// Display precedence: optimistic rename (just-saved value)
|
||||
// → user-set displayName → branch → fallback chain.
|
||||
// Auto-derived displayName (displayNameUserSet=false) is
|
||||
// intentionally skipped here so PR/issue titles surfaced
|
||||
// by getSessionTitle aren't shadowed — mirrors the gate in
|
||||
// format.ts:getSessionTitle.
|
||||
const pending = pendingRenames.get(session.id);
|
||||
const effectiveDisplayName =
|
||||
pending !== undefined
|
||||
? pending
|
||||
: session.displayNameUserSet
|
||||
? (session.displayName ?? "")
|
||||
: "";
|
||||
const title =
|
||||
effectiveDisplayName !== ""
|
||||
? effectiveDisplayName
|
||||
: (session.branch ?? getSessionTitle(session));
|
||||
const sessionHref = projectSessionPath(project.id, session.id);
|
||||
const isEditing = editingSessionId === session.id;
|
||||
if (isEditing) {
|
||||
return (
|
||||
|
|
@ -839,76 +1017,16 @@ function ProjectSidebarInner({
|
|||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
<SessionRow
|
||||
key={session.id}
|
||||
className={cn(
|
||||
"project-sidebar__sess-row group",
|
||||
isSessionActive && "project-sidebar__sess-row--active",
|
||||
)}
|
||||
>
|
||||
<a
|
||||
href={sessionHref}
|
||||
onClick={(e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
|
||||
e.preventDefault();
|
||||
navigate(sessionHref, session);
|
||||
}}
|
||||
className="project-sidebar__sess-link flex flex-1 min-w-0 items-center gap-[7px]"
|
||||
aria-current={isSessionActive ? "page" : undefined}
|
||||
aria-label={`Open ${title}`}
|
||||
>
|
||||
<SessionDot level={level} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
"project-sidebar__sess-label",
|
||||
isSessionActive && "project-sidebar__sess-label--active",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{showSessionId ? (
|
||||
<div className="project-sidebar__sess-meta">
|
||||
<span className="project-sidebar__sess-id">{session.id}</span>
|
||||
<span className="project-sidebar__sess-status">
|
||||
{LEVEL_LABELS[level]}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{!showSessionId ? (
|
||||
<span className="project-sidebar__sess-status project-sidebar__sess-status--inline">
|
||||
{LEVEL_LABELS[level]}
|
||||
</span>
|
||||
) : null}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
startRename(session, title);
|
||||
}}
|
||||
className="project-sidebar__sess-rename-btn opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100"
|
||||
title="Rename session"
|
||||
aria-label={`Rename ${session.id}`}
|
||||
>
|
||||
<svg
|
||||
width="11"
|
||||
height="11"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
session={session}
|
||||
level={level}
|
||||
isActive={isSessionActive}
|
||||
showSessionId={showSessionId}
|
||||
pendingRename={pendingRenames.get(session.id)}
|
||||
onNavigate={navigate}
|
||||
onStartRename={startRename}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : error ? (
|
||||
|
|
@ -923,7 +1041,9 @@ function ProjectSidebarInner({
|
|||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="project-sidebar__empty">No sessions shown</div>
|
||||
<div className="project-sidebar__empty">
|
||||
No active sessions
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -11,10 +11,9 @@ import {
|
|||
import dynamic from "next/dynamic";
|
||||
import { getSessionTitle } from "@/lib/format";
|
||||
import type { ProjectInfo } from "@/lib/project-name";
|
||||
import { SidebarContext } from "./workspace/SidebarContext";
|
||||
import { useSidebarContext } from "./workspace/SidebarContext";
|
||||
import { projectDashboardPath, projectSessionPath } from "@/lib/routes";
|
||||
|
||||
import { ProjectSidebar, type ProjectSidebarOrchestrator } from "./ProjectSidebar";
|
||||
import { MobileBottomNav } from "./MobileBottomNav";
|
||||
import { SessionDetailHeader, type OrchestratorZones } from "./SessionDetailHeader";
|
||||
import { SessionEndedSummary } from "./SessionEndedSummary";
|
||||
|
|
@ -40,11 +39,6 @@ interface SessionDetailProps {
|
|||
orchestratorZones?: OrchestratorZones;
|
||||
projectOrchestratorId?: string | null;
|
||||
projects?: ProjectInfo[];
|
||||
sidebarSessions?: DashboardSession[] | null;
|
||||
sidebarOrchestrators?: ProjectSidebarOrchestrator[];
|
||||
sidebarLoading?: boolean;
|
||||
sidebarError?: boolean;
|
||||
onRetrySidebar?: () => void;
|
||||
}
|
||||
|
||||
export function SessionDetail({
|
||||
|
|
@ -53,18 +47,12 @@ export function SessionDetail({
|
|||
orchestratorZones,
|
||||
projectOrchestratorId = null,
|
||||
projects = [],
|
||||
sidebarSessions = [],
|
||||
sidebarOrchestrators,
|
||||
sidebarLoading = false,
|
||||
sidebarError = false,
|
||||
onRetrySidebar,
|
||||
}: SessionDetailProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const isMobile = useMediaQuery(MOBILE_BREAKPOINT);
|
||||
const sidebarCtx = useSidebarContext();
|
||||
const startFullscreen = searchParams.get("fullscreen") === "true";
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
const [showTerminal, setShowTerminal] = useState(false);
|
||||
const [relaunchError, setRelaunchError] = useState<string | null>(null);
|
||||
const pr = session.pr;
|
||||
|
|
@ -162,10 +150,10 @@ export function SessionDetail({
|
|||
}, [session.id, session.projectId]);
|
||||
|
||||
const orchestratorHref = useMemo(() => {
|
||||
if (isOrchestrator) return projectSessionPath(session.projectId, session.id);
|
||||
if (isOrchestrator) return null;
|
||||
if (projectOrchestratorId) return projectSessionPath(session.projectId, projectOrchestratorId);
|
||||
return null;
|
||||
}, [isOrchestrator, projectOrchestratorId, session.id, session.projectId]);
|
||||
}, [isOrchestrator, projectOrchestratorId, session.projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
const frame = window.requestAnimationFrame(() => setShowTerminal(true));
|
||||
|
|
@ -175,130 +163,86 @@ export function SessionDetail({
|
|||
};
|
||||
}, [session.id]);
|
||||
|
||||
const handleToggleSidebar = useCallback(() => {
|
||||
if (isMobile) {
|
||||
setMobileSidebarOpen((v) => !v);
|
||||
} else {
|
||||
setSidebarCollapsed((v) => !v);
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={{ onToggleSidebar: handleToggleSidebar, mobileSidebarOpen }}>
|
||||
<div className="dashboard-app-shell">
|
||||
<SessionDetailHeader
|
||||
session={session}
|
||||
isOrchestrator={isOrchestrator}
|
||||
isMobile={isMobile}
|
||||
terminalEnded={terminalEnded}
|
||||
isRestorable={isRestorable}
|
||||
activity={activity}
|
||||
headline={headline}
|
||||
projects={projects}
|
||||
orchestratorHref={orchestratorHref}
|
||||
orchestratorZones={orchestratorZones}
|
||||
onToggleSidebar={handleToggleSidebar}
|
||||
onRestore={handleRestore}
|
||||
onKill={handleKill}
|
||||
onRelaunchClean={isOrchestrator ? handleRelaunchClean : undefined}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`dashboard-shell dashboard-shell--desktop${
|
||||
sidebarCollapsed ? " dashboard-shell--sidebar-collapsed" : ""
|
||||
}`}
|
||||
>
|
||||
{projects.length > 0 ? (
|
||||
<div
|
||||
className={`sidebar-wrapper${
|
||||
mobileSidebarOpen ? " sidebar-wrapper--mobile-open" : ""
|
||||
}`}
|
||||
>
|
||||
<ProjectSidebar
|
||||
projects={projects}
|
||||
sessions={sidebarSessions}
|
||||
orchestrators={sidebarOrchestrators}
|
||||
loading={sidebarLoading}
|
||||
error={sidebarError}
|
||||
onRetry={onRetrySidebar}
|
||||
activeProjectId={session.projectId}
|
||||
activeSessionId={session.id}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggleCollapsed={() => setSidebarCollapsed((current) => !current)}
|
||||
onMobileClose={() => setMobileSidebarOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{mobileSidebarOpen && (
|
||||
<div className="sidebar-mobile-backdrop" onClick={() => setMobileSidebarOpen(false)} />
|
||||
)}
|
||||
|
||||
<div className="dashboard-main dashboard-main--desktop">
|
||||
<main className="session-detail-page flex-1 min-h-0 flex flex-col bg-[var(--color-bg-base)]">
|
||||
{relaunchError ? (
|
||||
<div
|
||||
className="border-b border-[color-mix(in_srgb,var(--color-status-error)_28%,transparent)] bg-[color-mix(in_srgb,var(--color-status-error)_10%,transparent)] px-4 py-2 text-[12px] text-[var(--color-status-error)]"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<span className="font-semibold">Relaunch failed:</span> {relaunchError}
|
||||
<div className="mt-1 text-[var(--color-text-secondary)]">
|
||||
The previous orchestrator may already be terminated. Try again from the
|
||||
project dashboard.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRelaunchError(null)}
|
||||
className="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="dashboard-main--desktop">
|
||||
<SessionDetailHeader
|
||||
session={session}
|
||||
isOrchestrator={isOrchestrator}
|
||||
isMobile={isMobile}
|
||||
terminalEnded={terminalEnded}
|
||||
isRestorable={isRestorable}
|
||||
activity={activity}
|
||||
headline={headline}
|
||||
projects={projects}
|
||||
orchestratorHref={orchestratorHref}
|
||||
orchestratorZones={orchestratorZones}
|
||||
onToggleSidebar={sidebarCtx?.onToggleSidebar ?? (() => {})}
|
||||
onRestore={handleRestore}
|
||||
onKill={handleKill}
|
||||
onRelaunchClean={isOrchestrator ? handleRelaunchClean : undefined}
|
||||
/>
|
||||
<main className="session-detail-page flex-1 min-h-0 flex flex-col bg-[var(--color-bg-base)]">
|
||||
{relaunchError ? (
|
||||
<div
|
||||
className="border-b border-[color-mix(in_srgb,var(--color-status-error)_28%,transparent)] bg-[color-mix(in_srgb,var(--color-status-error)_10%,transparent)] px-4 py-2 text-[12px] text-[var(--color-status-error)]"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<span className="font-semibold">Relaunch failed:</span> {relaunchError}
|
||||
<div className="mt-1 text-[var(--color-text-secondary)]">
|
||||
The previous orchestrator may already be terminated. Try again from the
|
||||
project dashboard.
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{!showTerminal ? (
|
||||
<div className="session-detail-terminal-placeholder h-full" />
|
||||
) : terminalEnded ? (
|
||||
<SessionEndedSummary
|
||||
session={session}
|
||||
headline={headline}
|
||||
pr={pr}
|
||||
dashboardHref={dashboardHref}
|
||||
/>
|
||||
) : (
|
||||
<DirectTerminal
|
||||
sessionId={session.id}
|
||||
projectId={session.projectId}
|
||||
tmuxName={session.metadata?.tmuxName}
|
||||
startFullscreen={startFullscreen}
|
||||
variant={terminalVariant}
|
||||
appearance="dark"
|
||||
height="100%"
|
||||
isOpenCodeSession={isOpenCodeSession}
|
||||
reloadCommand={isOpenCodeSession ? reloadCommand : undefined}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRelaunchError(null)}
|
||||
className="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{!showTerminal ? (
|
||||
<div className="session-detail-terminal-placeholder h-full" />
|
||||
) : terminalEnded ? (
|
||||
<SessionEndedSummary
|
||||
session={session}
|
||||
headline={headline}
|
||||
pr={pr}
|
||||
dashboardHref={dashboardHref}
|
||||
/>
|
||||
) : (
|
||||
<DirectTerminal
|
||||
sessionId={session.id}
|
||||
projectId={session.projectId}
|
||||
tmuxName={session.metadata?.tmuxName}
|
||||
startFullscreen={startFullscreen}
|
||||
variant={terminalVariant}
|
||||
appearance="dark"
|
||||
height="100%"
|
||||
isOpenCodeSession={isOpenCodeSession}
|
||||
reloadCommand={isOpenCodeSession ? reloadCommand : undefined}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<MobileBottomNav
|
||||
ariaLabel="Session navigation"
|
||||
activeTab={isOrchestrator ? "orchestrator" : undefined}
|
||||
dashboardHref={dashboardHref}
|
||||
prsHref={
|
||||
session.projectId ? `/?project=${encodeURIComponent(session.projectId)}&tab=prs` : "/"
|
||||
}
|
||||
showOrchestrator={!!orchestratorHref}
|
||||
orchestratorHref={orchestratorHref}
|
||||
/>
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
</main>
|
||||
<MobileBottomNav
|
||||
ariaLabel="Session navigation"
|
||||
activeTab={isOrchestrator ? "orchestrator" : undefined}
|
||||
dashboardHref={dashboardHref}
|
||||
prsHref={
|
||||
session.projectId ? `/?project=${encodeURIComponent(session.projectId)}&tab=prs` : "/"
|
||||
}
|
||||
showOrchestrator={!!orchestratorHref}
|
||||
orchestratorHref={orchestratorHref}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -165,6 +165,7 @@ describe("Dashboard empty state", () => {
|
|||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
it("mounts the sidebar empty state on a fresh install with zero projects", () => {
|
||||
render(<Dashboard initialSessions={[]} projects={[]} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
|
|
|
|||
|
|
@ -483,7 +483,7 @@ describe("SessionDetail desktop layout", () => {
|
|||
expect(routerRefreshMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the desktop orchestrator button on orchestrator session pages", () => {
|
||||
it("hides the desktop orchestrator button on orchestrator session pages", () => {
|
||||
render(
|
||||
<SessionDetail
|
||||
session={makeSession({
|
||||
|
|
@ -505,8 +505,8 @@ describe("SessionDetail desktop layout", () => {
|
|||
);
|
||||
|
||||
expect(
|
||||
within(screen.getByRole("banner")).getByRole("link", { name: "Orchestrator" }),
|
||||
).toHaveAttribute("href", "/projects/my-app/sessions/my-app-orchestrator");
|
||||
within(screen.getByRole("banner")).queryByRole("link", { name: "Orchestrator" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("orchestrator")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -557,8 +557,8 @@ describe("SessionDetail desktop layout", () => {
|
|||
);
|
||||
|
||||
expect(
|
||||
within(screen.getByRole("banner")).getByRole("link", { name: "Orchestrator" }),
|
||||
).toHaveAttribute("href", "/projects/my-app/sessions/my-app-orchestrator");
|
||||
within(screen.getByRole("banner")).queryByRole("link", { name: "Orchestrator" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("routes to the project orchestrator after killing a worker session", async () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue