diff --git a/packages/junior-dashboard/e2e/conversations-event-log.spec.ts b/packages/junior-dashboard/e2e/conversations-event-log.spec.ts index c7538236d1..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,13 +166,23 @@ 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 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( + "datetime", report.events.find((event: { seq: number }) => event.seq === 3).createdAt, ); 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/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/Drawer.tsx b/packages/junior-dashboard/src/client/components/Drawer.tsx index d4e16ec585..8c7cf874bc 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 9eb0bbbb91..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` | @@ -149,7 +150,12 @@ 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. +- **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/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/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/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/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/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 ff87664745..e7369e17c5 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. @@ -30,14 +35,21 @@ 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} + + +

) : null; const title = event ? ( <> @@ -54,7 +66,10 @@ export function ConversationEventDetails(props: { ); const actions = onSelect && event ? ( - <> + + - + ) : undefined; return ( 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 98cc9a10bd..2da94de81e 100644 --- a/packages/junior-dashboard/src/client/conversations/README.md +++ b/packages/junior-dashboard/src/client/conversations/README.md @@ -114,9 +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 the zone name for each event date. 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 @@ -125,10 +128,14 @@ 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. +keeps focus. The sheet title is the event type. The header shows the event +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 @@ -137,6 +144,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={ <>
diff --git a/packages/junior-dashboard/src/client/conversations/eventTime.ts b/packages/junior-dashboard/src/client/conversations/eventTime.ts index 4fc5f6ef0a..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, @@ -54,7 +63,10 @@ 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)); + }, + formatShortTimestamp(value: string) { + return formatters.shortTimestamp.format(new Date(value)); }, }; }