fix: finish PR #1300 major follow-up fixes
This commit is contained in:
parent
76ae4ef426
commit
db340fad88
|
|
@ -4,3 +4,8 @@
|
|||
---
|
||||
|
||||
Allow workers to report non-terminal PR workflow events like `pr-created`, `draft-pr-created`, and `ready-for-review` with optional PR URL/number metadata, while keeping merged and closed PR state SCM-owned.
|
||||
|
||||
**Migration:** `Session` now carries canonical lifecycle truth in `session.lifecycle`
|
||||
and explicit activity-evidence metadata in `session.activitySignal`. Third-party
|
||||
callers that construct `Session` objects directly must populate those fields or
|
||||
route through the core session helpers that synthesize them.
|
||||
|
|
|
|||
|
|
@ -4,4 +4,4 @@
|
|||
"@aoagents/ao-web": patch
|
||||
---
|
||||
|
||||
Tighten the session lifecycle review follow-ups by debouncing report-watcher reactions, restoring the shared Geist/JetBrains font setup, wiring recovery validation to real agent activity probes, adding direct coverage for `ao report`, activity-signal classification, and dashboard lifecycle audit panels, and fixing the remaining lifecycle-state regressions around legacy merged-session rehydration and malformed canonical payload parsing.
|
||||
Tighten the session lifecycle review follow-ups by debouncing report-watcher reactions, restoring the shared Geist/JetBrains font setup, wiring recovery validation to real agent activity probes, adding direct coverage for `ao report`, activity-signal classification, and dashboard lifecycle audit panels, fixing the remaining lifecycle-state regressions around legacy merged-session rehydration and malformed canonical payload parsing, making agent-report metadata writes atomic, persisting canonical payloads for legacy sessions on read, stabilizing detecting evidence hashes, and removing the remaining inline-style cleanup debt from the session detail view.
|
||||
|
|
|
|||
|
|
@ -24,6 +24,17 @@ describe("hashEvidence", () => {
|
|||
const hash2 = hashEvidence("evidence B");
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
|
||||
it("ignores activity labels and timestamps when hashing probe evidence", () => {
|
||||
const active = hashEvidence(
|
||||
"signal_disagreement runtime=alive process=unknown activity_signal=valid via_native activity=active at=2026-04-18T10:00:00.000Z",
|
||||
);
|
||||
const blocked = hashEvidence(
|
||||
"signal_disagreement runtime=alive process=unknown activity_signal=valid via_native activity=blocked at=2026-04-18T10:01:00.000Z",
|
||||
);
|
||||
|
||||
expect(active).toBe(blocked);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDetectingTimedOut", () => {
|
||||
|
|
|
|||
|
|
@ -88,6 +88,34 @@ describe("list", () => {
|
|||
expect(repaired!["status"]).toBe("working");
|
||||
});
|
||||
|
||||
it("persists canonical lifecycle payloads for legacy session metadata on read", async () => {
|
||||
writeMetadata(sessionsDir, "app-legacy", {
|
||||
worktree: "/tmp/legacy",
|
||||
branch: "feat/legacy",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const oldTime = new Date("2026-01-02T00:00:00.000Z");
|
||||
utimesSync(join(sessionsDir, "app-legacy"), oldTime, oldTime);
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
const sessions = await sm.list("my-app");
|
||||
const legacy = sessions.find((session) => session.id === "app-legacy");
|
||||
|
||||
expect(legacy).toBeDefined();
|
||||
expect(legacy!.lastActivityAt.getTime()).toBe(oldTime.getTime());
|
||||
|
||||
const repaired = readMetadataRaw(sessionsDir, "app-legacy");
|
||||
expect(repaired?.["stateVersion"]).toBe("2");
|
||||
expect(repaired?.["statePayload"]).toBeTruthy();
|
||||
|
||||
const payload = JSON.parse(repaired!["statePayload"]);
|
||||
expect(payload.session.startedAt).toBe("2025-01-01T00:00:00.000Z");
|
||||
expect(payload.session.lastTransitionAt).toBe("2025-01-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("filters by project ID", async () => {
|
||||
// In hash-based architecture, each project has its own directory
|
||||
// so filtering is implicit. This test verifies list(projectId) only
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ import type {
|
|||
SessionId,
|
||||
SessionStatus,
|
||||
} from "./types.js";
|
||||
import { updateCanonicalLifecycle, updateMetadata, readMetadataRaw } from "./metadata.js";
|
||||
import { deriveLegacyStatus } from "./lifecycle-state.js";
|
||||
import { mutateMetadata, readMetadataRaw } from "./metadata.js";
|
||||
import { buildLifecycleMetadataPatch, cloneLifecycle, deriveLegacyStatus, parseCanonicalLifecycle } from "./lifecycle-state.js";
|
||||
import { parsePrFromUrl } from "./utils/pr.js";
|
||||
import { validateStatus } from "./utils/validation.js";
|
||||
|
||||
|
|
@ -402,7 +402,13 @@ export function applyAgentReport(
|
|||
let legacyStatus: SessionStatus | null = null;
|
||||
let previousLegacyStatus: SessionStatus | null = null;
|
||||
|
||||
const nextLifecycle = updateCanonicalLifecycle(dataDir, sessionId, (current) => {
|
||||
const nextMetadata = mutateMetadata(dataDir, sessionId, (existing) => {
|
||||
const current = cloneLifecycle(
|
||||
parseCanonicalLifecycle(existing, {
|
||||
sessionId,
|
||||
status: validateStatus(existing["status"]),
|
||||
}),
|
||||
);
|
||||
previousLegacyStatus = deriveLegacyStatus(current, validateStatus(raw["status"]));
|
||||
before = buildAuditSnapshot(current, previousLegacyStatus);
|
||||
const validation = validateAgentReportTransition(current, input.state);
|
||||
|
|
@ -454,36 +460,42 @@ export function applyAgentReport(
|
|||
current.session.startedAt = now;
|
||||
}
|
||||
legacyStatus = deriveLegacyStatus(current, previousLegacyStatus);
|
||||
return current;
|
||||
const next = { ...existing };
|
||||
Object.assign(
|
||||
next,
|
||||
buildLifecycleMetadataPatch(current, previousLegacyStatus),
|
||||
{
|
||||
[AGENT_REPORT_METADATA_KEYS.STATE]: input.state,
|
||||
[AGENT_REPORT_METADATA_KEYS.AT]: now,
|
||||
},
|
||||
);
|
||||
if (trimmedNote) {
|
||||
next[AGENT_REPORT_METADATA_KEYS.NOTE] = trimmedNote;
|
||||
} else {
|
||||
next[AGENT_REPORT_METADATA_KEYS.NOTE] = "";
|
||||
}
|
||||
if (isPRWorkflowReport(input.state)) {
|
||||
if (trimmedPrUrl) {
|
||||
next[AGENT_REPORT_METADATA_KEYS.PR_URL] = trimmedPrUrl;
|
||||
}
|
||||
if (prNumber !== undefined) {
|
||||
next[AGENT_REPORT_METADATA_KEYS.PR_NUMBER] = String(prNumber);
|
||||
}
|
||||
if (prIsDraft !== undefined) {
|
||||
next[AGENT_REPORT_METADATA_KEYS.PR_IS_DRAFT] = prIsDraft ? "true" : "false";
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!nextLifecycle || !before || !previousState || !nextState || !legacyStatus) {
|
||||
if (!nextMetadata || !before || !previousState || !nextState || !legacyStatus) {
|
||||
throw new Error(`Failed to apply agent report for session ${sessionId}`);
|
||||
}
|
||||
|
||||
// Persist report metadata alongside the lifecycle patch.
|
||||
const metadataUpdates: Record<string, string> = {
|
||||
[AGENT_REPORT_METADATA_KEYS.STATE]: input.state,
|
||||
[AGENT_REPORT_METADATA_KEYS.AT]: now,
|
||||
};
|
||||
if (trimmedNote) {
|
||||
metadataUpdates[AGENT_REPORT_METADATA_KEYS.NOTE] = trimmedNote;
|
||||
} else {
|
||||
// Clear stale notes from previous reports so they don't mislead humans.
|
||||
metadataUpdates[AGENT_REPORT_METADATA_KEYS.NOTE] = "";
|
||||
}
|
||||
if (isPRWorkflowReport(input.state)) {
|
||||
if (trimmedPrUrl) {
|
||||
metadataUpdates[AGENT_REPORT_METADATA_KEYS.PR_URL] = trimmedPrUrl;
|
||||
}
|
||||
if (prNumber !== undefined) {
|
||||
metadataUpdates[AGENT_REPORT_METADATA_KEYS.PR_NUMBER] = String(prNumber);
|
||||
}
|
||||
if (prIsDraft !== undefined) {
|
||||
metadataUpdates[AGENT_REPORT_METADATA_KEYS.PR_IS_DRAFT] = prIsDraft ? "true" : "false";
|
||||
}
|
||||
}
|
||||
updateMetadata(dataDir, sessionId, metadataUpdates);
|
||||
const nextLifecycle = parseCanonicalLifecycle(nextMetadata, {
|
||||
sessionId,
|
||||
status: validateStatus(nextMetadata["status"]),
|
||||
});
|
||||
|
||||
const after = buildAuditSnapshot(nextLifecycle, legacyStatus);
|
||||
const auditEntry: AgentReportAuditEntry = {
|
||||
|
|
|
|||
|
|
@ -568,8 +568,17 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
if (terminalOutput) {
|
||||
await agent.recordActivity(session, terminalOutput);
|
||||
}
|
||||
} catch {
|
||||
// Best effort only.
|
||||
} catch (error) {
|
||||
observer?.recordOperation?.({
|
||||
metric: "lifecycle_poll",
|
||||
operation: "activity.record",
|
||||
outcome: "failure",
|
||||
correlationId: createCorrelationId("lifecycle-poll"),
|
||||
projectId: session.projectId,
|
||||
sessionId: session.id,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
level: "warn",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -716,8 +725,17 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
const sessionsDir = getSessionsDir(config.configPath, project.path);
|
||||
updateMetadata(sessionsDir, session.id, { pr: detectedPR.url });
|
||||
}
|
||||
} catch {
|
||||
// Retry next poll.
|
||||
} catch (error) {
|
||||
observer?.recordOperation?.({
|
||||
metric: "lifecycle_poll",
|
||||
operation: "scm.detect_pr",
|
||||
outcome: "failure",
|
||||
correlationId: createCorrelationId("lifecycle-poll"),
|
||||
projectId: session.projectId,
|
||||
sessionId: session.id,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
level: "warn",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -789,8 +807,17 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
activityEvidence,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// Keep current status on SCM failure.
|
||||
} catch (error) {
|
||||
observer?.recordOperation?.({
|
||||
metric: "lifecycle_poll",
|
||||
operation: "scm.poll_pr",
|
||||
outcome: "failure",
|
||||
correlationId: createCorrelationId("lifecycle-poll"),
|
||||
projectId: session.projectId,
|
||||
sessionId: session.id,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
level: "warn",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,8 +43,18 @@ interface LifecycleDecision {
|
|||
* Create a short hash of evidence string for detecting unchanged evidence.
|
||||
* Used to prevent counter reset when the same weak evidence re-presents.
|
||||
*/
|
||||
function normalizeEvidenceForHash(evidence: string): string {
|
||||
return evidence
|
||||
.replace(/\sactivity=[^\s]+/g, "")
|
||||
.replace(/\sat=[^\s]+/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function hashEvidence(evidence: string): string {
|
||||
return createHash("sha256").update(evidence).digest("hex").slice(0, 12);
|
||||
return createHash("sha256")
|
||||
.update(normalizeEvidenceForHash(evidence))
|
||||
.digest("hex")
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
interface OpenPRDecisionInput {
|
||||
|
|
|
|||
|
|
@ -169,26 +169,50 @@ export function updateMetadata(
|
|||
sessionId: SessionId,
|
||||
updates: Partial<Record<string, string>>,
|
||||
): void {
|
||||
mutateMetadata(dataDir, sessionId, (existing) => {
|
||||
return applyMetadataUpdates(existing, updates);
|
||||
}, { createIfMissing: true });
|
||||
}
|
||||
|
||||
function applyMetadataUpdates(
|
||||
existing: Record<string, string>,
|
||||
updates: Partial<Record<string, string>>,
|
||||
): Record<string, string> {
|
||||
let next = { ...existing };
|
||||
// Merge updates — remove keys set to empty string
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value === undefined) continue;
|
||||
if (value === "") {
|
||||
const { [key]: _removed, ...rest } = next;
|
||||
void _removed;
|
||||
next = rest;
|
||||
} else {
|
||||
next[key] = value;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function mutateMetadata(
|
||||
dataDir: string,
|
||||
sessionId: SessionId,
|
||||
updater: (existing: Record<string, string>) => Record<string, string>,
|
||||
options: { createIfMissing?: boolean } = {},
|
||||
): Record<string, string> | null {
|
||||
const path = metadataPath(dataDir, sessionId);
|
||||
let existing: Record<string, string> = {};
|
||||
|
||||
if (existsSync(path)) {
|
||||
existing = parseKeyValueContent(readFileSync(path, "utf-8"));
|
||||
} else if (!options.createIfMissing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Merge updates — remove keys set to empty string
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value === undefined) continue;
|
||||
if (value === "") {
|
||||
const { [key]: _, ...rest } = existing;
|
||||
existing = rest;
|
||||
} else {
|
||||
existing[key] = value;
|
||||
}
|
||||
}
|
||||
const next = updater({ ...existing });
|
||||
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
atomicWriteFileSync(path, serializeMetadata(existing));
|
||||
atomicWriteFileSync(path, serializeMetadata(next));
|
||||
return next;
|
||||
}
|
||||
|
||||
export function readCanonicalLifecycle(
|
||||
|
|
|
|||
|
|
@ -494,6 +494,24 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
const duplicatePRAttachments = new Map<string, ActiveSessionRecord[]>();
|
||||
|
||||
for (const record of repaired) {
|
||||
if (record.raw["stateVersion"] !== "2" || !record.raw["statePayload"]) {
|
||||
const lifecycle = cloneLifecycle(
|
||||
parseCanonicalLifecycle(record.raw, {
|
||||
sessionId: record.sessionName,
|
||||
status: validateStatus(record.raw["status"]),
|
||||
createdAt: record.raw["createdAt"] ? new Date(record.raw["createdAt"]) : undefined,
|
||||
}),
|
||||
);
|
||||
const canonicalUpdates = lifecycleMetadataUpdates(record.raw, lifecycle);
|
||||
updateMetadataPreservingMtime(
|
||||
sessionsDir,
|
||||
record.sessionName,
|
||||
canonicalUpdates,
|
||||
record.modifiedAt,
|
||||
);
|
||||
record.raw = applyMetadataUpdatesToRaw(record.raw, canonicalUpdates);
|
||||
}
|
||||
|
||||
if (isOrchestratorSessionRecord(record.sessionName, record.raw, sessionPrefix)) {
|
||||
record.raw = repairSingleSessionMetadataOnRead(sessionsDir, record, sessionPrefix).raw;
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -428,6 +428,12 @@ describe("scm-github plugin", () => {
|
|||
expect(ghMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null when project has no repo configured", async () => {
|
||||
const result = await scm.detectPR(makeSession(), { ...project, repo: undefined });
|
||||
expect(result).toBeNull();
|
||||
expect(ghMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null on gh CLI error", async () => {
|
||||
mockGhError("gh: not found");
|
||||
const result = await scm.detectPR(makeSession(), project);
|
||||
|
|
|
|||
|
|
@ -356,6 +356,12 @@ describe("scm-gitlab plugin", () => {
|
|||
expect(glabMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null when project has no repo configured", async () => {
|
||||
const result = await scm.detectPR(makeSession(), { ...project, repo: undefined });
|
||||
expect(result).toBeNull();
|
||||
expect(glabMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null and warns on glab CLI error", async () => {
|
||||
mockGlabError("glab: not found");
|
||||
const result = await scm.detectPR(makeSession(), project);
|
||||
|
|
|
|||
|
|
@ -1841,6 +1841,37 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
height: 6px;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.session-detail-tone--working {
|
||||
color: var(--color-status-working);
|
||||
background: var(--color-status-working);
|
||||
}
|
||||
|
||||
.session-detail-tone--ready {
|
||||
color: var(--color-status-ready);
|
||||
background: var(--color-status-ready);
|
||||
}
|
||||
|
||||
.session-detail-tone--attention {
|
||||
color: var(--color-status-attention);
|
||||
background: var(--color-status-attention);
|
||||
}
|
||||
|
||||
.session-detail-tone--error {
|
||||
color: var(--color-status-error);
|
||||
background: var(--color-status-error);
|
||||
}
|
||||
|
||||
.session-detail-tone--muted {
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.session-detail-tone--accent {
|
||||
color: var(--color-accent);
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.session-detail-status-pill--active {
|
||||
|
|
@ -1939,6 +1970,69 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
background: color-mix(in srgb, var(--color-bg-elevated) 92%, transparent);
|
||||
}
|
||||
|
||||
.session-detail-height--worker {
|
||||
height: clamp(380px, 48vh, 560px);
|
||||
}
|
||||
|
||||
.session-detail-height--orchestrator {
|
||||
height: clamp(400px, 52vh, 620px);
|
||||
}
|
||||
|
||||
.session-detail-height--full {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.session-detail-zone-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.session-detail-zone-pill__value {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.session-detail-zone-pill__label {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.session-detail-zone-pill--merge {
|
||||
color: var(--color-status-ready);
|
||||
background: color-mix(in srgb, var(--color-status-ready) 10%, transparent);
|
||||
}
|
||||
|
||||
.session-detail-zone-pill--respond {
|
||||
color: var(--color-status-error);
|
||||
background: color-mix(in srgb, var(--color-status-error) 10%, transparent);
|
||||
}
|
||||
|
||||
.session-detail-zone-pill--review {
|
||||
color: var(--color-accent-orange);
|
||||
background: color-mix(in srgb, var(--color-accent-orange) 10%, transparent);
|
||||
}
|
||||
|
||||
.session-detail-zone-pill--working {
|
||||
color: var(--color-accent-blue);
|
||||
background: color-mix(in srgb, var(--color-accent-blue) 10%, transparent);
|
||||
}
|
||||
|
||||
.session-detail-zone-pill--pending {
|
||||
color: var(--color-status-attention);
|
||||
background: color-mix(in srgb, var(--color-status-attention) 10%, transparent);
|
||||
}
|
||||
|
||||
.session-detail-zone-pill--done {
|
||||
color: var(--color-text-tertiary);
|
||||
background: color-mix(in srgb, var(--color-text-tertiary) 14%, transparent);
|
||||
}
|
||||
|
||||
/* ── Session cards — Emil design vision ──────────────────────────────── */
|
||||
|
||||
.session-card {
|
||||
|
|
@ -5012,6 +5106,31 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.session-detail__status-pill--active {
|
||||
color: var(--color-status-working);
|
||||
background: color-mix(in srgb, var(--color-status-working) 12%, transparent);
|
||||
}
|
||||
|
||||
.session-detail__status-pill--ready {
|
||||
color: var(--color-status-ready);
|
||||
background: color-mix(in srgb, var(--color-status-ready) 12%, transparent);
|
||||
}
|
||||
|
||||
.session-detail__status-pill--waiting {
|
||||
color: var(--color-status-attention);
|
||||
background: color-mix(in srgb, var(--color-status-attention) 12%, transparent);
|
||||
}
|
||||
|
||||
.session-detail__status-pill--error {
|
||||
color: var(--color-status-error);
|
||||
background: color-mix(in srgb, var(--color-status-error) 12%, transparent);
|
||||
}
|
||||
|
||||
.session-detail__status-pill--idle {
|
||||
color: var(--color-text-muted);
|
||||
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.session-detail__time {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-mono);
|
||||
|
|
@ -5094,6 +5213,22 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-detail-ci-tone--neutral {
|
||||
background: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.session-detail-ci-tone--pass {
|
||||
background: var(--color-accent-green);
|
||||
}
|
||||
|
||||
.session-detail-ci-tone--fail {
|
||||
background: var(--color-accent-red);
|
||||
}
|
||||
|
||||
.session-detail-ci-tone--pending {
|
||||
background: var(--color-status-attention);
|
||||
}
|
||||
|
||||
/* ── Mobile PR Dense Rows enhancements ─────────────────────────────── */
|
||||
|
||||
.mobile-pr-mobile-layout {
|
||||
|
|
|
|||
|
|
@ -72,13 +72,6 @@ function formatTimeCompact(isoDate: string | null): string {
|
|||
return `${Math.floor(diffHours / 24)}d ago`;
|
||||
}
|
||||
|
||||
function getCiDotBg(pr: DashboardPR): string {
|
||||
if (isPRRateLimited(pr) || isPRUnenriched(pr)) return "var(--color-text-tertiary)";
|
||||
if (pr.ciStatus === "passing") return "var(--color-accent-green)";
|
||||
if (pr.ciStatus === "failing") return "var(--color-accent-red)";
|
||||
return "var(--color-status-attention)";
|
||||
}
|
||||
|
||||
function getCiShortLabel(pr: DashboardPR): string {
|
||||
if (isPRRateLimited(pr) || isPRUnenriched(pr)) return "CI";
|
||||
if (pr.ciStatus === "passing") return "CI passing";
|
||||
|
|
@ -132,6 +125,39 @@ function activityStateClass(activityLabel: string): string {
|
|||
return "session-detail-status-pill--neutral";
|
||||
}
|
||||
|
||||
function activityToneClass(activityColor: string): string {
|
||||
switch (activityColor) {
|
||||
case "var(--color-status-working)":
|
||||
return "session-detail-tone--working";
|
||||
case "var(--color-status-ready)":
|
||||
return "session-detail-tone--ready";
|
||||
case "var(--color-status-attention)":
|
||||
return "session-detail-tone--attention";
|
||||
case "var(--color-status-error)":
|
||||
return "session-detail-tone--error";
|
||||
default:
|
||||
return "session-detail-tone--muted";
|
||||
}
|
||||
}
|
||||
|
||||
function mobileStatusPillClass(activityLabel: string): string {
|
||||
const normalized = activityLabel.toLowerCase();
|
||||
if (normalized === "active") return "session-detail__status-pill--active";
|
||||
if (normalized === "ready") return "session-detail__status-pill--ready";
|
||||
if (normalized === "waiting for input") return "session-detail__status-pill--waiting";
|
||||
if (normalized === "blocked" || normalized === "exited") {
|
||||
return "session-detail__status-pill--error";
|
||||
}
|
||||
return "session-detail__status-pill--idle";
|
||||
}
|
||||
|
||||
function ciToneClass(pr: DashboardPR): string {
|
||||
if (isPRRateLimited(pr) || isPRUnenriched(pr)) return "session-detail-ci-tone--neutral";
|
||||
if (pr.ciStatus === "passing") return "session-detail-ci-tone--pass";
|
||||
if (pr.ciStatus === "failing") return "session-detail-ci-tone--fail";
|
||||
return "session-detail-ci-tone--pending";
|
||||
}
|
||||
|
||||
function SessionTopStrip({
|
||||
headline,
|
||||
crumbId,
|
||||
|
|
@ -199,8 +225,10 @@ function SessionTopStrip({
|
|||
)}
|
||||
>
|
||||
<span
|
||||
className="session-detail-status-pill__dot"
|
||||
style={{ background: activityColor }}
|
||||
className={cn(
|
||||
"session-detail-status-pill__dot",
|
||||
activityToneClass(activityColor),
|
||||
)}
|
||||
/>
|
||||
<span className="session-detail-status-pill__label">
|
||||
{activityLabel}
|
||||
|
|
@ -344,42 +372,36 @@ function _OrchestratorStatusStrip({
|
|||
return () => clearInterval(id);
|
||||
}, [createdAt]);
|
||||
|
||||
const stats: Array<{ value: number; label: string; color: string; bg: string }> = [
|
||||
const stats: Array<{ value: number; label: string; toneClass: string }> = [
|
||||
{
|
||||
value: zones.merge,
|
||||
label: "merge-ready",
|
||||
color: "var(--color-status-ready)",
|
||||
bg: "color-mix(in srgb, var(--color-status-ready) 10%, transparent)",
|
||||
toneClass: "session-detail-zone-pill--merge",
|
||||
},
|
||||
{
|
||||
value: zones.respond,
|
||||
label: "responding",
|
||||
color: "var(--color-status-error)",
|
||||
bg: "color-mix(in srgb, var(--color-status-error) 10%, transparent)",
|
||||
toneClass: "session-detail-zone-pill--respond",
|
||||
},
|
||||
{
|
||||
value: zones.review,
|
||||
label: "review",
|
||||
color: "var(--color-accent-orange)",
|
||||
bg: "color-mix(in srgb, var(--color-accent-orange) 10%, transparent)",
|
||||
toneClass: "session-detail-zone-pill--review",
|
||||
},
|
||||
{
|
||||
value: zones.working,
|
||||
label: "working",
|
||||
color: "var(--color-accent-blue)",
|
||||
bg: "color-mix(in srgb, var(--color-accent-blue) 10%, transparent)",
|
||||
toneClass: "session-detail-zone-pill--working",
|
||||
},
|
||||
{
|
||||
value: zones.pending,
|
||||
label: "pending",
|
||||
color: "var(--color-status-attention)",
|
||||
bg: "color-mix(in srgb, var(--color-status-attention) 10%, transparent)",
|
||||
toneClass: "session-detail-zone-pill--pending",
|
||||
},
|
||||
{
|
||||
value: zones.done,
|
||||
label: "done",
|
||||
color: "var(--color-text-tertiary)",
|
||||
bg: "color-mix(in srgb, var(--color-text-tertiary) 14%, transparent)",
|
||||
toneClass: "session-detail-zone-pill--done",
|
||||
},
|
||||
].filter((s) => s.value > 0);
|
||||
|
||||
|
|
@ -414,19 +436,12 @@ function _OrchestratorStatusStrip({
|
|||
stats.map((s) => (
|
||||
<div
|
||||
key={s.label}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1"
|
||||
style={{ background: s.bg }}
|
||||
className={cn("session-detail-zone-pill", s.toneClass)}
|
||||
>
|
||||
<span
|
||||
className="text-[15px] font-bold leading-none tabular-nums"
|
||||
style={{ color: s.color }}
|
||||
>
|
||||
<span className="session-detail-zone-pill__value">
|
||||
{s.value}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] font-medium"
|
||||
style={{ color: s.color, opacity: 0.8 }}
|
||||
>
|
||||
<span className="session-detail-zone-pill__label">
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -479,12 +494,14 @@ export function SessionDetail({
|
|||
};
|
||||
const headline = getSessionTitle(session);
|
||||
|
||||
const accentColor = "var(--color-accent)";
|
||||
const terminalVariant = isOrchestrator ? "orchestrator" : "agent";
|
||||
|
||||
const terminalHeight = isOrchestrator
|
||||
? "clamp(400px, 52vh, 620px)"
|
||||
: "clamp(380px, 48vh, 560px)";
|
||||
const terminalHeightClass = isOrchestrator
|
||||
? "session-detail-height--orchestrator"
|
||||
: "session-detail-height--worker";
|
||||
const isOpenCodeSession = session.metadata["agent"] === "opencode";
|
||||
const opencodeSessionId =
|
||||
typeof session.metadata["opencodeSessionId"] === "string" &&
|
||||
|
|
@ -659,17 +676,26 @@ export function SessionDetail({
|
|||
<div id="session-terminal-section" aria-hidden="true" />
|
||||
<div className="session-detail-section-label">
|
||||
<div
|
||||
className="session-detail-section-label__bar"
|
||||
style={{ background: isOrchestrator ? accentColor : activity.color }}
|
||||
className={cn(
|
||||
"session-detail-section-label__bar",
|
||||
isOrchestrator
|
||||
? "session-detail-tone--accent"
|
||||
: activityToneClass(activity.color),
|
||||
)}
|
||||
/>
|
||||
<span className="session-detail-section-label__text">
|
||||
Live Terminal
|
||||
</span>
|
||||
</div>
|
||||
{!showTerminal ? (
|
||||
<div className="session-detail-terminal-placeholder" style={{ height: terminalHeight }} />
|
||||
<div
|
||||
className={cn(
|
||||
"session-detail-terminal-placeholder",
|
||||
terminalHeightClass,
|
||||
)}
|
||||
/>
|
||||
) : terminalEnded ? (
|
||||
<div className="terminal-exited-placeholder" style={{ height: terminalHeight }}>
|
||||
<div className={cn("terminal-exited-placeholder", terminalHeightClass)}>
|
||||
<span className="terminal-exited-placeholder__text">
|
||||
Terminal session has ended
|
||||
</span>
|
||||
|
|
@ -695,14 +721,6 @@ export function SessionDetail({
|
|||
);
|
||||
}
|
||||
|
||||
const statusPillBg = activity.color === "var(--color-status-working)"
|
||||
? "color-mix(in srgb, var(--color-status-working) 12%, transparent)"
|
||||
: activity.color === "var(--color-status-attention)"
|
||||
? "color-mix(in srgb, var(--color-status-attention) 12%, transparent)"
|
||||
: activity.color === "var(--color-status-error)"
|
||||
? "color-mix(in srgb, var(--color-status-error) 12%, transparent)"
|
||||
: "color-mix(in srgb, var(--color-accent) 12%, transparent)";
|
||||
|
||||
return (
|
||||
<div className="session-detail--terminal-first">
|
||||
{/* Floating header */}
|
||||
|
|
@ -712,11 +730,13 @@ export function SessionDetail({
|
|||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</a>
|
||||
<span className="session-detail__status-dot" style={{ background: activity.color }} />
|
||||
<span className={cn("session-detail__status-dot", activityToneClass(activity.color))} />
|
||||
<span className="session-detail__session-id">{session.id}</span>
|
||||
<span
|
||||
className="session-detail__status-pill"
|
||||
style={{ background: statusPillBg, color: activity.color }}
|
||||
className={cn(
|
||||
"session-detail__status-pill",
|
||||
mobileStatusPillClass(activity.label),
|
||||
)}
|
||||
>
|
||||
{activity.label.toLowerCase()}
|
||||
</span>
|
||||
|
|
@ -728,9 +748,9 @@ export function SessionDetail({
|
|||
{/* Terminal fills the viewport */}
|
||||
<div className={`session-detail__terminal-full${pr ? " session-detail__terminal-full--with-sheet" : ""}`}>
|
||||
{!showTerminal ? (
|
||||
<div className="session-detail-terminal-placeholder" style={{ height: "100%" }} />
|
||||
<div className="session-detail-terminal-placeholder session-detail-height--full" />
|
||||
) : terminalEnded ? (
|
||||
<div className="terminal-exited-placeholder" style={{ height: "100%" }}>
|
||||
<div className="terminal-exited-placeholder session-detail-height--full">
|
||||
<span className="terminal-exited-placeholder__text">
|
||||
Terminal session has ended
|
||||
</span>
|
||||
|
|
@ -764,8 +784,7 @@ export function SessionDetail({
|
|||
</a>
|
||||
<span className="session-detail__sheet-item">
|
||||
<span
|
||||
className="session-detail__sheet-ci-dot"
|
||||
style={{ background: getCiDotBg(pr) }}
|
||||
className={cn("session-detail__sheet-ci-dot", ciToneClass(pr))}
|
||||
/>
|
||||
{getCiShortLabel(pr)}
|
||||
</span>
|
||||
|
|
|
|||
Loading…
Reference in New Issue