diff --git a/frontend/src/app/activity/page.tsx b/frontend/src/app/activity/page.tsx
index a44a7ed1..2c585672 100644
--- a/frontend/src/app/activity/page.tsx
+++ b/frontend/src/app/activity/page.tsx
@@ -14,6 +14,7 @@ import {
} from "@/api/generated/activity/activity";
import type { ActivityTaskCommentFeedItemRead } from "@/api/generated/model";
import { Markdown } from "@/components/atoms/Markdown";
+import { ActivityFeed } from "@/components/activity/ActivityFeed";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
@@ -331,28 +332,12 @@ export default function ActivityPage() {
- {feedQuery.isLoading && feedItems.length === 0 ? (
-
Loading feed…
- ) : feedQuery.error ? (
-
- {feedQuery.error.message || "Unable to load feed."}
-
- ) : orderedFeed.length === 0 ? (
-
-
- Waiting for new comments…
-
-
- When agents post updates, they will show up here.
-
-
- ) : (
-
- {orderedFeed.map((item) => (
-
- ))}
-
- )}
+
}
+ />
diff --git a/frontend/src/components/activity/ActivityFeed.test.tsx b/frontend/src/components/activity/ActivityFeed.test.tsx
new file mode 100644
index 00000000..239d1399
--- /dev/null
+++ b/frontend/src/components/activity/ActivityFeed.test.tsx
@@ -0,0 +1,88 @@
+import { render, screen } from "@testing-library/react";
+
+import { ActivityFeed } from "./ActivityFeed";
+
+type Item = { id: string; label: string };
+
+describe("ActivityFeed", () => {
+ it("renders loading state when loading and no items", () => {
+ render(
+
+ isLoading={true}
+ errorMessage={null}
+ items={[]}
+ renderItem={(item) => {item.label}
}
+ />,
+ );
+
+ expect(screen.getByText("Loading feed…")).toBeInTheDocument();
+ });
+
+ it("renders error state", () => {
+ render(
+
+ isLoading={false}
+ errorMessage={"Boom"}
+ items={[]}
+ renderItem={(item) => {item.label}
}
+ />,
+ );
+
+ expect(screen.getByText("Boom")).toBeInTheDocument();
+ });
+
+ it("renders default error message when errorMessage is empty", () => {
+ render(
+
+ isLoading={false}
+ errorMessage={""}
+ items={[]}
+ renderItem={(item) => {item.label}
}
+ />,
+ );
+
+ expect(screen.getByText("Unable to load feed.")).toBeInTheDocument();
+ });
+
+ it("renders empty state", () => {
+ render(
+
+ isLoading={false}
+ errorMessage={null}
+ items={[]}
+ renderItem={(item) => {item.label}
}
+ />,
+ );
+
+ expect(
+ screen.getByText("Waiting for new comments…"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText("When agents post updates, they will show up here."),
+ ).toBeInTheDocument();
+ });
+
+ it("renders items", () => {
+ const items: Item[] = [
+ { id: "1", label: "First" },
+ { id: "2", label: "Second" },
+ ];
+
+ render(
+
+ isLoading={false}
+ errorMessage={null}
+ items={items}
+ renderItem={(item) => (
+
+ {item.label}
+
+ )}
+ />,
+ );
+
+ expect(screen.getAllByTestId("feed-item")).toHaveLength(2);
+ expect(screen.getByText("First")).toBeInTheDocument();
+ expect(screen.getByText("Second")).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/activity/ActivityFeed.tsx b/frontend/src/components/activity/ActivityFeed.tsx
new file mode 100644
index 00000000..dd0bf908
--- /dev/null
+++ b/frontend/src/components/activity/ActivityFeed.tsx
@@ -0,0 +1,47 @@
+import type { ReactNode } from "react";
+
+type FeedItem = {
+ id: string;
+};
+
+type ActivityFeedProps = {
+ isLoading: boolean;
+ errorMessage?: string | null;
+ items: TItem[];
+ renderItem: (item: TItem) => ReactNode;
+};
+
+export function ActivityFeed({
+ isLoading,
+ errorMessage,
+ items,
+ renderItem,
+}: ActivityFeedProps) {
+ if (isLoading && items.length === 0) {
+ return Loading feed…
;
+ }
+
+ const hasError = errorMessage !== null && errorMessage !== undefined;
+ if (hasError) {
+ return (
+
+ {errorMessage || "Unable to load feed."}
+
+ );
+ }
+
+ if (items.length === 0) {
+ return (
+
+
+ Waiting for new comments…
+
+
+ When agents post updates, they will show up here.
+
+
+ );
+ }
+
+ return {items.map(renderItem)}
;
+}
diff --git a/frontend/src/lib/backoff.test.ts b/frontend/src/lib/backoff.test.ts
index 9fbba8bc..7248d00b 100644
--- a/frontend/src/lib/backoff.test.ts
+++ b/frontend/src/lib/backoff.test.ts
@@ -1,61 +1,67 @@
-import { describe, expect, it, vi } from "vitest";
-
import { createExponentialBackoff } from "./backoff";
describe("createExponentialBackoff", () => {
- it("increments attempt and clamps delay", () => {
- vi.spyOn(Math, "random").mockReturnValue(0);
+ it("uses default options", () => {
+ const backoff = createExponentialBackoff();
+ expect(backoff.attempt()).toBe(0);
+ const delay = backoff.nextDelayMs();
+ expect(delay).toBeGreaterThanOrEqual(50);
+ expect(backoff.attempt()).toBe(1);
+ });
+ it("clamps invalid base/max and increments attempt", () => {
const backoff = createExponentialBackoff({
- baseMs: 100,
+ baseMs: Number.NaN,
+ maxMs: Number.POSITIVE_INFINITY,
factor: 2,
- maxMs: 250,
jitter: 0,
});
expect(backoff.attempt()).toBe(0);
- expect(backoff.nextDelayMs()).toBe(100);
+ const d1 = backoff.nextDelayMs();
+ // baseMs clamps to min 50
+ expect(d1).toBe(50);
expect(backoff.attempt()).toBe(1);
- expect(backoff.nextDelayMs()).toBe(200);
- expect(backoff.nextDelayMs()).toBe(250); // capped
- });
- it("clamps invalid numeric options and treats negative jitter as zero", () => {
- vi.spyOn(Math, "random").mockReturnValue(0.9999);
-
- // baseMs: NaN should clamp to min (50)
- // maxMs: Infinity should clamp to min (= baseMs)
- // jitter: negative -> treated as 0 (no extra delay)
- const backoff = createExponentialBackoff({
- baseMs: Number.NaN,
- maxMs: Number.POSITIVE_INFINITY,
- jitter: -1,
- });
-
- // With maxMs clamped to baseMs, delay will always be baseMs
- expect(backoff.nextDelayMs()).toBe(50);
- expect(backoff.nextDelayMs()).toBe(50);
- });
-
- it("reset brings attempt back to zero", () => {
- vi.spyOn(Math, "random").mockReturnValue(0);
-
- const backoff = createExponentialBackoff({ baseMs: 100, jitter: 0 });
- backoff.nextDelayMs();
- expect(backoff.attempt()).toBe(1);
+ const d2 = backoff.nextDelayMs();
+ // maxMs=+Inf is treated as invalid and clamped to baseMs, so it will cap immediately.
+ expect(d2).toBe(50);
+ expect(backoff.attempt()).toBe(2);
backoff.reset();
expect(backoff.attempt()).toBe(0);
});
- it("uses defaults when options are omitted", () => {
- vi.spyOn(Math, "random").mockReturnValue(0);
+ it("applies positive jitter (extra delay only)", () => {
+ const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5);
- const backoff = createExponentialBackoff();
- expect(backoff.attempt()).toBe(0);
+ const backoff = createExponentialBackoff({
+ baseMs: 1000,
+ factor: 2,
+ maxMs: 2000,
+ jitter: 0.2,
+ });
- // Default baseMs is 1000 (clamped within bounds), jitter default is 0.2.
- // With Math.random=0, delay should be the normalized base (1000).
- expect(backoff.nextDelayMs()).toBe(1000);
+ // attempt=0 => normalized=1000 => delay = 1000 + floor(0.5*0.2*1000)=1100
+ expect(backoff.nextDelayMs()).toBe(1100);
+
+ // attempt=1 => raw=2000 capped=2000 => delay=2000 + floor(0.5*0.2*2000)=2200 but clamped to maxMs (2000)
+ expect(backoff.nextDelayMs()).toBe(2000);
+
+ randomSpy.mockRestore();
+ });
+
+ it("treats negative jitter as zero", () => {
+ const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.999);
+
+ const backoff = createExponentialBackoff({
+ baseMs: 100,
+ factor: 2,
+ maxMs: 1000,
+ jitter: -1,
+ });
+
+ expect(backoff.nextDelayMs()).toBe(100);
+ randomSpy.mockRestore();
});
});
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index 19c51c83..d72d1fbf 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -20,7 +20,8 @@
],
"paths": {
"@/*": ["./src/*"]
- }
+ },
+ "types": ["vitest/globals", "@testing-library/jest-dom/vitest"]
},
"include": [
"next-env.d.ts",
diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts
index be2897a6..6cb3a2b9 100644
--- a/frontend/vitest.config.ts
+++ b/frontend/vitest.config.ts
@@ -18,7 +18,7 @@ export default defineConfig({
reportsDirectory: "./coverage",
// Policy (scoped gate): require 100% coverage on *explicitly listed* unit-testable modules first.
// We'll expand this include list as we add more unit/component tests.
- include: ["src/lib/backoff.ts"],
+ include: ["src/lib/backoff.ts", "src/components/activity/ActivityFeed.tsx"],
exclude: ["**/*.d.ts", "src/**/__generated__/**", "src/**/generated/**"],
thresholds: {
lines: 100,