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:
parent
dee64fbc42
commit
b97bac5e94
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue