feat(web): Project-scoped dashboard with sidebar navigation (#381)

* feat(web): add project-based dashboard architecture

- Add project query parameter to API routes
- Filter sessions by project in both SSR and SSE
- Update useSessionEvents hook to accept project param
- Update Dashboard and pass project to hook
- Add unit tests for project filtering
- Add architecture spec document

Implements project-based architecture as defined in docs/specs/project-based-dashboard-architecture.md:
- GET /api/sessions?project=X returns sessions for project X
- GET /api/events?project=X streams only sessions for project X
- Dashboard uses project filter from config
- SSE URL includes project param when provided
- Project matching uses projectId and sessionPrefix
- Full backward compatibility maintained (no param = all sessions)

* fix: remove duplicate export default in page.tsx

* fix(web): clean project-scoped dashboard verification

* fix(web): scope dashboard using project id

* fix(web): address Bugbot findings in project-scoped dashboard

- Consolidate triplicated matchesProject into shared lib/project-utils.ts
- Fix Dashboard to receive both projectId (for SSE filtering) and projectName (for display)
- Remove inline matchesProject definitions from page.tsx, sessions/route.ts, events/route.ts

* fix(web): resolve Bugbot issues in project-scoped dashboard

- Add ?project=all query param support in SSR page to show all sessions
  (previously getPrimaryProjectId() always returned non-empty, making
  else branches unreachable)
- Exclude orchestrator sessions from SSE stream to match SSR/API behavior
  (orchestrator sessions get their own button, not a card)
- Add tests for SSE stream orchestrator exclusion and project filtering

Addresses Bugbot issues:
- #2903595986: Dead else branches when project filter always applied
- #2903595995: SSE stream includes orchestrator sessions unlike SSR/API

* feat(web): add project navigation sidebar

Add visible left sidebar with project navigation for multi-project setups:
- ProjectSidebar component with active state styling
- /api/projects endpoint to fetch configured projects
- getAllProjects() helper in project-name.ts
- Sidebar appears only when 2+ projects configured
- Click navigation updates ?project= query param
- Active project highlighted with accent color

Tests:
- ProjectSidebar component tests (8 tests)
- API routes tests with proper mocking
- All 386 web tests passing

Manual test steps:
1. Configure 2+ projects in agent-orchestrator.yaml
2. Start dashboard - sidebar should appear on left
3. Click different projects - URL updates, sessions filter
4. Click "All Projects" - shows all sessions across projects
5. Active project highlighted in sidebar

* fix(web): remove duplicate import in test file

* fix(web): consolidate ProjectInfo type to shared source

Remove duplicated ProjectInfo interface definitions from Dashboard.tsx and
ProjectSidebar.tsx. Both now import the type from @/lib/project-name where it is exported as the single source of truth.

This addresses Bugbot issue #2906835895: Triplicated ProjectInfo type instead of shared import.

* fix(web): integrate globalPause state from main

* fix(web): consolidate duplicate @/lib/types import

* feat(web): add project-based dashboard architecture

- Add project query parameter to API routes
- Filter sessions by project in both SSR and SSE
- Update useSessionEvents hook to accept project param
- Update Dashboard and pass project to hook
- Add unit tests for project filtering
- Add architecture spec document

Implements project-based architecture as defined in docs/specs/project-based-dashboard-architecture.md:
- GET /api/sessions?project=X returns sessions for project X
- GET /api/events?project=X streams only sessions for project X
- Dashboard uses project filter from config
- SSE URL includes project param when provided
- Project matching uses projectId and sessionPrefix
- Full backward compatibility maintained (no param = all sessions)

* fix: remove duplicate export default in page.tsx

* fix(web): clean project-scoped dashboard verification

* fix(web): scope dashboard using project id

* fix(web): address Bugbot findings in project-scoped dashboard

- Consolidate triplicated matchesProject into shared lib/project-utils.ts
- Fix Dashboard to receive both projectId (for SSE filtering) and projectName (for display)
- Remove inline matchesProject definitions from page.tsx, sessions/route.ts, events/route.ts

* fix(web): resolve Bugbot issues in project-scoped dashboard

- Add ?project=all query param support in SSR page to show all sessions
  (previously getPrimaryProjectId() always returned non-empty, making
  else branches unreachable)
- Exclude orchestrator sessions from SSE stream to match SSR/API behavior
  (orchestrator sessions get their own button, not a card)
- Add tests for SSE stream orchestrator exclusion and project filtering

Addresses Bugbot issues:
- #2903595986: Dead else branches when project filter always applied
- #2903595995: SSE stream includes orchestrator sessions unlike SSR/API

* feat(web): add project navigation sidebar

Add visible left sidebar with project navigation for multi-project setups:
- ProjectSidebar component with active state styling
- /api/projects endpoint to fetch configured projects
- getAllProjects() helper in project-name.ts
- Sidebar appears only when 2+ projects configured
- Click navigation updates ?project= query param
- Active project highlighted with accent color

Tests:
- ProjectSidebar component tests (8 tests)
- API routes tests with proper mocking
- All 386 web tests passing

Manual test steps:
1. Configure 2+ projects in agent-orchestrator.yaml
2. Start dashboard - sidebar should appear on left
3. Click different projects - URL updates, sessions filter
4. Click "All Projects" - shows all sessions across projects
5. Active project highlighted in sidebar

* fix(web): remove duplicate import in test file

* fix(web): consolidate ProjectInfo type to shared source

Remove duplicated ProjectInfo interface definitions from Dashboard.tsx and
ProjectSidebar.tsx. Both now import the type from @/lib/project-name where it is exported as the single source of truth.

This addresses Bugbot issue #2906835895: Triplicated ProjectInfo type instead of shared import.

* fix(web): integrate globalPause state from main

* fix(web): consolidate duplicate @/lib/types import

* fix(web): restore global pause state and membership refresh

* fix(web): satisfy lint in project-scoped page defaults

* fix(web): remove dead global pause reducer action

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix(web): restore global pause resume time

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* chore(web): retrigger bugbot after thread reset

* refactor(web): centralize project session filtering helpers

* fix(web): reset pause banner dismissal on new pause

* fix(web): remove useless orchestrator assignment

* fix(web): derive header stats from live session state

* fix(web): restore project name in dashboard header

* fix(web): restore backlog poller startup in events stream

* refactor(web): keep project-utils helpers internal

* chore: retrigger bugbot evaluation

* chore: retrigger stuck bugbot check

* fix: address latest bugbot findings for dashboard events

* test(web): add dashboard bugbot regression coverage

* refactor(web): simplify projectId selection in dashboard props

* fix(web): derive selected project name from project filter

* refactor(web): initialize dashboard defaults before service load

* fix(web): satisfy lint in dashboard page error path
This commit is contained in:
Harsh Batheja 2026-03-11 09:22:37 +05:30 committed by deepak
parent 1e56cb62c0
commit b3ff0d9b85
14 changed files with 1299 additions and 791 deletions

View File

@ -0,0 +1,458 @@
# Project-Based Dashboard Architecture
**Status:** Draft
**Author:** Agent Orchestrator
**Date:** 2026-03-09
**Target Merge:** `opencode-lifyecycle`
---
## Overview
This spec defines the architecture changes required to scope the Agent Orchestrator dashboard by project. Currently, the dashboard displays all sessions across all configured projects, which creates cognitive overhead for multi-project setups. The target architecture adds project filtering at every layer (API, SSE events, frontend state) while maintaining full backward compatibility for single-project deployments.
**Key Change:** Add optional `project` query parameter to session list/event endpoints, filtering all responses to a single project scope. Frontend will read `projectName` from config and pass it as the filter.
---
## Current State
### Data Flow
```
page.tsx (SSR)
getServices() → sessionManager.list() // NO project filter
enrichSessionsMetadata()
Dashboard.tsx
↓ (initial render)
useSessionEvents() ← EventSource("/api/events") // NO project filter
Real-time updates
```
### Key Files
| File | Role | Project Awareness |
| -------------------------------------------- | ----------------------------------------- | ----------------------------------------- |
| `packages/web/src/app/page.tsx` | SSR entry point, fetches initial sessions | Reads `projectName` for display only |
| `packages/web/src/app/api/sessions/route.ts` | GET `/api/sessions` — lists all sessions | **No filtering** |
| `packages/web/src/app/api/events/route.ts` | GET `/api/events` — SSE stream | **No filtering** |
| `packages/web/src/lib/services.ts` | Core services singleton | N/A |
| `packages/web/src/lib/serialize.ts` | Session → DashboardSession mapping | `resolveProject()` maps session → project |
| `packages/web/src/lib/types.ts` | Dashboard types | `DashboardSession.projectId` exists |
| `packages/web/src/components/Dashboard.tsx` | Main dashboard component | Displays all sessions |
| `packages/web/src/hooks/useSessionEvents.ts` | SSE event handler | Receives all sessions |
### Current API Response Shape
**GET /api/sessions**
```typescript
{
sessions: DashboardSession[]; // ALL sessions, unfiltered
stats: DashboardStats;
orchestratorId: string | null;
}
```
**GET /api/events (SSE)**
```typescript
{
type: "snapshot";
sessions: Array<{
id: string;
status: SessionStatus;
activity: ActivityState | null;
attentionLevel: AttentionLevel;
lastActivityAt: string;
}>; // ALL sessions, unfiltered
}
```
---
## Problems with Current Non-Project-Scoped Behavior
1. **Cognitive Overload** — Multi-project users see sessions from unrelated projects mixed together, making it hard to focus on one project's work.
2. **Stats Misleading**`DashboardStats` aggregates across all projects. "3 needs review" might be spread across 3 different projects, not actionable.
3. **Orchestrator Ambiguity** — Orchestrator session is found by suffix (`-orchestrator`). With multiple projects, multiple orchestrators may exist but only one is surfaced.
4. **SSE Inefficiency** — Client receives updates for ALL sessions, including irrelevant projects, wasting bandwidth and causing unnecessary re-renders.
5. **URL Non-Shareability** — Cannot share a dashboard URL scoped to a specific project. `http://localhost:3000/` shows everything.
6. **Future Multi-Tenant Blocker** — If AO ever supports multi-tenant hosting, unscoped APIs would leak data between tenants.
---
## Target Project-Based Architecture
### Design Principles
1. **Opt-in** — No `project` param = all sessions (backward compatible)
2. **Single Source of Truth**`projectName` comes from config, not URL state
3. **Filter at Source** — API and SSE filter before returning data, not client-side
4. **Zero Config for Single-Project** — Existing users see no change
5. **Type-Safe** — Project param typed in API contracts
### Target Data Flow
```
page.tsx (SSR)
↓ projectName from getProjectName()
GET /api/sessions?project=<name> // ← NEW: project filter
sessionManager.list().filter(s => s.projectId === project || matchesPrefix)
Dashboard.tsx (receives only project sessions)
useSessionEvents(projectName) ← EventSource("/api/events?project=<name>") // ← NEW
Real-time updates (project-scoped)
```
### URL Scheme
| URL | Behavior |
| ------------------ | ----------------------------------------------- |
| `/` | Scoped to first/primary project (from config) |
| `/?project=all` | Show all sessions (explicit multi-project view) |
| `/?project=my-app` | Scoped to `my-app` project |
**Default behavior:** When no `project` query param, use `projectName` from config (first project's name or `ao` fallback). This ensures single-project users see their project automatically.
---
## API Contract Changes
### 1. GET /api/sessions
**Query Parameters (NEW)**
```typescript
interface SessionsQueryParams {
/** Optional project filter. If omitted, returns all sessions. */
project?: string;
/** Existing: filter to non-exited sessions only */
active?: "true" | "false";
}
```
**Response Shape (UNCHANGED)**
```typescript
interface SessionsResponse {
sessions: DashboardSession[]; // Filtered by project if param provided
stats: DashboardStats; // Stats reflect filtered sessions only
orchestratorId: string | null; // Orchestrator for the project (if scoped)
}
```
**Behavior**
| Query | Result |
| ----------------------------- | -------------------------------------------------------------------------------------------------------- |
| No params | All sessions (backward compatible) |
| `?project=my-app` | Only sessions where `session.projectId === "my-app"` OR session ID starts with project's `sessionPrefix` |
| `?project=all` | All sessions (explicit unscoped) |
| `?project=nonexistent` | Empty sessions array, stats all zeros, `orchestratorId: null` |
| `?active=true&project=my-app` | Active sessions for project only |
**Project Resolution Logic** (reuse existing `resolveProject`)
```typescript
function matchesProject(session: Session, projectId: string, config: OrchestratorConfig): boolean {
// Direct match
if (session.projectId === projectId) return true;
// Prefix match (existing behavior in resolveProject)
const project = config.projects[projectId];
if (project?.sessionPrefix && session.id.startsWith(project.sessionPrefix)) return true;
return false;
}
```
### 2. GET /api/events (SSE)
**Query Parameters (NEW)**
```typescript
interface EventsQueryParams {
/** Optional project filter. If omitted, streams all sessions. */
project?: string;
}
```
**SSE Event Shape (UNCHANGED)**
```typescript
interface SSESnapshotEvent {
type: "snapshot";
sessions: Array<{
id: string;
status: SessionStatus;
activity: ActivityState | null;
attentionLevel: AttentionLevel;
lastActivityAt: string;
}>; // Filtered by project if param provided
}
```
**Behavior**
| Query | Result |
| ----------------- | ------------------------------------- |
| No params | Stream all sessions |
| `?project=my-app` | Stream only sessions matching project |
| `?project=all` | Stream all sessions |
---
## Frontend State Model Changes
### 1. page.tsx (SSR Entry)
**Current:**
```typescript
export default async function Home() {
const { sessionManager } = await getServices();
const allSessions = await sessionManager.list();
// ...
}
```
**Target:**
```typescript
export default async function Home({ searchParams }: { searchParams: { project?: string } }) {
const projectName = getProjectName();
const projectFilter = searchParams.project ?? projectName; // Default to config project
const res = await fetch(`${baseUrl}/api/sessions?project=${encodeURIComponent(projectFilter)}`);
const { sessions, stats, orchestratorId } = await res.json();
return (
<Dashboard
initialSessions={sessions}
stats={stats}
orchestratorId={orchestratorId}
projectName={projectFilter}
/>
);
}
```
### 2. useSessionEvents Hook
**Current:**
```typescript
export function useSessionEvents(initialSessions: DashboardSession[]): DashboardSession[] {
useEffect(() => {
const es = new EventSource("/api/events");
// ...
}, []);
}
```
**Target:**
```typescript
export function useSessionEvents(
initialSessions: DashboardSession[],
project?: string,
): DashboardSession[] {
useEffect(() => {
const url = project ? `/api/events?project=${encodeURIComponent(project)}` : "/api/events";
const es = new EventSource(url);
// ...
}, [project]);
}
```
### 3. Dashboard.tsx
**Current:**
```typescript
export function Dashboard({ initialSessions, stats, orchestratorId, projectName }: DashboardProps) {
const sessions = useSessionEvents(initialSessions);
// ...
}
```
**Target:**
```typescript
export function Dashboard({ initialSessions, stats, orchestratorId, projectName }: DashboardProps) {
const sessions = useSessionEvents(initialSessions, projectName);
// ...
}
```
**Note:** `projectName` already exists as a prop. We repurpose it to also serve as the SSE filter key.
---
## Migration / Backward Compatibility
### Backward Compatibility Guarantees
| Scenario | Before | After | Compatible? |
| ---------------------------------- | --------------------------------------------- | ------------------------------- | -------------- |
| Single project, no URL params | Shows all sessions (which is the one project) | Shows project-scoped sessions | ✅ Same result |
| Multi-project, `GET /api/sessions` | Returns all sessions | Returns all sessions (no param) | ✅ Same result |
| Multi-project, `GET /api/events` | Streams all sessions | Streams all sessions (no param) | ✅ Same result |
| Existing client using old API | Works | Works (params optional) | ✅ Same result |
### Migration Steps (Zero Downtime)
1. **Phase 1: API Support** — Add `project` query param support to both endpoints (optional param, defaults to all)
2. **Phase 2: Frontend Adoption** — Update `page.tsx` and `useSessionEvents` to pass project filter
3. **Phase 3: Documentation** — Update README and examples to document multi-project URL scheme
**No database migration required** — `Session.projectId` already exists.
### Breaking Changes
**None.** All changes are additive. Existing deployments continue to work without modification.
---
## Acceptance Criteria
### Must Have
- [ ] `GET /api/sessions?project=X` returns only sessions for project X
- [ ] `GET /api/events?project=X` streams only sessions for project X
- [ ] `DashboardStats` reflects only filtered sessions when project param present
- [ ] `orchestratorId` returns the orchestrator for the scoped project (not any orchestrator)
- [ ] `page.tsx` passes `projectName` as filter to both SSR fetch and SSE
- [ ] `useSessionEvents` accepts optional `project` param and constructs URL accordingly
- [ ] No project param = all sessions (backward compatible)
- [ ] Non-existent project = empty sessions, zero stats, null orchestrator
- [ ] Type safety: query params typed in route handlers
### Should Have
- [ ] URL `/?project=all` explicitly shows all sessions (for multi-project users who want overview)
- [ ] Project filter logged in API request for debugging
### Nice to Have
- [ ] Dashboard shows project name prominently when scoped
- [ ] Project switcher UI (future work, not in scope)
---
## Test Matrix
### Unit Tests
| Test Case | File | Description |
| -------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------- |
| Project filter matches by projectId | `api/sessions/route.test.ts` | Session with `projectId: "my-app"` appears when `?project=my-app` |
| Project filter matches by sessionPrefix | `api/sessions/route.test.ts` | Session ID `app-123` appears when `?project=my-app` where `sessionPrefix: "app"` |
| No filter returns all | `api/sessions/route.test.ts` | All sessions returned when no query param |
| Non-existent project returns empty | `api/sessions/route.test.ts` | Empty array + zero stats when `?project=nonexistent` |
| Stats reflect filtered sessions | `api/sessions/route.test.ts` | `needsReview` = 1 when filtered set has 1 review-pending PR |
| Orchestrator scoped to project | `api/sessions/route.test.ts` | Returns `my-app-orchestrator` not `other-orchestrator` when `?project=my-app` |
| SSE filters by project | `api/events/route.test.ts` | Snapshot event only includes sessions matching project |
| useSessionEvents constructs URL with project | `hooks/useSessionEvents.test.ts` | Hook creates EventSource with `?project=X` param |
### Integration Tests
| Test Case | File | Description |
| --------------------------------- | --------------------------------- | ------------------------------------------------------- |
| Full SSR flow with project filter | `app/page.test.tsx` | Page renders with filtered sessions from SSR |
| Client-SSE sync | `__tests__/api-routes.test.ts` | SSE snapshot matches SSR initial state for same project |
| Multi-project isolation | `__tests__/multi-project.test.ts` | Switching projects via URL changes session set |
### E2E Tests (Playwright)
| Test Case | Description |
| ------------------- | --------------------------------------------------------------------------------- |
| Single project view | Load dashboard, verify sessions belong to configured project |
| URL project param | Navigate to `/?project=my-app`, verify only that project's sessions |
| All projects view | Navigate to `/?project=all`, verify all sessions shown |
| Real-time filter | Spawn session in project A, verify project B dashboard doesn't receive SSE update |
---
## Self-Audit
### Risks
| Risk | Likelihood | Impact | Mitigation |
| --------------------------------------------- | ---------- | ------------------------------ | ----------------------------------------------------------------------- |
| Project name mismatch (config vs URL) | Medium | Confusion | Log warning when URL param doesn't match any configured project |
| Session missing projectId | Low | Incorrect filtering | Fallback to sessionPrefix matching (existing `resolveProject` behavior) |
| SSE client doesn't reconnect with new project | Low | Stale data | `useSessionEvents` recreates EventSource when `project` prop changes |
| Performance regression (filter overhead) | Very Low | Slower API | Filter is O(n) in-memory; negligible for typical session counts (<100) |
| Orchestrator not found for project | Medium | UI missing orchestrator button | Return `null`, gracefully hide orchestrator link |
### Edge Cases
1. **Session with `projectId` that doesn't match any config project**
- Current: Fallback to sessionPrefix or first project
- Target: Same fallback behavior; won't appear in project-scoped view unless matches prefix
2. **Multiple orchestrators (one per project)**
- Current: Only one surfaced (first found)
- Target: Orchestrator for scoped project surfaced
3. **Project renamed in config**
- Sessions with old `projectId` won't match new name
- Mitigation: sessionPrefix matching still works; user can use `?project=all` to find orphaned sessions
4. **Empty project (no sessions)**
- Returns empty sessions, zero stats
- Dashboard shows "no sessions" message (existing behavior)
5. **Special project name "all"**
- Reserved for showing all sessions
- If user has a project named "all", they must use exact match or rename project
### Rollback Plan
If issues arise post-deployment:
1. **Immediate** — Remove `project` param from frontend, revert to unfiltered behavior
2. **API** — Leave backend filter in place (backward compatible), frontend just stops using it
3. **Full Rollback** — Revert commit, redeploy. No data migration needed.
### Monitoring & Observability
- Log API requests with `project` param (debug level)
- Track SSE connections per project (metric)
- Alert on empty project results for configured projects (might indicate config drift)
---
## Out of Scope
The following are explicitly **NOT** part of this change:
- Project switcher UI component
- Per-project dashboard themes/branding
- Database schema changes
- Multi-tenant authentication/authorization
- Session migration between projects
- Project-level access control
---
## References
- `packages/web/src/lib/serialize.ts``resolveProject()` function (existing)
- `packages/core/src/types.ts``Session.projectId` field (existing)
- `agent-orchestrator.yaml.example` — Project configuration schema

View File

@ -165,9 +165,16 @@ vi.mock("@/lib/services", () => ({
startBacklogPoller: vi.fn(() => {}),
}));
vi.mock("@/lib/project-name", () => ({
getProjectName: () => "My App",
getPrimaryProjectId: () => "my-app",
getAllProjects: () => [{ id: "my-app", name: "My App" }],
}));
// ── Import routes after mocking ───────────────────────────────────────
import { GET as sessionsGET } from "@/app/api/sessions/route";
import { GET as projectsGET } from "@/app/api/projects/route";
import { POST as spawnPOST } from "@/app/api/spawn/route";
import { POST as sendPOST } from "@/app/api/sessions/[id]/send/route";
import { POST as messagePOST } from "@/app/api/sessions/[id]/message/route";
@ -563,13 +570,13 @@ describe("API Routes", () => {
describe("GET /api/events", () => {
it("returns SSE content type", async () => {
const res = await eventsGET();
const res = await eventsGET(makeRequest("http://localhost:3000/api/events"));
expect(res.headers.get("Content-Type")).toBe("text/event-stream");
expect(res.headers.get("Cache-Control")).toBe("no-cache");
});
it("streams initial snapshot event", async () => {
const res = await eventsGET();
const res = await eventsGET(makeRequest("http://localhost:3000/api/events"));
const reader = res.body!.getReader();
const { value } = await reader.read();
reader.cancel();
@ -583,5 +590,265 @@ describe("API Routes", () => {
expect(event.sessions[0]).toHaveProperty("id");
expect(event.sessions[0]).toHaveProperty("attentionLevel");
});
it("excludes orchestrator sessions from snapshot", async () => {
const sessionsWithOrchestrator = [
...testSessions,
makeSession({
id: "my-app-orchestrator",
projectId: "my-app",
status: "working",
activity: "active",
}),
];
(mockSessionManager.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
sessionsWithOrchestrator,
);
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 jsonStr = text.replace("data: ", "").trim();
const event = JSON.parse(jsonStr);
const sessionIds = event.sessions.map((s: { id: string }) => s.id);
expect(sessionIds).not.toContain("my-app-orchestrator");
expect(sessionIds.every((id: string) => !id.endsWith("-orchestrator"))).toBe(true);
});
it("filters sessions by project query param", async () => {
const multiProjectSessions = [
...testSessions,
makeSession({
id: "other-app-1",
projectId: "other-app",
status: "working",
activity: "active",
}),
];
(mockSessionManager.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
multiProjectSessions,
);
const res = await eventsGET(makeRequest("http://localhost:3000/api/events?project=my-app"));
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());
expect(event.sessions.every((s: { id: string }) => s.id !== "other-app-1")).toBe(true);
});
it("returns all non-orchestrator sessions when project=all", async () => {
const multiProjectSessions = [
...testSessions,
makeSession({
id: "other-app-1",
projectId: "other-app",
status: "working",
activity: "active",
}),
makeSession({
id: "my-app-orchestrator",
projectId: "my-app",
status: "working",
activity: "active",
}),
];
(mockSessionManager.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
multiProjectSessions,
);
const res = await eventsGET(makeRequest("http://localhost:3000/api/events?project=all"));
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());
// Should include both projects' worker sessions
const sessionIds = event.sessions.map((s: { id: string }) => s.id);
expect(sessionIds).toContain("other-app-1");
// But exclude orchestrator
expect(sessionIds).not.toContain("my-app-orchestrator");
});
});
// ── GET /api/sessions?project=X (project filtering) ───────────────────────
describe("GET /api/sessions?project=X", () => {
it("filters sessions by projectId", async () => {
const multiProjectSessions = [
...testSessions,
makeSession({
id: "other-app-1",
projectId: "other-app",
status: "working",
activity: "active",
}),
];
(mockSessionManager.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
multiProjectSessions,
);
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.sessions.every((s: { projectId: string }) => s.projectId === "my-app")).toBe(
true,
);
});
it("filters sessions by sessionPrefix when projectId does not match", async () => {
const prefixMatchSessions = [
makeSession({ id: "my-app-1", projectId: "", status: "working", activity: "active" }),
makeSession({
id: "backend-1",
projectId: "backend",
status: "working",
activity: "active",
}),
];
(mockSessionManager.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
prefixMatchSessions,
);
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.sessions.length).toBe(1);
expect(data.sessions[0].id).toBe("my-app-1");
});
it("returns all sessions when project=all", async () => {
const multiProjectSessions = [
...testSessions,
makeSession({
id: "other-app-1",
projectId: "other-app",
status: "working",
activity: "active",
}),
];
(mockSessionManager.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
multiProjectSessions,
);
const res = await sessionsGET(makeRequest("http://localhost:3000/api/sessions?project=all"));
expect(res.status).toBe(200);
const data = await res.json();
expect(data.sessions.length).toBe(multiProjectSessions.length);
});
it("returns empty array for non-existent project", async () => {
const res = await sessionsGET(
makeRequest("http://localhost:3000/api/sessions?project=nonexistent"),
);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.sessions).toEqual([]);
expect(data.stats.totalSessions).toBe(0);
});
it("finds orchestrator for the filtered project only", async () => {
const multiProjectSessions = [
...testSessions.filter((s) => !s.id.endsWith("-orchestrator")),
makeSession({
id: "my-app-orchestrator",
projectId: "my-app",
status: "working",
activity: "active",
}),
makeSession({
id: "other-app-orchestrator",
projectId: "other-app",
status: "working",
activity: "active",
}),
];
(mockSessionManager.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
multiProjectSessions,
);
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.orchestratorId).toBe("my-app-orchestrator");
});
it("stats reflect only filtered sessions", async () => {
const multiProjectSessions = [
makeSession({
id: "my-app-1",
projectId: "my-app",
status: "needs_input",
activity: "waiting_input",
}),
makeSession({
id: "my-app-2",
projectId: "my-app",
status: "mergeable",
activity: "idle",
pr: {
number: 1,
url: "",
title: "",
owner: "",
repo: "",
branch: "",
baseBranch: "",
isDraft: false,
},
}),
makeSession({
id: "other-app-1",
projectId: "other-app",
status: "needs_input",
activity: "waiting_input",
}),
];
(mockSessionManager.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
multiProjectSessions,
);
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.stats.totalSessions).toBe(2);
expect(data.stats.workingSessions).toBe(2);
});
});
// ── GET /api/projects ────────────────────────────────────────────────
describe("GET /api/projects", () => {
it("returns list of configured projects", async () => {
const res = await projectsGET(makeRequest("http://localhost:3000/api/projects"));
expect(res.status).toBe(200);
const data = await res.json();
expect(data.projects).toBeDefined();
expect(Array.isArray(data.projects)).toBe(true);
expect(data.projects.length).toBe(1);
expect(data.projects[0]).toHaveProperty("id");
expect(data.projects[0]).toHaveProperty("name");
});
it("project includes id and name", async () => {
const res = await projectsGET(makeRequest("http://localhost:3000/api/projects"));
const data = await res.json();
expect(data.projects[0].id).toBe("my-app");
expect(data.projects[0].name).toBe("My App");
});
});
});

View File

@ -1,34 +1,38 @@
import { getServices, startBacklogPoller } from "@/lib/services";
import { sessionToDashboard } from "@/lib/serialize";
import { getAttentionLevel } from "@/lib/types";
import { filterWorkerSessions } from "@/lib/project-utils";
import type { Session } from "@composio/ao-core";
export const dynamic = "force-dynamic";
/**
* GET /api/events SSE stream for real-time lifecycle events
*
* Sends session state updates to connected clients.
* Also starts the lifecycle manager and backlog poller on first connection.
/** GET /api/events SSE stream for real-time lifecycle events
* Query params:
* - project: Filter to a specific project. "all" = no filter.
*/
export async function GET(): Promise<Response> {
export async function GET(request: Request): Promise<Response> {
const { searchParams } = new URL(request.url);
const projectFilter = searchParams.get("project");
const encoder = new TextEncoder();
let heartbeat: ReturnType<typeof setInterval> | undefined;
let updates: ReturnType<typeof setInterval> | undefined;
// Start backlog poller (idempotent — lifecycle manager is started in services.ts)
const filterSessions = (
sessions: Session[],
config: { projects: Record<string, { sessionPrefix?: string }> },
) => filterWorkerSessions(sessions, projectFilter, config.projects);
startBacklogPoller();
const stream = new ReadableStream({
start(controller) {
// Send initial snapshot
void (async () => {
try {
const { sessionManager, lifecycleManager } = await getServices();
const { sessionManager, config } = await getServices();
const sessions = await sessionManager.list();
const dashboardSessions = sessions.map(sessionToDashboard);
// Include lifecycle state map for debugging/display
const lifecycleStates = Object.fromEntries(lifecycleManager.getStates());
const filteredSessions = filterSessions(sessions, config);
const dashboardSessions = filteredSessions.map(sessionToDashboard);
const initialEvent = {
type: "snapshot",
@ -39,7 +43,6 @@ export async function GET(): Promise<Response> {
attentionLevel: getAttentionLevel(s),
lastActivityAt: s.lastActivityAt,
})),
lifecycle: lifecycleStates,
};
controller.enqueue(encoder.encode(`data: ${JSON.stringify(initialEvent)}\n\n`));
} catch {
@ -49,7 +52,6 @@ export async function GET(): Promise<Response> {
}
})();
// Send periodic heartbeat
heartbeat = setInterval(() => {
try {
controller.enqueue(encoder.encode(`: heartbeat\n\n`));
@ -59,14 +61,14 @@ export async function GET(): Promise<Response> {
}
}, 15000);
// Poll for session state changes every 5 seconds
updates = setInterval(() => {
void (async () => {
let dashboardSessions;
try {
const { sessionManager } = await getServices();
const { sessionManager, config } = await getServices();
const sessions = await sessionManager.list();
dashboardSessions = sessions.map(sessionToDashboard);
const filteredSessions = filterSessions(sessions, config);
dashboardSessions = filteredSessions.map(sessionToDashboard);
} catch {
return;
}

View File

@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { getAllProjects } from "@/lib/project-name";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const projects = getAllProjects();
return NextResponse.json({ projects });
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Failed to load projects" },
{ status: 500 },
);
}
}

View File

@ -9,6 +9,7 @@ import {
computeStats,
} 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;
@ -29,25 +30,23 @@ async function settlesWithin(promise: Promise<unknown>, timeoutMs: number): Prom
}
}
/** GET /api/sessions List all sessions with full state
/** GET /api/sessions List sessions with full state
* Query params:
* - project: Filter to a specific project (by projectId or sessionPrefix). "all" = no filter.
* - active=true: Only return non-exited sessions
*/
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const projectFilter = searchParams.get("project");
const activeOnly = searchParams.get("active") === "true";
const { config, registry, sessionManager } = await getServices();
const coreSessions = await sessionManager.list();
// Find orchestrator session ID (if running) and expose to clients
const orchSession = coreSessions.find((s) => s.id.endsWith("-orchestrator"));
const orchestratorId = orchSession ? orchSession.id : null;
const globalPause = resolveGlobalPause(coreSessions);
const orchestratorId = findOrchestratorSessionId(coreSessions, projectFilter, config.projects);
// Filter out orchestrator sessions — they get their own button, not a card
let workerSessions = coreSessions.filter((s) => !s.id.endsWith("-orchestrator"));
let workerSessions = filterWorkerSessions(coreSessions, projectFilter, config.projects);
// Convert to dashboard format
let dashboardSessions = workerSessions.map(sessionToDashboard);
@ -90,7 +89,7 @@ export async function GET(request: Request) {
sessions: dashboardSessions,
stats: computeStats(dashboardSessions),
orchestratorId,
globalPause,
globalPause: resolveGlobalPause(coreSessions),
});
} catch (err) {
return NextResponse.json(

View File

@ -1,4 +1,6 @@
import type { Metadata } from "next";
export const dynamic = "force-dynamic";
import { Dashboard } from "@/components/Dashboard";
import type { DashboardSession } from "@/lib/types";
import { getServices, getSCM } from "@/lib/services";
@ -7,64 +9,66 @@ import {
resolveProject,
enrichSessionPR,
enrichSessionsMetadata,
computeStats,
} from "@/lib/serialize";
import { prCache, prCacheKey } from "@/lib/cache";
import { getProjectName } from "@/lib/project-name";
import { getPrimaryProjectId, getProjectName, getAllProjects } from "@/lib/project-name";
import { filterWorkerSessions, findOrchestratorSessionId } from "@/lib/project-utils";
import { resolveGlobalPause, type GlobalPauseState } from "@/lib/global-pause";
export const dynamic = "force-dynamic";
function getSelectedProjectName(projectFilter: string | undefined): string {
if (projectFilter === "all") return "All Projects";
const projects = getAllProjects();
if (projectFilter) {
const selectedProject = projects.find((project) => project.id === projectFilter);
if (selectedProject) return selectedProject.name;
}
return getProjectName();
}
export async function generateMetadata(): Promise<Metadata> {
const projectName = getProjectName();
// Use absolute to opt out of the layout's "%s | project" template
export async function generateMetadata(props: {
searchParams: Promise<{ project?: string }>;
}): Promise<Metadata> {
const searchParams = await props.searchParams;
const projectFilter = searchParams.project ?? getPrimaryProjectId();
const projectName = getSelectedProjectName(projectFilter);
return { title: { absolute: `ao | ${projectName}` } };
}
export default async function Home() {
export default async function Home(props: { searchParams: Promise<{ project?: string }> }) {
const searchParams = await props.searchParams;
let sessions: DashboardSession[] = [];
let orchestratorId: string | null = null;
let globalPause: GlobalPauseState | null = null;
let projectIds: string[] = [];
const projectName = getProjectName();
let orchestratorId: string | null;
let globalPause: GlobalPauseState | null;
// Allow ?project=all to show all sessions (for multi-project setups)
const projectFilter = searchParams.project ?? getPrimaryProjectId();
try {
const { config, registry, sessionManager } = await getServices();
projectIds = Object.keys(config.projects);
const allSessions = await sessionManager.list();
orchestratorId = findOrchestratorSessionId(allSessions, projectFilter, config.projects);
globalPause = resolveGlobalPause(allSessions);
// Find the orchestrator session (any session ending with -orchestrator)
// Only set orchestratorId if an actual session exists (no fallback)
const orchSession = allSessions.find((s) => s.id.endsWith("-orchestrator"));
if (orchSession) {
orchestratorId = orchSession.id;
}
const coreSessions = filterWorkerSessions(allSessions, projectFilter, config.projects);
// Filter out orchestrator from worker sessions
const coreSessions = allSessions.filter((s) => !s.id.endsWith("-orchestrator"));
sessions = coreSessions.map(sessionToDashboard);
// Enrich metadata (issue labels, agent summaries, issue titles) — cap at 3s
const metaTimeout = new Promise<void>((resolve) => setTimeout(resolve, 3_000));
await Promise.race([
enrichSessionsMetadata(coreSessions, sessions, config, registry),
metaTimeout,
]);
// Enrich sessions that have PRs with live SCM data
// Skip enrichment for terminal sessions (merged, closed, done, terminated)
const terminalStatuses = new Set(["merged", "killed", "cleanup", "done", "terminated"]);
const enrichPromises = coreSessions.map((core, i) => {
if (!core.pr) return Promise.resolve();
// Check cache first (before terminal status check)
const cacheKey = prCacheKey(core.pr.owner, core.pr.repo, core.pr.number);
const cached = prCache.get(cacheKey);
// Apply cached data if available (for both terminal and non-terminal sessions)
if (cached) {
if (sessions[i].pr) {
// Apply ALL cached fields (not just some)
sessions[i].pr.state = cached.state;
sessions[i].pr.title = cached.title;
sessions[i].pr.additions = cached.additions;
@ -85,8 +89,6 @@ export default async function Home() {
sessions[i].pr.unresolvedComments = cached.unresolvedComments;
}
// Skip enrichment if cache is fresh AND (terminal OR merged/closed)
// This allows terminal sessions to be enriched once when cache is missing/expired
if (
terminalStatuses.has(core.status) ||
cached.state === "merged" ||
@ -101,21 +103,26 @@ export default async function Home() {
if (!scm) return Promise.resolve();
return enrichSessionPR(sessions[i], scm, core.pr);
});
// Cap enrichment at 4s — if GitHub is slow/rate-limited, serve stale data fast
const enrichTimeout = new Promise<void>((resolve) => setTimeout(resolve, 4_000));
await Promise.race([Promise.allSettled(enrichPromises), enrichTimeout]);
} catch {
// Config not found or services unavailable — show empty dashboard
sessions = [];
orchestratorId = null;
globalPause = null;
}
const projectName = getSelectedProjectName(projectFilter);
const projects = getAllProjects();
const selectedProjectId = projectFilter === "all" ? undefined : projectFilter;
return (
<Dashboard
initialSessions={sessions}
stats={computeStats(sessions)}
orchestratorId={orchestratorId}
projectId={selectedProjectId}
projectName={projectName}
projects={projects}
initialGlobalPause={globalPause}
projectIds={projectIds}
/>
);
}

View File

@ -1,6 +1,6 @@
"use client";
import { useMemo, useState, useEffect, useCallback, type FormEvent } from "react";
import { useEffect, useMemo, useState } from "react";
import {
type DashboardSession,
type DashboardStats,
@ -15,46 +15,36 @@ import { AttentionZone } from "./AttentionZone";
import { PRTableRow } from "./PRStatus";
import { DynamicFavicon } from "./DynamicFavicon";
import { useSessionEvents } from "@/hooks/useSessionEvents";
interface BacklogIssue {
id: string;
title: string;
url: string;
state: string;
labels: string[];
projectId: string;
}
import { ProjectSidebar } from "./ProjectSidebar";
import type { ProjectInfo } from "@/lib/project-name";
interface DashboardProps {
initialSessions: DashboardSession[];
stats: DashboardStats;
orchestratorId?: string | null;
projectId?: string;
projectName?: string;
projects?: ProjectInfo[];
initialGlobalPause?: GlobalPauseState | null;
projectIds?: string[];
}
type Tab = "board" | "backlog" | "verify" | "prs";
const KANBAN_LEVELS = ["working", "pending", "review", "respond", "merge"] as const;
export function Dashboard({
initialSessions,
stats: _initialStats,
orchestratorId,
projectId,
projectName,
initialGlobalPause,
projectIds = [],
projects = [],
initialGlobalPause = null,
}: DashboardProps) {
const { sessions, globalPause } = useSessionEvents(initialSessions, initialGlobalPause ?? null);
const { sessions, globalPause } = useSessionEvents(
initialSessions,
initialGlobalPause,
projectId,
);
const [rateLimitDismissed, setRateLimitDismissed] = useState(false);
const [activeTab, setActiveTab] = useState<Tab>("board");
const [backlogIssues, setBacklogIssues] = useState<BacklogIssue[]>([]);
const [backlogLoading, setBacklogLoading] = useState(false);
const [showCreateForm, setShowCreateForm] = useState(false);
const [verifyIssues, setVerifyIssues] = useState<BacklogIssue[]>([]);
const [verifyLoading, setVerifyLoading] = useState(false);
const [globalPauseDismissed, setGlobalPauseDismissed] = useState(false);
const showSidebar = projects.length > 1;
const grouped = useMemo(() => {
const zones: Record<AttentionLevel, DashboardSession[]> = {
merge: [],
@ -77,77 +67,6 @@ export function Dashboard({
.sort((a, b) => mergeScore(a) - mergeScore(b));
}, [sessions]);
// Fetch backlog issues
const fetchBacklog = useCallback(async () => {
setBacklogLoading(true);
try {
const res = await fetch("/api/backlog");
if (res.ok) {
const data = await res.json();
setBacklogIssues(data.issues ?? []);
}
} catch {
// Non-critical
} finally {
setBacklogLoading(false);
}
}, []);
// Fetch verify issues
const fetchVerify = useCallback(async () => {
setVerifyLoading(true);
try {
const res = await fetch("/api/verify");
if (res.ok) {
const data = await res.json();
setVerifyIssues(data.issues ?? []);
}
} catch {
// Non-critical
} finally {
setVerifyLoading(false);
}
}, []);
useEffect(() => {
if (activeTab === "verify") {
fetchVerify();
const interval = setInterval(fetchVerify, 30_000);
return () => clearInterval(interval);
}
}, [activeTab, fetchVerify]);
const handleVerifyAction = async (
issueId: string,
projectId: string,
action: "verify" | "fail",
) => {
try {
const res = await fetch("/api/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ issueId, projectId, action }),
});
if (res.ok) {
setVerifyIssues((prev) =>
prev.filter((i) => !(i.id === issueId && i.projectId === projectId)),
);
} else {
console.error("Failed to update verify status:", await res.text());
}
} catch (err) {
console.error("Failed to update verify status:", err);
}
};
useEffect(() => {
if (activeTab === "backlog") {
fetchBacklog();
const interval = setInterval(fetchBacklog, 30_000);
return () => clearInterval(interval);
}
}, [activeTab, fetchBacklog]);
const handleSend = async (sessionId: string, message: string) => {
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/send`, {
method: "POST",
@ -192,7 +111,6 @@ export function Dashboard({
() => sessions.some((s) => s.pr && isPRRateLimited(s.pr)),
[sessions],
);
const liveStats = useMemo<DashboardStats>(
() => ({
totalSessions: sessions.length,
@ -205,26 +123,28 @@ export function Dashboard({
}),
[sessions],
);
const resumeAtLabel = useMemo(() => {
if (!globalPause) return null;
return new Date(globalPause.pausedUntil).toLocaleString();
}, [globalPause]);
// Counts for tab badges
const backlogCount = backlogIssues.length;
const verifyCount = verifyIssues.length;
const prCount = openPRs.length;
const needsAttention = grouped.respond.length + grouped.merge.length;
useEffect(() => {
setGlobalPauseDismissed(false);
}, [globalPause?.pausedUntil, globalPause?.reason, globalPause?.sourceSessionId]);
return (
<div className="px-8 py-7">
<DynamicFavicon sessions={sessions} projectName={projectName} />
{/* Header */}
<div className="mb-6 flex items-center justify-between border-b border-[var(--color-border-subtle)] pb-5">
<div className="flex items-center gap-6">
<h1 className="text-[17px] font-semibold tracking-[-0.02em] text-[var(--color-text-primary)]">
{projectName ?? "Orchestrator"}
</h1>
<StatusLine stats={liveStats} needsAttention={needsAttention} />
</div>
<div className="flex items-center gap-3">
<div className="flex h-screen">
{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)]">
{projectName ?? "Orchestrator"}
</h1>
<StatusLine stats={liveStats} />
</div>
{orchestratorId && (
<a
href={`/sessions/${encodeURIComponent(orchestratorId)}`}
@ -244,581 +164,163 @@ export function Dashboard({
</a>
)}
</div>
</div>
{/* Tab bar */}
<div className="mb-6 flex items-center gap-1 border-b border-[var(--color-border-subtle)]">
<TabButton
active={activeTab === "board"}
onClick={() => setActiveTab("board")}
badge={needsAttention > 0 ? needsAttention : undefined}
badgeColor="var(--color-status-error)"
>
Board
</TabButton>
<TabButton
active={activeTab === "backlog"}
onClick={() => setActiveTab("backlog")}
badge={backlogCount > 0 ? backlogCount : undefined}
badgeColor="var(--color-accent)"
>
Backlog
</TabButton>
<TabButton
active={activeTab === "verify"}
onClick={() => setActiveTab("verify")}
badge={verifyCount > 0 ? verifyCount : undefined}
badgeColor="rgb(245, 158, 11)"
>
Verify
</TabButton>
<TabButton
active={activeTab === "prs"}
onClick={() => setActiveTab("prs")}
badge={prCount > 0 ? prCount : undefined}
badgeColor="var(--color-status-ready)"
>
Pull Requests
</TabButton>
</div>
{globalPause && (
<div className="mb-6 rounded border border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.07)] px-3.5 py-2.5 text-[11px] text-[var(--color-status-attention)]">
<span className="font-semibold">Orchestrator paused:</span> {globalPause.reason}. Resume
after {new Date(globalPause.pausedUntil).toLocaleString()}.
{globalPause.sourceSessionId ? ` Source: ${globalPause.sourceSessionId}.` : ""}
</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
className="h-3.5 w-3.5 shrink-0"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</svg>
<span className="flex-1">
GitHub API rate limited PR data (CI status, review state, sizes) may be stale. Will
retry automatically on next refresh.
</span>
<button
onClick={() => setRateLimitDismissed(true)}
className="ml-1 shrink-0 opacity-60 hover:opacity-100"
aria-label="Dismiss"
>
{/* 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
className="h-3.5 w-3.5"
className="h-3.5 w-3.5 shrink-0"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M18 6 6 18M6 6l12 12" />
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</svg>
</button>
</div>
)}
{/* Board tab */}
{activeTab === "board" && (
<>
{/* Kanban columns for active zones */}
{hasKanbanSessions ? (
<div className="mb-8 flex gap-4 overflow-x-auto pb-2">
{KANBAN_LEVELS.map((level) =>
grouped[level].length > 0 ? (
<div key={level} className="min-w-[200px] flex-1">
<AttentionZone
level={level}
sessions={grouped[level]}
variant="column"
onSend={handleSend}
onKill={handleKill}
onMerge={handleMerge}
onRestore={handleRestore}
/>
</div>
) : null,
<span className="flex-1">
<strong>Orchestrator paused:</strong> {globalPause.reason}
{resumeAtLabel && (
<span className="ml-2 opacity-75">Resume after {resumeAtLabel}</span>
)}
</div>
) : (
<EmptyState
title="No active sessions"
description="Add issues to your backlog or spawn agents from the CLI"
action={
<button
onClick={() => setActiveTab("backlog")}
className="rounded-md bg-[var(--color-accent)] px-4 py-2 text-[12px] font-semibold text-[var(--color-text-inverse)] hover:opacity-90"
>
View Backlog
</button>
}
/>
)}
{/* Done — full-width grid below Kanban */}
{grouped.done.length > 0 && (
<div className="mb-8">
<AttentionZone
level="done"
sessions={grouped.done}
variant="grid"
onSend={handleSend}
onKill={handleKill}
onMerge={handleMerge}
onRestore={handleRestore}
/>
</div>
)}
</>
)}
{/* Backlog tab */}
{activeTab === "backlog" && (
<div>
<div className="mb-4 flex items-center justify-between">
<p className="text-[12px] text-[var(--color-text-secondary)]">
Issues labeled{" "}
<code className="rounded bg-[var(--color-bg-subtle)] px-1.5 py-0.5 text-[11px] text-[var(--color-accent)]">
agent:backlog
</code>{" "}
are auto-claimed by agents. Max {5} concurrent.
</p>
{globalPause.sourceSessionId && (
<span className="ml-2 opacity-75">(Source: {globalPause.sourceSessionId})</span>
)}
</span>
<button
onClick={() => setShowCreateForm(!showCreateForm)}
className="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-[12px] font-semibold text-[var(--color-text-inverse)] hover:opacity-90"
onClick={() => setGlobalPauseDismissed(true)}
className="ml-1 shrink-0 opacity-60 hover:opacity-100"
aria-label="Dismiss"
>
<svg
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M12 5v14M5 12h14" />
<path d="M18 6 6 18M6 6l12 12" />
</svg>
New Issue
</button>
</div>
)}
{showCreateForm && (
<CreateIssueForm
projectIds={projectIds}
onCreated={() => {
setShowCreateForm(false);
fetchBacklog();
}}
onCancel={() => setShowCreateForm(false)}
/>
)}
{backlogLoading && backlogIssues.length === 0 ? (
<div className="py-12 text-center text-[12px] text-[var(--color-text-tertiary)]">
Loading backlog...
</div>
) : backlogIssues.length === 0 ? (
<EmptyState
title="Backlog is empty"
description={`Add the "agent:backlog" label to GitHub issues, or create one above`}
/>
) : (
<div className="space-y-2">
{backlogIssues.map((issue) => (
<BacklogCard key={`${issue.projectId}-${issue.id}`} issue={issue} />
))}
</div>
)}
</div>
)}
{/* Verify tab */}
{activeTab === "verify" && (
<div>
<div className="mb-4">
<p className="text-[12px] text-[var(--color-text-secondary)]">
Issues labeled{" "}
<code
className="rounded bg-[var(--color-bg-subtle)] px-1.5 py-0.5 text-[11px]"
style={{ color: "rgb(245, 158, 11)" }}
{/* 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
className="h-3.5 w-3.5 shrink-0"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</svg>
<span className="flex-1">
GitHub API rate limited PR data (CI status, review state, sizes) may be stale. Will
retry automatically on next refresh.
</span>
<button
onClick={() => setRateLimitDismissed(true)}
className="ml-1 shrink-0 opacity-60 hover:opacity-100"
aria-label="Dismiss"
>
<svg
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
merged-unverified
</code>{" "}
need human verification on staging.
</p>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
)}
{verifyLoading && verifyIssues.length === 0 ? (
<div className="py-12 text-center text-[12px] text-[var(--color-text-tertiary)]">
Loading issues to verify...
</div>
) : verifyIssues.length === 0 ? (
<EmptyState
title="Nothing to verify"
description="All merged issues have been verified"
{/* Kanban columns for active zones */}
{hasKanbanSessions && (
<div className="mb-8 flex gap-4 overflow-x-auto pb-2">
{KANBAN_LEVELS.map((level) =>
grouped[level].length > 0 ? (
<div key={level} className="min-w-[200px] flex-1">
<AttentionZone
level={level}
sessions={grouped[level]}
variant="column"
onSend={handleSend}
onKill={handleKill}
onMerge={handleMerge}
onRestore={handleRestore}
/>
</div>
) : null,
)}
</div>
)}
{/* Done — full-width grid below Kanban */}
{grouped.done.length > 0 && (
<div className="mb-8">
<AttentionZone
level="done"
sessions={grouped.done}
variant="grid"
onSend={handleSend}
onKill={handleKill}
onMerge={handleMerge}
onRestore={handleRestore}
/>
) : (
<div className="space-y-2">
{verifyIssues.map((issue) => (
<VerifyCard
key={`${issue.projectId}-${issue.id}`}
issue={issue}
onVerify={() => handleVerifyAction(issue.id, issue.projectId, "verify")}
onFail={() => handleVerifyAction(issue.id, issue.projectId, "fail")}
/>
))}
</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)]">
Pull Requests
</h2>
<div className="overflow-hidden rounded-[6px] border border-[var(--color-border-default)]">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-[var(--color-border-muted)]">
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
PR
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Title
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Size
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
CI
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Review
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Unresolved
</th>
</tr>
</thead>
<tbody>
{openPRs.map((pr) => (
<PRTableRow key={pr.number} pr={pr} />
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* PRs tab */}
{activeTab === "prs" && (
<>
{openPRs.length > 0 ? (
<div className="mx-auto max-w-[900px]">
<div className="overflow-hidden rounded-[6px] border border-[var(--color-border-default)]">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-[var(--color-border-muted)]">
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
PR
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Title
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Size
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
CI
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Review
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Unresolved
</th>
</tr>
</thead>
<tbody>
{openPRs.map((pr) => (
<PRTableRow key={pr.number} pr={pr} />
))}
</tbody>
</table>
</div>
</div>
) : (
<EmptyState
title="No open PRs"
description="Agents will create PRs when they push code"
/>
)}
</>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function TabButton({
active,
onClick,
badge,
badgeColor,
children,
}: {
active: boolean;
onClick: () => void;
badge?: number;
badgeColor?: string;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
className={`relative px-4 py-2.5 text-[12px] font-semibold transition-colors ${
active
? "border-b-2 border-[var(--color-accent)] text-[var(--color-text-primary)]"
: "border-b-2 border-transparent text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]"
}`}
>
{children}
{badge !== undefined && badge > 0 && (
<span
className="ml-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-bold text-white"
style={{ backgroundColor: badgeColor ?? "var(--color-accent)" }}
>
{badge}
</span>
)}
</button>
);
}
function EmptyState({
title,
description,
action,
}: {
title: string;
description: string;
action?: React.ReactNode;
}) {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-[var(--color-border-subtle)] py-16">
<div className="text-[14px] font-medium text-[var(--color-text-secondary)]">{title}</div>
<div className="max-w-[400px] text-center text-[12px] text-[var(--color-text-tertiary)]">
{description}
</div>
{action && <div className="mt-2">{action}</div>}
</div>
);
}
function BacklogCard({ issue }: { issue: BacklogIssue }) {
return (
<a
href={issue.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)] px-4 py-3 transition-colors hover:border-[var(--color-border-default)] hover:no-underline"
>
<svg
className="h-4 w-4 shrink-0 text-[var(--color-status-ready)]"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v8M8 12h8" />
</svg>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-[var(--color-text-primary)] truncate">
#{issue.id} {issue.title}
</div>
<div className="mt-0.5 flex items-center gap-2">
<span className="text-[11px] text-[var(--color-text-tertiary)]">{issue.projectId}</span>
{issue.labels
.filter((l) => l !== "agent:backlog")
.map((label) => (
<span
key={label}
className="rounded-full bg-[var(--color-bg-subtle)] px-2 py-0.5 text-[10px] text-[var(--color-text-secondary)]"
>
{label}
</span>
))}
</div>
</div>
<span className="rounded-full bg-[rgba(88,166,255,0.1)] px-2.5 py-1 text-[10px] font-semibold text-[var(--color-accent)]">
queued
</span>
</a>
);
}
function VerifyCard({
issue,
onVerify,
onFail,
}: {
issue: BacklogIssue;
onVerify: () => Promise<void>;
onFail: () => Promise<void>;
}) {
const [acting, setActing] = useState<"verify" | "fail" | null>(null);
const handleAction = async (action: "verify" | "fail", handler: () => Promise<void>) => {
setActing(action);
try {
await handler();
} finally {
setActing(null);
}
};
return (
<div className="flex items-center gap-3 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)] px-4 py-3">
<svg
className="h-4 w-4 shrink-0"
style={{ color: "rgb(245, 158, 11)" }}
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</svg>
<div className="flex-1 min-w-0">
<a
href={issue.url}
target="_blank"
rel="noopener noreferrer"
className="text-[13px] font-medium text-[var(--color-text-primary)] truncate hover:underline"
>
#{issue.id} {issue.title}
</a>
<div className="mt-0.5 flex items-center gap-2">
<span className="text-[11px] text-[var(--color-text-tertiary)]">{issue.projectId}</span>
{issue.labels
.filter((l) => l !== "merged-unverified")
.map((label) => (
<span
key={label}
className="rounded-full bg-[var(--color-bg-subtle)] px-2 py-0.5 text-[10px] text-[var(--color-text-secondary)]"
>
{label}
</span>
))}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => handleAction("verify", onVerify)}
disabled={acting !== null}
className="rounded-md bg-[rgba(46,160,67,0.15)] px-3 py-1.5 text-[11px] font-semibold text-[rgb(46,160,67)] hover:bg-[rgba(46,160,67,0.25)] disabled:opacity-50"
>
{acting === "verify" ? "..." : "Verified"}
</button>
<button
onClick={() => handleAction("fail", onFail)}
disabled={acting !== null}
className="rounded-md bg-[rgba(248,81,73,0.15)] px-3 py-1.5 text-[11px] font-semibold text-[rgb(248,81,73)] hover:bg-[rgba(248,81,73,0.25)] disabled:opacity-50"
>
{acting === "fail" ? "..." : "Failed"}
</button>
</div>
)}
</div>
</div>
);
}
function CreateIssueForm({
projectIds,
onCreated,
onCancel,
}: {
projectIds: string[];
onCreated: () => void;
onCancel: () => void;
}) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [selectedProject, setSelectedProject] = useState(projectIds[0] ?? "");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!title.trim() || !selectedProject) return;
setSubmitting(true);
setError(null);
try {
const res = await fetch("/api/issues", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectId: selectedProject,
title: title.trim(),
description: description.trim(),
addToBacklog: true,
}),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error ?? "Failed to create issue");
}
setTitle("");
setDescription("");
onCreated();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create issue");
} finally {
setSubmitting(false);
}
};
return (
<form
onSubmit={handleSubmit}
className="mb-6 rounded-lg border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] p-4"
>
{projectIds.length > 1 && (
<div className="mb-3">
<select
value={selectedProject}
onChange={(e) => setSelectedProject(e.target.value)}
className="w-full rounded-md border border-[var(--color-border-default)] bg-[var(--color-bg-base)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] outline-none focus:border-[var(--color-accent)]"
>
{projectIds.map((id) => (
<option key={id} value={id}>
{id}
</option>
))}
</select>
</div>
)}
<div className="mb-3">
<input
type="text"
placeholder="Issue title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full rounded-md border border-[var(--color-border-default)] bg-[var(--color-bg-base)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-accent)]"
autoFocus
/>
</div>
<div className="mb-3">
<textarea
placeholder="Description (optional — be specific about what the agent should do)"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full rounded-md border border-[var(--color-border-default)] bg-[var(--color-bg-base)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-accent)] resize-none"
/>
</div>
{error && <div className="mb-3 text-[11px] text-[var(--color-status-error)]">{error}</div>}
<div className="flex items-center justify-between">
<span className="text-[11px] text-[var(--color-text-tertiary)]">
Will be created with <code className="text-[var(--color-accent)]">agent:backlog</code>{" "}
label
</span>
<div className="flex gap-2">
<button
type="button"
onClick={onCancel}
className="rounded-md px-3 py-1.5 text-[12px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]"
>
Cancel
</button>
<button
type="submit"
disabled={submitting || !title.trim()}
className="rounded-md bg-[var(--color-accent)] px-4 py-1.5 text-[12px] font-semibold text-[var(--color-text-inverse)] hover:opacity-90 disabled:opacity-50"
>
{submitting ? "Creating..." : "Create & Queue"}
</button>
</div>
</div>
</form>
);
}
function StatusLine({ stats, needsAttention }: { stats: DashboardStats; needsAttention: number }) {
function StatusLine({ stats }: { stats: DashboardStats }) {
if (stats.totalSessions === 0) {
return <span className="text-[13px] text-[var(--color-text-muted)]">no sessions</span>;
}
@ -829,8 +331,8 @@ function StatusLine({ stats, needsAttention }: { stats: DashboardStats; needsAtt
? [{ value: stats.workingSessions, label: "working", color: "var(--color-status-working)" }]
: []),
...(stats.openPRs > 0 ? [{ value: stats.openPRs, label: "PRs" }] : []),
...(needsAttention > 0
? [{ value: needsAttention, label: "need attention", color: "var(--color-status-error)" }]
...(stats.needsReview > 0
? [{ value: stats.needsReview, label: "need review", color: "var(--color-status-attention)" }]
: []),
];

View File

@ -0,0 +1,64 @@
"use client";
import { useRouter, usePathname } from "next/navigation";
import { cn } from "@/lib/cn";
import type { ProjectInfo } from "@/lib/project-name";
interface ProjectSidebarProps {
projects: ProjectInfo[];
activeProjectId: string | undefined;
}
export function ProjectSidebar({ projects, activeProjectId }: ProjectSidebarProps) {
const router = useRouter();
const pathname = usePathname();
const handleProjectClick = (projectId: string | null) => {
if (projectId === null) {
router.push(pathname + "?project=all");
} else {
router.push(pathname + `?project=${encodeURIComponent(projectId)}`);
}
};
if (projects.length <= 1) {
return null;
}
return (
<aside className="flex h-full w-[180px] flex-col border-r border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)]">
<div className="border-b border-[var(--color-border-subtle)] px-3 py-3">
<h2 className="text-[10px] font-bold uppercase tracking-[0.10em] text-[var(--color-text-tertiary)]">
Projects
</h2>
</div>
<nav className="flex-1 overflow-y-auto py-2">
<button
onClick={() => handleProjectClick(null)}
className={cn(
"w-full px-3 py-2 text-left text-[13px] transition-colors",
activeProjectId === undefined || activeProjectId === "all"
? "bg-[var(--color-accent-subtle)] text-[var(--color-accent)]"
: "text-[var(--color-text-secondary)] hover:bg-[rgba(255,255,255,0.03)] hover:text-[var(--color-text-primary)]",
)}
>
All Projects
</button>
{projects.map((project) => (
<button
key={project.id}
onClick={() => handleProjectClick(project.id)}
className={cn(
"w-full px-3 py-2 text-left text-[13px] transition-colors",
activeProjectId === project.id
? "bg-[var(--color-accent-subtle)] text-[var(--color-accent)]"
: "text-[var(--color-text-secondary)] hover:bg-[rgba(255,255,255,0.03)] hover:text-[var(--color-text-primary)]",
)}
>
{project.name}
</button>
))}
</nav>
</aside>
);
}

View File

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, act } from "@testing-library/react";
import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
import { Dashboard } from "@/components/Dashboard";
import type { GlobalPauseState } from "@/lib/types";
import { makeSession } from "@/__tests__/helpers";
@ -11,13 +11,6 @@ describe("Dashboard globalPause banner", () => {
close: () => void;
};
const defaultStats = {
totalSessions: 1,
workingSessions: 1,
openPRs: 0,
needsReview: 0,
};
const makeGlobalPause = (overrides: Partial<GlobalPauseState> = {}): GlobalPauseState => ({
pausedUntil: new Date(Date.now() + 3600000).toISOString(),
reason: "Model rate limit reached",
@ -43,13 +36,7 @@ describe("Dashboard globalPause banner", () => {
const sessions = [makeSession()];
const globalPause = makeGlobalPause();
render(
<Dashboard
initialSessions={sessions}
stats={defaultStats}
initialGlobalPause={globalPause}
/>,
);
render(<Dashboard initialSessions={sessions} initialGlobalPause={globalPause} />);
expect(screen.getByText(/Orchestrator paused:/)).toBeInTheDocument();
expect(screen.getByText(/Model rate limit reached/)).toBeInTheDocument();
@ -58,7 +45,7 @@ describe("Dashboard globalPause banner", () => {
it("hides banner when initialGlobalPause is null", () => {
const sessions = [makeSession()];
render(<Dashboard initialSessions={sessions} stats={defaultStats} initialGlobalPause={null} />);
render(<Dashboard initialSessions={sessions} initialGlobalPause={null} />);
expect(screen.queryByText(/Orchestrator paused:/)).not.toBeInTheDocument();
});
@ -67,28 +54,25 @@ describe("Dashboard globalPause banner", () => {
const sessions = [makeSession()];
const globalPause = makeGlobalPause({ reason: "Custom provider limit exceeded" });
render(
<Dashboard
initialSessions={sessions}
stats={defaultStats}
initialGlobalPause={globalPause}
/>,
);
render(<Dashboard initialSessions={sessions} initialGlobalPause={globalPause} />);
expect(screen.getByText(/Custom provider limit exceeded/)).toBeInTheDocument();
});
it("shows the automatic resume time", () => {
const sessions = [makeSession()];
const globalPause = makeGlobalPause({ pausedUntil: "2026-03-10T12:30:00.000Z" });
render(<Dashboard initialSessions={sessions} initialGlobalPause={globalPause} />);
expect(screen.getByText(/Resume after/)).toBeInTheDocument();
});
it("displays source session ID when provided", () => {
const sessions = [makeSession()];
const globalPause = makeGlobalPause({ sourceSessionId: "my-worker-42" });
render(
<Dashboard
initialSessions={sessions}
stats={defaultStats}
initialGlobalPause={globalPause}
/>,
);
render(<Dashboard initialSessions={sessions} initialGlobalPause={globalPause} />);
expect(screen.getByText(/Source: my-worker-42/)).toBeInTheDocument();
});
@ -104,7 +88,7 @@ describe("Dashboard globalPause banner", () => {
}),
} as Response);
render(<Dashboard initialSessions={sessions} stats={defaultStats} initialGlobalPause={null} />);
render(<Dashboard initialSessions={sessions} initialGlobalPause={null} />);
expect(screen.queryByText(/Orchestrator paused:/)).not.toBeInTheDocument();
@ -150,13 +134,7 @@ describe("Dashboard globalPause banner", () => {
}),
} as Response);
render(
<Dashboard
initialSessions={sessions}
stats={defaultStats}
initialGlobalPause={globalPause}
/>,
);
render(<Dashboard initialSessions={sessions} initialGlobalPause={globalPause} />);
expect(screen.getByText(/Orchestrator paused:/)).toBeInTheDocument();
@ -188,4 +166,32 @@ describe("Dashboard globalPause banner", () => {
expect(screen.queryByText(/Orchestrator paused:/)).not.toBeInTheDocument();
});
});
it("shows a new pause after dismissing a previous one", () => {
const sessions = [makeSession()];
const firstPause = makeGlobalPause({
reason: "First pause",
pausedUntil: "2026-03-10T12:00:00.000Z",
});
const secondPause = makeGlobalPause({
reason: "Second pause",
pausedUntil: "2026-03-10T13:00:00.000Z",
});
const { rerender } = render(
<Dashboard initialSessions={sessions} initialGlobalPause={firstPause} />,
);
expect(screen.getByText(/First pause/)).toBeInTheDocument();
const dismissButtons = screen.getAllByLabelText("Dismiss");
act(() => {
fireEvent.click(dismissButtons[0]);
});
expect(screen.queryByText(/Orchestrator paused:/)).not.toBeInTheDocument();
rerender(<Dashboard initialSessions={sessions} initialGlobalPause={secondPause} />);
expect(screen.getByText(/Second pause/)).toBeInTheDocument();
});
});

View File

@ -0,0 +1,78 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ProjectSidebar } from "@/components/ProjectSidebar";
const mockPush = vi.fn();
const mockPathname = "/";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
usePathname: () => mockPathname,
}));
describe("ProjectSidebar", () => {
const projects = [
{ id: "project-1", name: "Project One" },
{ id: "project-2", name: "Project Two" },
{ id: "project-3", name: "Project Three" },
];
beforeEach(() => {
mockPush.mockClear();
});
it("renders nothing when there is only one project", () => {
const { container } = render(
<ProjectSidebar projects={[projects[0]]} activeProjectId="project-1" />,
);
expect(container.firstChild).toBeNull();
});
it("renders nothing when there are no projects", () => {
const { container } = render(<ProjectSidebar projects={[]} activeProjectId={undefined} />);
expect(container.firstChild).toBeNull();
});
it("renders sidebar with all projects when there are multiple", () => {
render(<ProjectSidebar projects={projects} activeProjectId="project-1" />);
expect(screen.getByText("Projects")).toBeInTheDocument();
expect(screen.getByText("All Projects")).toBeInTheDocument();
expect(screen.getByText("Project One")).toBeInTheDocument();
expect(screen.getByText("Project Two")).toBeInTheDocument();
expect(screen.getByText("Project Three")).toBeInTheDocument();
});
it("highlights active project", () => {
render(<ProjectSidebar projects={projects} activeProjectId="project-2" />);
const projectTwoButton = screen.getByRole("button", { name: "Project Two" });
expect(projectTwoButton.className).toContain("accent");
});
it("highlights 'All Projects' when no project is active", () => {
render(<ProjectSidebar projects={projects} activeProjectId={undefined} />);
const allProjectsButton = screen.getByRole("button", { name: "All Projects" });
expect(allProjectsButton.className).toContain("accent");
});
it("navigates to project query param when clicking a project", () => {
render(<ProjectSidebar projects={projects} activeProjectId="project-1" />);
fireEvent.click(screen.getByRole("button", { name: "Project Two" }));
expect(mockPush).toHaveBeenCalledWith("/?project=project-2");
});
it("navigates to 'all' when clicking 'All Projects'", () => {
render(<ProjectSidebar projects={projects} activeProjectId="project-1" />);
fireEvent.click(screen.getByRole("button", { name: "All Projects" }));
expect(mockPush).toHaveBeenCalledWith("/?project=all");
});
it("encodes project ID in URL", () => {
const projectsWithSpecialChars = [
{ id: "my-app", name: "My App" },
{ id: "other-project", name: "Other Project" },
];
render(<ProjectSidebar projects={projectsWithSpecialChars} activeProjectId="my-app" />);
fireEvent.click(screen.getByRole("button", { name: "Other Project" }));
expect(mockPush).toHaveBeenCalledWith("/?project=other-project");
});
});

View File

@ -343,5 +343,45 @@ describe("useSessionEvents", () => {
expect(result.current.sessions[0].activity).toBe("idle");
expect(result.current.sessions[1].status).toBe("working");
});
it("swallows refresh fetch JSON failures without resetting sessions", async () => {
const sessions = makeSessions(1);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => {
throw new Error("bad json");
},
} as Response);
const { result } = renderHook(() => useSessionEvents(sessions, null));
await act(async () => {
eventSourceMock!.onmessage!.call(eventSourceMock, {
data: JSON.stringify({
type: "snapshot",
sessions: [
{
id: "session-0",
status: "working",
activity: "active",
lastActivityAt: new Date().toISOString(),
},
{
id: "session-new",
status: "working",
activity: "active",
lastActivityAt: new Date().toISOString(),
},
],
}),
} as MessageEvent);
await Promise.resolve();
});
expect(result.current.sessions).toHaveLength(1);
expect(result.current.sessions[0].id).toBe("session-0");
});
});
});

View File

@ -1,7 +1,7 @@
"use client";
import { useEffect, useReducer, useRef } from "react";
import type { DashboardSession, SSESnapshotEvent, GlobalPauseState } from "@/lib/types";
import type { DashboardSession, GlobalPauseState, SSESnapshotEvent } from "@/lib/types";
interface State {
sessions: DashboardSession[];
@ -42,18 +42,14 @@ function reducer(state: State, action: Action): State {
}
}
interface UseSessionEventsReturn {
sessions: DashboardSession[];
globalPause: GlobalPauseState | null;
}
export function useSessionEvents(
initialSessions: DashboardSession[],
initialGlobalPause: GlobalPauseState | null,
): UseSessionEventsReturn {
initialGlobalPause?: GlobalPauseState | null,
project?: string,
): State {
const [state, dispatch] = useReducer(reducer, {
sessions: initialSessions,
globalPause: initialGlobalPause,
globalPause: initialGlobalPause ?? null,
});
const sessionsRef = useRef(state.sessions);
const refreshingRef = useRef(false);
@ -63,61 +59,63 @@ export function useSessionEvents(
}, [state.sessions]);
useEffect(() => {
dispatch({ type: "reset", sessions: initialSessions, globalPause: initialGlobalPause });
dispatch({ type: "reset", sessions: initialSessions, globalPause: initialGlobalPause ?? null });
}, [initialSessions, initialGlobalPause]);
useEffect(() => {
const es = new EventSource("/api/events");
const url = project ? `/api/events?project=${encodeURIComponent(project)}` : "/api/events";
const es = new EventSource(url);
es.onmessage = (event: MessageEvent) => {
try {
const data = JSON.parse(event.data as string) as { type: string };
if (data.type === "snapshot") {
const snapshot = data as SSESnapshotEvent;
const workerPatches = snapshot.sessions.filter((s) => !s.id.endsWith("-orchestrator"));
dispatch({ type: "snapshot", patches: workerPatches });
dispatch({ type: "snapshot", patches: snapshot.sessions });
const currentIds = new Set(sessionsRef.current.map((s) => s.id));
const snapshotIds = new Set(workerPatches.map((s) => s.id));
const snapshotIds = new Set(snapshot.sessions.map((s) => s.id));
const sameMembership =
currentIds.size === snapshotIds.size &&
[...snapshotIds].every((id) => currentIds.has(id));
if (!sameMembership && !refreshingRef.current) {
refreshingRef.current = true;
void fetch("/api/sessions")
const sessionsUrl = project
? `/api/sessions?project=${encodeURIComponent(project)}`
: "/api/sessions";
void fetch(sessionsUrl)
.then((res) => (res.ok ? res.json() : null))
.then(
(
payload: { sessions?: DashboardSession[]; globalPause?: GlobalPauseState } | null,
updated: { sessions?: DashboardSession[]; globalPause?: GlobalPauseState } | null,
) => {
if (payload?.sessions) {
if (updated?.sessions) {
dispatch({
type: "reset",
sessions: payload.sessions,
globalPause: payload.globalPause ?? null,
sessions: updated.sessions,
globalPause: updated.globalPause ?? null,
});
}
},
)
.catch(() => undefined)
.finally(() => {
refreshingRef.current = false;
});
}
}
} catch {
// Ignore malformed messages
return;
}
};
es.onerror = () => {
// EventSource auto-reconnects; nothing to do here
};
es.onerror = () => undefined;
return () => {
es.close();
};
}, []);
}, [project]);
return { sessions: state.sessions, globalPause: state.globalPause };
return state;
}

View File

@ -1,14 +1,11 @@
import { cache } from "react";
import { loadConfig } from "@composio/ao-core";
/**
* Load the primary project name from config.
* Falls back to "ao" if config is unavailable.
*
* Wrapped with React.cache() to deduplicate filesystem reads
* within a single server render pass (layout + page + icon all
* call this, but config is only read once per request).
*/
export interface ProjectInfo {
id: string;
name: string;
}
export const getProjectName = cache((): string => {
try {
const config = loadConfig();
@ -22,3 +19,26 @@ export const getProjectName = cache((): string => {
}
return "ao";
});
export const getPrimaryProjectId = cache((): string => {
try {
const config = loadConfig();
const firstKey = Object.keys(config.projects)[0];
if (firstKey) return firstKey;
} catch {
// Config not available
}
return "ao";
});
export const getAllProjects = cache((): ProjectInfo[] => {
try {
const config = loadConfig();
return Object.entries(config.projects).map(([id, project]) => ({
id,
name: project.name ?? id,
}));
} catch {
return [];
}
});

View File

@ -0,0 +1,51 @@
type ProjectWithPrefix = { sessionPrefix?: string };
type SessionLike = { id: string; projectId: string };
/**
* Check if a session belongs to a specific project.
* Matches by projectId or sessionPrefix (same logic as resolveProject).
*
* @param session - Session with id and projectId
* @param projectId - The project key to match against
* @param projects - Projects config mapping
*/
function matchesProject(
session: SessionLike,
projectId: string,
projects: Record<string, ProjectWithPrefix>,
): boolean {
if (session.projectId === projectId) return true;
const project = projects[projectId];
if (project?.sessionPrefix && session.id.startsWith(project.sessionPrefix)) return true;
return false;
}
function isOrchestratorSession(session: { id: string }): boolean {
return session.id.endsWith("-orchestrator");
}
export function findOrchestratorSessionId<T extends SessionLike>(
sessions: T[],
projectFilter: string | null | undefined,
projects: Record<string, ProjectWithPrefix>,
): 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<T extends SessionLike>(
sessions: T[],
projectFilter: string | null | undefined,
projects: Record<string, ProjectWithPrefix>,
): T[] {
const workers = sessions.filter((s) => !isOrchestratorSession(s));
if (!projectFilter || projectFilter === "all") return workers;
return workers.filter((s) => matchesProject(s, projectFilter, projects));
}