diff --git a/console/src/hooks/selected-at-anchor.test.ts b/console/src/hooks/selected-at-anchor.test.ts new file mode 100644 index 0000000..148bc8e --- /dev/null +++ b/console/src/hooks/selected-at-anchor.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "bun:test" +import { applySelectedAtAnchor } from "./selected-at-anchor" + +// Fixed "now" so test expectations don't drift with wall-clock time. +const NOW = 1_780_000_000 // unix seconds (sometime in mid-2026) +const HOUR = 3600 +const MIN = 60 + +describe("applySelectedAtAnchor", () => { + it("no-ops when the anchor param is absent", () => { + const patch: { preset?: string; start?: number; end?: number } = { + preset: "15m", + start: NOW - 15 * MIN, + end: NOW, + } + applySelectedAtAnchor(patch, null, NOW) + expect(patch.preset).toBe("15m") + expect(patch.start).toBe(NOW - 15 * MIN) + expect(patch.end).toBe(NOW) + }) + + it("no-ops when the anchor is unparseable", () => { + const patch: { preset?: string; start?: number; end?: number } = { + preset: "15m", + start: NOW - 15 * MIN, + end: NOW, + } + applySelectedAtAnchor(patch, "not-a-number", NOW) + expect(patch.preset).toBe("15m") + expect(patch.start).toBe(NOW - 15 * MIN) + expect(patch.end).toBe(NOW) + }) + + it("no-ops when the anchor is inside the window", () => { + // Anchor 5 minutes before "now"; falls inside the 15-minute window. + const patch: { preset?: string; start?: number; end?: number } = { + preset: "15m", + start: NOW - 15 * MIN, + end: NOW, + } + applySelectedAtAnchor(patch, String(NOW - 5 * MIN), NOW) + expect(patch.preset).toBe("15m") + expect(patch.start).toBe(NOW - 15 * MIN) + expect(patch.end).toBe(NOW) + }) + + it("shifts a stale 15m window to bracket an anchor 30 minutes ago", () => { + // The motivating scenario: someone copied a `?preset=15m&selected_at=...` + // URL half an hour ago; the recipient opens it now. + const patch: { preset?: string; start?: number; end?: number } = { + preset: "15m", + start: NOW - 15 * MIN, + end: NOW, + } + const anchor = NOW - 30 * MIN + applySelectedAtAnchor(patch, String(anchor), NOW) + // Promoted to custom (so the URL serializes absolute start/end). + expect(patch.preset).toBe("custom") + // Duration preserved (15 minutes), end is anchor + 60s pad, + // anchor sits at the trailing edge of the window (with the pad). + expect(patch.end).toBe(anchor + 60) + expect(patch.start).toBe(anchor + 60 - 15 * MIN) + expect(patch.end! - patch.start!).toBe(15 * MIN) + }) + + it("treats a missing preset window as the default 1h preset", () => { + // No `preset` in URL → effective window is `[now-1h, now]` per the + // toolbar default. An anchor 2h before now is outside that. + const patch: { preset?: string; start?: number; end?: number } = {} + const anchor = NOW - 2 * HOUR + applySelectedAtAnchor(patch, String(anchor), NOW) + expect(patch.preset).toBe("custom") + expect(patch.end).toBe(anchor + 60) + expect(patch.start).toBe(anchor + 60 - HOUR) + }) + + it("preserves the user's chosen 1h duration when overriding", () => { + // User shared with preset=1h; recipient opens 4 hours later. + const patch: { preset?: string; start?: number; end?: number } = { + preset: "1h", + start: NOW - HOUR, + end: NOW, + } + const anchor = NOW - 5 * HOUR + applySelectedAtAnchor(patch, String(anchor), NOW) + expect(patch.preset).toBe("custom") + expect(patch.end! - patch.start!).toBe(HOUR) + // Anchor lands inside the new window with a 60s breathing pad. + expect(anchor).toBeGreaterThanOrEqual(patch.start!) + expect(anchor).toBeLessThanOrEqual(patch.end!) + }) + + it("handles anchors after the window end (clock skew / future-dated)", () => { + // Defensive case — an anchor in the future of the recipient's clock. + // The override should still bracket it (no assumption that anchor < now). + const patch: { preset?: string; start?: number; end?: number } = { + preset: "15m", + start: NOW - 15 * MIN, + end: NOW, + } + const anchor = NOW + 10 * MIN + applySelectedAtAnchor(patch, String(anchor), NOW) + expect(patch.preset).toBe("custom") + expect(patch.end).toBe(anchor + 60) + expect(patch.start).toBe(anchor + 60 - 15 * MIN) + }) +}) diff --git a/console/src/hooks/selected-at-anchor.ts b/console/src/hooks/selected-at-anchor.ts new file mode 100644 index 0000000..dffeb2e --- /dev/null +++ b/console/src/hooks/selected-at-anchor.ts @@ -0,0 +1,60 @@ +/** + * Window-anchor override for fresh URL loads with a stale relative preset. + * + * When a list page links to an item with `?selected=&selected_at=` + * and a recipient opens that URL hours later, the relative preset (e.g. + * `preset=15m`) bracketed to "now" would no longer contain the item. This + * helper detects that gap and shifts the window so the item lands at the + * trailing edge: + * + * - Keep the preset's *duration* — that's the "show me this much + * context" signal the original user picked. + * - Slide the window so `end = selectedAt + ANCHOR_PAD_SECONDS`, + * putting the item just past the right edge with a small breathing + * pad (so it shows up reliably in a desc-by-time list). + * - Promote `preset` to `"custom"` so the URL serializer writes absolute + * `start`/`end` back out and the shift survives subsequent navigation. + * + * No-ops when the anchor is absent, unparseable, or already inside the + * computed window. + * + * Stand-alone module (no `@/` aliases) so it's directly testable under bun + * without dragging in the toolbar store / react-router runtime deps. + */ + +export const ANCHOR_PAD_SECONDS = 60 + +/** Duration to fall back to when the caller's patch has no preset-derived + * window. Matches the toolbar's default `1h` preset — duplicated here + * rather than imported so this module stays dependency-free. */ +export const DEFAULT_FALLBACK_DURATION_SECONDS = 3600 + +export interface AnchorablePatch { + preset?: string + start?: number + end?: number +} + +export function applySelectedAtAnchor( + patch: AnchorablePatch, + selectedAtRaw: string | null, + nowSec: number, +): void { + if (selectedAtRaw == null) return + const selectedAt = Number(selectedAtRaw) + if (!Number.isFinite(selectedAt)) return + + // Treat an empty patch as the "default preset" window — 1h ending at now. + // Matches what the toolbar store falls back to when no preset param is in + // the URL. + const start = patch.start ?? nowSec - DEFAULT_FALLBACK_DURATION_SECONDS + const end = patch.end ?? nowSec + if (selectedAt >= start && selectedAt <= end) return + + const duration = end - start + const newEnd = selectedAt + ANCHOR_PAD_SECONDS + const newStart = newEnd - duration + patch.preset = "custom" + patch.start = newStart + patch.end = newEnd +} diff --git a/console/src/hooks/use-url-sync.ts b/console/src/hooks/use-url-sync.ts index 9d492ff..2cabd97 100644 --- a/console/src/hooks/use-url-sync.ts +++ b/console/src/hooks/use-url-sync.ts @@ -7,6 +7,7 @@ import { isValidPreset, } from "@/stores/toolbar" import { getSpecForPath, type DimensionKey } from "@/stores/page-filter-specs" +import { applySelectedAtAnchor } from "./selected-at-anchor" function nowSeconds() { return Math.floor(Date.now() / 1000) @@ -23,6 +24,13 @@ const P = { refresh: "refresh", } as const +/** + * Page-level "selected item" anchor — written by list pages alongside + * `?selected=` so the recipient of a shared link can recover the + * window the item was in, not just the most-recent N minutes. + */ +const SELECTED_AT_PARAM = "selected_at" + /** * Bidirectional sync between the toolbar Zustand store and URL search params. * Mount once in AppLayout. @@ -61,6 +69,9 @@ export function useToolbarUrlSync() { } } + const selectedAtRaw = searchParams.get(SELECTED_AT_PARAM) + applySelectedAtAnchor(hydratePatch, selectedAtRaw, nowSeconds()) + const wireApi = searchParams.get(P.wireApi) const model = searchParams.get(P.model) const serverIp = searchParams.get(P.serverIp) diff --git a/console/src/pages/agent-turns.tsx b/console/src/pages/agent-turns.tsx index 13beecb..6d59ade 100644 --- a/console/src/pages/agent-turns.tsx +++ b/console/src/pages/agent-turns.tsx @@ -94,6 +94,23 @@ export function AgentTurnsPage() { const agentKindFilter = agentKindStr ? agentKindStr.split(",") : [] const [selectedId, setSelectedId] = useSearchParamState("selected", "") + // Anchor (unix seconds) shared alongside `?selected` so a recipient who + // opens this URL with a stale relative preset still lands on the item's + // window — see use-url-sync.ts for the override logic. + const [, setSelectedAt] = useSearchParamState("selected_at", "") + + const selectItem = useCallback( + (turnId: string, startTimeUs: number) => { + setSelectedId(turnId) + setSelectedAt(String(Math.floor(startTimeUs / 1_000_000))) + }, + [setSelectedId, setSelectedAt], + ) + + const clearSelection = useCallback(() => { + setSelectedId("") + setSelectedAt("") + }, [setSelectedId, setSelectedAt]) const { data, isLoading, isError, error } = useAgentTurns({ page, @@ -211,7 +228,7 @@ export function AgentTurnsPage() { items.map((item) => ( setSelectedId(item.turn_id)} + onClick={() => selectItem(item.turn_id, item.start_time)} className={cn( "cursor-pointer border-b border-border/50 transition-colors hover:bg-muted/50", selectedId === item.turn_id && "bg-muted", @@ -283,7 +300,7 @@ export function AgentTurnsPage() { {/* Slide-over detail panel */} {selectedId && ( - setSelectedId("")} /> + )} ) diff --git a/console/src/pages/http-exchanges.tsx b/console/src/pages/http-exchanges.tsx index 4f16d0b..4109f22 100644 --- a/console/src/pages/http-exchanges.tsx +++ b/console/src/pages/http-exchanges.tsx @@ -132,6 +132,10 @@ export function HttpExchangesPage() { const isSse = sseStr === "true" ? true : sseStr === "false" ? false : undefined const [selectedId, setSelectedId] = useSearchParamState("selected", "") + // Anchor (unix seconds) shared alongside `?selected` so a recipient who + // opens this URL with a stale relative preset still lands on the + // exchange's window — see use-url-sync.ts for the override logic. + const [, setSelectedAt] = useSearchParamState("selected_at", "") const { data, isLoading, isError, error } = useHttpExchanges({ page, @@ -172,26 +176,37 @@ export function HttpExchangesPage() { ? items.findIndex((i) => i.id === selectedId) : -1 + const selectItemById = useCallback( + (id: string) => { + const item = items.find((i) => i.id === id) + setSelectedId(id) + // request_time is unix ms — convert to seconds for the anchor. + setSelectedAt(item ? String(Math.floor(item.request_time / 1000)) : "") + }, + [items, setSelectedId, setSelectedAt], + ) + const handleRowClick = useCallback( (id: string, _index: number) => { - setSelectedId(id) + selectItemById(id) }, - [setSelectedId], + [selectItemById], ) const handleNavigate = useCallback( (direction: "prev" | "next") => { const newIndex = direction === "prev" ? selectedIndex - 1 : selectedIndex + 1 if (newIndex >= 0 && newIndex < items.length) { - setSelectedId(items[newIndex].id) + selectItemById(items[newIndex].id) } }, - [selectedIndex, items, setSelectedId], + [selectedIndex, items, selectItemById], ) const handleClose = useCallback(() => { setSelectedId("") - }, [setSelectedId]) + setSelectedAt("") + }, [setSelectedId, setSelectedAt]) return (
diff --git a/console/src/pages/llm-calls.tsx b/console/src/pages/llm-calls.tsx index de22dd3..434e5b8 100644 --- a/console/src/pages/llm-calls.tsx +++ b/console/src/pages/llm-calls.tsx @@ -146,6 +146,10 @@ export function LlmCallsPage() { }, [finishReasonsData]) const [selectedId, setSelectedId] = useSearchParamState("selected", "") + // Anchor (unix seconds) shared alongside `?selected` so a recipient who + // opens this URL with a stale relative preset still lands on the call's + // window — see use-url-sync.ts for the override logic. + const [, setSelectedAt] = useSearchParamState("selected_at", "") const { data, isLoading, isError, error } = useLlmCalls({ page, @@ -185,26 +189,37 @@ export function LlmCallsPage() { ? items.findIndex((i) => i.id === selectedId) : -1 + const selectItemById = useCallback( + (id: string) => { + const item = items.find((i) => i.id === id) + setSelectedId(id) + // request_time is unix ms — convert to seconds for the anchor. + setSelectedAt(item ? String(Math.floor(item.request_time / 1000)) : "") + }, + [items, setSelectedId, setSelectedAt], + ) + const handleRowClick = useCallback( (id: string, _index: number) => { - setSelectedId(id) + selectItemById(id) }, - [setSelectedId], + [selectItemById], ) const handleNavigate = useCallback( (direction: "prev" | "next") => { const newIndex = direction === "prev" ? selectedIndex - 1 : selectedIndex + 1 if (newIndex >= 0 && newIndex < items.length) { - setSelectedId(items[newIndex].id) + selectItemById(items[newIndex].id) } }, - [selectedIndex, items, setSelectedId], + [selectedIndex, items, selectItemById], ) const handleClose = useCallback(() => { setSelectedId("") - }, [setSelectedId]) + setSelectedAt("") + }, [setSelectedId, setSelectedAt]) return (