fix(web): show Restore button for every exited session, including pr_merged (#1909)

* 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, compensating for a non-existent core constraint.

- `isDashboardSessionRestorable` now gates on `NON_RESTORABLE_STATUSES`
  only, matching core's `isRestorable`. Merged-but-running sessions
  (runtime still alive) remain non-restorable.
- `DoneCard` no longer hides Restore for merged sessions.
- `SessionEndedSummary` now exposes a prominent `Restore session` button
  alongside `Open PR` / `Back to dashboard`, so users don't have to find
  the small icon button in the header.

Closes #1907

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(web): add :focus-visible styles to ended-summary action buttons

Address PR #1909 review feedback. The new Restore session button (and
existing Open PR / Back to dashboard pills) lacked a visible focus ring,
making keyboard navigation invisible. Apply the same accent outline used
elsewhere in the dashboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Harshit Singh Bhandari 2026-05-19 02:50:34 +05:30 committed by GitHub
parent 6d48022c87
commit 07c90996d2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 114 additions and 10 deletions

View File

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

View File

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

View File

@ -125,7 +125,7 @@ function DoneCard({
</a>
) : null}
<span className="done-card__age">{formatRelativeTimeCompact(session.lastActivityAt)}</span>
{canRestore && !isMerged ? (
{canRestore ? (
<button
type="button"
className="done-card__restore"

View File

@ -216,6 +216,8 @@ export function SessionDetail({
headline={headline}
pr={pr}
dashboardHref={dashboardHref}
isRestorable={isRestorable}
onRestore={handleRestore}
/>
) : (
<DirectTerminal

View File

@ -8,6 +8,8 @@ interface SessionEndedSummaryProps {
headline: string;
pr: DashboardPR | null;
dashboardHref: string;
isRestorable: boolean;
onRestore: () => void;
}
function formatEndedTime(isoDate: string | null | undefined): string {
@ -42,6 +44,8 @@ export function SessionEndedSummary({
headline,
pr,
dashboardHref,
isRestorable,
onRestore,
}: SessionEndedSummaryProps) {
const reason = getEndedSessionReason(session);
const summary = getEndedSessionSummary(session, headline);
@ -107,12 +111,25 @@ export function SessionEndedSummary({
</div>
<div className="session-ended-summary__links">
{isRestorable ? (
<button
type="button"
onClick={onRestore}
className="session-ended-summary__primary"
>
Restore session
</button>
) : null}
{pr ? (
<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
className="session-ended-summary__primary"
className={
isRestorable
? "session-ended-summary__secondary"
: "session-ended-summary__primary"
}
>
Open PR #{pr.number}
</a>

View File

@ -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(<Dashboard initialSessions={[DONE_SESSION]} />);
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();
});
});

View File

@ -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(
<SessionDetail
session={makeSession({
id: "worker-pr-merged",
projectId: "my-app",
status: "cleanup",
activity: "exited",
pr: makePR({ number: 1904, state: "merged" }),
})}
/>,
);
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", () => {

View File

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

View File

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