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.
This commit is contained in:
Harsh 2026-03-11 23:25:53 +05:30
parent dee64fbc42
commit b97bac5e94
3 changed files with 91 additions and 57 deletions

View File

@ -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<void>((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 (
<Dashboard
initialSessions={sessions}
orchestratorId={orchestratorId}
projectId={selectedProjectId}
projectName={projectName}
projects={projects}
initialGlobalPause={globalPause}
orchestrators={orchestrators}
/>
);
}

View File

@ -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<DashboardSession, "id" | "metadata">): 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<DashboardSession | null>(null);
const [zoneCounts, setZoneCounts] = useState<ZoneCounts | null>(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 (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-[var(--color-bg-base)]">
<div className="text-[13px] text-[var(--color-status-error)]">{error ?? "Session not found"}</div>
<div className="text-[13px] text-[var(--color-status-error)]">
{error ?? "Session not found"}
</div>
<a href="/" className="text-[12px] text-[var(--color-accent)] hover:underline">
Back to dashboard
</a>
@ -138,7 +150,7 @@ export default function SessionPage() {
return (
<SessionDetail
session={session}
isOrchestrator={isOrchestrator}
isOrchestrator={isOrchestratorSession(session)}
orchestratorZones={zoneCounts ?? undefined}
/>
);

View File

@ -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<AttentionLevel, DashboardSession[]> = {
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<DashboardStats>(
() => ({
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 && <ProjectSidebar projects={projects} activeProjectId={projectId} />}
<div className="flex-1 overflow-y-auto px-8 py-7">
<DynamicFavicon sessions={sessions} projectName={projectName} />
{/* Header */}
<div className="mb-8 flex items-center justify-between border-b border-[var(--color-border-subtle)] pb-6">
<div className="flex items-center gap-6">
<h1 className="text-[17px] font-semibold tracking-[-0.02em] text-[var(--color-text-primary)]">
@ -145,27 +152,33 @@ export function Dashboard({
</h1>
<StatusLine stats={liveStats} />
</div>
{orchestratorId && (
<a
href={`/sessions/${encodeURIComponent(orchestratorId)}`}
className="orchestrator-btn flex items-center gap-2 rounded-[7px] px-4 py-2 text-[12px] font-semibold hover:no-underline"
>
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)] opacity-80" />
orchestrator
<svg
className="h-3 w-3 opacity-70"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" />
</svg>
</a>
{orchestrators.length > 0 && (
<div className="flex flex-wrap items-center justify-end gap-2">
{orchestrators.map((orchestrator) => (
<a
key={orchestrator.id}
href={`/sessions/${encodeURIComponent(orchestrator.id)}`}
className="orchestrator-btn flex items-center gap-2 rounded-[7px] px-4 py-2 text-[12px] font-semibold hover:no-underline"
>
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)] opacity-80" />
{orchestrators.length > 1
? `${orchestrator.projectName} orchestrator`
: "orchestrator"}
<svg
className="h-3 w-3 opacity-70"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" />
</svg>
</a>
))}
</div>
)}
</div>
{/* Global pause banner */}
{globalPause && !globalPauseDismissed && (
<div className="mb-6 flex items-center gap-2.5 rounded border border-[rgba(239,68,68,0.25)] bg-[rgba(239,68,68,0.05)] px-3.5 py-2.5 text-[11px] text-[var(--color-status-error)]">
<svg
@ -205,7 +218,6 @@ export function Dashboard({
</div>
)}
{/* Rate limit notice */}
{anyRateLimited && !rateLimitDismissed && (
<div className="mb-6 flex items-center gap-2.5 rounded border border-[rgba(245,158,11,0.25)] bg-[rgba(245,158,11,0.05)] px-3.5 py-2.5 text-[11px] text-[var(--color-status-attention)]">
<svg
@ -240,7 +252,6 @@ export function Dashboard({
</div>
)}
{/* Kanban columns for active zones */}
{hasKanbanSessions && (
<div className="mb-8 flex gap-4 overflow-x-auto pb-2">
{KANBAN_LEVELS.map((level) =>
@ -261,7 +272,6 @@ export function Dashboard({
</div>
)}
{/* Done — full-width grid below Kanban */}
{grouped.done.length > 0 && (
<div className="mb-8">
<AttentionZone
@ -276,7 +286,6 @@ export function Dashboard({
</div>
)}
{/* PR Table */}
{openPRs.length > 0 && (
<div className="mx-auto max-w-[900px]">
<h2 className="mb-3 px-1 text-[10px] font-bold uppercase tracking-[0.10em] text-[var(--color-text-tertiary)]">
@ -338,16 +347,18 @@ function StatusLine({ stats }: { stats: DashboardStats }) {
return (
<div className="flex items-baseline gap-0.5">
{parts.map((p, i) => (
<span key={p.label} className="flex items-baseline">
{i > 0 && <span className="mx-3 text-[11px] text-[var(--color-border-strong)]">·</span>}
{parts.map((part, index) => (
<span key={part.label} className="flex items-baseline">
{index > 0 && (
<span className="mx-3 text-[11px] text-[var(--color-border-strong)]">·</span>
)}
<span
className="text-[20px] font-bold tabular-nums tracking-tight"
style={{ color: p.color ?? "var(--color-text-primary)" }}
style={{ color: part.color ?? "var(--color-text-primary)" }}
>
{p.value}
{part.value}
</span>
<span className="ml-1.5 text-[11px] text-[var(--color-text-muted)]">{p.label}</span>
<span className="ml-1.5 text-[11px] text-[var(--color-text-muted)]">{part.label}</span>
</span>
))}
</div>