fix: finish PR #1300 major follow-up fixes

This commit is contained in:
harshitsinghbhandari 2026-04-18 12:35:15 +05:30
parent 76ae4ef426
commit db340fad88
13 changed files with 400 additions and 99 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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", () => {

View File

@ -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

View File

@ -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 = {

View File

@ -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",
});
}
}

View File

@ -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 {

View File

@ -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(

View File

@ -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;

View File

@ -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);

View File

@ -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);

View File

@ -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 {

View File

@ -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>