diff --git a/frontend/src/renderer/components/SessionInspector.test.tsx b/frontend/src/renderer/components/SessionInspector.test.tsx index 7c7eddb28..a5d654777 100644 --- a/frontend/src/renderer/components/SessionInspector.test.tsx +++ b/frontend/src/renderer/components/SessionInspector.test.tsx @@ -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 => ({ 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 => ({ 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( + , + ); + + expect(activitySection().getByText(label)).toBeInTheDocument(); + }); + + it("does not derive the Activity label from PR-oriented session status", () => { + renderWithQuery( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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(); diff --git a/frontend/src/renderer/components/SessionInspector.tsx b/frontend/src/renderer/components/SessionInspector.tsx index e7ae9d64d..485ea448d 100644 --- a/frontend/src/renderer/components/SessionInspector.tsx +++ b/frontend/src/renderer/components/SessionInspector.tsx @@ -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: ( - <> - - - - {detail ? — {detail} : 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 PR #{pr.number} + + ), + ts: null, + }); + } + + for (const pr of prs.filter((pr) => pr.state !== "draft")) { + events.push({ + tone: "neutral", node: ( <> Opened PR #{pr.number} @@ -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: ( + + + + + {scmTimelineStates(session).map((state) => ( + + + + ))} + + ), + 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 PR #{pr.number} + + ), + ts: null, + }); + } + + if (session.status === "merged") { + events.push({ + tone: "good", + node: <>Done, + ts: formatTimeCompact(session.updatedAt), + }); + } + return (
{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 | "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 = { + 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 = { + 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 ; +} + +function InspectorScmPill({ state }: { state: ScmTimelineState }) { + return ; +} + +function TimelinePill({ label, tone, breathe }: { label: string; tone: string; breathe: boolean }) { return ( (); + 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, diff --git a/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx b/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx index a81309c17..69daa089e 100644 --- a/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx +++ b/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx @@ -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", diff --git a/frontend/src/renderer/hooks/useWorkspaceQuery.ts b/frontend/src/renderer/hooks/useWorkspaceQuery.ts index 0967d8d31..c07efa195 100644 --- a/frontend/src/renderer/hooks/useWorkspaceQuery.ts +++ b/frontend/src/renderer/hooks/useWorkspaceQuery.ts @@ -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 { 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), diff --git a/frontend/src/renderer/types/workspace.ts b/frontend/src/renderer/types/workspace.ts index afb69b780..49a98f427 100644 --- a/frontend/src/renderer/types/workspace.ts +++ b/frontend/src/renderer/types/workspace.ts @@ -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(["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.