diff --git a/.server-changes/models-your-models-activity-charts.md b/.server-changes/models-your-models-activity-charts.md new file mode 100644 index 0000000000..747399606f --- /dev/null +++ b/.server-changes/models-your-models-activity-charts.md @@ -0,0 +1,20 @@ +--- +area: webapp +type: improvement +--- + +The three charts on the Models page "Your models" tab (Cost / Tokens / Calls over +time) now share the interactive behaviour from the agent landing page charts: +hovering one draws a synced vertical line at the same bucket on the other two, and +dragging across a chart zooms the Time/Date filter. The maximize button + "v" +shortcut and the width-aware x-axis / abbreviated y-axis were already inherited +from the shared query-widget primitives. + +Achieved by wrapping the three `MetricWidget`s in a `ChartSyncProvider` with +`useZoomToTimeFilter` — no new chart code, the sync/zoom support already lives in +`Chart.Bar`. Also made two small DRY passes on the primitives shared with the +agent charts: the duplicated "v" maximize-on-hover shortcut in `ChartCard` and +`QueryWidget` was extracted into a `useMaximizeShortcut` hook, and +`ChartBar`'s drag From/To tooltip now looks up the real data point so it renders +on the query-widget path (which keys tooltips off `__rawDate`) as well as the +activity-chart path (which keys off `bucket`). diff --git a/apps/webapp/app/components/metrics/QueryWidget.tsx b/apps/webapp/app/components/metrics/QueryWidget.tsx index cde5465721..0019587096 100644 --- a/apps/webapp/app/components/metrics/QueryWidget.tsx +++ b/apps/webapp/app/components/metrics/QueryWidget.tsx @@ -11,7 +11,7 @@ import { z } from "zod"; import { Card } from "~/components/primitives/charts/Card"; import { ShortcutKey } from "~/components/primitives/ShortcutKey"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; -import { useShortcutKeys } from "~/hooks/useShortcutKeys"; +import { useMaximizeShortcut } from "~/hooks/useMaximizeShortcut"; import { cn } from "~/utils/cn"; import { rowsToCSV, rowsToJSON } from "~/utils/dataExport"; import { QueryResultsChart } from "../code/QueryResultsChart"; @@ -186,13 +186,10 @@ export function QueryWidget({ const hasData = props.data.rows.length > 0; // "v" to toggle fullscreen on hovered widget - useShortcutKeys({ - shortcut: { key: "v" }, - action: useCallback(() => { - const isHovered = containerRef.current?.matches(":hover"); - if (!isFullscreen && !isHovered) return; - setIsFullscreen((prev) => !prev); - }, [isFullscreen]), + useMaximizeShortcut({ + containerRef, + isMaximized: isFullscreen, + setIsMaximized: setIsFullscreen, }); const copyToClipboard = useCallback((text: string) => { diff --git a/apps/webapp/app/components/primitives/charts/ChartBar.tsx b/apps/webapp/app/components/primitives/charts/ChartBar.tsx index ef9daeb532..a0c7b4a366 100644 --- a/apps/webapp/app/components/primitives/charts/ChartBar.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartBar.tsx @@ -181,9 +181,15 @@ export function ChartBarRenderer({ // Bucket width so the committed zoom range includes the last selected bucket. const bucketWidthMs = data.length >= 2 ? Number(data[1][dataKey]) - Number(data[0][dataKey]) : 0; - // Reuse the tooltip label formatter for the From/To edges (it reads `bucket` off the payload). - const formatZoomEdge = (v: number): string => - tooltipLabelFormatter ? tooltipLabelFormatter("", [{ payload: { bucket: v } }]) : String(v); + // Reuse the tooltip label formatter for the From/To edges. Different callers read + // different fields off the payload (activity charts read `bucket`; the query widget + // reads `__rawDate`/`__granularity`), so pass the real data point at this x when we + // can find it, falling back to a minimal `{ bucket }` payload. + const formatZoomEdge = (v: number): string => { + if (!tooltipLabelFormatter) return String(v); + const point = data.find((d) => Number(d[dataKey]) === v); + return tooltipLabelFormatter("", [{ payload: point ?? { bucket: v } }]); + }; let zoomFrom: string | null = null; let zoomTo: string | null = null; if (syncZoomSelection) { diff --git a/apps/webapp/app/components/primitives/charts/ChartCard.tsx b/apps/webapp/app/components/primitives/charts/ChartCard.tsx index 507092e40b..c0f9736baa 100644 --- a/apps/webapp/app/components/primitives/charts/ChartCard.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartCard.tsx @@ -1,9 +1,9 @@ import { Maximize2 } from "lucide-react"; -import { useCallback, useRef, useState, type ReactNode } from "react"; +import { useRef, useState, type ReactNode } from "react"; import { Button } from "~/components/primitives/Buttons"; import { ShortcutKey } from "~/components/primitives/ShortcutKey"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; -import { useShortcutKeys } from "~/hooks/useShortcutKeys"; +import { useMaximizeShortcut } from "~/hooks/useMaximizeShortcut"; import { cn } from "~/utils/cn"; import { Dialog, DialogContent, DialogHeader } from "../Dialog"; import { Card } from "./Card"; @@ -36,13 +36,10 @@ export function ChartCard({ const containerRef = useRef(null); // "v" toggles fullscreen for the hovered card. - useShortcutKeys({ - shortcut: { key: "v" }, - action: useCallback(() => { - const isHovered = containerRef.current?.matches(":hover"); - if (!isFullscreen && !isHovered) return; - setIsFullscreen((prev) => !prev); - }, [isFullscreen]), + useMaximizeShortcut({ + containerRef, + isMaximized: isFullscreen, + setIsMaximized: setIsFullscreen, disabled: !maximizable, }); diff --git a/apps/webapp/app/hooks/useMaximizeShortcut.ts b/apps/webapp/app/hooks/useMaximizeShortcut.ts new file mode 100644 index 0000000000..d36a105e93 --- /dev/null +++ b/apps/webapp/app/hooks/useMaximizeShortcut.ts @@ -0,0 +1,30 @@ +import { useCallback, type Dispatch, type RefObject, type SetStateAction } from "react"; +import { useShortcutKeys } from "~/hooks/useShortcutKeys"; + +/** + * Shared "v" shortcut for chart/widget cards that can go fullscreen: toggles the + * maximized state, but only for the card the cursor is currently over (or, when + * already maximized, closes it regardless). Used by both ChartCard and QueryWidget + * so the hover-scoped behaviour stays in one place. + */ +export function useMaximizeShortcut({ + containerRef, + isMaximized, + setIsMaximized, + disabled, +}: { + containerRef: RefObject; + isMaximized: boolean; + setIsMaximized: Dispatch>; + disabled?: boolean; +}) { + useShortcutKeys({ + shortcut: { key: "v" }, + action: useCallback(() => { + const isHovered = containerRef.current?.matches(":hover"); + if (!isMaximized && !isHovered) return; + setIsMaximized((prev) => !prev); + }, [isMaximized, containerRef, setIsMaximized]), + disabled, + }); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.models._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.models._index/route.tsx index b797e6434f..3a96e7da1f 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.models._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.models._index/route.tsx @@ -33,6 +33,7 @@ import { InlineCode } from "~/components/code/InlineCode"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import type { QueryWidgetConfig } from "~/components/metrics/QueryWidget"; import { AppliedFilter } from "~/components/primitives/AppliedFilter"; +import { ChartSyncProvider } from "~/components/primitives/charts/ChartSyncContext"; import { Badge } from "~/components/primitives/Badge"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Checkbox } from "~/components/primitives/Checkbox"; @@ -80,6 +81,7 @@ import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { useSearchParams } from "~/hooks/useSearchParam"; import { useShortcutKeys } from "~/hooks/useShortcutKeys"; +import { useZoomToTimeFilter } from "~/hooks/useZoomToTimeFilter"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { @@ -1251,50 +1253,56 @@ function YourModelsTab({ to, }; + // Mirror the agent landing page: the three charts share a hover indicator and + // drag-to-zoom commits the selection to the Time/Date filter (from/to URL params). + const zoomToTimeFilter = useZoomToTimeFilter(); + return (
-
-
- -
-
- -
-
- + +
+
+ +
+
+ +
+
+ +
-
+
{usage.length === 0 ? (