From 3aebf0e233596dc6fe4a8595a70d1f7f9f3e11b7 Mon Sep 17 00:00:00 2001 From: Harsh Date: Wed, 11 Mar 2026 23:25:27 +0530 Subject: [PATCH 01/16] feat: add shared orchestrator session detection Centralize orchestrator identification so core cleanup and downstream consumers can share one contract for per-project orchestrator sessions. --- packages/core/src/__tests__/types.test.ts | 21 +++++++++++++++++++++ packages/core/src/types.ts | 7 +++++++ 2 files changed, 28 insertions(+) create mode 100644 packages/core/src/__tests__/types.test.ts diff --git a/packages/core/src/__tests__/types.test.ts b/packages/core/src/__tests__/types.test.ts new file mode 100644 index 000000000..6c2fffaf8 --- /dev/null +++ b/packages/core/src/__tests__/types.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { isOrchestratorSession } from "../types.js"; + +describe("isOrchestratorSession", () => { + it("detects orchestrators by explicit role metadata", () => { + expect( + isOrchestratorSession({ + id: "app-control", + metadata: { role: "orchestrator" }, + }), + ).toBe(true); + }); + + it("falls back to orchestrator naming for legacy sessions", () => { + expect(isOrchestratorSession({ id: "app-orchestrator", metadata: {} })).toBe(true); + }); + + it("does not classify worker sessions as orchestrators", () => { + expect(isOrchestratorSession({ id: "app-7", metadata: { role: "worker" } })).toBe(false); + }); +}); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 9d2bf2746..bc6829f10 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -172,6 +172,13 @@ export interface Session { metadata: Record; } +export function isOrchestratorSession(session: { + id: SessionId; + metadata?: Record; +}): boolean { + return session.metadata?.["role"] === "orchestrator" || session.id.endsWith("-orchestrator"); +} + /** Config for creating a new session */ export interface SessionSpawnConfig { projectId: string; From 60a9c9fbb60749e4277406c443604d6a504fd20a Mon Sep 17 00:00:00 2001 From: Harsh Date: Wed, 11 Mar 2026 23:25:35 +0530 Subject: [PATCH 02/16] fix: preserve project ownership for legacy sessions Recover the owning project from the sessions directory and archived metadata so older session files still resolve to the correct project and reuse the right orchestrator mapping. --- .../src/__tests__/session-manager.test.ts | 28 +++++++++++++++++++ packages/core/src/session-manager.ts | 21 ++++++++++---- .../core/src/utils/session-from-metadata.ts | 3 +- 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/packages/core/src/__tests__/session-manager.test.ts b/packages/core/src/__tests__/session-manager.test.ts index 1412d2c54..a2aa995ba 100644 --- a/packages/core/src/__tests__/session-manager.test.ts +++ b/packages/core/src/__tests__/session-manager.test.ts @@ -1184,6 +1184,20 @@ describe("list", () => { expect(sessions[0].id).toBe("app-1"); }); + it("preserves owning project ID for legacy metadata missing the project field", async () => { + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp", + branch: "a", + status: "working", + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + const sessions = await sm.list("my-app"); + + expect(sessions).toHaveLength(1); + expect(sessions[0].projectId).toBe("my-app"); + }); + it("clears enrichment timeout when enrichment completes quickly", async () => { vi.useFakeTimers(); const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout"); @@ -1450,6 +1464,20 @@ describe("get", () => { expect(await sm.get("nonexistent")).toBeNull(); }); + it("assigns owning project ID when loading legacy metadata without project", async () => { + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp", + branch: "main", + status: "working", + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + const session = await sm.get("app-1"); + + expect(session).not.toBeNull(); + expect(session?.projectId).toBe("my-app"); + }); + it("auto-discovers and persists OpenCode session mapping when missing", async () => { const deleteLogPath = join(tmpDir, "opencode-get-remap.log"); const mockBin = installMockOpencode( diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 84ebf4af0..548f0694f 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -41,6 +41,7 @@ import { type PluginRegistry, type RuntimeHandle, type Issue, + isOrchestratorSession, PR_STATE, } from "./types.js"; import { @@ -228,10 +229,12 @@ function sleep(ms: number): Promise { function metadataToSession( sessionId: SessionId, meta: Record, + projectId: string, createdAt?: Date, modifiedAt?: Date, ): Session { return sessionFromMetadata(sessionId, meta, { + projectId, createdAt, lastActivityAt: modifiedAt ?? new Date(), }); @@ -556,6 +559,10 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM maybeAdd(id, readArchivedMetadataRaw(sessionsDir, id)); } + if (criteria.sessionId) { + maybeAdd(criteria.sessionId, readArchivedMetadataRaw(sessionsDir, criteria.sessionId)); + } + return [...new Set(ids)]; } @@ -1416,7 +1423,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM // If stat fails, timestamps will fall back to current time } - const session = metadataToSession(sessionName, raw, createdAt, modifiedAt); + const session = metadataToSession(sessionName, raw, sessionProjectId, createdAt, modifiedAt); const selectedAgentName = raw["agent"]; const effectiveAgentName = selectedAgentName ?? project.agent ?? config.defaults.agent; const plugins = resolvePlugins(project, effectiveAgentName); @@ -1455,7 +1462,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM async function get(sessionId: SessionId): Promise { // Try to find the session in any project's sessions directory - for (const project of Object.values(config.projects)) { + for (const [projectId, project] of Object.entries(config.projects)) { const sessionsDir = getProjectSessionsDir(project); const raw = readMetadataRaw(sessionsDir, sessionId); if (!raw) continue; @@ -1472,11 +1479,15 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM // If stat fails, timestamps will fall back to current time } +<<<<<<< HEAD const repaired = repairSingleSessionMetadataOnRead(sessionsDir, { sessionName: sessionId, raw, modifiedAt, }); +======= + const session = metadataToSession(sessionId, raw, projectId, createdAt, modifiedAt); +>>>>>>> d502cd1 (fix: preserve project ownership for legacy sessions) const session = metadataToSession(sessionId, repaired.raw, createdAt, modifiedAt); @@ -1606,7 +1617,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM // Never clean up orchestrator sessions — they manage the lifecycle. // Check explicit role metadata first, fall back to naming convention // for pre-existing sessions spawned before the role field was added. - if (session.metadata["role"] === "orchestrator" || session.id.endsWith("-orchestrator")) { + if (isOrchestratorSession(session)) { pushSkipped(session.projectId, session.id); continue; } @@ -1678,7 +1689,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM const archived = readArchivedMetadataRaw(sessionsDir, archivedId); if (!archived) continue; - if (archived["role"] === "orchestrator" || archivedId.endsWith("-orchestrator")) { + if (isOrchestratorSession({ id: archivedId, metadata: archived })) { pushSkipped(projectKey, archivedId); continue; } @@ -2107,7 +2118,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM // metadataToSession sets activity: null, so without enrichment a crashed // session (status "working", agent exited) would not be detected as terminal // and isRestorable would reject it. - const session = metadataToSession(sessionId, raw); + const session = metadataToSession(sessionId, raw, projectId); const plugins = resolvePlugins(project, raw["agent"]); await enrichSessionWithRuntimeState(session, plugins, true); diff --git a/packages/core/src/utils/session-from-metadata.ts b/packages/core/src/utils/session-from-metadata.ts index 50996fda8..fd5fc1dfb 100644 --- a/packages/core/src/utils/session-from-metadata.ts +++ b/packages/core/src/utils/session-from-metadata.ts @@ -3,6 +3,7 @@ import { parsePrFromUrl } from "./pr.js"; import { safeJsonParse, validateStatus } from "./validation.js"; interface SessionFromMetadataOptions { + projectId?: string; status?: SessionStatus; activity?: Session["activity"]; runtimeHandle?: RuntimeHandle | null; @@ -18,7 +19,7 @@ export function sessionFromMetadata( ): Session { return { id: sessionId, - projectId: meta["project"] ?? "", + projectId: meta["project"] ?? options.projectId ?? "", status: options.status ?? validateStatus(meta["status"]), activity: options.activity ?? null, branch: meta["branch"] || null, From dee64fbc427bffedff46d902be8621ddbc680505 Mon Sep 17 00:00:00 2001 From: Harsh Date: Wed, 11 Mar 2026 23:25:44 +0530 Subject: [PATCH 03/16] fix: return project-scoped orchestrators from sessions api Expose orchestrator links per project and keep them out of worker session stats so dashboard consumers stop collapsing multi-project state into one global orchestrator. --- packages/web/src/__tests__/api-routes.test.ts | 74 +++++++++++++++++++ packages/web/src/app/api/sessions/route.ts | 35 ++++++--- packages/web/src/lib/serialize.ts | 44 +++++++---- packages/web/src/lib/types.ts | 6 ++ 4 files changed, 135 insertions(+), 24 deletions(-) diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 5f23c96f6..f2b4a0569 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -61,6 +61,31 @@ const testSessions: Session[] = [ }), ]; +const multiProjectSessions: Session[] = [ + makeSession({ + id: "app-orchestrator", + projectId: "my-app", + metadata: { role: "orchestrator" }, + }), + makeSession({ + id: "backend-3", + projectId: "my-app", + status: "working", + activity: "active", + }), + makeSession({ + id: "docs-orchestrator", + projectId: "docs-app", + metadata: { role: "orchestrator" }, + }), + makeSession({ + id: "docs-2", + projectId: "docs-app", + status: "review_pending", + activity: "idle", + }), +]; + // ── Mock Services ───────────────────────────────────────────────────── const mockSessionManager: SessionManager = { @@ -164,6 +189,14 @@ const mockConfig: OrchestratorConfig = { sessionPrefix: "my-app", scm: { plugin: "github", webhook: {} }, }, + "docs-app": { + name: "Docs App", + repo: "acme/docs-app", + path: "/tmp/docs-app", + defaultBranch: "main", + sessionPrefix: "docs", + scm: { plugin: "github" }, + }, }, notifiers: {}, notificationRouting: { urgent: [], action: [], warning: [], info: [] }, @@ -285,6 +318,47 @@ describe("API Routes", () => { metadataSpy.mockRestore(); vi.useRealTimers(); }); + + it("returns per-project orchestrators and excludes them from worker sessions", async () => { + (mockSessionManager.list as ReturnType).mockResolvedValueOnce( + multiProjectSessions, + ); + + const res = await sessionsGET(makeRequest("http://localhost:3000/api/sessions")); + expect(res.status).toBe(200); + const data = await res.json(); + + expect(data.orchestratorId).toBeNull(); + expect(data.orchestrators).toEqual([ + { id: "docs-orchestrator", projectId: "docs-app", projectName: "Docs App" }, + { id: "app-orchestrator", projectId: "my-app", projectName: "My App" }, + ]); + expect(data.sessions.map((session: { id: string }) => session.id)).toEqual([ + "backend-3", + "docs-2", + ]); + expect(data.stats.totalSessions).toBe(2); + }); + + it("supports project-scoped session queries for orchestrator detail views", async () => { + (mockSessionManager.list as ReturnType).mockImplementationOnce( + async (projectId?: string) => + multiProjectSessions.filter((session) => !projectId || session.projectId === projectId), + ); + + const res = await sessionsGET( + makeRequest("http://localhost:3000/api/sessions?project=docs-app"), + ); + expect(res.status).toBe(200); + const data = await res.json(); + + expect(data.orchestratorId).toBe("docs-orchestrator"); + expect(data.orchestrators).toEqual([ + { id: "docs-orchestrator", projectId: "docs-app", projectName: "Docs App" }, + ]); + expect(data.sessions.map((session: { id: string }) => session.id)).toEqual(["docs-2"]); + expect(mockSessionManager.list).toHaveBeenCalledWith("docs-app"); + }); }); // ── POST /api/spawn ──────────────────────────────────────────────── diff --git a/packages/web/src/app/api/sessions/route.ts b/packages/web/src/app/api/sessions/route.ts index f3f3c37d8..f35b73d8e 100644 --- a/packages/web/src/app/api/sessions/route.ts +++ b/packages/web/src/app/api/sessions/route.ts @@ -1,4 +1,4 @@ -import { ACTIVITY_STATE } from "@composio/ao-core"; +import { ACTIVITY_STATE, isOrchestratorSession } from "@composio/ao-core"; import { NextResponse } from "next/server"; import { getServices, getSCM } from "@/lib/services"; import { @@ -7,9 +7,9 @@ import { enrichSessionPR, enrichSessionsMetadata, computeStats, + listDashboardOrchestrators, } from "@/lib/serialize"; import { resolveGlobalPause } from "@/lib/global-pause"; -import { filterWorkerSessions, findOrchestratorSessionId } from "@/lib/project-utils"; const METADATA_ENRICH_TIMEOUT_MS = 3_000; const PR_ENRICH_TIMEOUT_MS = 4_000; @@ -42,22 +42,34 @@ export async function GET(request: Request) { const activeOnly = searchParams.get("active") === "true"; const { config, registry, sessionManager } = await getServices(); - const coreSessions = await sessionManager.list(); + const requestedProjectId = + projectFilter && projectFilter !== "all" && config.projects[projectFilter] + ? projectFilter + : undefined; + const coreSessions = await sessionManager.list(requestedProjectId); - const orchestratorId = findOrchestratorSessionId(coreSessions, projectFilter, config.projects); + const matchesRequestedProject = (session: { id: string; projectId: string }): boolean => { + if (!projectFilter || projectFilter === "all") return true; + if (session.projectId === projectFilter) return true; + return config.projects[session.projectId]?.sessionPrefix === projectFilter; + }; - let workerSessions = filterWorkerSessions(coreSessions, projectFilter, config.projects); + const visibleSessions = requestedProjectId + ? coreSessions + : coreSessions.filter(matchesRequestedProject); + const orchestrators = listDashboardOrchestrators(visibleSessions, config.projects); + const orchestratorId = orchestrators.length === 1 ? (orchestrators[0]?.id ?? null) : null; + + let workerSessions = visibleSessions.filter((session) => !isOrchestratorSession(session)); - // Convert to dashboard format let dashboardSessions = workerSessions.map(sessionToDashboard); - // Filter to active sessions only if requested (keep workerSessions in sync) if (activeOnly) { const activeIndices = dashboardSessions - .map((s, i) => (s.activity !== ACTIVITY_STATE.EXITED ? i : -1)) - .filter((i) => i !== -1); - workerSessions = activeIndices.map((i) => workerSessions[i]); - dashboardSessions = activeIndices.map((i) => dashboardSessions[i]); + .map((session, index) => (session.activity !== ACTIVITY_STATE.EXITED ? index : -1)) + .filter((index) => index !== -1); + workerSessions = activeIndices.map((index) => workerSessions[index]); + dashboardSessions = activeIndices.map((index) => dashboardSessions[index]); } const metadataSettled = await settlesWithin( @@ -89,6 +101,7 @@ export async function GET(request: Request) { sessions: dashboardSessions, stats: computeStats(dashboardSessions), orchestratorId, + orchestrators, globalPause: resolveGlobalPause(coreSessions), }); } catch (err) { diff --git a/packages/web/src/lib/serialize.ts b/packages/web/src/lib/serialize.ts index d38ae5ff9..d8668cb49 100644 --- a/packages/web/src/lib/serialize.ts +++ b/packages/web/src/lib/serialize.ts @@ -5,17 +5,23 @@ * (string dates, flattened DashboardPR) suitable for JSON serialization. */ -import type { - Session, - Agent, - SCM, - PRInfo, - Tracker, - ProjectConfig, - OrchestratorConfig, - PluginRegistry, +import { + isOrchestratorSession, + type Session, + type Agent, + type SCM, + type PRInfo, + type Tracker, + type ProjectConfig, + type OrchestratorConfig, + type PluginRegistry, } from "@composio/ao-core"; -import type { DashboardSession, DashboardPR, DashboardStats } from "./types.js"; +import type { + DashboardSession, + DashboardPR, + DashboardStats, + DashboardOrchestratorLink, +} from "./types.js"; import { TTLCache, prCache, prCacheKey, type PREnrichmentData } from "./cache"; /** Cache for issue titles (5 min TTL — issue titles rarely change) */ @@ -55,9 +61,7 @@ export function sessionToDashboard(session: Session): DashboardSession { issueLabel: null, // Will be enriched by enrichSessionIssue() issueTitle: null, // Will be enriched by enrichSessionIssueTitle() summary, - summaryIsFallback: agentSummary - ? (session.agentInfo?.summaryIsFallback ?? false) - : false, + summaryIsFallback: agentSummary ? (session.agentInfo?.summaryIsFallback ?? false) : false, createdAt: session.createdAt.toISOString(), lastActivityAt: session.lastActivityAt.toISOString(), pr: session.pr ? basicPRToDashboard(session.pr) : null, @@ -65,6 +69,20 @@ export function sessionToDashboard(session: Session): DashboardSession { }; } +export function listDashboardOrchestrators( + sessions: Session[], + projects: Record, +): DashboardOrchestratorLink[] { + return sessions + .filter((session) => isOrchestratorSession(session)) + .map((session) => ({ + id: session.id, + projectId: session.projectId, + projectName: projects[session.projectId]?.name ?? session.projectId, + })) + .sort((a, b) => a.projectName.localeCompare(b.projectName) || a.id.localeCompare(b.id)); +} + /** * Convert minimal PRInfo to a DashboardPR with default values for enriched fields. * These defaults indicate "data not yet loaded" rather than "failing". diff --git a/packages/web/src/lib/types.ts b/packages/web/src/lib/types.ts index 65bd6cfe1..af9249385 100644 --- a/packages/web/src/lib/types.ts +++ b/packages/web/src/lib/types.ts @@ -130,6 +130,12 @@ export interface DashboardStats { needsReview: number; } +export interface DashboardOrchestratorLink { + id: string; + projectId: string; + projectName: string; +} + /** SSE snapshot event from /api/events */ export interface SSESnapshotEvent { type: "snapshot"; From b97bac5e941ef4f3a2d4d5957076ec7f6a6ad0bf Mon Sep 17 00:00:00 2001 From: Harsh Date: Wed, 11 Mar 2026 23:25:53 +0530 Subject: [PATCH 04/16] 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} ))}
From ac49fb4b81f813c7dec10ee93243fa3901888d25 Mon Sep 17 00:00:00 2001 From: Harsh Date: Wed, 11 Mar 2026 23:26:01 +0530 Subject: [PATCH 05/16] fix: separate orchestrators from worker status output Keep orchestrator control sessions visible in CLI status without counting them as worker sessions, and preserve explicit roles in JSON output for multi-project tooling. --- .../cli/__tests__/commands/status.test.ts | 100 ++++++++++++++++++ packages/cli/src/commands/status.ts | 63 ++++++++--- 2 files changed, 147 insertions(+), 16 deletions(-) diff --git a/packages/cli/__tests__/commands/status.test.ts b/packages/cli/__tests__/commands/status.test.ts index 10c5e3cac..3139922f4 100644 --- a/packages/cli/__tests__/commands/status.test.ts +++ b/packages/cli/__tests__/commands/status.test.ts @@ -158,6 +158,25 @@ function buildSessionsFromDir( }); } +function makeSession(overrides: Partial & { id: string; projectId: string }): Session { + return { + id: overrides.id, + projectId: overrides.projectId, + status: "working", + activity: null, + branch: null, + issueId: null, + pr: null, + workspacePath: null, + runtimeHandle: { id: overrides.id, runtimeName: "tmux", data: {} }, + agentInfo: null, + createdAt: new Date(), + lastActivityAt: new Date(), + metadata: {}, + ...overrides, + } satisfies Session; +} + vi.mock("../../src/lib/create-session-manager.js", () => ({ getSessionManager: async (): Promise => mockSessionManager as SessionManager, })); @@ -730,4 +749,85 @@ describe("status command", () => { expect(parsed[0].prNumber).toBeNull(); expect(mockDetectPR).not.toHaveBeenCalled(); }); + + it("shows one orchestrator per project without counting them as worker sessions", async () => { + mockConfigRef.current = { + ...(mockConfigRef.current as Record), + projects: { + "my-app": { + name: "My App", + repo: "org/my-app", + path: join(tmpDir, "main-repo"), + defaultBranch: "main", + sessionPrefix: "app", + scm: { plugin: "github" }, + }, + docs: { + name: "Docs", + repo: "org/docs", + path: join(tmpDir, "docs-repo"), + defaultBranch: "main", + sessionPrefix: "docs", + scm: { plugin: "github" }, + }, + }, + } as Record; + + mockSessionManager.list.mockResolvedValue([ + makeSession({ + id: "app-orchestrator", + projectId: "my-app", + metadata: { role: "orchestrator", summary: "Manage app agents" }, + }), + makeSession({ id: "app-1", projectId: "my-app", branch: "feat/app", activity: "active" }), + makeSession({ + id: "docs-orchestrator", + projectId: "docs", + metadata: { role: "orchestrator" }, + }), + ]); + mockGit.mockResolvedValue(null); + mockIntrospect.mockResolvedValue(null); + + await program.parseAsync(["node", "test", "status"]); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n"); + expect(output).toContain("Orchestrator:"); + expect(output).toContain("app-orchestrator"); + expect(output).toContain("docs-orchestrator"); + expect(output).toContain("1 active session across 2 projects · 2 orchestrators"); + }); + + it("includes orchestrators in JSON output with explicit roles", async () => { + mockSessionManager.list.mockResolvedValue([ + makeSession({ + id: "app-orchestrator", + projectId: "my-app", + metadata: { role: "orchestrator" }, + }), + makeSession({ + id: "app-1", + projectId: "my-app", + branch: "feat/json-worker", + activity: "ready", + }), + ]); + mockGit.mockResolvedValue(null); + + await program.parseAsync(["node", "test", "status", "--json"]); + + const jsonCalls = consoleSpy.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(jsonCalls); + expect(parsed).toHaveLength(2); + expect( + parsed.find((entry: { name: string }) => entry.name === "app-orchestrator"), + ).toMatchObject({ + role: "orchestrator", + project: "my-app", + }); + expect(parsed.find((entry: { name: string }) => entry.name === "app-1")).toMatchObject({ + role: "worker", + project: "my-app", + }); + }); }); diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index c25a5344a..785009ae1 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -10,6 +10,7 @@ import { type ActivityState, type Tracker, type ProjectConfig, + isOrchestratorSession, loadConfig, } from "@composio/ao-core"; import { git, getTmuxSessions, getTmuxActivity } from "../lib/shell.js"; @@ -27,6 +28,7 @@ import { getSessionManager } from "../lib/create-session-manager.js"; interface SessionInfo { name: string; + role: "worker" | "orchestrator"; branch: string | null; status: string | null; summary: string | null; @@ -42,10 +44,6 @@ interface SessionInfo { activity: ActivityState | null; } -function isOrchestratorSession(session: Session): boolean { - return session.metadata["role"] === "orchestrator" || session.id.endsWith("-orchestrator"); -} - async function gatherSessionInfo( session: Session, agent: Agent, @@ -122,6 +120,7 @@ async function gatherSessionInfo( return { name: session.id, + role: isOrchestratorSession(session) ? "orchestrator" : "worker", branch, status, summary, @@ -193,6 +192,18 @@ function printSessionRow(info: SessionInfo): void { } } +function printOrchestratorRow(info: SessionInfo): void { + const lastActivity = + info.lastActivity === "-" ? chalk.dim("unknown") : chalk.dim(info.lastActivity); + console.log( + ` ${chalk.magenta("Orchestrator:")} ${chalk.green(info.name)} ${chalk.dim("(")}${lastActivity}${chalk.dim(")")}`, + ); + const displaySummary = info.claudeSummary || info.summary; + if (displaySummary) { + console.log(` ${chalk.dim(displaySummary.slice(0, 60))}`); + } +} + export function registerStatus(program: Command): void { program .command("status") @@ -234,8 +245,9 @@ export function registerStatus(program: Command): void { // Show projects that have no sessions too (if not filtered) const projectIds = opts.project ? [opts.project] : Object.keys(config.projects); - let totalSessions = 0; const jsonOutput: SessionInfo[] = []; + let totalWorkers = 0; + let totalOrchestrators = 0; for (const projectId of projectIds) { const projectConfig = config.projects[projectId]; @@ -262,27 +274,43 @@ export function registerStatus(program: Command): void { continue; } - totalSessions += projectSessions.length; - - if (!opts.json) { - printTableHeader(); - } - // Gather all session info in parallel const infoPromises = projectSessions.map((s) => gatherSessionInfo(s, agent, scm, config)); const sessionInfos = await Promise.all(infoPromises); + const orchestrators = sessionInfos.filter((info) => info.role === "orchestrator"); + const workers = sessionInfos.filter((info) => info.role === "worker"); + + totalWorkers += workers.length; + totalOrchestrators += orchestrators.length; + for (const info of sessionInfos) { if (opts.json) { jsonOutput.push(info); - } else { - printSessionRow(info); } } - if (!opts.json) { - console.log(); + if (opts.json) { + continue; } + + if (orchestrators.length > 0) { + for (const info of orchestrators) { + printOrchestratorRow(info); + } + } + + if (workers.length === 0) { + console.log(chalk.dim(" (no active sessions)")); + console.log(); + continue; + } + + printTableHeader(); + for (const info of workers) { + printSessionRow(info); + } + console.log(); } if (opts.json) { @@ -290,7 +318,10 @@ export function registerStatus(program: Command): void { } else { console.log( chalk.dim( - ` ${totalSessions} active session${totalSessions !== 1 ? "s" : ""} across ${projectIds.length} project${projectIds.length !== 1 ? "s" : ""}`, + ` ${totalWorkers} active session${totalWorkers !== 1 ? "s" : ""} across ${projectIds.length} project${projectIds.length !== 1 ? "s" : ""}` + + (totalOrchestrators > 0 + ? ` · ${totalOrchestrators} orchestrator${totalOrchestrators !== 1 ? "s" : ""}` + : ""), ), ); From 16a5c82b57787f42c91d49a7beadb5703fb90ae1 Mon Sep 17 00:00:00 2001 From: Harsh Date: Wed, 11 Mar 2026 23:39:59 +0530 Subject: [PATCH 06/16] fix: align clean-branch merges with project filtering Preserve the legacy project fallback in the shared session loader and keep the sessions route filtering by project after the clean branch cherry-pick, so the final PR branch matches the verified multi-project behavior. --- packages/core/src/session-manager.ts | 16 ++++++++-------- packages/web/src/app/api/sessions/route.ts | 7 ++++--- packages/web/src/app/page.tsx | 2 -- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 548f0694f..b2b7b0e54 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -1208,7 +1208,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM const existingRaw = readMetadataRaw(sessionsDir, sessionId); const existingOrchestrator = existingRaw?.["runtimeHandle"] - ? metadataToSession(sessionId, existingRaw) + ? metadataToSession(sessionId, existingRaw, orchestratorConfig.projectId) : null; if (existingOrchestrator?.runtimeHandle) { const existingAlive = await plugins.runtime @@ -1217,7 +1217,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM if (existingAlive && orchestratorSessionStrategy === "reuse") { const persistedRaw = readMetadataRaw(sessionsDir, sessionId); if (persistedRaw?.["runtimeHandle"]) { - const persisted = metadataToSession(sessionId, persistedRaw); + const persisted = metadataToSession( + sessionId, + persistedRaw, + orchestratorConfig.projectId, + ); persisted.metadata["orchestratorSessionReused"] = "true"; return persisted; } @@ -1246,7 +1250,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM // Check if the session now exists and is alive. const concurrentRaw = readMetadataRaw(sessionsDir, sessionId); const concurrentSession = concurrentRaw?.["runtimeHandle"] - ? metadataToSession(sessionId, concurrentRaw) + ? metadataToSession(sessionId, concurrentRaw, orchestratorConfig.projectId) : null; if (concurrentSession?.runtimeHandle) { const concurrentAlive = await plugins.runtime @@ -1479,17 +1483,13 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM // If stat fails, timestamps will fall back to current time } -<<<<<<< HEAD const repaired = repairSingleSessionMetadataOnRead(sessionsDir, { sessionName: sessionId, raw, modifiedAt, }); -======= - const session = metadataToSession(sessionId, raw, projectId, createdAt, modifiedAt); ->>>>>>> d502cd1 (fix: preserve project ownership for legacy sessions) - const session = metadataToSession(sessionId, repaired.raw, createdAt, modifiedAt); + const session = metadataToSession(sessionId, repaired.raw, projectId, createdAt, modifiedAt); const selectedAgentName = repaired.raw["agent"]; const effectiveAgentName = selectedAgentName ?? project.agent ?? config.defaults.agent; diff --git a/packages/web/src/app/api/sessions/route.ts b/packages/web/src/app/api/sessions/route.ts index f35b73d8e..98c7180ca 100644 --- a/packages/web/src/app/api/sessions/route.ts +++ b/packages/web/src/app/api/sessions/route.ts @@ -51,12 +51,13 @@ export async function GET(request: Request) { const matchesRequestedProject = (session: { id: string; projectId: string }): boolean => { if (!projectFilter || projectFilter === "all") return true; if (session.projectId === projectFilter) return true; + if (config.projects[projectFilter]?.sessionPrefix) { + return session.id.startsWith(config.projects[projectFilter].sessionPrefix); + } return config.projects[session.projectId]?.sessionPrefix === projectFilter; }; - const visibleSessions = requestedProjectId - ? coreSessions - : coreSessions.filter(matchesRequestedProject); + const visibleSessions = coreSessions.filter(matchesRequestedProject); const orchestrators = listDashboardOrchestrators(visibleSessions, config.projects); const orchestratorId = orchestrators.length === 1 ? (orchestrators[0]?.id ?? null) : null; diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx index ddca05d7b..0e081d39c 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -118,8 +118,6 @@ export default async function Home(props: { searchParams: Promise<{ project?: st await Promise.race([Promise.allSettled(enrichPromises), enrichTimeout]); } catch { sessions = []; - globalPause = null; - orchestrators = []; } const projectName = getSelectedProjectName(projectFilter); From c537e01aeee16e88c88ba59949c8e6b950f6e191 Mon Sep 17 00:00:00 2001 From: Harsh Date: Thu, 12 Mar 2026 01:08:26 +0530 Subject: [PATCH 07/16] fix: stabilize orchestrator detail zone refresh Keep the orchestrator detail page keyed on stable project-scoped values so zone-count polling does not recreate its callback on every session fetch and hammer the sessions API. --- packages/web/src/app/sessions/[id]/page.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/web/src/app/sessions/[id]/page.tsx b/packages/web/src/app/sessions/[id]/page.tsx index 00d021ef0..8a87bba57 100644 --- a/packages/web/src/app/sessions/[id]/page.tsx +++ b/packages/web/src/app/sessions/[id]/page.tsx @@ -52,6 +52,8 @@ export default function SessionPage() { const [zoneCounts, setZoneCounts] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const sessionProjectId = session?.projectId ?? null; + const sessionIsOrchestrator = session ? isOrchestratorSession(session) : false; // Update document title based on session data useEffect(() => { @@ -84,9 +86,9 @@ export default function SessionPage() { }, [id]); const fetchZoneCounts = useCallback(async () => { - if (!session || !isOrchestratorSession(session) || !session.projectId) return; + if (!sessionIsOrchestrator || !sessionProjectId) return; try { - const res = await fetch(`/api/sessions?project=${encodeURIComponent(session.projectId)}`); + const res = await fetch(`/api/sessions?project=${encodeURIComponent(sessionProjectId)}`); if (!res.ok) return; const body = (await res.json()) as { sessions: DashboardSession[] }; const sessions = body.sessions ?? []; @@ -105,9 +107,9 @@ export default function SessionPage() { } setZoneCounts(counts); } catch { - // non-critical — status strip just won't show + // non-critical - status strip just won't show } - }, [session]); + }, [sessionIsOrchestrator, sessionProjectId]); // Initial fetch — session first, zone counts after (avoids blocking on slow /api/sessions) useEffect(() => { @@ -150,7 +152,7 @@ export default function SessionPage() { return ( ); From 9e1f58daa098bc11b70708d6a0fe7752db9ba087 Mon Sep 17 00:00:00 2001 From: Harsh Date: Thu, 12 Mar 2026 01:52:57 +0530 Subject: [PATCH 08/16] fix: share orchestrator detection across web filters Use the core orchestrator detector in web filtering paths so SSE snapshots, backlog polling, and pause state all treat metadata-role orchestrators consistently with the dashboard and sessions API. --- packages/web/src/__tests__/api-routes.test.ts | 26 +++++++++++++++++++ packages/web/src/lib/global-pause.ts | 3 ++- packages/web/src/lib/project-utils.ts | 8 +++--- packages/web/src/lib/services.ts | 3 ++- 4 files changed, 33 insertions(+), 7 deletions(-) diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index f2b4a0569..0e8d311af 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -725,6 +725,32 @@ describe("API Routes", () => { expect(sessionIds.every((id: string) => !id.endsWith("-orchestrator"))).toBe(true); }); + it("excludes metadata-role orchestrators from snapshots", async () => { + const sessionsWithMetadataRoleOrchestrator = [ + ...testSessions, + makeSession({ + id: "control-session", + projectId: "my-app", + status: "working", + activity: "active", + metadata: { role: "orchestrator" }, + }), + ]; + (mockSessionManager.list as ReturnType).mockResolvedValueOnce( + sessionsWithMetadataRoleOrchestrator, + ); + + const res = await eventsGET(makeRequest("http://localhost:3000/api/events")); + const reader = res.body!.getReader(); + const { value } = await reader.read(); + reader.cancel(); + const text = new TextDecoder().decode(value); + const event = JSON.parse(text.replace("data: ", "").trim()); + + const sessionIds = event.sessions.map((s: { id: string }) => s.id); + expect(sessionIds).not.toContain("control-session"); + }); + it("filters sessions by project query param", async () => { const multiProjectSessions = [ ...testSessions, diff --git a/packages/web/src/lib/global-pause.ts b/packages/web/src/lib/global-pause.ts index f75903014..351a2dfe3 100644 --- a/packages/web/src/lib/global-pause.ts +++ b/packages/web/src/lib/global-pause.ts @@ -2,6 +2,7 @@ import { GLOBAL_PAUSE_REASON_KEY, GLOBAL_PAUSE_SOURCE_KEY, GLOBAL_PAUSE_UNTIL_KEY, + isOrchestratorSession, parsePauseUntil, } from "@composio/ao-core"; @@ -14,7 +15,7 @@ export interface GlobalPauseState { export function resolveGlobalPause( sessions: Array<{ id: string; metadata: Record }>, ): GlobalPauseState | null { - const orchestrator = sessions.find((session) => session.id.endsWith("-orchestrator")); + const orchestrator = sessions.find((session) => isOrchestratorSession(session)); const pausedUntilRaw = orchestrator?.metadata[GLOBAL_PAUSE_UNTIL_KEY]; const parsed = parsePauseUntil(pausedUntilRaw); if (!parsed || parsed.getTime() <= Date.now()) return null; diff --git a/packages/web/src/lib/project-utils.ts b/packages/web/src/lib/project-utils.ts index 548ec441b..4089a7393 100644 --- a/packages/web/src/lib/project-utils.ts +++ b/packages/web/src/lib/project-utils.ts @@ -1,5 +1,7 @@ +import { isOrchestratorSession } from "@composio/ao-core"; + type ProjectWithPrefix = { sessionPrefix?: string }; -type SessionLike = { id: string; projectId: string }; +type SessionLike = { id: string; projectId: string; metadata?: Record }; /** * Check if a session belongs to a specific project. @@ -20,10 +22,6 @@ function matchesProject( return false; } -function isOrchestratorSession(session: { id: string }): boolean { - return session.id.endsWith("-orchestrator"); -} - export function findOrchestratorSessionId( sessions: T[], projectFilter: string | null | undefined, diff --git a/packages/web/src/lib/services.ts b/packages/web/src/lib/services.ts index 00c364d5f..5d1a8f780 100644 --- a/packages/web/src/lib/services.ts +++ b/packages/web/src/lib/services.ts @@ -30,6 +30,7 @@ import { type Session, type DecomposerConfig, DEFAULT_DECOMPOSER_CONFIG, + isOrchestratorSession, TERMINAL_STATUSES, } from "@composio/ao-core"; @@ -219,7 +220,7 @@ export async function pollBacklog(): Promise { await relabelReopenedIssues(config, registry); const workerSessions = allSessions.filter( - (s) => !s.id.endsWith("-orchestrator") && !TERMINAL_STATUSES.has(s.status), + (session) => !isOrchestratorSession(session) && !TERMINAL_STATUSES.has(session.status), ); const activeIssueIds = new Set( workerSessions.filter((s) => s.issueId).map((s) => s.issueId!.toLowerCase()), From 58ad53e8a326dbc94b844fc8bf96e4cebb5730e7 Mon Sep 17 00:00:00 2001 From: Harsh Date: Thu, 12 Mar 2026 01:58:55 +0530 Subject: [PATCH 09/16] fix: close remaining Bugbot findings Carry legacy project ownership through recovery, drop the redundant dashboard worker filter, and remove the now-unused orchestrator helper so the PR branch stays aligned with the shared per-project orchestration contract. --- .../src/__tests__/recovery-actions.test.ts | 23 +++++++++++++++++++ packages/core/src/recovery/actions.ts | 1 + packages/web/src/app/page.tsx | 5 +--- packages/web/src/lib/project-utils.ts | 16 ------------- 4 files changed, 25 insertions(+), 20 deletions(-) diff --git a/packages/core/src/__tests__/recovery-actions.test.ts b/packages/core/src/__tests__/recovery-actions.test.ts index 1f9cfc392..546262dd6 100644 --- a/packages/core/src/__tests__/recovery-actions.test.ts +++ b/packages/core/src/__tests__/recovery-actions.test.ts @@ -126,6 +126,29 @@ describe("recoverSession", () => { expect(metadata?.["recoveredAt"]).toBeUndefined(); }); + it("preserves project ownership when legacy metadata omits the project field", async () => { + rootDir = join(tmpdir(), `ao-recovery-${randomUUID()}`); + mkdirSync(rootDir, { recursive: true }); + mkdirSync(join(rootDir, "project"), { recursive: true }); + writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8"); + + const config = makeConfig(rootDir); + const registry = makeRegistry(); + const assessment = makeAssessment({ + rawMetadata: { + branch: "feature/recover", + worktree: join(rootDir, "project"), + status: "needs_input", + }, + }); + const context = makeContext(rootDir); + + const result = await recoverSession(assessment, config, registry, context); + + expect(result.success).toBe(true); + expect(result.session?.projectId).toBe("app"); + }); + it("returns the max-attempt reason when recovery escalates", async () => { rootDir = join(tmpdir(), `ao-recovery-${randomUUID()}`); mkdirSync(rootDir, { recursive: true }); diff --git a/packages/core/src/recovery/actions.ts b/packages/core/src/recovery/actions.ts index 11d4bb762..1df1b5681 100644 --- a/packages/core/src/recovery/actions.ts +++ b/packages/core/src/recovery/actions.ts @@ -72,6 +72,7 @@ export async function recoverSession( }; const session = sessionFromMetadata(sessionId, updatedMetadata, { + projectId: assessment.projectId, status: preservedStatus, runtimeHandle: assessment.runtimeHandle, lastActivityAt: new Date(), diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx index 0e081d39c..87f4c301a 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -3,7 +3,6 @@ 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, @@ -60,9 +59,7 @@ export default async function Home(props: { searchParams: Promise<{ project?: st orchestrators = listDashboardOrchestrators(visibleSessions, config.projects); - const coreSessions = filterWorkerSessions(allSessions, projectFilter, config.projects).filter( - (session) => !isOrchestratorSession(session), - ); + const coreSessions = filterWorkerSessions(allSessions, projectFilter, config.projects); sessions = coreSessions.map(sessionToDashboard); const metaTimeout = new Promise((resolve) => setTimeout(resolve, 3_000)); diff --git a/packages/web/src/lib/project-utils.ts b/packages/web/src/lib/project-utils.ts index 4089a7393..8b0868b48 100644 --- a/packages/web/src/lib/project-utils.ts +++ b/packages/web/src/lib/project-utils.ts @@ -22,22 +22,6 @@ function matchesProject( return false; } -export function findOrchestratorSessionId( - sessions: T[], - projectFilter: string | null | undefined, - projects: Record, -): string | null { - if (projectFilter && projectFilter !== "all") { - const session = sessions.find( - (s) => isOrchestratorSession(s) && matchesProject(s, projectFilter, projects), - ); - return session?.id ?? null; - } - - const session = sessions.find((s) => isOrchestratorSession(s)); - return session?.id ?? null; -} - export function filterWorkerSessions( sessions: T[], projectFilter: string | null | undefined, From 1d1c4533a2ed78d1e859518026c42d4c5582d757 Mon Sep 17 00:00:00 2001 From: Harsh Date: Thu, 12 Mar 2026 02:07:52 +0530 Subject: [PATCH 10/16] fix: unify page project filtering and client helpers Use the same project matching logic for dashboard orchestrators and workers, and switch the client detail page to the lightweight core types entry so the canonical orchestrator helper stays shared without pulling server-only modules into the browser bundle. --- packages/web/src/app/page.tsx | 11 ++--------- packages/web/src/app/sessions/[id]/page.tsx | 5 +---- packages/web/src/lib/project-utils.ts | 12 ++++++++++-- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx index 87f4c301a..67906979e 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -13,7 +13,7 @@ import { } from "@/lib/serialize"; import { prCache, prCacheKey } from "@/lib/cache"; import { getPrimaryProjectId, getProjectName, getAllProjects } from "@/lib/project-name"; -import { filterWorkerSessions } from "@/lib/project-utils"; +import { filterProjectSessions, filterWorkerSessions } from "@/lib/project-utils"; import { resolveGlobalPause, type GlobalPauseState } from "@/lib/global-pause"; function getSelectedProjectName(projectFilter: string | undefined): string { @@ -48,14 +48,7 @@ export default async function Home(props: { searchParams: Promise<{ project?: st globalPause = resolveGlobalPause(allSessions); - const visibleSessions = - projectFilter && projectFilter !== "all" - ? allSessions.filter( - (session) => - session.projectId === projectFilter || - config.projects[session.projectId]?.sessionPrefix === projectFilter, - ) - : allSessions; + const visibleSessions = filterProjectSessions(allSessions, projectFilter, config.projects); orchestrators = listDashboardOrchestrators(visibleSessions, config.projects); diff --git a/packages/web/src/app/sessions/[id]/page.tsx b/packages/web/src/app/sessions/[id]/page.tsx index 8a87bba57..fb855b564 100644 --- a/packages/web/src/app/sessions/[id]/page.tsx +++ b/packages/web/src/app/sessions/[id]/page.tsx @@ -2,14 +2,11 @@ import { useEffect, useState, useCallback } from "react"; import { useParams } from "next/navigation"; +import { isOrchestratorSession } from "@composio/ao-core/types"; 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; } diff --git a/packages/web/src/lib/project-utils.ts b/packages/web/src/lib/project-utils.ts index 8b0868b48..8ff34e64f 100644 --- a/packages/web/src/lib/project-utils.ts +++ b/packages/web/src/lib/project-utils.ts @@ -22,12 +22,20 @@ function matchesProject( return false; } +export function filterProjectSessions( + sessions: T[], + projectFilter: string | null | undefined, + projects: Record, +): T[] { + if (!projectFilter || projectFilter === "all") return sessions; + return sessions.filter((session) => matchesProject(session, projectFilter, projects)); +} + export function filterWorkerSessions( sessions: T[], projectFilter: string | null | undefined, projects: Record, ): T[] { const workers = sessions.filter((s) => !isOrchestratorSession(s)); - if (!projectFilter || projectFilter === "all") return workers; - return workers.filter((s) => matchesProject(s, projectFilter, projects)); + return filterProjectSessions(workers, projectFilter, projects); } From 3968bb0dc8333105d495299563a8288ada4a0248 Mon Sep 17 00:00:00 2001 From: Harsh Date: Thu, 12 Mar 2026 02:15:03 +0530 Subject: [PATCH 11/16] fix: reset dashboard state on page load failures Keep the SSR dashboard page using unified project filtering while clearing orchestrator links and pause state whenever enrichment fails, so error paths cannot render stale state beside an empty session list. --- packages/web/src/app/page.tsx | 58 +++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx index 67906979e..24281b4e5 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -37,27 +37,33 @@ export async function generateMetadata(props: { export default async function Home(props: { searchParams: Promise<{ project?: string }> }) { const searchParams = await props.searchParams; - let sessions: DashboardSession[] = []; - let globalPause: GlobalPauseState | null = null; - let orchestrators: Array<{ id: string; projectId: string; projectName: string }> = []; const projectFilter = searchParams.project ?? getPrimaryProjectId(); + const pageData: { + sessions: DashboardSession[]; + globalPause: GlobalPauseState | null; + orchestrators: Array<{ id: string; projectId: string; projectName: string }>; + } = { + sessions: [], + globalPause: null, + orchestrators: [], + }; try { const { config, registry, sessionManager } = await getServices(); const allSessions = await sessionManager.list(); - globalPause = resolveGlobalPause(allSessions); + pageData.globalPause = resolveGlobalPause(allSessions); const visibleSessions = filterProjectSessions(allSessions, projectFilter, config.projects); - orchestrators = listDashboardOrchestrators(visibleSessions, config.projects); + pageData.orchestrators = listDashboardOrchestrators(visibleSessions, config.projects); const coreSessions = filterWorkerSessions(allSessions, projectFilter, config.projects); - sessions = coreSessions.map(sessionToDashboard); + pageData.sessions = coreSessions.map(sessionToDashboard); const metaTimeout = new Promise((resolve) => setTimeout(resolve, 3_000)); await Promise.race([ - enrichSessionsMetadata(coreSessions, sessions, config, registry), + enrichSessionsMetadata(coreSessions, pageData.sessions, config, registry), metaTimeout, ]); @@ -69,25 +75,29 @@ export default async function Home(props: { searchParams: Promise<{ project?: st const cached = prCache.get(cacheKey); if (cached) { - if (sessions[i].pr) { - sessions[i].pr.state = cached.state; - sessions[i].pr.title = cached.title; - sessions[i].pr.additions = cached.additions; - sessions[i].pr.deletions = cached.deletions; - sessions[i].pr.ciStatus = cached.ciStatus as "none" | "pending" | "passing" | "failing"; - sessions[i].pr.reviewDecision = cached.reviewDecision as + if (pageData.sessions[i].pr) { + pageData.sessions[i].pr.state = cached.state; + pageData.sessions[i].pr.title = cached.title; + pageData.sessions[i].pr.additions = cached.additions; + pageData.sessions[i].pr.deletions = cached.deletions; + pageData.sessions[i].pr.ciStatus = cached.ciStatus as + | "none" + | "pending" + | "passing" + | "failing"; + pageData.sessions[i].pr.reviewDecision = cached.reviewDecision as | "none" | "pending" | "approved" | "changes_requested"; - sessions[i].pr.ciChecks = cached.ciChecks.map((c) => ({ + pageData.sessions[i].pr.ciChecks = cached.ciChecks.map((c) => ({ name: c.name, status: c.status as "pending" | "running" | "passed" | "failed" | "skipped", url: c.url, })); - sessions[i].pr.mergeability = cached.mergeability; - sessions[i].pr.unresolvedThreads = cached.unresolvedThreads; - sessions[i].pr.unresolvedComments = cached.unresolvedComments; + pageData.sessions[i].pr.mergeability = cached.mergeability; + pageData.sessions[i].pr.unresolvedThreads = cached.unresolvedThreads; + pageData.sessions[i].pr.unresolvedComments = cached.unresolvedComments; } if ( @@ -102,12 +112,14 @@ export default async function Home(props: { searchParams: Promise<{ project?: st const project = resolveProject(core, config.projects); const scm = getSCM(registry, project); if (!scm) return Promise.resolve(); - return enrichSessionPR(sessions[i], scm, core.pr); + return enrichSessionPR(pageData.sessions[i], scm, core.pr); }); const enrichTimeout = new Promise((resolve) => setTimeout(resolve, 4_000)); await Promise.race([Promise.allSettled(enrichPromises), enrichTimeout]); } catch { - sessions = []; + pageData.sessions = []; + pageData.globalPause = null; + pageData.orchestrators = []; } const projectName = getSelectedProjectName(projectFilter); @@ -116,12 +128,12 @@ export default async function Home(props: { searchParams: Promise<{ project?: st return ( ); } From 7025c1c2e1b1fd456b85b14538b0960333fe5cf8 Mon Sep 17 00:00:00 2001 From: Harsh Date: Thu, 12 Mar 2026 02:26:04 +0530 Subject: [PATCH 12/16] fix: align project filtering and global pause state Use one project-matching rule across the sessions API and SSR dashboard paths, and keep global pause derived from the full session set even when the API request is scoped to a single project. --- packages/web/src/__tests__/api-routes.test.ts | 43 +++++++++++++++++++ packages/web/src/app/api/sessions/route.ts | 16 ++----- packages/web/src/lib/project-utils.ts | 2 +- 3 files changed, 48 insertions(+), 13 deletions(-) diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 0e8d311af..90d42adde 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -359,6 +359,49 @@ describe("API Routes", () => { expect(data.sessions.map((session: { id: string }) => session.id)).toEqual(["docs-2"]); expect(mockSessionManager.list).toHaveBeenCalledWith("docs-app"); }); + + it("keeps global pause sourced from all projects even for project-scoped requests", async () => { + const pausedUntil = new Date(Date.now() + 60_000).toISOString(); + const pausedSessions = [ + makeSession({ + id: "docs-orchestrator", + projectId: "docs-app", + metadata: { + role: "orchestrator", + globalPauseUntil: pausedUntil, + globalPauseReason: "Rate limit hit", + globalPauseSource: "docs-orchestrator", + }, + }), + makeSession({ id: "docs-1", projectId: "docs-app", status: "working", activity: "active" }), + makeSession({ + id: "backend-3", + projectId: "my-app", + status: "working", + activity: "active", + }), + ]; + (mockSessionManager.list as ReturnType).mockImplementation( + async (projectId?: string) => + projectId + ? pausedSessions.filter((session) => session.projectId === projectId) + : pausedSessions, + ); + + const res = await sessionsGET( + makeRequest("http://localhost:3000/api/sessions?project=my-app"), + ); + expect(res.status).toBe(200); + const data = await res.json(); + + expect(data.globalPause).toMatchObject({ + pausedUntil, + reason: "Rate limit hit", + sourceSessionId: "docs-orchestrator", + }); + expect(mockSessionManager.list).toHaveBeenNthCalledWith(1, "my-app"); + expect(mockSessionManager.list).toHaveBeenNthCalledWith(2); + }); }); // ── POST /api/spawn ──────────────────────────────────────────────── diff --git a/packages/web/src/app/api/sessions/route.ts b/packages/web/src/app/api/sessions/route.ts index 98c7180ca..c7a069009 100644 --- a/packages/web/src/app/api/sessions/route.ts +++ b/packages/web/src/app/api/sessions/route.ts @@ -10,6 +10,7 @@ import { listDashboardOrchestrators, } from "@/lib/serialize"; import { resolveGlobalPause } from "@/lib/global-pause"; +import { filterProjectSessions } from "@/lib/project-utils"; const METADATA_ENRICH_TIMEOUT_MS = 3_000; const PR_ENRICH_TIMEOUT_MS = 4_000; @@ -47,17 +48,8 @@ export async function GET(request: Request) { ? projectFilter : undefined; const coreSessions = await sessionManager.list(requestedProjectId); - - const matchesRequestedProject = (session: { id: string; projectId: string }): boolean => { - if (!projectFilter || projectFilter === "all") return true; - if (session.projectId === projectFilter) return true; - if (config.projects[projectFilter]?.sessionPrefix) { - return session.id.startsWith(config.projects[projectFilter].sessionPrefix); - } - return config.projects[session.projectId]?.sessionPrefix === projectFilter; - }; - - const visibleSessions = coreSessions.filter(matchesRequestedProject); + const allSessions = requestedProjectId ? await sessionManager.list() : coreSessions; + const visibleSessions = filterProjectSessions(coreSessions, projectFilter, config.projects); const orchestrators = listDashboardOrchestrators(visibleSessions, config.projects); const orchestratorId = orchestrators.length === 1 ? (orchestrators[0]?.id ?? null) : null; @@ -103,7 +95,7 @@ export async function GET(request: Request) { stats: computeStats(dashboardSessions), orchestratorId, orchestrators, - globalPause: resolveGlobalPause(coreSessions), + globalPause: resolveGlobalPause(allSessions), }); } catch (err) { return NextResponse.json( diff --git a/packages/web/src/lib/project-utils.ts b/packages/web/src/lib/project-utils.ts index 8ff34e64f..e58ee7962 100644 --- a/packages/web/src/lib/project-utils.ts +++ b/packages/web/src/lib/project-utils.ts @@ -19,7 +19,7 @@ function matchesProject( if (session.projectId === projectId) return true; const project = projects[projectId]; if (project?.sessionPrefix && session.id.startsWith(project.sessionPrefix)) return true; - return false; + return projects[session.projectId]?.sessionPrefix === projectId; } export function filterProjectSessions( From 1bc3199ae373c615d7fb0da6cadf8777d6cae9b0 Mon Sep 17 00:00:00 2001 From: Harsh Date: Thu, 12 Mar 2026 02:34:20 +0530 Subject: [PATCH 13/16] fix: keep global pause visible across orchestrators Scan every orchestrator for active pause metadata instead of stopping at the first control session, so pause banners stay visible even when metadata-role orchestrators appear before the paused orchestrator in session listings. --- packages/web/src/__tests__/api-routes.test.ts | 32 +++++++++++++++++++ packages/web/src/lib/global-pause.ts | 21 ++++++------ 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 90d42adde..798caba2f 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -402,6 +402,38 @@ describe("API Routes", () => { expect(mockSessionManager.list).toHaveBeenNthCalledWith(1, "my-app"); expect(mockSessionManager.list).toHaveBeenNthCalledWith(2); }); + + it("finds active global pause even when a metadata-role orchestrator appears first", async () => { + const pausedUntil = new Date(Date.now() + 60_000).toISOString(); + const sessions = [ + makeSession({ + id: "control-session", + projectId: "docs-app", + metadata: { role: "orchestrator" }, + }), + makeSession({ + id: "docs-orchestrator", + projectId: "docs-app", + metadata: { + role: "orchestrator", + globalPauseUntil: pausedUntil, + globalPauseReason: "Rate limit hit", + globalPauseSource: "docs-orchestrator", + }, + }), + ]; + (mockSessionManager.list as ReturnType).mockResolvedValueOnce(sessions); + + const res = await sessionsGET(makeRequest("http://localhost:3000/api/sessions")); + expect(res.status).toBe(200); + const data = await res.json(); + + expect(data.globalPause).toMatchObject({ + pausedUntil, + reason: "Rate limit hit", + sourceSessionId: "docs-orchestrator", + }); + }); }); // ── POST /api/spawn ──────────────────────────────────────────────── diff --git a/packages/web/src/lib/global-pause.ts b/packages/web/src/lib/global-pause.ts index 351a2dfe3..2811aada6 100644 --- a/packages/web/src/lib/global-pause.ts +++ b/packages/web/src/lib/global-pause.ts @@ -15,14 +15,17 @@ export interface GlobalPauseState { export function resolveGlobalPause( sessions: Array<{ id: string; metadata: Record }>, ): GlobalPauseState | null { - const orchestrator = sessions.find((session) => isOrchestratorSession(session)); - const pausedUntilRaw = orchestrator?.metadata[GLOBAL_PAUSE_UNTIL_KEY]; - const parsed = parsePauseUntil(pausedUntilRaw); - if (!parsed || parsed.getTime() <= Date.now()) return null; + for (const session of sessions) { + if (!isOrchestratorSession(session)) continue; + const parsed = parsePauseUntil(session.metadata[GLOBAL_PAUSE_UNTIL_KEY]); + if (!parsed || parsed.getTime() <= Date.now()) continue; - return { - pausedUntil: parsed.toISOString(), - reason: orchestrator?.metadata[GLOBAL_PAUSE_REASON_KEY] ?? "Model rate limit reached", - sourceSessionId: orchestrator?.metadata[GLOBAL_PAUSE_SOURCE_KEY] ?? null, - }; + return { + pausedUntil: parsed.toISOString(), + reason: session.metadata[GLOBAL_PAUSE_REASON_KEY] ?? "Model rate limit reached", + sourceSessionId: session.metadata[GLOBAL_PAUSE_SOURCE_KEY] ?? null, + }; + } + + return null; } From 238752b29ee6821124f7434ea96c75606e555272 Mon Sep 17 00:00:00 2001 From: Harsh Date: Thu, 12 Mar 2026 02:42:50 +0530 Subject: [PATCH 14/16] fix: reuse shared orchestrator detection in lifecycle manager Drop the lifecycle manager's local orchestrator predicate so the final remaining core polling path follows the same shared detection logic as session cleanup, web filters, and CLI status. --- packages/core/src/lifecycle-manager.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index f05547f14..0ca4438c9 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -32,6 +32,7 @@ import { type Session, type EventPriority, type ProjectConfig as _ProjectConfig, + isOrchestratorSession, } from "./types.js"; import { updateMetadata } from "./metadata.js"; import { getSessionsDir } from "./paths.js"; @@ -225,10 +226,6 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan let polling = false; // re-entrancy guard let allCompleteEmitted = false; // guard against repeated all_complete - function isOrchestratorSession(session: Session): boolean { - return session.metadata["role"] === "orchestrator" || session.id.endsWith("-orchestrator"); - } - function setProjectPause(project: _ProjectConfig, sourceSessionId: string, until: Date): void { const sessionsDir = getSessionsDir(config.configPath, project.path); const orchestratorId = `${project.sessionPrefix}-orchestrator`; From aa6e47bbd0b1f802dcd9104bb380fb60444d3302 Mon Sep 17 00:00:00 2001 From: Harsh Date: Thu, 12 Mar 2026 05:13:21 +0530 Subject: [PATCH 15/16] feat: add dedicated all-projects portfolio view Treat the all-projects dashboard as a project overview surface with per-project metrics and orchestrator access, while keeping the detailed kanban lanes for single-project workflows. --- packages/web/src/components/Dashboard.tsx | 220 +++++++++++++++++++--- 1 file changed, 193 insertions(+), 27 deletions(-) diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index 2322e5b92..391f0aa54 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -46,6 +46,7 @@ export function Dashboard({ const [rateLimitDismissed, setRateLimitDismissed] = useState(false); const [globalPauseDismissed, setGlobalPauseDismissed] = useState(false); const showSidebar = projects.length > 1; + const allProjectsView = showSidebar && projectId === undefined; const grouped = useMemo(() => { const zones: Record = { @@ -72,6 +73,35 @@ export function Dashboard({ .sort((a, b) => mergeScore(a) - mergeScore(b)); }, [sessions]); + const projectOverviews = useMemo(() => { + if (!allProjectsView) return []; + + return projects.map((project) => { + const projectSessions = sessions.filter((session) => session.projectId === project.id); + const counts: Record = { + merge: 0, + respond: 0, + review: 0, + pending: 0, + working: 0, + done: 0, + }; + + for (const session of projectSessions) { + counts[getAttentionLevel(session)]++; + } + + return { + project, + orchestrator: + orchestrators.find((orchestrator) => orchestrator.projectId === project.id) ?? null, + sessionCount: projectSessions.length, + openPRCount: projectSessions.filter((session) => session.pr?.state === "open").length, + counts, + }; + }); + }, [allProjectsView, orchestrators, projects, sessions]); + const handleSend = async (sessionId: string, message: string) => { const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/send`, { method: "POST", @@ -152,31 +182,7 @@ export function Dashboard({

- {orchestrators.length > 0 && ( - - )} + {!allProjectsView && }
{globalPause && !globalPauseDismissed && ( @@ -252,7 +258,9 @@ export function Dashboard({
)} - {hasKanbanSessions && ( + {allProjectsView && } + + {!allProjectsView && hasKanbanSessions && (
{KANBAN_LEVELS.map((level) => grouped[level].length > 0 ? ( @@ -272,7 +280,7 @@ export function Dashboard({
)} - {grouped.done.length > 0 && ( + {!allProjectsView && grouped.done.length > 0 && (
+ + orchestrator + + + + + ); + } + + return ( +
+ + + {orchestrators.length} orchestrators + + + + + +
+ ); +} + +function ProjectOverviewGrid({ + overviews, +}: { + overviews: Array<{ + project: ProjectInfo; + orchestrator: DashboardOrchestratorLink | null; + sessionCount: number; + openPRCount: number; + counts: Record; + }>; +}) { + return ( +
+ {overviews.map(({ project, orchestrator, sessionCount, openPRCount, counts }) => ( +
+
+
+

+ {project.name} +

+
+ {sessionCount} active session{sessionCount !== 1 ? "s" : ""} + {openPRCount > 0 ? ` · ${openPRCount} open PR${openPRCount !== 1 ? "s" : ""}` : ""} +
+
+ + Open project + +
+ +
+ + + + + +
+ +
+
+ {orchestrator ? "Per-project orchestrator available" : "No running orchestrator"} +
+ {orchestrator ? ( + + + orchestrator + + ) : null} +
+
+ ))} +
+ ); +} + +function ProjectMetric({ label, value, tone }: { label: string; value: number; tone: string }) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} + function StatusLine({ stats }: { stats: DashboardStats }) { if (stats.totalSessions === 0) { return no sessions;