Merge remote-tracking branch 'origin/main' into feat/issue-1579

# Conflicts:
#	packages/web/src/components/Dashboard.tsx
This commit is contained in:
whoisasx 2026-05-18 01:43:02 +05:30
commit 2ce27ebdc4
48 changed files with 2562 additions and 1827 deletions

View File

@ -0,0 +1,8 @@
---
"@aoagents/ao-core": minor
"@aoagents/ao-web": minor
---
feat: "Launch Orchestrator (clean context)" action on the orchestrator session page
Adds a `Relaunch (clean)` action on the orchestrator session page that replaces the project's canonical orchestrator with a fresh one — killing the existing orchestrator, deleting its metadata, and spawning a new session with no carryover state. Backed by a new `SessionManager.relaunchOrchestrator(config)` method that ignores `orchestratorSessionStrategy`. Removes the now-redundant Orchestrator Selector page (`/orchestrators?project=X`) — there is only ever one orchestrator per project, so a selector page is no longer meaningful. Closes #1900 and #1080.

View File

@ -111,7 +111,7 @@ spawning -> working -> pr_open -> ci_failed / review_pending
+-> mergeable -> merged -> cleanup -> done
```
**Stale runtime reconciliation:** `sm.list()` detects dead runtimes (tmux/process gone) during enrichment and persists `runtime_lost` reason to disk. This maps to legacy status `killed`. Without this, sessions with dead runtimes would show stale "active" status indefinitely.
**Stale runtime reconciliation:** `sm.list()` detects dead runtimes (tmux/process gone) during enrichment and persists `detecting` state with `runtime_lost` reason to disk. The lifecycle manager's `resolveProbeDecision` pipeline is the single authority on terminal decisions — `sm.list()` never writes `terminated` directly (#1735).
### Data Flow
@ -224,7 +224,7 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
- Kanban board filters client-side via `projectSessions` memo
### Key invariants
- `sm.list()` persists `runtime_lost` lifecycle to disk when enrichment detects dead runtimes — this is the only place stale runtime state gets reconciled
- `sm.list()` persists `detecting` state (not `terminated`) to disk when enrichment detects dead runtimes — terminal decisions are made only by the lifecycle manager's probe pipeline (#1735)
- `deriveLegacyStatus()` maps canonical lifecycle to legacy status — new terminal reasons must be added here
- Tab completions merge local config + global config to show all projects
@ -450,8 +450,8 @@ import {
validateUrl, // Webhook URL validation
readLastJsonlEntry, // Efficient JSONL log tail (native agent JSONL)
readLastActivityEntry, // Read last AO activity JSONL entry
checkActivityLogState, // Extract waiting_input/blocked from AO JSONL (with staleness cap)
getActivityFallbackState, // Last-resort fallback: entry state + age-based decay
checkActivityLogState, // Extract sticky waiting_input/blocked from AO JSONL
getActivityFallbackState, // Last-resort fallback: actionable states + liveness age decay
recordTerminalActivity, // Shared recordActivity impl (classify + dedup + append)
classifyTerminalActivity, // Classify terminal output via detectActivity
appendActivityEntry, // Low-level JSONL append
@ -460,7 +460,7 @@ import {
normalizeAgentPermissionMode, // Normalize permission mode strings
DEFAULT_READY_THRESHOLD_MS, // 5 min — ready→idle threshold
DEFAULT_ACTIVE_WINDOW_MS, // 30s — active→ready window
ACTIVITY_INPUT_STALENESS_MS, // 5 min — waiting_input/blocked expiry
ACTIVITY_INPUT_STALENESS_MS, // Deprecated compatibility export; actionable states no longer expire by wallclock
PREFERRED_GH_PATH, // /usr/local/bin/gh
CI_STATUS, ACTIVITY_STATE, SESSION_STATUS, // Constants
type Session, type ProjectConfig, type RuntimeHandle,

View File

@ -9,9 +9,26 @@ import {
appendActivityEntry,
recordTerminalActivity,
getActivityLogPath,
ACTIVITY_INPUT_STALENESS_MS,
getActivityFallbackState,
} from "../activity-log.js";
import type { ActivityState } from "../types.js";
import type { ActivityDetection, ActivityLogEntry, ActivityState } from "../types.js";
const minutesAgo = (minutes: number): string => new Date(Date.now() - minutes * 60_000).toISOString();
const toActivityResult = (
entry: ActivityLogEntry,
): { entry: ActivityLogEntry; modifiedAt: Date } => ({
entry,
modifiedAt: new Date(entry.ts),
});
const detectWithProcessCheck = (
isProcessRunning: boolean,
activityResult: { entry: ActivityLogEntry; modifiedAt: Date } | null,
): ActivityDetection | null => {
if (!isProcessRunning) return { state: "exited", timestamp: new Date() };
return checkActivityLogState(activityResult) ?? getActivityFallbackState(activityResult, 30_000, 5 * 60_000);
};
describe("classifyTerminalActivity", () => {
it("returns active state with no trigger", () => {
@ -56,13 +73,20 @@ describe("checkActivityLogState", () => {
expect(result?.state).toBe("blocked");
});
it("returns null for stale waiting_input entry", () => {
const staleTs = new Date(Date.now() - ACTIVITY_INPUT_STALENESS_MS - 1000).toISOString();
it("returns waiting_input even when older than the former wallclock cap", () => {
const result = checkActivityLogState({
entry: { ts: staleTs, state: "waiting_input", source: "terminal" },
entry: { ts: minutesAgo(10), state: "waiting_input", source: "terminal" },
modifiedAt: new Date(),
});
expect(result).toBeNull();
expect(result?.state).toBe("waiting_input");
});
it("returns blocked even when older than the former wallclock cap", () => {
const result = checkActivityLogState({
entry: { ts: minutesAgo(6), state: "blocked", source: "terminal" },
modifiedAt: new Date(),
});
expect(result?.state).toBe("blocked");
});
it("returns null for non-critical states", () => {
@ -82,6 +106,77 @@ describe("checkActivityLogState", () => {
});
});
describe("getActivityFallbackState", () => {
it("returns waiting_input for a 10-minute-old entry instead of decaying to idle", () => {
const result = getActivityFallbackState(
toActivityResult({ ts: minutesAgo(10), state: "waiting_input", source: "terminal" }),
30_000,
5 * 60_000,
);
expect(result?.state).toBe("waiting_input");
});
it("returns blocked for a 6-minute-old entry instead of decaying to idle", () => {
const result = getActivityFallbackState(
toActivityResult({ ts: minutesAgo(6), state: "blocked", source: "terminal" }),
30_000,
5 * 60_000,
);
expect(result?.state).toBe("blocked");
});
it("returns blocked for a 1-minute-old entry with unchanged behavior", () => {
const result = getActivityFallbackState(
toActivityResult({ ts: minutesAgo(1), state: "blocked", source: "terminal" }),
30_000,
5 * 60_000,
);
expect(result?.state).toBe("blocked");
});
it("lets a newer active entry override an older waiting_input entry", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "ao-test-"));
try {
await mkdir(join(tmpDir, ".ao"), { recursive: true });
const waitingEntry: ActivityLogEntry = {
ts: minutesAgo(6),
state: "waiting_input",
source: "terminal",
};
const activeEntry: ActivityLogEntry = {
ts: new Date(Date.now() - 1000).toISOString(),
state: "active",
source: "terminal",
};
await writeFile(
getActivityLogPath(tmpDir),
`${JSON.stringify(waitingEntry)}\n${JSON.stringify(activeEntry)}\n`,
"utf-8",
);
const activityResult = await readLastActivityEntry(tmpDir);
const result = getActivityFallbackState(activityResult, 30_000, 5 * 60_000);
expect(activityResult?.entry.state).toBe("active");
expect(result?.state).toBe("active");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
it("returns exited when the process check fails before a stale waiting_input can fall through", () => {
const result = detectWithProcessCheck(
false,
toActivityResult({ ts: minutesAgo(6), state: "waiting_input", source: "terminal" }),
);
expect(result?.state).toBe("exited");
});
});
describe("readLastActivityEntry", () => {
let tmpDir: string;
@ -144,6 +239,25 @@ describe("readLastActivityEntry", () => {
const result = await readLastActivityEntry(tmpDir);
expect(result).toBeNull();
});
it("falls back to the previous complete line when a read races a truncated tail", async () => {
await mkdir(join(tmpDir, ".ao"), { recursive: true });
const completeEntry: ActivityLogEntry = {
ts: minutesAgo(10),
state: "waiting_input",
source: "terminal",
trigger: "approve?",
};
await writeFile(
getActivityLogPath(tmpDir),
`${JSON.stringify(completeEntry)}\n{"ts":"${new Date().toISOString()}","state":`,
"utf-8",
);
const result = await readLastActivityEntry(tmpDir);
expect(result?.entry).toEqual(completeEntry);
});
});
describe("recordTerminalActivity", () => {

View File

@ -8,6 +8,7 @@ import { createSessionManager } from "../../session-manager.js";
import {
writeMetadata,
readMetadataRaw,
updateMetadata,
} from "../../metadata.js";
import { createInitialCanonicalLifecycle } from "../../lifecycle-state.js";
import type {
@ -63,6 +64,47 @@ describe("list", () => {
expect(sessions.map((s) => s.id).sort()).toEqual(["app-1", "app-2"]);
});
it("skips dead-runtime agent metadata discovery when native restore metadata is already persisted", async () => {
writeMetadata(sessionsDir, "app-1", {
worktree: config.projects["my-app"]!.path,
branch: "feat/a",
status: "killed",
project: "my-app",
runtimeHandle: makeHandle("rt-old"),
});
updateMetadata(sessionsDir, "app-1", { codexThreadId: "thread-1" });
const deadRuntime: Runtime = {
...mockRuntime,
isAlive: vi.fn().mockResolvedValue(false),
};
const agentWithSessionInfo: Agent = {
...mockAgent,
name: "codex",
getSessionInfo: vi.fn().mockResolvedValue({
summary: null,
agentSessionId: "rollout-1",
metadata: { codexThreadId: "thread-1" },
}),
};
const registryWithDeadRuntime: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return deadRuntime;
if (slot === "agent") return agentWithSessionInfo;
if (slot === "workspace") return mockWorkspace;
return null;
}),
};
const sm = createSessionManager({ config, registry: registryWithDeadRuntime });
await sm.list("my-app");
await sm.list("my-app");
expect(agentWithSessionInfo.getSessionInfo).not.toHaveBeenCalled();
expect(readMetadataRaw(sessionsDir, "app-1")!["codexThreadId"]).toBe("thread-1");
});
it("does not backfill role onto foreign bare-id orchestrator records (issue #1048)", async () => {
// Regression guard for PR #1075 review comment: a legacy record whose id
// is `{projectId}-orchestrator` (pre-numbered scheme, wrong prefix) must
@ -220,7 +262,9 @@ describe("list", () => {
const sm = createSessionManager({ config, registry: registryWithDead });
const sessions = await sm.list();
expect(sessions[0].status).toBe("killed");
// sm.list() persists "detecting" (not "terminated") so the lifecycle
// manager's probe pipeline makes the final terminal decision (#1735).
expect(sessions[0].status).toBe("detecting");
expect(sessions[0].activity).toBe("exited");
});
@ -336,7 +380,8 @@ describe("list", () => {
expect(sessions).toHaveLength(1);
expect(sessions[0].runtimeHandle?.id).toBe(expectedTmuxName);
expect(sessions[0].status).toBe("killed");
// sm.list() persists "detecting" so the lifecycle manager decides (#1735).
expect(sessions[0].status).toBe("detecting");
expect(sessions[0].activity).toBe("exited");
expect(agentWithSpy.getActivityState).not.toHaveBeenCalled();
});

View File

@ -575,7 +575,7 @@ describe("restore", () => {
expect(meta!["restoreFallbackReason"]).toBe("mock-agent.getRestoreCommand returned null");
});
it("does not launch a fresh chat when a native-restore agent cannot build restore command", async () => {
it("falls back to a fresh launch when a native-restore agent cannot build restore command", async () => {
const wsPath = join(tmpDir, "ws-app-native-restore-missing");
mkdirSync(wsPath, { recursive: true });
@ -605,12 +605,119 @@ describe("restore", () => {
const sm = createSessionManager({ config, registry: registryWithNativeRestoreAgent });
await expect(sm.restore("app-1")).rejects.toThrow(SessionNotRestorableError);
expect(mockRuntime.create).not.toHaveBeenCalled();
await sm.restore("app-1");
expect(mockRuntime.create).toHaveBeenCalled();
const createCall = (mockRuntime.create as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(createCall.launchCommand).toBe("mock-agent --start");
const meta = readMetadataRaw(sessionsDir, "app-1");
expect(meta!["restoreFallbackReason"]).toBe("codex.getRestoreCommand returned null");
});
it("persists native restore metadata even when runtime is already dead", async () => {
const wsPath = join(tmpDir, "ws-app-dead-runtime-metadata");
mkdirSync(wsPath, { recursive: true });
const deadRuntime: Runtime = {
...mockRuntime,
isAlive: vi.fn().mockResolvedValue(false),
};
const agentWithDiscoverableThread: Agent = {
...mockAgent,
name: "codex",
getSessionInfo: vi.fn().mockResolvedValue({
summary: null,
agentSessionId: "rollout-1",
metadata: { codexThreadId: "thread-1" },
}),
getRestoreCommand: vi
.fn()
.mockImplementation(async (session) =>
session.metadata?.codexThreadId ? `codex resume ${session.metadata.codexThreadId}` : null,
),
};
const registryWithDeadRuntime: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return deadRuntime;
if (slot === "agent") return agentWithDiscoverableThread;
if (slot === "workspace") return mockWorkspace;
return null;
}),
};
writeMetadata(sessionsDir, "app-1", {
worktree: wsPath,
branch: "feat/TEST-1",
status: "killed",
project: "my-app",
runtimeHandle: makeHandle("rt-old"),
});
const sm = createSessionManager({ config, registry: registryWithDeadRuntime });
const restored = await sm.restore("app-1");
expect(agentWithDiscoverableThread.getSessionInfo).toHaveBeenCalled();
expect(agentWithDiscoverableThread.getRestoreCommand).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({ codexThreadId: "thread-1" }),
}),
expect.any(Object),
);
expect(restored.metadata["codexThreadId"]).toBe("thread-1");
const createCall = (deadRuntime.create as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(createCall.launchCommand).toBe("codex resume thread-1");
const meta = readMetadataRaw(sessionsDir, "app-1");
expect(meta!["codexThreadId"]).toBe("thread-1");
});
it("uses project path as restore workspace when worktree metadata is missing", async () => {
mkdirSync(config.projects["my-app"]!.path, { recursive: true });
const agentWithWorkspaceAssertion: Agent = {
...mockAgent,
name: "codex",
getRestoreCommand: vi
.fn()
.mockImplementation(async (session) =>
session.workspacePath === config.projects["my-app"]!.path
? "codex resume thread-1"
: null,
),
};
const registryWithWorkspaceAssertion: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "agent") return agentWithWorkspaceAssertion;
if (slot === "workspace") return mockWorkspace;
return null;
}),
};
writeMetadata(sessionsDir, "app-1", {
worktree: "",
branch: "feat/TEST-1",
status: "killed",
project: "my-app",
runtimeHandle: makeHandle("rt-old"),
});
const sm = createSessionManager({ config, registry: registryWithWorkspaceAssertion });
const restored = await sm.restore("app-1");
expect(restored.workspacePath).toBe(config.projects["my-app"]!.path);
expect(agentWithWorkspaceAssertion.getRestoreCommand).toHaveBeenCalledWith(
expect.objectContaining({ workspacePath: config.projects["my-app"]!.path }),
expect.any(Object),
);
const createCall = (mockRuntime.create as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(createCall.workspacePath).toBe(config.projects["my-app"]!.path);
expect(createCall.launchCommand).toBe("codex resume thread-1");
});
it("clears restore fallback reason when getRestoreCommand succeeds", async () => {
const wsPath = join(tmpDir, "ws-app-restore-clears-fallback");
mkdirSync(wsPath, { recursive: true });

View File

@ -13,7 +13,7 @@ import {
readMetadata,
readMetadataRaw,
} from "../../metadata.js";
import { getProjectWorktreesDir } from "../../paths.js";
import { getProjectDir, getProjectWorktreesDir } from "../../paths.js";
import type {
OrchestratorConfig,
PluginRegistry,
@ -2489,4 +2489,183 @@ describe("spawn", () => {
});
});
describe("relaunchOrchestrator", () => {
it("spawns a fresh orchestrator when none exists", async () => {
const sm = createSessionManager({ config, registry: mockRegistry });
const session = await sm.relaunchOrchestrator({ projectId: "my-app" });
expect(session.id).toBe("app-orchestrator");
expect(session.status).toBe("working");
expect(mockWorkspace.create).toHaveBeenCalledTimes(1);
expect(mockRuntime.create).toHaveBeenCalledTimes(1);
});
it("kills and replaces an existing orchestrator regardless of strategy", async () => {
writeMetadata(sessionsDir, "app-orchestrator", {
role: "orchestrator",
project: "my-app",
status: "working",
branch: "orchestrator/app-orchestrator",
worktree: join(tmpDir, "old-orchestrator-ws"),
runtimeHandle: makeHandle("old-rt"),
});
const sm = createSessionManager({ config, registry: mockRegistry });
const session = await sm.relaunchOrchestrator({ projectId: "my-app" });
expect(session.id).toBe("app-orchestrator");
expect(mockRuntime.destroy).toHaveBeenCalledWith(makeHandle("old-rt"));
expect(mockWorkspace.create).toHaveBeenCalledWith(
expect.objectContaining({ sessionId: "app-orchestrator" }),
);
const meta = readMetadataRaw(sessionsDir, "app-orchestrator");
expect(meta?.["status"]).toBe("working");
expect(meta?.["runtimeHandle"]).not.toContain("old-rt");
});
it("ignores project orchestratorSessionStrategy: ignore and still replaces", async () => {
const configWithIgnore: OrchestratorConfig = {
...config,
projects: {
...config.projects,
"my-app": {
...config.projects["my-app"]!,
orchestratorSessionStrategy: "ignore",
},
},
};
writeMetadata(sessionsDir, "app-orchestrator", {
role: "orchestrator",
project: "my-app",
status: "working",
branch: "orchestrator/app-orchestrator",
worktree: join(tmpDir, "old-orchestrator-ws"),
runtimeHandle: makeHandle("old-rt"),
});
const sm = createSessionManager({ config: configWithIgnore, registry: mockRegistry });
await sm.relaunchOrchestrator({ projectId: "my-app" });
expect(mockRuntime.destroy).toHaveBeenCalledWith(makeHandle("old-rt"));
expect(mockWorkspace.create).toHaveBeenCalled();
});
it("coalesces concurrent relaunch calls into a single replacement", async () => {
writeMetadata(sessionsDir, "app-orchestrator", {
role: "orchestrator",
project: "my-app",
status: "working",
branch: "orchestrator/app-orchestrator",
worktree: join(tmpDir, "old-orchestrator-ws"),
runtimeHandle: makeHandle("old-rt"),
});
const sm = createSessionManager({ config, registry: mockRegistry });
const [s1, s2] = await Promise.all([
sm.relaunchOrchestrator({ projectId: "my-app" }),
sm.relaunchOrchestrator({ projectId: "my-app" }),
]);
expect(s1.id).toBe("app-orchestrator");
expect(s2.id).toBe("app-orchestrator");
expect(mockRuntime.destroy).toHaveBeenCalledTimes(1);
expect(mockWorkspace.create).toHaveBeenCalledTimes(1);
expect(mockRuntime.create).toHaveBeenCalledTimes(1);
});
it("ensureOrchestrator waits for an in-flight relaunch before returning", async () => {
writeMetadata(sessionsDir, "app-orchestrator", {
role: "orchestrator",
project: "my-app",
status: "working",
branch: "orchestrator/app-orchestrator",
worktree: join(tmpDir, "old-orchestrator-ws"),
runtimeHandle: makeHandle("old-rt"),
});
let releaseWorkspace: () => void = () => {};
const blockingWorkspace = new Promise<void>((resolve) => {
releaseWorkspace = resolve;
});
(mockWorkspace.create as ReturnType<typeof vi.fn>).mockImplementationOnce(async (cfg) => {
await blockingWorkspace;
return {
path: join(tmpDir, "ws-relaunch"),
branch: cfg.branch,
sessionId: cfg.sessionId,
projectId: cfg.projectId,
};
});
const sm = createSessionManager({ config, registry: mockRegistry });
const relaunchPromise = sm.relaunchOrchestrator({ projectId: "my-app" });
await Promise.resolve();
const ensurePromise = sm.ensureOrchestrator({ projectId: "my-app" });
releaseWorkspace();
const [relaunched, ensured] = await Promise.all([relaunchPromise, ensurePromise]);
expect(relaunched.id).toBe("app-orchestrator");
expect(ensured.id).toBe("app-orchestrator");
// Both should resolve to the *post-relaunch* session, not the killed one.
expect(mockRuntime.destroy).toHaveBeenCalledWith(makeHandle("old-rt"));
});
it("waits for an in-flight ensureOrchestrator before replacing", async () => {
let releaseWorkspace: () => void = () => {};
const blockingWorkspace = new Promise<void>((resolve) => {
releaseWorkspace = resolve;
});
const ensureWorkspacePath = join(tmpDir, "ws-ensure");
(mockWorkspace.create as ReturnType<typeof vi.fn>).mockImplementationOnce(async (cfg) => {
await blockingWorkspace;
return {
path: ensureWorkspacePath,
branch: cfg.branch,
sessionId: cfg.sessionId,
projectId: cfg.projectId,
};
});
const sm = createSessionManager({ config, registry: mockRegistry });
const ensurePromise = sm.ensureOrchestrator({ projectId: "my-app" });
// Yield so ensure can register itself in the in-flight map.
await Promise.resolve();
const relaunchPromise = sm.relaunchOrchestrator({ projectId: "my-app" });
releaseWorkspace();
const [ensured, relaunched] = await Promise.all([ensurePromise, relaunchPromise]);
expect(ensured.id).toBe("app-orchestrator");
expect(relaunched.id).toBe("app-orchestrator");
// Relaunch's kill must run, destroying the runtime ensure just spawned.
expect(mockRuntime.destroy).toHaveBeenCalled();
});
it("rewrites the orchestrator-prompt file with the new system prompt", async () => {
writeMetadata(sessionsDir, "app-orchestrator", {
role: "orchestrator",
project: "my-app",
status: "working",
branch: "orchestrator/app-orchestrator",
worktree: join(tmpDir, "old-orchestrator-ws"),
runtimeHandle: makeHandle("old-rt"),
});
const sm = createSessionManager({ config, registry: mockRegistry });
await sm.relaunchOrchestrator({
projectId: "my-app",
systemPrompt: "FRESH ORCHESTRATOR PROMPT",
});
const promptPath = join(
getProjectDir("my-app"),
"orchestrator-prompt-app-orchestrator.md",
);
expect(existsSync(promptPath)).toBe(true);
expect(readFileSync(promptPath, "utf-8")).toBe("FRESH ORCHESTRATOR PROMPT");
});
});
});

View File

@ -499,6 +499,11 @@ export function createMockSessionManager(): OpenCodeSessionManager {
.mockResolvedValue(
makeSession({ id: "app-orchestrator", metadata: { role: "orchestrator" } }),
),
relaunchOrchestrator: vi
.fn()
.mockResolvedValue(
makeSession({ id: "app-orchestrator", metadata: { role: "orchestrator" } }),
),
restore: vi.fn().mockResolvedValue(makeSession()),
list: vi.fn().mockResolvedValue([]),
listCached: vi.fn().mockResolvedValue([]),

View File

@ -15,10 +15,8 @@ import { join, dirname } from "node:path";
import type { ActivityState, ActivityLogEntry, ActivityDetection } from "./types.js";
/**
* Maximum age (ms) for `waiting_input`/`blocked` entries before they're
* considered stale. If no new terminal output overwrites the entry within
* this window, the state falls through to downstream fallbacks instead of
* keeping the session stuck in `needs_input` on the dashboard forever.
* @deprecated Actionable states no longer decay on wallclock. Retained until
* the activity-reducer cleanup removes the old activity-log module.
*/
export const ACTIVITY_INPUT_STALENESS_MS = 5 * 60 * 1000; // 5 minutes
@ -129,7 +127,7 @@ export async function readLastActivityEntry(
/**
* Check the AO activity JSONL for actionable states only.
*
* Only returns `waiting_input`/`blocked` (with a staleness cap).
* Only returns `waiting_input`/`blocked`.
* Non-critical states (`active`, `ready`, `idle`) always return `null` so
* callers fall through to their native signals (git commits, chat history,
* API queries, native JSONL). This prevents the lifecycle manager's
@ -144,18 +142,12 @@ export function checkActivityLogState(
const { entry } = activityResult;
if (entry.state === "waiting_input" || entry.state === "blocked") {
// Use the entry's own timestamp for staleness — not file mtime, which
// gets refreshed every poll cycle by recordActivity and would prevent
// stale entries from ever being detected.
const entryTs = new Date(entry.ts);
if (Number.isNaN(entryTs.getTime())) return null;
const ageMs = Date.now() - entryTs.getTime();
if (ageMs <= ACTIVITY_INPUT_STALENESS_MS) {
return { state: entry.state, timestamp: entryTs };
}
return { state: entry.state, timestamp: entryTs };
}
// Non-critical states and stale entries — fall through to native signals
// Non-critical states fall through to native signals
return null;
}
@ -178,16 +170,8 @@ export function getActivityFallbackState(
const entryTs = new Date(entry.ts);
if (Number.isNaN(entryTs.getTime())) return null;
// Actionable states use the same staleness cap as checkActivityLogState.
// If the entry is stale, fall through to age-based decay instead of
// re-surfacing a waiting_input/blocked that checkActivityLogState already filtered.
if (entry.state === "waiting_input" || entry.state === "blocked") {
const ageMs = Date.now() - entryTs.getTime();
if (ageMs <= ACTIVITY_INPUT_STALENESS_MS) {
return { state: entry.state, timestamp: entryTs };
}
// Stale actionable entry — treat as idle
return { state: "idle", timestamp: entryTs };
return { state: entry.state, timestamp: entryTs };
}
// Age-based decay: active→ready→idle, but never promote past the

View File

@ -141,6 +141,7 @@ function metadataToSession(sessionId: string, project: PortfolioProject, metadat
return sessionFromMetadata(sessionId, metadataToRecord(metadata), {
projectId: project.id,
workspacePathFallback: project.repoPath,
status: (metadata.status as Session["status"]) || "spawning",
activity: null,
runtimeHandle: metadata.runtimeHandle ?? null,

View File

@ -132,6 +132,7 @@ export async function recoverSession(
const session = sessionFromMetadata(sessionId, updatedMetadata, {
projectId: assessment.projectId,
workspacePathFallback: assessment.workspacePath ?? undefined,
status: preservedStatus,
runtimeHandle: assessment.runtimeHandle,
lastActivityAt: new Date(),

View File

@ -338,27 +338,33 @@ function parseLifecycleFromRaw(
}
}
interface MetadataToSessionOptions {
projectId: string;
sessionPrefix?: string;
createdAt?: Date;
modifiedAt?: Date;
workspacePathFallback?: string;
}
/** Reconstruct a Session object from raw metadata key=value pairs. */
function metadataToSession(
sessionId: SessionId,
meta: Record<string, string>,
projectId: string,
sessionPrefix?: string,
createdAt?: Date,
modifiedAt?: Date,
options: MetadataToSessionOptions,
): Session {
const sessionKind =
meta["role"] === "orchestrator" ||
(sessionPrefix
? new RegExp(`^${escapeRegex(sessionPrefix)}-orchestrator-\\d+$`).test(sessionId)
(options.sessionPrefix
? new RegExp(`^${escapeRegex(options.sessionPrefix)}-orchestrator-\\d+$`).test(sessionId)
: false)
? "orchestrator"
: "worker";
return sessionFromMetadata(sessionId, meta, {
projectId,
projectId: options.projectId,
workspacePathFallback: options.workspacePathFallback,
sessionKind,
createdAt,
lastActivityAt: modifiedAt ?? new Date(),
createdAt: options.createdAt,
lastActivityAt: options.modifiedAt ?? new Date(),
});
}
@ -453,18 +459,6 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
return isOrchestratorSessionRecord(sessionId, metadata ?? {}, project.sessionPrefix);
}
function requiresNativeRestore(agentName: string): boolean {
// kimicode is intentionally excluded: kimi's session dir only exists if the
// previous launch ran far enough to write to ~/.kimi/sessions. A failed
// launch (e.g. the --agent-file YAML crash) leaves no session to resume,
// and falling back to a fresh getLaunchCommand is the only sensible choice.
return (
agentName === "claude-code" ||
agentName === "codex" ||
agentName === "opencode"
);
}
function applyMetadataUpdatesToRaw(
raw: Record<string, string>,
updates: Partial<Record<string, string>>,
@ -538,6 +532,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
expiresAt: number;
} | null = null;
const ensureOrchestratorPromises = new Map<string, Promise<Session>>();
const relaunchOrchestratorPromises = new Map<string, Promise<Session>>();
function invalidateCache(): void {
sessionCache = null;
@ -1019,12 +1014,68 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
*/
const TERMINAL_SESSION_STATUSES = new Set(["killed", "done", "merged", "terminated", "cleanup"]);
function hasPersistedNativeRestoreMetadata(session: Session, agent: Agent): boolean {
const metadata = session.metadata ?? {};
switch (agent.name) {
case "claude-code":
return typeof metadata["claudeSessionUuid"] === "string" && metadata["claudeSessionUuid"].trim().length > 0;
case "codex":
return typeof metadata["codexThreadId"] === "string" && metadata["codexThreadId"].trim().length > 0;
case "opencode":
return asValidOpenCodeSessionId(metadata["opencodeSessionId"]) !== null;
default:
return false;
}
}
function canDiscoverSessionInfoAfterRuntimeExit(agent: Agent): boolean {
return agent.name === "claude-code" || agent.name === "codex";
}
async function enrichSessionWithRuntimeState(
session: Session,
plugins: ReturnType<typeof resolvePlugins>,
handleFromMetadata: boolean,
sessionsDir: string,
): Promise<void> {
async function persistAgentSessionInfo(options?: { skipIfNativeRestoreMetadataPresent?: boolean }): Promise<void> {
if (!plugins.agent) return;
if (
options?.skipIfNativeRestoreMetadataPresent &&
hasPersistedNativeRestoreMetadata(session, plugins.agent)
) {
return;
}
let info: Awaited<ReturnType<Agent["getSessionInfo"]>>;
try {
info = await plugins.agent.getSessionInfo(session);
} catch {
// Can't get session info — keep existing values
info = null;
}
if (!info) return;
session.agentInfo = info;
const metadataUpdates = info.metadata ?? {};
const allAlreadyPersisted = Object.keys(metadataUpdates).every(
(key) => session.metadata?.[key] === metadataUpdates[key],
);
if (allAlreadyPersisted) return;
if (Object.keys(metadataUpdates).length > 0) {
try {
updateMetadata(sessionsDir, session.id, metadataUpdates);
session.metadata = applyMetadataUpdates(session.metadata, metadataUpdates);
invalidateCache();
} catch {
// Persisting agent metadata is best-effort; keep live agent info.
}
}
}
// Check runtime liveness first — for all statuses except "spawning".
// Skip spawning sessions because tmux may not be fully initialized yet,
// and a false-negative from isAlive() would permanently mark the session
@ -1065,6 +1116,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
activity: "exited",
source: "runtime",
});
// Dead-runtime session info discovery is intentionally limited to
// agents that recover restore metadata from persisted local files.
if (plugins.agent && canDiscoverSessionInfoAfterRuntimeExit(plugins.agent)) {
await persistAgentSessionInfo({ skipIfNativeRestoreMetadataPresent: true });
}
return;
}
} catch {
@ -1101,28 +1157,8 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
session.activitySignal = createActivitySignal("probe_failure", { source: "native" });
}
// Enrich with live agent session info (summary, cost).
let info: Awaited<ReturnType<Agent["getSessionInfo"]>>;
try {
info = await plugins.agent.getSessionInfo(session);
} catch {
// Can't get session info — keep existing values
info = null;
}
if (info) {
session.agentInfo = info;
const metadataUpdates = info.metadata ?? {};
if (Object.keys(metadataUpdates).length > 0) {
try {
updateMetadata(sessionsDir, session.id, metadataUpdates);
session.metadata = applyMetadataUpdates(session.metadata, metadataUpdates);
invalidateCache();
} catch {
// Persisting agent metadata is best-effort; keep live agent info.
}
}
}
// Enrich with agent session info (summary, cost, native restore metadata).
await persistAgentSessionInfo();
}
}
@ -1815,6 +1851,20 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
}
const sessionId = getOrchestratorSessionId(project);
// If a relaunch is mid-flight for this sessionId, wait it out — otherwise
// we could return a session that relaunch is about to kill, or race the
// relaunch's spawnOrchestrator on the same reservation.
const pendingRelaunch = relaunchOrchestratorPromises.get(sessionId);
if (pendingRelaunch) {
await pendingRelaunch.catch((err) => {
console.warn(
`[ensureOrchestrator] in-flight relaunch for ${sessionId} failed before ensure proceeded:`,
err,
);
});
}
const existing = await get(sessionId);
if (existing) {
const orchestratorSessionStrategy = normalizeOrchestratorSessionStrategy(
@ -1876,6 +1926,61 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
return promise;
}
async function relaunchOrchestratorInternal(
orchestratorConfig: OrchestratorSpawnConfig,
): Promise<Session> {
const project = config.projects[orchestratorConfig.projectId];
if (!project) {
throw new Error(`Unknown project: ${orchestratorConfig.projectId}`);
}
const sessionId = getOrchestratorSessionId(project);
const sessionsDir = getProjectSessionsDir(orchestratorConfig.projectId);
// If ensureOrchestrator is mid-flight for this sessionId, wait it out.
// Otherwise get() would return null (metadata not yet written) and we'd
// skip the kill, then race the in-flight spawnOrchestrator on the same
// reservation — surfacing "session already exists" instead of replacing.
const pendingEnsure = ensureOrchestratorPromises.get(sessionId);
if (pendingEnsure) {
await pendingEnsure.catch((err) => {
console.warn(
`[relaunchOrchestrator] in-flight ensure for ${sessionId} failed before relaunch proceeded:`,
err,
);
});
}
const existing = await get(sessionId);
if (existing) {
const existingAgent = resolveSelectionForSession(
project,
sessionId,
readMetadataRaw(sessionsDir, sessionId) ?? {},
).agentName;
await kill(sessionId, { purgeOpenCode: existingAgent === "opencode" });
deleteMetadata(sessionsDir, sessionId);
}
return spawnOrchestrator(orchestratorConfig);
}
async function relaunchOrchestrator(
orchestratorConfig: OrchestratorSpawnConfig,
): Promise<Session> {
const project = config.projects[orchestratorConfig.projectId];
if (!project) {
throw new Error(`Unknown project: ${orchestratorConfig.projectId}`);
}
const sessionId = getOrchestratorSessionId(project);
const existingPromise = relaunchOrchestratorPromises.get(sessionId);
if (existingPromise) return existingPromise;
const promise = relaunchOrchestratorInternal(orchestratorConfig).finally(() => {
relaunchOrchestratorPromises.delete(sessionId);
});
relaunchOrchestratorPromises.set(sessionId, promise);
return promise;
}
async function list(projectId?: string): Promise<Session[]> {
const allSessions = Object.entries(config.projects).flatMap(([entryProjectId, project]) => {
if (projectId && entryProjectId !== projectId) return [];
@ -1907,10 +2012,13 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
const session = metadataToSession(
sessionName,
raw,
sessionProjectId,
project.sessionPrefix,
createdAt,
modifiedAt,
{
projectId: sessionProjectId,
sessionPrefix: project.sessionPrefix,
createdAt,
modifiedAt,
workspacePathFallback: project.path,
},
);
const selection = resolveSelectionForSession(project, sessionName, raw);
const effectiveAgentName = selection.agentName;
@ -1941,23 +2049,30 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
}
}
// Persist lifecycle to disk when enrichment detected a dead runtime.
// enrichSessionWithRuntimeState updates the in-memory lifecycle but
// doesn't write to disk — without this, the stale "alive" state persists
// and the dashboard shows terminated sessions on the active sidebar.
// Persist runtime probe result to disk so the lifecycle manager sees it
// on next poll. We only persist the runtime signal and detecting state —
// the lifecycle manager's resolveProbeDecision pipeline is the single
// authority on terminal decisions (terminated/done). See #1735.
// Check the on-disk state (raw) to avoid re-writing when already
// detecting — enrichment sets detecting in-memory, but we only need
// to persist the transition once to avoid resetting lastTransitionAt.
const onDiskLifecycle = parseCanonicalLifecycle(raw, {
sessionId: sessionName,
status: validateStatus(raw["status"]),
});
if (
session.lifecycle &&
(session.lifecycle.runtime.state === "missing" ||
session.lifecycle.runtime.state === "exited") &&
session.lifecycle.session.state !== "terminated" &&
session.lifecycle.session.state !== "done"
onDiskLifecycle.session.state !== "terminated" &&
onDiskLifecycle.session.state !== "done" &&
onDiskLifecycle.session.state !== "detecting"
) {
try {
const persisted = buildUpdatedLifecycle(sessionName, raw, (next) => {
next.session.state = "terminated";
next.session.state = "detecting";
next.session.reason = "runtime_lost";
next.session.terminatedAt = new Date().toISOString();
next.session.lastTransitionAt = next.session.terminatedAt;
next.session.lastTransitionAt = new Date().toISOString();
next.runtime.state = session.lifecycle!.runtime.state;
next.runtime.reason = session.lifecycle!.runtime.reason;
next.runtime.lastObservedAt = new Date().toISOString();
@ -2021,10 +2136,13 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
const session = metadataToSession(
sessionId,
repaired.raw,
projectId,
project.sessionPrefix,
createdAt,
modifiedAt,
{
projectId,
sessionPrefix: project.sessionPrefix,
createdAt,
modifiedAt,
workspacePathFallback: project.path,
},
);
const selection = resolveSelectionForSession(project, sessionId, repaired.raw);
@ -2795,7 +2913,15 @@ 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, projectId, project.sessionPrefix);
const session = metadataToSession(
sessionId,
raw,
{
projectId,
sessionPrefix: project.sessionPrefix,
workspacePathFallback: project.path,
},
);
const plugins = resolvePlugins(project, selection.agentName);
await enrichSessionWithRuntimeState(session, plugins, true, sessionsDir);
@ -2931,13 +3057,12 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
launchCommand = restoreCmd;
updateMetadata(sessionsDir, sessionId, { restoreFallbackReason: "" });
} else {
// Agents with native restore can still launch fresh when no resumable
// session metadata exists; this keeps restore from becoming a hard stop.
const reason = `${plugins.agent.name}.getRestoreCommand returned null`;
updateMetadata(sessionsDir, sessionId, {
restoreFallbackReason: reason,
});
if (requiresNativeRestore(plugins.agent.name)) {
throw new SessionNotRestorableError(sessionId, reason);
}
launchCommand = plugins.agent.getLaunchCommand(agentLaunchConfig);
}
} else {
@ -3047,6 +3172,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
spawn,
spawnOrchestrator,
ensureOrchestrator,
relaunchOrchestrator,
restore,
list,
listCached,

View File

@ -1845,6 +1845,13 @@ export interface SessionManager {
spawn(config: SessionSpawnConfig): Promise<Session>;
spawnOrchestrator(config: OrchestratorSpawnConfig): Promise<Session>;
ensureOrchestrator(config: OrchestratorSpawnConfig): Promise<Session>;
/**
* Replace the canonical orchestrator with a fresh one. If an orchestrator
* already exists for the project, it is killed, its metadata deleted, and a
* new orchestrator spawned with no carryover state. Ignores
* `orchestratorSessionStrategy` replacement is the whole point.
*/
relaunchOrchestrator(config: OrchestratorSpawnConfig): Promise<Session>;
restore(sessionId: SessionId): Promise<Session>;
list(projectId?: string): Promise<Session[]>;
get(sessionId: SessionId): Promise<Session | null>;

View File

@ -14,6 +14,7 @@ import { safeJsonParse, validateStatus } from "./validation.js";
interface SessionFromMetadataOptions {
projectId?: string;
workspacePathFallback?: string;
status?: SessionStatus;
sessionKind?: SessionKind;
activity?: Session["activity"];
@ -86,7 +87,7 @@ export function sessionFromMetadata(
};
})()
: null,
workspacePath: meta["worktree"] || null,
workspacePath: meta["worktree"] || options.workspacePathFallback || null,
runtimeHandle: lifecycle.runtime.handle ?? runtimeHandle,
agentInfo: meta["summary"] ? { summary: meta["summary"], agentSessionId: null } : null,
createdAt: meta["createdAt"] ? new Date(meta["createdAt"]) : (options.createdAt ?? new Date()),

View File

@ -269,10 +269,10 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => {
// Linear API has eventual consistency — poll until the issue appears in list results
const found = await pollUntil(
async () => {
const issues = await tracker.listIssues!({ state: "open", limit: 50 }, project);
const issues = await tracker.listIssues!({ state: "open", limit: 100 }, project);
return issues.find((i: { id: string }) => i.id === issueIdentifier);
},
{ timeoutMs: 5_000, intervalMs: 500 },
{ timeoutMs: 15_000, intervalMs: 1_000 },
);
expect(found).toBeDefined();

View File

@ -1,9 +1,15 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node:fs";
import { existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { toClaudeProjectPath, create } from "../index.js";
import { createActivitySignal, type Session, type RuntimeHandle } from "@aoagents/ao-core";
import {
createActivitySignal,
readLastActivityEntry,
type ActivityState,
type Session,
type RuntimeHandle,
} from "@aoagents/ao-core";
// Mock homedir() so getActivityState looks in our temp dir
vi.mock("node:os", async (importOriginal) => {
@ -57,6 +63,16 @@ function writeJsonl(
}
}
function writeActivityLog(state: ActivityState, ageMs = 0): void {
const ts = new Date(Date.now() - ageMs).toISOString();
const aoDir = join(workspacePath, ".ao");
mkdirSync(aoDir, { recursive: true });
writeFileSync(
join(aoDir, "activity.jsonl"),
JSON.stringify({ ts, state, source: "terminal" }) + "\n",
);
}
// =============================================================================
// toClaudeProjectPath
// =============================================================================
@ -141,23 +157,74 @@ describe("Claude Code Activity Detection", () => {
// Fallback cases (no JSONL data available)
// -----------------------------------------------------------------------
it("returns 'idle' when no session file exists yet", async () => {
// projectDir exists but is empty — no .jsonl files yet (freshly spawned session)
const session = makeSession();
const result = await agent.getActivityState(session);
expect(result?.state).toBe("idle");
// timestamp must be session.createdAt so stuck-detection can fire eventually
expect(result?.timestamp).toBe(session.createdAt);
it("returns null when no session file or AO activity entry exists yet", async () => {
// projectDir exists but is empty, and the AO safety-net log is absent.
expect(await agent.getActivityState(makeSession())).toBeNull();
});
it("returns null when no workspacePath", async () => {
expect(await agent.getActivityState(makeSession({ workspacePath: null }))).toBeNull();
});
it("returns 'idle' when project directory does not exist", async () => {
// Process is running but no Claude project dir yet — treat as idle
it("returns null when project directory does not exist and AO activity is unavailable", async () => {
const badPath = join(fakeHome, "nonexistent-workspace");
expect((await agent.getActivityState(makeSession({ workspacePath: badPath })))?.state).toBe("idle");
expect(await agent.getActivityState(makeSession({ workspacePath: badPath }))).toBeNull();
});
it("recordActivity writes to .ao/activity.jsonl when workspacePath is set", async () => {
await agent.recordActivity?.(makeSession(), "Do you want to proceed?\n(Y)es / (N)o");
const result = await readLastActivityEntry(workspacePath);
expect(result?.entry.state).toBe("waiting_input");
expect(result?.entry.source).toBe("terminal");
expect(result?.entry.trigger).toContain("Do you want to proceed?");
});
it("recordActivity is a no-op when workspacePath is null", async () => {
await agent.recordActivity?.(
makeSession({ workspacePath: null }),
"Do you want to proceed?\n(Y)es / (N)o",
);
expect(existsSync(join(workspacePath, ".ao", "activity.jsonl"))).toBe(false);
});
it("keeps native JSONL as primary when AO activity JSONL also exists", async () => {
writeJsonl([{ type: "assistant", message: { content: "Done!" } }]);
writeActivityLog("waiting_input");
expect((await agent.getActivityState(makeSession()))?.state).toBe("ready");
});
it("falls back to AO JSONL waiting_input when native session lookup is unavailable", async () => {
await agent.recordActivity?.(makeSession(), "Do you want to proceed?\n(Y)es / (N)o");
expect((await agent.getActivityState(makeSession()))?.state).toBe("waiting_input");
});
it("falls back to AO JSONL waiting_input when native session entry predates this session", async () => {
writeJsonl([{ type: "assistant", message: { content: "Previous session done" } }], 120_000);
const session = makeSession({ createdAt: new Date() });
await agent.recordActivity?.(session, "Do you want to proceed?\n(Y)es / (N)o");
expect((await agent.getActivityState(session))?.state).toBe("waiting_input");
});
it("returns idle for stale native session entry when AO JSONL is unavailable", async () => {
writeJsonl([{ type: "assistant", message: { content: "Previous session done" } }], 120_000);
const session = makeSession({ createdAt: new Date() });
const result = await agent.getActivityState(session);
expect(result?.state).toBe("idle");
expect(result?.timestamp).toBe(session.createdAt);
});
it("falls back to AO JSONL age-decay when native session lookup is unavailable", async () => {
writeActivityLog("active", 400_000);
expect((await agent.getActivityState(makeSession()))?.state).toBe("idle");
});
// -----------------------------------------------------------------------
@ -306,8 +373,8 @@ describe("Claude Code Activity Detection", () => {
it("ignores agent- prefixed JSONL files", async () => {
writeJsonl([{ type: "user" }], 0, "agent-toolkit.jsonl");
// No real session file → process is running, treat as idle
expect((await agent.getActivityState(makeSession()))?.state).toBe("idle");
// No real session file and no AO activity fallback.
expect(await agent.getActivityState(makeSession())).toBeNull();
});
it("reads last entry from multi-entry JSONL (not first)", async () => {

View File

@ -6,6 +6,10 @@ import {
PROCESS_PROBE_INDETERMINATE,
DEFAULT_READY_THRESHOLD_MS,
DEFAULT_ACTIVE_WINDOW_MS,
readLastActivityEntry,
checkActivityLogState,
getActivityFallbackState,
recordTerminalActivity,
type Agent,
type AgentSessionInfo,
type AgentLaunchConfig,
@ -947,6 +951,13 @@ function createClaudeCodeAgent(): Agent {
return classifyTerminalOutput(terminalOutput);
},
async recordActivity(session: Session, terminalOutput: string): Promise<void> {
if (!session.workspacePath) return;
await recordTerminalActivity(session.workspacePath, terminalOutput, (output) =>
this.detectActivity(output),
);
},
async isProcessRunning(handle: RuntimeHandle): Promise<ProcessProbeResult> {
const pid = await findClaudeProcess(handle);
if (pid === PROCESS_PROBE_INDETERMINATE) return PROCESS_PROBE_INDETERMINATE;
@ -976,51 +987,67 @@ function createClaudeCodeAgent(): Agent {
const projectDir = join(homedir(), ".claude", "projects", projectPath);
const sessionFile = await findLatestSessionFile(projectDir);
if (!sessionFile) {
// No session file yet — process is running but no conversation started.
// Treat as idle (waiting for first task).
return { state: "idle", timestamp: session.createdAt };
let staleNativeState: ActivityDetection | null = null;
if (sessionFile) {
const entry = await readLastJsonlEntry(sessionFile);
if (entry) {
// If the JSONL entry predates this session, it's from a previous session
// in the same worktree. Fall through to the AO safety net first: the
// terminal may have already surfaced waiting_input/blocked before
// Claude writes this session's first native JSONL entry.
if (session.createdAt && entry.modifiedAt < session.createdAt) {
staleNativeState = { state: "idle", timestamp: session.createdAt };
} else {
const ageMs = Date.now() - entry.modifiedAt.getTime();
const timestamp = entry.modifiedAt;
const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold);
switch (entry.lastType) {
case "user":
case "tool_use":
case "progress":
if (ageMs <= activeWindowMs) return { state: "active", timestamp };
return { state: ageMs > threshold ? "idle" : "ready", timestamp };
case "assistant":
case "system":
case "summary":
case "result":
return { state: ageMs > threshold ? "idle" : "ready", timestamp };
case "permission_request":
return { state: "waiting_input", timestamp };
case "error":
return { state: "blocked", timestamp };
default:
if (ageMs <= activeWindowMs) return { state: "active", timestamp };
return { state: ageMs > threshold ? "idle" : "ready", timestamp };
}
}
}
// Session file exists but no parseable entry — fall through to AO JSONL
// checks below instead of returning early, so terminal-derived
// waiting_input/blocked can still be detected.
}
const entry = await readLastJsonlEntry(sessionFile);
if (!entry) {
// Empty file or read error — cannot determine activity
return null;
}
// If the JSONL entry predates this session, it's from a previous session
// in the same worktree. Treat as no data (agent hasn't written yet).
if (session.createdAt && entry.modifiedAt < session.createdAt) {
return { state: "idle", timestamp: session.createdAt };
}
const ageMs = Date.now() - entry.modifiedAt.getTime();
const timestamp = entry.modifiedAt;
// Fallback: check AO activity JSONL (terminal-derived) for
// waiting_input/blocked when Claude's native JSONL is unavailable.
const activityResult = await readLastActivityEntry(session.workspacePath);
const activityState = checkActivityLogState(activityResult);
if (activityState) return activityState;
// Last fallback: use the AO entry with age-based decay when native
// session lookup is missing or unparseable (e.g. Claude project slug drift).
const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold);
switch (entry.lastType) {
case "user":
case "tool_use":
case "progress":
if (ageMs <= activeWindowMs) return { state: "active", timestamp };
return { state: ageMs > threshold ? "idle" : "ready", timestamp };
const fallback = getActivityFallbackState(activityResult, activeWindowMs, threshold);
if (fallback) return fallback;
case "assistant":
case "system":
case "summary":
case "result":
return { state: ageMs > threshold ? "idle" : "ready", timestamp };
if (staleNativeState) return staleNativeState;
case "permission_request":
return { state: "waiting_input", timestamp };
case "error":
return { state: "blocked", timestamp };
default:
if (ageMs <= activeWindowMs) return { state: "active", timestamp };
return { state: ageMs > threshold ? "idle" : "ready", timestamp };
}
return null;
},
async getSessionInfo(session: Session): Promise<AgentSessionInfo | null> {

View File

@ -129,6 +129,7 @@ const mockSessionManager: SessionManager = {
cleanup: vi.fn(async () => ({ killed: [], skipped: [], errors: [] })),
spawnOrchestrator: vi.fn(),
ensureOrchestrator: vi.fn(),
relaunchOrchestrator: vi.fn(),
remap: vi.fn(async () => "ses_mock"),
restore: vi.fn(async (id: string) => {
const session = testSessions.find((s) => s.id === id);
@ -232,7 +233,7 @@ import {
GET as sessionDetailGET,
PATCH as sessionDetailPATCH,
} from "@/app/api/sessions/[id]/route";
import { POST as orchestratorsPOST, GET as orchestratorsGET } from "@/app/api/orchestrators/route";
import { POST as orchestratorsPOST } from "@/app/api/orchestrators/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";
@ -1173,53 +1174,49 @@ describe("API Routes", () => {
expect(data.recovery).toBe("reuse-or-recreate-workspace");
expect(data.error).toContain('AO found an older orchestrator workspace for "my-app"');
});
});
describe("GET /api/orchestrators", () => {
it("returns orchestrators for a project", async () => {
const orchestrator = makeSession({
id: "my-app-orchestrator",
projectId: "my-app",
metadata: { role: "orchestrator" },
it("calls relaunchOrchestrator instead of spawnOrchestrator when clean is true", async () => {
(mockSessionManager.relaunchOrchestrator as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
makeSession({
id: "my-app-orchestrator",
projectId: "my-app",
metadata: { role: "orchestrator" },
}),
);
const req = makeRequest("/api/orchestrators", {
method: "POST",
body: JSON.stringify({ projectId: "my-app", clean: true }),
headers: { "Content-Type": "application/json" },
});
(mockSessionManager.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce([orchestrator]);
const res = await orchestratorsPOST(req);
const res = await orchestratorsGET(
makeRequest("http://localhost:3000/api/orchestrators?project=my-app"),
);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.orchestrators).toHaveLength(1);
expect(data.orchestrators[0].id).toBe("my-app-orchestrator");
expect(data.projectName).toBe("My App");
expect(res.status).toBe(201);
expect(mockSessionManager.relaunchOrchestrator).toHaveBeenCalledWith({
projectId: "my-app",
systemPrompt: expect.stringContaining("# My App Orchestrator"),
});
expect(mockSessionManager.spawnOrchestrator).not.toHaveBeenCalled();
});
it("returns 400 when project parameter is missing", async () => {
const res = await orchestratorsGET(makeRequest("http://localhost:3000/api/orchestrators"));
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toMatch(/Missing project query parameter/);
});
it("uses spawnOrchestrator when clean is false or omitted", async () => {
(mockSessionManager.spawnOrchestrator as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
makeSession({
id: "my-app-orchestrator",
projectId: "my-app",
metadata: { role: "orchestrator" },
}),
);
it("returns 404 for unknown project", async () => {
const res = await orchestratorsGET(
makeRequest("http://localhost:3000/api/orchestrators?project=unknown-app"),
);
expect(res.status).toBe(404);
const data = await res.json();
expect(data.error).toMatch(/Unknown project/);
});
const req = makeRequest("/api/orchestrators", {
method: "POST",
body: JSON.stringify({ projectId: "my-app", clean: false }),
headers: { "Content-Type": "application/json" },
});
await orchestratorsPOST(req);
it("returns 500 when list fails", async () => {
(mockSessionManager.list as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("boom"),
);
const res = await orchestratorsGET(
makeRequest("http://localhost:3000/api/orchestrators?project=my-app"),
);
expect(res.status).toBe(500);
const data = await res.json();
expect(data.error).toBe("boom");
expect(mockSessionManager.spawnOrchestrator).toHaveBeenCalled();
expect(mockSessionManager.relaunchOrchestrator).not.toHaveBeenCalled();
});
});

View File

@ -1,103 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import OrchestratorsRoute from "@/app/orchestrators/page";
import { getServices } from "@/lib/services";
import { getAllProjects } from "@/lib/project-name";
// ── Mocks ─────────────────────────────────────────────────────────────
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
}),
}));
vi.mock("next/link", () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href}>{children}</a>
),
}));
vi.mock("@/lib/services", () => ({
getServices: vi.fn(),
}));
vi.mock("@/lib/project-name", () => ({
getAllProjects: vi.fn(),
}));
global.fetch = vi.fn();
// ── Tests ─────────────────────────────────────────────────────────────
describe("Orchestrators Page (OrchestratorsRoute)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders the page with searchParams and listed orchestrators", async () => {
const mockSessionManager = {
list: vi.fn().mockResolvedValue([
{
id: "app-orchestrator",
projectId: "my-app",
status: "working",
activity: "active",
metadata: { role: "orchestrator" },
createdAt: new Date(),
lastActivityAt: new Date(),
},
]),
};
(getServices as any).mockResolvedValue({
config: {
projects: {
"my-app": { name: "My App", sessionPrefix: "app" },
},
},
sessionManager: mockSessionManager,
});
(getAllProjects as any).mockReturnValue([{ id: "my-app", name: "My App" }]);
const searchParams = Promise.resolve({ project: "my-app" });
const jsx = await OrchestratorsRoute({ searchParams });
render(jsx);
expect(screen.getByText("My App")).toBeInTheDocument();
expect(screen.getByText("app-orchestrator")).toBeInTheDocument();
});
it("shows error when project is missing in searchParams", async () => {
const searchParams = Promise.resolve({});
const jsx = await OrchestratorsRoute({ searchParams });
render(jsx);
expect(screen.getByText("Missing Project")).toBeInTheDocument();
});
it("shows error when project is not found in config", async () => {
(getServices as any).mockResolvedValue({
config: { projects: {} },
sessionManager: { list: vi.fn() },
});
(getAllProjects as any).mockReturnValue([]);
const searchParams = Promise.resolve({ project: "ghost" });
const jsx = await OrchestratorsRoute({ searchParams });
render(jsx);
expect(screen.getByText('Project "ghost" not found')).toBeInTheDocument();
});
it("handles service errors gracefully", async () => {
(getServices as any).mockRejectedValue(new Error("Database down"));
const searchParams = Promise.resolve({ project: "my-app" });
const jsx = await OrchestratorsRoute({ searchParams });
render(jsx);
expect(screen.getByText("Database down")).toBeInTheDocument();
});
});

View File

@ -2,6 +2,22 @@ import { cleanup } from "@testing-library/react";
import * as matchers from "@testing-library/jest-dom/matchers";
import { afterEach, expect } from "vitest";
// Node.js 25 exposes a native localStorage stub via --localstorage-file that
// lacks .clear()/.key()/.length. Replace it with a complete in-memory mock so
// tests that call window.localStorage.clear() work on any Node version.
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (k: string) => store[k] ?? null,
setItem: (k: string, v: string) => { store[k] = String(v); },
removeItem: (k: string) => { Reflect.deleteProperty(store, k); },
clear: () => { store = {}; },
get length() { return Object.keys(store).length; },
key: (i: number) => Object.keys(store)[i] ?? null,
};
})();
Object.defineProperty(window, "localStorage", { value: localStorageMock, writable: true, configurable: true });
expect.extend(matchers);
afterEach(() => {
cleanup();

View File

@ -1,8 +1,7 @@
import { type NextRequest, NextResponse } from "next/server";
import { generateOrchestratorPrompt, generateSessionPrefix } from "@aoagents/ao-core";
import { generateOrchestratorPrompt } from "@aoagents/ao-core";
import { getServices } from "@/lib/services";
import { validateIdentifier, validateConfiguredProject } from "@/lib/validation";
import { mapSessionsToOrchestrators } from "@/lib/orchestrator-utils";
function classifySpawnError(projectId: string, error: unknown): {
status: number;
@ -35,46 +34,6 @@ function classifySpawnError(projectId: string, error: unknown): {
};
}
/**
* GET /api/orchestrators?project=<projectId>
* List existing orchestrator sessions for a project.
*/
export async function GET(request: NextRequest) {
const projectId = request.nextUrl.searchParams.get("project");
if (!projectId) {
return NextResponse.json({ error: "Missing project query parameter" }, { status: 400 });
}
const projectErr = validateIdentifier(projectId, "projectId");
if (projectErr) {
return NextResponse.json({ error: projectErr }, { status: 400 });
}
try {
const { config, sessionManager } = await getServices();
const configProjectErr = validateConfiguredProject(config.projects, projectId);
if (configProjectErr) {
return NextResponse.json({ error: configProjectErr }, { status: 404 });
}
const project = config.projects[projectId];
const sessionPrefix = project.sessionPrefix ?? projectId;
const allSessions = await sessionManager.list(projectId);
const allSessionPrefixes = Object.entries(config.projects).map(
([, p]) => p.sessionPrefix ?? generateSessionPrefix(p.name ?? ""),
);
const orchestrators = mapSessionsToOrchestrators(allSessions, sessionPrefix, project.name, allSessionPrefixes);
return NextResponse.json({ orchestrators, projectName: project.name });
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Failed to list orchestrators" },
{ status: 500 },
);
}
}
export async function POST(request: NextRequest) {
const body = (await request.json().catch(() => null)) as Record<string, unknown> | null;
if (!body) {
@ -86,6 +45,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: projectErr }, { status: 400 });
}
const clean = body.clean === true;
try {
const { config, sessionManager } = await getServices();
const projectId = body.projectId as string;
@ -96,7 +57,9 @@ export async function POST(request: NextRequest) {
const project = config.projects[projectId];
const systemPrompt = generateOrchestratorPrompt({ config, projectId, project });
const session = await sessionManager.spawnOrchestrator({ projectId, systemPrompt });
const session = clean
? await sessionManager.relaunchOrchestrator({ projectId, systemPrompt })
: await sessionManager.spawnOrchestrator({ projectId, systemPrompt });
return NextResponse.json(
{

View File

@ -31,7 +31,7 @@ function sanitizeString(value: unknown): string | undefined {
}
function revalidateProjectPaths(projectId: string): void {
for (const route of ["/", "/orchestrators", "/prs", `/projects/${projectId}`]) {
for (const route of ["/", "/prs", `/projects/${projectId}`]) {
try {
revalidatePath(route);
} catch {

View File

@ -33,7 +33,7 @@ function isGitRepository(projectPath: string): boolean {
}
function revalidatePortfolioPaths(projectId: string): void {
for (const route of ["/", "/orchestrators", "/prs", `/projects/${projectId}`]) {
for (const route of ["/", "/prs", `/projects/${projectId}`]) {
try {
revalidatePath(route);
} catch {

View File

@ -16,7 +16,7 @@ export async function GET(request: Request) {
? projectFilter
: undefined;
const coreSessions = await sessionManager.list(requestedProjectId);
const coreSessions = await sessionManager.listCached(requestedProjectId);
const visibleSessions = filterWorkerSessions(coreSessions, projectFilter, config.projects);
// Convert to dashboard format

View File

@ -1230,6 +1230,11 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
color 100ms ease,
transform 120ms cubic-bezier(0.23, 1, 0.32, 1);
}
.dashboard-app-btn--icon {
width: 27px;
padding: 0;
justify-content: center;
}
.dashboard-app-btn:hover {
background: var(--color-bg-subtle);
@ -1328,6 +1333,12 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
.topbar-status-pill__label {
color: inherit;
}
.topbar-status-pill__dot--working {
background: var(--color-status-working);
}
.topbar-status-pill__dot--attention {
background: var(--color-status-attention);
}
/* Branch pill — compact topbar variant */
.topbar-branch-pill {
@ -4265,7 +4276,8 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px 8px;
height: 48px;
padding: 0 12px;
border-bottom: 1px solid var(--sidebar-border);
flex-shrink: 0;
}
@ -4902,6 +4914,8 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
.project-sidebar__sess-row--active {
background: transparent;
border-left: 2px solid var(--color-accent-amber);
padding-left: 24px;
}
/* Session label */
@ -5046,6 +5060,13 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
font-size: 11px;
}
.project-sidebar__empty-hint {
display: block;
font-size: 10px;
margin-top: 3px;
color: var(--color-text-muted);
}
.project-sidebar__footer {
margin-top: auto;
}

View File

@ -1,83 +0,0 @@
import type { Metadata } from "next";
import { OrchestratorSelector, type Orchestrator } from "@/components/OrchestratorSelector";
import { getServices } from "@/lib/services";
import { getAllProjects } from "@/lib/project-name";
import { generateSessionPrefix } from "@aoagents/ao-core";
import { mapSessionsToOrchestrators } from "@/lib/orchestrator-utils";
export const dynamic = "force-dynamic";
export async function generateMetadata(props: {
searchParams: Promise<{ project?: string }>;
}): Promise<Metadata> {
const searchParams = await props.searchParams;
const projectId = searchParams.project;
let projectName = "Orchestrator";
if (projectId) {
const projects = getAllProjects();
const project = projects.find((p) => p.id === projectId);
if (project) {
projectName = project.name;
}
}
return { title: { absolute: `ao | ${projectName} - Orchestrator` } };
}
export default async function OrchestratorsRoute(props: {
searchParams: Promise<{ project?: string }>;
}) {
const searchParams = await props.searchParams;
const projectId = searchParams.project;
if (!projectId) {
return (
<div className="flex h-screen items-center justify-center bg-[var(--color-bg-base)]">
<div className="text-center">
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]">
Missing Project
</h1>
<p className="mt-2 text-[var(--color-text-secondary)]">
No project specified. Please provide a project parameter.
</p>
</div>
</div>
);
}
let orchestrators: Orchestrator[] = [];
let projectName = projectId;
let error: string | null = null;
try {
const { config, sessionManager } = await getServices();
const project = config.projects[projectId];
if (!project) {
error = `Project "${projectId}" not found`;
} else {
projectName = project.name;
const sessionPrefix = project.sessionPrefix ?? projectId;
const allSessions = await sessionManager.list(projectId);
const allSessionPrefixes = Object.entries(config.projects).map(
([, p]) => p.sessionPrefix ?? generateSessionPrefix(p.name ?? ""),
);
orchestrators = mapSessionsToOrchestrators(
allSessions,
sessionPrefix,
project.name,
allSessionPrefixes,
);
}
} catch (err) {
error = err instanceof Error ? err.message : "Failed to load orchestrators";
}
return (
<OrchestratorSelector
orchestrators={orchestrators}
projectId={projectId}
projectName={projectName}
error={error}
/>
);
}

View File

@ -0,0 +1,122 @@
import { render, screen, act } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
let mockPathname = "/projects/proj-1";
let mockParams: Record<string, string> = { projectId: "proj-1" };
vi.mock("next/navigation", () => ({
useParams: () => mockParams,
usePathname: () => mockPathname,
}));
vi.mock("next-themes", () => ({
useTheme: () => ({ resolvedTheme: "light", setTheme: vi.fn() }),
}));
vi.mock("@/providers/MuxProvider", () => ({
useMuxOptional: () => ({ status: "connecting", sessions: [], lastError: null }),
}));
vi.mock("@/hooks/useSessionEvents", () => ({
useSessionEvents: ({ initialSessions }: { initialSessions: unknown[] }) => ({
sessions: initialSessions,
liveSessionsResolved: true,
attentionLevels: {},
loadError: null,
}),
}));
vi.mock("@/components/ProjectSidebar", () => ({
ProjectSidebar: (props: { activeProjectId?: string; orchestrators?: unknown[] }) => (
<div data-testid="sidebar" data-project={props.activeProjectId} data-orchestrators={JSON.stringify(props.orchestrators ?? [])} />
),
}));
import { ProjectLayoutClient } from "../project-layout-client";
const projects = [{ id: "proj-1", name: "Project One", sessionPrefix: "proj-1" }];
const orchestrators = [{ id: "proj-1-orchestrator", projectId: "proj-1" }];
beforeEach(() => {
mockPathname = "/projects/proj-1";
mockParams = { projectId: "proj-1" };
});
describe("ProjectLayoutClient", () => {
it("renders children and sidebar", () => {
render(
<ProjectLayoutClient
initialSessions={[]}
initialProjects={projects}
initialOrchestrators={[]}
>
<div data-testid="page-content">Page</div>
</ProjectLayoutClient>,
);
expect(screen.getByTestId("sidebar")).toBeInTheDocument();
expect(screen.getByTestId("page-content")).toBeInTheDocument();
});
it("passes activeProjectId from route params to sidebar", () => {
mockParams = { projectId: "proj-1" };
render(
<ProjectLayoutClient
initialSessions={[]}
initialProjects={projects}
initialOrchestrators={[]}
>
<div />
</ProjectLayoutClient>,
);
expect(screen.getByTestId("sidebar").dataset.project).toBe("proj-1");
});
it("passes initialOrchestrators directly to sidebar", () => {
render(
<ProjectLayoutClient
initialSessions={[]}
initialProjects={projects}
initialOrchestrators={orchestrators}
>
<div />
</ProjectLayoutClient>,
);
const sidebar = screen.getByTestId("sidebar");
expect(JSON.parse(sidebar.dataset.orchestrators ?? "[]")).toEqual(orchestrators);
});
it("resets mobile sidebar when pathname changes", async () => {
const { rerender } = render(
<ProjectLayoutClient
initialSessions={[]}
initialProjects={projects}
initialOrchestrators={[]}
>
<div />
</ProjectLayoutClient>,
);
// Simulate pathname change
mockPathname = "/projects/proj-1/sessions/sess-1";
await act(async () => {
rerender(
<ProjectLayoutClient
initialSessions={[]}
initialProjects={projects}
initialOrchestrators={[]}
>
<div />
</ProjectLayoutClient>,
);
});
// Sidebar wrapper should not have the mobile-open class
const wrapper = document.querySelector(".sidebar-wrapper");
expect(wrapper?.classList.contains("sidebar-wrapper--mobile-open")).toBe(false);
});
});

View File

@ -0,0 +1,5 @@
import type { ReactNode } from "react";
export default function ProjectIdLayout({ children }: { children: ReactNode }) {
return <>{children}</>;
}

View File

@ -8,7 +8,8 @@ describe("ProjectRouteLoading", () => {
expect(screen.getByText("Agent Orchestrator")).toBeInTheDocument();
expect(screen.getByText("Loading project…")).toBeInTheDocument();
expect(screen.getByText("Projects")).toBeInTheDocument();
expect(screen.getByLabelText("Loading project dashboard")).toBeInTheDocument();
// Sidebar is owned by ProjectLayoutClient — no duplicate skeleton sidebar here
expect(screen.queryByText("Projects")).not.toBeInTheDocument();
expect(screen.getByText("Working")).toBeInTheDocument();
});
});

View File

@ -1,101 +1,40 @@
function ProjectLoadingSidebar() {
return (
<aside className="project-sidebar flex h-full flex-col" aria-hidden="true">
<div className="project-sidebar__compact-hdr">
<span className="project-sidebar__sect-label">Projects</span>
</div>
<div className="project-sidebar__tree flex-1 overflow-y-auto overflow-x-hidden">
<div className="py-2">
{["w-28", "w-24", "w-32", "w-20"].map((nameWidth, index) => (
<div
key={`project-loading-row-${index}`}
className="flex items-center gap-3 border-b border-[var(--color-border-subtle)] px-4 py-3"
>
<div className="h-3 w-3 animate-pulse bg-[color-mix(in_srgb,var(--color-text-primary)_8%,transparent)]" />
<div
className={`h-4 animate-pulse bg-[color-mix(in_srgb,var(--color-text-primary)_10%,transparent)] ${nameWidth}`}
/>
<div className="ml-auto h-6 w-6 animate-pulse border border-[var(--color-border-default)] bg-[var(--color-bg-base)]" />
</div>
))}
</div>
</div>
<div className="project-sidebar__footer">
<div className="flex items-center gap-2 border-t border-[var(--color-border-subtle)] px-2 py-2">
<div className="h-7 w-7 animate-pulse border border-[var(--color-border-default)] bg-[var(--color-bg-surface)]" />
<div className="h-7 w-7 animate-pulse border border-[var(--color-border-default)] bg-[var(--color-bg-surface)]" />
<div className="h-7 w-7 animate-pulse border border-[var(--color-border-default)] bg-[var(--color-bg-surface)]" />
<div className="ml-auto h-7 w-7 animate-pulse border border-[var(--color-border-default)] bg-[var(--color-bg-surface)]" />
</div>
</div>
</aside>
);
}
export default function ProjectRouteLoading() {
return (
<div className="min-h-screen bg-[var(--color-bg-canvas)]">
<div className="dashboard-app-shell">
<header className="dashboard-app-header" aria-hidden="true">
<button type="button" className="dashboard-app-sidebar-toggle" aria-label="Toggle sidebar">
<svg
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
viewBox="0 0 24 24"
aria-hidden="true"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" />
</svg>
</button>
<div className="dashboard-app-header__brand">
<span className="dashboard-app-header__brand-dot" aria-hidden="true" />
<span>Agent Orchestrator</span>
</div>
<span className="dashboard-app-header__sep" aria-hidden="true" />
<span className="dashboard-app-header__project">Loading project</span>
<div className="dashboard-app-header__spacer" />
<div className="dashboard-app-header__actions">
<div className="h-9 w-36 animate-pulse border border-[var(--color-border-default)] bg-[var(--color-bg-surface)]" />
</div>
</header>
<div className="sidebar-wrapper">
<ProjectLoadingSidebar />
<div className="dashboard-main--desktop">
<header className="dashboard-app-header" aria-hidden="true">
<button type="button" className="dashboard-app-sidebar-toggle" aria-label="Toggle sidebar">
<svg
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
viewBox="0 0 24 24"
aria-hidden="true"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" />
</svg>
</button>
<div className="dashboard-app-header__brand dashboard-app-header__brand--hide-mobile">
<span>Agent Orchestrator</span>
</div>
<span className="dashboard-app-header__sep topbar-desktop-only" aria-hidden="true" />
<span className="dashboard-app-header__project">Loading project</span>
<div className="dashboard-app-header__spacer" />
</header>
<main className="dashboard-main dashboard-main--desktop overflow-y-auto">
<div className="dashboard-main__subhead">
<div className="h-8 w-40 animate-pulse bg-[color-mix(in_srgb,var(--color-bg-elevated)_88%,transparent)]" />
<div className="mt-3 h-4 w-72 max-w-full animate-pulse bg-[color-mix(in_srgb,var(--color-bg-elevated)_82%,transparent)]" />
</div>
<div className="board-wrapper" aria-hidden="true">
<div className="kanban-ghost">
{["Working", "Pending", "Review", "Respond", "Merge"].map((label) => (
<div key={label} className="kanban-ghost__col">
<div className="kanban-ghost__head">{label}</div>
</div>
))}
</div>
<div className="board-center">
<div
className="empty-state"
role="status"
aria-label="Loading project dashboard"
>
<div className="empty-state__icon" />
<div className="h-5 w-40 animate-pulse bg-[color-mix(in_srgb,var(--color-bg-elevated)_88%,transparent)]" />
<div className="h-4 w-56 animate-pulse bg-[color-mix(in_srgb,var(--color-bg-elevated)_82%,transparent)]" />
<main className="dashboard-main flex-1 min-h-0 overflow-hidden">
<div className="board-wrapper" aria-hidden="true">
<div className="kanban-ghost">
{["Working", "Pending", "Review", "Respond", "Merge"].map((label) => (
<div key={label} className="kanban-ghost__col">
<div className="kanban-ghost__head">{label}</div>
</div>
</div>
))}
</div>
</main>
</div>
</div>
</main>
</div>
);
}

View File

@ -29,7 +29,7 @@ export default async function ProjectPage(props: {
const pageData = await getDashboardPageData(projectId);
return (
<div className="min-h-screen bg-[var(--color-bg-canvas)]">
<div className="flex-1 min-h-screen bg-[var(--color-bg-canvas)]">
<Dashboard
initialSessions={pageData.sessions}
projectId={pageData.selectedProjectId}

View File

@ -0,0 +1,91 @@
"use client";
import { useState, useCallback, useEffect, type ReactNode } from "react";
import { useParams, usePathname } from "next/navigation";
import { useSessionEvents } from "@/hooks/useSessionEvents";
import { useMuxOptional } from "@/providers/MuxProvider";
import { ProjectSidebar, type ProjectSidebarOrchestrator } from "@/components/ProjectSidebar";
import { SidebarContext } from "@/components/workspace/SidebarContext";
import { useMediaQuery, MOBILE_BREAKPOINT } from "@/hooks/useMediaQuery";
import type { DashboardSession } from "@/lib/types";
import type { ProjectInfo } from "@/lib/project-name";
function extractActiveSessionId(pathname: string | null): string | undefined {
if (!pathname) return undefined;
const match = pathname.match(/\/sessions\/([^/]+)/);
return match?.[1] ?? undefined;
}
interface ProjectLayoutClientProps {
children: ReactNode;
initialSessions: DashboardSession[];
initialProjects: ProjectInfo[];
initialOrchestrators: ProjectSidebarOrchestrator[];
}
export function ProjectLayoutClient({
children,
initialSessions,
initialProjects,
initialOrchestrators,
}: ProjectLayoutClientProps) {
const params = useParams();
const pathname = usePathname();
const projectId = typeof params.projectId === "string" ? params.projectId : undefined;
const activeSessionId = extractActiveSessionId(pathname);
const isMobile = useMediaQuery(MOBILE_BREAKPOINT);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
// Close mobile overlay whenever the route changes within the layout.
useEffect(() => {
setMobileSidebarOpen(false);
}, [pathname]);
const mux = useMuxOptional();
const { sessions, liveSessionsResolved } = useSessionEvents({
initialSessions,
muxSessions: mux?.status === "connected" ? mux.sessions : undefined,
muxLastError: mux?.lastError,
attentionZones: "simple",
});
const handleToggleSidebar = useCallback(() => {
if (isMobile) {
setMobileSidebarOpen((v) => !v);
} else {
setSidebarCollapsed((v) => !v);
}
}, [isMobile]);
return (
<SidebarContext.Provider value={{ onToggleSidebar: handleToggleSidebar, mobileSidebarOpen }}>
<div className="dashboard-app-shell">
<div
className={`dashboard-shell--desktop${sidebarCollapsed ? " dashboard-shell--sidebar-collapsed" : ""}`}
>
<div
className={`sidebar-wrapper${mobileSidebarOpen ? " sidebar-wrapper--mobile-open" : ""}`}
>
<ProjectSidebar
projects={initialProjects}
sessions={sessions}
orchestrators={initialOrchestrators}
activeProjectId={projectId}
activeSessionId={activeSessionId}
loading={!liveSessionsResolved}
collapsed={sidebarCollapsed}
onToggleCollapsed={() => setSidebarCollapsed((v) => !v)}
onMobileClose={() => setMobileSidebarOpen(false)}
/>
</div>
{mobileSidebarOpen && (
<div className="sidebar-mobile-backdrop" onClick={() => setMobileSidebarOpen(false)} />
)}
{children}
</div>
</div>
</SidebarContext.Provider>
);
}

View File

@ -1 +1,459 @@
export { default } from "../../../../sessions/[id]/page";
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { isOrchestratorSession } from "@aoagents/ao-core/types";
import { SessionDetail } from "@/components/SessionDetail";
import { ErrorDisplay } from "@/components/ErrorDisplay";
import {
type DashboardSession,
type ActivityState,
getAttentionLevel,
} from "@/lib/types";
import { activityIcon } from "@/lib/activity-icons";
import type { ProjectInfo } from "@/lib/project-name";
import { getSessionTitle } from "@/lib/format";
import { useMuxSessionActivity } from "@/hooks/useMuxSessionActivity";
import { projectSessionPath } from "@/lib/routes";
import { fetchJsonWithTimeout } from "@/lib/client-fetch";
function truncate(s: string, max: number): string {
const codePoints = Array.from(s);
return codePoints.length > max ? codePoints.slice(0, max).join("") + "..." : s;
}
function buildSessionTitle(
session: DashboardSession,
prefixByProject: Map<string, string>,
activityOverride?: ActivityState | null,
): string {
const id = session.id;
const activity = activityOverride !== undefined ? activityOverride : session.activity;
const emoji = activity ? (activityIcon[activity] ?? "") : "";
const allPrefixes = [...prefixByProject.values()];
const isOrchestrator = isOrchestratorSession(
session,
prefixByProject.get(session.projectId),
allPrefixes,
);
const detail = isOrchestrator ? "Orchestrator Terminal" : truncate(getSessionTitle(session), 40);
return emoji ? `${emoji} ${id} | ${detail}` : `${id} | ${detail}`;
}
interface ZoneCounts {
merge: number;
respond: number;
review: number;
pending: number;
working: number;
done: number;
}
interface ProjectSessionsBody {
sessions?: DashboardSession[];
orchestratorId?: string | null;
orchestrators?: Array<{ id: string; projectId: string; projectName: string }>;
}
let cachedProjects: ProjectInfo[] | null = null;
const SESSION_PAGE_REFRESH_INTERVAL_MS = 2000;
const SESSION_FETCH_TIMEOUT_MS = 8000;
const PROJECT_SESSIONS_FETCH_TIMEOUT_MS = 5000;
const PROJECTS_FETCH_TIMEOUT_MS = 5000;
function areProjectsEqual(previous: ProjectInfo[] | null, next: ProjectInfo[]): boolean {
if (!previous || previous.length !== next.length) return false;
return previous.every((p, i) => JSON.stringify(p) === JSON.stringify(next[i]));
}
function isAbortLikeError(error: unknown): boolean {
if (error instanceof DOMException) return error.name === "AbortError";
if (error instanceof Error) {
const msg = error.message.toLowerCase();
return msg.includes("aborted") || msg.includes("aborterror");
}
return false;
}
function getSessionLoadErrorMessage(error: Error): string {
const normalized = error.message.toLowerCase();
if (normalized.includes("timed out"))
return "The session request is taking too long. You can retry, or return to the project and reopen a different session.";
if (normalized.includes("network"))
return "The session request failed before the dashboard got a response. Check the local server connection and try again.";
if (normalized.includes("404"))
return "This session is no longer available. It may have been removed while the page was open.";
if (normalized.includes("500"))
return "The server returned an internal error while loading this session. Try again to re-fetch the latest state.";
return "The dashboard could not load this session cleanly. Try again to re-fetch the latest state.";
}
function LoadingContent() {
return (
<div className="flex h-full min-h-screen items-center justify-center bg-[var(--color-bg-base)]">
<div className="flex flex-col items-center gap-3">
<svg
className="h-5 w-5 animate-spin text-[var(--color-text-tertiary)]"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M12 3a9 9 0 1 0 9 9" />
</svg>
<div className="text-[13px] text-[var(--color-text-tertiary)]">Loading session</div>
</div>
</div>
);
}
export default function ProjectSessionPage() {
const params = useParams();
const pathname = usePathname();
const router = useRouter();
const id = params.id as string;
const expectedProjectId = typeof params.projectId === "string" ? params.projectId : undefined;
// Read optimistic session data written by sidebar navigation
const cachedSession = (() => {
if (typeof sessionStorage === "undefined") return null;
try {
const raw = sessionStorage.getItem(`ao-session-nav:${id}`);
if (raw) {
sessionStorage.removeItem(`ao-session-nav:${id}`);
return JSON.parse(raw) as DashboardSession;
}
} catch {
/* ignore */
}
return null;
})();
const [session, setSession] = useState<DashboardSession | null>(cachedSession);
const [zoneCounts, setZoneCounts] = useState<ZoneCounts | null>(null);
const [projectOrchestratorId, setProjectOrchestratorId] = useState<string | null | undefined>(
undefined,
);
const [projects, setProjects] = useState<ProjectInfo[]>([]);
const [loading, setLoading] = useState(cachedSession === null);
const [routeError, setRouteError] = useState<Error | null>(null);
const [sessionMissing, setSessionMissing] = useState(false);
const [prefixByProject, setPrefixByProject] = useState<Map<string, string>>(new Map());
const sessionProjectId = session?.projectId ?? null;
const allPrefixes = [...prefixByProject.values()];
const sessionIsOrchestrator = session
? isOrchestratorSession(session, prefixByProject.get(session.projectId), allPrefixes)
: false;
const sessionProjectIdRef = useRef<string | null>(null);
const sessionIsOrchestratorRef = useRef(false);
const resolvedProjectSessionsKeyRef = useRef<string | null>(null);
const prefixByProjectRef = useRef<Map<string, string>>(new Map());
const hasLoadedSessionRef = useRef(cachedSession !== null);
const fetchingSessionRef = useRef(false);
const fetchingProjectSessionsRef = useRef(false);
const sessionFetchControllerRef = useRef<AbortController | null>(null);
const projectSessionsFetchControllerRef = useRef<AbortController | null>(null);
const pageUnloadingRef = useRef(false);
const mountedRef = useRef(true);
const sseActivity = useMuxSessionActivity(id);
useEffect(() => {
prefixByProjectRef.current = prefixByProject;
}, [prefixByProject]);
useEffect(() => {
if (session) {
document.title = buildSessionTitle(session, prefixByProject, sseActivity?.activity);
} else {
document.title = `${id} | Session Detail`;
}
}, [session, id, prefixByProject, sseActivity]);
useEffect(() => {
sessionProjectIdRef.current = sessionProjectId;
}, [sessionProjectId]);
useEffect(() => {
sessionIsOrchestratorRef.current = sessionIsOrchestrator;
}, [sessionIsOrchestrator]);
useEffect(() => {
if (!session || !projects.some((p) => p.id === session.projectId)) return;
if (
pathname?.startsWith("/projects/") &&
expectedProjectId &&
session.projectId !== expectedProjectId
) {
router.replace(projectSessionPath(session.projectId, session.id));
}
}, [expectedProjectId, pathname, projects, router, session]);
useEffect(() => {
if (!sessionIsOrchestrator) setZoneCounts(null);
}, [sessionIsOrchestrator]);
const fetchProjects = useCallback(async () => {
if (cachedProjects) {
setProjects(cachedProjects);
setPrefixByProject(new Map(cachedProjects.map((p) => [p.id, p.sessionPrefix ?? p.id])));
}
try {
const data = await fetchJsonWithTimeout<{ projects?: ProjectInfo[] } | null>(
"/api/projects",
{
timeoutMs: PROJECTS_FETCH_TIMEOUT_MS,
timeoutMessage: `Projects request timed out after ${PROJECTS_FETCH_TIMEOUT_MS}ms`,
},
);
if (!data?.projects) return;
if (!areProjectsEqual(cachedProjects, data.projects)) {
cachedProjects = data.projects;
setProjects(data.projects);
setPrefixByProject(new Map(data.projects.map((p) => [p.id, p.sessionPrefix ?? p.id])));
}
} catch (err) {
console.error("Failed to fetch projects:", err);
}
}, []);
const fetchSession = useCallback(async () => {
if (fetchingSessionRef.current) return;
fetchingSessionRef.current = true;
const controller = new AbortController();
sessionFetchControllerRef.current = controller;
try {
const data = await fetchJsonWithTimeout<DashboardSession | { error: string }>(
`/api/sessions/${encodeURIComponent(id)}`,
{
signal: controller.signal,
timeoutMs: SESSION_FETCH_TIMEOUT_MS,
timeoutMessage: `Session request timed out after ${SESSION_FETCH_TIMEOUT_MS}ms`,
},
);
setSession(data as DashboardSession);
setRouteError(null);
setSessionMissing(false);
hasLoadedSessionRef.current = true;
} catch (err) {
if (pageUnloadingRef.current || controller.signal.aborted || isAbortLikeError(err)) return;
const message = err instanceof Error ? err.message : "Failed to load session";
const normalized = message.toLowerCase();
if (normalized.includes("session not found") || normalized.includes("http 404")) {
if (!hasLoadedSessionRef.current) setSessionMissing(true);
setLoading(false);
return;
}
console.error("Failed to fetch session:", err);
if (!hasLoadedSessionRef.current) {
setRouteError(err instanceof Error ? err : new Error("Failed to load session"));
}
} finally {
fetchingSessionRef.current = false;
if (sessionFetchControllerRef.current === controller)
sessionFetchControllerRef.current = null;
if (!controller.signal.aborted || hasLoadedSessionRef.current) {
setLoading(false);
} else if (mountedRef.current) {
// Aborted before any session was loaded and the component is still
// mounted — React Strict Mode fired the cleanup between mount 1 and
// mount 2, aborting the first fetch. Mount 2's fetchSession() was
// blocked by fetchingSessionRef (not yet reset). Retry immediately
// now that the ref is clear. mountedRef guards against the navigation-
// away case where the component is genuinely unmounted and we should
// not start a new request.
void fetchSession();
}
}
}, [id]);
const fetchProjectSessions = useCallback(async () => {
if (fetchingProjectSessionsRef.current) return;
const projectId = sessionProjectIdRef.current;
if (!projectId) return;
const isOrchestrator = sessionIsOrchestratorRef.current;
const projectSessionsKey = `${projectId}:${isOrchestrator ? "orchestrator" : "worker"}`;
if (!isOrchestrator && resolvedProjectSessionsKeyRef.current === projectSessionsKey) return;
fetchingProjectSessionsRef.current = true;
const controller = new AbortController();
projectSessionsFetchControllerRef.current = controller;
try {
const query = isOrchestrator
? `/api/sessions?project=${encodeURIComponent(projectId)}&fresh=true`
: `/api/sessions?project=${encodeURIComponent(projectId)}&orchestratorOnly=true&fresh=true`;
const body = await fetchJsonWithTimeout<ProjectSessionsBody>(query, {
signal: controller.signal,
timeoutMs: PROJECT_SESSIONS_FETCH_TIMEOUT_MS,
timeoutMessage: `Project sessions request timed out after ${PROJECT_SESSIONS_FETCH_TIMEOUT_MS}ms`,
});
const sessions = body.sessions ?? [];
const orchestratorId =
body.orchestratorId ??
body.orchestrators?.find((o) => o.projectId === projectId)?.id ??
null;
setProjectOrchestratorId((current) =>
current === orchestratorId ? current : orchestratorId,
);
if (!isOrchestrator) {
resolvedProjectSessionsKeyRef.current = projectSessionsKey;
return;
}
const counts: ZoneCounts = {
merge: 0,
respond: 0,
review: 0,
pending: 0,
working: 0,
done: 0,
};
const allPfxs = [...prefixByProjectRef.current.values()];
for (const s of sessions) {
if (!isOrchestratorSession(s, prefixByProjectRef.current.get(s.projectId), allPfxs)) {
const level = getAttentionLevel(s);
if (level === "action") continue;
counts[level]++;
}
}
setZoneCounts(counts);
} catch (err) {
if (pageUnloadingRef.current || controller.signal.aborted || isAbortLikeError(err)) return;
console.error("Failed to fetch project sessions:", err);
} finally {
fetchingProjectSessionsRef.current = false;
if (projectSessionsFetchControllerRef.current === controller)
projectSessionsFetchControllerRef.current = null;
}
}, []);
useEffect(() => {
void Promise.all([fetchProjects(), fetchSession()]);
}, [fetchProjects, fetchSession]);
useEffect(() => {
if (!sessionProjectId) return;
void fetchProjectSessions();
}, [fetchProjectSessions, sessionIsOrchestrator, sessionProjectId]);
useEffect(() => {
const interval = setInterval(() => {
void fetchSession();
void fetchProjectSessions();
}, SESSION_PAGE_REFRESH_INTERVAL_MS);
return () => clearInterval(interval);
}, [fetchSession, fetchProjectSessions]);
useEffect(() => {
pageUnloadingRef.current = false;
const mark = () => {
pageUnloadingRef.current = true;
};
window.addEventListener("pagehide", mark);
window.addEventListener("beforeunload", mark);
return () => {
window.removeEventListener("pagehide", mark);
window.removeEventListener("beforeunload", mark);
};
}, []);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
useEffect(() => {
return () => {
sessionFetchControllerRef.current?.abort();
projectSessionsFetchControllerRef.current?.abort();
};
}, []);
if (loading) return <div className="dashboard-main--desktop"><LoadingContent /></div>;
if (sessionMissing) {
return (
<div className="dashboard-main--desktop">
<div className="flex h-full items-center justify-center">
<ErrorDisplay
title="Session not found"
message="This session is no longer available. It may have been removed, renamed, or already cleaned up."
tone="not-found"
primaryAction={{
label: "Back to dashboard",
href: expectedProjectId ? `/projects/${expectedProjectId}` : "/",
}}
secondaryAction={{ label: "Retry", onClick: () => void fetchSession() }}
compact
chrome="card"
/>
</div>
</div>
);
}
if (routeError) {
return (
<div className="dashboard-main--desktop">
<div className="flex h-full items-center justify-center">
<ErrorDisplay
title="Failed to load session"
message={getSessionLoadErrorMessage(routeError)}
tone="error"
primaryAction={{
label: "Try again",
onClick: () => {
setRouteError(null);
setSessionMissing(false);
setLoading(true);
void Promise.all([fetchProjects(), fetchSession()]);
},
}}
secondaryAction={{
label: "Back to dashboard",
href: session?.projectId ? `/projects/${session.projectId}` : "/",
}}
error={routeError}
compact
chrome="card"
/>
</div>
</div>
);
}
if (!session) {
return (
<div className="dashboard-main--desktop">
<div className="flex h-full items-center justify-center">
<ErrorDisplay
title="Session unavailable"
message="The backend has not returned this session yet. This can happen right after spawning an orchestrator; retry once the terminal registers the session."
tone="error"
primaryAction={{ label: "Retry", onClick: () => void fetchSession() }}
secondaryAction={{
label: "Back to dashboard",
href: expectedProjectId ? `/projects/${expectedProjectId}` : "/",
}}
compact
chrome="card"
/>
</div>
</div>
);
}
return (
<SessionDetail
session={session}
isOrchestrator={sessionIsOrchestrator}
orchestratorZones={zoneCounts ?? undefined}
projectOrchestratorId={projectOrchestratorId}
projects={projects}
/>
);
}

View File

@ -0,0 +1,19 @@
import { getDashboardPageData } from "@/lib/dashboard-page-data";
import { ProjectLayoutClient } from "./[projectId]/project-layout-client";
import type { ReactNode } from "react";
export const dynamic = "force-dynamic";
export default async function ProjectLayout({ children }: { children: ReactNode }) {
const pageData = await getDashboardPageData("all");
return (
<ProjectLayoutClient
initialSessions={pageData.sessions}
initialProjects={pageData.projects}
initialOrchestrators={pageData.orchestrators}
>
{children}
</ProjectLayoutClient>
);
}

View File

@ -454,77 +454,6 @@ describe("SessionPage project polling", () => {
expect(screen.getAllByText("Failed to load session").length).toBeGreaterThan(0);
});
it("marks sidebar data as loading until the sessions list resolves", async () => {
const workerSession = makeWorkerSession();
let resolveSidebarSessions: ((value: Response) => void) | null = null;
global.fetch = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/projects") {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
projects: [{ id: "my-app", name: "My App", sessionPrefix: "my-app" }],
}),
} as Response);
}
if (url === "/api/sessions/worker-1") {
return Promise.resolve({
ok: true,
status: 200,
json: async () => workerSession,
} as Response);
}
if (url === "/api/sessions?fresh=true") {
return new Promise<Response>((resolve) => {
resolveSidebarSessions = resolve;
});
}
if (url === "/api/sessions?project=my-app&orchestratorOnly=true&fresh=true") {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ orchestratorId: "my-app-orchestrator" }),
} as Response);
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
}) as typeof fetch;
const { default: SessionPage } = await import("./page");
render(<SessionPage />);
await flushAsyncWork();
const latestBeforeSidebarResolve = sessionDetailSpy.mock.lastCall?.[0] as {
sidebarLoading?: boolean;
sidebarSessions?: DashboardSession[] | null;
};
expect(latestBeforeSidebarResolve.sidebarLoading).toBe(true);
expect(latestBeforeSidebarResolve.sidebarSessions).toBeNull();
await act(async () => {
resolveSidebarSessions?.({
ok: true,
status: 200,
json: async () => ({ sessions: [workerSession] }),
} as Response);
await Promise.resolve();
});
const latestAfterSidebarResolve = sessionDetailSpy.mock.lastCall?.[0] as {
sidebarLoading?: boolean;
sidebarSessions?: DashboardSession[] | null;
};
expect(latestAfterSidebarResolve.sidebarLoading).toBe(false);
expect(latestAfterSidebarResolve.sidebarSessions).toEqual([workerSession]);
});
it("revalidates projects and sidebar sessions on remount even when cache exists", async () => {
const workerSession = makeWorkerSession();
@ -642,145 +571,7 @@ describe("SessionPage project polling", () => {
);
});
it("surfaces sidebar fetch failures instead of leaving the loading skeleton active", async () => {
const workerSession = makeWorkerSession();
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/projects") {
return {
ok: true,
status: 200,
json: async () => ({
projects: [{ id: "my-app", name: "My App", sessionPrefix: "my-app" }],
}),
} as Response;
}
if (url === "/api/sessions/worker-1") {
return {
ok: true,
status: 200,
json: async () => workerSession,
} as Response;
}
if (url === "/api/sessions?fresh=true") {
return {
ok: false,
status: 500,
json: async () => ({}),
} as Response;
}
if (url === "/api/sessions?project=my-app&orchestratorOnly=true&fresh=true") {
return {
ok: true,
status: 200,
json: async () => ({ orchestratorId: "my-app-orchestrator" }),
} as Response;
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof fetch;
const { default: SessionPage } = await import("./page");
render(<SessionPage />);
await flushAsyncWork();
const latestProps = sessionDetailSpy.mock.lastCall?.[0] as {
sidebarError?: boolean;
sidebarLoading?: boolean;
sidebarSessions?: DashboardSession[] | null;
};
expect(latestProps.sidebarLoading).toBe(false);
expect(latestProps.sidebarError).toBe(true);
expect(latestProps.sidebarSessions).toEqual([]);
});
it("applies mux snapshots that arrive before the initial sidebar fetch resolves", async () => {
const workerSession = makeWorkerSession();
const muxPatchedLastActivityAt = "2026-04-14T12:00:00.000Z";
let resolveSidebarSessions: ((value: Response) => void) | null = null;
mockMuxState.current = {
status: "connected",
sessions: [
{
id: "worker-1",
status: "working",
activity: "ready",
attentionLevel: "pending",
lastActivityAt: muxPatchedLastActivityAt,
},
],
};
global.fetch = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/projects") {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
projects: [{ id: "my-app", name: "My App", sessionPrefix: "my-app" }],
}),
} as Response);
}
if (url === "/api/sessions/worker-1") {
return Promise.resolve({
ok: true,
status: 200,
json: async () => workerSession,
} as Response);
}
if (url === "/api/sessions?fresh=true") {
return new Promise<Response>((resolve) => {
resolveSidebarSessions = resolve;
});
}
if (url === "/api/sessions?project=my-app&orchestratorOnly=true&fresh=true") {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ orchestratorId: "my-app-orchestrator" }),
} as Response);
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
}) as typeof fetch;
const { default: SessionPage } = await import("./page");
render(<SessionPage />);
await flushAsyncWork();
await act(async () => {
resolveSidebarSessions?.({
ok: true,
status: 200,
json: async () => ({ sessions: [workerSession] }),
} as Response);
await Promise.resolve();
});
const latestProps = sessionDetailSpy.mock.lastCall?.[0] as {
sidebarSessions?: DashboardSession[] | null;
};
expect(latestProps.sidebarSessions).toEqual([
{
...workerSession,
activity: "ready",
lastActivityAt: muxPatchedLastActivityAt,
},
]);
});
it("redirects the legacy session URL to the project-scoped route for clean projects", async () => {
mockPathname = "/sessions/worker-1";

View File

@ -5,10 +5,7 @@ import { useParams, usePathname, useRouter } from "next/navigation";
import { ACTIVITY_STATE, SESSION_STATUS, isOrchestratorSession } from "@aoagents/ao-core/types";
import { SessionDetail } from "@/components/SessionDetail";
import { ErrorDisplay } from "@/components/ErrorDisplay";
import {
ProjectSidebar,
type ProjectSidebarOrchestrator,
} from "@/components/ProjectSidebar";
import { ProjectSidebar, type ProjectSidebarOrchestrator } from "@/components/ProjectSidebar";
import { useMediaQuery, MOBILE_BREAKPOINT } from "@/hooks/useMediaQuery";
import {
type DashboardSession,
@ -938,11 +935,6 @@ export default function SessionPage() {
orchestratorZones={zoneCounts ?? undefined}
projectOrchestratorId={projectOrchestratorId}
projects={projects}
sidebarSessions={sidebarSessions}
sidebarOrchestrators={sidebarOrchestrators}
sidebarLoading={sidebarSessions === null}
sidebarError={sidebarError}
onRetrySidebar={fetchSidebarSessions}
/>
);
}

View File

@ -108,9 +108,10 @@ export function CopyDebugBundleButton({ projectId }: CopyDebugBundleButtonProps)
return (
<button
type="button"
className="orchestrator-btn flex min-h-[44px] min-w-[44px] items-center gap-2 px-4 py-2 text-[12px] font-semibold text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-bg-hover)] disabled:opacity-50"
className="dashboard-app-btn dashboard-app-btn--icon"
onClick={() => void handleClick()}
disabled={busy}
title="Copy debug bundle"
aria-label="Copy debug bundle for issue reports"
>
<svg
@ -125,7 +126,6 @@ export function CopyDebugBundleButton({ projectId }: CopyDebugBundleButtonProps)
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
Copy debug info
</button>
);
}

View File

@ -18,7 +18,6 @@ import { AttentionZone } from "./AttentionZone";
import { DynamicFavicon, countNeedingAttention } from "./DynamicFavicon";
import { useSessionEvents } from "@/hooks/useSessionEvents";
import { useMuxOptional } from "@/providers/MuxProvider";
import { ProjectSidebar } from "./ProjectSidebar";
import type { ProjectInfo } from "@/lib/project-name";
import { EmptyState } from "./Skeleton";
import { ToastProvider, useToast } from "./Toast";
@ -26,7 +25,9 @@ import { ConnectionBar } from "./ConnectionBar";
import { UpdateBanner } from "./UpdateBanner";
import { CopyDebugBundleButton } from "./CopyDebugBundleButton";
import { DashboardNotificationButton } from "./DashboardNotificationButton";
import { SidebarContext } from "./workspace/SidebarContext";
import { SidebarContext, useSidebarContext } from "./workspace/SidebarContext";
import { ProjectSidebar } from "./ProjectSidebar";
import { isOrchestratorSession } from "@aoagents/ao-core/types";
import { projectDashboardPath, projectSessionPath } from "@/lib/routes";
import { BottomSheet } from "./BottomSheet";
@ -176,6 +177,25 @@ function DashboardInner({
if (!projectId) return sessions;
return sessions.filter((s) => s.projectId === projectId);
}, [sessions, projectId]);
const allSessionPrefixes = useMemo(
() => projects.map((p) => p.sessionPrefix ?? p.id),
[projects],
);
const sidebarOrchestrators = useMemo(
() =>
sessions
.filter((s) =>
isOrchestratorSession(
s,
projects.find((p) => p.id === s.projectId)?.sessionPrefix ?? s.projectId,
allSessionPrefixes,
),
)
.map((s) => ({ id: s.id, projectId: s.projectId })),
[sessions, projects, allSessionPrefixes],
);
const connectionStatus: "connected" | "reconnecting" | "disconnected" =
mux?.status === "disconnected"
? "disconnected"
@ -196,9 +216,20 @@ function DashboardInner({
useState<DashboardOrchestratorLink[]>(orchestratorLinks);
const [spawningProjectIds, setSpawningProjectIds] = useState<string[]>([]);
const [spawnErrors, setSpawnErrors] = useState<Record<string, string>>({});
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const isMobile = useMediaQuery(MOBILE_BREAKPOINT);
// Detect if a parent layout already owns the sidebar — if so, skip rendering our own.
const parentCtx = useSidebarContext();
const isInsideLayout = parentCtx !== null;
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const handleToggleSidebar = useCallback(() => {
if (isInsideLayout && parentCtx) { parentCtx.onToggleSidebar(); return; }
if (isMobile) {
setMobileSidebarOpen((v) => !v);
} else {
setSidebarCollapsed((v) => !v);
}
}, [isMobile, isInsideLayout, parentCtx]);
const [collapsedZones, setCollapsedZones] = useState<Set<AttentionLevel>>(
() => new Set<AttentionLevel>(["done", "working"]),
);
@ -251,10 +282,6 @@ function DashboardInner({
document.title = needsAttention > 0 ? `${label} (${needsAttention} need attention)` : label;
}, [attentionLevels, projectName]);
useEffect(() => {
setMobileMenuOpen(false);
}, [searchParams]);
const grouped = useMemo(() => {
const zones: Record<AttentionLevel, DashboardSession[]> = {
merge: [],
@ -517,293 +544,337 @@ function DashboardInner({
return next;
});
};
const handleToggleSidebar = () => {
if (typeof window !== "undefined" && window.innerWidth < 768) {
setMobileMenuOpen((current) => !current);
} else {
setSidebarCollapsed((current) => !current);
}
};
return (
<SidebarContext.Provider
value={{ onToggleSidebar: handleToggleSidebar, mobileSidebarOpen: mobileMenuOpen }}
>
<>
<UpdateBanner />
<ConnectionBar status={connectionStatus} />
<div className="dashboard-app-shell">
<header className="dashboard-app-header">
<button
type="button"
className="dashboard-app-sidebar-toggle"
onClick={handleToggleSidebar}
aria-label="Toggle sidebar"
>
{isMobile ? (
const mainPanel = (
<div className="dashboard-main--desktop">
<header className="dashboard-app-header">
<button
type="button"
className="dashboard-app-sidebar-toggle"
onClick={handleToggleSidebar}
aria-label="Toggle sidebar"
>
{isMobile ? (
<svg
width="16"
height="16"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
) : (
<svg
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
viewBox="0 0 24 24"
aria-hidden="true"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" />
</svg>
)}
</button>
<div className="dashboard-app-header__brand dashboard-app-header__brand--hide-mobile">
<span>Agent Orchestrator</span>
</div>
{showHeaderProjectLabel ? (
<>
<span className="dashboard-app-header__sep topbar-desktop-only" aria-hidden="true" />
<div className="topbar-project-pills-group">
<div className="topbar-project-line">
<span className="dashboard-app-header__project">{headerProjectLabel}</span>
</div>
{!allProjectsView && projectSessions.length > 0 ? (
<div className="topbar-session-pills">
{grouped.working.length > 0 ? (
<div className="topbar-status-pill topbar-status-pill--active">
<span className="topbar-status-pill__dot topbar-status-pill__dot--working" />
<span className="topbar-status-pill__label">
{grouped.working.length} working
</span>
</div>
) : null}
{grouped.merge.length +
grouped.action.length +
grouped.respond.length +
grouped.review.length >
0 ? (
<div className="topbar-status-pill topbar-status-pill--waiting-for-input">
<span className="topbar-status-pill__dot topbar-status-pill__dot--attention" />
<span className="topbar-status-pill__label">
{grouped.merge.length +
grouped.action.length +
grouped.respond.length +
grouped.review.length}{" "}
need attention
</span>
</div>
) : null}
</div>
) : null}
</div>
</>
) : null}
<div className="dashboard-app-header__spacer" />
<div className="dashboard-app-header__actions">
{showDebugBundleButton ? <CopyDebugBundleButton projectId={projectId} /> : null}
<DashboardNotificationButton />
{!allProjectsView && orchestratorHref ? (
<Link
href={orchestratorHref}
className="dashboard-app-btn dashboard-app-btn--amber"
aria-label="Orchestrator"
>
<svg
width="16"
height="16"
width="12"
height="12"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle cx="12" cy="5" r="2" fill="currentColor" stroke="none" />
<path d="M12 7v4M12 11H6M12 11h6M6 11v3M12 11v3M18 11v3" />
<circle cx="6" cy="17" r="2" />
<circle cx="12" cy="17" r="2" />
<circle cx="18" cy="17" r="2" />
</svg>
Orchestrator
</Link>
) : canSpawnProjectOrchestrator && activeProject ? (
<button
type="button"
className="dashboard-app-btn dashboard-app-btn--amber"
aria-label="Spawn Orchestrator"
onClick={() => void handleSpawnOrchestrator(activeProject)}
disabled={isSpawningCurrentProject}
>
<svg
width="12"
height="12"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle cx="12" cy="5" r="2" fill="currentColor" stroke="none" />
<path d="M12 7v4M12 11H6M12 11h6M6 11v3M12 11v3M18 11v3" />
<circle cx="6" cy="17" r="2" />
<circle cx="12" cy="17" r="2" />
<circle cx="18" cy="17" r="2" />
</svg>
{isSpawningCurrentProject ? "Spawning..." : "Spawn Orchestrator"}
</button>
) : null}
</div>
</header>
<main className="dashboard-main flex-1 min-h-0 overflow-hidden">
<DynamicFavicon attentionLevels={attentionLevels} projectName={projectName} />
<div className="dashboard-main__subhead">
<h1 className="dashboard-main__title">Dashboard</h1>
<p className="dashboard-main__subtitle">
Live agent sessions, pull requests, and merge status.
</p>
</div>
<div className="dashboard-main__body">
{loadErrorBanner}
{anyRateLimited && !rateLimitDismissed && (
<div className="dashboard-alert mb-4 flex items-center gap-2.5 border border-[color-mix(in_srgb,var(--color-status-attention)_25%,transparent)] bg-[var(--color-tint-yellow)] 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"
aria-hidden="true"
>
<path d="M4 6h16M4 12h16M4 18h16" />
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</svg>
) : (
<svg
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
viewBox="0 0 24 24"
aria-hidden="true"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" />
</svg>
)}
</button>
<div className="dashboard-app-header__brand">
<span className="dashboard-app-header__brand-dot" aria-hidden="true" />
<span>Agent Orchestrator</span>
</div>
{showHeaderProjectLabel ? (
<>
<span className="dashboard-app-header__sep" aria-hidden="true" />
<span className="dashboard-app-header__project">{headerProjectLabel}</span>
</>
) : null}
{showDebugBundleButton ? <CopyDebugBundleButton projectId={projectId} /> : null}
<div className="dashboard-app-header__spacer" />
<div className="dashboard-app-header__actions">
<DashboardNotificationButton />
{!allProjectsView && orchestratorHref ? (
<Link
href={orchestratorHref}
className="dashboard-app-btn dashboard-app-btn--amber"
aria-label="Orchestrator"
>
<svg
width="12"
height="12"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle cx="12" cy="5" r="2" fill="currentColor" stroke="none" />
<path d="M12 7v4M12 11H6M12 11h6M6 11v3M12 11v3M18 11v3" />
<circle cx="6" cy="17" r="2" />
<circle cx="12" cy="17" r="2" />
<circle cx="18" cy="17" r="2" />
</svg>
Orchestrator
</Link>
) : canSpawnProjectOrchestrator && activeProject ? (
<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
type="button"
className="dashboard-app-btn dashboard-app-btn--amber"
aria-label="Spawn Orchestrator"
onClick={() => void handleSpawnOrchestrator(activeProject)}
disabled={isSpawningCurrentProject}
onClick={() => setRateLimitDismissed(true)}
className="ml-1 shrink-0 opacity-60 hover:opacity-100"
aria-label="Dismiss"
>
<svg
width="12"
height="12"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle cx="12" cy="5" r="2" fill="currentColor" stroke="none" />
<path d="M12 7v4M12 11H6M12 11h6M6 11v3M12 11v3M18 11v3" />
<circle cx="6" cy="17" r="2" />
<circle cx="12" cy="17" r="2" />
<circle cx="18" cy="17" r="2" />
<path d="M18 6 6 18M6 6l12 12" />
</svg>
{isSpawningCurrentProject ? "Spawning..." : "Spawn Orchestrator"}
</button>
) : null}
</div>
</header>
<div
className={`dashboard-shell dashboard-shell--desktop${sidebarCollapsed ? " dashboard-shell--sidebar-collapsed" : ""}`}
>
<div
className={`sidebar-wrapper${mobileMenuOpen ? " sidebar-wrapper--mobile-open" : ""}`}
>
<ProjectSidebar
projects={projects}
sessions={sessions}
orchestrators={activeOrchestrators}
activeProjectId={projectId}
activeSessionId={activeSessionId}
collapsed={sidebarCollapsed}
onToggleCollapsed={() => setSidebarCollapsed((current) => !current)}
onMobileClose={() => setMobileMenuOpen(false)}
/>
</div>
{mobileMenuOpen && (
<div className="sidebar-mobile-backdrop" onClick={() => setMobileMenuOpen(false)} />
</div>
)}
<main className="dashboard-main dashboard-main--desktop">
<DynamicFavicon attentionLevels={attentionLevels} projectName={projectName} />
<div className="dashboard-main__subhead">
<h1 className="dashboard-main__title">Dashboard</h1>
<p className="dashboard-main__subtitle">
Live agent sessions, pull requests, and merge status.
</p>
{allProjectsView && (
<ProjectOverviewGrid
overviews={projectOverviews}
onSpawnOrchestrator={handleSpawnOrchestrator}
spawningProjectIds={spawningProjectIds}
spawnErrors={spawnErrors}
attentionZones={attentionZones}
/>
)}
{!allProjectsView && hasAnySessions && (
<div className="kanban-board-wrap">
<div
className="kanban-board"
data-columns={kanbanLevels.length}
style={
{
"--kanban-column-count": kanbanLevels.length,
} as React.CSSProperties
}
>
{kanbanLevels.map((level) => (
<AttentionZone
key={level}
level={level}
sessions={grouped[level]}
onSend={handleSend}
onKill={handleKill}
onMerge={handleMerge}
onRestore={handleRestore}
compactMobile={isMobile}
collapsed={isMobile && collapsedZones.has(level)}
onToggle={isMobile ? handleZoneToggle : undefined}
onPreview={isMobile ? handlePreview : undefined}
/>
))}
</div>
</div>
)}
<div className="dashboard-main__body">
{loadErrorBanner}
{anyRateLimited && !rateLimitDismissed && (
<div className="dashboard-alert mb-4 flex items-center gap-2.5 border border-[color-mix(in_srgb,var(--color-status-attention)_25%,transparent)] bg-[var(--color-tint-yellow)] 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"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
)}
{allProjectsView && (
<ProjectOverviewGrid
overviews={projectOverviews}
onSpawnOrchestrator={handleSpawnOrchestrator}
spawningProjectIds={spawningProjectIds}
spawnErrors={spawnErrors}
attentionZones={attentionZones}
/>
)}
{!allProjectsView && hasAnySessions && (
<div className="kanban-board-wrap">
<div
className="kanban-board"
data-columns={kanbanLevels.length}
style={
{
"--kanban-column-count": kanbanLevels.length,
} as React.CSSProperties
{showEmptyState ? (
<EmptyState
orchestratorHref={orchestratorHref}
onSpawnOrchestrator={
canSpawnProjectOrchestrator && activeProject
? () => {
void handleSpawnOrchestrator(activeProject);
}
>
{kanbanLevels.map((level) => (
<AttentionZone
key={level}
level={level}
sessions={grouped[level]}
onSend={handleSend}
onKill={handleKill}
onMerge={handleMerge}
onRestore={handleRestore}
compactMobile={isMobile}
collapsed={isMobile && collapsedZones.has(level)}
onToggle={isMobile ? handleZoneToggle : undefined}
onPreview={isMobile ? handlePreview : undefined}
/>
))}
</div>
</div>
)}
: null
}
spawnLabel={isSpawningCurrentProject ? "Spawning..." : "Spawn Orchestrator"}
spawnDisabled={isSpawningCurrentProject}
/>
) : null}
{showEmptyState ? (
<EmptyState
orchestratorHref={orchestratorHref}
onSpawnOrchestrator={
canSpawnProjectOrchestrator && activeProject
? () => {
void handleSpawnOrchestrator(activeProject);
}
: null
}
spawnLabel={isSpawningCurrentProject ? "Spawning..." : "Spawn Orchestrator"}
spawnDisabled={isSpawningCurrentProject}
/>
) : null}
{!allProjectsView && currentProjectSpawnError ? (
<p className="mt-3 text-[11px] text-[var(--color-status-error)]">
{currentProjectSpawnError}
</p>
) : null}
{!allProjectsView && currentProjectSpawnError ? (
<p className="mt-3 text-[11px] text-[var(--color-status-error)]">
{currentProjectSpawnError}
</p>
) : null}
{!allProjectsView && grouped.done.length > 0 && (
<div className="done-bar mt-6">
<button
type="button"
className="done-bar__toggle"
onClick={() => setDoneExpanded((v) => !v)}
aria-expanded={doneExpanded}
>
<svg
className={`done-bar__chevron${doneExpanded ? " done-bar__chevron--open" : ""}`}
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="m9 18 6-6-6-6" />
</svg>
<span className="done-bar__label">Done / Terminated</span>
<span className="done-bar__count">{grouped.done.length}</span>
</button>
{doneExpanded && (
<div className="done-bar__cards">
{grouped.done.map((session) => (
<DoneCard key={session.id} session={session} onRestore={handleRestore} />
))}
</div>
)}
{!allProjectsView && grouped.done.length > 0 && (
<div className="done-bar mt-6">
<button
type="button"
className="done-bar__toggle"
onClick={() => setDoneExpanded((v) => !v)}
aria-expanded={doneExpanded}
>
<svg
className={`done-bar__chevron${doneExpanded ? " done-bar__chevron--open" : ""}`}
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="m9 18 6-6-6-6" />
</svg>
<span className="done-bar__label">Done / Terminated</span>
<span className="done-bar__count">{grouped.done.length}</span>
</button>
{doneExpanded && (
<div className="done-bar__cards">
{grouped.done.map((session) => (
<DoneCard key={session.id} session={session} onRestore={handleRestore} />
))}
</div>
)}
</div>
</main>
)}
</div>
</div>
<BottomSheet
session={previewSession}
mode={bottomSheetMode}
onCancel={handleBottomSheetClose}
onConfirm={handleBottomSheetConfirmKill}
onRequestKill={handleRequestKill}
onMerge={handleMerge}
isMergeReady={previewSession ? attentionLevels[previewSession.id] === "merge" : false}
/>
</main>
</div>
);
const bottomSheet = (
<BottomSheet
session={previewSession}
mode={bottomSheetMode}
onCancel={handleBottomSheetClose}
onConfirm={handleBottomSheetConfirmKill}
onRequestKill={handleRequestKill}
onMerge={handleMerge}
isMergeReady={previewSession ? attentionLevels[previewSession.id] === "merge" : false}
/>
);
if (isInsideLayout) {
return (
<>
<UpdateBanner />
<ConnectionBar status={connectionStatus} />
{mainPanel}
{bottomSheet}
</>
);
}
return (
<SidebarContext.Provider value={{ onToggleSidebar: handleToggleSidebar, mobileSidebarOpen }}>
<UpdateBanner />
<ConnectionBar status={connectionStatus} />
<div className="dashboard-app-shell">
<div
className={`dashboard-shell--desktop${sidebarCollapsed ? " dashboard-shell--sidebar-collapsed" : ""}`}
>
<div
className={`sidebar-wrapper${mobileSidebarOpen ? " sidebar-wrapper--mobile-open" : ""}`}
>
<ProjectSidebar
projects={projects}
sessions={sessions}
orchestrators={sidebarOrchestrators}
activeProjectId={projectId}
activeSessionId={activeSessionId}
loading={!liveSessionsResolved}
collapsed={sidebarCollapsed}
onToggleCollapsed={() => setSidebarCollapsed((v) => !v)}
onMobileClose={() => setMobileSidebarOpen(false)}
/>
</div>
{mobileSidebarOpen && (
<div
className="sidebar-mobile-backdrop"
onClick={() => setMobileSidebarOpen(false)}
/>
)}
{mainPanel}
</div>
</div>
{bottomSheet}
</SidebarContext.Provider>
);
}

View File

@ -1,273 +0,0 @@
"use client";
import { useState, useRef } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { cn } from "@/lib/cn";
import { projectDashboardPath, projectSessionPath } from "@/lib/routes";
export interface Orchestrator {
id: string;
projectId: string;
projectName: string;
status: string;
activity: string | null;
createdAt: string | null;
lastActivityAt: string | null;
}
interface OrchestratorSelectorProps {
orchestrators: Orchestrator[];
projectId: string;
projectName: string;
error: string | null;
}
function formatRelativeTime(isoDate: string | null): string {
if (!isoDate) return "Unknown";
const date = new Date(isoDate);
const timestamp = date.getTime();
// Guard against invalid dates (NaN) and future timestamps
if (!Number.isFinite(timestamp)) return "Unknown";
const now = new Date();
const diffMs = now.getTime() - timestamp;
// Handle future timestamps
if (diffMs < 0) return "Just now";
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return "Just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
return `${diffDays}d ago`;
}
function getStatusColor(status: string): string {
switch (status) {
case "working":
return "var(--color-status-working)";
case "spawning":
return "var(--color-status-attention)";
case "pr_open":
case "review_pending":
case "approved":
case "mergeable":
return "var(--color-status-ready)";
case "ci_failed":
case "changes_requested":
return "var(--color-status-error)";
case "merged":
case "done":
case "killed":
case "terminated":
return "var(--color-text-tertiary)";
default:
return "var(--color-text-secondary)";
}
}
function getActivityLabel(activity: string | null): string {
if (!activity) return "";
switch (activity) {
case "active":
return "Active";
case "ready":
return "Ready";
case "idle":
return "Idle";
case "waiting_input":
return "Waiting";
case "blocked":
return "Blocked";
case "exited":
return "Exited";
default:
return activity;
}
}
export function OrchestratorSelector({
orchestrators,
projectId,
projectName,
error,
}: OrchestratorSelectorProps) {
const router = useRouter();
const [isSpawning, setIsSpawning] = useState(false);
const [spawnError, setSpawnError] = useState<string | null>(null);
const spawnLockRef = useRef(false);
const handleSpawnNew = async () => {
// Synchronous re-entrancy guard: React state updates are async,
// so two clicks before rerender would fire two POSTs without this.
if (spawnLockRef.current) return;
spawnLockRef.current = true;
setIsSpawning(true);
setSpawnError(null);
try {
const response = await fetch("/api/orchestrators", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId }),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Failed to spawn orchestrator");
}
const data = await response.json();
router.push(projectSessionPath(projectId, data.orchestrator.id));
} catch (err) {
setSpawnError(err instanceof Error ? err.message : "Failed to spawn orchestrator");
} finally {
setIsSpawning(false);
spawnLockRef.current = false;
}
};
if (error) {
return (
<div className="flex h-screen items-center justify-center bg-[var(--color-bg-base)]">
<div className="text-center">
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]">Error</h1>
<p className="mt-2 text-[var(--color-text-secondary)]">{error}</p>
<Link
href="/"
className="orchestrator-btn mt-4 inline-flex items-center gap-2 px-4 py-2 text-sm font-medium"
>
Go to Dashboard
</Link>
</div>
</div>
);
}
return (
<div className="flex min-h-screen flex-col bg-[var(--color-bg-base)]">
{/* Header */}
<header className="nav-glass sticky top-0 z-10 border-b border-[var(--color-border-subtle)] px-6 py-4">
<div className="mx-auto flex max-w-3xl items-center justify-between">
<div>
<h1 className="text-lg font-semibold text-[var(--color-text-primary)]">
{projectName}
</h1>
<p className="text-sm text-[var(--color-text-secondary)]">Project orchestrator</p>
</div>
<Link
href={projectDashboardPath(projectId)}
className="text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]"
>
Dashboard
</Link>
</div>
</header>
{/* Content */}
<main className="flex-1 px-6 py-8">
<div className="mx-auto max-w-3xl">
{/* Info banner */}
<div className="mb-6 rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)] p-4">
<p className="text-sm text-[var(--color-text-secondary)]">
AO keeps one main orchestrator per project. Opening or spawning here reuses that
canonical session instead of creating another duplicate.
</p>
</div>
{/* Existing orchestrators */}
<div className="mb-6">
<h2 className="mb-3 text-sm font-medium text-[var(--color-text-secondary)]">
Main Session
</h2>
<div className="space-y-2">
{orchestrators.map((orch) => (
<Link
key={orch.id}
href={projectSessionPath(orch.projectId, orch.id)}
className={cn(
"flex items-center justify-between rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)] p-4",
"transition-all hover:border-[var(--color-border-default)] hover:shadow-sm",
)}
>
<div className="flex items-center gap-3">
<div
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: getStatusColor(orch.status) }}
/>
<div>
<div className="font-medium text-[var(--color-text-primary)]">{orch.id}</div>
<div className="flex items-center gap-2 text-xs text-[var(--color-text-secondary)]">
<span className="capitalize">{orch.status.replace(/_/g, " ")}</span>
{orch.activity && (
<>
<span className="text-[var(--color-text-tertiary)]">&middot;</span>
<span>{getActivityLabel(orch.activity)}</span>
</>
)}
</div>
</div>
</div>
<div className="text-right text-xs text-[var(--color-text-tertiary)]">
<div>Created {formatRelativeTime(orch.createdAt)}</div>
{orch.lastActivityAt && (
<div>Active {formatRelativeTime(orch.lastActivityAt)}</div>
)}
</div>
</Link>
))}
</div>
</div>
{/* Start new section */}
<div className="border-t border-[var(--color-border-subtle)] pt-6">
<h2 className="mb-3 text-sm font-medium text-[var(--color-text-secondary)]">
Open Orchestrator
</h2>
<button
type="button"
onClick={handleSpawnNew}
disabled={isSpawning}
className={cn(
"orchestrator-btn w-full px-4 py-3 text-sm font-medium",
"disabled:cursor-wait disabled:opacity-70",
)}
>
{isSpawning ? (
<span className="flex items-center justify-center gap-2">
<svg
className="h-4 w-4 animate-spin"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Opening orchestrator...
</span>
) : (
"Open Orchestrator"
)}
</button>
{spawnError && (
<p className="mt-2 text-sm text-[var(--color-status-error)]">{spawnError}</p>
)}
</div>
</div>
</main>
</div>
);
}

View File

@ -1,11 +1,11 @@
"use client";
import Link from "next/link";
import { useState, useEffect, useMemo, useRef } from "react";
import { useState, useEffect, useMemo, useRef, useCallback, memo } from "react";
import { useRouter } from "next/navigation";
import { cn } from "@/lib/cn";
import type { ProjectInfo } from "@/lib/project-name";
import { getAttentionLevel, type DashboardSession, type AttentionLevel } from "@/lib/types";
import { getAttentionLevel, type DashboardSession } from "@/lib/types";
import { isOrchestratorSession } from "@aoagents/ao-core/types";
import { getSessionTitle, humanizeBranch } from "@/lib/format";
import { usePopoverClamp } from "@/hooks/usePopoverClamp";
@ -42,7 +42,7 @@ interface ProjectSidebarProps {
type SessionDotLevel = "respond" | "review" | "action" | "pending" | "working" | "merge" | "done";
function SessionDot({ level }: { level: SessionDotLevel }) {
const SessionDot = memo(function SessionDot({ level }: { level: SessionDotLevel }) {
return (
<div
className={cn(
@ -52,7 +52,7 @@ function SessionDot({ level }: { level: SessionDotLevel }) {
data-level={level}
/>
);
}
});
// ProjectSidebar consumes `getAttentionLevel()` without passing a mode,
// so the function defaults to "detailed" and `action` never appears here
@ -70,15 +70,41 @@ function loadShowSessionId(): boolean {
}
}
const LEVEL_LABELS: Record<AttentionLevel, string> = {
working: "working",
pending: "pending",
review: "review",
respond: "respond",
action: "action",
merge: "merge",
done: "done",
};
const SHOW_KILLED_KEY = "ao:sidebar:show-killed";
const SHOW_DONE_KEY = "ao:sidebar:show-done";
const EXPANDED_PROJECTS_KEY = "ao:sidebar:expanded-projects";
function loadShowKilled(): boolean {
if (typeof window === "undefined") return false;
try {
return window.sessionStorage.getItem(SHOW_KILLED_KEY) === "true";
} catch {
return false;
}
}
function loadShowDone(): boolean {
if (typeof window === "undefined") return false;
try {
return window.sessionStorage.getItem(SHOW_DONE_KEY) === "true";
} catch {
return false;
}
}
function loadExpandedProjects(): Set<string> | null {
if (typeof window === "undefined") return null;
try {
const raw = window.sessionStorage.getItem(EXPANDED_PROJECTS_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return new Set<string>(parsed);
return null;
} catch {
return null;
}
}
export function ProjectSidebar(props: ProjectSidebarProps) {
if (props.projects.length === 0) {
@ -87,6 +113,102 @@ export function ProjectSidebar(props: ProjectSidebarProps) {
return <ProjectSidebarInner {...props} />;
}
interface SessionRowProps {
session: DashboardSession;
level: SessionDotLevel;
isActive: boolean;
showSessionId: boolean;
pendingRename: string | undefined;
onNavigate: (href: string, session: DashboardSession) => void;
onStartRename: (session: DashboardSession, title: string) => void;
}
const SessionRow = memo(function SessionRow({
session,
level,
isActive,
showSessionId,
pendingRename,
onNavigate,
onStartRename,
}: SessionRowProps) {
const effectiveDisplayName =
pendingRename !== undefined
? pendingRename
: session.displayNameUserSet
? (session.displayName ?? "")
: "";
const title =
effectiveDisplayName !== ""
? effectiveDisplayName
: (session.branch ?? getSessionTitle(session));
const sessionHref = projectSessionPath(session.projectId, session.id);
return (
<div
className={cn(
"project-sidebar__sess-row group",
isActive && "project-sidebar__sess-row--active",
)}
>
<a
href={sessionHref}
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
onNavigate(sessionHref, session);
}}
className="project-sidebar__sess-link flex flex-1 min-w-0 items-center gap-[7px]"
aria-current={isActive ? "page" : undefined}
aria-label={`Open ${title}`}
>
<SessionDot level={level} />
<div className="flex-1 min-w-0">
<span
className={cn(
"project-sidebar__sess-label",
isActive && "project-sidebar__sess-label--active",
)}
>
{title}
</span>
{showSessionId ? (
<div className="project-sidebar__sess-meta">
<span className="project-sidebar__sess-id">{session.id}</span>
</div>
) : null}
</div>
</a>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onStartRename(session, title);
}}
className="project-sidebar__sess-rename-btn opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100"
title="Rename session"
aria-label={`Rename ${session.id}`}
>
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
</svg>
</button>
</div>
);
});
function ProjectSidebarEmpty({ collapsed = false }: { collapsed?: boolean }) {
const [addProjectOpen, setAddProjectOpen] = useState(false);
@ -162,13 +284,15 @@ function ProjectSidebarInner({
onMobileClose,
}: ProjectSidebarProps) {
const router = useRouter();
const isLoading = loading || sessions === null;
const _isLoading = loading || sessions === null;
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(
() => new Set(activeProjectId && activeProjectId !== "all" ? [activeProjectId] : []),
() =>
loadExpandedProjects() ??
new Set(activeProjectId && activeProjectId !== "all" ? [activeProjectId] : []),
);
const [showKilled, setShowKilled] = useState(false);
const [showDone, setShowDone] = useState(false);
const [showKilled, setShowKilled] = useState<boolean>(loadShowKilled);
const [showDone, setShowDone] = useState<boolean>(loadShowDone);
const [showSessionId, setShowSessionId] = useState<boolean>(loadShowSessionId);
// Inline session-rename state. Only one row is edited at a time. `pendingRenames`
// mirrors the in-flight / just-saved value so the new label appears immediately
@ -198,6 +322,30 @@ function ProjectSidebarInner({
}
}, [showSessionId]);
useEffect(() => {
try {
window.sessionStorage.setItem(SHOW_KILLED_KEY, String(showKilled));
} catch {
// sessionStorage unavailable — accept in-memory state.
}
}, [showKilled]);
useEffect(() => {
try {
window.sessionStorage.setItem(SHOW_DONE_KEY, String(showDone));
} catch {
// sessionStorage unavailable — accept in-memory state.
}
}, [showDone]);
useEffect(() => {
try {
window.sessionStorage.setItem(EXPANDED_PROJECTS_KEY, JSON.stringify([...expandedProjects]));
} catch {
// sessionStorage unavailable — accept in-memory state.
}
}, [expandedProjects]);
// Close the settings popover on outside click or Escape.
useEffect(() => {
if (!settingsOpen) return;
@ -266,20 +414,37 @@ function ProjectSidebarInner({
[visibleProjects],
);
// The API (selectPreferredOrchestratorId) sends at most one entry per
// project, so collapsing into a Map keyed by projectId is lossless. If a
// future API change starts emitting multiples, the last one wins here.
const orchestratorByProject = useMemo(
() => new Map((orchestrators ?? []).map((o) => [o.projectId, o])),
[orchestrators],
);
// Stable ref so sessionsByProject can read latest sessions without depending
// on the array reference (which changes every SSE tick even when content is unchanged).
const sessionsRef = useRef(sessions);
sessionsRef.current = sessions;
// Content-based key — only changes when session IDs, statuses, or projects change.
// Used as the sole sessions-related dependency of sessionsByProject below.
const sessionsKey = useMemo(
() =>
(sessions ?? [])
.map(
(s) =>
`${s.id}:${s.status}:${s.activity ?? ""}:${s.projectId}:${s.displayName ?? ""}:${s.displayNameUserSet ? "1" : "0"}:${s.branch ?? ""}:${s.issueTitle ?? ""}:${s.pr?.title ?? ""}:${s.summary ?? ""}`,
)
.join("|"),
[sessions],
);
const sessionsByProject = useMemo(() => {
const map = new Map<string, DashboardSession[]>();
// Build a set of valid project IDs to filter sessions strictly
const validProjectIds = new Set(visibleProjects.map((p) => p.id));
for (const s of sessions ?? []) {
// Read via ref so this memo only reruns when sessionsKey changes (content
// changed), not when sessions gets a new array reference with identical data.
for (const s of sessionsRef.current ?? []) {
// Only include sessions whose projectId matches a configured project
if (!validProjectIds.has(s.projectId)) continue;
if (isOrchestratorSession(s, prefixByProject.get(s.projectId), allPrefixes)) continue;
@ -295,7 +460,8 @@ function ProjectSidebarInner({
map.set(s.projectId, list);
}
return map;
}, [sessions, prefixByProject, allPrefixes, visibleProjects, showKilled, showDone]);
}, [sessionsKey, prefixByProject, allPrefixes, visibleProjects, showKilled, showDone]);
// Clear an optimistic rename once the prop session.displayName catches up.
// Without this, we'd keep masking the server value forever after a save.
@ -313,18 +479,23 @@ function ProjectSidebarInner({
if (changed) setPendingRenames(next);
}, [sessions, pendingRenames]);
const startRename = (session: DashboardSession, currentTitle: string) => {
// Prefer the in-flight optimistic value over the prop — if the user opens
// rename while a previous PATCH is still propagating, the prop still shows
// the pre-rename value but we want the input to start from the latest.
// Auto-derived displayName isn't pre-filled (user-set flag absent) — start
// from the live title so the user types over the visible label.
const pending = pendingRenames.get(session.id);
const initial =
pending ?? (session.displayNameUserSet ? (session.displayName ?? "") : "");
setEditingSessionId(session.id);
setEditingValue(initial || currentTitle);
};
const pendingRenamesRef = useRef(pendingRenames);
pendingRenamesRef.current = pendingRenames;
const startRename = useCallback(
(session: DashboardSession, currentTitle: string) => {
// Prefer the in-flight optimistic value over the prop — if the user opens
// rename while a previous PATCH is still propagating, the prop still shows
// the pre-rename value but we want the input to start from the latest.
// Auto-derived displayName isn't pre-filled (user-set flag absent) — start
// from the live title so the user types over the visible label.
const pending = pendingRenamesRef.current.get(session.id);
const initial = pending ?? (session.displayNameUserSet ? (session.displayName ?? "") : "");
setEditingSessionId(session.id);
setEditingValue(initial || currentTitle);
},
[],
);
const cancelRename = () => {
setEditingSessionId(null);
@ -367,17 +538,20 @@ function ProjectSidebarInner({
}
};
const navigate = (url: string, session?: DashboardSession) => {
if (session) {
try {
sessionStorage.setItem(`ao-session-nav:${session.id}`, JSON.stringify(session));
} catch {
// sessionStorage unavailable — silent fallback
const navigate = useCallback(
(url: string, session?: DashboardSession) => {
if (session) {
try {
sessionStorage.setItem(`ao-session-nav:${session.id}`, JSON.stringify(session));
} catch {
// sessionStorage unavailable — silent fallback
}
}
}
router.push(url);
onMobileClose?.();
};
router.push(url);
onMobileClose?.();
},
[router, onMobileClose],
);
const toggleExpand = (projectId: string) => {
setExpandedProjects((prev) => {
@ -544,6 +718,16 @@ function ProjectSidebarInner({
{/* Project tree */}
<div className="project-sidebar__tree flex-1 overflow-y-auto overflow-x-hidden">
{sessions === null ? (
<div className="space-y-1 px-3 py-3" aria-label="Loading projects">
{Array.from({ length: 4 }, (_, i) => (
<div key={i} className="flex items-center gap-2 py-1">
<div className="h-2 w-2 shrink-0 animate-pulse rounded-full bg-[var(--color-border-strong)]" />
<div className="h-3 flex-1 animate-pulse rounded bg-[var(--color-bg-primary)]" />
</div>
))}
</div>
) : null}
{visibleProjects.map((project) => {
const workerSessions = sessionsByProject.get(project.id) ?? [];
const isExpanded = expandedProjects.has(project.id);
@ -553,8 +737,14 @@ function ProjectSidebarInner({
// sessionsByProject already applies the showDone filter consistently.
const visibleSessions = workerSessions;
const hasActiveSessions = visibleSessions.length > 0;
const orchestratorLink = orchestratorByProject.get(project.id) ?? null;
// Look up the full session object so navigate() can cache it in
// sessionStorage — prevents the "Session unavailable" flash on
// first load. Orchestrators are filtered out of sessionsByProject
// but still present in the raw sessions prop.
const orchestratorSession = orchestratorLink
? (sessions?.find((s) => s.id === orchestratorLink.id) ?? null)
: null;
return (
<div key={project.id} className="project-sidebar__project">
@ -625,7 +815,7 @@ function ProjectSidebarInner({
hasActiveSessions && "project-sidebar__proj-badge--active",
)}
>
{workerSessions.length}
{sessionsByProject.get(project.id)?.length ?? 0}
</span>
</button>
)}
@ -656,14 +846,17 @@ function ProjectSidebarInner({
</Link>
) : null}
{/* Orchestrator button */}
{!isDegraded && orchestratorLink && (
<Link
<a
href={projectSessionPath(project.id, orchestratorLink.id)}
prefetch={false}
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
e.stopPropagation();
onMobileClose?.();
navigate(
projectSessionPath(project.id, orchestratorLink.id),
orchestratorSession ?? undefined,
);
}}
className="project-sidebar__proj-action"
aria-label={`Open ${project.name} orchestrator`}
@ -683,7 +876,7 @@ function ProjectSidebarInner({
<circle cx="12" cy="17" r="2" />
<circle cx="18" cy="17" r="2" />
</svg>
</Link>
</a>
)}
<div
@ -730,7 +923,10 @@ function ProjectSidebarInner({
role="menuitem"
onClick={() => {
setProjectMenuOpenId(null);
navigate(projectSessionPath(project.id, orchestratorLink.id));
navigate(
projectSessionPath(project.id, orchestratorLink.id),
orchestratorSession ?? undefined,
);
}}
>
Open orchestrator
@ -768,7 +964,7 @@ function ProjectSidebarInner({
{/* Sessions */}
{!isDegraded && isExpanded && (
<div className="project-sidebar__sessions">
{isLoading ? (
{sessions === null ? (
<div className="space-y-2 px-3 py-2" aria-label="Loading sessions">
{Array.from({ length: 3 }, (_, index) => (
<div
@ -785,24 +981,6 @@ function ProjectSidebarInner({
visibleSessions.map((session) => {
const level = getAttentionLevel(session);
const isSessionActive = activeSessionId === session.id;
// Display precedence: optimistic rename (just-saved value)
// → user-set displayName → branch → fallback chain.
// Auto-derived displayName (displayNameUserSet=false) is
// intentionally skipped here so PR/issue titles surfaced
// by getSessionTitle aren't shadowed — mirrors the gate in
// format.ts:getSessionTitle.
const pending = pendingRenames.get(session.id);
const effectiveDisplayName =
pending !== undefined
? pending
: session.displayNameUserSet
? (session.displayName ?? "")
: "";
const title =
effectiveDisplayName !== ""
? effectiveDisplayName
: (session.branch ?? getSessionTitle(session));
const sessionHref = projectSessionPath(project.id, session.id);
const isEditing = editingSessionId === session.id;
if (isEditing) {
return (
@ -839,76 +1017,16 @@ function ProjectSidebarInner({
);
}
return (
<div
<SessionRow
key={session.id}
className={cn(
"project-sidebar__sess-row group",
isSessionActive && "project-sidebar__sess-row--active",
)}
>
<a
href={sessionHref}
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
navigate(sessionHref, session);
}}
className="project-sidebar__sess-link flex flex-1 min-w-0 items-center gap-[7px]"
aria-current={isSessionActive ? "page" : undefined}
aria-label={`Open ${title}`}
>
<SessionDot level={level} />
<div className="flex-1 min-w-0">
<span
className={cn(
"project-sidebar__sess-label",
isSessionActive && "project-sidebar__sess-label--active",
)}
>
{title}
</span>
{showSessionId ? (
<div className="project-sidebar__sess-meta">
<span className="project-sidebar__sess-id">{session.id}</span>
<span className="project-sidebar__sess-status">
{LEVEL_LABELS[level]}
</span>
</div>
) : null}
</div>
{!showSessionId ? (
<span className="project-sidebar__sess-status project-sidebar__sess-status--inline">
{LEVEL_LABELS[level]}
</span>
) : null}
</a>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
startRename(session, title);
}}
className="project-sidebar__sess-rename-btn opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100"
title="Rename session"
aria-label={`Rename ${session.id}`}
>
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
</svg>
</button>
</div>
session={session}
level={level}
isActive={isSessionActive}
showSessionId={showSessionId}
pendingRename={pendingRenames.get(session.id)}
onNavigate={navigate}
onStartRename={startRename}
/>
);
})
) : error ? (
@ -923,7 +1041,9 @@ function ProjectSidebarInner({
</button>
</div>
) : (
<div className="project-sidebar__empty">No sessions shown</div>
<div className="project-sidebar__empty">
No active sessions
</div>
)}
</div>
)}

View File

@ -11,10 +11,9 @@ import {
import dynamic from "next/dynamic";
import { getSessionTitle } from "@/lib/format";
import type { ProjectInfo } from "@/lib/project-name";
import { SidebarContext } from "./workspace/SidebarContext";
import { useSidebarContext } from "./workspace/SidebarContext";
import { projectDashboardPath, projectSessionPath } from "@/lib/routes";
import { ProjectSidebar, type ProjectSidebarOrchestrator } from "./ProjectSidebar";
import { MobileBottomNav } from "./MobileBottomNav";
import { SessionDetailHeader, type OrchestratorZones } from "./SessionDetailHeader";
import { SessionEndedSummary } from "./SessionEndedSummary";
@ -40,11 +39,6 @@ interface SessionDetailProps {
orchestratorZones?: OrchestratorZones;
projectOrchestratorId?: string | null;
projects?: ProjectInfo[];
sidebarSessions?: DashboardSession[] | null;
sidebarOrchestrators?: ProjectSidebarOrchestrator[];
sidebarLoading?: boolean;
sidebarError?: boolean;
onRetrySidebar?: () => void;
}
export function SessionDetail({
@ -53,19 +47,14 @@ export function SessionDetail({
orchestratorZones,
projectOrchestratorId = null,
projects = [],
sidebarSessions = [],
sidebarOrchestrators,
sidebarLoading = false,
sidebarError = false,
onRetrySidebar,
}: SessionDetailProps) {
const router = useRouter();
const searchParams = useSearchParams();
const isMobile = useMediaQuery(MOBILE_BREAKPOINT);
const sidebarCtx = useSidebarContext();
const startFullscreen = searchParams.get("fullscreen") === "true";
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [showTerminal, setShowTerminal] = useState(false);
const [relaunchError, setRelaunchError] = useState<string | null>(null);
const pr = session.pr;
const terminalEnded = isDashboardSessionTerminal(session);
const isRestorable = isDashboardSessionRestorable(session);
@ -119,11 +108,52 @@ export function SessionDetail({
}
}, [session.id]);
const handleRelaunchClean = useCallback(async () => {
const confirmed = window.confirm(
"This will discard the current orchestrator's conversation and state. Continue?",
);
if (!confirmed) return;
setRelaunchError(null);
try {
const res = await fetch("/api/orchestrators", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId: session.projectId, clean: true }),
});
if (!res.ok) {
// Surface server-side errors. Note: a failure here may indicate the
// existing orchestrator was killed but respawn failed — the banner
// tells the user the previous session was terminated so they don't
// assume the page they're looking at is still live.
let message = "";
try {
const data = (await res.json()) as { error?: string };
message = data.error ?? "";
} catch {
message = await res.text().catch(() => "");
}
throw new Error(message || `HTTP ${res.status}`);
}
// Hard-navigate to the freshly spawned orchestrator's session page.
// Orchestrator session IDs are fixed per project, so this is the same
// path in practice — but reading from the response keeps us correct if
// the contract ever changes (and a hard nav forces the terminal
// WebSocket to reconnect against the new tmux session).
const data = (await res.json()) as { orchestrator?: { id: string } };
const newId = data.orchestrator?.id ?? session.id;
window.location.href = projectSessionPath(session.projectId, newId);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to relaunch orchestrator";
console.error("Failed to relaunch orchestrator:", err);
setRelaunchError(message);
}
}, [session.id, session.projectId]);
const orchestratorHref = useMemo(() => {
if (isOrchestrator) return projectSessionPath(session.projectId, session.id);
if (isOrchestrator) return null;
if (projectOrchestratorId) return projectSessionPath(session.projectId, projectOrchestratorId);
return null;
}, [isOrchestrator, projectOrchestratorId, session.id, session.projectId]);
}, [isOrchestrator, projectOrchestratorId, session.projectId]);
useEffect(() => {
const frame = window.requestAnimationFrame(() => setShowTerminal(true));
@ -133,104 +163,86 @@ export function SessionDetail({
};
}, [session.id]);
const handleToggleSidebar = useCallback(() => {
if (isMobile) {
setMobileSidebarOpen((v) => !v);
} else {
setSidebarCollapsed((v) => !v);
}
}, [isMobile]);
return (
<SidebarContext.Provider value={{ onToggleSidebar: handleToggleSidebar, mobileSidebarOpen }}>
<div className="dashboard-app-shell">
<SessionDetailHeader
session={session}
isOrchestrator={isOrchestrator}
isMobile={isMobile}
terminalEnded={terminalEnded}
isRestorable={isRestorable}
activity={activity}
headline={headline}
projects={projects}
orchestratorHref={orchestratorHref}
orchestratorZones={orchestratorZones}
onToggleSidebar={handleToggleSidebar}
onRestore={handleRestore}
onKill={handleKill}
/>
<div
className={`dashboard-shell dashboard-shell--desktop${
sidebarCollapsed ? " dashboard-shell--sidebar-collapsed" : ""
}`}
>
{projects.length > 0 ? (
<div
className={`sidebar-wrapper${
mobileSidebarOpen ? " sidebar-wrapper--mobile-open" : ""
}`}
>
<ProjectSidebar
projects={projects}
sessions={sidebarSessions}
orchestrators={sidebarOrchestrators}
loading={sidebarLoading}
error={sidebarError}
onRetry={onRetrySidebar}
activeProjectId={session.projectId}
activeSessionId={session.id}
collapsed={sidebarCollapsed}
onToggleCollapsed={() => setSidebarCollapsed((current) => !current)}
onMobileClose={() => setMobileSidebarOpen(false)}
/>
</div>
) : null}
{mobileSidebarOpen && (
<div className="sidebar-mobile-backdrop" onClick={() => setMobileSidebarOpen(false)} />
)}
<div className="dashboard-main dashboard-main--desktop">
<main className="session-detail-page flex-1 min-h-0 flex flex-col bg-[var(--color-bg-base)]">
<div className="flex-1 min-h-0 flex flex-col">
{!showTerminal ? (
<div className="session-detail-terminal-placeholder h-full" />
) : terminalEnded ? (
<SessionEndedSummary
session={session}
headline={headline}
pr={pr}
dashboardHref={dashboardHref}
/>
) : (
<DirectTerminal
sessionId={session.id}
projectId={session.projectId}
tmuxName={session.metadata?.tmuxName}
startFullscreen={startFullscreen}
variant={terminalVariant}
appearance="dark"
height="100%"
isOpenCodeSession={isOpenCodeSession}
reloadCommand={isOpenCodeSession ? reloadCommand : undefined}
autoFocus
/>
)}
<div className="dashboard-main--desktop">
<SessionDetailHeader
session={session}
isOrchestrator={isOrchestrator}
isMobile={isMobile}
terminalEnded={terminalEnded}
isRestorable={isRestorable}
activity={activity}
headline={headline}
projects={projects}
orchestratorHref={orchestratorHref}
orchestratorZones={orchestratorZones}
onToggleSidebar={sidebarCtx?.onToggleSidebar ?? (() => {})}
onRestore={handleRestore}
onKill={handleKill}
onRelaunchClean={isOrchestrator ? handleRelaunchClean : undefined}
/>
<main className="session-detail-page flex-1 min-h-0 flex flex-col bg-[var(--color-bg-base)]">
{relaunchError ? (
<div
className="border-b border-[color-mix(in_srgb,var(--color-status-error)_28%,transparent)] bg-[color-mix(in_srgb,var(--color-status-error)_10%,transparent)] px-4 py-2 text-[12px] text-[var(--color-status-error)]"
role="alert"
aria-live="assertive"
>
<div className="flex items-start justify-between gap-3">
<div>
<span className="font-semibold">Relaunch failed:</span> {relaunchError}
<div className="mt-1 text-[var(--color-text-secondary)]">
The previous orchestrator may already be terminated. Try again from the
project dashboard.
</div>
</div>
</main>
<button
type="button"
onClick={() => setRelaunchError(null)}
className="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]"
aria-label="Dismiss"
>
×
</button>
</div>
</div>
) : null}
<div className="flex-1 min-h-0 flex flex-col">
{!showTerminal ? (
<div className="session-detail-terminal-placeholder h-full" />
) : terminalEnded ? (
<SessionEndedSummary
session={session}
headline={headline}
pr={pr}
dashboardHref={dashboardHref}
/>
) : (
<DirectTerminal
sessionId={session.id}
projectId={session.projectId}
tmuxName={session.metadata?.tmuxName}
startFullscreen={startFullscreen}
variant={terminalVariant}
appearance="dark"
height="100%"
isOpenCodeSession={isOpenCodeSession}
reloadCommand={isOpenCodeSession ? reloadCommand : undefined}
autoFocus
/>
)}
</div>
<MobileBottomNav
ariaLabel="Session navigation"
activeTab={isOrchestrator ? "orchestrator" : undefined}
dashboardHref={dashboardHref}
prsHref={
session.projectId ? `/?project=${encodeURIComponent(session.projectId)}&tab=prs` : "/"
}
showOrchestrator={!!orchestratorHref}
orchestratorHref={orchestratorHref}
/>
</div>
</SidebarContext.Provider>
</main>
<MobileBottomNav
ariaLabel="Session navigation"
activeTab={isOrchestrator ? "orchestrator" : undefined}
dashboardHref={dashboardHref}
prsHref={
session.projectId ? `/?project=${encodeURIComponent(session.projectId)}&tab=prs` : "/"
}
showOrchestrator={!!orchestratorHref}
orchestratorHref={orchestratorHref}
/>
</div>
);
}

View File

@ -33,6 +33,7 @@ interface SessionDetailHeaderProps {
onToggleSidebar: () => void;
onRestore: () => void;
onKill: () => void;
onRelaunchClean?: () => void;
}
function normalizeActivityLabelForClass(activityLabel: string): string {
@ -77,6 +78,7 @@ export function SessionDetailHeader({
onToggleSidebar,
onRestore,
onKill,
onRelaunchClean,
}: SessionDetailHeaderProps) {
const pr = session.pr;
const allGreen = pr ? isPRMergeReady(pr) : false;
@ -287,6 +289,30 @@ export function SessionDetailHeader({
</button>
) : null}
{isOrchestrator && onRelaunchClean ? (
<button
type="button"
className="dashboard-app-btn dashboard-app-btn--amber"
onClick={onRelaunchClean}
aria-label="Launch Orchestrator (clean context)"
>
<svg
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M20 11a8 8 0 0 0-14.9-3.98" />
<path d="M4 5v4h4" />
<path d="M4 13a8 8 0 0 0 14.9 3.98" />
<path d="M20 19v-4h-4" />
</svg>
<span className="topbar-btn-label">Relaunch (clean)</span>
</button>
) : null}
{orchestratorHref ? (
<a
href={orchestratorHref}

View File

@ -165,6 +165,7 @@ describe("Dashboard empty state", () => {
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("mounts the sidebar empty state on a fresh install with zero projects", () => {
render(<Dashboard initialSessions={[]} projects={[]} />);

View File

@ -1,241 +0,0 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { OrchestratorSelector } from "../OrchestratorSelector";
const mockPush = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
}));
const mockOrchestrators = [
{
id: "app-orchestrator",
projectId: "my-project",
projectName: "My Project",
status: "working",
activity: "active",
createdAt: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago
lastActivityAt: new Date(Date.now() - 300000).toISOString(), // 5 min ago
},
];
const defaultProps = {
orchestrators: mockOrchestrators,
projectId: "my-project",
projectName: "My Project",
error: null,
};
describe("OrchestratorSelector", () => {
beforeEach(() => {
vi.clearAllMocks();
mockPush.mockClear();
global.fetch = vi.fn();
});
it("renders orchestrator list", () => {
render(<OrchestratorSelector {...defaultProps} />);
expect(screen.getByText("app-orchestrator")).toBeInTheDocument();
});
it("displays project name in header", () => {
render(<OrchestratorSelector {...defaultProps} />);
expect(screen.getByText("My Project")).toBeInTheDocument();
expect(screen.getByText("Project orchestrator")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Dashboard" })).toHaveAttribute(
"href",
"/projects/my-project",
);
});
it("explains that orchestrator opening reuses the canonical session", () => {
render(<OrchestratorSelector {...defaultProps} />);
expect(screen.getByText(/one main orchestrator per project/i)).toBeInTheDocument();
});
it("shows error state", () => {
render(<OrchestratorSelector {...defaultProps} orchestrators={[]} error="Project not found" />);
expect(screen.getByText("Error")).toBeInTheDocument();
expect(screen.getByText("Project not found")).toBeInTheDocument();
});
it("shows open orchestrator button", () => {
render(<OrchestratorSelector {...defaultProps} />);
expect(screen.getByRole("button", { name: /open orchestrator/i })).toBeInTheDocument();
});
it("spawns new orchestrator on button click and navigates", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
orchestrator: { id: "app-orchestrator" },
}),
});
global.fetch = mockFetch;
render(<OrchestratorSelector {...defaultProps} />);
const button = screen.getByRole("button", { name: /open orchestrator/i });
fireEvent.click(button);
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("/api/orchestrators", expect.any(Object));
});
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/projects/my-project/sessions/app-orchestrator");
});
});
it("shows loading state while spawning", async () => {
const mockFetch = vi.fn().mockImplementation(
() => new Promise(() => {}), // Never resolves
);
global.fetch = mockFetch;
render(<OrchestratorSelector {...defaultProps} />);
const button = screen.getByRole("button", { name: /open orchestrator/i });
fireEvent.click(button);
await waitFor(() => {
expect(screen.getByText(/opening orchestrator/i)).toBeInTheDocument();
});
});
it("shows error when spawn fails", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
json: () => Promise.resolve({ error: "Failed to spawn" }),
});
global.fetch = mockFetch;
render(<OrchestratorSelector {...defaultProps} />);
const button = screen.getByRole("button", { name: /open orchestrator/i });
fireEvent.click(button);
await waitFor(() => {
expect(screen.getByText("Failed to spawn")).toBeInTheDocument();
});
});
it("links to orchestrator session page", () => {
render(<OrchestratorSelector {...defaultProps} />);
const link = screen.getByRole("link", { name: /app-orchestrator/i });
expect(link).toHaveAttribute("href", "/projects/my-project/sessions/app-orchestrator");
});
it("displays status and activity for each orchestrator", () => {
render(<OrchestratorSelector {...defaultProps} />);
expect(screen.getByText("working")).toBeInTheDocument();
expect(screen.getByText("Active")).toBeInTheDocument();
});
it("covers relative time for days and status colors/labels", () => {
const wideOrchestrators = [
{
id: "orch-2",
projectId: "my-project",
projectName: "My Project",
status: "ci_failed",
activity: "waiting_input",
createdAt: new Date(Date.now() - 3600000 * 50).toISOString(), // 2d ago
lastActivityAt: null,
},
{
id: "orch-3",
projectId: "my-project",
projectName: "My Project",
status: "killed",
activity: "ready",
createdAt: new Date(Date.now() - 1000).toISOString(), // Just now
lastActivityAt: null,
},
{
id: "orch-4",
projectId: "my-project",
projectName: "My Project",
status: "unknown",
activity: "blocked",
createdAt: new Date().toISOString(),
lastActivityAt: null,
},
{
id: "orch-5",
projectId: "my-project",
projectName: "My Project",
status: "mergeable",
activity: "exited",
createdAt: new Date().toISOString(),
lastActivityAt: null,
},
];
render(<OrchestratorSelector {...defaultProps} orchestrators={wideOrchestrators} />);
expect(screen.getByText(/2d ago/)).toBeInTheDocument();
expect(screen.getAllByText(/Just now/i).length).toBeGreaterThan(0);
expect(screen.getByText(/Waiting/)).toBeInTheDocument();
expect(screen.getByText(/Ready/)).toBeInTheDocument();
expect(screen.getByText(/Blocked/)).toBeInTheDocument();
expect(screen.getByText(/Exited/)).toBeInTheDocument();
expect(screen.getByText(/ci failed/i)).toBeInTheDocument();
});
describe("formatRelativeTime edge cases", () => {
it("shows Unknown for invalid date strings", () => {
const orchestratorsWithInvalidDate = [
{
...mockOrchestrators[0],
createdAt: "not-a-valid-date",
lastActivityAt: null,
},
];
render(
<OrchestratorSelector {...defaultProps} orchestrators={orchestratorsWithInvalidDate} />,
);
// The "Created Unknown" text should appear for invalid dates
expect(screen.getByText(/Created Unknown/)).toBeInTheDocument();
});
it("shows Just now for future timestamps", () => {
const futureDate = new Date(Date.now() + 60000).toISOString(); // 1 minute in future
const orchestratorsWithFutureDate = [
{
...mockOrchestrators[0],
createdAt: futureDate,
lastActivityAt: null,
},
];
render(
<OrchestratorSelector {...defaultProps} orchestrators={orchestratorsWithFutureDate} />,
);
// Future timestamps should show "Just now" instead of negative values
expect(screen.getByText(/Created Just now/)).toBeInTheDocument();
});
it("shows Unknown for null dates", () => {
const orchestratorsWithNullDate = [
{
...mockOrchestrators[0],
createdAt: null,
lastActivityAt: null,
},
];
render(<OrchestratorSelector {...defaultProps} orchestrators={orchestratorsWithNullDate} />);
expect(screen.getByText(/Created Unknown/)).toBeInTheDocument();
});
});
});

View File

@ -399,6 +399,8 @@ describe("ProjectSidebar", () => {
/>,
);
// Only the killed-but-still-needs-attention session is counted; the merged
// session is filtered out by sessionsByProject (showDone = false by default).
expect(screen.getByRole("button", { name: /^Project One 1$/ })).toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Open Runtime missing but needs review" }),

View File

@ -304,6 +304,161 @@ describe("SessionDetail desktop layout", () => {
).not.toBeInTheDocument();
});
it("renders Relaunch (clean) on live orchestrator sessions and navigates to the new session", async () => {
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
const hrefSetter = vi.fn();
Object.defineProperty(window, "location", {
value: {
...window.location,
set href(value: string) {
hrefSetter(value);
},
},
writable: true,
});
vi.mocked(global.fetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "/api/orchestrators") {
return {
ok: true,
json: async () => ({
orchestrator: { id: "my-app-orchestrator", projectId: "my-app" },
}),
} as Response;
}
return { ok: true, json: async () => ({}), text: async () => "" } as Response;
});
render(
<SessionDetail
session={makeSession({
id: "my-app-orchestrator",
projectId: "my-app",
status: "working",
activity: "active",
summary: "Project orchestrator",
})}
isOrchestrator
orchestratorZones={{
merge: 0,
respond: 0,
review: 0,
pending: 0,
working: 0,
done: 0,
}}
projectOrchestratorId="my-app-orchestrator"
projects={[{ id: "my-app", name: "My App", path: "/tmp/my-app" }]}
/>,
);
const relaunchBtn = within(screen.getByRole("banner")).getByRole("button", {
name: /launch orchestrator \(clean context\)/i,
});
fireEvent.click(relaunchBtn);
expect(confirmSpy).toHaveBeenCalled();
await act(async () => {});
expect(global.fetch).toHaveBeenCalledWith("/api/orchestrators", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId: "my-app", clean: true }),
});
expect(hrefSetter).toHaveBeenCalledWith("/projects/my-app/sessions/my-app-orchestrator");
confirmSpy.mockRestore();
});
it("keeps Relaunch (clean) visible on terminated orchestrator sessions", () => {
render(
<SessionDetail
session={makeSession({
id: "my-app-orchestrator",
projectId: "my-app",
status: "terminated",
activity: "exited",
summary: "Project orchestrator",
pr: null,
})}
isOrchestrator
orchestratorZones={{ merge: 0, respond: 0, review: 0, pending: 0, working: 0, done: 0 }}
projectOrchestratorId="my-app-orchestrator"
projects={[{ id: "my-app", name: "My App", path: "/tmp/my-app" }]}
/>,
);
expect(
within(screen.getByRole("banner")).getByRole("button", {
name: /launch orchestrator \(clean context\)/i,
}),
).toBeInTheDocument();
});
it("surfaces a relaunch error banner when POST fails after confirm", async () => {
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
vi.mocked(global.fetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "/api/orchestrators") {
return {
ok: false,
status: 500,
json: async () => ({ error: "kill+respawn failed" }),
text: async () => "kill+respawn failed",
} as Response;
}
return { ok: true, json: async () => ({}), text: async () => "" } as Response;
});
render(
<SessionDetail
session={makeSession({
id: "my-app-orchestrator",
projectId: "my-app",
status: "working",
activity: "active",
summary: "Project orchestrator",
})}
isOrchestrator
orchestratorZones={{ merge: 0, respond: 0, review: 0, pending: 0, working: 0, done: 0 }}
projectOrchestratorId="my-app-orchestrator"
projects={[{ id: "my-app", name: "My App", path: "/tmp/my-app" }]}
/>,
);
fireEvent.click(
within(screen.getByRole("banner")).getByRole("button", {
name: /launch orchestrator \(clean context\)/i,
}),
);
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent(/kill\+respawn failed/i);
expect(alert).toHaveTextContent(/previous orchestrator may already be terminated/i);
fireEvent.click(within(alert).getByRole("button", { name: "Dismiss" }));
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
confirmSpy.mockRestore();
});
it("does not render Relaunch (clean) on worker sessions", () => {
render(
<SessionDetail
session={makeSession({
id: "worker-1",
projectId: "my-app",
status: "working",
})}
projects={[{ id: "my-app", name: "My App", path: "/tmp/my-app" }]}
/>,
);
expect(
screen.queryByRole("button", { name: /launch orchestrator \(clean context\)/i }),
).not.toBeInTheDocument();
});
it("restores without using router refresh on the client-only session page", async () => {
render(
<SessionDetail
@ -328,7 +483,7 @@ describe("SessionDetail desktop layout", () => {
expect(routerRefreshMock).not.toHaveBeenCalled();
});
it("keeps the desktop orchestrator button on orchestrator session pages", () => {
it("hides the desktop orchestrator button on orchestrator session pages", () => {
render(
<SessionDetail
session={makeSession({
@ -350,8 +505,8 @@ describe("SessionDetail desktop layout", () => {
);
expect(
within(screen.getByRole("banner")).getByRole("link", { name: "Orchestrator" }),
).toHaveAttribute("href", "/projects/my-app/sessions/my-app-orchestrator");
within(screen.getByRole("banner")).queryByRole("link", { name: "Orchestrator" }),
).not.toBeInTheDocument();
expect(screen.getByText("orchestrator")).toBeInTheDocument();
});
@ -402,8 +557,8 @@ describe("SessionDetail desktop layout", () => {
);
expect(
within(screen.getByRole("banner")).getByRole("link", { name: "Orchestrator" }),
).toHaveAttribute("href", "/projects/my-app/sessions/my-app-orchestrator");
within(screen.getByRole("banner")).queryByRole("link", { name: "Orchestrator" }),
).not.toBeInTheDocument();
});
it("routes to the project orchestrator after killing a worker session", async () => {

View File

@ -1,39 +0,0 @@
import {
isOrchestratorSession,
isTerminalSession,
type Session,
} from "@aoagents/ao-core/types";
import type { Orchestrator } from "@/components/OrchestratorSelector";
export function getOrchestratorSessionId(project: { sessionPrefix: string }): string {
return `${project.sessionPrefix}-orchestrator`;
}
/**
* Filter and map sessions to orchestrator DTOs.
* Shared between page.tsx and API route to ensure consistent orchestrator listing.
*/
export function mapSessionsToOrchestrators(
sessions: Session[],
sessionPrefix: string,
projectName: string,
allSessionPrefixes?: string[],
): Orchestrator[] {
const canonicalId = getOrchestratorSessionId({ sessionPrefix });
return sessions
.filter((s) => isOrchestratorSession(s, sessionPrefix, allSessionPrefixes) && !isTerminalSession(s))
.sort((a, b) => {
if (a.id === canonicalId) return -1;
if (b.id === canonicalId) return 1;
return (b.lastActivityAt?.getTime() ?? 0) - (a.lastActivityAt?.getTime() ?? 0);
})
.map((s) => ({
id: s.id,
projectId: s.projectId,
projectName,
status: s.status,
activity: s.activity,
createdAt: s.createdAt?.toISOString() ?? null,
lastActivityAt: s.lastActivityAt?.toISOString() ?? null,
}));
}

View File

@ -253,7 +253,7 @@ Step 4 (the JSONL entry fallback) is mandatory. Skipping it means `getActivitySt
|---|---|---|
| `DEFAULT_ACTIVE_WINDOW_MS` | 30 seconds | Activity newer than this is `active`; older is `ready` |
| `DEFAULT_READY_THRESHOLD_MS` | 5 minutes | `ready` sessions older than this become `idle` |
| `ACTIVITY_INPUT_STALENESS_MS` | 5 minutes | `waiting_input` / `blocked` JSONL entries expire after this duration |
| `ACTIVITY_INPUT_STALENESS_MS` | 5 minutes | Deprecated compatibility export. `waiting_input` / `blocked` entries no longer expire by wallclock; they persist until process death or a newer entry overrides them. |
---

View File

@ -468,7 +468,7 @@ All utilities are exported from `@aoagents/ao-core`. The source lives in `packag
|--------|-------|
| `DEFAULT_READY_THRESHOLD_MS` | `300_000` (5 min) — ready → idle threshold |
| `DEFAULT_ACTIVE_WINDOW_MS` | `30_000` (30 s) — active → ready window |
| `ACTIVITY_INPUT_STALENESS_MS` | `300_000` (5 min) — waiting_input/blocked expiry |
| `ACTIVITY_INPUT_STALENESS_MS` | Deprecated compatibility export (`300_000`); waiting_input/blocked no longer expire by wallclock |
| `PREFERRED_GH_PATH` | `/usr/local/bin/gh` |
| `CI_STATUS` | `{ PENDING, PASSING, FAILING, NONE }` |
| `ACTIVITY_STATE` | `{ ACTIVE, READY, IDLE, WAITING_INPUT, BLOCKED, EXITED }` |