From b97bac5e941ef4f3a2d4d5957076ec7f6a6ad0bf Mon Sep 17 00:00:00 2001 From: Harsh Date: Wed, 11 Mar 2026 23:25:53 +0530 Subject: [PATCH] fix: scope dashboard orchestrator links by project Render every project's orchestrator explicitly and keep orchestrator detail pages scoped to their own project so multi-project dashboards show the right control session. --- packages/web/src/app/page.tsx | 29 ++++--- packages/web/src/app/sessions/[id]/page.tsx | 30 ++++--- packages/web/src/components/Dashboard.tsx | 89 ++++++++++++--------- 3 files changed, 91 insertions(+), 57 deletions(-) diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx index 3ac9d0173..ddca05d7b 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -3,16 +3,18 @@ import type { Metadata } from "next"; export const dynamic = "force-dynamic"; import { Dashboard } from "@/components/Dashboard"; import type { DashboardSession } from "@/lib/types"; +import { isOrchestratorSession } from "@composio/ao-core"; import { getServices, getSCM } from "@/lib/services"; import { sessionToDashboard, resolveProject, enrichSessionPR, enrichSessionsMetadata, + listDashboardOrchestrators, } from "@/lib/serialize"; import { prCache, prCacheKey } from "@/lib/cache"; import { getPrimaryProjectId, getProjectName, getAllProjects } from "@/lib/project-name"; -import { filterWorkerSessions, findOrchestratorSessionId } from "@/lib/project-utils"; +import { filterWorkerSessions } from "@/lib/project-utils"; import { resolveGlobalPause, type GlobalPauseState } from "@/lib/global-pause"; function getSelectedProjectName(projectFilter: string | undefined): string { @@ -37,21 +39,30 @@ export async function generateMetadata(props: { export default async function Home(props: { searchParams: Promise<{ project?: string }> }) { const searchParams = await props.searchParams; let sessions: DashboardSession[] = []; - let orchestratorId: string | null; - let globalPause: GlobalPauseState | null; - // Allow ?project=all to show all sessions (for multi-project setups) + let globalPause: GlobalPauseState | null = null; + let orchestrators: Array<{ id: string; projectId: string; projectName: string }> = []; const projectFilter = searchParams.project ?? getPrimaryProjectId(); try { const { config, registry, sessionManager } = await getServices(); const allSessions = await sessionManager.list(); - orchestratorId = findOrchestratorSessionId(allSessions, projectFilter, config.projects); - globalPause = resolveGlobalPause(allSessions); - const coreSessions = filterWorkerSessions(allSessions, projectFilter, config.projects); + const visibleSessions = + projectFilter && projectFilter !== "all" + ? allSessions.filter( + (session) => + session.projectId === projectFilter || + config.projects[session.projectId]?.sessionPrefix === projectFilter, + ) + : allSessions; + orchestrators = listDashboardOrchestrators(visibleSessions, config.projects); + + const coreSessions = filterWorkerSessions(allSessions, projectFilter, config.projects).filter( + (session) => !isOrchestratorSession(session), + ); sessions = coreSessions.map(sessionToDashboard); const metaTimeout = new Promise((resolve) => setTimeout(resolve, 3_000)); @@ -107,8 +118,8 @@ export default async function Home(props: { searchParams: Promise<{ project?: st await Promise.race([Promise.allSettled(enrichPromises), enrichTimeout]); } catch { sessions = []; - orchestratorId = null; globalPause = null; + orchestrators = []; } const projectName = getSelectedProjectName(projectFilter); @@ -118,11 +129,11 @@ export default async function Home(props: { searchParams: Promise<{ project?: st return ( ); } diff --git a/packages/web/src/app/sessions/[id]/page.tsx b/packages/web/src/app/sessions/[id]/page.tsx index e82dcb55d..00d021ef0 100644 --- a/packages/web/src/app/sessions/[id]/page.tsx +++ b/packages/web/src/app/sessions/[id]/page.tsx @@ -6,6 +6,10 @@ import { SessionDetail } from "@/components/SessionDetail"; import { type DashboardSession, getAttentionLevel, type AttentionLevel } from "@/lib/types"; import { activityIcon } from "@/lib/activity-icons"; +function isOrchestratorSession(session: Pick): boolean { + return session.metadata["role"] === "orchestrator" || session.id.endsWith("-orchestrator"); +} + function truncate(s: string, max: number): string { return s.length > max ? s.slice(0, max) + "..." : s; } @@ -14,7 +18,7 @@ function truncate(s: string, max: number): string { function buildSessionTitle(session: DashboardSession): string { const id = session.id; const emoji = session.activity ? (activityIcon[session.activity] ?? "") : ""; - const isOrchestrator = id.endsWith("-orchestrator"); + const isOrchestrator = isOrchestratorSession(session); let detail: string; @@ -43,7 +47,6 @@ interface ZoneCounts { export default function SessionPage() { const params = useParams(); const id = params.id as string; - const isOrchestrator = id.endsWith("-orchestrator"); const [session, setSession] = useState(null); const [zoneCounts, setZoneCounts] = useState(null); @@ -81,15 +84,22 @@ export default function SessionPage() { }, [id]); const fetchZoneCounts = useCallback(async () => { - if (!isOrchestrator) return; + if (!session || !isOrchestratorSession(session) || !session.projectId) return; try { - const res = await fetch("/api/sessions"); + const res = await fetch(`/api/sessions?project=${encodeURIComponent(session.projectId)}`); if (!res.ok) return; const body = (await res.json()) as { sessions: DashboardSession[] }; const sessions = body.sessions ?? []; - const counts: ZoneCounts = { merge: 0, respond: 0, review: 0, pending: 0, working: 0, done: 0 }; + const counts: ZoneCounts = { + merge: 0, + respond: 0, + review: 0, + pending: 0, + working: 0, + done: 0, + }; for (const s of sessions) { - if (!s.id.endsWith("-orchestrator")) { + if (!isOrchestratorSession(s)) { counts[getAttentionLevel(s) as AttentionLevel]++; } } @@ -97,7 +107,7 @@ export default function SessionPage() { } catch { // non-critical — status strip just won't show } - }, [isOrchestrator]); + }, [session]); // Initial fetch — session first, zone counts after (avoids blocking on slow /api/sessions) useEffect(() => { @@ -127,7 +137,9 @@ export default function SessionPage() { if (error || !session) { return (
-
{error ?? "Session not found"}
+
+ {error ?? "Session not found"} +
← Back to dashboard @@ -138,7 +150,7 @@ export default function SessionPage() { return ( ); diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index 772a0da06..2322e5b92 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -7,6 +7,7 @@ import { type DashboardPR, type AttentionLevel, type GlobalPauseState, + type DashboardOrchestratorLink, getAttentionLevel, isPRRateLimited, } from "@/lib/types"; @@ -20,22 +21,22 @@ import type { ProjectInfo } from "@/lib/project-name"; interface DashboardProps { initialSessions: DashboardSession[]; - orchestratorId?: string | null; projectId?: string; projectName?: string; projects?: ProjectInfo[]; initialGlobalPause?: GlobalPauseState | null; + orchestrators?: DashboardOrchestratorLink[]; } const KANBAN_LEVELS = ["working", "pending", "review", "respond", "merge"] as const; export function Dashboard({ initialSessions, - orchestratorId, projectId, projectName, projects = [], initialGlobalPause = null, + orchestrators = [], }: DashboardProps) { const { sessions, globalPause } = useSessionEvents( initialSessions, @@ -45,6 +46,7 @@ export function Dashboard({ const [rateLimitDismissed, setRateLimitDismissed] = useState(false); const [globalPauseDismissed, setGlobalPauseDismissed] = useState(false); const showSidebar = projects.length > 1; + const grouped = useMemo(() => { const zones: Record = { merge: [], @@ -62,8 +64,11 @@ export function Dashboard({ const openPRs = useMemo(() => { return sessions - .filter((s): s is DashboardSession & { pr: DashboardPR } => s.pr?.state === "open") - .map((s) => s.pr) + .filter( + (session): session is DashboardSession & { pr: DashboardPR } => + session.pr?.state === "open", + ) + .map((session) => session.pr) .sort((a, b) => mergeScore(a) - mergeScore(b)); }, [sessions]); @@ -105,24 +110,27 @@ export function Dashboard({ } }; - const hasKanbanSessions = KANBAN_LEVELS.some((l) => grouped[l].length > 0); + const hasKanbanSessions = KANBAN_LEVELS.some((level) => grouped[level].length > 0); const anyRateLimited = useMemo( - () => sessions.some((s) => s.pr && isPRRateLimited(s.pr)), + () => sessions.some((session) => session.pr && isPRRateLimited(session.pr)), [sessions], ); + const liveStats = useMemo( () => ({ totalSessions: sessions.length, - workingSessions: sessions.filter((s) => s.activity !== null && s.activity !== "exited") - .length, - openPRs: sessions.filter((s) => s.pr?.state === "open").length, + workingSessions: sessions.filter( + (session) => session.activity !== null && session.activity !== "exited", + ).length, + openPRs: sessions.filter((session) => session.pr?.state === "open").length, needsReview: sessions.filter( - (s) => s.pr && !s.pr.isDraft && s.pr.reviewDecision === "pending", + (session) => session.pr && !session.pr.isDraft && session.pr.reviewDecision === "pending", ).length, }), [sessions], ); + const resumeAtLabel = useMemo(() => { if (!globalPause) return null; return new Date(globalPause.pausedUntil).toLocaleString(); @@ -137,7 +145,6 @@ export function Dashboard({ {showSidebar && }
- {/* Header */}

@@ -145,27 +152,33 @@ export function Dashboard({

- {orchestratorId && ( - - - orchestrator - - - - + {orchestrators.length > 0 && ( + )}
- {/* Global pause banner */} {globalPause && !globalPauseDismissed && (
)} - {/* Rate limit notice */} {anyRateLimited && !rateLimitDismissed && (
)} - {/* Kanban columns for active zones */} {hasKanbanSessions && (
{KANBAN_LEVELS.map((level) => @@ -261,7 +272,6 @@ export function Dashboard({
)} - {/* Done — full-width grid below Kanban */} {grouped.done.length > 0 && (
)} - {/* PR Table */} {openPRs.length > 0 && (

@@ -338,16 +347,18 @@ function StatusLine({ stats }: { stats: DashboardStats }) { return (
- {parts.map((p, i) => ( - - {i > 0 && ·} + {parts.map((part, index) => ( + + {index > 0 && ( + · + )} - {p.value} + {part.value} - {p.label} + {part.label} ))}