diff --git a/.changeset/restore-button-pr-merged.md b/.changeset/restore-button-pr-merged.md new file mode 100644 index 000000000..125ad6b7a --- /dev/null +++ b/.changeset/restore-button-pr-merged.md @@ -0,0 +1,19 @@ +--- +"@aoagents/ao-web": patch +--- + +fix(web): show Restore button for every exited session, including pr_merged + +The Restore button was hidden for sessions exited with `pr_merged` reason (legacy +status `cleanup`) on the dashboard kanban and absent altogether from the +session-detail "Terminal ended" panel. The core `isRestorable()` helper already +allowed restoring these sessions; the dashboard helpers were out of sync. Fixes +#1907. + +- `isDashboardSessionRestorable` now gates on `NON_RESTORABLE_STATUSES` only, + matching core's `isRestorable`. Merged-but-running sessions remain + non-restorable (lifecycle isn't terminal). +- `DoneCard` no longer hides Restore for merged sessions. +- `SessionEndedSummary` exposes a prominent `Restore session` button next to + `Open PR` / `Back to dashboard`, so users don't have to find the small + header icon. diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index 14f6fcf83..e47aa6308 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -719,10 +719,12 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { justify-content: center; min-height: 36px; border-radius: 999px; + font-family: inherit; font-size: 12px; font-weight: 650; padding: 0 15px; text-decoration: none; + cursor: pointer; transition: transform 120ms ease, border-color 120ms ease, @@ -747,6 +749,12 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { text-decoration: none; } +.session-ended-summary__primary:focus-visible, +.session-ended-summary__secondary:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + .session-ended-summary__evidence { display: grid; gap: 6px; diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index c270831f1..ff646762a 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -125,7 +125,7 @@ function DoneCard({ ) : null} {formatRelativeTimeCompact(session.lastActivityAt)} - {canRestore && !isMerged ? ( + {canRestore ? ( + ) : null} {pr ? ( Open PR #{pr.number} diff --git a/packages/web/src/components/__tests__/Dashboard.doneBar.test.tsx b/packages/web/src/components/__tests__/Dashboard.doneBar.test.tsx index 24cfe6a05..6d838dd70 100644 --- a/packages/web/src/components/__tests__/Dashboard.doneBar.test.tsx +++ b/packages/web/src/components/__tests__/Dashboard.doneBar.test.tsx @@ -60,10 +60,10 @@ describe("Dashboard done bar", () => { expect(screen.queryByText(/No active sessions/i)).not.toBeInTheDocument(); }); - it("does not render a restore action for merged sessions", () => { + it("renders a restore action for merged sessions", () => { render(); const toggle = screen.getByText("Done / Terminated").closest("button")!; fireEvent.click(toggle); - expect(screen.queryByRole("button", { name: /restore/i })).toBeNull(); + expect(screen.getByRole("button", { name: /restore/i })).toBeInTheDocument(); }); }); diff --git a/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx b/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx index 07a77e48f..d92e80e6a 100644 --- a/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx +++ b/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx @@ -223,6 +223,32 @@ describe("SessionDetail desktop layout", () => { "/projects/my-app", ); expect(screen.queryByTestId("direct-terminal")).not.toBeInTheDocument(); + // The ended-session body also exposes a prominent "Restore session" button + // so users don't have to find the small icon in the header. + expect( + within(screen.getByRole("region", { name: "Session ended summary" })).getByRole("button", { + name: "Restore session", + }), + ).toBeInTheDocument(); + }); + + it("shows the Restore button in the ended-summary for pr_merged sessions (status=cleanup)", () => { + render( + , + ); + expect( + within(screen.getByRole("region", { name: "Session ended summary" })).getByRole("button", { + name: "Restore session", + }), + ).toBeInTheDocument(); }); it("keeps restored working sessions live when terminatedAt is stale", () => { diff --git a/packages/web/src/lib/__tests__/types.test.ts b/packages/web/src/lib/__tests__/types.test.ts index ceee22169..04b6036a1 100644 --- a/packages/web/src/lib/__tests__/types.test.ts +++ b/packages/web/src/lib/__tests__/types.test.ts @@ -264,7 +264,7 @@ describe("getAttentionLevel", () => { }); describe("restore affordances", () => { - it("should not mark merged sessions as restorable", () => { + it("should not mark a running merged session as restorable (runtime still alive)", () => { const session = createSession({ status: "merged", lifecycle: { @@ -298,6 +298,41 @@ describe("getAttentionLevel", () => { expect(isDashboardSessionRestorable(session)).toBe(false); }); + + it("should mark a pr_merged-cleanup session as restorable", () => { + const session = createSession({ + status: "cleanup", + lifecycle: { + sessionState: "terminated", + sessionReason: "pr_merged", + prState: "merged", + prReason: "merged", + runtimeState: "missing", + runtimeReason: "pr_merged_cleanup", + session: { + state: "terminated", + reason: "pr_merged", + label: "terminated", + reasonLabel: "pr merged", + }, + pr: { state: "merged", reason: "merged", label: "merged", reasonLabel: "merged" }, + runtime: { + state: "missing", + reason: "pr_merged_cleanup", + label: "missing", + reasonLabel: "pr merged cleanup", + }, + legacyStatus: "cleanup", + evidence: null, + detectingAttempts: 0, + detectingEscalatedAt: null, + summary: "Session cleaned up after PR merge", + guidance: null, + }, + }); + + expect(isDashboardSessionRestorable(session)).toBe(true); + }); }); describe("respond state", () => { diff --git a/packages/web/src/lib/types.ts b/packages/web/src/lib/types.ts index 5174e0782..85f93d225 100644 --- a/packages/web/src/lib/types.ts +++ b/packages/web/src/lib/types.ts @@ -436,12 +436,9 @@ export function isDashboardSessionRestorable(session: DashboardSession): boolean isDashboardSessionTerminated(session) || session.lifecycle.runtimeState === "missing" || session.lifecycle.runtimeState === "exited"; - return ( - terminalByCoreTruth && session.lifecycle.prState !== "merged" && session.status !== "merged" - ); + return terminalByCoreTruth && !NON_RESTORABLE_STATUSES.has(session.status); } - if (!isDashboardSessionTerminal(session)) return false; - return session.pr?.state !== "merged" && session.status !== "merged"; + return isDashboardSessionTerminal(session) && !NON_RESTORABLE_STATUSES.has(session.status); } /**