fix(web): keep closed-unmerged sessions actionable
This commit is contained in:
parent
cc587a56da
commit
08667c8cb5
|
|
@ -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.
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Reference in New Issue