Skip to content

Commit 66ec787

Browse files
committed
fix: render raw session activity in inspector
1 parent a31cf1b commit 66ec787

5 files changed

Lines changed: 174 additions & 49 deletions

File tree

frontend/src/renderer/components/SessionInspector.test.tsx

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
22
import { render, screen, waitFor, within } from "@testing-library/react";
33
import userEvent from "@testing-library/user-event";
44
import type { ReactNode } from "react";
5-
import { beforeEach, describe, expect, it, vi } from "vitest";
5+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
66
import { SessionInspector } from "./SessionInspector";
77
import type { PRState, PullRequestFacts, WorkspaceSession } from "../types/workspace";
88

@@ -25,7 +25,7 @@ vi.mock("../lib/api-client", () => ({
2525
},
2626
}));
2727

28-
const pr = (n: number, state: PRState): PullRequestFacts => ({
28+
const pr = (n: number, state: PRState, overrides: Partial<PullRequestFacts> = {}): PullRequestFacts => ({
2929
url: `https://example.com/pr/${n}`,
3030
number: n,
3131
state,
@@ -34,9 +34,10 @@ const pr = (n: number, state: PRState): PullRequestFacts => ({
3434
mergeability: "mergeable",
3535
reviewComments: false,
3636
updatedAt: "2026-06-15T00:00:00Z",
37+
...overrides,
3738
});
3839

39-
const session = (prs: PullRequestFacts[]): WorkspaceSession => ({
40+
const session = (prs: PullRequestFacts[], overrides: Partial<WorkspaceSession> = {}): WorkspaceSession => ({
4041
id: "sess-1",
4142
workspaceId: "ws-1",
4243
workspaceName: "my-app",
@@ -47,6 +48,7 @@ const session = (prs: PullRequestFacts[]): WorkspaceSession => ({
4748
status: "review_pending",
4849
updatedAt: "2026-06-15T00:00:00Z",
4950
prs,
51+
...overrides,
5052
});
5153

5254
function renderWithQuery(children: ReactNode) {
@@ -119,9 +121,12 @@ beforeEach(() => {
119121
postMock.mockResolvedValue({ data: { ok: true, sessionId: "sess-1" }, error: undefined });
120122
});
121123

124+
afterEach(() => {
125+
vi.useRealTimers();
126+
});
127+
122128
describe("SessionInspector PR section", () => {
123-
// Scope assertions to the PR section: the activity timeline also renders
124-
// "Opened PR #n", so an unscoped query matches both the card and the event.
129+
// Scope assertions to the PR section so the card order is explicit.
125130
const prSection = (title: string) =>
126131
within(screen.getByText(title).closest("section.inspector-section") as HTMLElement);
127132

@@ -161,6 +166,78 @@ describe("SessionInspector PR section", () => {
161166
});
162167
});
163168

169+
describe("SessionInspector Activity section", () => {
170+
const activitySection = () =>
171+
within(screen.getByText("Activity").closest("section.inspector-section") as HTMLElement);
172+
173+
it.each([
174+
["idle", "Idle"],
175+
["active", "Working"],
176+
["waiting_input", "Input needed"],
177+
["exited", "Exited"],
178+
] as const)("renders %s from raw session activity", (state, label) => {
179+
renderWithQuery(
180+
<SessionInspector
181+
session={session([pr(7, "open")], {
182+
status: "review_pending",
183+
activity: { state, lastActivityAt: "2026-06-15T10:00:00Z" },
184+
})}
185+
/>,
186+
);
187+
188+
expect(activitySection().getByText(label)).toBeInTheDocument();
189+
});
190+
191+
it("does not derive the Activity label from PR-oriented session status", () => {
192+
renderWithQuery(
193+
<SessionInspector
194+
session={session([], {
195+
status: "review_pending",
196+
activity: { state: "idle", lastActivityAt: "2026-06-15T10:00:00Z" },
197+
})}
198+
/>,
199+
);
200+
201+
expect(activitySection().getByText("Idle")).toBeInTheDocument();
202+
expect(activitySection().queryByText("Input needed")).not.toBeInTheDocument();
203+
});
204+
205+
it("uses activity.lastActivityAt for the Activity timestamp", () => {
206+
vi.useFakeTimers();
207+
vi.setSystemTime(new Date("2026-06-15T12:00:00Z"));
208+
209+
renderWithQuery(
210+
<SessionInspector
211+
session={session([], {
212+
status: "working",
213+
updatedAt: "2026-06-15T11:55:00Z",
214+
activity: { state: "active", lastActivityAt: "2026-06-15T10:00:00Z" },
215+
})}
216+
/>,
217+
);
218+
219+
const activityRow = activitySection().getByText("Working").closest(".inspector-timeline__ev") as HTMLElement;
220+
expect(within(activityRow).getByText("2h ago")).toBeInTheDocument();
221+
});
222+
223+
it("keeps worktree, PR, and SCM context rows in the Activity timeline", () => {
224+
renderWithQuery(
225+
<SessionInspector
226+
session={session([pr(7, "open", { ci: "failing", review: "review_required" })], {
227+
status: "ci_failed",
228+
activity: { state: "idle", lastActivityAt: "2026-06-15T10:00:00Z" },
229+
})}
230+
/>,
231+
);
232+
233+
expect(activitySection().getByText(/Created worktree/)).toBeInTheDocument();
234+
expect(activitySection().getByText("Opened")).toBeInTheDocument();
235+
expect(activitySection().getByText("PR #7")).toBeInTheDocument();
236+
expect(activitySection().getByText("CI failed")).toBeInTheDocument();
237+
expect(activitySection().getByText("Review required")).toBeInTheDocument();
238+
});
239+
});
240+
164241
describe("SessionInspector tabs", () => {
165242
it("exposes Summary, Reviews, and Browser as the three inspector tabs", () => {
166243
renderWithQuery(<SessionInspector session={session([pr(1, "open")])} />);

frontend/src/renderer/components/SessionInspector.tsx

Lines changed: 64 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
77
import { formatTimeCompact } from "../lib/format-time";
88
import { useSessionScmSummary, type SessionPRSummary } from "../hooks/useSessionScmSummary";
99
import { prBrowserUrl, prStatusRows, sessionPRDisplaySummaries, type PRDisplayTone } from "../lib/pr-display";
10-
import type { SessionStatus, WorkspaceSession } from "../types/workspace";
11-
import { sortedPRs, workerDisplayStatus } from "../types/workspace";
10+
import type { SessionActivityState, WorkspaceSession } from "../types/workspace";
11+
import { sortedPRs } from "../types/workspace";
1212
import { BrowserPanelView } from "./BrowserPanel";
1313
import type { BrowserViewModel } from "../hooks/useBrowserView";
1414
import { Badge } from "./ui/badge";
@@ -259,38 +259,61 @@ type TimelineTone = "now" | "good" | "warn" | "neutral";
259259

260260
function ActivityTimeline({ session }: { session: WorkspaceSession }) {
261261
const events: { tone: TimelineTone; node: ReactNode; ts: string | null }[] = [];
262-
const detail = activityDetail(session.status);
262+
263+
events.push({
264+
tone: "neutral",
265+
node: <>Created worktree &amp; branch</>,
266+
ts: formatTimeCompact(session.createdAt ?? session.updatedAt),
267+
});
268+
269+
for (const pr of sortedPRs(session)) {
270+
events.push({
271+
tone: pr.state === "merged" ? "good" : "neutral",
272+
node:
273+
pr.state === "merged" ? (
274+
<>
275+
Merged <b>PR #{pr.number}</b>
276+
</>
277+
) : (
278+
<>
279+
Opened <b>PR #{pr.number}</b>
280+
</>
281+
),
282+
ts: null,
283+
});
284+
}
285+
286+
for (const event of scmActivityEvents(session)) {
287+
events.push(event);
288+
}
263289

264290
events.push({
265291
tone: "now",
266292
node: (
267293
<>
268294
<span className="inspector-timeline__badge">
269-
<InspectorStatusPill session={session} />
295+
<InspectorActivityPill state={session.activity?.state ?? "unknown"} />
270296
</span>
271-
{detail ? <span className="inspector-timeline__detail">{detail}</span> : null}
272297
</>
273298
),
274-
ts: formatTimeCompact(session.updatedAt),
299+
ts: session.activity?.lastActivityAt ? formatTimeCompact(session.activity.lastActivityAt) : null,
275300
});
276301

277-
for (const pr of sortedPRs(session)) {
302+
if (session.status === "merged") {
278303
events.push({
279304
tone: "good",
280-
node: (
281-
<>
282-
Opened <b>PR #{pr.number}</b>
283-
</>
284-
),
285-
ts: null,
305+
node: <>Done</>,
306+
ts: formatTimeCompact(session.updatedAt),
286307
});
287308
}
288309

289-
events.push({
290-
tone: "neutral",
291-
node: <>Created worktree &amp; branch</>,
292-
ts: formatTimeCompact(session.createdAt ?? session.updatedAt),
293-
});
310+
if (session.status === "terminated") {
311+
events.push({
312+
tone: "neutral",
313+
node: <>Terminated</>,
314+
ts: formatTimeCompact(session.updatedAt),
315+
});
316+
}
294317

295318
return (
296319
<div className="inspector-timeline">
@@ -313,38 +336,35 @@ function ActivityTimeline({ session }: { session: WorkspaceSession }) {
313336
);
314337
}
315338

316-
function activityDetail(status: SessionStatus): string | null {
317-
switch (status) {
318-
case "idle":
319-
return "Session idle";
320-
case "needs_input":
321-
return "Waiting for your input";
322-
case "no_signal":
323-
return "No recent agent signal";
324-
case "working":
325-
return null;
326-
default:
327-
return null;
339+
function scmActivityEvents(session: WorkspaceSession): { tone: TimelineTone; node: ReactNode; ts: string | null }[] {
340+
const events: { tone: TimelineTone; node: ReactNode; ts: string | null }[] = [];
341+
if (session.status === "ci_failed" || session.prs.some((pr) => pr.ci === "failing")) {
342+
events.push({
343+
tone: "warn",
344+
node: <>CI failed</>,
345+
ts: formatTimeCompact(session.updatedAt),
346+
});
347+
}
348+
if (session.status === "review_pending" || session.prs.some((pr) => pr.review === "review_required")) {
349+
events.push({
350+
tone: "neutral",
351+
node: <>Review required</>,
352+
ts: formatTimeCompact(session.updatedAt),
353+
});
328354
}
355+
return events;
329356
}
330357

331-
const STATUS_PILL: Record<
332-
ReturnType<typeof workerDisplayStatus> | "idle",
333-
{ label: string; tone: string; breathe: boolean }
334-
> = {
335-
working: { label: "Working", tone: "var(--orange)", breathe: true },
336-
needs_you: { label: "Input needed", tone: "var(--amber)", breathe: false },
337-
ci_failed: { label: "CI failed", tone: "var(--red)", breathe: false },
338-
no_signal: { label: "No signal", tone: "var(--fg-muted)", breathe: false },
339-
mergeable: { label: "Ready", tone: "var(--green)", breathe: false },
340-
done: { label: "Done", tone: "var(--fg-muted)", breathe: false },
341-
unknown: { label: "Unknown", tone: "var(--fg-muted)", breathe: false },
358+
const ACTIVITY_PILL: Record<SessionActivityState, { label: string; tone: string; breathe: boolean }> = {
359+
active: { label: "Working", tone: "var(--orange)", breathe: true },
342360
idle: { label: "Idle", tone: "var(--fg-muted)", breathe: false },
361+
waiting_input: { label: "Input needed", tone: "var(--amber)", breathe: false },
362+
exited: { label: "Exited", tone: "var(--fg-muted)", breathe: false },
363+
unknown: { label: "Unknown", tone: "var(--fg-muted)", breathe: false },
343364
};
344365

345-
function InspectorStatusPill({ session }: { session: WorkspaceSession }) {
346-
const key = session.status === "idle" ? "idle" : workerDisplayStatus(session);
347-
const { label, tone, breathe } = STATUS_PILL[key];
366+
function InspectorActivityPill({ state }: { state: SessionActivityState }) {
367+
const { label, tone, breathe } = ACTIVITY_PILL[state];
348368
return (
349369
<span
350370
className="inline-flex shrink-0 items-center gap-[7px] whitespace-nowrap rounded-[7px] px-[11px] py-[5px] text-[11.5px] font-semibold"

frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ describe("useWorkspaceQuery", () => {
6666
branch: "qa/modal-worker",
6767
status: "mergeable",
6868
isTerminated: false,
69+
activity: { state: "idle", lastActivityAt: "2026-06-10T15:30:00Z" },
6970
updatedAt: "2026-06-10T16:15:04Z",
7071
},
7172
{
@@ -99,6 +100,7 @@ describe("useWorkspaceQuery", () => {
99100
provider: "claude-code",
100101
branch: "qa/modal-worker",
101102
status: "mergeable",
103+
activity: { state: "idle", lastActivityAt: "2026-06-10T15:30:00Z" },
102104
});
103105
expect(workspace.sessions[1]).toMatchObject({
104106
id: "sess-2",

frontend/src/renderer/hooks/useWorkspaceQuery.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type PRState,
77
type PullRequestFacts,
88
toAgentProvider,
9+
toSessionActivity,
910
toSessionStatus,
1011
type WorkspaceSummary,
1112
} from "../types/workspace";
@@ -57,6 +58,7 @@ async function fetchWorkspaces(): Promise<WorkspaceSummary[]> {
5758
status: toSessionStatus(session.status, session.isTerminated),
5859
createdAt: session.createdAt,
5960
updatedAt: session.updatedAt,
61+
activity: toSessionActivity(session.activity),
6062
previewUrl: session.previewUrl,
6163
previewRevision: session.previewRevision,
6264
prs: (session.prs ?? []).map(toPullRequestFacts),

frontend/src/renderer/types/workspace.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,28 @@ export function toSessionStatus(status?: string, isTerminated = false): SessionS
3535
return isTerminated ? "terminated" : "unknown";
3636
}
3737

38+
export type SessionActivityState = "active" | "idle" | "waiting_input" | "exited" | "unknown";
39+
40+
const sessionActivityStates = new Set<SessionActivityState>(["active", "idle", "waiting_input", "exited"]);
41+
42+
export type SessionActivity = {
43+
state: SessionActivityState;
44+
lastActivityAt: string;
45+
};
46+
47+
export function toSessionActivity(
48+
activity?: { state?: string; lastActivityAt?: string } | null,
49+
): SessionActivity | undefined {
50+
if (!activity) return undefined;
51+
const state = sessionActivityStates.has(activity.state as SessionActivityState)
52+
? (activity.state as SessionActivityState)
53+
: "unknown";
54+
return {
55+
state,
56+
lastActivityAt: activity.lastActivityAt ?? "",
57+
};
58+
}
59+
3860
export type AgentProvider =
3961
| "codex"
4062
| "claude-code"
@@ -104,6 +126,8 @@ export type WorkspaceSession = {
104126
createdAt?: string;
105127
/** ISO timestamp from the daemon. */
106128
updatedAt: string;
129+
/** Raw agent lifecycle activity from the daemon. */
130+
activity?: SessionActivity;
107131
/**
108132
* Live preview target set by the daemon (via `ao preview`) and streamed over
109133
* CDC. When non-empty, the browser panel opens and navigates here.

0 commit comments

Comments
 (0)