From c07cb9a33da16d3ec2717a5726119b95acd42841 Mon Sep 17 00:00:00 2001 From: Steve Santacroce Date: Wed, 1 Apr 2026 18:42:29 -0400 Subject: [PATCH] Improve request log error details UX - add visible request detail access from error rows - show full request metadata and untruncated error text in dialog - add compact copy actions for request id and full error - refine dialog layout to reduce wasted space and improve scanability - update dev-local compose for hot-reloading frontend/backend work --- frontend/src/components/copy-button.test.tsx | 19 ++- frontend/src/components/copy-button.tsx | 16 ++- .../components/recent-requests-table.test.tsx | 92 +++++++++++-- .../components/recent-requests-table.tsx | 122 ++++++++++++++---- .../proposal.md | 20 +++ .../specs/frontend-architecture/spec.md | 46 +++++++ .../tasks.md | 16 +++ 7 files changed, 294 insertions(+), 37 deletions(-) create mode 100644 openspec/changes/improve-request-log-error-details/proposal.md create mode 100644 openspec/changes/improve-request-log-error-details/specs/frontend-architecture/spec.md create mode 100644 openspec/changes/improve-request-log-error-details/tasks.md diff --git a/frontend/src/components/copy-button.test.tsx b/frontend/src/components/copy-button.test.tsx index baa0c7fc..c20d2fd8 100644 --- a/frontend/src/components/copy-button.test.tsx +++ b/frontend/src/components/copy-button.test.tsx @@ -41,7 +41,7 @@ describe("CopyButton", () => { expect(writeText).toHaveBeenCalledWith("secret-value"); expect(toastSuccess).toHaveBeenCalledWith("Copied to clipboard"); - expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Copy Copied" })).toBeInTheDocument(); act(() => { vi.advanceTimersByTime(1_200); @@ -64,4 +64,21 @@ describe("CopyButton", () => { expect(toastError).toHaveBeenCalledWith("Failed to copy"); }); + + it("supports icon-only copy buttons with accessible labeling", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + + render(); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Copy Request ID" })); + await Promise.resolve(); + }); + + expect(writeText).toHaveBeenCalledWith("secret-value"); + expect(screen.getByRole("button", { name: "Copy Request ID Copied" })).toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/copy-button.tsx b/frontend/src/components/copy-button.tsx index 9cec05de..ed756f33 100644 --- a/frontend/src/components/copy-button.tsx +++ b/frontend/src/components/copy-button.tsx @@ -7,9 +7,10 @@ import { Button } from "@/components/ui/button"; export type CopyButtonProps = { value: string; label?: string; + iconOnly?: boolean; }; -export function CopyButton({ value, label = "Copy" }: CopyButtonProps) { +export function CopyButton({ value, label = "Copy", iconOnly = false }: CopyButtonProps) { const [copied, setCopied] = useState(false); const handleCopy = async () => { @@ -24,9 +25,16 @@ export function CopyButton({ value, label = "Copy" }: CopyButtonProps) { }; return ( - ); } diff --git a/frontend/src/features/dashboard/components/recent-requests-table.test.tsx b/frontend/src/features/dashboard/components/recent-requests-table.test.tsx index c5246e1b..47dd2062 100644 --- a/frontend/src/features/dashboard/components/recent-requests-table.test.tsx +++ b/frontend/src/features/dashboard/components/recent-requests-table.test.tsx @@ -1,11 +1,22 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { RecentRequestsTable } from "@/features/dashboard/components/recent-requests-table"; const ISO = "2026-01-01T12:00:00+00:00"; +const { toastSuccess, toastError } = vi.hoisted(() => ({ + toastSuccess: vi.fn(), + toastError: vi.fn(), +})); + +vi.mock("sonner", () => ({ + toast: { + success: toastSuccess, + error: toastError, + }, +})); + const PAGINATION_PROPS = { total: 1, limit: 25, @@ -16,9 +27,19 @@ const PAGINATION_PROPS = { }; describe("RecentRequestsTable", () => { - it("renders rows with status badges and supports error expansion", async () => { - const user = userEvent.setup(); + beforeEach(() => { + toastSuccess.mockReset(); + toastError.mockReset(); + }); + + it("renders rows with status badges and supports request details and copy actions", async () => { const longError = "Rate limit reached while processing this request ".repeat(3); + const writeText = vi.fn().mockResolvedValue(undefined); + + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); render( { expect(screen.getByText("Requested priority")).toBeInTheDocument(); expect(screen.getByText("WS")).toBeInTheDocument(); expect(screen.getByText("Rate limit")).toBeInTheDocument(); + expect(screen.getByText("rate_limit_exceeded")).toBeInTheDocument(); - const viewButton = screen.getByRole("button", { name: "View" }); - await user.click(viewButton); + const viewButton = screen.getByRole("button", { name: "View Details" }); + fireEvent.click(viewButton); const dialog = screen.getByRole("dialog"); expect(dialog).toBeInTheDocument(); - expect(screen.getByText("Error Detail")).toBeInTheDocument(); + expect(screen.getByText("Request Details")).toBeInTheDocument(); + expect(screen.getByText("req-1")).toBeInTheDocument(); + expect(screen.getAllByText("rate_limit_exceeded")[0]).toBeInTheDocument(); expect(dialog.textContent).toContain("Rate limit reached while processing this request"); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Copy Request ID" })); + await Promise.resolve(); + }); + + expect(writeText).toHaveBeenCalledWith("req-1"); + expect(toastSuccess).toHaveBeenCalledWith("Copied to clipboard"); + expect(screen.getByRole("button", { name: "Copy Request ID Copied" })).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Copy Error" })); + await Promise.resolve(); + }); + + expect(writeText).toHaveBeenCalledWith(longError); }); it("renders empty state", () => { @@ -108,4 +148,40 @@ describe("RecentRequestsTable", () => { expect(screen.getAllByText("--")[0]).toBeInTheDocument(); }); + + it("shows details action for error-code-only rows", async () => { + render( + , + ); + + expect(screen.getAllByText("upstream_error")[0]).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "View Details" })); + + expect(screen.getByRole("dialog")).toHaveTextContent("upstream_error"); + expect(screen.getByRole("dialog")).toHaveTextContent("Full Error"); + }); }); diff --git a/frontend/src/features/dashboard/components/recent-requests-table.tsx b/frontend/src/features/dashboard/components/recent-requests-table.tsx index 87cb137f..6c2abec8 100644 --- a/frontend/src/features/dashboard/components/recent-requests-table.tsx +++ b/frontend/src/features/dashboard/components/recent-requests-table.tsx @@ -2,6 +2,7 @@ import { Inbox } from "lucide-react"; import { useMemo, useState } from "react"; import { isEmailLabel } from "@/components/blur-email"; +import { CopyButton } from "@/components/copy-button"; import { usePrivacyStore } from "@/hooks/use-privacy"; import { EmptyState } from "@/components/empty-state"; import { Badge } from "@/components/ui/badge"; @@ -70,7 +71,7 @@ export function RecentRequestsTable({ onLimitChange, onOffsetChange, }: RecentRequestsTableProps) { - const [viewingError, setViewingError] = useState(null); + const [selectedRequest, setSelectedRequest] = useState(null); const blurred = usePrivacyStore((s) => s.blurred); const accountLabelMap = useMemo(() => { @@ -107,7 +108,7 @@ export function RecentRequestsTable({
- +
Time @@ -118,7 +119,7 @@ export function RecentRequestsTable({ Status Tokens Cost - Error + Error @@ -126,8 +127,8 @@ export function RecentRequestsTable({ const time = formatTimeLong(request.requestedAt); const accountLabel = request.accountId ? (accountLabelMap.get(request.accountId) ?? request.accountId) : "—"; const isEmailLabel = !!(request.accountId && emailLabelIds.has(request.accountId)); - const errorMessage = request.errorMessage || request.errorCode || "-"; - const hasLongError = errorMessage !== "-" && errorMessage.length > 72; + const errorPreview = request.errorMessage || request.errorCode || "-"; + const hasError = !!(request.errorCode || request.errorMessage); const visibleServiceTier = request.actualServiceTier ?? request.serviceTier; const showRequestedTier = !!request.requestedServiceTier && request.requestedServiceTier !== visibleServiceTier; @@ -195,23 +196,32 @@ export function RecentRequestsTable({ {formatCurrency(request.costUsd)} - -
-

- {errorMessage} -

- {hasLongError ? ( + + {hasError ? ( +
+ {request.errorCode ? ( +
+ + {request.errorCode} + +
+ ) : null} +

+ {errorPreview} +

- ) : null} -
+
+ ) : ( + - + )}
); @@ -232,16 +242,44 @@ export function RecentRequestsTable({ /> - { if (!open) setViewingError(null); }}> - + { if (!open) setSelectedRequest(null); }}> + - Error Detail - Full error message from the request. + Request Details + Inspect request metadata and copy the fields you need. -
-

- {viewingError} -

+
+
+ +
+ + + + + +
+
+ +
+
+

Full Error

+ {selectedRequest?.errorMessage ? ( + + ) : null} +
+
+

+ {selectedRequest?.errorMessage ?? selectedRequest?.errorCode ?? "No error detail recorded."} +

+
+
@@ -249,3 +287,39 @@ export function RecentRequestsTable({
); } + +type RequestDetailFieldProps = { + label: string; + value: string; + mono?: boolean; + copyValue?: string; + copyLabel?: string; + compactCopy?: boolean; +}; + +function RequestDetailField({ + label, + value, + mono = false, + copyValue, + copyLabel = "Copy", + compactCopy = false, +}: RequestDetailFieldProps) { + return ( +
+
+
+ {label} +
+ {copyValue ? ( + + ) : null} +
+
+

+ {value} +

+
+
+ ); +} diff --git a/openspec/changes/improve-request-log-error-details/proposal.md b/openspec/changes/improve-request-log-error-details/proposal.md new file mode 100644 index 00000000..223f6019 --- /dev/null +++ b/openspec/changes/improve-request-log-error-details/proposal.md @@ -0,0 +1,20 @@ +## Why + +The dashboard request logs table currently exposes error information in a way that is difficult to use during debugging. Long error messages are truncated into a narrow table cell, operators cannot reliably discover that more detail exists, and the UI does not provide a direct way to copy the error text or request identifier for follow-up investigation. + +Request logs are an operator workflow, not decorative telemetry. When a request fails, the dashboard should make the failure easy to inspect, distinguish, and copy without forcing users to leave the page or guess at hidden affordances. + +## What Changes + +- Improve the dashboard request logs interaction model so error rows always expose a visible path to full request details. +- Add a request-detail surface for request-log rows that shows the full error code and error message alongside existing request metadata. +- Add copy actions for high-value debugging fields such as request id and full error text. +- Replace the current single-line error truncation with a richer preview that remains scannable in the table while preserving density. +- Preserve existing request-log filtering and pagination behavior while adding detail interactions. + +## Impact + +- Specs: `openspec/specs/frontend-architecture/spec.md` +- Frontend: dashboard recent-requests table, request-detail interaction state, copy affordances, accessibility and keyboard flow +- Backend: no API contract change required if the existing request-log payload remains sufficient +- Tests: frontend interaction coverage for request details, copy actions, and preview behavior diff --git a/openspec/changes/improve-request-log-error-details/specs/frontend-architecture/spec.md b/openspec/changes/improve-request-log-error-details/specs/frontend-architecture/spec.md new file mode 100644 index 00000000..cf02e3ab --- /dev/null +++ b/openspec/changes/improve-request-log-error-details/specs/frontend-architecture/spec.md @@ -0,0 +1,46 @@ +## ADDED Requirements + +### Requirement: Request log error previews remain recognizable in the dashboard + +The Dashboard recent requests table MUST show a compact but recognizable preview for request-log errors without relying on a single-line truncation that hides the nature of the failure. For rows with an error code or error message, the table MUST expose a visible detail affordance directly in the row. + +#### Scenario: Error row shows a recognizable preview + +- **WHEN** `/api/request-logs` returns a row with a non-empty `errorMessage` +- **THEN** the recent requests table shows a compact preview that preserves enough text for the operator to recognize the failure category +- **AND** the row includes a visible action that opens full request details + +#### Scenario: Error row without message still exposes details + +- **WHEN** `/api/request-logs` returns a row with `errorMessage = null` and a non-empty `errorCode` +- **THEN** the recent requests table shows the error code as the preview +- **AND** the row still includes the visible request-details action + +### Requirement: Request log details expose full failure context + +The dashboard MUST provide a request-details surface for request-log rows so operators can inspect full failure context without losing the surrounding request-log state. For failed rows, that surface MUST display the full request id, status, model, transport, error code, and full error message when present. + +#### Scenario: Operator opens request details for a failed row + +- **WHEN** the operator opens request details for a request-log row whose status is not `ok` +- **THEN** the dashboard shows a request-details surface containing the row's full request id, status, model, transport, error code, and full error message +- **AND** the full error text is visible without truncation + +#### Scenario: Opening details preserves table context + +- **WHEN** the operator opens and closes a request-details surface from the recent requests table +- **THEN** the dashboard preserves the current request-log filters, pagination, and scroll context + +### Requirement: Request log details support copy-oriented debugging workflow + +The request-details surface MUST support copying the most useful debugging identifiers directly from the UI. At minimum, it MUST provide copy actions for the request id and the full error text when an error is present. + +#### Scenario: Operator copies request id from request details + +- **WHEN** the operator activates the request id copy action in the request-details surface +- **THEN** the dashboard copies the full request id value without truncation or formatting changes + +#### Scenario: Operator copies full error text from request details + +- **WHEN** the operator activates the error copy action for a failed request row +- **THEN** the dashboard copies the full error text exactly as shown in the request-details surface diff --git a/openspec/changes/improve-request-log-error-details/tasks.md b/openspec/changes/improve-request-log-error-details/tasks.md new file mode 100644 index 00000000..e49c15a3 --- /dev/null +++ b/openspec/changes/improve-request-log-error-details/tasks.md @@ -0,0 +1,16 @@ +## 1. Spec + +- [x] 1.1 Add request-log error preview requirements to `frontend-architecture` +- [x] 1.2 Add request-log detail and copy-action requirements to `frontend-architecture` + +## 2. Frontend + +- [x] 2.1 Replace the current hidden long-error affordance with a consistently visible request-details action for error rows +- [x] 2.2 Add a request-details dialog or drawer that shows full request metadata, error code, and error message +- [x] 2.3 Add copy actions for request id and full error text within the request-details surface +- [x] 2.4 Update the error cell preview so long messages remain recognizable without dominating table layout + +## 3. Tests + +- [x] 3.1 Add frontend tests covering request-details access from the recent-requests table +- [x] 3.2 Add frontend tests covering full error visibility and copy actions