Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions frontend/e2e/insights-quality.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,17 @@ test.describe("Insights quality rollout", () => {
await page.keyboard.press("Escape");
await expect(savedInsight).toBeVisible();
await savedInsight.click();
await expect(page).toHaveURL(/\/insights\?insight=42$/);
await expect(page).toHaveURL(/\/insights\?.*insight=42/);
const selectedInsightUrl = new URL(page.url());
expect(selectedInsightUrl.pathname).toBe("/insights");
expect(selectedInsightUrl.searchParams.get("insight")).toBe("42");
expect(selectedInsightUrl.searchParams.get("window_days")).toBe("365");
expect(selectedInsightUrl.searchParams.get("date_from")).toMatch(
/^\d{4}-\d{2}-\d{2}$/,
);
expect(selectedInsightUrl.searchParams.get("date_to")).toMatch(
/^\d{4}-\d{2}-\d{2}$/,
);

await expect(
page.locator(".generated-detail .badge", {
Expand Down Expand Up @@ -172,7 +182,17 @@ test.describe("Insights quality rollout", () => {
(window as unknown as { __copiedInsightLink?: string })
.__copiedInsightLink,
);
expect(copied).toBe(`${new URL(page.url()).origin}/insights?insight=42`);
const copiedUrl = new URL(copied!);
expect(copiedUrl.origin).toBe(selectedInsightUrl.origin);
expect(copiedUrl.pathname).toBe("/insights");
expect(copiedUrl.searchParams.get("insight")).toBe("42");
expect(copiedUrl.searchParams.get("window_days")).toBe("365");
expect(copiedUrl.searchParams.get("date_from")).toBe(
selectedInsightUrl.searchParams.get("date_from"),
);
expect(copiedUrl.searchParams.get("date_to")).toBe(
selectedInsightUrl.searchParams.get("date_to"),
);

await page.goto("/insights?insight=42");
await expect(
Expand Down
90 changes: 65 additions & 25 deletions frontend/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,18 @@
import { starred } from "./lib/stores/starred.svelte.js";
import { pins } from "./lib/stores/pins.svelte.js";
import { settings } from "./lib/stores/settings.svelte.js";
import { yokedDates } from "./lib/stores/yokedDates.svelte.js";
import { setAuthToken, getAuthToken, setServerUrl, getBase } from "./lib/api/runtime.js";
import { setupVisibilityHealthCheck } from "./lib/utils/health.js";
import { registerShortcuts } from "./lib/utils/keyboard.js";
import { shouldAutoSwitchTranscriptModeToNormal } from "./lib/utils/transcript-mode.js";
import {
filterParamsEqual,
hasFilterParams,
sessionDateIntentCleared,
sessionRouteParamsForDetailExit,
sessionRouteParamsForFilters,
} from "./lib/stores/sessionRouteParams.js";

let globalAuthToken: string = $state("");

Expand Down Expand Up @@ -222,16 +230,16 @@
messageListRef?.scrollToOrdinal(ordinal);
}

/** True when URL params contain session filter keys (deep-link). */
const SESSION_FILTER_KEYS = new Set([
"project", "machine", "agent", "date", "date_from", "date_to",
"active_since", "exclude_project", "min_messages", "max_messages",
"min_user_messages", "include_one_shot", "include_automated",
]);
function hasFilterParams(params: Record<string, string>): boolean {
return Object.keys(params).some((k) => SESSION_FILTER_KEYS.has(k));
function clearYokeForClearedSessionDates(
nextParams: Record<string, string>,
): void {
if (sessionDateIntentCleared(router.params, nextParams)) {
yokedDates.clear();
}
}

let lastDetailFilterParamsSignature: string | null = $state(null);

// React to route changes: reload sessions and apply URL params.
// Only apply URL deep-link params (initFromParams) when the URL
// actually contains filter keys — a bare /sessions preserves the
Expand Down Expand Up @@ -299,41 +307,73 @@
$effect(() => {
const activeId = sessions.activeSessionId;
const currentUrlSessionId = router.sessionId;
const filterParams = filtersToParams(sessions.filters);
const filterParamsSignature = JSON.stringify(filterParams);
untrack(() => {
if (router.route !== "sessions") return;
if (activeId === currentUrlSessionId) return;
if (router.route !== "sessions") {
lastDetailFilterParamsSignature = null;
return;
}
if (activeId) {
router.navigateToSession(activeId);
const nextParams = sessionRouteParamsForFilters(
filterParams,
router.params,
);
if (activeId === currentUrlSessionId) {
if (
lastDetailFilterParamsSignature !== null &&
lastDetailFilterParamsSignature !== filterParamsSignature &&
!filterParamsEqual(router.params, nextParams)
) {
clearYokeForClearedSessionDates(nextParams);
router.replaceParams(nextParams);
}
lastDetailFilterParamsSignature = filterParamsSignature;
return;
}
clearYokeForClearedSessionDates(nextParams);
router.navigateToSession(activeId, nextParams);
lastDetailFilterParamsSignature = filterParamsSignature;
} else {
router.navigateFromSession(filtersToParams(sessions.filters));
if (currentUrlSessionId === null) {
lastDetailFilterParamsSignature = null;
return;
}
const filterChangedOnDetail =
lastDetailFilterParamsSignature !== null &&
lastDetailFilterParamsSignature !== filterParamsSignature;
const nextParams = filterChangedOnDetail
? sessionRouteParamsForFilters(
filterParams,
router.params,
)
: sessionRouteParamsForDetailExit(
filterParams,
router.params,
);
clearYokeForClearedSessionDates(nextParams);
router.navigateFromSession(nextParams);
lastDetailFilterParamsSignature = null;
}
});
});

// Compare only filter keys so sticky params (e.g. desktop)
// don't cause spurious replaceParams calls.
function filterParamsEqual(
a: Record<string, string>,
b: Record<string, string>,
): boolean {
for (const k of SESSION_FILTER_KEYS) {
if ((a[k] ?? "") !== (b[k] ?? "")) return false;
}
return true;
}

// URL write-back: keep query string in sync with filter state
// when on /sessions with no session selected, so users can
// share/bookmark the view and the URL reflects what's shown.
// Tracks route so a tab switch back to /sessions also syncs
// the URL with localStorage-restored filters.
$effect(() => {
const route = router.route;
const newParams = filtersToParams(sessions.filters);
const newParams = sessionRouteParamsForFilters(
filtersToParams(sessions.filters),
router.params,
);
untrack(() => {
if (route !== "sessions") return;
if (router.sessionId) return;
if (filterParamsEqual(router.params, newParams)) return;
clearYokeForClearedSessionDates(newParams);
router.replaceParams(newParams);
});
});
Expand Down
144 changes: 144 additions & 0 deletions frontend/src/App.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { describe, expect, it } from "vite-plus/test";
import source from "./App.svelte?raw";
import { SESSION_FILTER_KEYS } from "./lib/stores/sessionRouteParams.js";

function appSourceSlice(startMarker: string, endMarker: string): string {
const start = source.indexOf(startMarker);
expect(start).toBeGreaterThan(-1);
const end = source.indexOf(endMarker, start);
expect(end).toBeGreaterThan(start);
return source.slice(start, end);
}

describe("App session URL date state", () => {
it("treats rolling window and termination as sessions route params", () => {
expect(SESSION_FILTER_KEYS.has("window_days")).toBe(true);
expect(SESSION_FILTER_KEYS.has("termination")).toBe(true);
});

it("preserves rolling window dates when writing sessions URLs", () => {
expect(source).toContain("sessionRouteParamsForFilters(");
expect(source).toContain("router.navigateFromSession(nextParams)");
expect(source).toContain(
"const newParams = sessionRouteParamsForFilters(",
);
expect(source).not.toContain(
"navigateFromSession(filtersToParams(sessions.filters))",
);
expect(source).not.toContain(
"const newParams = filtersToParams(sessions.filters);",
);
});

it("preserves rolling window dates when entering session detail", () => {
const syncUrlIndex = source.indexOf("// Sync active session to URL.");
const navigateFromSessionIndex = source.indexOf(
"router.navigateFromSession",
syncUrlIndex,
);
const activeSessionBranch = source.slice(
syncUrlIndex,
navigateFromSessionIndex,
);

expect(activeSessionBranch).toContain(
"const nextParams = sessionRouteParamsForFilters(",
);
expect(activeSessionBranch).toContain(
"router.navigateToSession(activeId, nextParams)",
);
expect(activeSessionBranch).not.toContain(
"router.navigateToSession(activeId);",
);
});

it("preserves direct detail URL params when leaving session detail", () => {
const syncUrlIndex = source.indexOf("// Sync active session to URL.");
const navigateFromSessionIndex = source.indexOf(
"router.navigateFromSession",
syncUrlIndex,
);
const inactiveSessionBranch = source.slice(
navigateFromSessionIndex - 260,
navigateFromSessionIndex + 80,
);

expect(source).toContain("sessionRouteParamsForDetailExit");
expect(inactiveSessionBranch).toContain(
": sessionRouteParamsForDetailExit(",
);
expect(inactiveSessionBranch).toContain(
"router.navigateFromSession(nextParams)",
);
});

it("updates detail URL params after explicit filter changes", () => {
const syncUrlBlock = appSourceSlice(
"// Sync active session to URL.",
"\n\n // URL write-back",
);

expect(source).toContain(
"let lastDetailFilterParamsSignature: string | null = $state(null);",
);
expect(syncUrlBlock).toContain("const filterParams = filtersToParams(");
expect(syncUrlBlock).toContain(
"lastDetailFilterParamsSignature !== null &&",
);
expect(syncUrlBlock).toContain("router.replaceParams(nextParams);");
expect(syncUrlBlock).toContain(
"lastDetailFilterParamsSignature = filterParamsSignature;",
);
});

it("does not preserve stale detail params after filter changes", () => {
const syncUrlBlock = appSourceSlice(
"// Sync active session to URL.",
"\n\n // URL write-back",
);

expect(syncUrlBlock).toContain("const filterChangedOnDetail =");
expect(syncUrlBlock).toContain(
"filterChangedOnDetail\n ? sessionRouteParamsForFilters(",
);
expect(syncUrlBlock).toContain(
": sessionRouteParamsForDetailExit(",
);
});

it("clears stored yoke when session date params are removed while analytics is unmounted", () => {
const syncUrlBlock = appSourceSlice(
"// Sync active session to URL.",
"\n\n // URL write-back",
);
const writeBackBlock = appSourceSlice(
"// URL write-back",
"\n\n function showAbout",
);

expect(source).toContain("import { yokedDates");
expect(source).toContain("function clearYokeForClearedSessionDates");
expect(source).toContain("sessionDateIntentCleared(");
expect(source).toContain("yokedDates.clear();");
expect(syncUrlBlock).toContain(
"clearYokeForClearedSessionDates(nextParams);",
);
expect(writeBackBlock).toContain(
"clearYokeForClearedSessionDates(newParams);",
);
});

it("clears detail filter signatures outside session detail routes", () => {
const syncUrlBlock = appSourceSlice(
"// Sync active session to URL.",
"\n\n // URL write-back",
);

expect(syncUrlBlock).toContain(
'if (router.route !== "sessions") {\n lastDetailFilterParamsSignature = null;',
);
expect(syncUrlBlock).toContain(
"if (currentUrlSessionId === null) {\n lastDetailFilterParamsSignature = null;",
);
});
});
Loading