From 4e90305aee43c741c7c4422d3dd2e422b1ea313f Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:08:59 +0000 Subject: [PATCH 1/5] feat(dashboard): Make the event details header easier to read Show a readable UTC timestamp and the event sequence number, and keep the step controls beside close so they do not move with the event type width. Co-Authored-By: adm --- .../e2e/conversations-event-log.spec.ts | 5 ++++ .../src/client/components/DetailsPanel.tsx | 25 +++++++++---------- .../src/client/components/README.md | 3 ++- .../ConversationEventDetails.tsx | 19 ++++++++------ .../src/client/conversations/README.md | 6 +++-- .../src/client/conversations/eventTime.ts | 2 +- 6 files changed, 36 insertions(+), 24 deletions(-) diff --git a/packages/junior-dashboard/e2e/conversations-event-log.spec.ts b/packages/junior-dashboard/e2e/conversations-event-log.spec.ts index c7538236d1..d793cb4f73 100644 --- a/packages/junior-dashboard/e2e/conversations-event-log.spec.ts +++ b/packages/junior-dashboard/e2e/conversations-event-log.spec.ts @@ -166,7 +166,12 @@ test("inspects all reporting events and searches full event data", async ({ await secondToggle.click(); await expect(localTime).toHaveAttribute("aria-pressed", "false"); await expect(entry.getByText("11:54:09.000", { exact: true })).toBeVisible(); + // UTC mode reads like local mode. The original ISO value stays available. await expect(panel.locator("header time")).toHaveText( + "Aug 7, 2026, 11:54:09.000 UTC", + ); + await expect(panel.locator("header time")).toHaveAttribute( + "datetime", report.events.find((event: { seq: number }) => event.seq === 3).createdAt, ); await secondTab.close(); diff --git a/packages/junior-dashboard/src/client/components/DetailsPanel.tsx b/packages/junior-dashboard/src/client/components/DetailsPanel.tsx index 0f33a257f3..72bc6b398d 100644 --- a/packages/junior-dashboard/src/client/components/DetailsPanel.tsx +++ b/packages/junior-dashboard/src/client/components/DetailsPanel.tsx @@ -110,20 +110,19 @@ export function DetailsPanel(props: { className="grid min-h-0 min-w-0 grid-rows-[auto_minmax(0,1fr)] border-l border-dashboard-border bg-dashboard-bg-elevated" >
-
-
{props.title}
- {props.actions ? ( -
{props.actions}
- ) : null} +
{props.title}
+ {/* Keep actions beside close, so they do not move with the title width. */} +
+ {props.actions} +
-
{props.children} diff --git a/packages/junior-dashboard/src/client/components/README.md b/packages/junior-dashboard/src/client/components/README.md index 9eb0bbbb91..f719fb659c 100644 --- a/packages/junior-dashboard/src/client/components/README.md +++ b/packages/junior-dashboard/src/client/components/README.md @@ -149,7 +149,8 @@ Shared class strings live in `src/client/styles.ts`. child. Use `ui/accordion` for a set of sections with headings. - **Details.** `DetailsPanel` docks an aside at 1280px and above, and uses a `BottomSheet` below that width. It owns Escape and the scroll frame for - docked panels. + docked panels. Docked actions sit beside the close control, so their + position does not change with the title width. - **Identity.** `ObjectIcon` owns the native Ticket, Code change, Automation, Deployment, and Item icons. Do not replace it with a provider logo or a status-only icon. diff --git a/packages/junior-dashboard/src/client/conversations/ConversationEventDetails.tsx b/packages/junior-dashboard/src/client/conversations/ConversationEventDetails.tsx index ff87664745..d65eb57c25 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationEventDetails.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationEventDetails.tsx @@ -30,14 +30,19 @@ export function ConversationEventDetails(props: { }) { const { conversation, event, onSelect } = props; const time = useEventTime(); + // The sequence number matches the `event` link parameter and the row label. const timestamp = event ? ( - +

+ #{event.seq} + + +

) : null; const title = event ? ( <> diff --git a/packages/junior-dashboard/src/client/conversations/README.md b/packages/junior-dashboard/src/client/conversations/README.md index 98cc9a10bd..efb711a6da 100644 --- a/packages/junior-dashboard/src/client/conversations/README.md +++ b/packages/junior-dashboard/src/client/conversations/README.md @@ -114,7 +114,8 @@ The timezone toggle uses `useEventTime` to share a UTC/local preference through `@uidotdev/usehooks` and browser local storage. It applies to event row clocks and detail timestamps. Local mode follows the browser timezone. The button uses a generic zone name because a log can span standard and daylight-saving time. -Detail timestamps use the zone name for each event date. The mode, not +Detail timestamps use a readable date and the zone name for each event date, +in both modes. Their tooltip shows the original ISO value. The mode, not the timezone identifier, persists across visits and tabs. This hook belongs to the client-rendered event viewer. Raw JSON retains the original ISO values. @@ -128,7 +129,8 @@ Closing returns focus to that row. Switching Conversations removes the panel. The previous and next controls select the adjacent visible row. They follow the event search, like Up and Down. They are necessary below 1280px, where the sheet covers the log. At the ends of the list, the control is disabled but -keeps focus. The sheet title is the event type. +keeps focus. The sheet title is the event type. The header shows the event +type, then the sequence number and timestamp. A linked event can be older than the loaded pages. While the event loads, the panel stays open. Only its header shows placeholders, so it does not compete diff --git a/packages/junior-dashboard/src/client/conversations/eventTime.ts b/packages/junior-dashboard/src/client/conversations/eventTime.ts index 4fc5f6ef0a..fe348446f8 100644 --- a/packages/junior-dashboard/src/client/conversations/eventTime.ts +++ b/packages/junior-dashboard/src/client/conversations/eventTime.ts @@ -54,7 +54,7 @@ export function useEventTime() { return formatters.clock.format(new Date(value)); }, formatTimestamp(value: string) { - return local ? formatters.timestamp.format(new Date(value)) : value; + return formatters.timestamp.format(new Date(value)); }, }; } From be2002444c5766401414bc836ea31c45de846c9c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:22:53 +0000 Subject: [PATCH 2/5] feat(dashboard): Stack nested drawers and show the subagent event log edge to edge Move the side Drawer to the shadcn Base UI drawer, so event details opened from a subagent drawer stack in front of it. Remove the body inset for the subagent event log, so rows meet the drawer edges and the sticky toolbar has no gap above it. Co-Authored-By: adm --- .../src/client/components/Drawer.tsx | 69 ++++++--- .../src/client/components/README.md | 7 +- .../src/client/components/ui/drawer.tsx | 140 ++++++++++++++++++ .../src/client/components/ui/useSheet.ts | 6 +- .../src/client/conversations/README.md | 4 +- .../SubagentTranscriptDrawer.tsx | 2 + 6 files changed, 202 insertions(+), 26 deletions(-) create mode 100644 packages/junior-dashboard/src/client/components/ui/drawer.tsx diff --git a/packages/junior-dashboard/src/client/components/Drawer.tsx b/packages/junior-dashboard/src/client/components/Drawer.tsx index d4e16ec585..17cc18f340 100644 --- a/packages/junior-dashboard/src/client/components/Drawer.tsx +++ b/packages/junior-dashboard/src/client/components/Drawer.tsx @@ -1,20 +1,31 @@ +import { X } from "lucide-react"; import { useRef, type ReactNode } from "react"; import { cn } from "../styles"; -import { Sheet, SheetContent, SheetHeader } from "./ui/sheet"; +import { Button } from "./ui/button"; +import { + Drawer as DrawerRoot, + DrawerClose, + DrawerContent as DrawerPopup, +} from "./ui/drawer"; import { useSheet } from "./ui/useSheet"; type DrawerProps = { actions?: ReactNode; children: ReactNode; closeLabel: string; + /** Remove the body inset, so content such as the event log meets the edges. */ + flush?: boolean; header: ReactNode; onClose(): void; openKey: string; width?: "default" | "wide" | "narrow"; } & ({ titleId: string; label?: never } | { label: string; titleId?: never }); -/** Render a side drawer with shared modal focus and dismissal behavior. */ +/** + * Render a side drawer with shared modal focus and dismissal behavior. + * A drawer opened from inside another drawer stacks in front of it. + */ export function Drawer(props: DrawerProps) { return ; } @@ -23,37 +34,49 @@ function DrawerContent(props: DrawerProps) { const closeRef = useRef(null); const sheet = useSheet(props.onClose, closeRef); return ( - - + - +
{props.header}
- {props.actions ? ( -
{props.actions}
- ) : null} - -
+
+ {props.actions} + + } + > + +
+
+
{props.children}
-
-
+ + ); } diff --git a/packages/junior-dashboard/src/client/components/README.md b/packages/junior-dashboard/src/client/components/README.md index f719fb659c..6bc9b37130 100644 --- a/packages/junior-dashboard/src/client/components/README.md +++ b/packages/junior-dashboard/src/client/components/README.md @@ -32,7 +32,8 @@ file when every caller needs the same style or variant. App components compose | App component | shadcn source | | ------------------------------ | ----------------- | -| `Drawer`, `BottomSheet` | `ui/sheet` | +| `Drawer` | `ui/drawer` | +| `BottomSheet` | `ui/sheet` | | `layout/DashboardHeader` (nav) | `ui/sheet` | | `ProfileMenu` (desktop) | `ui/popover` | | `ActorAvatar` | `ui/avatar` | @@ -151,6 +152,10 @@ Shared class strings live in `src/client/styles.ts`. `BottomSheet` below that width. It owns Escape and the scroll frame for docked panels. Docked actions sit beside the close control, so their position does not change with the title width. +- **Drawer.** `Drawer` is a right-side `ui/drawer`. A `Drawer` opened from + inside another `Drawer` stacks in front of it. The parent scales back, + shows a thin edge behind the child, and hides its content. Set `flush` when + the content owns its insets and sticky headers, such as the event log. - **Identity.** `ObjectIcon` owns the native Ticket, Code change, Automation, Deployment, and Item icons. Do not replace it with a provider logo or a status-only icon. diff --git a/packages/junior-dashboard/src/client/components/ui/drawer.tsx b/packages/junior-dashboard/src/client/components/ui/drawer.tsx new file mode 100644 index 0000000000..742bed4d45 --- /dev/null +++ b/packages/junior-dashboard/src/client/components/ui/drawer.tsx @@ -0,0 +1,140 @@ +import * as React from "react"; +import { Drawer as DrawerPrimitive } from "@base-ui/react/drawer"; +import { cn } from "@dashboard/lib/utils"; + +type DrawerContextProps = { + modal: DrawerPrimitive.Root.Props["modal"]; + swipeDirection: NonNullable; +}; + +const DrawerContext = React.createContext(null); + +function useDrawer() { + const context = React.useContext(DrawerContext); + + if (!context) { + throw new Error("useDrawer must be used within a Drawer."); + } + + return context; +} + +/** + * Own the open state, swipe dismissal, and nesting for a shadcn drawer. + * A drawer rendered inside another drawer stacks in front of it. + */ +function Drawer({ + modal = true, + swipeDirection = "down", + ...props +}: DrawerPrimitive.Root.Props) { + const contextValue = React.useMemo( + () => ({ modal, swipeDirection }), + [modal, swipeDirection], + ); + + return ( + + + + ); +} + +function DrawerPortal({ ...props }: DrawerPrimitive.Portal.Props) { + return ; +} + +/** Close the drawer through Base UI dismissal and focus handling. */ +function DrawerClose({ ...props }: DrawerPrimitive.Close.Props) { + return ; +} + +function DrawerOverlay({ + className, + ...props +}: DrawerPrimitive.Backdrop.Props) { + return ( + + ); +} + +/** + * Place the drawer on its swipe edge. While a nested drawer is open, this + * drawer scales back, peeks out behind it, and hides its content. + */ +function DrawerContent({ + className, + children, + ...props +}: DrawerPrimitive.Popup.Props) { + const { modal, swipeDirection } = useDrawer(); + const swipeAxis = + swipeDirection === "down" || swipeDirection === "up" ? "y" : "x"; + + return ( + + {/* App drawers trap focus without a modal root, so keep the overlay. + Base UI skips it for a nested drawer. */} + + + + + {children} + + + + + ); +} + +export { Drawer, DrawerClose, DrawerContent }; diff --git a/packages/junior-dashboard/src/client/components/ui/useSheet.ts b/packages/junior-dashboard/src/client/components/ui/useSheet.ts index 00f8f7f04a..a7203b748b 100644 --- a/packages/junior-dashboard/src/client/components/ui/useSheet.ts +++ b/packages/junior-dashboard/src/client/components/ui/useSheet.ts @@ -48,7 +48,11 @@ export function useSheet( open, modal: topmost ? "trap-focus" : false, disablePointerDismissal: !topmost, - onOpenChange(nextOpen, details) { + // Dialog and Drawer roots both use this handler, so read only `cancel`. + onOpenChange( + nextOpen: boolean, + details: Pick, + ) { if (!nextOpen && !topmost) { details.cancel(); return; diff --git a/packages/junior-dashboard/src/client/conversations/README.md b/packages/junior-dashboard/src/client/conversations/README.md index efb711a6da..9c0dff4b05 100644 --- a/packages/junior-dashboard/src/client/conversations/README.md +++ b/packages/junior-dashboard/src/client/conversations/README.md @@ -139,6 +139,8 @@ the log toolbar, columns, and row skeletons in the event log view. Standalone transcripts, such as a subagent drawer, keep selection in `ConversationEventLog`. Their event details use a nested drawer on desktop and -`BottomSheet` on smaller screens. This keeps the main log free of modal behavior +`BottomSheet` on smaller screens. The nested drawer stacks in front of the +subagent drawer. The subagent drawer shows its event log without a body inset, +so rows meet the drawer edges and the toolbar sticks to the top. This keeps the main log free of modal behavior while a nested transcript retains its parent drawer's focus rules. These event details have the same previous and next controls. diff --git a/packages/junior-dashboard/src/client/conversations/SubagentTranscriptDrawer.tsx b/packages/junior-dashboard/src/client/conversations/SubagentTranscriptDrawer.tsx index a2395d3e16..3a2116a34a 100644 --- a/packages/junior-dashboard/src/client/conversations/SubagentTranscriptDrawer.tsx +++ b/packages/junior-dashboard/src/client/conversations/SubagentTranscriptDrawer.tsx @@ -108,6 +108,8 @@ function SubagentTranscriptDrawerContent(props: { } closeLabel="Close subagent transcript" + // The event log owns its row insets and sticky toolbar, like the page. + flush={view === "raw"} header={ <>
From b5b5e84235b3e65d7e74316d1f67ddb3ef4065a8 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:33:58 +0000 Subject: [PATCH 3/5] feat(dashboard): Group the event step buttons Join the previous and next event buttons in one shadcn button group, so they read as one control apart from close. Co-Authored-By: adm --- .../src/client/components/ui/button-group.tsx | 59 +++++++++++++++++++ .../src/client/components/ui/separator.tsx | 22 +++++++ .../ConversationEventDetails.tsx | 15 ++++- .../src/client/conversations/README.md | 3 +- 4 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 packages/junior-dashboard/src/client/components/ui/button-group.tsx create mode 100644 packages/junior-dashboard/src/client/components/ui/separator.tsx diff --git a/packages/junior-dashboard/src/client/components/ui/button-group.tsx b/packages/junior-dashboard/src/client/components/ui/button-group.tsx new file mode 100644 index 0000000000..778082f13a --- /dev/null +++ b/packages/junior-dashboard/src/client/components/ui/button-group.tsx @@ -0,0 +1,59 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@dashboard/lib/utils"; + +import { Separator } from "@dashboard/components/ui/separator"; + +const buttonGroupVariants = cva( + "flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2", + { + variants: { + orientation: { + horizontal: + "*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-md! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0", + vertical: + "flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-md! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0", + }, + }, + defaultVariants: { + orientation: "horizontal", + }, + }, +); + +/** Join related buttons into one control and name it for assistive technology. */ +function ButtonGroup({ + className, + orientation, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +/** Draw a divider between buttons in a group. */ +function ButtonGroupSeparator({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { ButtonGroup, ButtonGroupSeparator }; diff --git a/packages/junior-dashboard/src/client/components/ui/separator.tsx b/packages/junior-dashboard/src/client/components/ui/separator.tsx new file mode 100644 index 0000000000..601dd2f10b --- /dev/null +++ b/packages/junior-dashboard/src/client/components/ui/separator.tsx @@ -0,0 +1,22 @@ +import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"; +import { cn } from "@dashboard/lib/utils"; + +function Separator({ + className, + orientation = "horizontal", + ...props +}: SeparatorPrimitive.Props) { + return ( + + ); +} + +export { Separator }; diff --git a/packages/junior-dashboard/src/client/conversations/ConversationEventDetails.tsx b/packages/junior-dashboard/src/client/conversations/ConversationEventDetails.tsx index d65eb57c25..aa48feeb70 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationEventDetails.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationEventDetails.tsx @@ -4,6 +4,10 @@ import { ChevronDown, ChevronUp } from "lucide-react"; import { cn } from "../styles"; import { DetailsPanel } from "../components/DetailsPanel"; import { Button } from "../components/ui/button"; +import { + ButtonGroup, + ButtonGroupSeparator, +} from "../components/ui/button-group"; import { Skeleton } from "../components/ui/skeleton"; import type { ConversationTranscript } from "../types"; import { useEventTime } from "./eventTime"; @@ -11,8 +15,9 @@ import { EventDetails } from "./EventDetails"; import { ForkConversationButton } from "./ForkConversationButton"; // Step buttons keep focus at the ends of the log, so style the ARIA state. +// They sit in one bordered group, so they are a little smaller than `size-9`. const stepButtonClass = - "data-disabled:cursor-not-allowed data-disabled:opacity-50 data-disabled:hover:!bg-transparent data-disabled:hover:!text-dashboard-text-muted"; + "size-8 data-disabled:cursor-not-allowed data-disabled:opacity-50 data-disabled:hover:!bg-transparent data-disabled:hover:!text-dashboard-text-muted"; /** * Show the selected event without blocking the main conversation's event log. @@ -59,7 +64,10 @@ export function ConversationEventDetails(props: { ); const actions = onSelect && event ? ( - <> + + - + ) : undefined; return ( diff --git a/packages/junior-dashboard/src/client/conversations/README.md b/packages/junior-dashboard/src/client/conversations/README.md index 9c0dff4b05..687d04f1f4 100644 --- a/packages/junior-dashboard/src/client/conversations/README.md +++ b/packages/junior-dashboard/src/client/conversations/README.md @@ -126,7 +126,8 @@ places `ConversationEventDetails` beside the log in the page panel. Selecting another row changes the event and resets its scroll position and disclosures. Closing returns focus to that row. Switching Conversations removes the panel. -The previous and next controls select the adjacent visible row. They follow +The previous and next controls select the adjacent visible row. They form one +`ui/button-group` named "Event navigation". They follow the event search, like Up and Down. They are necessary below 1280px, where the sheet covers the log. At the ends of the list, the control is disabled but keeps focus. The sheet title is the event type. The header shows the event From 3768e5a6314fe09ca6049614a0663cbbfe41e4d2 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:38:52 +0000 Subject: [PATCH 4/5] fix(dashboard): Keep side drawers full width on small screens Match the data-[swipe-axis=x] variant of the drawer's 75% default, so the full-width override wins below 640px. --- packages/junior-dashboard/src/client/components/Drawer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/junior-dashboard/src/client/components/Drawer.tsx b/packages/junior-dashboard/src/client/components/Drawer.tsx index 17cc18f340..8c7cf874bc 100644 --- a/packages/junior-dashboard/src/client/components/Drawer.tsx +++ b/packages/junior-dashboard/src/client/components/Drawer.tsx @@ -41,7 +41,7 @@ function DrawerContent(props: DrawerProps) { aria-labelledby={props.titleId} data-dashboard-modal="" className={cn( - "[--drawer-content-width:100%] data-[swipe-axis=x]:sm:[--drawer-content-width:100%]", + "data-[swipe-axis=x]:[--drawer-content-width:100%] data-[swipe-axis=x]:sm:[--drawer-content-width:100%]", props.width === "wide" ? "data-[swipe-axis=x]:md:[--drawer-content-width:min(760px,94vw)]" : props.width === "narrow" From 6641398d457a12660a3591a4b1adcd56c8cf74e7 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 06:03:31 +0000 Subject: [PATCH 5/5] fix(dashboard): Fit event detail timestamps and group fields in cards The event details header wrapped its timestamp onto a second line next to the navigation controls. Show it to the second in the sans face on one line, and keep milliseconds in the tooltip. Group event detail fields in the same card that Conversation details use. Move that card into DetailsList as DetailsCard so both panels share it. Groups inside a card, such as tool input and output, use a divider. Co-Authored-By: Aditya Mathur --- .../e2e/conversations-event-log.spec.ts | 11 +++- .../conversations/ConversationDetails.tsx | 30 +-------- .../ConversationEventDetails.tsx | 12 ++-- .../src/client/conversations/DetailsList.tsx | 33 ++++++++++ .../src/client/conversations/EventDetails.tsx | 65 +++++++++++++------ .../src/client/conversations/README.md | 14 ++-- .../src/client/conversations/eventTime.ts | 12 ++++ 7 files changed, 118 insertions(+), 59 deletions(-) diff --git a/packages/junior-dashboard/e2e/conversations-event-log.spec.ts b/packages/junior-dashboard/e2e/conversations-event-log.spec.ts index d793cb4f73..d96b673e77 100644 --- a/packages/junior-dashboard/e2e/conversations-event-log.spec.ts +++ b/packages/junior-dashboard/e2e/conversations-event-log.spec.ts @@ -151,7 +151,7 @@ test("inspects all reporting events and searches full event data", async ({ await expect(localTime).toHaveAttribute("aria-pressed", "true"); await expect(localTime).toHaveText("ET"); await expect(entry.getByText("07:54:09.000", { exact: true })).toBeVisible(); - await expect(panel.locator("header time")).toContainText("07:54:09.000"); + await expect(panel.locator("header time")).toContainText("07:54:09"); await page.reload(); await expect(localTime).toHaveAttribute("aria-pressed", "true"); await expect(entry.getByText("07:54:09.000", { exact: true })).toBeVisible(); @@ -166,8 +166,13 @@ test("inspects all reporting events and searches full event data", async ({ await secondToggle.click(); await expect(localTime).toHaveAttribute("aria-pressed", "false"); await expect(entry.getByText("11:54:09.000", { exact: true })).toBeVisible(); - // UTC mode reads like local mode. The original ISO value stays available. + // UTC mode reads like local mode. The header leaves out milliseconds, so + // it fits beside the actions. The tooltip and ISO value keep them. await expect(panel.locator("header time")).toHaveText( + "Aug 7, 2026, 11:54:09 UTC", + ); + await expect(panel.locator("header time")).toHaveAttribute( + "title", "Aug 7, 2026, 11:54:09.000 UTC", ); await expect(panel.locator("header time")).toHaveAttribute( @@ -177,7 +182,7 @@ test("inspects all reporting events and searches full event data", async ({ await secondTab.close(); await page.bringToFront(); await localTime.click(); - await expect(panel.locator("header time")).toContainText("07:54:09.000"); + await expect(panel.locator("header time")).toContainText("07:54:09"); await panel.getByText("Raw JSON", { exact: true }).click(); const rawJson = JSON.stringify( report.events.find((event: { seq: number }) => event.seq === 3), diff --git a/packages/junior-dashboard/src/client/conversations/ConversationDetails.tsx b/packages/junior-dashboard/src/client/conversations/ConversationDetails.tsx index 5191ab25cd..0e4505329e 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationDetails.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationDetails.tsx @@ -28,6 +28,7 @@ import { ConversationWatches, useConversationWatches, } from "./ConversationWatches"; +import { DetailsCard } from "./DetailsList"; import { ShareConversationButton } from "./ShareConversationButton"; import type { SubagentTranscriptTarget } from "./SubagentTranscriptDrawer"; @@ -158,13 +159,13 @@ export function ConversationDetails(props: {
) : null} {sections.map((section) => ( - {section.content} - + ))}
) : ( @@ -201,31 +202,6 @@ function TabCount(props: { count: number | undefined }) { ); } -/** Group one kind of conversation detail in a compact card. */ -function DetailsSection(props: { - children: ReactNode; - count?: number; - title?: string; -}) { - return ( -
- {props.title ? ( -

- {props.title} - {props.count ? ( - - {props.count.toLocaleString("en-US")} - - ) : null} -

- ) : null} -
- {props.children} -
-
- ); -} - /** Draw the Sentry glyph in the current text color, sized like a header icon. */ function SentryGlyph() { return ( diff --git a/packages/junior-dashboard/src/client/conversations/ConversationEventDetails.tsx b/packages/junior-dashboard/src/client/conversations/ConversationEventDetails.tsx index aa48feeb70..e7369e17c5 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationEventDetails.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationEventDetails.tsx @@ -36,16 +36,18 @@ export function ConversationEventDetails(props: { const { conversation, event, onSelect } = props; const time = useEventTime(); // The sequence number matches the `event` link parameter and the row label. + // The line stays on one row beside the actions. The tooltip keeps the + // milliseconds that the short timestamp leaves out. const timestamp = event ? ( -

- #{event.seq} +

+ #{event.seq}

) : null; diff --git a/packages/junior-dashboard/src/client/conversations/DetailsList.tsx b/packages/junior-dashboard/src/client/conversations/DetailsList.tsx index 4dd690fce7..548969be14 100644 --- a/packages/junior-dashboard/src/client/conversations/DetailsList.tsx +++ b/packages/junior-dashboard/src/client/conversations/DetailsList.tsx @@ -3,6 +3,39 @@ import { useState, type ReactNode } from "react"; import { cn, dashboardDisclosureChevronClass } from "../styles"; +/** + * Group one kind of detail in a compact card. Conversation details and event + * details share it, so both panels read the same way. A `label` names the + * card as a region. + */ +export function DetailsCard(props: { + children: ReactNode; + count?: number; + label?: string; + title?: string; +}) { + return ( +
+ {props.title ? ( +

+ {props.title} + {props.count ? ( + + {props.count.toLocaleString("en-US")} + + ) : null} +

+ ) : null} +
+ {props.children} +
+
+ ); +} + /** Rows a details list shows before the reader asks for the rest. */ const COLLAPSED_ROW_COUNT = 3; diff --git a/packages/junior-dashboard/src/client/conversations/EventDetails.tsx b/packages/junior-dashboard/src/client/conversations/EventDetails.tsx index 3091a6130b..af47ada840 100644 --- a/packages/junior-dashboard/src/client/conversations/EventDetails.tsx +++ b/packages/junior-dashboard/src/client/conversations/EventDetails.tsx @@ -12,6 +12,7 @@ import { import { dashboardDisclosureChevronClass } from "../styles"; import { RedactedMarker } from "./TranscriptRedacted"; +import { DetailsCard } from "./DetailsList"; import { TranscriptText } from "./TranscriptText"; import { eventLogModel } from "./eventLog"; import { formatCostBreakdown } from "../format"; @@ -61,7 +62,9 @@ export function EventDetails({ event }: { event: ConversationReportEvent }) { content = ( <> {Object.keys(metadata).length > 0 ? ( - + + + ) : null} {summary ? ( @@ -76,7 +79,9 @@ export function EventDetails({ event }: { event: ConversationReportEvent }) { const { type: _, text, redacted, ...metadata } = data; content = ( <> - + + + {redacted ? ( @@ -97,14 +102,14 @@ export function EventDetails({ event }: { event: ConversationReportEvent }) { {input !== undefined ? ( - + - + ) : null} {output !== undefined ? ( - + - + ) : null} ); @@ -132,13 +137,15 @@ export function EventDetails({ event }: { event: ConversationReportEvent }) { const { type: _, presentation, ...metadata } = data; content = ( <> - + + + {presentation.preview ? ( ) : null} {presentation.details?.map((detail, index) => ( - + {detail.description ? ( ) : null} - + ))} @@ -158,12 +165,16 @@ export function EventDetails({ event }: { event: ConversationReportEvent }) { } default: { const { type: _, ...fields } = data; - content = ; + content = ( + + + + ); } } return ( -
+
+
-
+
json} label="Copy JSON" />
+
+ {props.children} +
+ + ); +} + +// A group inside a card, such as a tool input. It does not nest another card. +function DetailSubsection(props: { title: string; children: ReactNode }) { return ( -
-

+
+

{props.title} -

+

{props.children}
); @@ -275,8 +302,8 @@ function DetailValue({ value }: { value: unknown }) { key={key} className={ nested - ? "grid min-w-0 gap-2 py-2" - : "grid min-w-0 grid-cols-[minmax(0,7rem)_minmax(0,1fr)] items-baseline gap-x-4 py-2 @min-[30rem]:grid-cols-[minmax(0,10rem)_minmax(0,1fr)]" + ? "grid min-w-0 gap-2 py-2 first:pt-0 last:pb-0" + : "grid min-w-0 grid-cols-[minmax(0,7rem)_minmax(0,1fr)] items-baseline gap-x-4 py-2 first:pt-0 last:pb-0 @min-[30rem]:grid-cols-[minmax(0,10rem)_minmax(0,1fr)]" } >
diff --git a/packages/junior-dashboard/src/client/conversations/README.md b/packages/junior-dashboard/src/client/conversations/README.md index 687d04f1f4..2da94de81e 100644 --- a/packages/junior-dashboard/src/client/conversations/README.md +++ b/packages/junior-dashboard/src/client/conversations/README.md @@ -114,10 +114,12 @@ The timezone toggle uses `useEventTime` to share a UTC/local preference through `@uidotdev/usehooks` and browser local storage. It applies to event row clocks and detail timestamps. Local mode follows the browser timezone. The button uses a generic zone name because a log can span standard and daylight-saving time. -Detail timestamps use a readable date and the zone name for each event date, -in both modes. Their tooltip shows the original ISO value. The mode, not -the timezone identifier, persists across visits and tabs. This hook belongs to -the client-rendered event viewer. Raw JSON retains the original ISO values. +Detail timestamps use a readable date to the second and the zone name for each +event date, in both modes. The header keeps them on one line beside the +actions. Their tooltip adds milliseconds, and the `time` element keeps the +original ISO value. The mode, not the timezone identifier, persists across +visits and tabs. This hook belongs to the client-rendered event viewer. Raw +JSON retains the original ISO values. ## Event details @@ -131,7 +133,9 @@ The previous and next controls select the adjacent visible row. They form one the event search, like Up and Down. They are necessary below 1280px, where the sheet covers the log. At the ends of the list, the control is disabled but keeps focus. The sheet title is the event type. The header shows the event -type, then the sequence number and timestamp. +type, then the sequence number and timestamp. The body groups fields in the +same `DetailsCard` that Conversation details use. Groups inside a card, such +as tool input and output, use a divider, not another card. A linked event can be older than the loaded pages. While the event loads, the panel stays open. Only its header shows placeholders, so it does not compete diff --git a/packages/junior-dashboard/src/client/conversations/eventTime.ts b/packages/junior-dashboard/src/client/conversations/eventTime.ts index fe348446f8..d45d192ac7 100644 --- a/packages/junior-dashboard/src/client/conversations/eventTime.ts +++ b/packages/junior-dashboard/src/client/conversations/eventTime.ts @@ -28,6 +28,15 @@ export function useEventTime() { day: "numeric", timeZoneName: "short", }), + // The details header is narrow, so it leaves out milliseconds. + shortTimestamp: new Intl.DateTimeFormat(undefined, { + ...clock, + fractionalSecondDigits: undefined, + year: "numeric", + month: "short", + day: "numeric", + timeZoneName: "short", + }), // A log can span both standard time and daylight-saving time. zone: new Intl.DateTimeFormat(undefined, { timeZone, @@ -56,5 +65,8 @@ export function useEventTime() { formatTimestamp(value: string) { return formatters.timestamp.format(new Date(value)); }, + formatShortTimestamp(value: string) { + return formatters.shortTimestamp.format(new Date(value)); + }, }; }