From 3ba3b5fffd24f6f4ff1f66658f3c8ec9ed1ed049 Mon Sep 17 00:00:00 2001 From: Vader Yang Date: Fri, 15 May 2026 17:01:06 +0800 Subject: [PATCH 1/2] feat(console): selected_at anchor recovers item window on stale shared link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the previous PR (selected id in URL): copying a list page URL like `?preset=15m&selected=` and opening it half an hour later would compute `start=now-15m, end=now` from scratch — the selected item is no longer in that window. The detail panel still loaded (it queries by id) but the list behind it showed an unrelated slice, the row had no highlight, prev/next disabled. Fix the window without changing the original tab's behaviour: 1. List pages also write `?selected_at=` when an item is selected — taken from the item's start_time (agent turns) or request_time (llm calls / http exchanges). Cleared together with `selected` when the panel is closed. 2. `useToolbarUrlSync` reads `selected_at` during hydration. If the anchor falls outside the preset-derived window, override: - keep the preset's *duration* (the original user's "show me this much context" signal), - slide so `end = anchor + 60s` (small breathing pad keeps the item from sitting flush at the edge in a desc-by-time list), - promote `preset` to `custom` so subsequent URL writes carry absolute start/end and the shift survives navigation. No-op when the anchor is inside the window, absent, unparseable, or future-dated relative to a window that already includes it. 3. Pure helper `applySelectedAtAnchor` lives in its own module (`selected-at-anchor.ts`, no `@/` aliases) so it's directly testable under bun without the toolbar-store / react-router runtime chain. 7 unit tests cover the no-op cases, the stale- preset shift, default-1h fallback, and clock-skew anchors. Effects: - Original tab: relative preset still ticks `now` as usual; no surprise switch to `custom`. - Fresh URL load: window auto-widens / slides to bracket the shared item; list, highlight, prev/next all work. Co-Authored-By: Claude Opus 4.7 (1M context) --- console/src/hooks/selected-at-anchor.test.ts | 107 +++++++++++++++++++ console/src/hooks/selected-at-anchor.ts | 60 +++++++++++ console/src/hooks/use-url-sync.ts | 11 ++ console/src/pages/agent-turns.tsx | 21 +++- console/src/pages/http-exchanges.tsx | 25 ++++- console/src/pages/llm-calls.tsx | 25 ++++- 6 files changed, 237 insertions(+), 12 deletions(-) create mode 100644 console/src/hooks/selected-at-anchor.test.ts create mode 100644 console/src/hooks/selected-at-anchor.ts 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 (
From 9cab7aed44aa9e4330194c0ad7c7d84bce088867 Mon Sep 17 00:00:00 2001 From: Vader Yang Date: Tue, 19 May 2026 15:51:15 +0800 Subject: [PATCH 2/2] ci: re-trigger workflow after retargeting base to main