From bea36c81dd724d4d32ef2032f10b4623c6f200c4 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 18 Aug 2026 10:39:32 +0000 Subject: [PATCH 1/4] feat(site): default AI Gateway sessions list to a 24h time range The sessions list page is slow because ListAIBridgeSessions scans all interceptions when no time filter is set. Merge an in-memory default range (the last 24 hours) into the sessions query payload and add a time range filter to the filter bar so users can override it for forensics. No backend changes: started_after/started_before already exist end to end. --- .../ListSessionsFilter.stories.tsx | 62 +++++++--- .../ListSessionsPage/ListSessionsFilter.tsx | 30 ++++- .../ListSessionsPage/ListSessionsPage.tsx | 27 ++++- .../ListSessionsPageView.stories.tsx | 37 ++++-- .../ListSessionsPage/timeRange.test.ts | 111 ++++++++++++++++++ .../ListSessionsPage/timeRange.ts | 74 ++++++++++++ .../AIBridgePage/filters/ClientFilter.tsx | 4 +- .../AIBridgePage/filters/ModelFilter.tsx | 4 +- .../AIBridgePage/filters/ProviderFilter.tsx | 4 +- 9 files changed, 316 insertions(+), 37 deletions(-) create mode 100644 site/src/pages/AIBridgePage/ListSessionsPage/timeRange.test.ts create mode 100644 site/src/pages/AIBridgePage/ListSessionsPage/timeRange.ts diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.stories.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.stories.tsx index 0d162008cfd4e..132244bea5748 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.stories.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.stories.tsx @@ -1,28 +1,47 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { ComponentProps } from "react"; +import { fn } from "storybook/test"; import { getDefaultFilterProps, MockMenu, } from "#/components/Filter/storyHelpers"; import { ListSessionsFilter } from "./ListSessionsFilter"; +import type { TimeRange } from "./timeRange"; type FilterProps = ComponentProps; -const defaultFilterProps = getDefaultFilterProps< - Pick ->({ - query: "", - values: { - username: undefined, - provider: undefined, - }, - menus: { - user: MockMenu, - provider: MockMenu, - client: MockMenu, - model: MockMenu, - }, -}); +type FilterAndMenus = Pick; + +const timeRange: TimeRange = { + startedAfter: new Date("2026-08-12T15:00:00Z"), + startedBefore: new Date("2026-08-13T15:00:00Z"), +}; + +const timeRangeProps: Pick< + FilterProps, + "timeRange" | "defaultTimeRange" | "onTimeRangeChange" +> = { + timeRange, + defaultTimeRange: timeRange, + onTimeRangeChange: fn(), +}; + +const defaultFilterProps = { + ...getDefaultFilterProps({ + query: "", + values: { + username: undefined, + provider: undefined, + }, + menus: { + user: MockMenu, + provider: MockMenu, + client: MockMenu, + model: MockMenu, + }, + }), + ...timeRangeProps, +}; const meta: Meta = { title: "pages/AIBridgePage/ListSessionsFilter", @@ -40,7 +59,7 @@ export const Default: Story = { export const WithQuery: Story = { args: { - ...getDefaultFilterProps>({ + ...getDefaultFilterProps({ query: "initiator:me", values: { username: "me", @@ -54,6 +73,17 @@ export const WithQuery: Story = { }, used: true, }), + ...timeRangeProps, + }, +}; + +export const ExplicitTimeRange: Story = { + args: { + ...defaultFilterProps, + timeRange: { + startedAfter: new Date("2026-08-01T09:30:00"), + startedBefore: new Date("2026-08-02T17:45:00"), + }, }, }; diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.tsx index 1ff6c32043545..51d624d1852d3 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.tsx @@ -1,4 +1,5 @@ import type { FC } from "react"; +import { DateTimeRangeFilter } from "#/components/DateTimeRangeFilter/DateTimeRangeFilter"; import { Filter, MenuSkeleton, @@ -11,6 +12,11 @@ import { ProviderFilter, type ProviderFilterMenu, } from "../filters/ProviderFilter"; +import type { TimeRange } from "./timeRange"; + +// Narrower than the SelectFilter default so the search input keeps most +// of the row on wide viewports. +const FILTER_WIDTH = 150; interface ListSessionsFilterProps { filter: ReturnType; @@ -21,12 +27,18 @@ interface ListSessionsFilterProps { client: ClientFilterMenu; model: ModelFilterMenu; }; + timeRange: TimeRange; + defaultTimeRange: TimeRange; + onTimeRangeChange: (range: TimeRange) => void; } export const ListSessionsFilter: FC = ({ filter, error, menus, + timeRange, + defaultTimeRange, + onTimeRangeChange, }) => { return ( = ({ error={error} options={ <> - - - - + + + + + } /> diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPage.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPage.tsx index 9ba32bc54c399..d32fd29009eaa 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPage.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPage.tsx @@ -1,7 +1,8 @@ import type { FC } from "react"; +import { useState } from "react"; import { useNavigate, useSearchParams } from "react-router"; import { paginatedSessions } from "#/api/queries/aiBridge"; -import { useFilter } from "#/components/Filter/Filter"; +import { useFilter, useFilterParamsKey } from "#/components/Filter/Filter"; import { useUserFilterMenu } from "#/components/Filter/UserFilter"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { usePaginatedQuery } from "#/hooks/usePaginatedQuery"; @@ -13,6 +14,12 @@ import { useModelFilterMenu } from "../filters/ModelFilter"; import { useProviderFilterMenu } from "../filters/ProviderFilter"; import { getAIBridgePermissions } from "../getAIBridgePermissions"; import { ListSessionsPageView } from "./ListSessionsPageView"; +import { + defaultTimeRange, + parseTimeRange, + setTimeRangeInQuery, + withDefaultTimeRange, +} from "./timeRange"; const AISessionListPage: FC = () => { const { permissions } = useAuthenticated(); @@ -26,9 +33,21 @@ const AISessionListPage: FC = () => { const canViewSessions = isEntitled && hasPermission; + // The default time range lives in memory, not the URL, so a shared link + // resolves relative to the viewer's current time. It is fixed per mount + // so query cache keys stay stable. + const [defaultRange] = useState(() => defaultTimeRange(new Date())); + const [searchParams, setSearchParams] = useSearchParams(); const sessionsQuery = usePaginatedQuery({ ...paginatedSessions(searchParams), + // Merge the default range into every fetch (including prefetches) so + // the unfiltered sessions query never scans the entire table. + queryPayload: () => + withDefaultTimeRange( + searchParams.get(useFilterParamsKey) ?? "", + defaultRange, + ), enabled: canViewSessions, }); @@ -38,6 +57,8 @@ const AISessionListPage: FC = () => { onUpdate: sessionsQuery.goToFirstPage, }); + const explicitTimeRange = parseTimeRange(filter.values); + const timeRange = explicitTimeRange ?? defaultRange; const userMenu = useUserFilterMenu({ value: filter.values.initiator, onChange: (option) => @@ -97,6 +118,10 @@ const AISessionListPage: FC = () => { client: clientMenu, model: modelMenu, }, + timeRange, + defaultTimeRange: defaultRange, + onTimeRangeChange: (range) => + filter.update(setTimeRangeInQuery(filter.values, range)), }} /> diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.stories.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.stories.tsx index ff32f8230c3dd..eca9ff5e0aa71 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.stories.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.stories.tsx @@ -11,22 +11,33 @@ import { } from "#/components/PaginationWidget/PaginationContainer.mocks"; import { MockSession } from "#/testHelpers/entities"; import { ListSessionsPageView } from "./ListSessionsPageView"; +import type { TimeRange } from "./timeRange"; type FilterProps = ComponentProps["filterProps"]; -const defaultFilterProps = getDefaultFilterProps({ - query: "owner:me", - values: { - username: undefined, - provider: undefined, - }, - menus: { - user: MockMenu, - provider: MockMenu, - client: MockMenu, - model: MockMenu, - }, -}); +const timeRange: TimeRange = { + startedAfter: new Date("2026-08-12T15:00:00Z"), + startedBefore: new Date("2026-08-13T15:00:00Z"), +}; + +const defaultFilterProps: FilterProps = { + ...getDefaultFilterProps({ + query: "owner:me", + values: { + username: undefined, + provider: undefined, + }, + menus: { + user: MockMenu, + provider: MockMenu, + client: MockMenu, + model: MockMenu, + }, + }), + timeRange, + defaultTimeRange: timeRange, + onTimeRangeChange: fn(), +}; const meta: Meta = { title: "pages/AIBridgePage/ListSessionsPageView", diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.test.ts b/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.test.ts new file mode 100644 index 0000000000000..51aba139aa65f --- /dev/null +++ b/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { + defaultTimeRange, + parseTimeRange, + setTimeRangeInQuery, + type TimeRange, + toRFC3339, + withDefaultTimeRange, +} from "./timeRange"; + +const now = new Date(2026, 7, 13, 15, 0, 0); + +describe("toRFC3339", () => { + it("serializes in UTC with second precision", () => { + expect(toRFC3339(new Date(Date.UTC(2026, 7, 13, 10, 0, 0, 500)))).toBe( + "2026-08-13T10:00:00Z", + ); + }); +}); + +describe("defaultTimeRange", () => { + it("spans the 24 hours ending at now", () => { + const range = defaultTimeRange(now); + expect(range.startedBefore).toEqual(now); + expect(range.startedAfter).toEqual( + new Date(now.getTime() - 24 * 60 * 60 * 1000), + ); + }); +}); + +describe("withDefaultTimeRange", () => { + const range: TimeRange = { + startedAfter: new Date(Date.UTC(2026, 7, 12, 15, 0, 0)), + startedBefore: new Date(Date.UTC(2026, 7, 13, 15, 0, 0)), + }; + + it("appends the default range to an empty query", () => { + expect(withDefaultTimeRange("", range)).toBe( + 'started_after:"2026-08-12T15:00:00Z" started_before:"2026-08-13T15:00:00Z"', + ); + }); + + it("appends the default range alongside other filters", () => { + expect(withDefaultTimeRange("initiator:me", range)).toBe( + 'initiator:me started_after:"2026-08-12T15:00:00Z" started_before:"2026-08-13T15:00:00Z"', + ); + }); + + it("leaves a query with an explicit time range untouched", () => { + const afterOnly = 'started_after:"2026-08-01T00:00:00Z"'; + expect(withDefaultTimeRange(afterOnly, range)).toBe(afterOnly); + + const beforeOnly = 'started_before:"2026-08-01T00:00:00Z"'; + expect(withDefaultTimeRange(beforeOnly, range)).toBe(beforeOnly); + }); +}); + +describe("setTimeRangeInQuery", () => { + const range: TimeRange = { + startedAfter: new Date(Date.UTC(2026, 7, 12, 15, 0, 0)), + startedBefore: new Date(Date.UTC(2026, 7, 13, 15, 0, 0)), + }; + + it("preserves other filters and replaces the time range", () => { + expect( + setTimeRangeInQuery( + { + initiator: "me", + started_after: "2026-01-01T00:00:00Z", + started_before: "2026-01-02T00:00:00Z", + }, + range, + ), + ).toBe( + 'initiator:me started_after:"2026-08-12T15:00:00Z" started_before:"2026-08-13T15:00:00Z"', + ); + }); +}); + +describe("parseTimeRange", () => { + it("parses an explicit range", () => { + expect( + parseTimeRange({ + started_after: "2026-08-12T15:00:00Z", + started_before: "2026-08-13T15:00:00Z", + }), + ).toEqual({ + startedAfter: new Date(Date.UTC(2026, 7, 12, 15, 0, 0)), + startedBefore: new Date(Date.UTC(2026, 7, 13, 15, 0, 0)), + }); + }); + + it("returns null when either bound is missing", () => { + expect(parseTimeRange({})).toBeNull(); + expect( + parseTimeRange({ started_after: "2026-08-12T15:00:00Z" }), + ).toBeNull(); + expect( + parseTimeRange({ started_before: "2026-08-13T15:00:00Z" }), + ).toBeNull(); + }); + + it("returns null for malformed bounds", () => { + expect( + parseTimeRange({ + started_after: "bogus", + started_before: "2026-08-13T15:00:00Z", + }), + ).toBeNull(); + }); +}); diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.ts b/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.ts new file mode 100644 index 0000000000000..7914fb3e59f43 --- /dev/null +++ b/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.ts @@ -0,0 +1,74 @@ +import type { TimeRange } from "#/components/DateTimeRangeFilter/timeRange"; +import { stringifyFilter } from "#/components/Filter/filterQuery"; + +export type { TimeRange } from "#/components/DateTimeRangeFilter/timeRange"; + +/** Serializes a Date as RFC 3339 in UTC with second precision. */ +export const toRFC3339 = (date: Date): string => { + return date.toISOString().replace(/\.\d{3}Z$/, "Z"); +}; + +/** The default sessions window: the 24 hours ending at now. */ +export const defaultTimeRange = (now: Date): TimeRange => ({ + startedAfter: new Date(now.getTime() - 24 * 60 * 60 * 1000), + startedBefore: now, +}); + +const TIME_RANGE_KEY_PATTERN = /started_(after|before):/; + +/** + * Appends the default time range to a filter query unless the query already + * sets an explicit started_after or started_before. The default is kept in + * memory instead of the URL so shared links resolve relative to the + * viewer's current time. + */ +export const withDefaultTimeRange = ( + query: string, + range: TimeRange, +): string => { + if (TIME_RANGE_KEY_PATTERN.test(query)) { + return query; + } + const suffix = stringifyFilter({ + started_after: toRFC3339(range.startedAfter), + started_before: toRFC3339(range.startedBefore), + }); + return query === "" ? suffix : `${query} ${suffix}`; +}; + +/** + * Builds a filter query string that replaces any existing time range in + * values with the given range while preserving all other filters. + * stringifyFilter quotes the RFC 3339 values because the backend query + * parser treats unquoted colons as key/value separators. + */ +export const setTimeRangeInQuery = ( + values: Record, + range: TimeRange, +): string => { + return stringifyFilter({ + ...values, + started_after: toRFC3339(range.startedAfter), + started_before: toRFC3339(range.startedBefore), + }); +}; + +/** Extracts an explicit time range from filter values, or null if absent. */ +export const parseTimeRange = ( + values: Record, +): TimeRange | null => { + const after = values.started_after; + const before = values.started_before; + if (!after || !before) { + return null; + } + const startedAfter = new Date(after); + const startedBefore = new Date(before); + if ( + Number.isNaN(startedAfter.getTime()) || + Number.isNaN(startedBefore.getTime()) + ) { + return null; + } + return { startedAfter, startedBefore }; +}; diff --git a/site/src/pages/AIBridgePage/filters/ClientFilter.tsx b/site/src/pages/AIBridgePage/filters/ClientFilter.tsx index acb5a05d6bd46..d7508dbd2a40b 100644 --- a/site/src/pages/AIBridgePage/filters/ClientFilter.tsx +++ b/site/src/pages/AIBridgePage/filters/ClientFilter.tsx @@ -56,9 +56,10 @@ export type ClientFilterMenu = ReturnType; interface ClientFilterProps { menu: ClientFilterMenu; + width?: number; } -export const ClientFilter: React.FC = ({ menu }) => { +export const ClientFilter: React.FC = ({ menu, width }) => { return ( = ({ menu }) => { options={menu.searchOptions} onSelect={(option) => menu.selectOption(option)} selectedOption={menu.selectedOption ?? undefined} + width={width} selectFilterSearch={ ; interface ModelFilterProps { menu: ModelFilterMenu; + width?: number; } -export const ModelFilter: FC = ({ menu }) => { +export const ModelFilter: FC = ({ menu, width }) => { return ( = ({ menu }) => { options={menu.searchOptions} onSelect={(option) => menu.selectOption(option)} selectedOption={menu.selectedOption ?? undefined} + width={width} selectFilterSearch={ ; interface ProviderFilterProps { menu: ProviderFilterMenu; + width?: number; } -export const ProviderFilter: FC = ({ menu }) => { +export const ProviderFilter: FC = ({ menu, width }) => { return ( = ({ menu }) => { options={menu.searchOptions} onSelect={(option) => menu.selectOption(option)} selectedOption={menu.selectedOption ?? undefined} + width={width} /> ); }; From e3c366b1ee7cf88b7f86cbecf5775d2bc528d551 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 18 Aug 2026 11:12:43 +0000 Subject: [PATCH 2/4] docs(ai-gateway): document the 24h default sessions window The sessions list now defaults to the last 24 hours of activity; document the default window and the time range filter used to widen it. --- docs/ai-coder/ai-gateway/audit.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/ai-coder/ai-gateway/audit.md b/docs/ai-coder/ai-gateway/audit.md index b43f8984413ff..743d44a35cca0 100644 --- a/docs/ai-coder/ai-gateway/audit.md +++ b/docs/ai-coder/ai-gateway/audit.md @@ -46,9 +46,13 @@ not just what was called. ### Sessions list -The sessions page (`http:///ai-gateway/sessions`) lists all sessions in -reverse-chronological order. Each row shows the last prompt, initiator, provider, -client, token usage, network requests, thread count, and timestamp. +The sessions page (`http:///ai-gateway/sessions`) lists sessions in +reverse-chronological order. By default it shows sessions with activity in the +last 24 hours. Use the time range filter in the filter bar to widen or narrow +the window, for example to see older sessions. + +Each row shows the last prompt, initiator, provider, client, token usage, +network requests, thread count, and timestamp. The **Network Requests** column reports the total and blocked [Agent Firewall](../agent-firewall/index.md) requests for the session. It shows From ae9505a61dc3b3134da400c1ba2cd1a9934f21f6 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 19 Aug 2026 14:49:09 +0000 Subject: [PATCH 3/4] refactor(site): address review feedback on sessions time range Drop the TimeRange re-export and import it from the component module directly. Use dayjs utc formatting for RFC 3339 instead of a regex. Detect existing time bounds by parsing the filter query instead of a regex, so a quoted value mentioning started_after is not mistaken for a bound. A deliberately one-sided query is left alone. --- .../ListSessionsFilter.stories.tsx | 2 +- .../ListSessionsPage/ListSessionsFilter.tsx | 2 +- .../ListSessionsPageView.stories.tsx | 2 +- .../ListSessionsPage/timeRange.test.ts | 10 ++++++- .../ListSessionsPage/timeRange.ts | 28 ++++++++++++------- 5 files changed, 30 insertions(+), 14 deletions(-) diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.stories.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.stories.tsx index 132244bea5748..2a8f2aa40307e 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.stories.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.stories.tsx @@ -1,12 +1,12 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { ComponentProps } from "react"; import { fn } from "storybook/test"; +import type { TimeRange } from "#/components/DateTimeRangeFilter/timeRange"; import { getDefaultFilterProps, MockMenu, } from "#/components/Filter/storyHelpers"; import { ListSessionsFilter } from "./ListSessionsFilter"; -import type { TimeRange } from "./timeRange"; type FilterProps = ComponentProps; diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.tsx index 51d624d1852d3..747064f70898b 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.tsx @@ -1,5 +1,6 @@ import type { FC } from "react"; import { DateTimeRangeFilter } from "#/components/DateTimeRangeFilter/DateTimeRangeFilter"; +import type { TimeRange } from "#/components/DateTimeRangeFilter/timeRange"; import { Filter, MenuSkeleton, @@ -12,7 +13,6 @@ import { ProviderFilter, type ProviderFilterMenu, } from "../filters/ProviderFilter"; -import type { TimeRange } from "./timeRange"; // Narrower than the SelectFilter default so the search input keeps most // of the row on wide viewports. diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.stories.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.stories.tsx index eca9ff5e0aa71..80e8e7a1d6a1a 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.stories.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { ComponentProps } from "react"; import { fn } from "storybook/test"; +import type { TimeRange } from "#/components/DateTimeRangeFilter/timeRange"; import { getDefaultFilterProps, MockMenu, @@ -11,7 +12,6 @@ import { } from "#/components/PaginationWidget/PaginationContainer.mocks"; import { MockSession } from "#/testHelpers/entities"; import { ListSessionsPageView } from "./ListSessionsPageView"; -import type { TimeRange } from "./timeRange"; type FilterProps = ComponentProps["filterProps"]; diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.test.ts b/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.test.ts index 51aba139aa65f..94d333b1aeeec 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.test.ts +++ b/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; +import type { TimeRange } from "#/components/DateTimeRangeFilter/timeRange"; import { defaultTimeRange, parseTimeRange, setTimeRangeInQuery, - type TimeRange, toRFC3339, withDefaultTimeRange, } from "./timeRange"; @@ -53,6 +53,14 @@ describe("withDefaultTimeRange", () => { const beforeOnly = 'started_before:"2026-08-01T00:00:00Z"'; expect(withDefaultTimeRange(beforeOnly, range)).toBe(beforeOnly); }); + + it("does not mistake a quoted value containing the key for a bound", () => { + // A quoted value that mentions started_after is not a time bound. + const query = 'session_id:"started_after:2026-08-01"'; + expect(withDefaultTimeRange(query, range)).toBe( + 'session_id:"started_after:2026-08-01" started_after:"2026-08-12T15:00:00Z" started_before:"2026-08-13T15:00:00Z"', + ); + }); }); describe("setTimeRangeInQuery", () => { diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.ts b/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.ts index 7914fb3e59f43..f67f6a8c2fd26 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.ts +++ b/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.ts @@ -1,11 +1,16 @@ +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; import type { TimeRange } from "#/components/DateTimeRangeFilter/timeRange"; -import { stringifyFilter } from "#/components/Filter/filterQuery"; +import { + parseFilterQuery, + stringifyFilter, +} from "#/components/Filter/filterQuery"; -export type { TimeRange } from "#/components/DateTimeRangeFilter/timeRange"; +dayjs.extend(utc); /** Serializes a Date as RFC 3339 in UTC with second precision. */ export const toRFC3339 = (date: Date): string => { - return date.toISOString().replace(/\.\d{3}Z$/, "Z"); + return dayjs(date).utc().format("YYYY-MM-DDTHH:mm:ss[Z]"); }; /** The default sessions window: the 24 hours ending at now. */ @@ -14,26 +19,29 @@ export const defaultTimeRange = (now: Date): TimeRange => ({ startedBefore: now, }); -const TIME_RANGE_KEY_PATTERN = /started_(after|before):/; - /** * Appends the default time range to a filter query unless the query already - * sets an explicit started_after or started_before. The default is kept in - * memory instead of the URL so shared links resolve relative to the - * viewer's current time. + * sets started_after or started_before. A deliberately one-sided query is + * left alone. The default is kept in memory instead of the URL so shared + * links resolve relative to the viewer's current time. */ export const withDefaultTimeRange = ( query: string, range: TimeRange, ): string => { - if (TIME_RANGE_KEY_PATTERN.test(query)) { + const values = parseFilterQuery(query); + if ( + values.started_after !== undefined || + values.started_before !== undefined + ) { return query; } const suffix = stringifyFilter({ + ...values, started_after: toRFC3339(range.startedAfter), started_before: toRFC3339(range.startedBefore), }); - return query === "" ? suffix : `${query} ${suffix}`; + return suffix; }; /** From f3bbbb10d8de9fa76f7bac2f9bd821ce47d362d4 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 19 Aug 2026 15:22:21 +0000 Subject: [PATCH 4/4] refactor(site): rename setTimeRangeInQuery to queryWithTimeRange The function returns a stringified filter query rather than setting anything; name it for what it returns. --- .../AIBridgePage/ListSessionsPage/ListSessionsPage.tsx | 4 ++-- .../pages/AIBridgePage/ListSessionsPage/timeRange.test.ts | 6 +++--- site/src/pages/AIBridgePage/ListSessionsPage/timeRange.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPage.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPage.tsx index d32fd29009eaa..efaf984c7f7c7 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPage.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPage.tsx @@ -17,7 +17,7 @@ import { ListSessionsPageView } from "./ListSessionsPageView"; import { defaultTimeRange, parseTimeRange, - setTimeRangeInQuery, + queryWithTimeRange, withDefaultTimeRange, } from "./timeRange"; @@ -121,7 +121,7 @@ const AISessionListPage: FC = () => { timeRange, defaultTimeRange: defaultRange, onTimeRangeChange: (range) => - filter.update(setTimeRangeInQuery(filter.values, range)), + filter.update(queryWithTimeRange(filter.values, range)), }} /> diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.test.ts b/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.test.ts index 94d333b1aeeec..b2a00ea81ecfc 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.test.ts +++ b/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.test.ts @@ -3,7 +3,7 @@ import type { TimeRange } from "#/components/DateTimeRangeFilter/timeRange"; import { defaultTimeRange, parseTimeRange, - setTimeRangeInQuery, + queryWithTimeRange, toRFC3339, withDefaultTimeRange, } from "./timeRange"; @@ -63,7 +63,7 @@ describe("withDefaultTimeRange", () => { }); }); -describe("setTimeRangeInQuery", () => { +describe("queryWithTimeRange", () => { const range: TimeRange = { startedAfter: new Date(Date.UTC(2026, 7, 12, 15, 0, 0)), startedBefore: new Date(Date.UTC(2026, 7, 13, 15, 0, 0)), @@ -71,7 +71,7 @@ describe("setTimeRangeInQuery", () => { it("preserves other filters and replaces the time range", () => { expect( - setTimeRangeInQuery( + queryWithTimeRange( { initiator: "me", started_after: "2026-01-01T00:00:00Z", diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.ts b/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.ts index f67f6a8c2fd26..1d33e59719a3e 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.ts +++ b/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.ts @@ -50,7 +50,7 @@ export const withDefaultTimeRange = ( * stringifyFilter quotes the RFC 3339 values because the backend query * parser treats unquoted colons as key/value separators. */ -export const setTimeRangeInQuery = ( +export const queryWithTimeRange = ( values: Record, range: TimeRange, ): string => {