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:
parent
6d48022c87
commit
07c90996d2
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -216,6 +216,8 @@ export function SessionDetail({
|
|||
headline={headline}
|
||||
pr={pr}
|
||||
dashboardHref={dashboardHref}
|
||||
isRestorable={isRestorable}
|
||||
onRestore={handleRestore}
|
||||
/>
|
||||
) : (
|
||||
<DirectTerminal
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue