fix(web): keep closed-unmerged sessions actionable

This commit is contained in:
harshitsinghbhandari 2026-04-17 14:42:25 +05:30
parent cc587a56da
commit 08667c8cb5
6 changed files with 221 additions and 45 deletions

View File

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

View File

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

View File

@ -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()}
>
<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" />
@ -123,16 +132,18 @@ function DoneCard({
</a>
) : null}
<span className="done-card__age">{formatRelativeTimeCompact(session.lastActivityAt)}</span>
<button
type="button"
className="done-card__restore"
onClick={(e) => {
e.stopPropagation();
onRestore(session.id);
}}
>
Restore
</button>
{isRestorable ? (
<button
type="button"
className="done-card__restore"
onClick={(e) => {
e.stopPropagation();
onRestore(session.id);
}}
>
Restore
</button>
) : null}
</div>
</div>
);
@ -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 (
<button
type="button"
className="mobile-feed-card"
onClick={() => onTap(session)}
>
<button type="button" className="mobile-feed-card" onClick={() => onTap(session)}>
<div className="mobile-feed-card__strip" data-level={level} />
<div className="mobile-feed-card__content">
<div className="mobile-feed-card__header">
@ -205,13 +216,10 @@ function MobileFeedCard({
{session.branch ? (
<span className="mobile-feed-card__branch">{session.branch}</span>
) : null}
{pr ? (
<span className="mobile-feed-card__pr">#{pr.number}</span>
) : null}
{pr ? <span className="mobile-feed-card__pr">#{pr.number}</span> : null}
{pr && !isPRRateLimited(pr) && !isPRUnenriched(pr) ? (
<span className="mobile-feed-card__diff">
<span style={{ color: "var(--color-accent-green)" }}>+{pr.additions}</span>
{" "}
<span style={{ color: "var(--color-accent-green)" }}>+{pr.additions}</span>{" "}
<span style={{ color: "var(--color-accent-red)" }}>-{pr.deletions}</span>
</span>
) : null}
@ -468,9 +476,10 @@ function DashboardInner({
}, []);
const mobileFeedSessions = useMemo(() => {
const levels = mobileFilter === "all"
? MOBILE_KANBAN_ORDER
: MOBILE_KANBAN_ORDER.filter((level) => level === mobileFilter);
const levels =
mobileFilter === "all"
? MOBILE_KANBAN_ORDER
: MOBILE_KANBAN_ORDER.filter((level) => level === mobileFilter);
const feed: Array<{ session: DashboardSession; level: AttentionLevel }> = [];
for (const level of levels) {
for (const session of grouped[level]) {

View File

@ -59,4 +59,11 @@ describe("Dashboard done bar", () => {
render(<Dashboard initialSessions={[DONE_SESSION]} />);
expect(screen.queryByText(/No active sessions/i)).not.toBeInTheDocument();
});
it("does not render a restore action for merged sessions", () => {
render(<Dashboard initialSessions={[DONE_SESSION]} />);
const toggle = screen.getByText("Done / Terminated").closest("button")!;
fireEvent.click(toggle);
expect(screen.queryByRole("button", { name: /restore/i })).toBeNull();
});
});

View File

@ -9,6 +9,8 @@ import {
TERMINAL_STATUSES,
TERMINAL_ACTIVITIES,
NON_RESTORABLE_STATUSES,
isDashboardSessionDone,
isDashboardSessionRestorable,
type DashboardSession,
type DashboardPR,
} from "../types";
@ -77,9 +79,19 @@ describe("getAttentionLevel", () => {
prReason: "merged",
runtimeState: "alive",
runtimeReason: "process_running",
session: { state: "idle", reason: "merged_waiting_decision", label: "merged, waiting decision", reasonLabel: "merged waiting decision" },
session: {
state: "idle",
reason: "merged_waiting_decision",
label: "merged, waiting decision",
reasonLabel: "merged waiting decision",
},
pr: { state: "merged", reason: "merged", label: "merged", reasonLabel: "merged" },
runtime: { state: "alive", reason: "process_running", label: "alive", reasonLabel: "process running" },
runtime: {
state: "alive",
reason: "process_running",
label: "alive",
reasonLabel: "process running",
},
legacyStatus: "merged",
evidence: null,
detectingAttempts: 0,
@ -116,9 +128,41 @@ describe("getAttentionLevel", () => {
expect(getAttentionLevel(session)).toBe("done");
});
it("should return 'done' for closed PR regardless of session status", () => {
it("should keep closed-unmerged PR sessions actionable", () => {
const session = createSession({
status: "working",
lifecycle: {
sessionState: "idle",
sessionReason: "pr_closed_waiting_decision",
prState: "closed",
prReason: "closed_unmerged",
runtimeState: "alive",
runtimeReason: "process_running",
session: {
state: "idle",
reason: "pr_closed_waiting_decision",
label: "idle",
reasonLabel: "pr closed waiting decision",
},
pr: {
state: "closed",
reason: "closed_unmerged",
label: "closed",
reasonLabel: "closed unmerged",
},
runtime: {
state: "alive",
reason: "process_running",
label: "alive",
reasonLabel: "process running",
},
legacyStatus: "idle",
evidence: null,
detectingAttempts: 0,
detectingEscalatedAt: null,
summary: "PR closed without merge",
guidance: null,
},
pr: {
number: 1,
url: "https://github.com/test/repo/pull/1",
@ -145,7 +189,9 @@ describe("getAttentionLevel", () => {
unresolvedComments: [],
},
});
expect(getAttentionLevel(session)).toBe("done");
expect(isDashboardSessionDone(session)).toBe(false);
expect(isDashboardSessionRestorable(session)).toBe(false);
expect(getAttentionLevel(session)).toBe("pending");
});
it("should ignore metadata attention overrides for terminal sessions", () => {
@ -209,6 +255,43 @@ describe("getAttentionLevel", () => {
});
});
describe("restore affordances", () => {
it("should not mark merged sessions as restorable", () => {
const session = createSession({
status: "merged",
lifecycle: {
sessionState: "idle",
sessionReason: "merged_waiting_decision",
prState: "merged",
prReason: "merged",
runtimeState: "alive",
runtimeReason: "process_running",
session: {
state: "idle",
reason: "merged_waiting_decision",
label: "merged, waiting decision",
reasonLabel: "merged waiting decision",
},
pr: { state: "merged", reason: "merged", label: "merged", reasonLabel: "merged" },
runtime: {
state: "alive",
reason: "process_running",
label: "alive",
reasonLabel: "process running",
},
legacyStatus: "merged",
evidence: null,
detectingAttempts: 0,
detectingEscalatedAt: null,
summary: "PR merged; worker is still available for a keep-or-kill decision",
guidance: null,
},
});
expect(isDashboardSessionRestorable(session)).toBe(false);
});
});
describe("respond state", () => {
it("should return 'respond' for waiting_input activity", () => {
const session = createSession({ activity: "waiting_input" });
@ -225,9 +308,19 @@ describe("getAttentionLevel", () => {
prReason: "in_progress",
runtimeState: "probe_failed",
runtimeReason: "probe_error",
session: { state: "detecting", reason: "probe_failure", label: "detecting", reasonLabel: "probe failure" },
session: {
state: "detecting",
reason: "probe_failure",
label: "detecting",
reasonLabel: "probe failure",
},
pr: { state: "open", reason: "in_progress", label: "open", reasonLabel: "in progress" },
runtime: { state: "probe_failed", reason: "probe_error", label: "probe failed", reasonLabel: "probe error" },
runtime: {
state: "probe_failed",
reason: "probe_error",
label: "probe failed",
reasonLabel: "probe error",
},
legacyStatus: "detecting",
evidence: "signal_disagreement",
detectingAttempts: 1,
@ -454,9 +547,19 @@ describe("getAttentionLevel", () => {
prReason: "in_progress",
runtimeState: "alive",
runtimeReason: "process_running",
session: { state: "working", reason: "task_in_progress", label: "working", reasonLabel: "task in progress" },
session: {
state: "working",
reason: "task_in_progress",
label: "working",
reasonLabel: "task in progress",
},
pr: { state: "open", reason: "in_progress", label: "open", reasonLabel: "in progress" },
runtime: { state: "alive", reason: "process_running", label: "alive", reasonLabel: "process running" },
runtime: {
state: "alive",
reason: "process_running",
label: "alive",
reasonLabel: "process running",
},
legacyStatus: "pr_open",
evidence: null,
detectingAttempts: 0,

View File

@ -286,8 +286,7 @@ export function isDashboardSessionDone(session: DashboardSession): boolean {
return (
session.lifecycle.sessionState === "done" ||
session.lifecycle.sessionState === "terminated" ||
session.lifecycle.prState === "merged" ||
session.lifecycle.prState === "closed"
session.lifecycle.prState === "merged"
);
}
if (
@ -299,7 +298,7 @@ export function isDashboardSessionDone(session: DashboardSession): boolean {
) {
return true;
}
return session.pr?.state === "merged" || session.pr?.state === "closed";
return session.pr?.state === "merged";
}
export function isDashboardSessionTerminal(session: DashboardSession): boolean {
@ -329,8 +328,18 @@ export function isDashboardRuntimeEnded(session: DashboardSession): boolean {
}
export function isDashboardSessionRestorable(session: DashboardSession): boolean {
if (session.lifecycle) {
const terminalByCoreTruth =
session.lifecycle.sessionState === "done" ||
session.lifecycle.sessionState === "terminated" ||
session.lifecycle.runtimeState === "missing" ||
session.lifecycle.runtimeState === "exited";
return (
terminalByCoreTruth && session.lifecycle.prState !== "merged" && session.status !== "merged"
);
}
if (!isDashboardSessionTerminal(session)) return false;
return session.lifecycle?.prState !== "merged" && session.status !== "merged";
return session.pr?.state !== "merged" && session.status !== "merged";
}
/** Determines which attention zone a session belongs to */