From 2efc2f9542939821e2cad261ff5f16804f3bf830 Mon Sep 17 00:00:00 2001 From: Renn F Date: Wed, 15 Jul 2026 16:03:11 +0200 Subject: [PATCH] fix(panel): compact task-status tiles beside the donut; journal ids copyable + task-linked Five near-empty stat cards become a 2x3 tile band sharing the row with the status donut. Journal entry cards and the entry page render full ids with the shared CopyButton and a /tasks quick-link badge, with the entry-card anchor restructured so links no longer nest. --- .../[entryId]/__tests__/page.test.tsx | 81 +++++++++++++++++++ .../(dashboard)/journals/[entryId]/page.tsx | 29 ++++--- panel/src/app/(dashboard)/metrics/page.tsx | 59 +++++++------- .../journals/__tests__/entry-card.test.tsx | 62 ++++++++++++++ panel/src/components/journals/entry-card.tsx | 81 +++++++++++-------- .../__tests__/task-status-tiles.test.tsx | 37 +++++++++ panel/src/components/metrics/index.ts | 2 + .../components/metrics/task-status-tiles.tsx | 36 +++++++++ 8 files changed, 318 insertions(+), 69 deletions(-) create mode 100644 panel/src/app/(dashboard)/journals/[entryId]/__tests__/page.test.tsx create mode 100644 panel/src/components/journals/__tests__/entry-card.test.tsx create mode 100644 panel/src/components/metrics/__tests__/task-status-tiles.test.tsx create mode 100644 panel/src/components/metrics/task-status-tiles.tsx diff --git a/panel/src/app/(dashboard)/journals/[entryId]/__tests__/page.test.tsx b/panel/src/app/(dashboard)/journals/[entryId]/__tests__/page.test.tsx new file mode 100644 index 00000000..2159c2f1 --- /dev/null +++ b/panel/src/app/(dashboard)/journals/[entryId]/__tests__/page.test.tsx @@ -0,0 +1,81 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, act } from "@testing-library/react"; +import { Suspense } from "react"; +import { JournalEntryType, type JournalEntry } from "@/types"; + +// CEO feedback: the journal/task ids on the entry detail page were shown +// truncated with "..." and had no copy button. Guards the fix: no ellipsis, +// a copy button for the full id on both the journal id and the task id, and +// the existing task quick-link stays intact. + +vi.mock("@/hooks/use-journals", () => ({ + useJournalEntry: vi.fn(), +})); + +vi.mock("@/hooks", () => ({ + usePageRefresh: () => ({ register: vi.fn(), unregister: vi.fn() }), +})); + +import { useJournalEntry } from "@/hooks/use-journals"; +import JournalEntryPage from "../page"; + +const entry: JournalEntry = { + id: "entry-1", + journal_id: "a1b2c3d4-3333-4444-5555-666677778888", + 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, +}; + +async function renderPage() { + await act(async () => { + render( + + + , + ); + }); +} + +describe("JournalEntryPage — id display", () => { + it("shows the journal id8 without an ellipsis and with a copy button", async () => { + vi.mocked(useJournalEntry).mockReturnValue({ + data: entry, + isLoading: false, + error: undefined, + refetch: vi.fn(), + } as unknown as ReturnType); + + await renderPage(); + + expect(await screen.findByText("a1b2c3d4")).toBeInTheDocument(); + expect(screen.queryByText(/a1b2c3d4\.\.\./)).not.toBeInTheDocument(); + // Two copy buttons: journal id + task id. + expect(screen.getAllByRole("button", { name: /copy/i })).toHaveLength(2); + }); + + it("keeps the task quick-link and adds a copy button for the full task id", async () => { + vi.mocked(useJournalEntry).mockReturnValue({ + data: entry, + isLoading: false, + error: undefined, + refetch: vi.fn(), + } as unknown as ReturnType); + + await renderPage(); + + const taskLink = (await screen.findByText("Task #e27ef84d")).closest("a"); + expect(taskLink).toHaveAttribute( + "href", + "/tasks/e27ef84d-1111-2222-3333-444455556666", + ); + }); +}); diff --git a/panel/src/app/(dashboard)/journals/[entryId]/page.tsx b/panel/src/app/(dashboard)/journals/[entryId]/page.tsx index 8f9e1a57..a7732342 100644 --- a/panel/src/app/(dashboard)/journals/[entryId]/page.tsx +++ b/panel/src/app/(dashboard)/journals/[entryId]/page.tsx @@ -14,6 +14,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { Markdown } from "@/components/ui/markdown"; import { EntryTypeBadge } from "@/components/journals/entry-type-badge"; +import { CopyButton } from "@/components/ui/copy-button"; import { ArrowLeft, AlertTriangle, @@ -148,7 +149,13 @@ export default function JournalEntryPage({ params }: JournalEntryPageProps) { Journal -

{entry.journal_id.slice(0, 8)}...

+

+ {entry.journal_id.slice(0, 8)} + +

@@ -159,14 +166,18 @@ export default function JournalEntryPage({ params }: JournalEntryPageProps) { Related Task - - - Task #{entry.task_id.slice(0, 8)} - - +
+ + + Task #{entry.task_id.slice(0, 8)} + + + +
)} diff --git a/panel/src/app/(dashboard)/metrics/page.tsx b/panel/src/app/(dashboard)/metrics/page.tsx index cae85f9d..88323db0 100644 --- a/panel/src/app/(dashboard)/metrics/page.tsx +++ b/panel/src/app/(dashboard)/metrics/page.tsx @@ -39,6 +39,7 @@ import { AgentUsageChart, TeamUsageChart, TaskStatusChart, + TaskStatusTiles, SessionsTable, } from "@/components/metrics"; import { @@ -330,34 +331,36 @@ function PerformanceTabContent() { {/* Task Status */}

Task Status

-
-
- } - /> - } - /> - } - /> - } - /> - } - /> -
+
+ , + }, + { + 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}
+
+ ))} +
+ ); +}