feat(resume): Resume in the kebab menu (A1) + harden the resume kickoff (A2)
A1 — SessionMenu gains a Resume item, gated to status!=="finalized" && !archived (matching the backend's 409 read-only guard), wired in AppShell to doResume -> POST /sessions/:id/resume. SessionMenu.test.tsx (3 tests); frontend 96/96, tsc clean. A2 — diagnosis-first clean-room repro driving `pi --mode rpc` with the backend's exact resume handshake shows the cold-start stall NO LONGER reproduces on pi 0.79.4 (8/8 chained into `tht session show` + `read SKILL.md` in-turn, fresh and partway sessions). The earlier narrate-and-stop predates the pi upgrade. Defense-in-depth anyway: RIPRENDI_KICKOFF hardened to force the in-turn tool call (gate_resume_kickoff.test.js + live regression 2/2). Gate JS 34/34. PROJECT_STATE open-item #1 (resume stall) flipped to RESOLVED; cross-model resume robustness folded into workstream G. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -118,6 +118,7 @@ export function AppShell() {
|
||||
<SessionMenu
|
||||
session={s}
|
||||
groups={groups}
|
||||
onResume={() => doResume(s.id)}
|
||||
onView={() => setPanelSession(s)}
|
||||
onRename={() => setRenameTarget(s)}
|
||||
onMove={(g) => move(s, g)}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { vi } from "vitest";
|
||||
import { SessionMenu } from "./SessionMenu";
|
||||
import type { SessionSummary } from "../api/types";
|
||||
|
||||
const base: SessionSummary = {
|
||||
id: "s1", status: "open", question: "q", summary: null,
|
||||
created_at: "2026-01-01T00:00:00Z", updated_at: null, author: null,
|
||||
name: null, group: null, archived: false,
|
||||
};
|
||||
|
||||
function renderMenu(over: Partial<SessionSummary>, onResume = vi.fn()) {
|
||||
render(
|
||||
<SessionMenu
|
||||
session={{ ...base, ...over }}
|
||||
groups={[]}
|
||||
onResume={onResume}
|
||||
onView={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
onMove={vi.fn()}
|
||||
onNewGroup={vi.fn()}
|
||||
onArchiveToggle={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
return onResume;
|
||||
}
|
||||
|
||||
test("Resume shows for an open, non-archived session and calls onResume", async () => {
|
||||
const onResume = renderMenu({ status: "open", archived: false });
|
||||
await userEvent.click(screen.getByRole("button", { name: /session actions/i }));
|
||||
await userEvent.click(await screen.findByText("Resume"));
|
||||
expect(onResume).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("Resume is hidden for a finalized session", async () => {
|
||||
renderMenu({ status: "finalized" });
|
||||
await userEvent.click(screen.getByRole("button", { name: /session actions/i }));
|
||||
await screen.findByText("Split view"); // menu is open
|
||||
expect(screen.queryByText("Resume")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("Resume is hidden for an archived session", async () => {
|
||||
renderMenu({ status: "open", archived: true });
|
||||
await userEvent.click(screen.getByRole("button", { name: /session actions/i }));
|
||||
await screen.findByText("Split view"); // menu is open
|
||||
expect(screen.queryByText("Resume")).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import type { SessionSummary } from "../api/types";
|
||||
interface Props {
|
||||
session: SessionSummary;
|
||||
groups: string[];
|
||||
onResume: () => void;
|
||||
onView: () => void;
|
||||
onRename: () => void;
|
||||
onMove: (group: string) => void;
|
||||
@@ -16,7 +17,10 @@ interface Props {
|
||||
const itemCls =
|
||||
"flex w-full cursor-default select-none items-center rounded-md px-2 py-1.5 text-sm outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground";
|
||||
|
||||
export function SessionMenu({ session, groups, onView, onRename, onMove, onNewGroup, onArchiveToggle, onDelete }: Props) {
|
||||
export function SessionMenu({ session, groups, onResume, onView, onRename, onMove, onNewGroup, onArchiveToggle, onDelete }: Props) {
|
||||
// Resume re-enters the workflow; only meaningful while the session is still live
|
||||
// (the backend returns 409 for finalized/archived, matching this guard).
|
||||
const canResume = session.status !== "finalized" && !session.archived;
|
||||
return (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
@@ -29,6 +33,9 @@ export function SessionMenu({ session, groups, onView, onRename, onMove, onNewGr
|
||||
<Menu.Portal>
|
||||
<Menu.Positioner side="bottom" align="end" sideOffset={4}>
|
||||
<Menu.Popup className="z-50 min-w-44 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
|
||||
{canResume && (
|
||||
<Menu.Item className={itemCls} onClick={onResume}>Resume</Menu.Item>
|
||||
)}
|
||||
<Menu.Item className={itemCls} onClick={onView}>Split view</Menu.Item>
|
||||
<Menu.Item className={itemCls} onClick={onRename}>Rename</Menu.Item>
|
||||
<Menu.SubmenuRoot>
|
||||
|
||||
Reference in New Issue
Block a user