fix: render raw activity state in inspector (#2278)

* fix: render raw session activity in inspector

* fix: hide unknown activity state in inspector
This commit is contained in:
Adil Shaikh 2026-07-04 00:29:32 +05:30 committed by GitHub
parent 8cadea5b16
commit 4557146681
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 311 additions and 52 deletions

View File

@ -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,
});
const sessionWithProvider = (prs: PullRequestFacts[], provider: WorkspaceSession["provider"]): WorkspaceSession => ({
@ -124,9 +126,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);
@ -166,6 +171,174 @@ 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("renders unknown activity as unavailable instead of leaking the internal enum", () => {
renderWithQuery(
<SessionInspector
session={session([], {
status: "working",
activity: { state: "unknown", lastActivityAt: "2026-06-15T10:00:00Z" },
})}
/>,
);
expect(activitySection().getByText("Activity Unavailable")).toBeInTheDocument();
expect(activitySection().queryByText("Unknown")).not.toBeInTheDocument();
});
it("falls back to unavailable when no activity has been reported", () => {
renderWithQuery(<SessionInspector session={session([], { status: "working" })} />);
expect(activitySection().getByText("Activity Unavailable")).toBeInTheDocument();
});
it("keeps the last known activity visible when the daemon reports no signal", () => {
renderWithQuery(
<SessionInspector
session={session([], {
status: "no_signal",
activity: { state: "idle", lastActivityAt: "2026-06-15T10:00:00Z" },
})}
/>,
);
const activityRow = activitySection().getByText("Idle").closest(".inspector-timeline__ev") as HTMLElement;
expect(within(activityRow).getByText("No Signal")).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")])} />);

View File

@ -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 &amp; 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,47 @@ function ActivityTimeline({ session }: { session: WorkspaceSession }) {
}
events.push({
tone: "neutral",
node: <>Created worktree &amp; 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>
{session.status === "no_signal" ? (
<span className="inspector-timeline__badge">
<TimelinePill {...ACTIVITY_WARNING_PILL.no_signal} />
</span>
) : null}
{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 +354,35 @@ 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: "Activity Unavailable", 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];
const ACTIVITY_WARNING_PILL: Record<"no_signal", { label: string; tone: string; breathe: boolean }> = {
no_signal: { label: "No Signal", tone: "var(--fg-muted)", breathe: false },
};
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 +401,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,

View File

@ -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",

View File

@ -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),

View File

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