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:
parent
8cadea5b16
commit
4557146681
|
|
@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import type { ReactNode } from "react";
|
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 { SessionInspector } from "./SessionInspector";
|
||||||
import type { PRState, PullRequestFacts, WorkspaceSession } from "../types/workspace";
|
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}`,
|
url: `https://example.com/pr/${n}`,
|
||||||
number: n,
|
number: n,
|
||||||
state,
|
state,
|
||||||
|
|
@ -34,9 +34,10 @@ const pr = (n: number, state: PRState): PullRequestFacts => ({
|
||||||
mergeability: "mergeable",
|
mergeability: "mergeable",
|
||||||
reviewComments: false,
|
reviewComments: false,
|
||||||
updatedAt: "2026-06-15T00:00:00Z",
|
updatedAt: "2026-06-15T00:00:00Z",
|
||||||
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
const session = (prs: PullRequestFacts[]): WorkspaceSession => ({
|
const session = (prs: PullRequestFacts[], overrides: Partial<WorkspaceSession> = {}): WorkspaceSession => ({
|
||||||
id: "sess-1",
|
id: "sess-1",
|
||||||
workspaceId: "ws-1",
|
workspaceId: "ws-1",
|
||||||
workspaceName: "my-app",
|
workspaceName: "my-app",
|
||||||
|
|
@ -47,6 +48,7 @@ const session = (prs: PullRequestFacts[]): WorkspaceSession => ({
|
||||||
status: "review_pending",
|
status: "review_pending",
|
||||||
updatedAt: "2026-06-15T00:00:00Z",
|
updatedAt: "2026-06-15T00:00:00Z",
|
||||||
prs,
|
prs,
|
||||||
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
const sessionWithProvider = (prs: PullRequestFacts[], provider: WorkspaceSession["provider"]): WorkspaceSession => ({
|
const sessionWithProvider = (prs: PullRequestFacts[], provider: WorkspaceSession["provider"]): WorkspaceSession => ({
|
||||||
|
|
@ -124,9 +126,12 @@ beforeEach(() => {
|
||||||
postMock.mockResolvedValue({ data: { ok: true, sessionId: "sess-1" }, error: undefined });
|
postMock.mockResolvedValue({ data: { ok: true, sessionId: "sess-1" }, error: undefined });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
describe("SessionInspector PR section", () => {
|
describe("SessionInspector PR section", () => {
|
||||||
// Scope assertions to the PR section: the activity timeline also renders
|
// Scope assertions to the PR section so the card order is explicit.
|
||||||
// "Opened PR #n", so an unscoped query matches both the card and the event.
|
|
||||||
const prSection = (title: string) =>
|
const prSection = (title: string) =>
|
||||||
within(screen.getByText(title).closest("section.inspector-section") as HTMLElement);
|
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", () => {
|
describe("SessionInspector tabs", () => {
|
||||||
it("exposes Summary, Reviews, and Browser as the three inspector tabs", () => {
|
it("exposes Summary, Reviews, and Browser as the three inspector tabs", () => {
|
||||||
renderWithQuery(<SessionInspector session={session([pr(1, "open")])} />);
|
renderWithQuery(<SessionInspector session={session([pr(1, "open")])} />);
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
|
||||||
import { formatTimeCompact } from "../lib/format-time";
|
import { formatTimeCompact } from "../lib/format-time";
|
||||||
import { useSessionScmSummary, type SessionPRSummary } from "../hooks/useSessionScmSummary";
|
import { useSessionScmSummary, type SessionPRSummary } from "../hooks/useSessionScmSummary";
|
||||||
import { prBrowserUrl, prStatusRows, sessionPRDisplaySummaries, type PRDisplayTone } from "../lib/pr-display";
|
import { prBrowserUrl, prStatusRows, sessionPRDisplaySummaries, type PRDisplayTone } from "../lib/pr-display";
|
||||||
import type { SessionStatus, WorkspaceSession } from "../types/workspace";
|
import type { SessionActivityState, WorkspaceSession } from "../types/workspace";
|
||||||
import { sortedPRs, workerDisplayStatus } from "../types/workspace";
|
import { sortedPRs } from "../types/workspace";
|
||||||
import { BrowserPanelView } from "./BrowserPanel";
|
import { BrowserPanelView } from "./BrowserPanel";
|
||||||
import type { BrowserViewModel } from "../hooks/useBrowserView";
|
import type { BrowserViewModel } from "../hooks/useBrowserView";
|
||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
|
|
@ -259,24 +259,29 @@ type TimelineTone = "now" | "good" | "warn" | "neutral";
|
||||||
|
|
||||||
function ActivityTimeline({ session }: { session: WorkspaceSession }) {
|
function ActivityTimeline({ session }: { session: WorkspaceSession }) {
|
||||||
const events: { tone: TimelineTone; node: ReactNode; ts: string | null }[] = [];
|
const events: { tone: TimelineTone; node: ReactNode; ts: string | null }[] = [];
|
||||||
const detail = activityDetail(session.status);
|
|
||||||
|
|
||||||
events.push({
|
events.push({
|
||||||
tone: "now",
|
tone: "neutral",
|
||||||
node: (
|
node: <>Created worktree & branch</>,
|
||||||
<>
|
ts: formatTimeCompact(session.createdAt ?? session.updatedAt),
|
||||||
<span className="inspector-timeline__badge">
|
|
||||||
<InspectorStatusPill session={session} />
|
|
||||||
</span>
|
|
||||||
{detail ? <span className="inspector-timeline__detail"> — {detail}</span> : null}
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
ts: formatTimeCompact(session.updatedAt),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const pr of sortedPRs(session)) {
|
const prs = sortedPRs(session);
|
||||||
|
for (const pr of prs.filter((pr) => pr.state === "draft")) {
|
||||||
events.push({
|
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: (
|
node: (
|
||||||
<>
|
<>
|
||||||
Opened <b>PR #{pr.number}</b>
|
Opened <b>PR #{pr.number}</b>
|
||||||
|
|
@ -287,11 +292,47 @@ function ActivityTimeline({ session }: { session: WorkspaceSession }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
events.push({
|
events.push({
|
||||||
tone: "neutral",
|
tone: "now",
|
||||||
node: <>Created worktree & branch</>,
|
node: (
|
||||||
ts: formatTimeCompact(session.createdAt ?? session.updatedAt),
|
<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 (
|
return (
|
||||||
<div className="inspector-timeline">
|
<div className="inspector-timeline">
|
||||||
{events.map((event, index) => (
|
{events.map((event, index) => (
|
||||||
|
|
@ -313,38 +354,35 @@ function ActivityTimeline({ session }: { session: WorkspaceSession }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function activityDetail(status: SessionStatus): string | null {
|
const ACTIVITY_PILL: Record<SessionActivityState, { label: string; tone: string; breathe: boolean }> = {
|
||||||
switch (status) {
|
active: { label: "Working", tone: "var(--orange)", breathe: true },
|
||||||
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 },
|
|
||||||
idle: { label: "Idle", tone: "var(--fg-muted)", breathe: false },
|
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 ACTIVITY_WARNING_PILL: Record<"no_signal", { label: string; tone: string; breathe: boolean }> = {
|
||||||
const key = session.status === "idle" ? "idle" : workerDisplayStatus(session);
|
no_signal: { label: "No Signal", tone: "var(--fg-muted)", breathe: false },
|
||||||
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 (
|
return (
|
||||||
<span
|
<span
|
||||||
className="inline-flex shrink-0 items-center gap-[7px] whitespace-nowrap rounded-[7px] px-[11px] py-[5px] text-[11.5px] font-semibold"
|
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({
|
function ReviewsView({
|
||||||
session,
|
session,
|
||||||
onOpenReviewerTerminal,
|
onOpenReviewerTerminal,
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ describe("useWorkspaceQuery", () => {
|
||||||
branch: "qa/modal-worker",
|
branch: "qa/modal-worker",
|
||||||
status: "mergeable",
|
status: "mergeable",
|
||||||
isTerminated: false,
|
isTerminated: false,
|
||||||
|
activity: { state: "idle", lastActivityAt: "2026-06-10T15:30:00Z" },
|
||||||
updatedAt: "2026-06-10T16:15:04Z",
|
updatedAt: "2026-06-10T16:15:04Z",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -99,6 +100,7 @@ describe("useWorkspaceQuery", () => {
|
||||||
provider: "claude-code",
|
provider: "claude-code",
|
||||||
branch: "qa/modal-worker",
|
branch: "qa/modal-worker",
|
||||||
status: "mergeable",
|
status: "mergeable",
|
||||||
|
activity: { state: "idle", lastActivityAt: "2026-06-10T15:30:00Z" },
|
||||||
});
|
});
|
||||||
expect(workspace.sessions[1]).toMatchObject({
|
expect(workspace.sessions[1]).toMatchObject({
|
||||||
id: "sess-2",
|
id: "sess-2",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
type PRState,
|
type PRState,
|
||||||
type PullRequestFacts,
|
type PullRequestFacts,
|
||||||
toAgentProvider,
|
toAgentProvider,
|
||||||
|
toSessionActivity,
|
||||||
toSessionStatus,
|
toSessionStatus,
|
||||||
type WorkspaceSummary,
|
type WorkspaceSummary,
|
||||||
} from "../types/workspace";
|
} from "../types/workspace";
|
||||||
|
|
@ -57,6 +58,7 @@ async function fetchWorkspaces(): Promise<WorkspaceSummary[]> {
|
||||||
status: toSessionStatus(session.status, session.isTerminated),
|
status: toSessionStatus(session.status, session.isTerminated),
|
||||||
createdAt: session.createdAt,
|
createdAt: session.createdAt,
|
||||||
updatedAt: session.updatedAt,
|
updatedAt: session.updatedAt,
|
||||||
|
activity: toSessionActivity(session.activity),
|
||||||
previewUrl: session.previewUrl,
|
previewUrl: session.previewUrl,
|
||||||
previewRevision: session.previewRevision,
|
previewRevision: session.previewRevision,
|
||||||
prs: (session.prs ?? []).map(toPullRequestFacts),
|
prs: (session.prs ?? []).map(toPullRequestFacts),
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,28 @@ export function toSessionStatus(status?: string, isTerminated = false): SessionS
|
||||||
return isTerminated ? "terminated" : "unknown";
|
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 =
|
export type AgentProvider =
|
||||||
| "codex"
|
| "codex"
|
||||||
| "claude-code"
|
| "claude-code"
|
||||||
|
|
@ -104,6 +126,8 @@ export type WorkspaceSession = {
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
/** ISO timestamp from the daemon. */
|
/** ISO timestamp from the daemon. */
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
/** Raw agent lifecycle activity from the daemon. */
|
||||||
|
activity?: SessionActivity;
|
||||||
/**
|
/**
|
||||||
* Live preview target set by the daemon (via `ao preview`) and streamed over
|
* Live preview target set by the daemon (via `ao preview`) and streamed over
|
||||||
* CDC. When non-empty, the browser panel opens and navigates here.
|
* CDC. When non-empty, the browser panel opens and navigates here.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue