Fix root dashboard project selection (#1480)
* style(design): strengthen dashboard hierarchy
* fix(web): prefer current repo dashboard project
* Revert "style(design): strengthen dashboard hierarchy"
This reverts commit 5db28280e1.
* Fix root dashboard project selection
* fix(web): bound session loading and reduce dashboard polling
* Fix project-name test temp path and reuse loaded config
* fix(web): clear recovered errors and restore fresh detail polling
* fix: stabilize dashboard session handling
* Fix ambiguous project-name fallback discovery
* Fix dashboard session selection regressions
* Remove unused orchestrator session imports
This commit is contained in:
parent
b086908f60
commit
0538e07b65
|
|
@ -1507,10 +1507,6 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
throw new Error(`Agent plugin '${selection.agentName}' not found`);
|
||||
}
|
||||
|
||||
const orchestratorSessionStrategy = normalizeOrchestratorSessionStrategy(
|
||||
project.orchestratorSessionStrategy,
|
||||
);
|
||||
|
||||
// Get the sessions directory for this project
|
||||
const sessionsDir = getProjectSessionsDir(project);
|
||||
|
||||
|
|
@ -1520,7 +1516,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
validateAndStoreOrigin(config.configPath, project.storageKey!);
|
||||
}
|
||||
|
||||
// Reserve a new unique orchestrator identity (e.g. {prefix}-orchestrator-1, -2, …).
|
||||
const orchestratorSessionStrategy = normalizeOrchestratorSessionStrategy(
|
||||
project.orchestratorSessionStrategy,
|
||||
);
|
||||
|
||||
// Reserve a new unique orchestrator identity (e.g. {prefix}-orchestrator-1, -2, ...).
|
||||
// Each spawnOrchestrator call gets its own numbered session and isolated worktree.
|
||||
const identity = reserveNextOrchestratorIdentity(project, sessionsDir);
|
||||
const sessionId = identity.sessionId;
|
||||
|
|
|
|||
|
|
@ -211,6 +211,7 @@ vi.mock("@/lib/services", () => ({
|
|||
// ── Import routes after mocking ───────────────────────────────────────
|
||||
|
||||
import { GET as sessionsGET } from "@/app/api/sessions/route";
|
||||
import { GET as sessionDetailGET } from "@/app/api/sessions/[id]/route";
|
||||
import { POST as orchestratorsPOST, GET as orchestratorsGET } 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";
|
||||
|
|
@ -683,6 +684,32 @@ describe("API Routes", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("GET /api/sessions/[id]", () => {
|
||||
it("returns partial session data when metadata and PR enrichment stall", async () => {
|
||||
const metadataSpy = vi
|
||||
.spyOn(serialize, "enrichSessionsMetadata")
|
||||
.mockImplementation(() => new Promise<void>(() => {}));
|
||||
const prSpy = vi
|
||||
.spyOn(serialize, "enrichSessionPR")
|
||||
.mockImplementation(() => new Promise<boolean>(() => {}));
|
||||
|
||||
const responsePromise = sessionDetailGET(
|
||||
makeRequest("http://localhost:3000/api/sessions/backend-7"),
|
||||
{ params: Promise.resolve({ id: "backend-7" }) },
|
||||
);
|
||||
|
||||
const res = await responsePromise;
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.id).toBe("backend-7");
|
||||
expect(data.projectId).toBe("my-app");
|
||||
|
||||
metadataSpy.mockRestore();
|
||||
prSpy.mockRestore();
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
describe("GET /api/runtime/terminal", () => {
|
||||
function withEnv(overrides: Record<string, string | undefined>, fn: () => Promise<void>) {
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
|
|
|
|||
|
|
@ -204,18 +204,71 @@ describe("SessionCard", () => {
|
|||
expect(screen.getByText("feat/cool-thing")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render lifecycle guidance as a pill on kanban cards", () => {
|
||||
const session = makeSession({
|
||||
lifecycle: {
|
||||
sessionState: "detecting",
|
||||
sessionReason: "runtime_lost",
|
||||
prState: "none",
|
||||
prReason: "not_created",
|
||||
runtimeState: "missing",
|
||||
runtimeReason: "tmux_missing",
|
||||
session: {
|
||||
state: "detecting",
|
||||
reason: "runtime_lost",
|
||||
label: "detecting",
|
||||
reasonLabel: "runtime lost",
|
||||
startedAt: new Date().toISOString(),
|
||||
completedAt: null,
|
||||
terminatedAt: null,
|
||||
lastTransitionAt: new Date().toISOString(),
|
||||
},
|
||||
pr: {
|
||||
state: "none",
|
||||
reason: "not_created",
|
||||
label: "not created",
|
||||
reasonLabel: "not created",
|
||||
number: null,
|
||||
url: null,
|
||||
lastObservedAt: null,
|
||||
},
|
||||
runtime: {
|
||||
state: "missing",
|
||||
reason: "tmux_missing",
|
||||
label: "missing",
|
||||
reasonLabel: "tmux missing",
|
||||
lastObservedAt: new Date().toISOString(),
|
||||
},
|
||||
legacyStatus: "detecting",
|
||||
evidence: null,
|
||||
detectingAttempts: 1,
|
||||
detectingEscalatedAt: null,
|
||||
summary: "Detecting runtime truth (runtime lost)",
|
||||
guidance: "Checking runtime and process evidence now.",
|
||||
},
|
||||
});
|
||||
|
||||
render(<SessionCard session={session} />);
|
||||
expect(
|
||||
screen.queryByText("Checking runtime and process evidence now."),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders terminal link", () => {
|
||||
const session = makeSession({ id: "backend-5" });
|
||||
render(<SessionCard session={session} />);
|
||||
const link = screen.getByText("terminal");
|
||||
expect(link).toHaveAttribute("href", "/projects/my-app/sessions/backend-5#session-terminal-section");
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
"/projects/my-app/sessions/backend-5#session-terminal-section",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows restore button when agent has exited", () => {
|
||||
const session = makeSession({ activity: "exited" });
|
||||
render(<SessionCard session={session} />);
|
||||
// Header shows compact "restore"; expanded panel shows "restore session"
|
||||
expect(screen.getByText("restore")).toBeInTheDocument();
|
||||
expect(screen.getByText("restore")).toHaveClass("session-card__restore-control");
|
||||
});
|
||||
|
||||
it("does not show restore button when agent is active", () => {
|
||||
|
|
@ -274,7 +327,11 @@ describe("SessionCard", () => {
|
|||
state: "open",
|
||||
ciStatus: "passing",
|
||||
ciChecks: [
|
||||
{ name: "lint-and-type-checks", status: "passed", url: "https://github.com/owner/repo/runs/111" },
|
||||
{
|
||||
name: "lint-and-type-checks",
|
||||
status: "passed",
|
||||
url: "https://github.com/owner/repo/runs/111",
|
||||
},
|
||||
{ name: "tests", status: "passed", url: "https://github.com/owner/repo/runs/222" },
|
||||
{ name: "no-url-check", status: "passed" },
|
||||
],
|
||||
|
|
@ -524,6 +581,29 @@ describe("SessionCard", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("hides quick reply presets for terminal sessions", () => {
|
||||
const session = makeSession({
|
||||
id: "respond-ended",
|
||||
status: "terminated",
|
||||
activity: "exited",
|
||||
summary: "Need approval to proceed",
|
||||
});
|
||||
|
||||
render(<SessionCard session={session} />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Continue" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Abort" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Skip" })).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("textbox", { name: /type a reply to the agent/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /view current context/i })).toHaveAttribute(
|
||||
"href",
|
||||
"/projects/my-app/sessions/respond-ended",
|
||||
);
|
||||
expect(screen.getByText("restore")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("prevents duplicate enter submits and only clears the textarea after send settles", async () => {
|
||||
let resolveSend: (() => void) | null = null;
|
||||
const onSend = vi.fn(
|
||||
|
|
@ -587,7 +667,9 @@ describe("SessionCard", () => {
|
|||
expect(screen.getByRole("textbox", { name: /type a reply to the agent/i })).toHaveValue(
|
||||
"please continue",
|
||||
);
|
||||
expect(screen.getByRole("textbox", { name: /type a reply to the agent/i })).not.toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: /type a reply to the agent/i }),
|
||||
).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const SESSION_EVENTS_POLL_INTERVAL_MS = 2000;
|
||||
const SESSION_EVENTS_POLL_INTERVAL_MS = 5000;
|
||||
|
||||
/**
|
||||
* GET /api/events — SSE stream for real-time lifecycle events
|
||||
|
|
@ -28,6 +28,7 @@ export async function GET(request: Request): Promise<Response> {
|
|||
let updates: ReturnType<typeof setInterval> | undefined;
|
||||
let observerProjectId: string | undefined;
|
||||
let observer: ProjectObserver | null = null;
|
||||
let streamClosed = false;
|
||||
|
||||
const ensureObserver = (config: ServicesConfig): ProjectObserver | null => {
|
||||
if (!observerProjectId) {
|
||||
|
|
@ -44,8 +45,28 @@ export async function GET(request: Request): Promise<Response> {
|
|||
return observer;
|
||||
};
|
||||
|
||||
const stopStream = () => {
|
||||
if (streamClosed) return;
|
||||
streamClosed = true;
|
||||
if (heartbeat) clearInterval(heartbeat);
|
||||
if (updates) clearInterval(updates);
|
||||
};
|
||||
|
||||
const encodeEvent = (payload: unknown) => encoder.encode(`data: ${JSON.stringify(payload)}\n\n`);
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const safeEnqueue = (payload: unknown): boolean => {
|
||||
if (streamClosed) return false;
|
||||
try {
|
||||
controller.enqueue(encodeEvent(payload));
|
||||
return true;
|
||||
} catch {
|
||||
stopStream();
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const { config } = await getServices();
|
||||
|
|
@ -96,7 +117,7 @@ export async function GET(request: Request): Promise<Response> {
|
|||
lastActivityAt: s.lastActivityAt,
|
||||
})),
|
||||
};
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(initialEvent)}\n\n`));
|
||||
safeEnqueue(initialEvent);
|
||||
if (projectObserver && observerProjectId) {
|
||||
projectObserver.recordOperation({
|
||||
metric: "sse_snapshot",
|
||||
|
|
@ -108,23 +129,23 @@ export async function GET(request: Request): Promise<Response> {
|
|||
level: "info",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// If services aren't available, send empty snapshot
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ type: "snapshot", correlationId, emittedAt: new Date().toISOString(), sessions: [] })}\n\n`,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
safeEnqueue({
|
||||
type: "error",
|
||||
correlationId,
|
||||
emittedAt: new Date().toISOString(),
|
||||
error: error instanceof Error ? error.message : "Failed to load live dashboard data",
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
// Send periodic heartbeat
|
||||
heartbeat = setInterval(() => {
|
||||
if (streamClosed) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`: heartbeat\n\n`));
|
||||
} catch {
|
||||
clearInterval(heartbeat);
|
||||
clearInterval(updates);
|
||||
stopStream();
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
|
|
@ -172,7 +193,7 @@ export async function GET(request: Request): Promise<Response> {
|
|||
lastActivityAt: s.lastActivityAt,
|
||||
})),
|
||||
};
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
||||
safeEnqueue(event);
|
||||
if (projectObserver && observerProjectId) {
|
||||
projectObserver.recordOperation({
|
||||
metric: "sse_snapshot",
|
||||
|
|
@ -184,21 +205,27 @@ export async function GET(request: Request): Promise<Response> {
|
|||
level: "info",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// enqueue failure means the stream is closed — clean up both intervals
|
||||
clearInterval(updates);
|
||||
clearInterval(heartbeat);
|
||||
} catch (error) {
|
||||
safeEnqueue({
|
||||
type: "error",
|
||||
correlationId,
|
||||
emittedAt: new Date().toISOString(),
|
||||
error: error instanceof Error ? error.message : "Live dashboard update failed",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Transient service error — skip this poll, retry on next interval
|
||||
return;
|
||||
} catch (error) {
|
||||
safeEnqueue({
|
||||
type: "error",
|
||||
correlationId,
|
||||
emittedAt: new Date().toISOString(),
|
||||
error: error instanceof Error ? error.message : "Live dashboard update failed",
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, SESSION_EVENTS_POLL_INTERVAL_MS);
|
||||
},
|
||||
cancel() {
|
||||
clearInterval(heartbeat);
|
||||
clearInterval(updates);
|
||||
stopStream();
|
||||
void (async () => {
|
||||
try {
|
||||
const { config } = await getServices();
|
||||
|
|
|
|||
|
|
@ -7,8 +7,14 @@ import {
|
|||
enrichSessionPR,
|
||||
enrichSessionsMetadata,
|
||||
} from "@/lib/serialize";
|
||||
import { settlesWithin } from "@/lib/async-utils";
|
||||
import { getCorrelationId, jsonWithCorrelation, recordApiObservation } from "@/lib/observability";
|
||||
|
||||
const AGENT_REPORT_AUDIT_TIMEOUT_MS = 1000;
|
||||
const METADATA_ENRICH_TIMEOUT_MS = 3000;
|
||||
const PR_CACHE_ENRICH_TIMEOUT_MS = 1000;
|
||||
const PR_LIVE_ENRICH_TIMEOUT_MS = 2000;
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const correlationId = getCorrelationId(_request);
|
||||
const startedAt = Date.now();
|
||||
|
|
@ -23,27 +29,39 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
|
|||
|
||||
const dashboardSession = sessionToDashboard(coreSession);
|
||||
const project = resolveProject(coreSession, config.projects);
|
||||
if (project) {
|
||||
if (project?.storageKey) {
|
||||
const sessionsDir = getSessionsDir(project.storageKey);
|
||||
dashboardSession.agentReportAudit = await readAgentReportAuditTrailAsync(
|
||||
sessionsDir,
|
||||
coreSession.id,
|
||||
);
|
||||
const auditPromise = readAgentReportAuditTrailAsync(sessionsDir, coreSession.id).then((audit) => {
|
||||
dashboardSession.agentReportAudit = audit;
|
||||
});
|
||||
await settlesWithin(auditPromise, AGENT_REPORT_AUDIT_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
// Enrich metadata (issue labels, agent summaries, issue titles)
|
||||
await enrichSessionsMetadata([coreSession], [dashboardSession], config, registry);
|
||||
await settlesWithin(
|
||||
enrichSessionsMetadata([coreSession], [dashboardSession], config, registry),
|
||||
METADATA_ENRICH_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
// Enrich PR — serve cache immediately, refresh in background if stale
|
||||
if (coreSession.pr) {
|
||||
const scm = getSCM(registry, project);
|
||||
if (scm) {
|
||||
const cached = await enrichSessionPR(dashboardSession, scm, coreSession.pr, {
|
||||
cacheOnly: true,
|
||||
});
|
||||
let cached = false;
|
||||
const cachedSettled = await settlesWithin(
|
||||
enrichSessionPR(dashboardSession, scm, coreSession.pr, {
|
||||
cacheOnly: true,
|
||||
}).then((result) => {
|
||||
cached = result;
|
||||
}),
|
||||
PR_CACHE_ENRICH_TIMEOUT_MS,
|
||||
);
|
||||
if (!cached) {
|
||||
// Nothing cached yet — block once to populate, then future calls use cache
|
||||
await enrichSessionPR(dashboardSession, scm, coreSession.pr);
|
||||
await settlesWithin(
|
||||
enrichSessionPR(dashboardSession, scm, coreSession.pr),
|
||||
cachedSettled ? PR_LIVE_ENRICH_TIMEOUT_MS : PR_CACHE_ENRICH_TIMEOUT_MS + PR_LIVE_ENRICH_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@
|
|||
|
||||
/* ── Light mode (default) ─────────────────────────────────────────── */
|
||||
:root {
|
||||
--font-geist-sans:
|
||||
"SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||
--font-jetbrains-mono: "SF Mono", "Menlo", "Consolas", "Liberation Mono", monospace;
|
||||
|
||||
/* Base surfaces — design-system light palette */
|
||||
--color-bg-base: #f5f3f0;
|
||||
--color-bg-surface: #ffffff;
|
||||
|
|
@ -160,16 +164,16 @@
|
|||
|
||||
/* Alert colors */
|
||||
--color-alert-ci: #dc2626;
|
||||
--color-alert-ci-bg: rgba(220,38,38,0.06);
|
||||
--color-alert-ci-bg: rgba(220, 38, 38, 0.06);
|
||||
--color-alert-ci-unknown: #b8860b;
|
||||
--color-alert-review: #2563eb;
|
||||
--color-alert-review-bg: rgba(37,99,235,0.06);
|
||||
--color-alert-review-bg: rgba(37, 99, 235, 0.06);
|
||||
--color-alert-changes: #d97706;
|
||||
--color-alert-changes-bg: rgba(217,119,6,0.06);
|
||||
--color-alert-changes-bg: rgba(217, 119, 6, 0.06);
|
||||
--color-alert-conflict: #ca8a04;
|
||||
--color-alert-conflict-bg: rgba(202,138,4,0.06);
|
||||
--color-alert-conflict-bg: rgba(202, 138, 4, 0.06);
|
||||
--color-alert-comment: #7c3aed;
|
||||
--color-alert-comment-bg: rgba(124,58,237,0.06);
|
||||
--color-alert-comment-bg: rgba(124, 58, 237, 0.06);
|
||||
}
|
||||
|
||||
/* ── Dark mode ────────────────────────────────────────────────────── */
|
||||
|
|
@ -306,16 +310,16 @@
|
|||
|
||||
/* Alert colors — warm-tinted */
|
||||
--color-alert-ci: #ef4444;
|
||||
--color-alert-ci-bg: rgba(239,68,68,0.08);
|
||||
--color-alert-ci-bg: rgba(239, 68, 68, 0.08);
|
||||
--color-alert-ci-unknown: #e2a336;
|
||||
--color-alert-review: #60a5fa;
|
||||
--color-alert-review-bg: rgba(96,165,250,0.08);
|
||||
--color-alert-review-bg: rgba(96, 165, 250, 0.08);
|
||||
--color-alert-changes: #f97316;
|
||||
--color-alert-changes-bg: rgba(249,115,22,0.08);
|
||||
--color-alert-changes-bg: rgba(249, 115, 22, 0.08);
|
||||
--color-alert-conflict: #eab308;
|
||||
--color-alert-conflict-bg: rgba(234,179,8,0.08);
|
||||
--color-alert-conflict-bg: rgba(234, 179, 8, 0.08);
|
||||
--color-alert-comment: #a78bfa;
|
||||
--color-alert-comment-bg: rgba(167,139,250,0.08);
|
||||
--color-alert-comment-bg: rgba(167, 139, 250, 0.08);
|
||||
}
|
||||
|
||||
@theme {
|
||||
|
|
@ -535,6 +539,252 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
font-size: 13px;
|
||||
}
|
||||
|
||||
.session-ended-summary {
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: clamp(24px, 5vw, 56px);
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 50% 22%,
|
||||
color-mix(in srgb, var(--color-accent-amber) 10%, transparent),
|
||||
transparent 30%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
var(--color-bg-inset),
|
||||
color-mix(in srgb, var(--color-bg-base) 94%, black)
|
||||
);
|
||||
}
|
||||
|
||||
.session-ended-summary__panel {
|
||||
width: min(760px, 100%);
|
||||
border: 1px solid color-mix(in srgb, var(--color-border-default) 88%, transparent);
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--color-bg-elevated) 92%, transparent),
|
||||
var(--color-bg-surface)
|
||||
);
|
||||
box-shadow: var(--detail-card-shadow);
|
||||
padding: clamp(22px, 4vw, 34px);
|
||||
}
|
||||
|
||||
.session-ended-summary__eyebrow {
|
||||
width: fit-content;
|
||||
margin-bottom: 18px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent-amber) 30%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-accent-amber) 9%, transparent);
|
||||
color: var(--color-accent-amber);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.18em;
|
||||
line-height: 1;
|
||||
padding: 7px 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.session-ended-summary__header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.session-ended-summary__icon {
|
||||
display: flex;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent-amber) 24%, transparent);
|
||||
border-radius: 16px;
|
||||
background: color-mix(in srgb, var(--color-accent-amber) 8%, var(--color-bg-elevated));
|
||||
color: var(--color-accent-amber);
|
||||
}
|
||||
|
||||
.session-ended-summary__icon svg {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.session-ended-summary__title-group {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-ended-summary__title {
|
||||
margin: 0;
|
||||
color: var(--color-text-primary);
|
||||
font-size: clamp(24px, 3vw, 34px);
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.045em;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.session-ended-summary__subtitle {
|
||||
max-width: 560px;
|
||||
margin: 10px 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.session-ended-summary__body {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.session-ended-summary__section {
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: 16px;
|
||||
background: color-mix(in srgb, var(--color-bg-elevated) 72%, transparent);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.session-ended-summary__label {
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.session-ended-summary__copy {
|
||||
margin: 0;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.session-ended-summary__facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.session-ended-summary__fact {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--color-bg-elevated) 58%, transparent);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.session-ended-summary__fact span {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--color-text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.session-ended-summary__fact strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-ended-summary__links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.session-ended-summary__primary,
|
||||
.session-ended-summary__secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 36px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
padding: 0 15px;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
transform 120ms ease,
|
||||
border-color 120ms ease,
|
||||
background 120ms ease;
|
||||
}
|
||||
|
||||
.session-ended-summary__primary {
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent-amber) 60%, transparent);
|
||||
background: var(--color-accent-amber);
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
|
||||
.session-ended-summary__secondary {
|
||||
border: 1px solid var(--color-border-default);
|
||||
background: color-mix(in srgb, var(--color-bg-elevated) 80%, transparent);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.session-ended-summary__primary:hover,
|
||||
.session-ended-summary__secondary:hover {
|
||||
transform: translateY(-1px);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.session-ended-summary__evidence {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
border-top: 1px solid var(--color-border-subtle);
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.session-ended-summary__evidence span {
|
||||
color: var(--color-text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.session-ended-summary__evidence code {
|
||||
color: var(--color-text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.session-ended-summary {
|
||||
align-items: flex-start;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.session-ended-summary__panel {
|
||||
border-radius: 18px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.session-ended-summary__header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.session-ended-summary__facts {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.session-ended-summary__facts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Empty State + Ghost Kanban ──────────────────────────────────────── */
|
||||
|
||||
.board-wrapper {
|
||||
|
|
@ -660,7 +910,10 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background 100ms ease, border-color 100ms ease, color 100ms ease,
|
||||
transition:
|
||||
background 100ms ease,
|
||||
border-color 100ms ease,
|
||||
color 100ms ease,
|
||||
transform 120ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
animation: empty-state-enter 280ms cubic-bezier(0.23, 1, 0.32, 1) 135ms both;
|
||||
}
|
||||
|
|
@ -896,7 +1149,9 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 100ms, color 100ms;
|
||||
transition:
|
||||
background 100ms,
|
||||
color 100ms;
|
||||
}
|
||||
|
||||
.dashboard-app-sidebar-toggle:hover {
|
||||
|
|
@ -977,6 +1232,23 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.topbar-action-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dashboard-app-btn--restore {
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.dashboard-app-btn--restore:hover {
|
||||
background: color-mix(in srgb, var(--color-accent-amber) 4%, transparent);
|
||||
border-color: var(--color-accent-amber-border);
|
||||
color: var(--color-accent-amber);
|
||||
}
|
||||
|
||||
.dashboard-app-btn--amber {
|
||||
border-color: var(--color-accent-amber-border);
|
||||
background: var(--color-accent-amber-dim);
|
||||
|
|
@ -1020,12 +1292,22 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
color: var(--color-text-secondary);
|
||||
}
|
||||
/* State-specific tint backgrounds */
|
||||
.topbar-status-pill--active { background: color-mix(in srgb, var(--color-status-working) 10%, transparent); }
|
||||
.topbar-status-pill--ready { background: color-mix(in srgb, var(--color-status-ready) 10%, transparent); }
|
||||
.topbar-status-pill--idle { background: color-mix(in srgb, var(--color-text-tertiary) 10%, transparent); }
|
||||
.topbar-status-pill--waiting-for-input { background: color-mix(in srgb, var(--color-status-attention) 10%, transparent); }
|
||||
.topbar-status-pill--active {
|
||||
background: color-mix(in srgb, var(--color-status-working) 10%, transparent);
|
||||
}
|
||||
.topbar-status-pill--ready {
|
||||
background: color-mix(in srgb, var(--color-status-ready) 10%, transparent);
|
||||
}
|
||||
.topbar-status-pill--idle {
|
||||
background: color-mix(in srgb, var(--color-text-tertiary) 10%, transparent);
|
||||
}
|
||||
.topbar-status-pill--waiting-for-input {
|
||||
background: color-mix(in srgb, var(--color-status-attention) 10%, transparent);
|
||||
}
|
||||
.topbar-status-pill--blocked,
|
||||
.topbar-status-pill--exited { background: color-mix(in srgb, var(--color-status-error) 10%, transparent); }
|
||||
.topbar-status-pill--exited {
|
||||
background: color-mix(in srgb, var(--color-status-error) 10%, transparent);
|
||||
}
|
||||
|
||||
.topbar-status-pill__dot {
|
||||
width: 6px;
|
||||
|
|
@ -1033,7 +1315,9 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.topbar-status-pill__label { color: inherit; }
|
||||
.topbar-status-pill__label {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Branch pill — compact topbar variant */
|
||||
.topbar-branch-pill {
|
||||
|
|
@ -1050,8 +1334,12 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.topbar-branch-pill--link { color: var(--color-accent); }
|
||||
.topbar-branch-pill--link:hover { text-decoration: underline; }
|
||||
.topbar-branch-pill--link {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.topbar-branch-pill--link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Danger button variant (kill) */
|
||||
.dashboard-app-btn--danger {
|
||||
|
|
@ -1084,9 +1372,15 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.topbar-pr-dot--green { background: var(--color-status-ready); }
|
||||
.topbar-pr-dot--red { background: var(--color-status-error); }
|
||||
.topbar-pr-dot--amber { background: var(--color-status-attention); }
|
||||
.topbar-pr-dot--green {
|
||||
background: var(--color-status-ready);
|
||||
}
|
||||
.topbar-pr-dot--red {
|
||||
background: var(--color-status-error);
|
||||
}
|
||||
.topbar-pr-dot--amber {
|
||||
background: var(--color-status-attention);
|
||||
}
|
||||
|
||||
/* Popover panel */
|
||||
.topbar-pr-popover {
|
||||
|
|
@ -1107,7 +1401,9 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
|
||||
/* Session title hidden on mobile (pills move to second row instead) */
|
||||
@media (max-width: 640px) {
|
||||
.dashboard-app-header__session-title { display: none; }
|
||||
.dashboard-app-header__session-title {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: popover anchors from left edge (topbar is narrow) */
|
||||
|
|
@ -1211,7 +1507,10 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 100ms, color 100ms, border-color 100ms;
|
||||
transition:
|
||||
background 100ms,
|
||||
color 100ms,
|
||||
border-color 100ms;
|
||||
}
|
||||
|
||||
.project-sidebar__footer-btn:hover {
|
||||
|
|
@ -2032,8 +2331,16 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
|
||||
.session-detail-status-pill--error {
|
||||
color: var(--color-status-error, var(--color-accent-red));
|
||||
border-color: color-mix(in srgb, var(--color-status-error, var(--color-accent-red)) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--color-status-error, var(--color-accent-red)) 8%, transparent);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-status-error, var(--color-accent-red)) 20%,
|
||||
transparent
|
||||
);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--color-status-error, var(--color-accent-red)) 8%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.session-detail-status-pill--neutral {
|
||||
|
|
@ -2396,7 +2703,10 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
border-radius: 3px;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: color 80ms, border-color 80ms, background 80ms;
|
||||
transition:
|
||||
color 80ms,
|
||||
border-color 80ms,
|
||||
background 80ms;
|
||||
}
|
||||
|
||||
.card__pr:hover {
|
||||
|
|
@ -2473,11 +2783,26 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
transition: background 80ms;
|
||||
}
|
||||
|
||||
.alert-row--ci { --ar-color: var(--color-alert-ci); --ar-bg: var(--color-alert-ci-bg); }
|
||||
.alert-row--changes { --ar-color: var(--color-alert-changes); --ar-bg: var(--color-alert-changes-bg); }
|
||||
.alert-row--review { --ar-color: var(--color-alert-review); --ar-bg: var(--color-alert-review-bg); }
|
||||
.alert-row--conflict { --ar-color: var(--color-alert-conflict); --ar-bg: var(--color-alert-conflict-bg); }
|
||||
.alert-row--comment { --ar-color: var(--color-alert-comment); --ar-bg: var(--color-alert-comment-bg); }
|
||||
.alert-row--ci {
|
||||
--ar-color: var(--color-alert-ci);
|
||||
--ar-bg: var(--color-alert-ci-bg);
|
||||
}
|
||||
.alert-row--changes {
|
||||
--ar-color: var(--color-alert-changes);
|
||||
--ar-bg: var(--color-alert-changes-bg);
|
||||
}
|
||||
.alert-row--review {
|
||||
--ar-color: var(--color-alert-review);
|
||||
--ar-bg: var(--color-alert-review-bg);
|
||||
}
|
||||
.alert-row--conflict {
|
||||
--ar-color: var(--color-alert-conflict);
|
||||
--ar-bg: var(--color-alert-conflict-bg);
|
||||
}
|
||||
.alert-row--comment {
|
||||
--ar-color: var(--color-alert-comment);
|
||||
--ar-bg: var(--color-alert-comment-bg);
|
||||
}
|
||||
|
||||
.alert-row__icon {
|
||||
flex-shrink: 0;
|
||||
|
|
@ -2524,7 +2849,9 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
font-size: 9.5px;
|
||||
font-weight: 600;
|
||||
opacity: 0.85;
|
||||
transition: background 100ms, color 100ms;
|
||||
transition:
|
||||
background 100ms,
|
||||
color 100ms;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
text-transform: lowercase;
|
||||
|
|
@ -2658,6 +2985,23 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
text-decoration: none;
|
||||
}
|
||||
|
||||
.session-card__restore-control {
|
||||
font-size: 10.5px;
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
padding: 2px 8px 2px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border-default);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.session-card__restore-control:hover {
|
||||
background: color-mix(in srgb, var(--color-accent-amber) 4%, transparent);
|
||||
border-color: var(--color-accent-amber-border);
|
||||
color: var(--color-accent-amber);
|
||||
}
|
||||
|
||||
.btn--danger {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
|
|
@ -3106,14 +3450,24 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
|
||||
.session-detail-blocker-chip--fail {
|
||||
color: var(--color-status-error, var(--color-accent-red));
|
||||
background: color-mix(in srgb, var(--color-status-error, var(--color-accent-red)) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-status-error, var(--color-accent-red)) 18%, transparent);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--color-status-error, var(--color-accent-red)) 8%,
|
||||
transparent
|
||||
);
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--color-status-error, var(--color-accent-red)) 18%, transparent);
|
||||
}
|
||||
|
||||
.session-detail-blocker-chip--warn {
|
||||
color: var(--color-status-attention, var(--color-accent-yellow));
|
||||
background: color-mix(in srgb, var(--color-status-attention, var(--color-accent-yellow)) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-status-attention, var(--color-accent-yellow)) 18%, transparent);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--color-status-attention, var(--color-accent-yellow)) 8%,
|
||||
transparent
|
||||
);
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--color-status-attention, var(--color-accent-yellow)) 18%, transparent);
|
||||
}
|
||||
|
||||
.session-detail-blocker-chip--muted {
|
||||
|
|
@ -3146,14 +3500,24 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
|
||||
.session-detail-ci-chip--fail {
|
||||
color: var(--color-accent-red, var(--color-status-error));
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent-red, var(--color-status-error)) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--color-accent-red, var(--color-status-error)) 6%, transparent);
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--color-accent-red, var(--color-status-error)) 20%, transparent);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent-red, var(--color-status-error)) 6%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.session-detail-ci-chip--pending {
|
||||
color: var(--color-accent-yellow, var(--color-status-attention));
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent-yellow, var(--color-status-attention)) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--color-accent-yellow, var(--color-status-attention)) 6%, transparent);
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--color-accent-yellow, var(--color-status-attention)) 20%, transparent);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent-yellow, var(--color-status-attention)) 6%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.session-detail-ci-chip--queued {
|
||||
|
|
@ -3226,7 +3590,11 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
color: var(--color-accent-red, var(--color-status-error));
|
||||
background: color-mix(in srgb, var(--color-accent-red, var(--color-status-error)) 12%, transparent);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent-red, var(--color-status-error)) 12%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.session-detail-comments-strip__hint {
|
||||
|
|
@ -3322,14 +3690,20 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
.session-detail-comment__fix-btn {
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-accent-amber-border, color-mix(in srgb, var(--color-accent) 30%, transparent));
|
||||
background: var(--color-accent-amber-dim, color-mix(in srgb, var(--color-accent) 12%, transparent));
|
||||
border: 1px solid
|
||||
var(--color-accent-amber-border, color-mix(in srgb, var(--color-accent) 30%, transparent));
|
||||
background: var(
|
||||
--color-accent-amber-dim,
|
||||
color-mix(in srgb, var(--color-accent) 12%, transparent)
|
||||
);
|
||||
color: var(--color-accent-amber, var(--color-accent));
|
||||
font-family: var(--font-sans, inherit);
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 100ms, transform 100ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
transition:
|
||||
background 100ms,
|
||||
transform 100ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
.session-detail-comment__fix-btn:hover {
|
||||
|
|
@ -3371,7 +3745,11 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 100ms, border-color 100ms, color 100ms, transform 100ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
transition:
|
||||
background 100ms,
|
||||
border-color 100ms,
|
||||
color 100ms,
|
||||
transform 100ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
.session-detail-action-btn:hover {
|
||||
|
|
@ -3386,12 +3764,24 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
|
||||
.session-detail-action-btn--danger {
|
||||
color: var(--color-accent-red, var(--color-status-error));
|
||||
border-color: color-mix(in srgb, var(--color-accent-red, var(--color-status-error)) 22%, var(--color-border-default));
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent-red, var(--color-status-error)) 22%,
|
||||
var(--color-border-default)
|
||||
);
|
||||
}
|
||||
|
||||
.session-detail-action-btn--danger:hover {
|
||||
background: color-mix(in srgb, var(--color-accent-red, var(--color-status-error)) 8%, transparent);
|
||||
border-color: color-mix(in srgb, var(--color-accent-red, var(--color-status-error)) 40%, var(--color-border-default));
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent-red, var(--color-status-error)) 8%,
|
||||
transparent
|
||||
);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent-red, var(--color-status-error)) 40%,
|
||||
var(--color-border-default)
|
||||
);
|
||||
color: var(--color-accent-red, var(--color-status-error));
|
||||
}
|
||||
|
||||
|
|
@ -3470,7 +3860,9 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 100ms, color 100ms;
|
||||
transition:
|
||||
background 100ms,
|
||||
color 100ms;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
|
@ -3575,7 +3967,9 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
flex-shrink: 0;
|
||||
color: var(--color-text-muted);
|
||||
border-radius: 4px;
|
||||
transition: background 80ms, color 80ms;
|
||||
transition:
|
||||
background 80ms,
|
||||
color 80ms;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
|
@ -4016,9 +4410,15 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.project-sidebar__collapsed-session-btn[data-level="respond"] { color: var(--color-status-attention); }
|
||||
.project-sidebar__collapsed-session-btn[data-level="review"] { color: var(--color-status-review); }
|
||||
.project-sidebar__collapsed-session-btn[data-level="working"] { color: var(--color-status-active); }
|
||||
.project-sidebar__collapsed-session-btn[data-level="respond"] {
|
||||
color: var(--color-status-attention);
|
||||
}
|
||||
.project-sidebar__collapsed-session-btn[data-level="review"] {
|
||||
color: var(--color-status-review);
|
||||
}
|
||||
.project-sidebar__collapsed-session-btn[data-level="working"] {
|
||||
color: var(--color-status-active);
|
||||
}
|
||||
|
||||
.project-sidebar__session-abbr-first {
|
||||
font-size: 10px;
|
||||
|
|
@ -4620,9 +5020,12 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
/* ── Kanban board ────────────────────────────────────────────────────── */
|
||||
|
||||
.kanban-board {
|
||||
--kanban-column-count: 5;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(var(--kanban-column-count), minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: calc(100vh - 240px);
|
||||
height: calc(100dvh - 240px);
|
||||
overflow-x: auto;
|
||||
|
|
@ -4692,7 +5095,6 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
.kanban-column__header {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 10px 7px;
|
||||
|
|
@ -4911,7 +5313,9 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
color 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
|
@ -6266,6 +6670,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
.session-detail__terminal-full .direct-terminal,
|
||||
.session-detail__terminal-full .direct-terminal__container,
|
||||
.session-detail__terminal-full .terminal-exited-placeholder,
|
||||
.session-detail__terminal-full .session-ended-summary,
|
||||
.session-detail__terminal-full .session-detail-terminal-placeholder {
|
||||
height: 100% !important;
|
||||
min-height: 0;
|
||||
|
|
@ -6936,7 +7341,11 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
|
||||
.bottom-sheet__btn--danger:hover {
|
||||
background: color-mix(in srgb, var(--color-accent-red, var(--color-status-error)) 92%, black 8%);
|
||||
border-color: color-mix(in srgb, var(--color-accent-red, var(--color-status-error)) 92%, black 8%);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent-red, var(--color-status-error)) 92%,
|
||||
black 8%
|
||||
);
|
||||
}
|
||||
|
||||
.bottom-sheet__btn--primary {
|
||||
|
|
@ -7031,8 +7440,12 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
|
||||
/* Mobile: hide brand, icon-only buttons */
|
||||
@media (max-width: 640px) {
|
||||
.dashboard-app-header__brand--hide-mobile { display: none; }
|
||||
.topbar-btn-label { display: none; }
|
||||
.dashboard-app-header__brand--hide-mobile {
|
||||
display: none;
|
||||
}
|
||||
.topbar-btn-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile bottom nav is position:fixed so no padding-bottom needed on session-detail-page */
|
||||
|
|
@ -7068,7 +7481,10 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.terminal-chrome-bar { padding: 4px 8px; gap: 4px; }
|
||||
.terminal-chrome-bar {
|
||||
padding: 4px 8px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Compact pane label on mobile */
|
||||
.terminal-chrome-pane-label {
|
||||
|
|
@ -7094,8 +7510,15 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
.terminal-chrome-status-row {
|
||||
gap: 4px;
|
||||
}
|
||||
.terminal-chrome-status-row .rounded-full { width: 5px; height: 5px; min-width: 5px; min-height: 5px; }
|
||||
.terminal-chrome-status-row span { font-size: 8px !important; }
|
||||
.terminal-chrome-status-row .rounded-full {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
min-width: 5px;
|
||||
min-height: 5px;
|
||||
}
|
||||
.terminal-chrome-status-row span {
|
||||
font-size: 8px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Topbar session pills ──────────────────────────────────────────── */
|
||||
|
|
@ -7126,11 +7549,17 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
|
||||
/* topbar-mobile-only is the inverse of topbar-desktop-only; hidden on
|
||||
desktop, shown below the mobile breakpoint. */
|
||||
.topbar-mobile-only { display: none; }
|
||||
.topbar-mobile-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.topbar-desktop-only { display: none; }
|
||||
.topbar-mobile-only { display: inline; }
|
||||
.topbar-desktop-only {
|
||||
display: none;
|
||||
}
|
||||
.topbar-mobile-only {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Stack project name above pills in the same 48px topbar */
|
||||
.topbar-project-pills-group {
|
||||
|
|
|
|||
|
|
@ -4,11 +4,6 @@ vi.mock("@/lib/project-name", () => ({
|
|||
getProjectName: () => "Agent Orchestrator",
|
||||
}));
|
||||
|
||||
vi.mock("next/font/google", () => ({
|
||||
Geist: () => ({ variable: "--font-geist-sans" }),
|
||||
JetBrains_Mono: () => ({ variable: "--font-jetbrains-mono" }),
|
||||
}));
|
||||
|
||||
describe("app layout metadata", () => {
|
||||
it("exports the themed mobile viewport colors", async () => {
|
||||
const { viewport } = await import("./layout");
|
||||
|
|
|
|||
|
|
@ -1,24 +1,10 @@
|
|||
import type { Metadata, Viewport } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import { Geist, JetBrains_Mono } from "next/font/google";
|
||||
import { getProjectName } from "@/lib/project-name";
|
||||
import { ServiceWorkerRegistrar } from "@/components/ServiceWorkerRegistrar";
|
||||
import { Providers } from "@/app/providers";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-geist-sans",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-jetbrains-mono",
|
||||
display: "swap",
|
||||
weight: ["400", "500"],
|
||||
});
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
|
|
@ -47,11 +33,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`dark ${geistSans.variable} ${jetbrainsMono.variable}`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<html lang="en" className="dark" suppressHydrationWarning>
|
||||
<body className="h-screen overflow-hidden bg-[var(--color-bg-base)] text-[var(--color-text-primary)] antialiased">
|
||||
<Providers>{children}</Providers>
|
||||
<ServiceWorkerRegistrar />
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export async function generateMetadata(props: {
|
|||
projectName = project.name;
|
||||
}
|
||||
}
|
||||
return { title: { absolute: `ao | ${projectName} - Select Orchestrator` } };
|
||||
return { title: { absolute: `ao | ${projectName} - Orchestrator` } };
|
||||
}
|
||||
|
||||
export default async function OrchestratorsRoute(props: {
|
||||
|
|
@ -61,7 +61,12 @@ export default async function OrchestratorsRoute(props: {
|
|||
const allSessionPrefixes = Object.entries(config.projects).map(
|
||||
([, p]) => p.sessionPrefix ?? generateSessionPrefix(p.name ?? ""),
|
||||
);
|
||||
orchestrators = mapSessionsToOrchestrators(allSessions, sessionPrefix, project.name, allSessionPrefixes);
|
||||
orchestrators = mapSessionsToOrchestrators(
|
||||
allSessions,
|
||||
sessionPrefix,
|
||||
project.name,
|
||||
allSessionPrefixes,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Failed to load orchestrators";
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ import { ErrorDisplay } from "@/components/ErrorDisplay";
|
|||
|
||||
function getSessionErrorMessage(error: Error): string {
|
||||
const normalized = error.message.toLowerCase();
|
||||
if (normalized.includes("timed out")) {
|
||||
return "The session request did not complete in time. Check the backend process and try again once the API is responsive.";
|
||||
}
|
||||
if (normalized.includes("network")) {
|
||||
return "The session request failed before the dashboard got a response. Check the server connection and try again.";
|
||||
}
|
||||
|
|
@ -18,6 +21,9 @@ function getSessionErrorMessage(error: Error): string {
|
|||
if (normalized.includes("500")) {
|
||||
return "The server returned an internal error while loading this session. Try re-fetching the session data.";
|
||||
}
|
||||
if (error.message.trim().length > 0) {
|
||||
return error.message;
|
||||
}
|
||||
return "The dashboard could not load this session cleanly. Try again to re-fetch the latest state.";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,26 +1,23 @@
|
|||
import React, { type ReactNode } from "react";
|
||||
import { act, render, screen, cleanup } from "@testing-library/react";
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DashboardSession } from "@/lib/types";
|
||||
import type { SessionPatch } from "@/lib/mux-protocol";
|
||||
|
||||
const sessionDetailSpy = vi.fn();
|
||||
const notFoundError = new Error("NEXT_NOT_FOUND");
|
||||
const notFoundSpy = vi.fn(() => {
|
||||
throw notFoundError;
|
||||
});
|
||||
const replaceSpy = vi.fn();
|
||||
let mockPathname = "/projects/my-app/sessions/worker-1";
|
||||
let mockParams: Record<string, string> = { id: "worker-1" };
|
||||
const mockMuxState: {
|
||||
current?: { sessions: SessionPatch[]; status?: "connecting" | "connected" | "reconnecting" | "disconnected" };
|
||||
current?: {
|
||||
sessions: SessionPatch[];
|
||||
status?: "connecting" | "connected" | "reconnecting" | "disconnected";
|
||||
};
|
||||
} = {};
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useParams: () => mockParams,
|
||||
usePathname: () => mockPathname,
|
||||
useRouter: () => ({ replace: replaceSpy }),
|
||||
notFound: notFoundSpy,
|
||||
}));
|
||||
|
||||
vi.mock("@/providers/MuxProvider", () => ({
|
||||
|
|
@ -60,27 +57,6 @@ async function flushAsyncWork(): Promise<void> {
|
|||
});
|
||||
}
|
||||
|
||||
class TestErrorBoundary extends React.Component<
|
||||
{ children: ReactNode },
|
||||
{ error: Error | null }
|
||||
> {
|
||||
constructor(props: { children: ReactNode }) {
|
||||
super(props);
|
||||
this.state = { error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return <div data-testid="route-error">{this.state.error.message}</div>;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
describe("SessionPage project polling", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
|
@ -108,7 +84,6 @@ describe("SessionPage project polling", () => {
|
|||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
notFoundSpy.mockReset();
|
||||
});
|
||||
|
||||
it("resolves orchestrator nav once for non-orchestrator pages and skips repeated project polling", async () => {
|
||||
|
|
@ -168,16 +143,30 @@ describe("SessionPage project polling", () => {
|
|||
render(<SessionPage />);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith("/api/projects");
|
||||
expect(fetch).toHaveBeenCalledWith("/api/sessions/worker-1");
|
||||
expect(fetch).toHaveBeenCalledWith("/api/sessions?fresh=true");
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/projects",
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/sessions/worker-1",
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/sessions?fresh=true",
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith("/api/sessions?project=my-app&orchestratorOnly=true&fresh=true");
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/sessions?project=my-app&orchestratorOnly=true&fresh=true",
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
|
||||
expect(
|
||||
vi.mocked(fetch).mock.calls.filter(
|
||||
([url]) => url === "/api/sessions?project=my-app&orchestratorOnly=true&fresh=true",
|
||||
),
|
||||
vi
|
||||
.mocked(fetch)
|
||||
.mock.calls.filter(
|
||||
([url]) => url === "/api/sessions?project=my-app&orchestratorOnly=true&fresh=true",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
|
|
@ -186,9 +175,11 @@ describe("SessionPage project polling", () => {
|
|||
await flushAsyncWork();
|
||||
|
||||
expect(
|
||||
vi.mocked(fetch).mock.calls.filter(
|
||||
([url]) => url === "/api/sessions?project=my-app&orchestratorOnly=true&fresh=true",
|
||||
),
|
||||
vi
|
||||
.mocked(fetch)
|
||||
.mock.calls.filter(
|
||||
([url]) => url === "/api/sessions?project=my-app&orchestratorOnly=true&fresh=true",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
|
||||
expect(
|
||||
|
|
@ -261,10 +252,13 @@ describe("SessionPage project polling", () => {
|
|||
});
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith("/api/sessions?project=my-app&fresh=true");
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/sessions?project=my-app&fresh=true",
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes 404 responses through notFound()", async () => {
|
||||
it("renders an inline missing-session state instead of blanking the shell", async () => {
|
||||
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/projects") {
|
||||
|
|
@ -291,11 +285,12 @@ describe("SessionPage project polling", () => {
|
|||
render(<SessionPage />);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(notFoundSpy).toHaveBeenCalled();
|
||||
expect(screen.getByText("Session not found")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Toggle sidebar" })).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("session-detail")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("throws non-404 session fetch failures to the route error boundary", async () => {
|
||||
it("renders an inline error state instead of throwing the route away", async () => {
|
||||
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/projects") {
|
||||
|
|
@ -319,15 +314,96 @@ describe("SessionPage project polling", () => {
|
|||
|
||||
const { default: SessionPage } = await import("./page");
|
||||
|
||||
render(
|
||||
<TestErrorBoundary>
|
||||
<SessionPage />
|
||||
</TestErrorBoundary>,
|
||||
);
|
||||
|
||||
render(<SessionPage />);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(screen.getByTestId("route-error")).toHaveTextContent("HTTP 500");
|
||||
expect(screen.getByText("Failed to load session")).toBeInTheDocument();
|
||||
expect(screen.getByText(/internal error/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Toggle sidebar" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("times out a stuck session fetch and replaces the infinite loader with an error state", async () => {
|
||||
global.fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/projects") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ projects: [] }),
|
||||
} as Response);
|
||||
}
|
||||
|
||||
if (url === "/api/sessions/worker-1") {
|
||||
return new Promise<Response>((_, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (url === "/api/sessions?fresh=true") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ sessions: [] }),
|
||||
} as Response);
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
const { default: SessionPage } = await import("./page");
|
||||
|
||||
render(<SessionPage />);
|
||||
|
||||
expect(screen.getByText("Loading session…")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(8_000);
|
||||
});
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(screen.getByText("Failed to load session")).toBeInTheDocument();
|
||||
expect(screen.getByText(/taking too long/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText("Loading session…")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a recoverable unavailable state when the first session request aborts", async () => {
|
||||
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.reject(new DOMException("Aborted", "AbortError"));
|
||||
}
|
||||
|
||||
if (url === "/api/sessions?fresh=true") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ sessions: [] }),
|
||||
} as Response);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
|
||||
}) as typeof fetch;
|
||||
|
||||
const { default: SessionPage } = await import("./page");
|
||||
|
||||
render(<SessionPage />);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(screen.getByText("Session unavailable")).toBeInTheDocument();
|
||||
expect(screen.getByText(/backend has not returned this session yet/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText("Loading session…")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks sidebar data as loading until the sessions list resolves", async () => {
|
||||
|
|
@ -453,12 +529,69 @@ describe("SessionPage project polling", () => {
|
|||
render(<SessionPage />);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(([url]) => url === "/api/projects"),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(([url]) => url === "/api/sessions?fresh=true"),
|
||||
).toHaveLength(2);
|
||||
expect(fetchMock.mock.calls.filter(([url]) => url === "/api/projects")).toHaveLength(2);
|
||||
expect(fetchMock.mock.calls.filter(([url]) => url === "/api/sessions?fresh=true")).toHaveLength(
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it("silences aborted sidebar refreshes during unmount", async () => {
|
||||
const workerSession = makeWorkerSession();
|
||||
const consoleErrorSpy = vi.spyOn(console, "error");
|
||||
|
||||
global.fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
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?project=my-app&orchestratorOnly=true&fresh=true") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ orchestratorId: "my-app-orchestrator" }),
|
||||
} as Response);
|
||||
}
|
||||
|
||||
if (url === "/api/sessions?fresh=true") {
|
||||
return new Promise<Response>((_, reject) => {
|
||||
init?.signal?.addEventListener(
|
||||
"abort",
|
||||
() => reject(new DOMException("The operation was aborted.", "AbortError")),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
|
||||
}) as typeof fetch;
|
||||
|
||||
const { default: SessionPage } = await import("./page");
|
||||
|
||||
const rendered = render(<SessionPage />);
|
||||
await flushAsyncWork();
|
||||
|
||||
rendered.unmount();
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalledWith(
|
||||
"Failed to fetch sidebar sessions:",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces sidebar fetch failures instead of leaving the loading skeleton active", async () => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { notFound, useParams, usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useState, useCallback, useRef, type ReactNode } from "react";
|
||||
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 } from "@/components/ProjectSidebar";
|
||||
import { useMediaQuery, MOBILE_BREAKPOINT } from "@/hooks/useMediaQuery";
|
||||
import { type DashboardSession, type ActivityState, getAttentionLevel } from "@/lib/types";
|
||||
import { activityIcon } from "@/lib/activity-icons";
|
||||
import type { ProjectInfo } from "@/lib/project-name";
|
||||
|
|
@ -12,6 +15,7 @@ import { useSSESessionActivity } from "@/hooks/useSSESessionActivity";
|
|||
import { useMuxOptional } from "@/providers/MuxProvider";
|
||||
import type { SessionPatch } from "@/lib/mux-protocol";
|
||||
import { projectSessionPath } from "@/lib/routes";
|
||||
import { fetchJsonWithTimeout } from "@/lib/client-fetch";
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
// Split on code points so emoji / astral characters aren't cleaved into
|
||||
|
|
@ -30,7 +34,11 @@ function buildSessionTitle(
|
|||
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 isOrchestrator = isOrchestratorSession(
|
||||
session,
|
||||
prefixByProject.get(session.projectId),
|
||||
allPrefixes,
|
||||
);
|
||||
|
||||
let detail: string;
|
||||
|
||||
|
|
@ -67,6 +75,9 @@ interface ProjectSessionsBody {
|
|||
let cachedProjects: ProjectInfo[] | null = null;
|
||||
let cachedSidebarSessions: DashboardSession[] | null = null;
|
||||
const SESSION_PAGE_REFRESH_INTERVAL_MS = 2000;
|
||||
const SESSION_FETCH_TIMEOUT_MS = 8000;
|
||||
const PROJECT_SIDEBAR_FETCH_TIMEOUT_MS = 5000;
|
||||
const PROJECTS_FETCH_TIMEOUT_MS = 5000;
|
||||
const validSessionStatuses = new Set<string>(Object.values(SESSION_STATUS));
|
||||
const validActivityStates = new Set<string>(Object.values(ACTIVITY_STATE));
|
||||
const warnedMuxPatchValues = new Set<string>();
|
||||
|
|
@ -104,7 +115,175 @@ function areSidebarSessionsEqual(
|
|||
});
|
||||
}
|
||||
|
||||
function applyMuxSessionPatches(current: DashboardSession[] | null, patches: SessionPatch[]): DashboardSession[] | null {
|
||||
function isAbortLikeError(error: unknown): boolean {
|
||||
if (error instanceof DOMException) {
|
||||
return error.name === "AbortError";
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.toLowerCase();
|
||||
return message.includes("aborted") || message.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, so the page stopped waiting instead of spinning forever. 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 SidebarPlaceholder({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="project-sidebar h-full">
|
||||
<div className="space-y-3 px-3 py-4">
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.18em] text-[var(--color-text-tertiary)]">
|
||||
Projects
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }, (_, index) => (
|
||||
<div
|
||||
key={`sidebar-placeholder-${index}`}
|
||||
className="h-10 animate-pulse border border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="pt-2 text-[12px] text-[var(--color-text-tertiary)]">{message}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionPageShell({
|
||||
projects,
|
||||
projectsLoading,
|
||||
sidebarSessions,
|
||||
sidebarLoading,
|
||||
sidebarError,
|
||||
onRetrySidebar,
|
||||
activeProjectId,
|
||||
activeSessionId,
|
||||
children,
|
||||
}: {
|
||||
projects: ProjectInfo[];
|
||||
projectsLoading: boolean;
|
||||
sidebarSessions: DashboardSession[] | null;
|
||||
sidebarLoading: boolean;
|
||||
sidebarError: boolean;
|
||||
onRetrySidebar: () => void;
|
||||
activeProjectId?: string;
|
||||
activeSessionId?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const isMobile = useMediaQuery(MOBILE_BREAKPOINT);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
|
||||
const handleToggleSidebar = useCallback(() => {
|
||||
if (isMobile) {
|
||||
setMobileSidebarOpen((current) => !current);
|
||||
return;
|
||||
}
|
||||
setSidebarCollapsed((current) => !current);
|
||||
}, [isMobile]);
|
||||
|
||||
return (
|
||||
<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 ? (
|
||||
<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">
|
||||
<span>Agent Orchestrator</span>
|
||||
</div>
|
||||
<div className="dashboard-app-header__spacer" />
|
||||
</header>
|
||||
|
||||
<div
|
||||
className={`dashboard-shell dashboard-shell--desktop${sidebarCollapsed ? " dashboard-shell--sidebar-collapsed" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`sidebar-wrapper${mobileSidebarOpen ? " sidebar-wrapper--mobile-open" : ""}`}
|
||||
>
|
||||
{projects.length > 0 ? (
|
||||
<ProjectSidebar
|
||||
projects={projects}
|
||||
sessions={sidebarSessions}
|
||||
loading={sidebarLoading}
|
||||
error={sidebarError}
|
||||
onRetry={onRetrySidebar}
|
||||
activeProjectId={activeProjectId}
|
||||
activeSessionId={activeSessionId}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggleCollapsed={() => setSidebarCollapsed((current) => !current)}
|
||||
onMobileClose={() => setMobileSidebarOpen(false)}
|
||||
/>
|
||||
) : (
|
||||
<SidebarPlaceholder
|
||||
message={projectsLoading ? "Loading projects..." : "Projects unavailable."}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{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">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function applyMuxSessionPatches(
|
||||
current: DashboardSession[] | null,
|
||||
patches: SessionPatch[],
|
||||
): DashboardSession[] | null {
|
||||
if (!current || patches.length === 0) {
|
||||
return current;
|
||||
}
|
||||
|
|
@ -185,15 +364,22 @@ export default function SessionPage() {
|
|||
sessionStorage.removeItem(`ao-session-nav:${id}`);
|
||||
return JSON.parse(raw) as DashboardSession;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} 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 [projectOrchestratorId, setProjectOrchestratorId] = useState<string | null | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [projects, setProjects] = useState<ProjectInfo[]>([]);
|
||||
const [sidebarSessions, setSidebarSessions] = useState<DashboardSession[] | null>(() => cachedSidebarSessions);
|
||||
const [projectsLoading, setProjectsLoading] = useState(cachedProjects === null);
|
||||
const [sidebarSessions, setSidebarSessions] = useState<DashboardSession[] | null>(
|
||||
() => cachedSidebarSessions,
|
||||
);
|
||||
const [loading, setLoading] = useState(cachedSession === null);
|
||||
const [routeError, setRouteError] = useState<Error | null>(null);
|
||||
const [sessionMissing, setSessionMissing] = useState(false);
|
||||
|
|
@ -214,6 +400,10 @@ export default function SessionPage() {
|
|||
const fetchingSessionRef = useRef(false);
|
||||
const fetchingProjectSessionsRef = useRef(false);
|
||||
const fetchingSidebarRef = useRef(false);
|
||||
const sessionFetchControllerRef = useRef<AbortController | null>(null);
|
||||
const projectSessionsFetchControllerRef = useRef<AbortController | null>(null);
|
||||
const sidebarFetchControllerRef = useRef<AbortController | null>(null);
|
||||
const pageUnloadingRef = useRef(false);
|
||||
|
||||
// Keep prefixByProjectRef in sync so fetchProjectSessions (stable [] dep) reads latest map
|
||||
useEffect(() => {
|
||||
|
|
@ -224,33 +414,33 @@ export default function SessionPage() {
|
|||
const fetchProjects = useCallback(async () => {
|
||||
if (cachedProjects) {
|
||||
setProjects(cachedProjects);
|
||||
setPrefixByProject(
|
||||
new Map(cachedProjects.map((p) => [p.id, p.sessionPrefix ?? p.id])),
|
||||
);
|
||||
setPrefixByProject(new Map(cachedProjects.map((p) => [p.id, p.sessionPrefix ?? p.id])));
|
||||
setProjectsLoading(false);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/projects");
|
||||
if (!res.ok) {
|
||||
console.error("Failed to fetch projects:", new Error(`HTTP ${res.status}`));
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as { projects?: ProjectInfo[] } | null;
|
||||
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])),
|
||||
);
|
||||
setPrefixByProject(new Map(data.projects.map((p) => [p.id, p.sessionPrefix ?? p.id])));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch projects:", err);
|
||||
} finally {
|
||||
setProjectsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Subscribe to SSE for real-time activity updates (title emoji)
|
||||
const sseActivity = useSSESessionActivity(id);
|
||||
const sseActivity = useSSESessionActivity(id, sessionProjectId ?? expectedProjectId ?? undefined);
|
||||
|
||||
// Update document title based on session data + SSE activity override
|
||||
useEffect(() => {
|
||||
|
|
@ -291,22 +481,37 @@ export default function SessionPage() {
|
|||
const fetchSession = useCallback(async () => {
|
||||
if (fetchingSessionRef.current) return;
|
||||
fetchingSessionRef.current = true;
|
||||
const controller = new AbortController();
|
||||
sessionFetchControllerRef.current = controller;
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`);
|
||||
if (res.status === 404) {
|
||||
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 normalizedMessage = message.toLowerCase();
|
||||
if (
|
||||
normalizedMessage.includes("session not found") ||
|
||||
normalizedMessage.includes("http 404")
|
||||
) {
|
||||
if (!hasLoadedSessionRef.current) {
|
||||
setSessionMissing(true);
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as DashboardSession;
|
||||
setSession(data);
|
||||
setRouteError(null);
|
||||
setSessionMissing(false);
|
||||
hasLoadedSessionRef.current = true;
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch session:", err);
|
||||
if (!hasLoadedSessionRef.current) {
|
||||
setRouteError(err instanceof Error ? err : new Error("Failed to load session"));
|
||||
|
|
@ -314,6 +519,9 @@ export default function SessionPage() {
|
|||
} finally {
|
||||
setLoading(false);
|
||||
fetchingSessionRef.current = false;
|
||||
if (sessionFetchControllerRef.current === controller) {
|
||||
sessionFetchControllerRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
|
|
@ -325,22 +533,25 @@ export default function SessionPage() {
|
|||
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 res = await fetch(query);
|
||||
if (!res.ok) {
|
||||
console.error("Failed to fetch project sessions for", projectId, new Error(`HTTP ${res.status}`));
|
||||
return;
|
||||
}
|
||||
const body = (await res.json()) as ProjectSessionsBody;
|
||||
const body = await fetchJsonWithTimeout<ProjectSessionsBody>(query, {
|
||||
signal: controller.signal,
|
||||
timeoutMs: PROJECT_SIDEBAR_FETCH_TIMEOUT_MS,
|
||||
timeoutMessage: `Project sessions request timed out after ${PROJECT_SIDEBAR_FETCH_TIMEOUT_MS}ms`,
|
||||
});
|
||||
const sessions = body.sessions ?? [];
|
||||
const orchestratorId =
|
||||
body.orchestratorId ??
|
||||
body.orchestrators?.find((orchestrator) => orchestrator.projectId === projectId)?.id ??
|
||||
null;
|
||||
setProjectOrchestratorId((current) => (current === orchestratorId ? current : orchestratorId));
|
||||
setProjectOrchestratorId((current) =>
|
||||
current === orchestratorId ? current : orchestratorId,
|
||||
);
|
||||
|
||||
if (!isOrchestrator) {
|
||||
resolvedProjectSessionsKeyRef.current = projectSessionsKey;
|
||||
|
|
@ -366,36 +577,53 @@ export default function SessionPage() {
|
|||
}
|
||||
}
|
||||
setZoneCounts(counts);
|
||||
} catch {
|
||||
// non-critical - status strip just won't show
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchSidebarSessions = useCallback(async () => {
|
||||
if (fetchingSidebarRef.current) return;
|
||||
fetchingSidebarRef.current = true;
|
||||
const controller = new AbortController();
|
||||
sidebarFetchControllerRef.current = controller;
|
||||
try {
|
||||
const res = await fetch("/api/sessions?fresh=true");
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const body = (await res.json()) as { sessions?: DashboardSession[] } | null;
|
||||
const body = await fetchJsonWithTimeout<{ sessions?: DashboardSession[] } | null>(
|
||||
"/api/sessions?fresh=true",
|
||||
{
|
||||
signal: controller.signal,
|
||||
timeoutMs: PROJECT_SIDEBAR_FETCH_TIMEOUT_MS,
|
||||
timeoutMessage: `Sidebar sessions request timed out after ${PROJECT_SIDEBAR_FETCH_TIMEOUT_MS}ms`,
|
||||
},
|
||||
);
|
||||
const restSessions = body?.sessions ?? [];
|
||||
const nextSessions =
|
||||
applyMuxSessionPatches(restSessions, pendingMuxSessionsRef.current ?? []) ?? restSessions;
|
||||
cachedSidebarSessions = nextSessions;
|
||||
setSidebarError(false);
|
||||
setSidebarSessions((current) => (
|
||||
areSidebarSessionsEqual(current, nextSessions) ? current : nextSessions
|
||||
));
|
||||
setSidebarSessions((current) =>
|
||||
areSidebarSessionsEqual(current, nextSessions) ? current : nextSessions,
|
||||
);
|
||||
} catch (err) {
|
||||
if (pageUnloadingRef.current || controller.signal.aborted || isAbortLikeError(err)) {
|
||||
return;
|
||||
}
|
||||
console.error("Failed to fetch sidebar sessions:", err);
|
||||
setSidebarError(true);
|
||||
setSidebarSessions((current) => (current === null ? [] : current));
|
||||
} finally {
|
||||
fetchingSidebarRef.current = false;
|
||||
if (sidebarFetchControllerRef.current === controller) {
|
||||
sidebarFetchControllerRef.current = null;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
@ -448,11 +676,7 @@ export default function SessionPage() {
|
|||
|
||||
// Initial fetch — load independent sidebar/session data in parallel.
|
||||
useEffect(() => {
|
||||
void Promise.all([
|
||||
fetchProjects(),
|
||||
fetchSession(),
|
||||
fetchSidebarSessions(),
|
||||
]);
|
||||
void Promise.all([fetchProjects(), fetchSession(), fetchSidebarSessions()]);
|
||||
}, [fetchProjects, fetchSession, fetchSidebarSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -471,25 +695,150 @@ export default function SessionPage() {
|
|||
return () => clearInterval(interval);
|
||||
}, [fetchSession, fetchProjectSessions, fetchSidebarSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
pageUnloadingRef.current = false;
|
||||
const markPageUnloading = () => {
|
||||
pageUnloadingRef.current = true;
|
||||
};
|
||||
|
||||
window.addEventListener("pagehide", markPageUnloading);
|
||||
window.addEventListener("beforeunload", markPageUnloading);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pagehide", markPageUnloading);
|
||||
window.removeEventListener("beforeunload", markPageUnloading);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
sessionFetchControllerRef.current?.abort();
|
||||
projectSessionsFetchControllerRef.current?.abort();
|
||||
sidebarFetchControllerRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[var(--color-bg-base)]">
|
||||
<div className="text-[13px] text-[var(--color-text-tertiary)]">Loading session…</div>
|
||||
</div>
|
||||
<SessionPageShell
|
||||
projects={projects}
|
||||
projectsLoading={projectsLoading}
|
||||
sidebarSessions={sidebarSessions}
|
||||
sidebarLoading={sidebarSessions === null}
|
||||
sidebarError={sidebarError}
|
||||
onRetrySidebar={fetchSidebarSessions}
|
||||
activeProjectId={expectedProjectId ?? undefined}
|
||||
activeSessionId={id}
|
||||
>
|
||||
<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>
|
||||
</SessionPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (sessionMissing) {
|
||||
notFound();
|
||||
return null;
|
||||
return (
|
||||
<SessionPageShell
|
||||
projects={projects}
|
||||
projectsLoading={projectsLoading}
|
||||
sidebarSessions={sidebarSessions}
|
||||
sidebarLoading={sidebarSessions === null}
|
||||
sidebarError={sidebarError}
|
||||
onRetrySidebar={fetchSidebarSessions}
|
||||
activeProjectId={expectedProjectId ?? undefined}
|
||||
activeSessionId={id}
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
</SessionPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (routeError) {
|
||||
throw routeError;
|
||||
return (
|
||||
<SessionPageShell
|
||||
projects={projects}
|
||||
projectsLoading={projectsLoading}
|
||||
sidebarSessions={sidebarSessions}
|
||||
sidebarLoading={sidebarSessions === null}
|
||||
sidebarError={sidebarError}
|
||||
onRetrySidebar={fetchSidebarSessions}
|
||||
activeProjectId={session?.projectId ?? expectedProjectId ?? undefined}
|
||||
activeSessionId={id}
|
||||
>
|
||||
<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(), fetchSidebarSessions()]);
|
||||
},
|
||||
}}
|
||||
secondaryAction={{
|
||||
label: "Back to dashboard",
|
||||
href: session?.projectId ? `/projects/${session.projectId}` : "/",
|
||||
}}
|
||||
error={routeError}
|
||||
compact
|
||||
chrome="card"
|
||||
/>
|
||||
</SessionPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
throw new Error("Session data was unavailable after loading completed");
|
||||
return (
|
||||
<SessionPageShell
|
||||
projects={projects}
|
||||
projectsLoading={projectsLoading}
|
||||
sidebarSessions={sidebarSessions}
|
||||
sidebarLoading={sidebarSessions === null}
|
||||
sidebarError={sidebarError}
|
||||
onRetrySidebar={fetchSidebarSessions}
|
||||
activeProjectId={expectedProjectId ?? undefined}
|
||||
activeSessionId={id}
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
</SessionPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useMediaQuery, MOBILE_BREAKPOINT } from "@/hooks/useMediaQuery";
|
||||
import {
|
||||
NON_RESTORABLE_STATUSES,
|
||||
|
|
@ -105,7 +105,14 @@ function DoneCard({
|
|||
className="done-card__pr"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<svg width="9" height="9" fill="none" stroke="currentColor" strokeWidth="1.8" viewBox="0 0 24 24">
|
||||
<svg
|
||||
width="9"
|
||||
height="9"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle cx="18" cy="18" r="3" />
|
||||
<circle cx="6" cy="6" r="3" />
|
||||
<path d="M6 9v3a6 6 0 0 0 6 6h3" />
|
||||
|
|
@ -142,7 +149,8 @@ function DashboardInner({
|
|||
}: DashboardProps) {
|
||||
const orchestratorLinks = orchestrators ?? EMPTY_ORCHESTRATORS;
|
||||
const mux = useMuxOptional();
|
||||
const kanbanLevels = attentionZones === "detailed" ? DETAILED_KANBAN_LEVELS : SIMPLE_KANBAN_LEVELS;
|
||||
const kanbanLevels =
|
||||
attentionZones === "detailed" ? DETAILED_KANBAN_LEVELS : SIMPLE_KANBAN_LEVELS;
|
||||
const initialAttentionLevels = useMemo(() => {
|
||||
const levels: Record<string, AttentionLevel> = {};
|
||||
for (const s of initialSessions) {
|
||||
|
|
@ -150,16 +158,21 @@ function DashboardInner({
|
|||
}
|
||||
return levels;
|
||||
}, [initialSessions, attentionZones]);
|
||||
const { sessions, connectionStatus, sseAttentionLevels, liveSessionsResolved } = useSessionEvents({
|
||||
initialSessions,
|
||||
project: projectId,
|
||||
muxSessions: mux?.status === "connected" ? mux.sessions : undefined,
|
||||
initialAttentionLevels,
|
||||
attentionZones,
|
||||
});
|
||||
const { sessions, connectionStatus, sseAttentionLevels, liveSessionsResolved, loadError } =
|
||||
useSessionEvents({
|
||||
initialSessions,
|
||||
project: projectId,
|
||||
muxSessions: mux?.status === "connected" ? mux.sessions : undefined,
|
||||
initialAttentionLevels,
|
||||
attentionZones,
|
||||
});
|
||||
const recoveredFromLoadError = Boolean(dashboardLoadError) && liveSessionsResolved;
|
||||
const visibleDashboardLoadError = recoveredFromLoadError ? undefined : dashboardLoadError;
|
||||
const visibleDashboardLoadError =
|
||||
loadError ?? (recoveredFromLoadError ? undefined : dashboardLoadError);
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const routerRef = useRef(router);
|
||||
routerRef.current = router;
|
||||
const activeSessionId = searchParams.get("session") ?? undefined;
|
||||
const [rateLimitDismissed, setRateLimitDismissed] = useState(false);
|
||||
const [activeOrchestrators, setActiveOrchestrators] =
|
||||
|
|
@ -195,9 +208,11 @@ function DashboardInner({
|
|||
Boolean(projectId) &&
|
||||
projects.some((project) => project.id === projectId && !project.resolveError) &&
|
||||
!orchestratorHref;
|
||||
const activeProject = projectId ? projects.find((project) => project.id === projectId) ?? null : null;
|
||||
const activeProject = projectId
|
||||
? (projects.find((project) => project.id === projectId) ?? null)
|
||||
: null;
|
||||
const isSpawningCurrentProject = projectId ? spawningProjectIds.includes(projectId) : false;
|
||||
const currentProjectSpawnError = projectId ? spawnErrors[projectId] ?? null : null;
|
||||
const currentProjectSpawnError = projectId ? (spawnErrors[projectId] ?? null) : null;
|
||||
|
||||
const displaySessions = useMemo(() => {
|
||||
if (allProjectsView || !activeSessionId) return sessions;
|
||||
|
|
@ -219,7 +234,6 @@ function DashboardInner({
|
|||
setMobileMenuOpen(false);
|
||||
}, [searchParams]);
|
||||
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const zones: Record<AttentionLevel, DashboardSession[]> = {
|
||||
merge: [],
|
||||
|
|
@ -279,7 +293,6 @@ function DashboardInner({
|
|||
});
|
||||
}, [activeOrchestrators, allProjectsView, attentionZones, projects, sessionsByProject]);
|
||||
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (sessionId: string, message: string) => {
|
||||
try {
|
||||
|
|
@ -342,7 +355,6 @@ function DashboardInner({
|
|||
[killSession],
|
||||
);
|
||||
|
||||
|
||||
const handleMerge = useCallback(
|
||||
async (prNumber: number) => {
|
||||
try {
|
||||
|
|
@ -375,6 +387,7 @@ function DashboardInner({
|
|||
showToast(`Restore failed: ${text}`, "error");
|
||||
} else {
|
||||
showToast("Session restored", "success");
|
||||
routerRef.current.refresh();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Network error restoring ${sessionId}:`, error);
|
||||
|
|
@ -431,11 +444,15 @@ function DashboardInner({
|
|||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<span className="font-semibold text-[var(--color-status-error)]">Orchestrator failed to load</span>
|
||||
<span className="break-words text-[var(--color-text-secondary)]">{visibleDashboardLoadError}</span>
|
||||
<span className="font-semibold text-[var(--color-status-error)]">
|
||||
Orchestrator failed to load
|
||||
</span>
|
||||
<span className="break-words text-[var(--color-text-secondary)]">
|
||||
{visibleDashboardLoadError}
|
||||
</span>
|
||||
<span className="text-[var(--color-text-secondary)]">
|
||||
Confirm <span className="font-mono text-[10px]">agent-orchestrator.yaml</span> exists and is valid, then run{" "}
|
||||
<span className="font-mono text-[10px]">ao doctor</span> for diagnostics.
|
||||
Confirm <span className="font-mono text-[10px]">agent-orchestrator.yaml</span> exists and is
|
||||
valid, then run <span className="font-mono text-[10px]">ao doctor</span> for diagnostics.
|
||||
</span>
|
||||
</div>
|
||||
) : null;
|
||||
|
|
@ -460,7 +477,9 @@ function DashboardInner({
|
|||
};
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={{ onToggleSidebar: handleToggleSidebar, mobileSidebarOpen: mobileMenuOpen }}>
|
||||
<SidebarContext.Provider
|
||||
value={{ onToggleSidebar: handleToggleSidebar, mobileSidebarOpen: mobileMenuOpen }}
|
||||
>
|
||||
<>
|
||||
<ConnectionBar status={connectionStatus} />
|
||||
<div className="dashboard-app-shell">
|
||||
|
|
@ -473,7 +492,15 @@ function DashboardInner({
|
|||
aria-label="Toggle sidebar"
|
||||
>
|
||||
{isMobile ? (
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<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>
|
||||
) : (
|
||||
|
|
@ -561,7 +588,9 @@ function DashboardInner({
|
|||
className={`dashboard-shell dashboard-shell--desktop${sidebarCollapsed ? " dashboard-shell--sidebar-collapsed" : ""}`}
|
||||
>
|
||||
{showSidebar && (
|
||||
<div className={`sidebar-wrapper${mobileMenuOpen ? " sidebar-wrapper--mobile-open" : ""}`}>
|
||||
<div
|
||||
className={`sidebar-wrapper${mobileMenuOpen ? " sidebar-wrapper--mobile-open" : ""}`}
|
||||
>
|
||||
<ProjectSidebar
|
||||
projects={projects}
|
||||
sessions={sessions}
|
||||
|
|
@ -634,7 +663,15 @@ function DashboardInner({
|
|||
|
||||
{!allProjectsView && hasAnySessions && (
|
||||
<div className="kanban-board-wrap">
|
||||
<div className="kanban-board">
|
||||
<div
|
||||
className="kanban-board"
|
||||
data-columns={kanbanLevels.length}
|
||||
style={
|
||||
{
|
||||
"--kanban-column-count": kanbanLevels.length,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{kanbanLevels.map((level) => (
|
||||
<AttentionZone
|
||||
key={level}
|
||||
|
|
@ -739,7 +776,7 @@ function ProjectOverviewGrid({
|
|||
}) {
|
||||
return (
|
||||
<div className="mb-8 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{overviews.map(({ project, orchestrator, sessionCount, openPRCount, counts }) => (
|
||||
{overviews.map(({ project, orchestrator, sessionCount, openPRCount, counts }) =>
|
||||
(() => {
|
||||
const isDegraded = Boolean(project.resolveError);
|
||||
const projectHref = projectDashboardPath(project.id);
|
||||
|
|
@ -760,7 +797,9 @@ function ProjectOverviewGrid({
|
|||
) : (
|
||||
<>
|
||||
{sessionCount} active session{sessionCount !== 1 ? "s" : ""}
|
||||
{openPRCount > 0 ? ` · ${openPRCount} open PR${openPRCount !== 1 ? "s" : ""}` : ""}
|
||||
{openPRCount > 0
|
||||
? ` · ${openPRCount} open PR${openPRCount !== 1 ? "s" : ""}`
|
||||
: ""}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -793,8 +832,8 @@ function ProjectOverviewGrid({
|
|||
{isDegraded
|
||||
? "Project config could not be resolved"
|
||||
: orchestrator
|
||||
? "Per-project orchestrator available"
|
||||
: "No running orchestrator"}
|
||||
? "Per-project orchestrator available"
|
||||
: "No running orchestrator"}
|
||||
</div>
|
||||
{isDegraded ? (
|
||||
<Link
|
||||
|
|
@ -818,7 +857,9 @@ function ProjectOverviewGrid({
|
|||
disabled={spawningProjectIds.includes(project.id)}
|
||||
className="orchestrator-btn px-3 py-1.5 text-[11px] font-semibold disabled:cursor-wait disabled:opacity-70"
|
||||
>
|
||||
{spawningProjectIds.includes(project.id) ? "Spawning..." : "Spawn Orchestrator"}
|
||||
{spawningProjectIds.includes(project.id)
|
||||
? "Spawning..."
|
||||
: "Spawn Orchestrator"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -830,8 +871,8 @@ function ProjectOverviewGrid({
|
|||
</div>
|
||||
</section>
|
||||
);
|
||||
})()
|
||||
))}
|
||||
})(),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ export function OrchestratorSelector({
|
|||
<h1 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
{projectName}
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--color-text-secondary)]">Select an orchestrator</p>
|
||||
<p className="text-sm text-[var(--color-text-secondary)]">Project orchestrator</p>
|
||||
</div>
|
||||
<Link
|
||||
href={projectDashboardPath(projectId)}
|
||||
|
|
@ -171,19 +171,15 @@ export function OrchestratorSelector({
|
|||
{/* 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)]">
|
||||
Found{" "}
|
||||
<span className="font-medium text-[var(--color-text-primary)]">
|
||||
{orchestrators.length}
|
||||
</span>{" "}
|
||||
existing orchestrator session{orchestrators.length !== 1 ? "s" : ""}. You can resume
|
||||
an existing session or start a new one.
|
||||
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)]">
|
||||
Existing Sessions
|
||||
Main Session
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{orchestrators.map((orch) => (
|
||||
|
|
@ -227,7 +223,7 @@ export function OrchestratorSelector({
|
|||
{/* 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)]">
|
||||
Or Start Fresh
|
||||
Open Orchestrator
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -260,10 +256,10 @@ export function OrchestratorSelector({
|
|||
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>
|
||||
Creating new orchestrator...
|
||||
Opening orchestrator...
|
||||
</span>
|
||||
) : (
|
||||
"Start New Orchestrator"
|
||||
"Open Orchestrator"
|
||||
)}
|
||||
</button>
|
||||
{spawnError && (
|
||||
|
|
|
|||
|
|
@ -27,14 +27,7 @@ interface ProjectSidebarProps {
|
|||
onMobileClose?: () => void;
|
||||
}
|
||||
|
||||
type SessionDotLevel =
|
||||
| "respond"
|
||||
| "review"
|
||||
| "action"
|
||||
| "pending"
|
||||
| "working"
|
||||
| "merge"
|
||||
| "done";
|
||||
type SessionDotLevel = "respond" | "review" | "action" | "pending" | "working" | "merge" | "done";
|
||||
|
||||
function SessionDot({ level }: { level: SessionDotLevel }) {
|
||||
return (
|
||||
|
|
@ -201,11 +194,13 @@ function ProjectSidebarInner({
|
|||
// Only include sessions whose projectId matches a configured project
|
||||
if (!validProjectIds.has(s.projectId)) continue;
|
||||
if (isOrchestratorSession(s, prefixByProject.get(s.projectId), allPrefixes)) continue;
|
||||
// Filter by status visibility — use getAttentionLevel so the collected
|
||||
// set matches what the expanded/collapsed rendering below actually shows.
|
||||
// Otherwise the project badge count can disagree with the visible rows.
|
||||
if (s.status === "killed" && !showKilled) continue;
|
||||
if (getAttentionLevel(s) === "done" && !showDone) continue;
|
||||
// Keep terminal sessions visible when they still need human attention.
|
||||
// Otherwise ACTION-column cards disappear from the sidebar just because
|
||||
// their runtime has ended.
|
||||
const level = getAttentionLevel(s);
|
||||
if (level === "done") {
|
||||
if (s.status === "killed" ? !showKilled && !showDone : !showDone) continue;
|
||||
}
|
||||
const list = map.get(s.projectId) ?? [];
|
||||
list.push(s);
|
||||
map.set(s.projectId, list);
|
||||
|
|
@ -279,9 +274,11 @@ function ProjectSidebarInner({
|
|||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<aside className={cn(
|
||||
"project-sidebar project-sidebar--collapsed flex flex-col h-full items-center py-2 gap-1 overflow-y-auto",
|
||||
)}>
|
||||
<aside
|
||||
className={cn(
|
||||
"project-sidebar project-sidebar--collapsed flex flex-col h-full items-center py-2 gap-1 overflow-y-auto",
|
||||
)}
|
||||
>
|
||||
{visibleProjects.map((project, idx) => {
|
||||
const workerSessions = sessionsByProject.get(project.id) ?? [];
|
||||
// sessionsByProject already applies the showDone filter consistently.
|
||||
|
|
@ -304,7 +301,9 @@ function ProjectSidebarInner({
|
|||
{visibleSessions.slice(0, 5).map((session) => {
|
||||
const level = getAttentionLevel(session);
|
||||
const rawTitle = session.branch ?? getSessionTitle(session);
|
||||
const displayTitle = session.branch ? humanizeBranch(session.branch) || rawTitle : rawTitle;
|
||||
const displayTitle = session.branch
|
||||
? humanizeBranch(session.branch) || rawTitle
|
||||
: rawTitle;
|
||||
const abbr = displayTitle.replace(/\s+/g, "").slice(0, 3).toUpperCase();
|
||||
const isActive = activeSessionId === session.id;
|
||||
const sessionHref = projectSessionPath(project.id, session.id);
|
||||
|
|
@ -331,7 +330,9 @@ function ProjectSidebarInner({
|
|||
);
|
||||
})}
|
||||
{visibleSessions.length > 5 && (
|
||||
<span className="project-sidebar__collapsed-overflow">+{visibleSessions.length - 5}</span>
|
||||
<span className="project-sidebar__collapsed-overflow">
|
||||
+{visibleSessions.length - 5}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -341,393 +342,447 @@ function ProjectSidebarInner({
|
|||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="project-sidebar flex h-full flex-col"
|
||||
>
|
||||
<div className="project-sidebar__compact-hdr">
|
||||
<span className="project-sidebar__sect-label">Projects</span>
|
||||
<button
|
||||
type="button"
|
||||
className="project-sidebar__add-btn"
|
||||
aria-label="New project"
|
||||
onClick={() => setAddProjectOpen(true)}
|
||||
>
|
||||
<svg fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<aside className="project-sidebar flex h-full flex-col">
|
||||
<div className="project-sidebar__compact-hdr">
|
||||
<span className="project-sidebar__sect-label">Projects</span>
|
||||
<button
|
||||
type="button"
|
||||
className="project-sidebar__add-btn"
|
||||
aria-label="New project"
|
||||
onClick={() => setAddProjectOpen(true)}
|
||||
>
|
||||
<svg fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stale-data banner: keep cached sessions visible on fetch failure but
|
||||
{/* Stale-data banner: keep cached sessions visible on fetch failure but
|
||||
surface the error so users know the list may be out of date. */}
|
||||
{error && sessions && sessions.length > 0 ? (
|
||||
<div
|
||||
role="status"
|
||||
className="mx-3 mb-2 flex items-center justify-between gap-2 rounded-md border border-[var(--color-border-strong)] bg-[var(--color-bg-primary)] px-2 py-1.5 text-[11px] text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
<span>Failed to refresh · showing cached sessions</span>
|
||||
{onRetry ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="font-medium text-[var(--color-link)] hover:underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{error && sessions && sessions.length > 0 ? (
|
||||
<div
|
||||
role="status"
|
||||
className="mx-3 mb-2 flex items-center justify-between gap-2 rounded-md border border-[var(--color-border-strong)] bg-[var(--color-bg-primary)] px-2 py-1.5 text-[11px] text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
<span>Failed to refresh · showing cached sessions</span>
|
||||
{onRetry ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="font-medium text-[var(--color-link)] hover:underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Project tree */}
|
||||
<div className="project-sidebar__tree flex-1 overflow-y-auto overflow-x-hidden">
|
||||
{visibleProjects.map((project) => {
|
||||
const workerSessions = sessionsByProject.get(project.id) ?? [];
|
||||
const isExpanded = expandedProjects.has(project.id);
|
||||
const isActive = activeProjectId === project.id;
|
||||
const isDegraded = Boolean(project.resolveError);
|
||||
const projectHref = projectDashboardPath(project.id);
|
||||
// sessionsByProject already applies the showDone filter consistently.
|
||||
const visibleSessions = workerSessions;
|
||||
const hasActiveSessions = visibleSessions.length > 0;
|
||||
{/* Project tree */}
|
||||
<div className="project-sidebar__tree flex-1 overflow-y-auto overflow-x-hidden">
|
||||
{visibleProjects.map((project) => {
|
||||
const workerSessions = sessionsByProject.get(project.id) ?? [];
|
||||
const isExpanded = expandedProjects.has(project.id);
|
||||
const isActive = activeProjectId === project.id;
|
||||
const isDegraded = Boolean(project.resolveError);
|
||||
const projectHref = projectDashboardPath(project.id);
|
||||
// sessionsByProject already applies the showDone filter consistently.
|
||||
const visibleSessions = workerSessions;
|
||||
const hasActiveSessions = visibleSessions.length > 0;
|
||||
|
||||
const orchestratorSession = sessions?.find(
|
||||
(s) => isOrchestratorSession(s, prefixByProject.get(s.projectId), allPrefixes) && s.projectId === project.id
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={project.id} className="project-sidebar__project">
|
||||
{/* Project row: toggle + action buttons */}
|
||||
<div className="project-sidebar__proj-row flex items-center">
|
||||
{isDegraded ? (
|
||||
<a
|
||||
href={projectHref}
|
||||
onClick={(e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
|
||||
e.preventDefault();
|
||||
navigate(projectHref);
|
||||
}}
|
||||
className={cn(
|
||||
"project-sidebar__proj-toggle project-sidebar__proj-toggle--link project-sidebar__proj-toggle--degraded",
|
||||
isActive && "project-sidebar__proj-toggle--active",
|
||||
)}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
>
|
||||
<svg
|
||||
className="project-sidebar__proj-chevron project-sidebar__proj-chevron--degraded"
|
||||
width="10"
|
||||
height="10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 9v4" />
|
||||
<path d="M12 17h.01" />
|
||||
<path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.72 3h16.92a2 2 0 0 0 1.72-3L13.7 3.86a2 2 0 0 0-3.4 0Z" />
|
||||
</svg>
|
||||
<span className="project-sidebar__proj-name">{project.name}</span>
|
||||
<span className="project-sidebar__proj-badge project-sidebar__proj-badge--degraded">
|
||||
degraded
|
||||
</span>
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpand(project.id)}
|
||||
className={cn(
|
||||
"project-sidebar__proj-toggle",
|
||||
isActive && "project-sidebar__proj-toggle--active",
|
||||
)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
>
|
||||
<svg
|
||||
className={cn(
|
||||
"project-sidebar__proj-chevron",
|
||||
isExpanded && "project-sidebar__proj-chevron--open",
|
||||
)}
|
||||
width="10"
|
||||
height="10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
<span className="project-sidebar__proj-name">{project.name}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"project-sidebar__proj-badge",
|
||||
hasActiveSessions && "project-sidebar__proj-badge--active",
|
||||
)}
|
||||
>
|
||||
{workerSessions.length}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Dashboard button */}
|
||||
{!isDegraded ? (
|
||||
<Link
|
||||
href={projectHref}
|
||||
onClick={(e) => { e.stopPropagation(); onMobileClose?.(); }}
|
||||
className="project-sidebar__proj-action"
|
||||
aria-label={`Open ${project.name} dashboard`}
|
||||
title="Dashboard"
|
||||
>
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path d="M3 13h8V3H3zm10 8h8V11h-8zM3 21h8v-6H3zm10-10h8V3h-8z" />
|
||||
</svg>
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{/* Orchestrator button */}
|
||||
{!isDegraded && orchestratorSession && (
|
||||
<Link
|
||||
href={projectSessionPath(project.id, orchestratorSession.id)}
|
||||
onClick={(e) => { e.stopPropagation(); onMobileClose?.(); }}
|
||||
className="project-sidebar__proj-action"
|
||||
aria-label={`Open ${project.name} orchestrator`}
|
||||
title="Orchestrator"
|
||||
>
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<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>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="project-sidebar__proj-menu"
|
||||
ref={projectMenuOpenId === project.id ? projectMenuRef : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setProjectMenuOpenId((current) => (current === project.id ? null : project.id));
|
||||
}}
|
||||
className="project-sidebar__proj-action project-sidebar__proj-action--menu"
|
||||
aria-label={`Project actions for ${project.name}`}
|
||||
aria-expanded={projectMenuOpenId === project.id}
|
||||
aria-haspopup="menu"
|
||||
title="Project actions"
|
||||
>
|
||||
<svg width="12" height="12" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="5" r="1.75" />
|
||||
<circle cx="12" cy="12" r="1.75" />
|
||||
<circle cx="12" cy="19" r="1.75" />
|
||||
</svg>
|
||||
</button>
|
||||
{projectMenuOpenId === project.id ? (
|
||||
<div
|
||||
ref={projectMenuPopoverRef}
|
||||
className="project-sidebar__proj-menu-popover"
|
||||
role="menu"
|
||||
aria-label={`${project.name} actions`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="project-sidebar__proj-menu-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setProjectMenuOpenId(null);
|
||||
setProjectSettingsProjectId(project.id);
|
||||
}}
|
||||
>
|
||||
Project settings
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="project-sidebar__proj-menu-item project-sidebar__proj-menu-item--danger"
|
||||
role="menuitem"
|
||||
onClick={() => void handleRemoveProject(project)}
|
||||
disabled={deletingProjectId === project.id}
|
||||
>
|
||||
{deletingProjectId === project.id ? "Removing..." : "Remove project"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
const orchestratorSession = sessions?.find(
|
||||
(s) =>
|
||||
isOrchestratorSession(s, prefixByProject.get(s.projectId), allPrefixes) &&
|
||||
s.projectId === project.id,
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={project.id} className="project-sidebar__project">
|
||||
{/* Project row: toggle + action buttons */}
|
||||
<div className="project-sidebar__proj-row flex items-center">
|
||||
{isDegraded ? (
|
||||
<div className="project-sidebar__degraded-note">Config needs repair</div>
|
||||
<a
|
||||
href={projectHref}
|
||||
onClick={(e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
|
||||
e.preventDefault();
|
||||
navigate(projectHref);
|
||||
}}
|
||||
className={cn(
|
||||
"project-sidebar__proj-toggle project-sidebar__proj-toggle--link project-sidebar__proj-toggle--degraded",
|
||||
isActive && "project-sidebar__proj-toggle--active",
|
||||
)}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
>
|
||||
<svg
|
||||
className="project-sidebar__proj-chevron project-sidebar__proj-chevron--degraded"
|
||||
width="10"
|
||||
height="10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 9v4" />
|
||||
<path d="M12 17h.01" />
|
||||
<path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.72 3h16.92a2 2 0 0 0 1.72-3L13.7 3.86a2 2 0 0 0-3.4 0Z" />
|
||||
</svg>
|
||||
<span className="project-sidebar__proj-name">{project.name}</span>
|
||||
<span className="project-sidebar__proj-badge project-sidebar__proj-badge--degraded">
|
||||
degraded
|
||||
</span>
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpand(project.id)}
|
||||
className={cn(
|
||||
"project-sidebar__proj-toggle",
|
||||
isActive && "project-sidebar__proj-toggle--active",
|
||||
)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
>
|
||||
<svg
|
||||
className={cn(
|
||||
"project-sidebar__proj-chevron",
|
||||
isExpanded && "project-sidebar__proj-chevron--open",
|
||||
)}
|
||||
width="10"
|
||||
height="10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
<span className="project-sidebar__proj-name">{project.name}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"project-sidebar__proj-badge",
|
||||
hasActiveSessions && "project-sidebar__proj-badge--active",
|
||||
)}
|
||||
>
|
||||
{workerSessions.length}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Dashboard button */}
|
||||
{!isDegraded ? (
|
||||
<Link
|
||||
href={projectHref}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMobileClose?.();
|
||||
}}
|
||||
className="project-sidebar__proj-action"
|
||||
aria-label={`Open ${project.name} dashboard`}
|
||||
title="Dashboard"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M3 13h8V3H3zm10 8h8V11h-8zM3 21h8v-6H3zm10-10h8V3h-8z" />
|
||||
</svg>
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{/* Sessions */}
|
||||
{!isDegraded && isExpanded && (
|
||||
<div className="project-sidebar__sessions">
|
||||
{isLoading ? (
|
||||
<div className="space-y-2 px-3 py-2" aria-label="Loading sessions">
|
||||
{Array.from({ length: 3 }, (_, index) => (
|
||||
<div
|
||||
key={`${project.id}-loading-${index}`}
|
||||
className="flex items-center gap-3 border border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)] px-2 py-2"
|
||||
>
|
||||
<div className="h-2 w-2 shrink-0 animate-pulse bg-[var(--color-border-strong)]" />
|
||||
<div className="h-3 flex-1 animate-pulse bg-[var(--color-bg-primary)]" />
|
||||
<div className="h-3 w-12 animate-pulse bg-[var(--color-bg-primary)]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : visibleSessions.length > 0 ? (
|
||||
visibleSessions.map((session) => {
|
||||
const level = getAttentionLevel(session);
|
||||
const isSessionActive = activeSessionId === session.id;
|
||||
const title = session.branch ?? getSessionTitle(session);
|
||||
const sessionHref = projectSessionPath(project.id, session.id);
|
||||
return (
|
||||
<a
|
||||
key={session.id}
|
||||
href={sessionHref}
|
||||
onClick={(e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
|
||||
e.preventDefault();
|
||||
navigate(sessionHref, session);
|
||||
}}
|
||||
className={cn(
|
||||
"project-sidebar__sess-row",
|
||||
isSessionActive && "project-sidebar__sess-row--active",
|
||||
)}
|
||||
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>
|
||||
);
|
||||
})
|
||||
) : error ? (
|
||||
<div className="px-3 py-2">
|
||||
<div className="project-sidebar__empty">Failed to load sessions</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 text-xs font-medium text-[var(--color-link)] hover:underline"
|
||||
onClick={onRetry}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="project-sidebar__empty">No active sessions</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Orchestrator button */}
|
||||
{!isDegraded && orchestratorSession && (
|
||||
<Link
|
||||
href={projectSessionPath(project.id, orchestratorSession.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMobileClose?.();
|
||||
}}
|
||||
className="project-sidebar__proj-action"
|
||||
aria-label={`Open ${project.name} orchestrator`}
|
||||
title="Orchestrator"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<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>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="project-sidebar__proj-menu"
|
||||
ref={projectMenuOpenId === project.id ? projectMenuRef : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setProjectMenuOpenId((current) =>
|
||||
current === project.id ? null : project.id,
|
||||
);
|
||||
}}
|
||||
className="project-sidebar__proj-action project-sidebar__proj-action--menu"
|
||||
aria-label={`Project actions for ${project.name}`}
|
||||
aria-expanded={projectMenuOpenId === project.id}
|
||||
aria-haspopup="menu"
|
||||
title="Project actions"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="5" r="1.75" />
|
||||
<circle cx="12" cy="12" r="1.75" />
|
||||
<circle cx="12" cy="19" r="1.75" />
|
||||
</svg>
|
||||
</button>
|
||||
{projectMenuOpenId === project.id ? (
|
||||
<div
|
||||
ref={projectMenuPopoverRef}
|
||||
className="project-sidebar__proj-menu-popover"
|
||||
role="menu"
|
||||
aria-label={`${project.name} actions`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="project-sidebar__proj-menu-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setProjectMenuOpenId(null);
|
||||
setProjectSettingsProjectId(project.id);
|
||||
}}
|
||||
>
|
||||
Project settings
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="project-sidebar__proj-menu-item project-sidebar__proj-menu-item--danger"
|
||||
role="menuitem"
|
||||
onClick={() => void handleRemoveProject(project)}
|
||||
disabled={deletingProjectId === project.id}
|
||||
>
|
||||
{deletingProjectId === project.id ? "Removing..." : "Remove project"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="project-sidebar__footer">
|
||||
<div className="flex items-center gap-1 border-t border-[var(--color-border-subtle)] px-2 py-2">
|
||||
{/* Show killed toggle */}
|
||||
|
||||
{isDegraded ? (
|
||||
<div className="project-sidebar__degraded-note">Config needs repair</div>
|
||||
) : null}
|
||||
|
||||
{/* Sessions */}
|
||||
{!isDegraded && isExpanded && (
|
||||
<div className="project-sidebar__sessions">
|
||||
{isLoading ? (
|
||||
<div className="space-y-2 px-3 py-2" aria-label="Loading sessions">
|
||||
{Array.from({ length: 3 }, (_, index) => (
|
||||
<div
|
||||
key={`${project.id}-loading-${index}`}
|
||||
className="flex items-center gap-3 border border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)] px-2 py-2"
|
||||
>
|
||||
<div className="h-2 w-2 shrink-0 animate-pulse bg-[var(--color-border-strong)]" />
|
||||
<div className="h-3 flex-1 animate-pulse bg-[var(--color-bg-primary)]" />
|
||||
<div className="h-3 w-12 animate-pulse bg-[var(--color-bg-primary)]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : visibleSessions.length > 0 ? (
|
||||
visibleSessions.map((session) => {
|
||||
const level = getAttentionLevel(session);
|
||||
const isSessionActive = activeSessionId === session.id;
|
||||
const title = session.branch ?? getSessionTitle(session);
|
||||
const sessionHref = projectSessionPath(project.id, session.id);
|
||||
return (
|
||||
<a
|
||||
key={session.id}
|
||||
href={sessionHref}
|
||||
onClick={(e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
|
||||
e.preventDefault();
|
||||
navigate(sessionHref, session);
|
||||
}}
|
||||
className={cn(
|
||||
"project-sidebar__sess-row",
|
||||
isSessionActive && "project-sidebar__sess-row--active",
|
||||
)}
|
||||
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>
|
||||
);
|
||||
})
|
||||
) : error ? (
|
||||
<div className="px-3 py-2">
|
||||
<div className="project-sidebar__empty">Failed to load sessions</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 text-xs font-medium text-[var(--color-link)] hover:underline"
|
||||
onClick={onRetry}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="project-sidebar__empty">No sessions shown</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="project-sidebar__footer">
|
||||
<div className="flex items-center gap-1 border-t border-[var(--color-border-subtle)] px-2 py-2">
|
||||
{/* Show killed toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowKilled(!showKilled)}
|
||||
className={cn(
|
||||
"project-sidebar__footer-btn",
|
||||
showKilled && "project-sidebar__footer-btn--active",
|
||||
)}
|
||||
aria-pressed={showKilled}
|
||||
title={showKilled ? "Hide killed sessions" : "Show killed sessions"}
|
||||
aria-label={showKilled ? "Hide killed sessions" : "Show killed sessions"}
|
||||
>
|
||||
{/* skull / terminated icon */}
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 3C7.03 3 3 7.03 3 12c0 3.1 1.5 5.84 3.8 7.55V21h2.4v-1h1.6v1h2.4v-1h1.6v1H17v-1.45A9 9 0 0 0 21 12c0-4.97-4.03-9-9-9z" />
|
||||
<circle cx="9" cy="11" r="1.5" fill="currentColor" stroke="none" />
|
||||
<circle cx="15" cy="11" r="1.5" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* Show done toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDone(!showDone)}
|
||||
className={cn(
|
||||
"project-sidebar__footer-btn",
|
||||
showDone && "project-sidebar__footer-btn--active",
|
||||
)}
|
||||
aria-pressed={showDone}
|
||||
title={showDone ? "Hide completed sessions" : "Show completed sessions"}
|
||||
aria-label={showDone ? "Hide completed sessions" : "Show completed sessions"}
|
||||
>
|
||||
{/* checkmark / done icon */}
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
{/* Sidebar display settings (gear) */}
|
||||
<div className="project-sidebar__settings-wrap" ref={settingsRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowKilled(!showKilled)}
|
||||
onClick={() => setSettingsOpen((v) => !v)}
|
||||
className={cn(
|
||||
"project-sidebar__footer-btn",
|
||||
showKilled && "project-sidebar__footer-btn--active",
|
||||
settingsOpen && "project-sidebar__footer-btn--active",
|
||||
)}
|
||||
aria-pressed={showKilled}
|
||||
title={showKilled ? "Hide killed sessions" : "Show killed sessions"}
|
||||
aria-label={showKilled ? "Hide killed sessions" : "Show killed sessions"}
|
||||
aria-expanded={settingsOpen}
|
||||
aria-haspopup="dialog"
|
||||
title="Sidebar settings"
|
||||
aria-label="Sidebar settings"
|
||||
>
|
||||
{/* skull / terminated icon */}
|
||||
<svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.75" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 3C7.03 3 3 7.03 3 12c0 3.1 1.5 5.84 3.8 7.55V21h2.4v-1h1.6v1h2.4v-1h1.6v1H17v-1.45A9 9 0 0 0 21 12c0-4.97-4.03-9-9-9z" />
|
||||
<circle cx="9" cy="11" r="1.5" fill="currentColor" stroke="none" />
|
||||
<circle cx="15" cy="11" r="1.5" fill="currentColor" stroke="none" />
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* Show done toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDone(!showDone)}
|
||||
className={cn(
|
||||
"project-sidebar__footer-btn",
|
||||
showDone && "project-sidebar__footer-btn--active",
|
||||
)}
|
||||
aria-pressed={showDone}
|
||||
title={showDone ? "Hide completed sessions" : "Show completed sessions"}
|
||||
aria-label={showDone ? "Hide completed sessions" : "Show completed sessions"}
|
||||
>
|
||||
{/* checkmark / done icon */}
|
||||
<svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
{/* Sidebar display settings (gear) */}
|
||||
<div className="project-sidebar__settings-wrap" ref={settingsRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSettingsOpen((v) => !v)}
|
||||
className={cn(
|
||||
"project-sidebar__footer-btn",
|
||||
settingsOpen && "project-sidebar__footer-btn--active",
|
||||
)}
|
||||
aria-expanded={settingsOpen}
|
||||
aria-haspopup="dialog"
|
||||
title="Sidebar settings"
|
||||
{settingsOpen ? (
|
||||
<div
|
||||
ref={settingsPopoverRef}
|
||||
className="project-sidebar__settings-popover"
|
||||
role="dialog"
|
||||
aria-label="Sidebar settings"
|
||||
>
|
||||
<svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.75" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
</button>
|
||||
{settingsOpen ? (
|
||||
<div
|
||||
ref={settingsPopoverRef}
|
||||
className="project-sidebar__settings-popover"
|
||||
role="dialog"
|
||||
aria-label="Sidebar settings"
|
||||
>
|
||||
<label className="project-sidebar__settings-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showSessionId}
|
||||
onChange={(e) => setShowSessionId(e.target.checked)}
|
||||
/>
|
||||
<span>Show session ID</span>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<ThemeToggle className="project-sidebar__theme-toggle" />
|
||||
<label className="project-sidebar__settings-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showSessionId}
|
||||
onChange={(e) => setShowSessionId(e.target.checked)}
|
||||
/>
|
||||
<span>Show session ID</span>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<ThemeToggle className="project-sidebar__theme-toggle" />
|
||||
</div>
|
||||
<AddProjectModal open={addProjectOpen} onClose={() => setAddProjectOpen(false)} />
|
||||
<ProjectSettingsModal
|
||||
open={projectSettingsProjectId !== null}
|
||||
projectId={projectSettingsProjectId}
|
||||
onClose={() => setProjectSettingsProjectId(null)}
|
||||
/>
|
||||
</div>
|
||||
<AddProjectModal open={addProjectOpen} onClose={() => setAddProjectOpen(false)} />
|
||||
<ProjectSettingsModal
|
||||
open={projectSettingsProjectId !== null}
|
||||
projectId={projectSettingsProjectId}
|
||||
onClose={() => setProjectSettingsProjectId(null)}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
getSessionTruthLabel,
|
||||
getPRTruthLabel,
|
||||
getRuntimeTruthLabel,
|
||||
getLifecycleGuidance,
|
||||
isDashboardSessionDone,
|
||||
isDashboardSessionTerminal,
|
||||
isDashboardSessionRestorable,
|
||||
|
|
@ -78,7 +77,11 @@ function getDoneStatusInfo(session: DashboardSession): {
|
|||
};
|
||||
}
|
||||
|
||||
if (session.lifecycle?.sessionState === "terminated" || status === "killed" || status === "terminated") {
|
||||
if (
|
||||
session.lifecycle?.sessionState === "terminated" ||
|
||||
status === "killed" ||
|
||||
status === "terminated"
|
||||
) {
|
||||
return {
|
||||
label: getSessionTruthLabel(session),
|
||||
pillClass: "done-status-pill--killed",
|
||||
|
|
@ -190,9 +193,10 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
|
|||
|
||||
const title = getSessionTitle(session);
|
||||
const footerStatus = getFooterStatusLabel(session, level, Boolean(isReadyToMerge));
|
||||
const visiblePassingChecks = !rateLimited && pr && !prUnenriched
|
||||
? pr.ciChecks.filter((check) => check.status === "passed").slice(0, 3)
|
||||
: [];
|
||||
const visiblePassingChecks =
|
||||
!rateLimited && pr && !prUnenriched
|
||||
? pr.ciChecks.filter((check) => check.status === "passed").slice(0, 3)
|
||||
: [];
|
||||
const isDone = isDashboardSessionDone(session) || level === "done";
|
||||
const truthLine = session.lifecycle
|
||||
? [
|
||||
|
|
@ -201,7 +205,6 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
|
|||
`Runtime ${getRuntimeTruthLabel(session)}`,
|
||||
].join(" · ")
|
||||
: null;
|
||||
const lifecycleGuidance = getLifecycleGuidance(session);
|
||||
const secondaryText = session.issueLabel
|
||||
? `${session.issueLabel}${session.issueTitle ? ` · ${session.issueTitle}` : ""}`
|
||||
: (session.issueTitle ??
|
||||
|
|
@ -326,6 +329,13 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
|
|||
</span>
|
||||
</span>
|
||||
))}
|
||||
<a
|
||||
href={projectSessionPath(session.projectId, session.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="done-meta-chip font-[var(--font-mono)] font-semibold text-[var(--color-accent)] no-underline hover:underline"
|
||||
>
|
||||
View current context
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Expandable detail panel */}
|
||||
|
|
@ -473,9 +483,7 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
|
|||
cardDotTone === "exited" && "card__adot--exited",
|
||||
)}
|
||||
/>
|
||||
<span className="card__id">
|
||||
{session.id}
|
||||
</span>
|
||||
<span className="card__id">{session.id}</span>
|
||||
<div className="flex-1" />
|
||||
{isRestorable && (
|
||||
<button
|
||||
|
|
@ -483,24 +491,30 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
|
|||
e.stopPropagation();
|
||||
onRestore?.(session.id);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 border border-[color-mix(in_srgb,var(--color-accent)_35%,transparent)] px-2 py-0.5 text-[11px] text-[var(--color-accent)] transition-colors hover:bg-[var(--color-tint-blue)]"
|
||||
className="session-card__control session-card__restore-control"
|
||||
>
|
||||
<svg
|
||||
className="session-card__control-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-3 w-3"
|
||||
>
|
||||
<polyline points="1 4 1 10 7 10" />
|
||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
|
||||
<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>
|
||||
restore
|
||||
</button>
|
||||
)}
|
||||
{!isTerminal && (
|
||||
<a
|
||||
href={projectSessionHashPath(session.projectId, session.id, "#session-terminal-section")}
|
||||
href={projectSessionHashPath(
|
||||
session.projectId,
|
||||
session.id,
|
||||
"#session-terminal-section",
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="session-card__control session-card__terminal-link"
|
||||
>
|
||||
|
|
@ -522,17 +536,11 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
|
|||
|
||||
<div className="session-card__body flex min-h-0 flex-1 flex-col">
|
||||
<div className="card__title-wrap">
|
||||
<p className="card__title">
|
||||
{title}
|
||||
</p>
|
||||
<p className="card__title">{title}</p>
|
||||
</div>
|
||||
|
||||
<div className="card__meta">
|
||||
{session.branch && (
|
||||
<span className="card__branch">
|
||||
{session.branch}
|
||||
</span>
|
||||
)}
|
||||
{session.branch && <span className="card__branch">{session.branch}</span>}
|
||||
{session.branch && pr ? (
|
||||
<span className="card__meta-sep" aria-hidden="true">
|
||||
·
|
||||
|
|
@ -569,15 +577,21 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
|
|||
<div className="px-[10px] pb-[5px]">
|
||||
{level === "merge" || isReadyToMerge ? (
|
||||
<p className="session-card__secondary session-card__secondary--merge">
|
||||
<svg width="9" height="9" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<svg
|
||||
width="9"
|
||||
height="9"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
<span>{secondaryText}</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="session-card__secondary">
|
||||
{secondaryText}
|
||||
</p>
|
||||
<p className="session-card__secondary">{secondaryText}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -590,14 +604,6 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
|
|||
</div>
|
||||
)}
|
||||
|
||||
{lifecycleGuidance && (
|
||||
<div className="px-[10px] pb-[6px]">
|
||||
<p className="inline-flex items-center gap-1 rounded-full border border-[color-mix(in_srgb,var(--color-status-attention)_35%,transparent)] bg-[color-mix(in_srgb,var(--color-status-attention)_9%,transparent)] px-2 py-1 text-[10px] leading-none text-[var(--color-status-attention)]">
|
||||
{lifecycleGuidance}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rateLimited && pr?.state === "open" && (
|
||||
<div className="px-[10px] pb-[5px]">
|
||||
<span className="inline-flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
|
||||
|
|
@ -621,7 +627,15 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
|
|||
{visiblePassingChecks.map((check) => {
|
||||
const chipContent = (
|
||||
<>
|
||||
<svg width="8" height="8" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<svg
|
||||
width="8"
|
||||
height="8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
{check.name}
|
||||
|
|
@ -650,10 +664,7 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
|
|||
{!rateLimited && alerts.length > 0 && (
|
||||
<div className="card__alerts flex flex-col">
|
||||
{alerts.slice(0, 3).map((alert) => (
|
||||
<div
|
||||
key={alert.key}
|
||||
className={cn("alert-row", `alert-row--${alert.type}`)}
|
||||
>
|
||||
<div key={alert.key} className={cn("alert-row", `alert-row--${alert.type}`)}>
|
||||
<span className="alert-row__icon">{alert.icon}</span>
|
||||
<span className="alert-row__text">
|
||||
<a
|
||||
|
|
@ -671,7 +682,8 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
|
|||
</a>
|
||||
{alert.notified && (
|
||||
<span className="alert-row__notified" title="Agent has been notified">
|
||||
{" "}· notified
|
||||
{" "}
|
||||
· notified
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
|
@ -700,7 +712,16 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
|
|||
<div className="quick-reply" onClick={(e) => e.stopPropagation()}>
|
||||
{session.summary && !session.summaryIsFallback && (
|
||||
<div className="card__agent-msg">
|
||||
<svg className="card__agent-msg-icon" width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<svg
|
||||
className="card__agent-msg-icon"
|
||||
width="10"
|
||||
height="10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
<span>{session.summary}</span>
|
||||
|
|
@ -713,55 +734,61 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
|
|||
>
|
||||
View current context →
|
||||
</a>
|
||||
<div className="card__presets">
|
||||
<button
|
||||
className="card__preset"
|
||||
onClick={() => void handleQuickReply("continue")}
|
||||
disabled={sendingQuickReply !== null}
|
||||
>
|
||||
{sendingQuickReply === "continue"
|
||||
? "Sending..."
|
||||
: sentQuickReply === "continue"
|
||||
? "Sent"
|
||||
: "Continue"}
|
||||
</button>
|
||||
<button
|
||||
className="card__preset"
|
||||
onClick={() => void handleQuickReply("abort")}
|
||||
disabled={sendingQuickReply !== null}
|
||||
>
|
||||
{sendingQuickReply === "abort"
|
||||
? "Sending..."
|
||||
: sentQuickReply === "abort"
|
||||
? "Sent"
|
||||
: "Abort"}
|
||||
</button>
|
||||
<button
|
||||
className="card__preset"
|
||||
onClick={() => void handleQuickReply("skip")}
|
||||
disabled={sendingQuickReply !== null}
|
||||
>
|
||||
{sendingQuickReply === "skip"
|
||||
? "Sending..."
|
||||
: sentQuickReply === "skip"
|
||||
? "Sent"
|
||||
: "Skip"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="card__reply-wrap">
|
||||
<textarea
|
||||
className="card__reply"
|
||||
placeholder={sendingQuickReply !== null ? "Sending..." : "Type a reply... (Enter to send)"}
|
||||
aria-label="Type a reply to the agent"
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
void handleReplyKeyDown(e);
|
||||
}}
|
||||
rows={1}
|
||||
disabled={sendingQuickReply !== null}
|
||||
/>
|
||||
</div>
|
||||
{!isTerminal && (
|
||||
<>
|
||||
<div className="card__presets">
|
||||
<button
|
||||
className="card__preset"
|
||||
onClick={() => void handleQuickReply("continue")}
|
||||
disabled={sendingQuickReply !== null}
|
||||
>
|
||||
{sendingQuickReply === "continue"
|
||||
? "Sending..."
|
||||
: sentQuickReply === "continue"
|
||||
? "Sent"
|
||||
: "Continue"}
|
||||
</button>
|
||||
<button
|
||||
className="card__preset"
|
||||
onClick={() => void handleQuickReply("abort")}
|
||||
disabled={sendingQuickReply !== null}
|
||||
>
|
||||
{sendingQuickReply === "abort"
|
||||
? "Sending..."
|
||||
: sentQuickReply === "abort"
|
||||
? "Sent"
|
||||
: "Abort"}
|
||||
</button>
|
||||
<button
|
||||
className="card__preset"
|
||||
onClick={() => void handleQuickReply("skip")}
|
||||
disabled={sendingQuickReply !== null}
|
||||
>
|
||||
{sendingQuickReply === "skip"
|
||||
? "Sending..."
|
||||
: sentQuickReply === "skip"
|
||||
? "Sent"
|
||||
: "Skip"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="card__reply-wrap">
|
||||
<textarea
|
||||
className="card__reply"
|
||||
placeholder={
|
||||
sendingQuickReply !== null ? "Sending..." : "Type a reply... (Enter to send)"
|
||||
}
|
||||
aria-label="Type a reply to the agent"
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
void handleReplyKeyDown(e);
|
||||
}}
|
||||
rows={1}
|
||||
disabled={sendingQuickReply !== null}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -809,7 +836,9 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
|
|||
)}
|
||||
>
|
||||
{killConfirming ? (
|
||||
<span className="font-mono text-[10px] font-semibold tracking-[0.04em]">kill?</span>
|
||||
<span className="font-mono text-[10px] font-semibold tracking-[0.04em]">
|
||||
kill?
|
||||
</span>
|
||||
) : (
|
||||
<svg
|
||||
className="session-card__control-icon"
|
||||
|
|
@ -852,7 +881,8 @@ function getFooterStatusLabel(
|
|||
if (isReadyToMerge || level === "merge") return "mergeable";
|
||||
if (session.lifecycle?.sessionState === "detecting") return "detecting";
|
||||
if (level === "respond") return getSessionTruthLabel(session);
|
||||
if (session.lifecycle?.prReason === "ci_failing" || session.status === "ci_failed") return "ci failing";
|
||||
if (session.lifecycle?.prReason === "ci_failing" || session.status === "ci_failed")
|
||||
return "ci failing";
|
||||
if (level === "review") return getPRTruthLabel(session);
|
||||
if (level === "working") return getSessionTruthLabel(session);
|
||||
return getSessionTruthLabel(session);
|
||||
|
|
@ -949,7 +979,15 @@ function getAlerts(session: DashboardSession): Alert[] {
|
|||
key: "review",
|
||||
type: "review",
|
||||
icon: (
|
||||
<svg width="9" height="9" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<svg
|
||||
width="9"
|
||||
height="9"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
import { CI_STATUS } from "@aoagents/ao-core/types";
|
||||
import { cn } from "@/lib/cn";
|
||||
import dynamic from "next/dynamic";
|
||||
import { getSessionTitle } from "@/lib/format";
|
||||
import { formatRelativeTime, getSessionTitle } from "@/lib/format";
|
||||
import { buildGitHubCompareUrl } from "@/lib/github-links";
|
||||
import type { ProjectInfo } from "@/lib/project-name";
|
||||
import { SidebarContext } from "./workspace/SidebarContext";
|
||||
|
|
@ -59,7 +59,6 @@ interface SessionDetailProps {
|
|||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
const activityMeta: Record<string, { label: string; color: string }> = {
|
||||
active: { label: "Active", color: "var(--color-status-working)" },
|
||||
ready: { label: "Ready", color: "var(--color-status-ready)" },
|
||||
|
|
@ -91,29 +90,153 @@ function normalizeActivityLabelForClass(activityLabel: string): string {
|
|||
return activityLabel.toLowerCase().replace(/\s+/g, "-");
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact agent-zone breakdown for orchestrator sessions, rendered inline in
|
||||
* the topbar pill row. Replaces the previous stacked status strip that sat
|
||||
* above the terminal.
|
||||
*/
|
||||
function formatEndedTime(isoDate: string | null | undefined): string {
|
||||
if (!isoDate) return "Unknown";
|
||||
const timestamp = new Date(isoDate).getTime();
|
||||
if (!Number.isFinite(timestamp)) return "Unknown";
|
||||
return formatRelativeTime(timestamp);
|
||||
}
|
||||
|
||||
function getEndedSessionReason(session: DashboardSession): string {
|
||||
if (session.lifecycle?.runtime.reasonLabel) {
|
||||
return session.lifecycle.runtime.reasonLabel;
|
||||
}
|
||||
if (session.status === "killed") return "Manually stopped";
|
||||
if (session.status === "terminated") return "Runtime unavailable";
|
||||
if (session.status === "done" || session.status === "merged") return "Work completed";
|
||||
return "Terminal ended";
|
||||
}
|
||||
|
||||
function getEndedSessionSummary(session: DashboardSession, headline: string): string {
|
||||
const pinnedSummary = session.metadata["pinnedSummary"];
|
||||
if (pinnedSummary) return pinnedSummary;
|
||||
if (session.summary && !session.summaryIsFallback) return session.summary;
|
||||
if (session.lifecycle?.summary) return session.lifecycle.summary;
|
||||
if (session.userPrompt) return session.userPrompt;
|
||||
if (session.summary) return session.summary;
|
||||
return headline;
|
||||
}
|
||||
|
||||
function SessionEndedSummary({
|
||||
session,
|
||||
headline,
|
||||
pr,
|
||||
dashboardHref,
|
||||
}: {
|
||||
session: DashboardSession;
|
||||
headline: string;
|
||||
pr: DashboardPR | null;
|
||||
dashboardHref: string;
|
||||
}) {
|
||||
const reason = getEndedSessionReason(session);
|
||||
const summary = getEndedSessionSummary(session, headline);
|
||||
const endedAt =
|
||||
session.lifecycle?.session.terminatedAt ??
|
||||
session.lifecycle?.session.completedAt ??
|
||||
session.lifecycle?.session.lastTransitionAt ??
|
||||
session.lastActivityAt;
|
||||
const runtimeLabel = session.lifecycle?.runtime.label ?? "Unavailable";
|
||||
const prLabel = pr
|
||||
? pr.state === "merged"
|
||||
? "Merged"
|
||||
: pr.state === "closed"
|
||||
? "Closed"
|
||||
: pr.mergeability.mergeable
|
||||
? "Open, merge-ready"
|
||||
: "Open"
|
||||
: "No PR";
|
||||
|
||||
return (
|
||||
<section className="session-ended-summary" aria-label="Session ended summary">
|
||||
<div className="session-ended-summary__panel">
|
||||
<div className="session-ended-summary__eyebrow">Terminal ended</div>
|
||||
<div className="session-ended-summary__header">
|
||||
<div className="session-ended-summary__icon" aria-hidden="true">
|
||||
<svg fill="none" stroke="currentColor" strokeWidth="1.8" viewBox="0 0 24 24">
|
||||
<rect x="3" y="5" width="18" height="14" rx="3" />
|
||||
<path d="M7 10l3 2-3 2" />
|
||||
<path d="M13 15h4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="session-ended-summary__title-group">
|
||||
<h2 className="session-ended-summary__title">{headline}</h2>
|
||||
<p className="session-ended-summary__subtitle">
|
||||
{reason}. The live terminal is gone, but the session context is still available.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="session-ended-summary__body">
|
||||
<div className="session-ended-summary__section">
|
||||
<div className="session-ended-summary__label">What happened</div>
|
||||
<p className="session-ended-summary__copy">{summary}</p>
|
||||
</div>
|
||||
|
||||
<div className="session-ended-summary__facts" aria-label="Session facts">
|
||||
<div className="session-ended-summary__fact">
|
||||
<span>Session</span>
|
||||
<strong>{session.id}</strong>
|
||||
</div>
|
||||
<div className="session-ended-summary__fact">
|
||||
<span>Ended</span>
|
||||
<strong>{formatEndedTime(endedAt)}</strong>
|
||||
</div>
|
||||
<div className="session-ended-summary__fact">
|
||||
<span>Runtime</span>
|
||||
<strong>{runtimeLabel}</strong>
|
||||
</div>
|
||||
<div className="session-ended-summary__fact">
|
||||
<span>PR</span>
|
||||
<strong>{prLabel}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="session-ended-summary__links">
|
||||
{pr ? (
|
||||
<a
|
||||
href={pr.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="session-ended-summary__primary"
|
||||
>
|
||||
Open PR #{pr.number}
|
||||
</a>
|
||||
) : null}
|
||||
<a href={dashboardHref} className="session-ended-summary__secondary">
|
||||
Back to dashboard
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{session.lifecycle?.evidence ? (
|
||||
<div className="session-ended-summary__evidence">
|
||||
<span>Evidence</span>
|
||||
<code>{session.lifecycle.evidence}</code>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function OrchestratorZonePills({ zones }: { zones: OrchestratorZones }) {
|
||||
const stats: Array<{ value: number; label: string; toneClass: string }> = [
|
||||
{ value: zones.merge, label: "merge", toneClass: "topbar-zone-pill--merge" },
|
||||
{ value: zones.merge, label: "merge", toneClass: "topbar-zone-pill--merge" },
|
||||
{ value: zones.respond, label: "respond", toneClass: "topbar-zone-pill--respond" },
|
||||
{ value: zones.review, label: "review", toneClass: "topbar-zone-pill--review" },
|
||||
{ value: zones.review, label: "review", toneClass: "topbar-zone-pill--review" },
|
||||
{ value: zones.working, label: "working", toneClass: "topbar-zone-pill--working" },
|
||||
{ value: zones.pending, label: "pending", toneClass: "topbar-zone-pill--pending" },
|
||||
{ value: zones.done, label: "done", toneClass: "topbar-zone-pill--done" },
|
||||
].filter((s) => s.value > 0);
|
||||
{ value: zones.done, label: "done", toneClass: "topbar-zone-pill--done" },
|
||||
].filter((stat) => stat.value > 0);
|
||||
|
||||
if (stats.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{stats.map((s) => (
|
||||
<span key={s.label} className={cn("topbar-zone-pill", s.toneClass)}>
|
||||
<span className="topbar-zone-pill__value">{s.value}</span>
|
||||
<span className="topbar-zone-pill__label">{s.label}</span>
|
||||
{stats.map((stat) => (
|
||||
<span key={stat.label} className={cn("topbar-zone-pill", stat.toneClass)}>
|
||||
<span className="topbar-zone-pill__value">{stat.value}</span>
|
||||
<span className="topbar-zone-pill__label">{stat.label}</span>
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
|
|
@ -186,7 +309,9 @@ export function SessionDetail({
|
|||
|
||||
const handleKill = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${encodeURIComponent(session.id)}/kill`, { method: "POST" });
|
||||
const res = await fetch(`/api/sessions/${encodeURIComponent(session.id)}/kill`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
if (projectOrchestratorId) {
|
||||
router.push(projectSessionPath(session.projectId, projectOrchestratorId));
|
||||
|
|
@ -200,8 +325,13 @@ export function SessionDetail({
|
|||
|
||||
const handleRestore = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${encodeURIComponent(session.id)}/restore`, { method: "POST" });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const res = await fetch(`/api/sessions/${encodeURIComponent(session.id)}/restore`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const message = await res.text().catch(() => "");
|
||||
throw new Error(message || `HTTP ${res.status}`);
|
||||
}
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
console.error("Failed to restore session:", err);
|
||||
|
|
@ -232,12 +362,11 @@ export function SessionDetail({
|
|||
|
||||
const headerProjectLabel =
|
||||
projects.find((project) => project.id === session.projectId)?.name ?? session.projectId;
|
||||
const showHeaderProjectLabel =
|
||||
headerProjectLabel.trim().toLowerCase() !== "agent orchestrator";
|
||||
const showHeaderProjectLabel = headerProjectLabel.trim().toLowerCase() !== "agent orchestrator";
|
||||
const orchestratorHref = useMemo(() => {
|
||||
if (isOrchestrator) return projectSessionPath(session.projectId, session.id);
|
||||
if (!projectOrchestratorId) return null;
|
||||
return projectSessionPath(session.projectId, projectOrchestratorId);
|
||||
if (projectOrchestratorId) return projectSessionPath(session.projectId, projectOrchestratorId);
|
||||
return null;
|
||||
}, [isOrchestrator, projectOrchestratorId, session.id, session.projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -258,238 +387,309 @@ export function SessionDetail({
|
|||
|
||||
return (
|
||||
<SidebarContext.Provider value={{ onToggleSidebar: handleToggleSidebar, mobileSidebarOpen }}>
|
||||
<div className="dashboard-app-shell">
|
||||
<header className="dashboard-app-header">
|
||||
{projects.length > 0 ? (
|
||||
<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>
|
||||
) : null}
|
||||
<div className="dashboard-app-header__brand dashboard-app-header__brand--hide-mobile">
|
||||
<span>Agent Orchestrator</span>
|
||||
</div>
|
||||
{/* Desktop sep (hidden on mobile since brand is hidden) */}
|
||||
{showHeaderProjectLabel && (
|
||||
<span className="dashboard-app-header__sep topbar-desktop-only" aria-hidden="true" />
|
||||
)}
|
||||
{/* Project name + pills: stacked column on mobile, inline on desktop.
|
||||
<div className="dashboard-app-shell">
|
||||
<header className="dashboard-app-header">
|
||||
{projects.length > 0 ? (
|
||||
<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>
|
||||
) : null}
|
||||
<div className="dashboard-app-header__brand dashboard-app-header__brand--hide-mobile">
|
||||
<span>Agent Orchestrator</span>
|
||||
</div>
|
||||
{/* Desktop sep (hidden on mobile since brand is hidden) */}
|
||||
{showHeaderProjectLabel && (
|
||||
<span className="dashboard-app-header__sep topbar-desktop-only" aria-hidden="true" />
|
||||
)}
|
||||
{/* Project name + pills: stacked column on mobile, inline on desktop.
|
||||
On mobile the project name + session id share line 1 (so ao-N stays
|
||||
visually bound to the project), pills stack below on line 2. */}
|
||||
<div className="topbar-project-pills-group">
|
||||
<div className="topbar-project-line">
|
||||
{showHeaderProjectLabel && (
|
||||
<span className="dashboard-app-header__project">{headerProjectLabel}</span>
|
||||
)}
|
||||
<span className="dashboard-app-header__session-id topbar-mobile-only">
|
||||
{session.id}
|
||||
</span>
|
||||
{isOrchestrator && (
|
||||
<span className="session-detail-mode-badge">orchestrator</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="topbar-session-pills">
|
||||
<div className={cn("topbar-status-pill", `topbar-status-pill--${normalizeActivityLabelForClass(activity.label)}`)}>
|
||||
<span className="topbar-status-pill__dot" style={{ background: activity.color }} />
|
||||
<span className="topbar-status-pill__label">{activity.label}</span>
|
||||
</div>
|
||||
{session.branch ? (
|
||||
pr ? (
|
||||
<a
|
||||
href={buildGitHubBranchUrl(pr)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="topbar-branch-pill topbar-branch-pill--link"
|
||||
>
|
||||
{session.branch}
|
||||
</a>
|
||||
) : (
|
||||
<span className="topbar-branch-pill">{session.branch}</span>
|
||||
)
|
||||
) : null}
|
||||
{isOrchestrator && orchestratorZones ? (
|
||||
<OrchestratorZonePills zones={orchestratorZones} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{/* Desktop-only session title + session id.
|
||||
On mobile the session id lives next to the project name (above). */}
|
||||
<span className="dashboard-app-header__sep topbar-desktop-only" aria-hidden="true" />
|
||||
<span className="dashboard-app-header__session-title topbar-desktop-only">{headline}</span>
|
||||
<span className="dashboard-app-header__session-id topbar-desktop-only">{session.id}</span>
|
||||
<div className="dashboard-app-header__spacer" />
|
||||
<div className="dashboard-app-header__actions">
|
||||
{pr ? (
|
||||
<div className="topbar-pr-btn-wrap" ref={prPopoverRef}>
|
||||
{/* Anchored to the actual PR URL so ctrl/cmd-click opens the PR on
|
||||
GitHub in a new tab. Plain click toggles the details popover. */}
|
||||
<a
|
||||
href={pr.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn("dashboard-app-btn topbar-pr-btn", prPopoverOpen && "topbar-pr-btn--open")}
|
||||
onClick={(e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
|
||||
e.preventDefault();
|
||||
setPrPopoverOpen((v) => !v);
|
||||
}}
|
||||
aria-expanded={prPopoverOpen}
|
||||
aria-label={`PR #${pr.number}`}
|
||||
>
|
||||
<span className={cn(
|
||||
"topbar-pr-dot",
|
||||
allGreen
|
||||
? "topbar-pr-dot--green"
|
||||
: (pr.ciStatus === CI_STATUS.FAILING || pr.reviewDecision === "changes_requested")
|
||||
? "topbar-pr-dot--red"
|
||||
: "topbar-pr-dot--amber",
|
||||
)} />
|
||||
PR #{pr.number}
|
||||
<svg width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2.5"
|
||||
viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d={prPopoverOpen ? "M18 15l-6-6-6 6" : "M6 9l6 6 6-6"} />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
{prPopoverOpen && (
|
||||
<div className="topbar-pr-popover">
|
||||
<SessionDetailPRCard pr={pr} sessionId={session.id} metadata={session.metadata} />
|
||||
</div>
|
||||
<div className="topbar-project-pills-group">
|
||||
<div className="topbar-project-line">
|
||||
{showHeaderProjectLabel && (
|
||||
<span className="dashboard-app-header__project">{headerProjectLabel}</span>
|
||||
)}
|
||||
<span className="dashboard-app-header__session-id topbar-mobile-only">
|
||||
{session.id}
|
||||
</span>
|
||||
{isOrchestrator && (
|
||||
<span className="session-detail-mode-badge">orchestrator</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="topbar-session-pills">
|
||||
<div
|
||||
className={cn(
|
||||
"topbar-status-pill",
|
||||
`topbar-status-pill--${normalizeActivityLabelForClass(activity.label)}`,
|
||||
)}
|
||||
>
|
||||
<span className="topbar-status-pill__dot" style={{ background: activity.color }} />
|
||||
<span className="topbar-status-pill__label">{activity.label}</span>
|
||||
</div>
|
||||
{session.branch ? (
|
||||
pr ? (
|
||||
<a
|
||||
href={buildGitHubBranchUrl(pr)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="topbar-branch-pill topbar-branch-pill--link"
|
||||
>
|
||||
{session.branch}
|
||||
</a>
|
||||
) : (
|
||||
<span className="topbar-branch-pill">{session.branch}</span>
|
||||
)
|
||||
) : null}
|
||||
{isOrchestrator && orchestratorZones ? (
|
||||
<OrchestratorZonePills zones={orchestratorZones} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{/* Desktop-only session title + session id.
|
||||
On mobile the session id lives next to the project name (above). */}
|
||||
<span className="dashboard-app-header__sep topbar-desktop-only" aria-hidden="true" />
|
||||
<span className="dashboard-app-header__session-title topbar-desktop-only">
|
||||
{headline}
|
||||
</span>
|
||||
<span className="dashboard-app-header__session-id topbar-desktop-only">
|
||||
{session.id}
|
||||
</span>
|
||||
<div className="dashboard-app-header__spacer" />
|
||||
<div className="dashboard-app-header__actions">
|
||||
{pr ? (
|
||||
<div className="topbar-pr-btn-wrap" ref={prPopoverRef}>
|
||||
{/* Anchored to the actual PR URL so ctrl/cmd-click opens the PR on
|
||||
GitHub in a new tab. Plain click toggles the details popover. */}
|
||||
<a
|
||||
href={pr.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"dashboard-app-btn topbar-pr-btn",
|
||||
prPopoverOpen && "topbar-pr-btn--open",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
|
||||
e.preventDefault();
|
||||
setPrPopoverOpen((v) => !v);
|
||||
}}
|
||||
aria-expanded={prPopoverOpen}
|
||||
aria-label={`PR #${pr.number}`}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"topbar-pr-dot",
|
||||
allGreen
|
||||
? "topbar-pr-dot--green"
|
||||
: pr.ciStatus === CI_STATUS.FAILING ||
|
||||
pr.reviewDecision === "changes_requested"
|
||||
? "topbar-pr-dot--red"
|
||||
: "topbar-pr-dot--amber",
|
||||
)}
|
||||
/>
|
||||
PR #{pr.number}
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={prPopoverOpen ? "M18 15l-6-6-6 6" : "M6 9l6 6 6-6"} />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
{/* Restore is available for any restorable session; Kill stays worker-only. */}
|
||||
{isRestorable ? (
|
||||
<button type="button" className="dashboard-app-btn" onClick={handleRestore}>
|
||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<polyline points="1 4 1 10 7 10" />
|
||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
|
||||
</svg>
|
||||
<span className="topbar-btn-label">Restore</span>
|
||||
</button>
|
||||
) : !isOrchestrator && !terminalEnded ? (
|
||||
<button type="button" className="dashboard-app-btn dashboard-app-btn--danger" onClick={handleKill}>
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
{prPopoverOpen && (
|
||||
<div className="topbar-pr-popover">
|
||||
<SessionDetailPRCard
|
||||
pr={pr}
|
||||
sessionId={session.id}
|
||||
metadata={session.metadata}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Restore is available for any restorable session; Kill stays worker-only. */}
|
||||
{isRestorable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="dashboard-app-btn dashboard-app-btn--restore"
|
||||
onClick={handleRestore}
|
||||
>
|
||||
<svg
|
||||
className="topbar-action-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<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">Restore</span>
|
||||
</button>
|
||||
) : !isOrchestrator && !terminalEnded ? (
|
||||
<button
|
||||
type="button"
|
||||
className="dashboard-app-btn dashboard-app-btn--danger"
|
||||
onClick={handleKill}
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
<span className="topbar-btn-label">Kill</span>
|
||||
</button>
|
||||
) : null}
|
||||
) : null}
|
||||
|
||||
{!isOrchestrator && orchestratorHref ? (
|
||||
<a
|
||||
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"
|
||||
{orchestratorHref ? (
|
||||
<a
|
||||
href={orchestratorHref}
|
||||
className="dashboard-app-btn dashboard-app-btn--amber topbar-desktop-only"
|
||||
aria-label="Orchestrator"
|
||||
>
|
||||
<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>
|
||||
<span className="topbar-btn-label">Orchestrator</span>
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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}
|
||||
loading={sidebarLoading}
|
||||
error={sidebarError}
|
||||
onRetry={onRetrySidebar}
|
||||
activeProjectId={session.projectId}
|
||||
activeSessionId={session.id}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggleCollapsed={() => setSidebarCollapsed((current) => !current)}
|
||||
onMobileClose={() => setMobileSidebarOpen(false)}
|
||||
/>
|
||||
<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>
|
||||
<span className="topbar-btn-label">Orchestrator</span>
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{mobileSidebarOpen && (
|
||||
<div className="sidebar-mobile-backdrop" onClick={() => setMobileSidebarOpen(false)} />
|
||||
)}
|
||||
</header>
|
||||
|
||||
<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)]">
|
||||
{/* Terminal — fills all remaining height */}
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{!showTerminal ? (
|
||||
<div className="session-detail-terminal-placeholder h-full" />
|
||||
) : terminalEnded ? (
|
||||
<div className="terminal-exited-placeholder h-full">
|
||||
<span className="terminal-exited-placeholder__text">Terminal session has ended</span>
|
||||
</div>
|
||||
) : (
|
||||
<DirectTerminal
|
||||
sessionId={session.id}
|
||||
startFullscreen={startFullscreen}
|
||||
variant={terminalVariant}
|
||||
appearance="dark"
|
||||
height="100%"
|
||||
isOpenCodeSession={isOpenCodeSession}
|
||||
reloadCommand={isOpenCodeSession ? reloadCommand : undefined}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
<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}
|
||||
loading={sidebarLoading}
|
||||
error={sidebarError}
|
||||
onRetry={onRetrySidebar}
|
||||
activeProjectId={session.projectId}
|
||||
activeSessionId={session.id}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggleCollapsed={() => setSidebarCollapsed((current) => !current)}
|
||||
onMobileClose={() => setMobileSidebarOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
) : 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)]">
|
||||
{/* Terminal — fills all remaining height */}
|
||||
<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}
|
||||
startFullscreen={startFullscreen}
|
||||
variant={terminalVariant}
|
||||
appearance="dark"
|
||||
height="100%"
|
||||
isOpenCodeSession={isOpenCodeSession}
|
||||
reloadCommand={isOpenCodeSession ? reloadCommand : undefined}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Session detail PR card ────────────────────────────────────────────
|
||||
|
||||
function SessionDetailPRCard({ pr, sessionId, metadata }: { pr: DashboardPR; sessionId: string; metadata: Record<string, string> }) {
|
||||
function SessionDetailPRCard({
|
||||
pr,
|
||||
sessionId,
|
||||
metadata,
|
||||
}: {
|
||||
pr: DashboardPR;
|
||||
sessionId: string;
|
||||
metadata: Record<string, string>;
|
||||
}) {
|
||||
const [sendingComments, setSendingComments] = useState<Set<string>>(new Set());
|
||||
const [sentComments, setSentComments] = useState<Set<string>>(new Set());
|
||||
const [errorComments, setErrorComments] = useState<Set<string>>(new Set());
|
||||
|
|
@ -565,7 +765,8 @@ function SessionDetailPRCard({ pr, sessionId, metadata }: { pr: DashboardPR; ses
|
|||
const fileCount = pr.changedFiles ?? 0;
|
||||
|
||||
const mergeabilityReliable = !isPRUnenriched(pr) && !isPRRateLimited(pr);
|
||||
const hasConflicts = mergeabilityReliable && pr.state !== "merged" && !pr.mergeability.noConflicts;
|
||||
const hasConflicts =
|
||||
mergeabilityReliable && pr.state !== "merged" && !pr.mergeability.noConflicts;
|
||||
const showConflictActions = hasConflicts && pr.state === "open";
|
||||
const compareUrl = showConflictActions ? buildGitHubCompareUrl(pr) : "";
|
||||
|
||||
|
|
@ -611,9 +812,7 @@ function SessionDetailPRCard({ pr, sessionId, metadata }: { pr: DashboardPR; ses
|
|||
{fileCount} file{fileCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
{pr.isDraft && (
|
||||
<span className="session-detail-pr-card__diff-label">Draft</span>
|
||||
)}
|
||||
{pr.isDraft && <span className="session-detail-pr-card__diff-label">Draft</span>}
|
||||
{pr.state === "merged" && (
|
||||
<span className="session-detail-pr-card__diff-label">Merged</span>
|
||||
)}
|
||||
|
|
@ -691,10 +890,19 @@ function SessionDetailPRCard({ pr, sessionId, metadata }: { pr: DashboardPR; ses
|
|||
check.status === "passed" && "session-detail-ci-chip--pass",
|
||||
check.status === "failed" && "session-detail-ci-chip--fail",
|
||||
check.status === "pending" && "session-detail-ci-chip--pending",
|
||||
check.status !== "passed" && check.status !== "failed" && check.status !== "pending" && "session-detail-ci-chip--queued",
|
||||
check.status !== "passed" &&
|
||||
check.status !== "failed" &&
|
||||
check.status !== "pending" &&
|
||||
"session-detail-ci-chip--queued",
|
||||
)}
|
||||
>
|
||||
{check.status === "passed" ? "\u2713" : check.status === "failed" ? "\u2717" : check.status === "pending" ? "\u25CF" : "\u25CB"}{" "}
|
||||
{check.status === "passed"
|
||||
? "\u2713"
|
||||
: check.status === "failed"
|
||||
? "\u2717"
|
||||
: check.status === "pending"
|
||||
? "\u25CF"
|
||||
: "\u25CB"}{" "}
|
||||
{check.name}
|
||||
</span>
|
||||
);
|
||||
|
|
@ -817,14 +1025,16 @@ function buildBlockerChips(pr: DashboardPR, metadata: Record<string, string>): B
|
|||
const hasChangesRequested =
|
||||
pr.reviewDecision === "changes_requested" || lifecycleStatus === "changes_requested";
|
||||
const mergeabilityReliable = !isPRUnenriched(pr) && !isPRRateLimited(pr);
|
||||
const hasConflicts = mergeabilityReliable && pr.state !== "merged" && !pr.mergeability.noConflicts;
|
||||
const hasConflicts =
|
||||
mergeabilityReliable && pr.state !== "merged" && !pr.mergeability.noConflicts;
|
||||
|
||||
if (ciIsFailing) {
|
||||
const failCount = pr.ciChecks.filter((c) => c.status === "failed").length;
|
||||
chips.push({
|
||||
icon: "\u2717",
|
||||
variant: "fail",
|
||||
text: failCount > 0 ? `${failCount} check${failCount !== 1 ? "s" : ""} failing` : "CI failing",
|
||||
text:
|
||||
failCount > 0 ? `${failCount} check${failCount !== 1 ? "s" : ""} failing` : "CI failing",
|
||||
notified: ciNotified,
|
||||
});
|
||||
} else if (pr.ciStatus === CI_STATUS.PENDING) {
|
||||
|
|
@ -832,13 +1042,23 @@ function buildBlockerChips(pr: DashboardPR, metadata: Record<string, string>): B
|
|||
}
|
||||
|
||||
if (hasChangesRequested) {
|
||||
chips.push({ icon: "\u2717", variant: "fail", text: "Changes requested", notified: reviewNotified });
|
||||
chips.push({
|
||||
icon: "\u2717",
|
||||
variant: "fail",
|
||||
text: "Changes requested",
|
||||
notified: reviewNotified,
|
||||
});
|
||||
} else if (!pr.mergeability.approved) {
|
||||
chips.push({ icon: "\u25CB", variant: "muted", text: "Awaiting reviewer" });
|
||||
}
|
||||
|
||||
if (hasConflicts) {
|
||||
chips.push({ icon: "\u2717", variant: "fail", text: "Merge conflicts", notified: conflictNotified });
|
||||
chips.push({
|
||||
icon: "\u2717",
|
||||
variant: "fail",
|
||||
text: "Merge conflicts",
|
||||
notified: conflictNotified,
|
||||
});
|
||||
}
|
||||
|
||||
if (pr.isDraft) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import { Dashboard } from "../Dashboard";
|
||||
|
||||
const eventSourceConstructorMock = vi.hoisted(() => vi.fn());
|
||||
let lastEventSourceMock: {
|
||||
onmessage: ((event: MessageEvent) => void) | null;
|
||||
onerror: ((event?: Event) => void) | null;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
|
||||
|
|
@ -16,6 +21,7 @@ beforeEach(() => {
|
|||
onerror: null,
|
||||
close: vi.fn(),
|
||||
};
|
||||
lastEventSourceMock = eventSourceMock;
|
||||
eventSourceConstructorMock.mockReset();
|
||||
eventSourceConstructorMock.mockImplementation(() => eventSourceMock as unknown as EventSource);
|
||||
global.EventSource = Object.assign(eventSourceConstructorMock, {
|
||||
|
|
@ -110,6 +116,35 @@ describe("Dashboard empty state", () => {
|
|||
expect(eventSourceConstructorMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows a live load error banner when the SSE stream reports a backend failure", async () => {
|
||||
render(<Dashboard initialSessions={[]} />);
|
||||
|
||||
await waitFor(() => {
|
||||
lastEventSourceMock.onmessage?.({
|
||||
data: JSON.stringify({ type: "error", error: "session list timed out" }),
|
||||
} as MessageEvent);
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("session list timed out");
|
||||
});
|
||||
});
|
||||
|
||||
it("clears a live load error banner after a healthy snapshot with unchanged sessions", async () => {
|
||||
render(<Dashboard initialSessions={[]} />);
|
||||
|
||||
await waitFor(() => {
|
||||
lastEventSourceMock.onmessage?.({
|
||||
data: JSON.stringify({ type: "error", error: "session list timed out" }),
|
||||
} as MessageEvent);
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("session list timed out");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
lastEventSourceMock.onmessage?.({
|
||||
data: JSON.stringify({ type: "snapshot", sessions: [] }),
|
||||
} as MessageEvent);
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows empty state when only done sessions exist", () => {
|
||||
render(
|
||||
<Dashboard
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { Dashboard } from "@/components/Dashboard";
|
||||
import { makePR, makeSession } from "@/__tests__/helpers";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
|
||||
usePathname: () => "/",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
describe("Dashboard kanban layout", () => {
|
||||
beforeEach(() => {
|
||||
global.EventSource = vi.fn(
|
||||
() =>
|
||||
({
|
||||
onmessage: null,
|
||||
onerror: null,
|
||||
close: vi.fn(),
|
||||
}) as unknown as EventSource,
|
||||
);
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
it("uses four board columns in simple attention mode", () => {
|
||||
render(
|
||||
<Dashboard
|
||||
initialSessions={[
|
||||
makeSession({
|
||||
id: "respond-1",
|
||||
status: "waiting_input",
|
||||
activity: "waiting_input",
|
||||
summary: "Needs a reply",
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const board = document.querySelector(".kanban-board");
|
||||
expect(board).toHaveAttribute("data-columns", "4");
|
||||
expect(board).toHaveStyle({ "--kanban-column-count": "4" });
|
||||
expect(screen.getByText("Action")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Respond")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses five board columns in detailed attention mode", () => {
|
||||
render(
|
||||
<Dashboard
|
||||
initialSessions={[
|
||||
makeSession({
|
||||
id: "review-1",
|
||||
status: "reviewing",
|
||||
pr: makePR({
|
||||
number: 42,
|
||||
reviewDecision: "changes_requested",
|
||||
}),
|
||||
}),
|
||||
]}
|
||||
attentionZones="detailed"
|
||||
/>,
|
||||
);
|
||||
|
||||
const board = document.querySelector(".kanban-board");
|
||||
expect(board).toHaveAttribute("data-columns", "5");
|
||||
expect(board).toHaveStyle({ "--kanban-column-count": "5" });
|
||||
expect(screen.getByText("Respond")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -3,8 +3,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import { Dashboard } from "../Dashboard";
|
||||
import { makePR, makeSession } from "../../__tests__/helpers";
|
||||
|
||||
const refreshMock = vi.fn();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: refreshMock }),
|
||||
usePathname: () => "/",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
|
@ -27,6 +29,7 @@ function mockMobileViewport() {
|
|||
|
||||
describe("Dashboard unified layout (mobile viewport)", () => {
|
||||
beforeEach(() => {
|
||||
refreshMock.mockReset();
|
||||
mockMobileViewport();
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
const eventSourceMock = {
|
||||
|
|
@ -70,10 +73,7 @@ describe("Dashboard unified layout (mobile viewport)", () => {
|
|||
|
||||
it("shows hamburger toggle button in topbar on mobile", () => {
|
||||
render(
|
||||
<Dashboard
|
||||
initialSessions={[makeSession()]}
|
||||
projects={[{ id: "my-app", name: "My App" }]}
|
||||
/>,
|
||||
<Dashboard initialSessions={[makeSession()]} projects={[{ id: "my-app", name: "My App" }]} />,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Toggle sidebar")).toBeInTheDocument();
|
||||
|
|
@ -96,7 +96,9 @@ describe("Dashboard unified layout (mobile viewport)", () => {
|
|||
it("shows PRs link in header pointing to PR page", () => {
|
||||
render(
|
||||
<Dashboard
|
||||
initialSessions={[makeSession({ id: "merge-2", status: "approved", pr: makePR({ number: 91 }) })]}
|
||||
initialSessions={[
|
||||
makeSession({ id: "merge-2", status: "approved", pr: makePR({ number: 91 }) }),
|
||||
]}
|
||||
projectId="my-app"
|
||||
/>,
|
||||
);
|
||||
|
|
@ -216,6 +218,7 @@ describe("Dashboard unified layout (mobile viewport)", () => {
|
|||
expect(global.fetch).toHaveBeenCalledWith("/api/sessions/done-1/restore", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(refreshMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("kill button requires a two-click confirmation before firing", async () => {
|
||||
|
|
@ -243,10 +246,7 @@ describe("Dashboard unified layout (mobile viewport)", () => {
|
|||
|
||||
// First click → enters confirming state; does not fire the kill request
|
||||
fireEvent.click(killButtons[0]);
|
||||
expect(fetchSpy).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("/kill"),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(fetchSpy).not.toHaveBeenCalledWith(expect.stringContaining("/kill"), expect.anything());
|
||||
|
||||
// Button now advertises the confirm affordance
|
||||
const confirm = screen.getByRole("button", { name: "Confirm terminate session" });
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ vi.mock("next/navigation", () => ({
|
|||
|
||||
const mockOrchestrators = [
|
||||
{
|
||||
id: "app-orchestrator-1",
|
||||
id: "app-orchestrator",
|
||||
projectId: "my-project",
|
||||
projectName: "My Project",
|
||||
status: "working",
|
||||
|
|
@ -17,15 +17,6 @@ const mockOrchestrators = [
|
|||
createdAt: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago
|
||||
lastActivityAt: new Date(Date.now() - 300000).toISOString(), // 5 min ago
|
||||
},
|
||||
{
|
||||
id: "app-orchestrator-2",
|
||||
projectId: "my-project",
|
||||
projectName: "My Project",
|
||||
status: "spawning",
|
||||
activity: null,
|
||||
createdAt: new Date(Date.now() - 7200000).toISOString(), // 2 hours ago
|
||||
lastActivityAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
|
|
@ -45,43 +36,37 @@ describe("OrchestratorSelector", () => {
|
|||
it("renders orchestrator list", () => {
|
||||
render(<OrchestratorSelector {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("app-orchestrator-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("app-orchestrator-2")).toBeInTheDocument();
|
||||
expect(screen.getByText("app-orchestrator")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays project name in header", () => {
|
||||
render(<OrchestratorSelector {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("My Project")).toBeInTheDocument();
|
||||
expect(screen.getByText("Select an orchestrator")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Dashboard" })).toHaveAttribute("href", "/projects/my-project");
|
||||
expect(screen.getByText("Project orchestrator")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Dashboard" })).toHaveAttribute(
|
||||
"href",
|
||||
"/projects/my-project",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows count of existing sessions", () => {
|
||||
it("explains that orchestrator opening reuses the canonical session", () => {
|
||||
render(<OrchestratorSelector {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText(/existing orchestrator sessions/)).toBeInTheDocument();
|
||||
// The count "2" appears in multiple places, so we check the full info banner text
|
||||
expect(screen.getByText(/Found/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/one main orchestrator per project/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error state", () => {
|
||||
render(
|
||||
<OrchestratorSelector
|
||||
{...defaultProps}
|
||||
orchestrators={[]}
|
||||
error="Project not found"
|
||||
/>,
|
||||
);
|
||||
render(<OrchestratorSelector {...defaultProps} orchestrators={[]} error="Project not found" />);
|
||||
|
||||
expect(screen.getByText("Error")).toBeInTheDocument();
|
||||
expect(screen.getByText("Project not found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows start new orchestrator button", () => {
|
||||
it("shows open orchestrator button", () => {
|
||||
render(<OrchestratorSelector {...defaultProps} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /start new orchestrator/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /open orchestrator/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("spawns new orchestrator on button click and navigates", async () => {
|
||||
|
|
@ -89,14 +74,14 @@ describe("OrchestratorSelector", () => {
|
|||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
orchestrator: { id: "app-orchestrator-3" },
|
||||
orchestrator: { id: "app-orchestrator" },
|
||||
}),
|
||||
});
|
||||
global.fetch = mockFetch;
|
||||
|
||||
render(<OrchestratorSelector {...defaultProps} />);
|
||||
|
||||
const button = screen.getByRole("button", { name: /start new orchestrator/i });
|
||||
const button = screen.getByRole("button", { name: /open orchestrator/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -104,7 +89,7 @@ describe("OrchestratorSelector", () => {
|
|||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith("/projects/my-project/sessions/app-orchestrator-3");
|
||||
expect(mockPush).toHaveBeenCalledWith("/projects/my-project/sessions/app-orchestrator");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -116,11 +101,11 @@ describe("OrchestratorSelector", () => {
|
|||
|
||||
render(<OrchestratorSelector {...defaultProps} />);
|
||||
|
||||
const button = screen.getByRole("button", { name: /start new orchestrator/i });
|
||||
const button = screen.getByRole("button", { name: /open orchestrator/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/creating new orchestrator/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/opening orchestrator/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -133,7 +118,7 @@ describe("OrchestratorSelector", () => {
|
|||
|
||||
render(<OrchestratorSelector {...defaultProps} />);
|
||||
|
||||
const button = screen.getByRole("button", { name: /start new orchestrator/i });
|
||||
const button = screen.getByRole("button", { name: /open orchestrator/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -144,8 +129,8 @@ describe("OrchestratorSelector", () => {
|
|||
it("links to orchestrator session page", () => {
|
||||
render(<OrchestratorSelector {...defaultProps} />);
|
||||
|
||||
const link = screen.getByRole("link", { name: /app-orchestrator-1/i });
|
||||
expect(link).toHaveAttribute("href", "/projects/my-project/sessions/app-orchestrator-1");
|
||||
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", () => {
|
||||
|
|
@ -153,7 +138,6 @@ describe("OrchestratorSelector", () => {
|
|||
|
||||
expect(screen.getByText("working")).toBeInTheDocument();
|
||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||
expect(screen.getByText("spawning")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("covers relative time for days and status colors/labels", () => {
|
||||
|
|
@ -196,12 +180,7 @@ describe("OrchestratorSelector", () => {
|
|||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<OrchestratorSelector
|
||||
{...defaultProps}
|
||||
orchestrators={wideOrchestrators}
|
||||
/>,
|
||||
);
|
||||
render(<OrchestratorSelector {...defaultProps} orchestrators={wideOrchestrators} />);
|
||||
|
||||
expect(screen.getByText(/2d ago/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Just now/i).length).toBeGreaterThan(0);
|
||||
|
|
@ -222,10 +201,7 @@ describe("OrchestratorSelector", () => {
|
|||
},
|
||||
];
|
||||
render(
|
||||
<OrchestratorSelector
|
||||
{...defaultProps}
|
||||
orchestrators={orchestratorsWithInvalidDate}
|
||||
/>,
|
||||
<OrchestratorSelector {...defaultProps} orchestrators={orchestratorsWithInvalidDate} />,
|
||||
);
|
||||
|
||||
// The "Created Unknown" text should appear for invalid dates
|
||||
|
|
@ -242,10 +218,7 @@ describe("OrchestratorSelector", () => {
|
|||
},
|
||||
];
|
||||
render(
|
||||
<OrchestratorSelector
|
||||
{...defaultProps}
|
||||
orchestrators={orchestratorsWithFutureDate}
|
||||
/>,
|
||||
<OrchestratorSelector {...defaultProps} orchestrators={orchestratorsWithFutureDate} />,
|
||||
);
|
||||
|
||||
// Future timestamps should show "Just now" instead of negative values
|
||||
|
|
@ -260,12 +233,7 @@ describe("OrchestratorSelector", () => {
|
|||
lastActivityAt: null,
|
||||
},
|
||||
];
|
||||
render(
|
||||
<OrchestratorSelector
|
||||
{...defaultProps}
|
||||
orchestrators={orchestratorsWithNullDate}
|
||||
/>,
|
||||
);
|
||||
render(<OrchestratorSelector {...defaultProps} orchestrators={orchestratorsWithNullDate} />);
|
||||
|
||||
expect(screen.getByText(/Created Unknown/)).toBeInTheDocument();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { ProjectSidebar } from "@/components/ProjectSidebar";
|
||||
import { makeSession } from "@/__tests__/helpers";
|
||||
import { makePR, makeSession } from "@/__tests__/helpers";
|
||||
|
||||
const mockPush = vi.fn();
|
||||
const mockRefresh = vi.fn();
|
||||
|
|
@ -202,7 +202,10 @@ describe("ProjectSidebar", () => {
|
|||
json: async () => ({ ok: true }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.stubGlobal("confirm", vi.fn(() => true));
|
||||
vi.stubGlobal(
|
||||
"confirm",
|
||||
vi.fn(() => true),
|
||||
);
|
||||
|
||||
render(
|
||||
<ProjectSidebar
|
||||
|
|
@ -254,6 +257,94 @@ describe("ProjectSidebar", () => {
|
|||
expect(screen.queryByRole("link", { name: "Open feat/test" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps killed sessions visible when they still need attention", () => {
|
||||
const lastActivityAt = new Date().toISOString();
|
||||
|
||||
render(
|
||||
<ProjectSidebar
|
||||
projects={projects}
|
||||
sessions={[
|
||||
makeSession({
|
||||
id: "worker-ended",
|
||||
projectId: "project-1",
|
||||
summary: "Runtime missing but needs review",
|
||||
branch: null,
|
||||
status: "killed",
|
||||
activity: "exited",
|
||||
lastActivityAt,
|
||||
pr: makePR({
|
||||
title: "Runtime missing but needs review",
|
||||
ciStatus: "failing",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: false,
|
||||
approved: true,
|
||||
noConflicts: true,
|
||||
blockers: ["CI failing"],
|
||||
},
|
||||
}),
|
||||
lifecycle: {
|
||||
sessionState: "detecting",
|
||||
sessionReason: "probe_failure",
|
||||
prState: "open",
|
||||
prReason: "ci_failing",
|
||||
runtimeState: "missing",
|
||||
runtimeReason: "process_missing",
|
||||
session: {
|
||||
state: "detecting",
|
||||
reason: "probe_failure",
|
||||
label: "Detecting",
|
||||
reasonLabel: "Probe failure",
|
||||
startedAt: lastActivityAt,
|
||||
completedAt: null,
|
||||
terminatedAt: null,
|
||||
lastTransitionAt: lastActivityAt,
|
||||
},
|
||||
pr: {
|
||||
state: "open",
|
||||
reason: "ci_failing",
|
||||
label: "Open",
|
||||
reasonLabel: "CI failing",
|
||||
number: 100,
|
||||
url: "https://github.com/acme/app/pull/100",
|
||||
lastObservedAt: lastActivityAt,
|
||||
},
|
||||
runtime: {
|
||||
state: "missing",
|
||||
reason: "process_missing",
|
||||
label: "Missing",
|
||||
reasonLabel: "Process missing",
|
||||
lastObservedAt: lastActivityAt,
|
||||
},
|
||||
legacyStatus: "killed",
|
||||
evidence: null,
|
||||
detectingAttempts: 1,
|
||||
detectingEscalatedAt: null,
|
||||
summary: "Session detecting, PR open, runtime missing",
|
||||
guidance: null,
|
||||
},
|
||||
}),
|
||||
makeSession({
|
||||
id: "worker-done",
|
||||
projectId: "project-1",
|
||||
summary: "Actually finished",
|
||||
status: "merged",
|
||||
activity: "exited",
|
||||
pr: makePR({ state: "merged" }),
|
||||
}),
|
||||
]}
|
||||
activeProjectId="project-1"
|
||||
activeSessionId="worker-ended"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /^Project One 1$/ })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Open Runtime missing but needs review" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "Open Actually finished" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("navigates session rows to the selected session detail route", () => {
|
||||
mockPathname = "/sessions/ao-143";
|
||||
|
||||
|
|
@ -341,6 +432,6 @@ describe("ProjectSidebar", () => {
|
|||
);
|
||||
|
||||
expect(screen.getByLabelText("Loading sessions")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No active sessions")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("No sessions shown")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -120,10 +120,9 @@ describe("SessionDetail desktop layout", () => {
|
|||
expect(screen.getByRole("button", { name: "Toggle sidebar" })).toBeInTheDocument();
|
||||
expect(screen.getAllByText("My App").length).toBeGreaterThanOrEqual(1);
|
||||
// Scope to topbar since MobileBottomNav also has an Orchestrator link
|
||||
expect(within(screen.getByRole("banner")).getByRole("link", { name: "Orchestrator" })).toHaveAttribute(
|
||||
"href",
|
||||
"/projects/my-app/sessions/my-app-orchestrator",
|
||||
);
|
||||
expect(
|
||||
within(screen.getByRole("banner")).getByRole("link", { name: "Orchestrator" }),
|
||||
).toHaveAttribute("href", "/projects/my-app/sessions/my-app-orchestrator");
|
||||
// Branch pill is rendered as link when session has a PR
|
||||
expect(screen.getByRole("link", { name: "feat/desktop-detail" })).toHaveAttribute(
|
||||
"href",
|
||||
|
|
@ -191,7 +190,7 @@ describe("SessionDetail desktop layout", () => {
|
|||
expect(screen.getByRole("button", { name: "Ask Agent to Fix" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows terminal-ended placeholder for exited desktop sessions", () => {
|
||||
it("shows an actionable summary for exited desktop sessions", () => {
|
||||
render(
|
||||
<SessionDetail
|
||||
session={makeSession({
|
||||
|
|
@ -199,12 +198,30 @@ describe("SessionDetail desktop layout", () => {
|
|||
projectId: "my-app",
|
||||
status: "terminated",
|
||||
activity: "exited",
|
||||
summary: "Investigated the dashboard loading issue",
|
||||
branch: "fix/session-loading",
|
||||
pr: null,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Terminal session has ended/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("region", { name: "Session ended summary" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Terminal ended")).toBeInTheDocument();
|
||||
expect(screen.getByText("Investigated the dashboard loading issue")).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByLabelText("Session facts")).getByText("worker-ended"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Restore terminal" })).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByRole("banner")).getByRole("button", { name: "Restore" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByRole("banner")).queryByRole("link", { name: "Orchestrator" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Back to dashboard" })).toHaveAttribute(
|
||||
"href",
|
||||
"/projects/my-app",
|
||||
);
|
||||
expect(screen.queryByTestId("direct-terminal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -233,11 +250,39 @@ describe("SessionDetail desktop layout", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
expect(within(screen.getByRole("banner")).getByRole("button", { name: "Restore" })).toBeInTheDocument();
|
||||
expect(within(screen.getByRole("banner")).queryByRole("button", { name: "Kill" })).not.toBeInTheDocument();
|
||||
expect(within(screen.getByRole("banner")).getByRole("button", { name: "Restore" })).toHaveClass(
|
||||
"dashboard-app-btn--restore",
|
||||
);
|
||||
expect(
|
||||
within(screen.getByRole("banner")).queryByRole("button", { name: "Kill" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the desktop orchestrator button on orchestrator session pages", () => {
|
||||
it("restores without using router refresh on the client-only session page", async () => {
|
||||
render(
|
||||
<SessionDetail
|
||||
session={makeSession({
|
||||
id: "worker-restore",
|
||||
projectId: "my-app",
|
||||
status: "terminated",
|
||||
activity: "exited",
|
||||
pr: null,
|
||||
})}
|
||||
projects={[{ id: "my-app", name: "My App", path: "/tmp/my-app" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Restore" }));
|
||||
|
||||
await act(async () => {});
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith("/api/sessions/worker-restore/restore", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(routerRefreshMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the desktop orchestrator button on orchestrator session pages", () => {
|
||||
render(
|
||||
<SessionDetail
|
||||
session={makeSession({
|
||||
|
|
@ -258,12 +303,63 @@ describe("SessionDetail desktop layout", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
// Topbar should NOT show an Orchestrator link when already on orchestrator.
|
||||
// Scope to banner since MobileBottomNav keeps its own tab link for active highlighting.
|
||||
expect(within(screen.getByRole("banner")).queryByRole("link", { name: "Orchestrator" })).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByRole("banner")).getByRole("link", { name: "Orchestrator" }),
|
||||
).toHaveAttribute("href", "/projects/my-app/sessions/my-app-orchestrator");
|
||||
expect(screen.getByText("orchestrator")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the main orchestrator button when an orchestrator target exists", () => {
|
||||
const { rerender } = render(
|
||||
<SessionDetail
|
||||
session={makeSession({ id: "worker-with-orchestrator", projectId: "my-app" })}
|
||||
projectOrchestratorId="my-app-orchestrator"
|
||||
projects={[{ id: "my-app", name: "My App", path: "/tmp/my-app" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
within(screen.getByRole("banner")).getByRole("link", { name: "Orchestrator" }),
|
||||
).toHaveAttribute("href", "/projects/my-app/sessions/my-app-orchestrator");
|
||||
|
||||
rerender(
|
||||
<SessionDetail
|
||||
session={makeSession({ id: "worker-without-orchestrator", projectId: "my-app" })}
|
||||
projectOrchestratorId={null}
|
||||
projects={[{ id: "my-app", name: "My App", path: "/tmp/my-app" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
within(screen.getByRole("banner")).queryByRole("link", { name: "Orchestrator" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<SessionDetail
|
||||
session={makeSession({
|
||||
id: "my-app-orchestrator",
|
||||
projectId: "my-app",
|
||||
summary: "Project orchestrator",
|
||||
})}
|
||||
isOrchestrator
|
||||
orchestratorZones={{
|
||||
merge: 1,
|
||||
respond: 0,
|
||||
review: 0,
|
||||
pending: 0,
|
||||
working: 2,
|
||||
done: 3,
|
||||
}}
|
||||
projectOrchestratorId="my-app-orchestrator"
|
||||
projects={[{ id: "my-app", name: "My App", path: "/tmp/my-app" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
within(screen.getByRole("banner")).getByRole("link", { name: "Orchestrator" }),
|
||||
).toHaveAttribute("href", "/projects/my-app/sessions/my-app-orchestrator");
|
||||
});
|
||||
|
||||
it("routes to the project orchestrator after killing a worker session", async () => {
|
||||
render(
|
||||
<SessionDetail
|
||||
|
|
@ -283,7 +379,11 @@ describe("SessionDetail desktop layout", () => {
|
|||
it("routes to the project dashboard after killing a worker with no orchestrator", async () => {
|
||||
render(
|
||||
<SessionDetail
|
||||
session={makeSession({ id: "worker-kill-dashboard", projectId: "my-app", status: "running" })}
|
||||
session={makeSession({
|
||||
id: "worker-kill-dashboard",
|
||||
projectId: "my-app",
|
||||
status: "running",
|
||||
})}
|
||||
projectOrchestratorId={null}
|
||||
/>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -24,8 +24,20 @@ describe("useSessionEvents - mux", () => {
|
|||
it("triggers refresh when mux patch contains unknown id", async () => {
|
||||
const initialSessions = [s1];
|
||||
const muxSessions = [
|
||||
{ id: "s1", status: "working", activity: "active", attentionLevel: "working" as const, lastActivityAt: now },
|
||||
{ id: "s2", status: "working", activity: "active", attentionLevel: "working" as const, lastActivityAt: now },
|
||||
{
|
||||
id: "s1",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
attentionLevel: "working" as const,
|
||||
lastActivityAt: now,
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
attentionLevel: "working" as const,
|
||||
lastActivityAt: now,
|
||||
},
|
||||
];
|
||||
renderHook(() =>
|
||||
useSessionEvents({
|
||||
|
|
@ -42,4 +54,59 @@ describe("useSessionEvents - mux", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not warn when an in-flight refresh is aborted on unmount", async () => {
|
||||
vi.useFakeTimers();
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
(_input: RequestInfo | URL, init?: RequestInit) =>
|
||||
new Promise<Response>((_, reject) => {
|
||||
init?.signal?.addEventListener(
|
||||
"abort",
|
||||
() => reject(new DOMException("The operation was aborted.", "AbortError")),
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const initialSessions = [s1];
|
||||
const muxSessions = [
|
||||
{
|
||||
id: "s1",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
attentionLevel: "working" as const,
|
||||
lastActivityAt: now,
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
attentionLevel: "working" as const,
|
||||
lastActivityAt: now,
|
||||
},
|
||||
];
|
||||
|
||||
const { unmount } = renderHook(() =>
|
||||
useSessionEvents({
|
||||
initialSessions,
|
||||
project: "proj",
|
||||
muxSessions,
|
||||
attentionZones: "simple",
|
||||
}),
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(120);
|
||||
unmount();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalledWith(
|
||||
"[useSessionEvents] refresh failed:",
|
||||
expect.anything(),
|
||||
);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
type DashboardSession,
|
||||
type SSESnapshotEvent,
|
||||
} from "@/lib/types";
|
||||
import { fetchJsonWithTimeout } from "@/lib/client-fetch";
|
||||
|
||||
/** Debounce before fetching full session list after membership change. */
|
||||
const MEMBERSHIP_REFRESH_DELAY_MS = 120;
|
||||
|
|
@ -15,16 +16,30 @@ const MEMBERSHIP_REFRESH_DELAY_MS = 120;
|
|||
const STALE_REFRESH_INTERVAL_MS = 15000;
|
||||
/** Grace period before declaring "disconnected" (allows for transient reconnects). */
|
||||
const DISCONNECTED_GRACE_PERIOD_MS = 4000;
|
||||
const LIVE_REFRESH_TIMEOUT_MS = 6000;
|
||||
|
||||
function isAbortLikeError(error: unknown): boolean {
|
||||
if (error instanceof DOMException) {
|
||||
return error.name === "AbortError";
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.toLowerCase();
|
||||
return message.includes("aborted") || message.includes("aborterror");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
type ConnectionStatus = "connected" | "reconnecting" | "disconnected";
|
||||
|
||||
/** Server-computed attention levels from the latest SSE snapshot. */
|
||||
export type SSEAttentionMap = Readonly<Record<string, AttentionLevel>>;
|
||||
|
||||
|
||||
interface State {
|
||||
sessions: DashboardSession[];
|
||||
connectionStatus: ConnectionStatus;
|
||||
loadError?: string;
|
||||
/** Attention levels from the latest SSE snapshot (server-computed, includes PR state). */
|
||||
sseAttentionLevels: SSEAttentionMap;
|
||||
/**
|
||||
|
|
@ -38,6 +53,7 @@ interface State {
|
|||
type Action =
|
||||
| { type: "reset"; sessions: DashboardSession[]; sseAttentionLevels?: SSEAttentionMap }
|
||||
| { type: "snapshot"; patches: SSESnapshotEvent["sessions"] }
|
||||
| { type: "setLoadError"; message?: string }
|
||||
| { type: "setConnection"; status: ConnectionStatus }
|
||||
| { type: "markLiveSessionsResolved" };
|
||||
|
||||
|
|
@ -47,10 +63,14 @@ function reducer(state: State, action: Action): State {
|
|||
return {
|
||||
...state,
|
||||
sessions: action.sessions,
|
||||
loadError: undefined,
|
||||
...(action.sseAttentionLevels !== undefined
|
||||
? { sseAttentionLevels: action.sseAttentionLevels }
|
||||
: {}),
|
||||
};
|
||||
case "setLoadError":
|
||||
if (state.loadError === action.message) return state;
|
||||
return { ...state, loadError: action.message };
|
||||
case "markLiveSessionsResolved":
|
||||
if (state.liveSessionsResolved) return state;
|
||||
return { ...state, liveSessionsResolved: true };
|
||||
|
|
@ -70,7 +90,12 @@ function reducer(state: State, action: Action): State {
|
|||
return s;
|
||||
}
|
||||
changed = true;
|
||||
return { ...s, status: patch.status, activity: patch.activity, lastActivityAt: patch.lastActivityAt };
|
||||
return {
|
||||
...s,
|
||||
status: patch.status,
|
||||
activity: patch.activity,
|
||||
lastActivityAt: patch.lastActivityAt,
|
||||
};
|
||||
});
|
||||
|
||||
// Build attention level map from server-computed values
|
||||
|
|
@ -84,11 +109,18 @@ function reducer(state: State, action: Action): State {
|
|||
Object.keys(levels).length !== Object.keys(state.sseAttentionLevels).length ||
|
||||
action.patches.some((p) => state.sseAttentionLevels[p.id] !== p.attentionLevel);
|
||||
|
||||
if (!sessionsChanged && !levelsChanged) return state;
|
||||
if (!sessionsChanged && !levelsChanged) {
|
||||
if (state.loadError === undefined) return state;
|
||||
return {
|
||||
...state,
|
||||
loadError: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
sessions: sessionsChanged ? next : state.sessions,
|
||||
loadError: undefined,
|
||||
sseAttentionLevels: levelsChanged ? levels : state.sseAttentionLevels,
|
||||
};
|
||||
}
|
||||
|
|
@ -107,7 +139,13 @@ function createMembershipKey(
|
|||
export interface UseSessionEventsOptions {
|
||||
initialSessions: DashboardSession[];
|
||||
project?: string;
|
||||
muxSessions?: Array<{ id: string; status: string; activity: string | null; attentionLevel: AttentionLevel; lastActivityAt: string }>;
|
||||
muxSessions?: Array<{
|
||||
id: string;
|
||||
status: string;
|
||||
activity: string | null;
|
||||
attentionLevel: AttentionLevel;
|
||||
lastActivityAt: string;
|
||||
}>;
|
||||
initialAttentionLevels?: SSEAttentionMap;
|
||||
disabled?: boolean;
|
||||
/**
|
||||
|
|
@ -136,6 +174,7 @@ export function useSessionEvents(options: UseSessionEventsOptions): State {
|
|||
const [state, dispatch] = useReducer(reducer, {
|
||||
sessions: initialSessions,
|
||||
connectionStatus: "connected" as ConnectionStatus,
|
||||
loadError: undefined,
|
||||
sseAttentionLevels: initialAttentionLevels ?? ({} as SSEAttentionMap),
|
||||
liveSessionsResolved: false,
|
||||
});
|
||||
|
|
@ -148,6 +187,7 @@ export function useSessionEvents(options: UseSessionEventsOptions): State {
|
|||
const lastFetchStartedAtRef = useRef(0);
|
||||
const disconnectedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const activeRefreshControllerRef = useRef<AbortController | null>(null);
|
||||
const pageUnloadingRef = useRef(false);
|
||||
|
||||
// Reset state when server-rendered props change (e.g. full page refresh)
|
||||
useEffect(() => {
|
||||
|
|
@ -167,6 +207,22 @@ export function useSessionEvents(options: UseSessionEventsOptions): State {
|
|||
// SSE setup/teardown runs only on that transition, not every mux update.
|
||||
const muxActive = muxSessions !== undefined;
|
||||
|
||||
useEffect(() => {
|
||||
pageUnloadingRef.current = false;
|
||||
|
||||
const markPageUnloading = () => {
|
||||
pageUnloadingRef.current = true;
|
||||
};
|
||||
|
||||
window.addEventListener("pagehide", markPageUnloading);
|
||||
window.addEventListener("beforeunload", markPageUnloading);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pagehide", markPageUnloading);
|
||||
window.removeEventListener("beforeunload", markPageUnloading);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Define scheduleRefresh with useCallback so both effects can use it
|
||||
const scheduleRefresh = useCallback(() => {
|
||||
// Skip scheduling if a timer is already pending
|
||||
|
|
@ -190,33 +246,40 @@ export function useSessionEvents(options: UseSessionEventsOptions): State {
|
|||
? `/api/sessions?project=${encodeURIComponent(project)}`
|
||||
: "/api/sessions";
|
||||
|
||||
void fetch(sessionsUrl, { signal: refreshController.signal, cache: "no-store" })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then(
|
||||
(updated: { sessions?: DashboardSession[] } | null) => {
|
||||
if (refreshController.signal.aborted || !updated?.sessions) {
|
||||
// Update timestamp even for non-OK responses to prevent retry storms
|
||||
if (!refreshController.signal.aborted) {
|
||||
lastRefreshAtRef.current = Date.now();
|
||||
}
|
||||
return;
|
||||
void fetchJsonWithTimeout<{ sessions?: DashboardSession[] } | null>(sessionsUrl, {
|
||||
signal: refreshController.signal,
|
||||
cache: "no-store",
|
||||
timeoutMs: LIVE_REFRESH_TIMEOUT_MS,
|
||||
timeoutMessage: `Dashboard refresh timed out after ${LIVE_REFRESH_TIMEOUT_MS}ms`,
|
||||
})
|
||||
.then((updated) => {
|
||||
if (refreshController.signal.aborted || !updated?.sessions) {
|
||||
// Update timestamp even for non-OK responses to prevent retry storms
|
||||
if (!refreshController.signal.aborted) {
|
||||
lastRefreshAtRef.current = Date.now();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
lastRefreshAtRef.current = Date.now();
|
||||
dispatch({ type: "markLiveSessionsResolved" });
|
||||
const sseAttentionLevels = Object.fromEntries(
|
||||
updated.sessions.map((s) => [s.id, getAttentionLevel(s, attentionZones)]),
|
||||
) as SSEAttentionMap;
|
||||
dispatch({
|
||||
type: "reset",
|
||||
sessions: updated.sessions,
|
||||
sseAttentionLevels,
|
||||
});
|
||||
},
|
||||
)
|
||||
lastRefreshAtRef.current = Date.now();
|
||||
dispatch({ type: "markLiveSessionsResolved" });
|
||||
const sseAttentionLevels = Object.fromEntries(
|
||||
updated.sessions.map((s) => [s.id, getAttentionLevel(s, attentionZones)]),
|
||||
) as SSEAttentionMap;
|
||||
dispatch({
|
||||
type: "reset",
|
||||
sessions: updated.sessions,
|
||||
sseAttentionLevels,
|
||||
});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
if (pageUnloadingRef.current || refreshController.signal.aborted || isAbortLikeError(err))
|
||||
return;
|
||||
console.warn("[useSessionEvents] refresh failed:", err);
|
||||
dispatch({
|
||||
type: "setLoadError",
|
||||
message: err instanceof Error ? err.message : "Dashboard refresh failed",
|
||||
});
|
||||
// Update timestamp on failure to prevent retry loops on every SSE snapshot
|
||||
lastRefreshAtRef.current = Date.now();
|
||||
})
|
||||
|
|
@ -325,7 +388,14 @@ export function useSessionEvents(options: UseSessionEventsOptions): State {
|
|||
|
||||
es.onmessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data as string) as { type: string };
|
||||
const data = JSON.parse(event.data as string) as { type: string; error?: string };
|
||||
if (data.type === "error") {
|
||||
dispatch({
|
||||
type: "setLoadError",
|
||||
message: data.error ?? "Live dashboard updates failed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (data.type === "snapshot") {
|
||||
const snapshot = data as SSESnapshotEvent;
|
||||
dispatch({ type: "markLiveSessionsResolved" });
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ describe("getDashboardPageData fast path", () => {
|
|||
hoisted.getServicesMock.mockResolvedValue({
|
||||
config: { projects: { docs: { id: "docs" } } },
|
||||
registry: { scm: "registry" },
|
||||
sessionManager: { list: vi.fn().mockResolvedValue(allSessions) },
|
||||
sessionManager: { listCached: vi.fn().mockResolvedValue(allSessions) },
|
||||
});
|
||||
hoisted.filterProjectSessionsMock.mockReturnValue(allSessions);
|
||||
hoisted.filterWorkerSessionsMock.mockReturnValue(allSessions);
|
||||
|
|
@ -116,7 +116,7 @@ describe("getDashboardPageData fast path", () => {
|
|||
hoisted.getServicesMock.mockResolvedValue({
|
||||
config: { projects: { mono: { id: "mono" } } },
|
||||
registry: { scm: "registry" },
|
||||
sessionManager: { list: vi.fn().mockResolvedValue([core]) },
|
||||
sessionManager: { listCached: vi.fn().mockResolvedValue([core]) },
|
||||
});
|
||||
hoisted.filterProjectSessionsMock.mockReturnValue([core]);
|
||||
hoisted.filterWorkerSessionsMock.mockReturnValue([core]);
|
||||
|
|
@ -147,7 +147,7 @@ describe("getDashboardPageData fast path", () => {
|
|||
hoisted.getServicesMock.mockResolvedValue({
|
||||
config: { projects: { mono: { id: "mono" } } },
|
||||
registry: { scm: "registry" },
|
||||
sessionManager: { list: vi.fn().mockResolvedValue([core]) },
|
||||
sessionManager: { listCached: vi.fn().mockResolvedValue([core]) },
|
||||
});
|
||||
hoisted.filterProjectSessionsMock.mockReturnValue([core]);
|
||||
hoisted.filterWorkerSessionsMock.mockReturnValue([core]);
|
||||
|
|
@ -181,7 +181,7 @@ describe("getDashboardPageData fast path", () => {
|
|||
dashboard: { attentionZones: "detailed" },
|
||||
},
|
||||
registry: { scm: "registry" },
|
||||
sessionManager: { list: vi.fn().mockRejectedValue(new Error("list boom")) },
|
||||
sessionManager: { listCached: vi.fn().mockRejectedValue(new Error("list boom")) },
|
||||
});
|
||||
|
||||
const pageData = await getDashboardPageData("docs");
|
||||
|
|
@ -198,7 +198,7 @@ describe("getDashboardPageData fast path", () => {
|
|||
hoisted.getServicesMock.mockResolvedValue({
|
||||
config: { projects: { docs: { id: "docs" } } },
|
||||
registry: { scm: "registry" },
|
||||
sessionManager: { list: vi.fn().mockResolvedValue([core]) },
|
||||
sessionManager: { listCached: vi.fn().mockResolvedValue([core]) },
|
||||
});
|
||||
hoisted.filterProjectSessionsMock.mockReturnValue([core]);
|
||||
hoisted.filterWorkerSessionsMock.mockReturnValue([core]);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
const { mockLoadConfig, mockGetGlobalConfigPath, MockConfigNotFoundError } = vi.hoisted(() => {
|
||||
const mockLoadConfig = vi.fn();
|
||||
|
|
@ -21,10 +24,12 @@ vi.mock("@aoagents/ao-core", () => ({
|
|||
|
||||
describe("project-name fallback discovery", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
mockLoadConfig.mockReset();
|
||||
mockGetGlobalConfigPath.mockClear();
|
||||
mockGetGlobalConfigPath.mockReturnValue("/tmp/global-config.yaml");
|
||||
delete process.env["AO_CONFIG_PATH"];
|
||||
});
|
||||
|
||||
it("falls back to discovered local config when the canonical global config is missing", async () => {
|
||||
|
|
@ -52,4 +57,160 @@ describe("project-name fallback discovery", () => {
|
|||
expect(mockLoadConfig).toHaveBeenNthCalledWith(1, "/tmp/global-config.yaml");
|
||||
expect(mockLoadConfig).toHaveBeenNthCalledWith(2);
|
||||
});
|
||||
|
||||
it("prefers the current repo project over the first configured project", async () => {
|
||||
const config = {
|
||||
configPath: "/tmp/global-config.yaml",
|
||||
projects: {
|
||||
"vinesight-web": {
|
||||
name: "vinesight-web",
|
||||
path: "/Users/ashishhuddar/vinesight",
|
||||
sessionPrefix: "vw",
|
||||
},
|
||||
"agent-orchestrator": {
|
||||
name: "Agent Orchestrator",
|
||||
path: "/Users/ashishhuddar/agent-orchestrator",
|
||||
sessionPrefix: "ao",
|
||||
},
|
||||
},
|
||||
degradedProjects: {},
|
||||
};
|
||||
|
||||
mockLoadConfig.mockReturnValue(config);
|
||||
vi.spyOn(process, "cwd").mockReturnValue("/Users/ashishhuddar/agent-orchestrator");
|
||||
|
||||
const { getPrimaryProjectId, getProjectName } = await import("../project-name");
|
||||
|
||||
expect(getPrimaryProjectId()).toBe("agent-orchestrator");
|
||||
expect(getProjectName()).toBe("Agent Orchestrator");
|
||||
});
|
||||
|
||||
it("does not infer the current project from an ambiguous path basename", async () => {
|
||||
const config = {
|
||||
configPath: "/tmp/global-config.yaml",
|
||||
projects: {
|
||||
first: {
|
||||
name: "First",
|
||||
path: "/repos/client-a/integrator",
|
||||
sessionPrefix: "first",
|
||||
},
|
||||
second: {
|
||||
name: "Second",
|
||||
path: "/repos/client-b/integrator",
|
||||
sessionPrefix: "second",
|
||||
},
|
||||
},
|
||||
degradedProjects: {},
|
||||
};
|
||||
|
||||
mockLoadConfig.mockReturnValue(config);
|
||||
vi.spyOn(process, "cwd").mockReturnValue("/tmp/checkout/integrator");
|
||||
|
||||
const { getPrimaryProjectId, getProjectName } = await import("../project-name");
|
||||
|
||||
expect(getPrimaryProjectId()).toBe("first");
|
||||
expect(getProjectName()).toBe("First");
|
||||
});
|
||||
|
||||
it("prefers the repo discovered from local config when the dashboard is running from packages/web", async () => {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), "ao-project-name-web-"));
|
||||
const repoRoot = join(tempRoot, "agent-orchestrator");
|
||||
const webDir = join(repoRoot, "packages", "web");
|
||||
mkdirSync(webDir, { recursive: true });
|
||||
const localConfigPath = join(repoRoot, "agent-orchestrator.yaml");
|
||||
writeFileSync(localConfigPath, "projects: {}\n");
|
||||
|
||||
const globalConfig = {
|
||||
configPath: "/tmp/global-config.yaml",
|
||||
projects: {
|
||||
"vinesight-web": {
|
||||
name: "vinesight-web",
|
||||
path: join(tempRoot, "vinesight"),
|
||||
sessionPrefix: "vw",
|
||||
},
|
||||
"agent-orchestrator": {
|
||||
name: "Agent Orchestrator",
|
||||
path: repoRoot,
|
||||
sessionPrefix: "ao",
|
||||
},
|
||||
},
|
||||
degradedProjects: {},
|
||||
};
|
||||
const localConfig = {
|
||||
configPath: localConfigPath,
|
||||
projects: {
|
||||
"agent-orchestrator": {
|
||||
name: "Agent Orchestrator",
|
||||
path: repoRoot,
|
||||
sessionPrefix: "ao",
|
||||
},
|
||||
},
|
||||
degradedProjects: {},
|
||||
};
|
||||
|
||||
mockLoadConfig.mockImplementation((configPath?: string) => {
|
||||
if (configPath === "/tmp/global-config.yaml") {
|
||||
return globalConfig;
|
||||
}
|
||||
if (configPath === localConfigPath) {
|
||||
return localConfig;
|
||||
}
|
||||
throw new Error(`unexpected config path: ${String(configPath)}`);
|
||||
});
|
||||
vi.spyOn(process, "cwd").mockReturnValue(webDir);
|
||||
|
||||
const { getPrimaryProjectId, getProjectName } = await import("../project-name");
|
||||
|
||||
expect(getPrimaryProjectId()).toBe("agent-orchestrator");
|
||||
expect(getProjectName()).toBe("Agent Orchestrator");
|
||||
});
|
||||
|
||||
it("ignores ambient AO_CONFIG_PATH when discovering the local repo project", async () => {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), "ao-project-name-"));
|
||||
const repoRoot = join(tempRoot, "agent-orchestrator");
|
||||
const webDir = join(repoRoot, "packages", "web");
|
||||
mkdirSync(webDir, { recursive: true });
|
||||
const localConfigPath = join(repoRoot, "agent-orchestrator.yaml");
|
||||
writeFileSync(localConfigPath, "projects: {}\n");
|
||||
|
||||
const globalConfig = {
|
||||
configPath: "/tmp/global-config.yaml",
|
||||
projects: {
|
||||
healthy: {
|
||||
name: "Healthy",
|
||||
path: repoRoot,
|
||||
sessionPrefix: "healthy",
|
||||
},
|
||||
},
|
||||
degradedProjects: {},
|
||||
};
|
||||
const localConfig = {
|
||||
configPath: localConfigPath,
|
||||
projects: {
|
||||
healthy: {
|
||||
name: "Healthy",
|
||||
path: repoRoot,
|
||||
sessionPrefix: "healthy",
|
||||
},
|
||||
},
|
||||
degradedProjects: {},
|
||||
};
|
||||
|
||||
process.env["AO_CONFIG_PATH"] = "/tmp/ambient-config.yaml";
|
||||
mockLoadConfig.mockImplementation((configPath?: string) => {
|
||||
if (configPath === "/tmp/global-config.yaml") {
|
||||
return globalConfig;
|
||||
}
|
||||
if (configPath === localConfigPath) {
|
||||
return localConfig;
|
||||
}
|
||||
throw new Error(`unexpected config path: ${String(configPath)}`);
|
||||
});
|
||||
vi.spyOn(process, "cwd").mockReturnValue(webDir);
|
||||
|
||||
const { getPrimaryProjectId, getProjectName } = await import("../project-name");
|
||||
|
||||
expect(getPrimaryProjectId()).toBe("healthy");
|
||||
expect(getProjectName()).toBe("Healthy");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ describe("sessionToDashboard", () => {
|
|||
expect(dashboard.attentionLevel).toBe("working");
|
||||
});
|
||||
|
||||
it("should expose detecting guidance and evidence from legacy metadata", () => {
|
||||
it("should expose detecting evidence from legacy metadata without card guidance copy", () => {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker", new Date("2025-01-01T00:00:00Z"));
|
||||
lifecycle.session.state = "detecting";
|
||||
lifecycle.session.reason = "probe_failure";
|
||||
|
|
@ -184,7 +184,7 @@ describe("sessionToDashboard", () => {
|
|||
|
||||
const dashboard = sessionToDashboard(coreSession);
|
||||
|
||||
expect(dashboard.lifecycle?.guidance).toContain("Retry 2");
|
||||
expect(dashboard.lifecycle?.guidance).toBeNull();
|
||||
expect(dashboard.lifecycle?.evidence).toContain("signal_disagreement");
|
||||
expect(dashboard.attentionLevel).toBe("respond");
|
||||
});
|
||||
|
|
@ -1028,15 +1028,18 @@ describe("enrichSessionsMetadata", () => {
|
|||
});
|
||||
|
||||
it("starts issue-title fetches before agent summaries finish", async () => {
|
||||
let resolveSummary: ((value: { summary: string; summaryIsFallback: false; agentSessionId: string }) => void) | null = null;
|
||||
let resolveSummary:
|
||||
| ((value: { summary: string; summaryIsFallback: false; agentSessionId: string }) => void)
|
||||
| null = null;
|
||||
|
||||
const tracker = mockTracker("Fix auth bug");
|
||||
const agent = {
|
||||
...mockAgent(),
|
||||
getSessionInfo: vi.fn().mockImplementation(
|
||||
() => new Promise((resolve) => {
|
||||
resolveSummary = resolve;
|
||||
}),
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveSummary = resolve;
|
||||
}),
|
||||
),
|
||||
} as Agent;
|
||||
const registry = mockRegistry(tracker, agent);
|
||||
|
|
@ -1207,7 +1210,9 @@ describe("enrichSessionsMetadataFast", () => {
|
|||
const urlBase = "https://github.com/test/repo/issues/fast";
|
||||
const tracker: Tracker = {
|
||||
name: "mock-tracker",
|
||||
getIssue: vi.fn().mockResolvedValue({ id: "99", title: "Should not be called", url: urlBase }),
|
||||
getIssue: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ id: "99", title: "Should not be called", url: urlBase }),
|
||||
issueLabel: vi.fn().mockReturnValue("#99"),
|
||||
} as unknown as Tracker;
|
||||
const agent: Agent = {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
"use client";
|
||||
|
||||
interface FetchJsonOptions extends RequestInit {
|
||||
timeoutMs?: number;
|
||||
timeoutMessage?: string;
|
||||
}
|
||||
|
||||
function mergeAbortSignals(
|
||||
signals: Array<AbortSignal | null | undefined>,
|
||||
): AbortSignal | undefined {
|
||||
const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal));
|
||||
if (activeSignals.length === 0) return undefined;
|
||||
if (activeSignals.length === 1) return activeSignals[0];
|
||||
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort();
|
||||
|
||||
for (const signal of activeSignals) {
|
||||
if (signal.aborted) {
|
||||
controller.abort();
|
||||
break;
|
||||
}
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
}
|
||||
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response): Promise<string> {
|
||||
try {
|
||||
const payload = (await response.json()) as { error?: string; message?: string } | null;
|
||||
const message = payload?.error ?? payload?.message;
|
||||
if (typeof message === "string" && message.trim().length > 0) {
|
||||
return message.trim();
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse failures and fall back to status text.
|
||||
}
|
||||
|
||||
const statusText = typeof response.statusText === "string" ? response.statusText.trim() : "";
|
||||
if (statusText.length > 0) {
|
||||
return `${response.status} ${statusText}`;
|
||||
}
|
||||
|
||||
return `HTTP ${response.status}`;
|
||||
}
|
||||
|
||||
export async function fetchJsonWithTimeout<T>(
|
||||
input: RequestInfo | URL,
|
||||
options: FetchJsonOptions = {},
|
||||
): Promise<T> {
|
||||
const { timeoutMs = 8_000, timeoutMessage, signal, ...init } = options;
|
||||
const timeoutController = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let timedOut = false;
|
||||
|
||||
try {
|
||||
const mergedSignal = mergeAbortSignals([signal, timeoutController.signal]);
|
||||
const requestInit: RequestInit = { ...init };
|
||||
if (mergedSignal) {
|
||||
requestInit.signal = mergedSignal;
|
||||
}
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
timeoutController.abort();
|
||||
reject(new Error(timeoutMessage ?? `Request timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
const response = await Promise.race([
|
||||
fetch(input, requestInit).catch((error: unknown) => {
|
||||
if (timedOut) {
|
||||
throw new Error(timeoutMessage ?? `Request timed out after ${timeoutMs}ms`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}),
|
||||
timeoutPromise,
|
||||
]);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response));
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -90,7 +90,7 @@ export const getDashboardPageData = cache(async function getDashboardPageData(pr
|
|||
|
||||
let config: Awaited<ReturnType<typeof getServices>>["config"];
|
||||
let registry: Awaited<ReturnType<typeof getServices>>["registry"];
|
||||
let allSessions: Awaited<ReturnType<Awaited<ReturnType<typeof getServices>>["sessionManager"]["list"]>>;
|
||||
let allSessions: Awaited<ReturnType<Awaited<ReturnType<typeof getServices>>["sessionManager"]["listCached"]>>;
|
||||
|
||||
try {
|
||||
const services = await getServices();
|
||||
|
|
@ -98,7 +98,7 @@ export const getDashboardPageData = cache(async function getDashboardPageData(pr
|
|||
registry = services.registry;
|
||||
pageData.attentionZones = config.dashboard?.attentionZones ?? DEFAULT_ATTENTION_ZONE_MODE;
|
||||
try {
|
||||
allSessions = await services.sessionManager.list();
|
||||
allSessions = await services.sessionManager.listCached();
|
||||
} catch (listErr) {
|
||||
pageData.dashboardLoadError = formatDashboardLoadError(listErr);
|
||||
return pageData;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import "server-only";
|
||||
|
||||
import { cache } from "react";
|
||||
import { existsSync, realpathSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { ConfigNotFoundError, getGlobalConfigPath, loadConfig } from "@aoagents/ao-core";
|
||||
|
||||
export interface ProjectInfo {
|
||||
|
|
@ -26,9 +28,97 @@ function loadProjectDiscoveryConfig() {
|
|||
}
|
||||
}
|
||||
|
||||
function getCanonicalPath(path: string): string {
|
||||
try {
|
||||
return realpathSync(path);
|
||||
} catch {
|
||||
return resolve(path);
|
||||
}
|
||||
}
|
||||
|
||||
function findProjectIdForPath(projectPath: string, config: ReturnType<typeof loadProjectDiscoveryConfig>): string | undefined {
|
||||
const canonicalProjectPath = getCanonicalPath(projectPath);
|
||||
|
||||
for (const [projectId, project] of Object.entries(config.projects)) {
|
||||
if (typeof project.path !== "string") continue;
|
||||
if (getCanonicalPath(project.path) === canonicalProjectPath) {
|
||||
return projectId;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findLocalConfigPath(startDir: string): string | undefined {
|
||||
let currentDir = resolve(startDir);
|
||||
|
||||
while (true) {
|
||||
for (const filename of ["agent-orchestrator.yaml", "agent-orchestrator.yml"]) {
|
||||
const candidate = resolve(currentDir, filename);
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const parentDir = dirname(currentDir);
|
||||
if (parentDir === currentDir) {
|
||||
return undefined;
|
||||
}
|
||||
currentDir = parentDir;
|
||||
}
|
||||
}
|
||||
|
||||
function findDiscoveredRepoProjectId(config: ReturnType<typeof loadProjectDiscoveryConfig>): string | undefined {
|
||||
try {
|
||||
const localConfigPath = findLocalConfigPath(process.cwd());
|
||||
if (!localConfigPath) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const discoveredConfig = loadConfig(localConfigPath);
|
||||
const canonicalGlobalConfigPath = getCanonicalPath(getGlobalConfigPath());
|
||||
const canonicalDiscoveredConfigPath = getCanonicalPath(discoveredConfig.configPath);
|
||||
|
||||
if (canonicalDiscoveredConfigPath !== canonicalGlobalConfigPath) {
|
||||
for (const project of Object.values(discoveredConfig.projects)) {
|
||||
if (typeof project.path !== "string") continue;
|
||||
const projectId = findProjectIdForPath(project.path, config);
|
||||
if (projectId) return projectId;
|
||||
}
|
||||
|
||||
return findProjectIdForPath(dirname(discoveredConfig.configPath), config);
|
||||
}
|
||||
} catch {
|
||||
// Fall through to cwd-based discovery for environments without a local config.
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findCurrentRepoProjectId(
|
||||
config: ReturnType<typeof loadProjectDiscoveryConfig> = loadProjectDiscoveryConfig(),
|
||||
): string | undefined {
|
||||
try {
|
||||
const discoveredProjectId = findDiscoveredRepoProjectId(config);
|
||||
if (discoveredProjectId) {
|
||||
return discoveredProjectId;
|
||||
}
|
||||
|
||||
const cwd = getCanonicalPath(process.cwd());
|
||||
return findProjectIdForPath(cwd, config);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export const getProjectName = cache((): string => {
|
||||
try {
|
||||
const config = loadProjectDiscoveryConfig();
|
||||
const currentProjectId = findCurrentRepoProjectId(config);
|
||||
if (currentProjectId) {
|
||||
const currentProject = config.projects[currentProjectId];
|
||||
return currentProject?.name ?? currentProjectId;
|
||||
}
|
||||
const firstKey = Object.keys(config.projects)[0];
|
||||
if (firstKey) {
|
||||
const name = config.projects[firstKey].name ?? firstKey;
|
||||
|
|
@ -43,6 +133,8 @@ export const getProjectName = cache((): string => {
|
|||
export const getPrimaryProjectId = cache((): string => {
|
||||
try {
|
||||
const config = loadProjectDiscoveryConfig();
|
||||
const currentProjectId = findCurrentRepoProjectId(config);
|
||||
if (currentProjectId) return currentProjectId;
|
||||
const firstKey = Object.keys(config.projects)[0];
|
||||
if (firstKey) return firstKey;
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -105,22 +105,6 @@ function buildLifecycleSummary(session: Session): string {
|
|||
return `Session ${humanizeLifecycleToken(lifecycle.session.state)} (${humanizeLifecycleToken(lifecycle.session.reason)})`;
|
||||
}
|
||||
|
||||
function buildLifecycleGuidance(session: Session): string | null {
|
||||
const { lifecycle, metadata } = session;
|
||||
if (lifecycle.session.state !== "detecting") {
|
||||
return null;
|
||||
}
|
||||
const attempts = Number.parseInt(metadata["detectingAttempts"] ?? "0", 10);
|
||||
const normalizedAttempts = Number.isFinite(attempts) ? attempts : 0;
|
||||
if (metadata["detectingEscalatedAt"]) {
|
||||
return "Detection retries exhausted. Inspect runtime evidence or restore the session manually.";
|
||||
}
|
||||
if (normalizedAttempts > 0) {
|
||||
return `Checking runtime and process evidence now. Retry ${normalizedAttempts} is in progress.`;
|
||||
}
|
||||
return "Checking runtime and process evidence now.";
|
||||
}
|
||||
|
||||
function buildDashboardLifecycle(session: Session): NonNullable<DashboardSession["lifecycle"]> {
|
||||
const lifecycle = session.lifecycle;
|
||||
return {
|
||||
|
|
@ -161,7 +145,7 @@ function buildDashboardLifecycle(session: Session): NonNullable<DashboardSession
|
|||
detectingAttempts: Number.parseInt(session.metadata["detectingAttempts"] ?? "0", 10) || 0,
|
||||
detectingEscalatedAt: session.metadata["detectingEscalatedAt"] ?? null,
|
||||
summary: buildLifecycleSummary(session),
|
||||
guidance: buildLifecycleGuidance(session),
|
||||
guidance: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -246,7 +230,8 @@ export function listDashboardOrchestrators(
|
|||
}
|
||||
|
||||
const currentActivity = current.lastActivityAt?.getTime() ?? current.createdAt?.getTime() ?? 0;
|
||||
const candidateActivity = session.lastActivityAt?.getTime() ?? session.createdAt?.getTime() ?? 0;
|
||||
const candidateActivity =
|
||||
session.lastActivityAt?.getTime() ?? session.createdAt?.getTime() ?? 0;
|
||||
if (candidateActivity > currentActivity) {
|
||||
bestByProject.set(session.projectId, session);
|
||||
continue;
|
||||
|
|
@ -300,7 +285,9 @@ function basicPRToDashboard(pr: PRInfo): DashboardPR {
|
|||
};
|
||||
}
|
||||
|
||||
function normalizeDashboardPRState(state: Session["lifecycle"]["pr"]["state"]): DashboardPR["state"] {
|
||||
function normalizeDashboardPRState(
|
||||
state: Session["lifecycle"]["pr"]["state"],
|
||||
): DashboardPR["state"] {
|
||||
switch (state) {
|
||||
case "merged":
|
||||
return "merged";
|
||||
|
|
@ -616,7 +603,10 @@ function prepareSessionMetadataEnrichment(
|
|||
|
||||
// Issue labels (synchronous string parsing, no API calls)
|
||||
projects.forEach((project, i) => {
|
||||
if ((!dashboardSessions[i].issueUrl && !dashboardSessions[i].issueId) || !project?.tracker?.plugin) {
|
||||
if (
|
||||
(!dashboardSessions[i].issueUrl && !dashboardSessions[i].issueId) ||
|
||||
!project?.tracker?.plugin
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const tracker = registry.get<Tracker>("tracker", project.tracker.plugin);
|
||||
|
|
|
|||
Loading…
Reference in New Issue