+
,
+ },
+ {
+ label: "In Progress",
+ value: inProgress,
+ icon:
,
+ },
+ {
+ label: "Blocked",
+ value: blocked,
+ icon:
,
+ },
+ {
+ label: "Awaiting QA",
+ value: awaitingQa,
+ icon:
,
+ },
+ {
+ label: "Completed",
+ value: completed,
+ icon:
,
+ },
+ ]}
+ />
(invalid nested anchors).
+
+const baseEntry: JournalEntry = {
+ id: "entry-11112222-3333-4444-5555-666677778888",
+ journal_id: "journal-1",
+ type: JournalEntryType.GENERAL,
+ title: "Shipped the thing",
+ content: "Body",
+ task_id: "e27ef84d-1111-2222-3333-444455556666",
+ session_id: null,
+ timestamp: "2026-07-10T12:00:00Z",
+ tags: [],
+ sentiment: null,
+ is_private: false,
+ created_at: "2026-07-10T12:00:00Z",
+ updated_at: null,
+};
+
+describe("EntryCard — task id display", () => {
+ it("shows the id8 prefix without an ellipsis", () => {
+ render();
+ expect(screen.getByText("Task #e27ef84d")).toBeInTheDocument();
+ expect(screen.queryByText(/e27ef84d\.\.\./)).not.toBeInTheDocument();
+ });
+
+ it("links the task badge to the task detail page", () => {
+ render();
+ const taskLink = screen.getByText("Task #e27ef84d").closest("a");
+ expect(taskLink).toHaveAttribute(
+ "href",
+ "/tasks/e27ef84d-1111-2222-3333-444455556666",
+ );
+ });
+
+ it("renders a copy button for the full task id, separate from the task link", () => {
+ render();
+ const copyButton = screen.getByRole("button", { name: /copy/i });
+ expect(copyButton.closest("a")).toBeNull();
+ });
+
+ it("links the card body to the entry detail page", () => {
+ render();
+ const entryLink = screen.getByText("Shipped the thing").closest("a");
+ expect(entryLink).toHaveAttribute(
+ "href",
+ "/journals/entry-11112222-3333-4444-5555-666677778888",
+ );
+ });
+
+ it("omits the task row entirely when there is no related task", () => {
+ render();
+ expect(screen.queryByText(/^Task #/)).not.toBeInTheDocument();
+ });
+});
diff --git a/panel/src/components/journals/entry-card.tsx b/panel/src/components/journals/entry-card.tsx
index e31a357c..beca59bc 100644
--- a/panel/src/components/journals/entry-card.tsx
+++ b/panel/src/components/journals/entry-card.tsx
@@ -4,6 +4,7 @@ import { JournalEntry } from "@/types";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Markdown } from "@/components/ui/markdown";
+import { CopyButton } from "@/components/ui/copy-button";
import { EntryTypeBadge } from "./entry-type-badge";
import { Clock, Tag, Link2, ChevronRight } from "lucide-react";
import Link from "next/link";
@@ -24,9 +25,16 @@ function formatTime(timestamp: string): string {
export function EntryCard({ entry }: EntryCardProps) {
return (
-
-
-
+
+
+ {/* Clicking the header/title/content opens the entry. The footer's
+ task badge + copy button are deliberately outside this link so
+ they aren't nested anchors and don't trigger entry navigation. */}
+
{/* Header */}
@@ -53,36 +61,45 @@ export function EntryCard({ entry }: EntryCardProps) {
{entry.content}
+
- {/* Footer */}
-
- {/* Tags */}
- {entry.tags.length > 0 && (
-
-
- {entry.tags.slice(0, 3).map((tag) => (
-
- {tag}
-
- ))}
- {entry.tags.length > 3 && (
-
- +{entry.tags.length - 3}
-
- )}
-
- )}
+ {/* Footer */}
+
+ {/* Tags */}
+ {entry.tags.length > 0 && (
+
+
+ {entry.tags.slice(0, 3).map((tag) => (
+
+ {tag}
+
+ ))}
+ {entry.tags.length > 3 && (
+
+ +{entry.tags.length - 3}
+
+ )}
+
+ )}
- {/* Related Task */}
- {entry.task_id && (
-
-
- Task #{entry.task_id.slice(0, 8)}
-
- )}
-
-
-
-
+ {/* Related Task — quick-link + copy full id */}
+ {entry.task_id && (
+
+
+
+
+ Task #{entry.task_id.slice(0, 8)}
+
+
+
+
+ )}
+
+
+
);
}
diff --git a/panel/src/components/metrics/__tests__/task-status-tiles.test.tsx b/panel/src/components/metrics/__tests__/task-status-tiles.test.tsx
new file mode 100644
index 00000000..23e818ad
--- /dev/null
+++ b/panel/src/components/metrics/__tests__/task-status-tiles.test.tsx
@@ -0,0 +1,37 @@
+import { describe, it, expect } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { TaskStatusTiles } from "../task-status-tiles";
+
+describe("TaskStatusTiles", () => {
+ it("renders a tile per status with its label and value", () => {
+ render(
+
icon },
+ { label: "Completed", value: 12, icon: icon },
+ ]}
+ />,
+ );
+ expect(screen.getByText("Pending")).toBeInTheDocument();
+ expect(screen.getByText("3")).toBeInTheDocument();
+ expect(screen.getByText("Completed")).toBeInTheDocument();
+ expect(screen.getByText("12")).toBeInTheDocument();
+ });
+
+ it("renders one tile for every entry passed", () => {
+ render(
+ icon },
+ { label: "B", value: 2, icon: icon },
+ { label: "C", value: 3, icon: icon },
+ { label: "D", value: 4, icon: icon },
+ { label: "E", value: 5, icon: icon },
+ ]}
+ />,
+ );
+ ["A", "B", "C", "D", "E"].forEach((label) => {
+ expect(screen.getByText(label)).toBeInTheDocument();
+ });
+ });
+});
diff --git a/panel/src/components/metrics/index.ts b/panel/src/components/metrics/index.ts
index e2b75b9a..5ee982fd 100644
--- a/panel/src/components/metrics/index.ts
+++ b/panel/src/components/metrics/index.ts
@@ -3,4 +3,6 @@ export { ModelUsageDonut } from "./model-usage-donut";
export { AgentUsageChart } from "./agent-usage-chart";
export { TeamUsageChart } from "./team-usage-chart";
export { TaskStatusChart } from "./task-status-chart";
+export { TaskStatusTiles } from "./task-status-tiles";
+export type { TaskStatusTileData } from "./task-status-tiles";
export { SessionsTable } from "./sessions-table";
diff --git a/panel/src/components/metrics/task-status-tiles.tsx b/panel/src/components/metrics/task-status-tiles.tsx
new file mode 100644
index 00000000..848e0e74
--- /dev/null
+++ b/panel/src/components/metrics/task-status-tiles.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import { Card } from "@/components/ui/card";
+import { cn } from "@/lib/utils";
+
+export interface TaskStatusTileData {
+ label: string;
+ value: number;
+ icon: React.ReactNode;
+}
+
+interface TaskStatusTilesProps {
+ tiles: TaskStatusTileData[];
+ className?: string;
+}
+
+/**
+ * Compact grid of task-status counts (label + number + status-colored icon
+ * per tile) — replaces five full-size stat cards that sat mostly empty next
+ * to the status donut (CEO feedback: "much smaller cards 2x3 or something").
+ */
+export function TaskStatusTiles({ tiles, className }: TaskStatusTilesProps) {
+ return (
+
+ {tiles.map((tile) => (
+
+
+ {tile.icon}
+ {tile.label}
+
+ {tile.value}
+
+ ))}
+
+ );
+}