fix: render raw session activity in inspector
This commit is contained in:
parent
a31cf1b582
commit
8d8fb42a94
|
|
@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SessionInspector } from "./SessionInspector";
|
||||
import type { PRState, PullRequestFacts, WorkspaceSession } from "../types/workspace";
|
||||
|
||||
|
|
@ -25,7 +25,7 @@ vi.mock("../lib/api-client", () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
const pr = (n: number, state: PRState): PullRequestFacts => ({
|
||||
const pr = (n: number, state: PRState, overrides: Partial<PullRequestFacts> = {}): PullRequestFacts => ({
|
||||
url: `https://example.com/pr/${n}`,
|
||||
number: n,
|
||||
state,
|
||||
|
|
@ -34,9 +34,10 @@ const pr = (n: number, state: PRState): PullRequestFacts => ({
|
|||
mergeability: "mergeable",
|
||||
reviewComments: false,
|
||||
updatedAt: "2026-06-15T00:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const session = (prs: PullRequestFacts[]): WorkspaceSession => ({
|
||||
const session = (prs: PullRequestFacts[], overrides: Partial<WorkspaceSession> = {}): WorkspaceSession => ({
|
||||
id: "sess-1",
|
||||
workspaceId: "ws-1",
|
||||
workspaceName: "my-app",
|
||||
|
|
@ -47,6 +48,7 @@ const session = (prs: PullRequestFacts[]): WorkspaceSession => ({
|
|||
status: "review_pending",
|
||||
updatedAt: "2026-06-15T00:00:00Z",
|
||||
prs,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function renderWithQuery(children: ReactNode) {
|
||||
|
|
@ -119,9 +121,12 @@ beforeEach(() => {
|
|||
postMock.mockResolvedValue({ data: { ok: true, sessionId: "sess-1" }, error: undefined });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("SessionInspector PR section", () => {
|
||||
// Scope assertions to the PR section: the activity timeline also renders
|
||||
// "Opened PR #n", so an unscoped query matches both the card and the event.
|
||||
// Scope assertions to the PR section so the card order is explicit.
|
||||
const prSection = (title: string) =>
|
||||
within(screen.getByText(title).closest("section.inspector-section") as HTMLElement);
|
||||
|
||||
|
|
@ -161,6 +166,140 @@ describe("SessionInspector PR section", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("SessionInspector Activity section", () => {
|
||||
const activitySection = () =>
|
||||
within(screen.getByText("Activity").closest("section.inspector-section") as HTMLElement);
|
||||
|
||||
it.each([
|
||||
["idle", "Idle"],
|
||||
["active", "Working"],
|
||||
["waiting_input", "Input needed"],
|
||||
["exited", "Exited"],
|
||||
] as const)("renders %s from raw session activity", (state, label) => {
|
||||
renderWithQuery(
|
||||
<SessionInspector
|
||||
session={session([pr(7, "open")], {
|
||||
status: "review_pending",
|
||||
activity: { state, lastActivityAt: "2026-06-15T10:00:00Z" },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(activitySection().getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not derive the Activity label from PR-oriented session status", () => {
|
||||
renderWithQuery(
|
||||
<SessionInspector
|
||||
session={session([], {
|
||||
status: "review_pending",
|
||||
activity: { state: "idle", lastActivityAt: "2026-06-15T10:00:00Z" },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(activitySection().getByText("Idle")).toBeInTheDocument();
|
||||
expect(activitySection().queryByText("Input needed")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["ci_failed", "CI failed"],
|
||||
["changes_requested", "Changes requested"],
|
||||
] as const)("renders %s as an SCM state in the current Activity row", (status, label) => {
|
||||
renderWithQuery(
|
||||
<SessionInspector
|
||||
session={session([], {
|
||||
status,
|
||||
activity: { state: "idle", lastActivityAt: "2026-06-15T10:00:00Z" },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const activityRow = activitySection().getByText("Idle").closest(".inspector-timeline__ev") as HTMLElement;
|
||||
expect(within(activityRow).getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders PR conflicts as an SCM state in the current Activity row", () => {
|
||||
renderWithQuery(
|
||||
<SessionInspector
|
||||
session={session([pr(7, "open", { mergeability: "conflicting" })], {
|
||||
status: "working",
|
||||
activity: { state: "idle", lastActivityAt: "2026-06-15T10:00:00Z" },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const activityRow = activitySection().getByText("Idle").closest(".inspector-timeline__ev") as HTMLElement;
|
||||
expect(within(activityRow).getByText("Conflict")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses activity.lastActivityAt for the Activity timestamp", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-15T12:00:00Z"));
|
||||
|
||||
renderWithQuery(
|
||||
<SessionInspector
|
||||
session={session([], {
|
||||
status: "working",
|
||||
updatedAt: "2026-06-15T11:55:00Z",
|
||||
activity: { state: "active", lastActivityAt: "2026-06-15T10:00:00Z" },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const activityRow = activitySection().getByText("Working").closest(".inspector-timeline__ev") as HTMLElement;
|
||||
expect(within(activityRow).getByText("2h ago")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps worktree, PR, and SCM context rows in the Activity timeline", () => {
|
||||
renderWithQuery(
|
||||
<SessionInspector
|
||||
session={session([pr(7, "open", { ci: "failing", review: "changes_requested" })], {
|
||||
status: "ci_failed",
|
||||
activity: { state: "idle", lastActivityAt: "2026-06-15T10:00:00Z" },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(activitySection().getByText(/Created worktree/)).toBeInTheDocument();
|
||||
expect(activitySection().getByText("Opened")).toBeInTheDocument();
|
||||
expect(activitySection().getByText("PR #7")).toBeInTheDocument();
|
||||
const activityRow = activitySection().getByText("Idle").closest(".inspector-timeline__ev") as HTMLElement;
|
||||
expect(within(activityRow).getByText("CI failed")).toBeInTheDocument();
|
||||
expect(within(activityRow).getByText("Changes requested")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("orders timeline milestones around the combined current state row", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-15T12:00:00Z"));
|
||||
|
||||
renderWithQuery(
|
||||
<SessionInspector
|
||||
session={session([pr(42, "draft"), pr(41, "open"), pr(40, "merged")], {
|
||||
status: "merged",
|
||||
createdAt: "2026-06-15T09:00:00Z",
|
||||
updatedAt: "2026-06-15T11:55:00Z",
|
||||
activity: { state: "idle", lastActivityAt: "2026-06-15T10:00:00Z" },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const section = screen.getByText("Activity").closest("section.inspector-section") as HTMLElement;
|
||||
const rows = Array.from(section.querySelectorAll(".inspector-timeline__ev"), (row) =>
|
||||
row.textContent?.replace(/\s+/g, " ").trim(),
|
||||
);
|
||||
expect(rows).toEqual([
|
||||
"Created worktree & branch3h ago",
|
||||
"Draft PR #42",
|
||||
"Opened PR #41",
|
||||
"Opened PR #40",
|
||||
"Idle2h ago",
|
||||
"Merged PR #40",
|
||||
"Done5m ago",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionInspector tabs", () => {
|
||||
it("exposes Summary, Reviews, and Browser as the three inspector tabs", () => {
|
||||
renderWithQuery(<SessionInspector session={session([pr(1, "open")])} />);
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
|
|||
import { formatTimeCompact } from "../lib/format-time";
|
||||
import { useSessionScmSummary, type SessionPRSummary } from "../hooks/useSessionScmSummary";
|
||||
import { prBrowserUrl, prStatusRows, sessionPRDisplaySummaries, type PRDisplayTone } from "../lib/pr-display";
|
||||
import type { SessionStatus, WorkspaceSession } from "../types/workspace";
|
||||
import { sortedPRs, workerDisplayStatus } from "../types/workspace";
|
||||
import type { SessionActivityState, WorkspaceSession } from "../types/workspace";
|
||||
import { sortedPRs } from "../types/workspace";
|
||||
import { BrowserPanelView } from "./BrowserPanel";
|
||||
import type { BrowserViewModel } from "../hooks/useBrowserView";
|
||||
import { Badge } from "./ui/badge";
|
||||
|
|
@ -259,24 +259,29 @@ type TimelineTone = "now" | "good" | "warn" | "neutral";
|
|||
|
||||
function ActivityTimeline({ session }: { session: WorkspaceSession }) {
|
||||
const events: { tone: TimelineTone; node: ReactNode; ts: string | null }[] = [];
|
||||
const detail = activityDetail(session.status);
|
||||
|
||||
events.push({
|
||||
tone: "now",
|
||||
node: (
|
||||
<>
|
||||
<span className="inspector-timeline__badge">
|
||||
<InspectorStatusPill session={session} />
|
||||
</span>
|
||||
{detail ? <span className="inspector-timeline__detail"> — {detail}</span> : null}
|
||||
</>
|
||||
),
|
||||
ts: formatTimeCompact(session.updatedAt),
|
||||
tone: "neutral",
|
||||
node: <>Created worktree & branch</>,
|
||||
ts: formatTimeCompact(session.createdAt ?? session.updatedAt),
|
||||
});
|
||||
|
||||
for (const pr of sortedPRs(session)) {
|
||||
const prs = sortedPRs(session);
|
||||
for (const pr of prs.filter((pr) => pr.state === "draft")) {
|
||||
events.push({
|
||||
tone: "good",
|
||||
tone: "neutral",
|
||||
node: (
|
||||
<>
|
||||
Draft <b>PR #{pr.number}</b>
|
||||
</>
|
||||
),
|
||||
ts: null,
|
||||
});
|
||||
}
|
||||
|
||||
for (const pr of prs.filter((pr) => pr.state !== "draft")) {
|
||||
events.push({
|
||||
tone: "neutral",
|
||||
node: (
|
||||
<>
|
||||
Opened <b>PR #{pr.number}</b>
|
||||
|
|
@ -287,11 +292,42 @@ function ActivityTimeline({ session }: { session: WorkspaceSession }) {
|
|||
}
|
||||
|
||||
events.push({
|
||||
tone: "neutral",
|
||||
node: <>Created worktree & branch</>,
|
||||
ts: formatTimeCompact(session.createdAt ?? session.updatedAt),
|
||||
tone: "now",
|
||||
node: (
|
||||
<span className="inline-flex flex-wrap items-center gap-1.5">
|
||||
<span className="inspector-timeline__badge">
|
||||
<InspectorActivityPill state={session.activity?.state ?? "unknown"} />
|
||||
</span>
|
||||
{scmTimelineStates(session).map((state) => (
|
||||
<span key={state} className="inspector-timeline__badge">
|
||||
<InspectorScmPill state={state} />
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
),
|
||||
ts: session.activity?.lastActivityAt ? formatTimeCompact(session.activity.lastActivityAt) : null,
|
||||
});
|
||||
|
||||
for (const pr of prs.filter((pr) => pr.state === "merged")) {
|
||||
events.push({
|
||||
tone: "good",
|
||||
node: (
|
||||
<>
|
||||
Merged <b>PR #{pr.number}</b>
|
||||
</>
|
||||
),
|
||||
ts: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (session.status === "merged") {
|
||||
events.push({
|
||||
tone: "good",
|
||||
node: <>Done</>,
|
||||
ts: formatTimeCompact(session.updatedAt),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="inspector-timeline">
|
||||
{events.map((event, index) => (
|
||||
|
|
@ -313,38 +349,31 @@ function ActivityTimeline({ session }: { session: WorkspaceSession }) {
|
|||
);
|
||||
}
|
||||
|
||||
function activityDetail(status: SessionStatus): string | null {
|
||||
switch (status) {
|
||||
case "idle":
|
||||
return "Session idle";
|
||||
case "needs_input":
|
||||
return "Waiting for your input";
|
||||
case "no_signal":
|
||||
return "No recent agent signal";
|
||||
case "working":
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_PILL: Record<
|
||||
ReturnType<typeof workerDisplayStatus> | "idle",
|
||||
{ label: string; tone: string; breathe: boolean }
|
||||
> = {
|
||||
working: { label: "Working", tone: "var(--orange)", breathe: true },
|
||||
needs_you: { label: "Input needed", tone: "var(--amber)", breathe: false },
|
||||
ci_failed: { label: "CI failed", tone: "var(--red)", breathe: false },
|
||||
no_signal: { label: "No signal", tone: "var(--fg-muted)", breathe: false },
|
||||
mergeable: { label: "Ready", tone: "var(--green)", breathe: false },
|
||||
done: { label: "Done", tone: "var(--fg-muted)", breathe: false },
|
||||
unknown: { label: "Unknown", tone: "var(--fg-muted)", breathe: false },
|
||||
const ACTIVITY_PILL: Record<SessionActivityState, { label: string; tone: string; breathe: boolean }> = {
|
||||
active: { label: "Working", tone: "var(--orange)", breathe: true },
|
||||
idle: { label: "Idle", tone: "var(--fg-muted)", breathe: false },
|
||||
waiting_input: { label: "Input needed", tone: "var(--amber)", breathe: false },
|
||||
exited: { label: "Exited", tone: "var(--fg-muted)", breathe: false },
|
||||
unknown: { label: "Unknown", tone: "var(--fg-muted)", breathe: false },
|
||||
};
|
||||
|
||||
function InspectorStatusPill({ session }: { session: WorkspaceSession }) {
|
||||
const key = session.status === "idle" ? "idle" : workerDisplayStatus(session);
|
||||
const { label, tone, breathe } = STATUS_PILL[key];
|
||||
type ScmTimelineState = "ci_failed" | "changes_requested" | "conflict";
|
||||
|
||||
const SCM_PILL: Record<ScmTimelineState, { label: string; tone: string; breathe: boolean }> = {
|
||||
ci_failed: { label: "CI failed", tone: "var(--red)", breathe: false },
|
||||
changes_requested: { label: "Changes requested", tone: "var(--amber)", breathe: false },
|
||||
conflict: { label: "Conflict", tone: "var(--red)", breathe: false },
|
||||
};
|
||||
|
||||
function InspectorActivityPill({ state }: { state: SessionActivityState }) {
|
||||
return <TimelinePill {...ACTIVITY_PILL[state]} />;
|
||||
}
|
||||
|
||||
function InspectorScmPill({ state }: { state: ScmTimelineState }) {
|
||||
return <TimelinePill {...SCM_PILL[state]} />;
|
||||
}
|
||||
|
||||
function TimelinePill({ label, tone, breathe }: { label: string; tone: string; breathe: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-[7px] whitespace-nowrap rounded-[7px] px-[11px] py-[5px] text-[11.5px] font-semibold"
|
||||
|
|
@ -363,6 +392,26 @@ function InspectorStatusPill({ session }: { session: WorkspaceSession }) {
|
|||
);
|
||||
}
|
||||
|
||||
function scmTimelineStates(session: WorkspaceSession): ScmTimelineState[] {
|
||||
const states: ScmTimelineState[] = [];
|
||||
const seen = new Set<ScmTimelineState>();
|
||||
const add = (state: ScmTimelineState) => {
|
||||
if (seen.has(state)) return;
|
||||
seen.add(state);
|
||||
states.push(state);
|
||||
};
|
||||
|
||||
if (session.status === "ci_failed") add("ci_failed");
|
||||
if (session.status === "changes_requested") add("changes_requested");
|
||||
for (const pr of session.prs) {
|
||||
if (pr.ci === "failing") add("ci_failed");
|
||||
if (pr.review === "changes_requested") add("changes_requested");
|
||||
if (pr.mergeability === "conflicting") add("conflict");
|
||||
}
|
||||
|
||||
return states;
|
||||
}
|
||||
|
||||
function ReviewsView({
|
||||
session,
|
||||
onOpenReviewerTerminal,
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ describe("useWorkspaceQuery", () => {
|
|||
branch: "qa/modal-worker",
|
||||
status: "mergeable",
|
||||
isTerminated: false,
|
||||
activity: { state: "idle", lastActivityAt: "2026-06-10T15:30:00Z" },
|
||||
updatedAt: "2026-06-10T16:15:04Z",
|
||||
},
|
||||
{
|
||||
|
|
@ -99,6 +100,7 @@ describe("useWorkspaceQuery", () => {
|
|||
provider: "claude-code",
|
||||
branch: "qa/modal-worker",
|
||||
status: "mergeable",
|
||||
activity: { state: "idle", lastActivityAt: "2026-06-10T15:30:00Z" },
|
||||
});
|
||||
expect(workspace.sessions[1]).toMatchObject({
|
||||
id: "sess-2",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
type PRState,
|
||||
type PullRequestFacts,
|
||||
toAgentProvider,
|
||||
toSessionActivity,
|
||||
toSessionStatus,
|
||||
type WorkspaceSummary,
|
||||
} from "../types/workspace";
|
||||
|
|
@ -57,6 +58,7 @@ async function fetchWorkspaces(): Promise<WorkspaceSummary[]> {
|
|||
status: toSessionStatus(session.status, session.isTerminated),
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt,
|
||||
activity: toSessionActivity(session.activity),
|
||||
previewUrl: session.previewUrl,
|
||||
previewRevision: session.previewRevision,
|
||||
prs: (session.prs ?? []).map(toPullRequestFacts),
|
||||
|
|
|
|||
|
|
@ -35,6 +35,28 @@ export function toSessionStatus(status?: string, isTerminated = false): SessionS
|
|||
return isTerminated ? "terminated" : "unknown";
|
||||
}
|
||||
|
||||
export type SessionActivityState = "active" | "idle" | "waiting_input" | "exited" | "unknown";
|
||||
|
||||
const sessionActivityStates = new Set<SessionActivityState>(["active", "idle", "waiting_input", "exited"]);
|
||||
|
||||
export type SessionActivity = {
|
||||
state: SessionActivityState;
|
||||
lastActivityAt: string;
|
||||
};
|
||||
|
||||
export function toSessionActivity(
|
||||
activity?: { state?: string; lastActivityAt?: string } | null,
|
||||
): SessionActivity | undefined {
|
||||
if (!activity) return undefined;
|
||||
const state = sessionActivityStates.has(activity.state as SessionActivityState)
|
||||
? (activity.state as SessionActivityState)
|
||||
: "unknown";
|
||||
return {
|
||||
state,
|
||||
lastActivityAt: activity.lastActivityAt ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export type AgentProvider =
|
||||
| "codex"
|
||||
| "claude-code"
|
||||
|
|
@ -104,6 +126,8 @@ export type WorkspaceSession = {
|
|||
createdAt?: string;
|
||||
/** ISO timestamp from the daemon. */
|
||||
updatedAt: string;
|
||||
/** Raw agent lifecycle activity from the daemon. */
|
||||
activity?: SessionActivity;
|
||||
/**
|
||||
* Live preview target set by the daemon (via `ao preview`) and streamed over
|
||||
* CDC. When non-empty, the browser panel opens and navigates here.
|
||||
|
|
|
|||
Loading…
Reference in New Issue