diff --git a/features/inspector/components/inspector-client/inspector-client.test.tsx b/features/inspector/components/inspector-client/inspector-client.test.tsx
index 21a11e0..20e338f 100644
--- a/features/inspector/components/inspector-client/inspector-client.test.tsx
+++ b/features/inspector/components/inspector-client/inspector-client.test.tsx
@@ -14,23 +14,33 @@ const {
mockStartPolling,
mockStopPolling,
mockRehydrate,
-} = vi.hoisted(() => ({
- mockSeedEntries: vi.fn(),
- mockStartPolling: vi.fn(),
- mockStopPolling: vi.fn(),
- mockRehydrate: vi.fn().mockResolvedValue(undefined),
-}));
+ mockStoreState,
+} = vi.hoisted(() => {
+ const state = {
+ entries: [] as RequestEntry[],
+ isLoading: false,
+ view: "list" as "list" | "timeline",
+ };
+ return {
+ mockSeedEntries: vi.fn(),
+ mockStartPolling: vi.fn(),
+ mockStopPolling: vi.fn(),
+ mockRehydrate: vi.fn().mockResolvedValue(undefined),
+ mockStoreState: state,
+ };
+});
vi.mock(
"@/features/inspector/stores/use-inspector-store/use-inspector-store",
() => {
const mockStore = vi.fn(() => ({
- entries: [],
+ entries: mockStoreState.entries,
filters: { service: "", status: "all", text: "" },
- status: "idle",
+ status: mockStoreState.isLoading ? "polling" : "idle",
+ isLoading: mockStoreState.isLoading,
lastUpdatedAt: null,
- isPolling: false,
- view: "list",
+ isPolling: mockStoreState.isLoading,
+ view: mockStoreState.view,
seedEntries: mockSeedEntries,
startPolling: mockStartPolling,
stopPolling: mockStopPolling,
@@ -53,6 +63,22 @@ vi.mock("@/features/inspector/components/request-list/request-list", () => ({
),
}));
+vi.mock("@/features/inspector/components/inspector-timeline/inspector-timeline", () => ({
+ InspectorTimeline: ({ entries }: { entries: RequestEntry[] }) => (
+
+ ),
+}));
+
+vi.mock("@/features/inspector/components/inspector-empty/inspector-empty", () => ({
+ InspectorEmpty: () => ,
+}));
+
+vi.mock("@/features/inspector/components/inspector-skeleton/inspector-skeleton", () => ({
+ InspectorSkeleton: ({ withSpine }: { withSpine: boolean }) => (
+
+ ),
+}));
+
import { InspectorClient } from "./inspector-client";
// ── Fixtures ──────────────────────────────────────────────────────────────────
@@ -69,6 +95,7 @@ const dict: ClientDict = {
status: { label: "Status", all: "All", success: "Success", error: "Error" },
text: { placeholder: "Search…" },
},
+ view: { label: "View", list: "List", timeline: "Timeline" },
clearBuffer: "Clear",
statusPolling: "Live",
statusError: "Error",
@@ -76,7 +103,7 @@ const dict: ClientDict = {
lastUpdated: "Updated {time} ago",
},
empty: { title: "No requests", body: "Make some AWS calls" },
- card: { duration: "{ms}ms", attempts: "{n} attempts" },
+ card: { duration: "{ms}ms", attempts: "{n} attempts", retries: "{n} retries" },
detail: {
title: "Detail",
input: "Input",
@@ -107,6 +134,9 @@ afterEach(() => {
cleanup();
vi.clearAllMocks();
mockRehydrate.mockResolvedValue(undefined);
+ mockStoreState.entries = [];
+ mockStoreState.isLoading = false;
+ mockStoreState.view = "list";
});
// ── Tests ─────────────────────────────────────────────────────────────────────
@@ -119,7 +149,8 @@ describe("InspectorClient", () => {
expect(screen.getByTestId("inspector-toolbar")).toBeInTheDocument();
});
- it("renders the request list", async () => {
+ it("renders the request list when entries exist", async () => {
+ mockStoreState.entries = [makeEntry("e1")];
await act(async () => {
render();
});
@@ -147,4 +178,47 @@ describe("InspectorClient", () => {
});
expect(mockStartPolling).toHaveBeenCalledOnce();
});
+
+ // ── Task 3.12 / 3.13: view toggle ────────────────────────────────────────────
+
+ it("renders RequestList when view is 'list' and entries exist", async () => {
+ mockStoreState.entries = [makeEntry("e1")];
+ mockStoreState.view = "list";
+ await act(async () => {
+ render();
+ });
+ expect(screen.getByTestId("request-list")).toBeInTheDocument();
+ expect(screen.queryByTestId("inspector-timeline")).not.toBeInTheDocument();
+ });
+
+ it("renders InspectorTimeline when view is 'timeline' and entries exist", async () => {
+ mockStoreState.entries = [makeEntry("e1")];
+ mockStoreState.view = "timeline";
+ await act(async () => {
+ render();
+ });
+ expect(screen.getByTestId("inspector-timeline")).toBeInTheDocument();
+ expect(screen.queryByTestId("request-list")).not.toBeInTheDocument();
+ });
+
+ it("renders InspectorSkeleton when isLoading is true", async () => {
+ mockStoreState.isLoading = true;
+ await act(async () => {
+ render();
+ });
+ expect(screen.getByTestId("inspector-skeleton")).toBeInTheDocument();
+ expect(screen.queryByTestId("request-list")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("inspector-timeline")).not.toBeInTheDocument();
+ });
+
+ it("renders InspectorEmpty when no entries and not loading", async () => {
+ mockStoreState.entries = [];
+ mockStoreState.isLoading = false;
+ await act(async () => {
+ render();
+ });
+ expect(screen.getByTestId("inspector-empty")).toBeInTheDocument();
+ expect(screen.queryByTestId("request-list")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("inspector-timeline")).not.toBeInTheDocument();
+ });
});
diff --git a/features/inspector/components/inspector-client/inspector-client.tsx b/features/inspector/components/inspector-client/inspector-client.tsx
index 379aaec..171e431 100644
--- a/features/inspector/components/inspector-client/inspector-client.tsx
+++ b/features/inspector/components/inspector-client/inspector-client.tsx
@@ -4,6 +4,9 @@ import { useEffect, useMemo } from "react";
import { useInspectorStore } from "@/features/inspector/stores/use-inspector-store/use-inspector-store";
import { InspectorToolbar } from "@/features/inspector/components/inspector-toolbar/inspector-toolbar";
import { RequestList } from "@/features/inspector/components/request-list/request-list";
+import { InspectorTimeline } from "@/features/inspector/components/inspector-timeline/inspector-timeline";
+import { InspectorEmpty } from "@/features/inspector/components/inspector-empty/inspector-empty";
+import { InspectorSkeleton } from "@/features/inspector/components/inspector-skeleton/inspector-skeleton";
import type { RequestEntry } from "@/features/inspector/lib/types/types";
import type { InspectorDict } from "@/features/inspector/i18n/en";
import type { WidenStringLiterals } from "@/features/shared/i18n/widen-literals";
@@ -27,15 +30,16 @@ const SERVICES = ["SQS", "SNS", "S3", "Lambda", "DynamoDB", "CloudWatchLogs"];
// ── Component ─────────────────────────────────────────────────────────────────
export function InspectorClient({ initialEntries, dict }: InspectorClientProps) {
- const { entries, filters, seedEntries, startPolling, stopPolling } =
+ const { entries, filters, view, isPolling, seedEntries, startPolling, stopPolling } =
useInspectorStore();
// Mount: rehydrate → seed RSC entries → start polling
useEffect(() => {
- void useInspectorStore.persist.rehydrate().then(() => {
+ void (async () => {
+ await useInspectorStore.persist.rehydrate();
seedEntries(initialEntries);
startPolling();
- });
+ })();
return () => {
stopPolling();
@@ -57,11 +61,26 @@ export function InspectorClient({ initialEntries, dict }: InspectorClientProps)
});
}, [entries, filters]);
+ // ── Render body ───────────────────────────────────────────────────────────────
+
+ function renderBody() {
+ if (isPolling && entries.length === 0) {
+ return ;
+ }
+ if (filtered.length === 0) {
+ return ;
+ }
+ if (view === "timeline") {
+ return ;
+ }
+ return ;
+ }
+
return (
);
diff --git a/features/inspector/components/inspector-empty/inspector-empty.test.tsx b/features/inspector/components/inspector-empty/inspector-empty.test.tsx
new file mode 100644
index 0000000..4048405
--- /dev/null
+++ b/features/inspector/components/inspector-empty/inspector-empty.test.tsx
@@ -0,0 +1,38 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it } from "vitest";
+
+import { InspectorEmpty } from "./inspector-empty";
+
+// ── Fixtures ──────────────────────────────────────────────────────────────────
+
+const dict = {
+ empty: {
+ title: "No requests yet",
+ body: "AWS SDK calls made by Server Actions will appear here.",
+ },
+};
+
+afterEach(() => {
+ cleanup();
+});
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+describe("InspectorEmpty", () => {
+ it("renders the empty title", () => {
+ render();
+ expect(screen.getByText("No requests yet")).toBeInTheDocument();
+ });
+
+ it("renders the empty body text", () => {
+ render();
+ expect(
+ screen.getByText("AWS SDK calls made by Server Actions will appear here."),
+ ).toBeInTheDocument();
+ });
+
+ it("renders a SearchX icon (svg element present)", () => {
+ const { container } = render();
+ expect(container.querySelector("svg")).toBeInTheDocument();
+ });
+});
diff --git a/features/inspector/components/inspector-empty/inspector-empty.tsx b/features/inspector/components/inspector-empty/inspector-empty.tsx
new file mode 100644
index 0000000..2c95fec
--- /dev/null
+++ b/features/inspector/components/inspector-empty/inspector-empty.tsx
@@ -0,0 +1,25 @@
+import { SearchX } from "lucide-react";
+import type { InspectorDict } from "@/features/inspector/i18n/en";
+import type { WidenStringLiterals } from "@/features/shared/i18n/widen-literals";
+
+// ── Types ──────────────────────────────────────────────────────────────────────
+
+type EmptyDict = Pick, "empty">;
+
+type InspectorEmptyProps = {
+ dict: EmptyDict;
+};
+
+// ── Component ─────────────────────────────────────────────────────────────────
+
+export function InspectorEmpty({ dict }: InspectorEmptyProps) {
+ return (
+
+
+
+
{dict.empty.title}
+
{dict.empty.body}
+
+
+ );
+}
diff --git a/features/inspector/components/inspector-skeleton/inspector-skeleton.test.tsx b/features/inspector/components/inspector-skeleton/inspector-skeleton.test.tsx
new file mode 100644
index 0000000..9109c73
--- /dev/null
+++ b/features/inspector/components/inspector-skeleton/inspector-skeleton.test.tsx
@@ -0,0 +1,47 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it } from "vitest";
+
+import { InspectorSkeleton } from "./inspector-skeleton";
+
+afterEach(() => {
+ cleanup();
+});
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+describe("InspectorSkeleton", () => {
+ describe("list mode (withSpine=false)", () => {
+ it("renders skeleton card items", () => {
+ const { container } = render();
+ // Expect at least one skeleton placeholder element
+ const items = container.querySelectorAll("[data-testid='skeleton-item']");
+ expect(items.length).toBeGreaterThan(0);
+ });
+
+ it("does NOT render spine dots when withSpine is false", () => {
+ const { container } = render();
+ expect(container.querySelector("[data-testid='spine-dot']")).not.toBeInTheDocument();
+ });
+ });
+
+ describe("timeline mode (withSpine=true)", () => {
+ it("renders skeleton card items with spine dots when withSpine is true", () => {
+ const { container } = render();
+ const items = container.querySelectorAll("[data-testid='skeleton-item']");
+ expect(items.length).toBeGreaterThan(0);
+ });
+
+ it("renders spine dots when withSpine is true", () => {
+ const { container } = render();
+ const spineDots = container.querySelectorAll("[data-testid='spine-dot']");
+ expect(spineDots.length).toBeGreaterThan(0);
+ });
+
+ it("spine dot count matches skeleton item count", () => {
+ const { container } = render();
+ const items = container.querySelectorAll("[data-testid='skeleton-item']");
+ const spineDots = container.querySelectorAll("[data-testid='spine-dot']");
+ expect(spineDots.length).toBe(items.length);
+ });
+ });
+});
diff --git a/features/inspector/components/inspector-skeleton/inspector-skeleton.tsx b/features/inspector/components/inspector-skeleton/inspector-skeleton.tsx
new file mode 100644
index 0000000..b6c290f
--- /dev/null
+++ b/features/inspector/components/inspector-skeleton/inspector-skeleton.tsx
@@ -0,0 +1,42 @@
+// ── Types ──────────────────────────────────────────────────────────────────────
+
+type InspectorSkeletonProps = {
+ withSpine?: boolean;
+ count?: number;
+};
+
+// ── Component ─────────────────────────────────────────────────────────────────
+
+const DEFAULT_COUNT = 5;
+
+export function InspectorSkeleton({
+ withSpine = false,
+ count = DEFAULT_COUNT,
+}: InspectorSkeletonProps) {
+ const items = Array.from({ length: count });
+
+ return (
+
+ {items.map((_, idx) => (
+
+ {withSpine && (
+
+
+ {idx < count - 1 &&
}
+
+ )}
+
+
+ ))}
+
+ );
+}
diff --git a/features/inspector/components/inspector-timeline/inspector-timeline.test.tsx b/features/inspector/components/inspector-timeline/inspector-timeline.test.tsx
new file mode 100644
index 0000000..eaa68ee
--- /dev/null
+++ b/features/inspector/components/inspector-timeline/inspector-timeline.test.tsx
@@ -0,0 +1,79 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { RequestEntry } from "@/features/inspector/lib/types/types";
+
+// ── Mocks ─────────────────────────────────────────────────────────────────────
+
+vi.mock("@/features/inspector/components/request-card/request-card", () => ({
+ RequestCard: ({ entry }: { entry: RequestEntry }) => (
+
+ ),
+}));
+
+import { InspectorTimeline } from "./inspector-timeline";
+
+// ── Fixtures ──────────────────────────────────────────────────────────────────
+
+const dict = {
+ card: { duration: "{ms}ms", attempts: "{n} attempts", retries: "{n} retries" },
+};
+
+function makeEntry(id: string, timestamp: number): RequestEntry {
+ return {
+ id,
+ timestamp,
+ service: "SQS",
+ operation: "SendMessageCommand",
+ input: {},
+ output: {},
+ durationMs: 10,
+ status: "success",
+ attempts: 1,
+ };
+}
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+describe("InspectorTimeline", () => {
+ it("renders one RequestCard per entry", () => {
+ const entries = [
+ makeEntry("e1", 1000),
+ makeEntry("e2", 2000),
+ makeEntry("e3", 3000),
+ ];
+ render();
+ expect(screen.getAllByTestId("request-card")).toHaveLength(3);
+ });
+
+ it("renders entries sorted descending by timestamp (newest first)", () => {
+ const entries = [
+ makeEntry("e1", 1000),
+ makeEntry("e3", 3000),
+ makeEntry("e2", 2000),
+ ];
+ render();
+ const cards = screen.getAllByTestId("request-card");
+ // e3 (timestamp=3000) must appear first
+ expect(cards[0].getAttribute("data-id")).toBe("e3");
+ expect(cards[1].getAttribute("data-id")).toBe("e2");
+ expect(cards[2].getAttribute("data-id")).toBe("e1");
+ });
+
+ it("renders a spine container (flex gap structure) for each entry", () => {
+ const entries = [makeEntry("e1", 1000), makeEntry("e2", 2000)];
+ const { container } = render();
+ // Each entry is wrapped in a flex row with gap — verify spine dots are rendered
+ const spineDots = container.querySelectorAll("[data-testid='spine-dot']");
+ expect(spineDots.length).toBe(2);
+ });
+
+ it("renders nothing when entries is empty", () => {
+ const { container } = render();
+ expect(container.querySelectorAll("[data-testid='request-card']")).toHaveLength(0);
+ });
+});
diff --git a/features/inspector/components/inspector-timeline/inspector-timeline.tsx b/features/inspector/components/inspector-timeline/inspector-timeline.tsx
new file mode 100644
index 0000000..3d76317
--- /dev/null
+++ b/features/inspector/components/inspector-timeline/inspector-timeline.tsx
@@ -0,0 +1,48 @@
+import { getServiceColorClasses } from "@/features/inspector/lib/service-color/service-color";
+import { RequestCard } from "@/features/inspector/components/request-card/request-card";
+import type { RequestEntry } from "@/features/inspector/lib/types/types";
+import { cn } from "@/lib/utils";
+import type { InspectorDict } from "@/features/inspector/i18n/en";
+import type { WidenStringLiterals } from "@/features/shared/i18n/widen-literals";
+
+// ── Types ──────────────────────────────────────────────────────────────────────
+
+type CardDict = Pick, "card">;
+
+type InspectorTimelineProps = {
+ entries: RequestEntry[];
+ dict: CardDict;
+};
+
+// ── Component ─────────────────────────────────────────────────────────────────
+
+export function InspectorTimeline({ entries, dict }: InspectorTimelineProps) {
+ const sorted = [...entries].sort((a, b) => b.timestamp - a.timestamp);
+
+ return (
+
+ {sorted.map((entry, idx) => (
+
+ {/* Spine column */}
+
+
+ {idx < sorted.length - 1 && (
+
+ )}
+
+
+ {/* Card column */}
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/features/inspector/components/inspector-toolbar/inspector-toolbar.test.tsx b/features/inspector/components/inspector-toolbar/inspector-toolbar.test.tsx
index d3f516d..b648ce8 100644
--- a/features/inspector/components/inspector-toolbar/inspector-toolbar.test.tsx
+++ b/features/inspector/components/inspector-toolbar/inspector-toolbar.test.tsx
@@ -7,6 +7,10 @@ import type { WidenStringLiterals } from "@/features/shared/i18n/widen-literals"
const mockSetFilter = vi.fn();
const mockClearBuffer = vi.fn().mockResolvedValue(undefined);
+const mockSetView = vi.fn();
+
+// mockView holds the current view for the mock store
+const mockState = { view: "list" as "list" | "timeline" };
vi.mock(
"@/features/inspector/stores/use-inspector-store/use-inspector-store",
@@ -15,8 +19,10 @@ vi.mock(
filters: { service: "", status: "all", text: "" },
status: "idle",
lastUpdatedAt: null,
+ view: mockState.view,
setFilter: mockSetFilter,
clearBuffer: mockClearBuffer,
+ setView: mockSetView,
})),
}),
);
@@ -55,6 +61,11 @@ const dict: ToolbarDict = {
placeholder: "Search…",
},
},
+ view: {
+ label: "View",
+ list: "List",
+ timeline: "Timeline",
+ },
clearBuffer: "Clear",
statusPolling: "Live",
statusError: "Error",
@@ -66,6 +77,7 @@ const dict: ToolbarDict = {
afterEach(() => {
cleanup();
vi.clearAllMocks();
+ mockState.view = "list";
});
// ── Tests ─────────────────────────────────────────────────────────────────────
@@ -110,4 +122,54 @@ describe("InspectorToolbar", () => {
// "All" appears in both trigger and dropdown item
expect(screen.getAllByText("All").length).toBeGreaterThanOrEqual(1);
});
+
+ describe("view toggle", () => {
+ it("renders List and Timeline toggle buttons", () => {
+ mockState.view = "list";
+ render();
+ expect(screen.getByRole("tab", { name: /^list$/i })).toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: /^timeline$/i })).toBeInTheDocument();
+ });
+
+ it("List button has aria-pressed=true when view is list", () => {
+ mockState.view = "list";
+ render();
+ expect(screen.getByRole("tab", { name: /^list$/i })).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+ });
+
+ it("Timeline button has aria-pressed=false when view is list", () => {
+ mockState.view = "list";
+ render();
+ expect(screen.getByRole("tab", { name: /^timeline$/i })).toHaveAttribute(
+ "aria-pressed",
+ "false",
+ );
+ });
+
+ it("Timeline button has aria-pressed=true when view is timeline", () => {
+ mockState.view = "timeline";
+ render();
+ expect(screen.getByRole("tab", { name: /^timeline$/i })).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+ });
+
+ it("clicking List button calls setView('list')", () => {
+ mockState.view = "timeline";
+ render();
+ fireEvent.click(screen.getByRole("tab", { name: /^list$/i }));
+ expect(mockSetView).toHaveBeenCalledWith("list");
+ });
+
+ it("clicking Timeline button calls setView('timeline')", () => {
+ mockState.view = "list";
+ render();
+ fireEvent.click(screen.getByRole("tab", { name: /^timeline$/i }));
+ expect(mockSetView).toHaveBeenCalledWith("timeline");
+ });
+ });
});
diff --git a/features/inspector/components/inspector-toolbar/inspector-toolbar.tsx b/features/inspector/components/inspector-toolbar/inspector-toolbar.tsx
index a75221d..63d43e9 100644
--- a/features/inspector/components/inspector-toolbar/inspector-toolbar.tsx
+++ b/features/inspector/components/inspector-toolbar/inspector-toolbar.tsx
@@ -25,7 +25,7 @@ const STATUS_OPTIONS = ["all", "success", "error"] as const;
// ── Component ─────────────────────────────────────────────────────────────────
export function InspectorToolbar({ dict, services }: InspectorToolbarProps) {
- const { filters, setFilter, clearBuffer } = useInspectorStore();
+ const { filters, setFilter, clearBuffer, view, setView } = useInspectorStore();
const t = dict.toolbar;
return (
@@ -36,7 +36,7 @@ export function InspectorToolbar({ dict, services }: InspectorToolbarProps) {
+ {/* View toggle — segmented control */}
+
+
+
+
+
{/* Clear buffer */}