From 08667c8cb529a804cffaff484eccac4b17f6afa9 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Fri, 17 Apr 2026 14:42:25 +0530 Subject: [PATCH] fix(web): keep closed-unmerged sessions actionable --- .../fix-web-lifecycle-restore-affordances.md | 5 + .../src/__tests__/get-attention-level.test.ts | 59 +++++++-- packages/web/src/components/Dashboard.tsx | 59 +++++---- .../__tests__/Dashboard.doneBar.test.tsx | 7 ++ packages/web/src/lib/__tests__/types.test.ts | 119 ++++++++++++++++-- packages/web/src/lib/types.ts | 17 ++- 6 files changed, 221 insertions(+), 45 deletions(-) create mode 100644 .changeset/fix-web-lifecycle-restore-affordances.md diff --git a/.changeset/fix-web-lifecycle-restore-affordances.md b/.changeset/fix-web-lifecycle-restore-affordances.md new file mode 100644 index 000000000..d3257c31a --- /dev/null +++ b/.changeset/fix-web-lifecycle-restore-affordances.md @@ -0,0 +1,5 @@ +--- +"@aoagents/ao-web": patch +--- + +Keep closed-unmerged sessions actionable in the dashboard by removing them from the done lane unless the runtime actually ended, and hide restore controls for merged sessions that are intentionally non-restorable. diff --git a/packages/web/src/__tests__/get-attention-level.test.ts b/packages/web/src/__tests__/get-attention-level.test.ts index 4856c745f..0ad66d287 100644 --- a/packages/web/src/__tests__/get-attention-level.test.ts +++ b/packages/web/src/__tests__/get-attention-level.test.ts @@ -245,29 +245,72 @@ describe("getAttentionLevel", () => { describe("done zone", () => { it("returns done when PR is merged", () => { const pr = makePR({ state: "merged" }); - const session = makeSession({ status: "merged", activity: "exited", pr }); + const session = makeSession({ + status: "merged", + activity: "exited", + pr, + lifecycle: { + ...makeSession().lifecycle!, + sessionState: "idle", + sessionReason: "merged_waiting_decision", + prState: "merged", + prReason: "merged", + }, + }); expect(getAttentionLevel(session)).toBe("done"); }); - it("returns done when PR is closed (not merged)", () => { - const pr = makePR({ state: "closed" }); - const session = makeSession({ status: "working", activity: "idle", pr }); - expect(getAttentionLevel(session)).toBe("done"); + it("returns pending when the PR is closed without merge but runtime is still alive", () => { + const pr = makePR({ + state: "closed", + reviewDecision: "none", + mergeability: { + mergeable: false, + ciPassing: false, + approved: false, + noConflicts: true, + blockers: [], + }, + }); + const session = makeSession({ + status: "idle", + activity: "idle", + pr, + lifecycle: { + ...makeSession().lifecycle!, + sessionState: "idle", + sessionReason: "pr_closed_waiting_decision", + prState: "closed", + prReason: "closed_unmerged", + runtimeState: "alive", + }, + }); + expect(getAttentionLevel(session)).toBe("pending"); }); it("returns done when session status is merged (even with open PR state)", () => { const pr = makePR({ state: "merged" }); - const session = makeSession({ status: "merged", activity: "idle", pr }); + const session = makeSession({ status: "merged", activity: "idle", pr, lifecycle: undefined }); expect(getAttentionLevel(session)).toBe("done"); }); it("returns done when session is killed", () => { - const session = makeSession({ status: "killed", activity: "exited", pr: null }); + const session = makeSession({ + status: "killed", + activity: "exited", + pr: null, + lifecycle: undefined, + }); expect(getAttentionLevel(session)).toBe("done"); }); it("returns done when agent has exited with cleanup status", () => { - const session = makeSession({ status: "cleanup", activity: "exited", pr: null }); + const session = makeSession({ + status: "cleanup", + activity: "exited", + pr: null, + lifecycle: undefined, + }); expect(getAttentionLevel(session)).toBe("done"); }); }); diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index 48bfec114..6c3acfd2f 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -12,6 +12,7 @@ import { isPRRateLimited, isPRMergeReady, isPRUnenriched, + isDashboardSessionRestorable, } from "@/lib/types"; import { AttentionZone } from "./AttentionZone"; import { DynamicFavicon, countNeedingAttention } from "./DynamicFavicon"; @@ -98,6 +99,7 @@ function DoneCard({ session.lifecycle?.sessionState === "terminated" || session.status === "killed" || session.status === "terminated"; + const isRestorable = isDashboardSessionRestorable(session); const badgeLabel = isMerged ? "merged" : isTerminated ? "terminated" : "done"; const badgeClass = `done-card__badge ${isTerminated ? "done-card__badge--terminated" : "done-card__badge--merged"}`; @@ -114,7 +116,14 @@ function DoneCard({ className="done-card__pr" onClick={(e) => e.stopPropagation()} > - + @@ -123,16 +132,18 @@ function DoneCard({ ) : null} {formatRelativeTimeCompact(session.lastActivityAt)} - + {isRestorable ? ( + + ) : null} ); @@ -149,7 +160,11 @@ function getCiPillClass(pr: DashboardPR): string { function getCiLabel(pr: DashboardPR): string { if (isPRRateLimited(pr) || isPRUnenriched(pr)) return ""; - return pr.ciStatus === "passing" ? "CI passing" : pr.ciStatus === "failing" ? "CI failed" : "CI pending"; + return pr.ciStatus === "passing" + ? "CI passing" + : pr.ciStatus === "failing" + ? "CI failed" + : "CI pending"; } function getReviewPillClass(pr: DashboardPR): string { @@ -187,11 +202,7 @@ function MobileFeedCard({ const pr = session.pr; return ( -