diff --git a/frontend/index.html b/frontend/index.html
index 21c0ad48..082ad5ce 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -3,7 +3,7 @@
-
+
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 6b353179..1f2aa660 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -189,18 +189,100 @@
messageListRef?.scrollToOrdinal(next.ordinals[0]!);
}
- // React to route changes: initialize session filters from URL params
+ // React to route changes: initialize session filters from URL params.
+ // Only track route and params — NOT sessionId. When the URL sync
+ // effect deselects a session (changing sessionId), we must not
+ // re-run initFromParams or it will reset filters the user just set.
$effect(() => {
const _route = router.route;
const params = router.params;
untrack(() => {
- sessions.initFromParams(params);
+ const sid = router.sessionId;
+ if (!sid) {
+ sessions.initFromParams(params);
+ }
sessions.load();
sessions.loadProjects();
sessions.loadAgents();
});
});
+ // Deep-link: select session from URL and handle ?msg param.
+ $effect(() => {
+ const sid = router.sessionId;
+ const msgParam = router.params["msg"] ?? null;
+ untrack(() => {
+ if (sid) {
+ if (sid !== sessions.activeSessionId) {
+ sessions.navigateToSession(sid);
+ }
+ if (msgParam) {
+ if (msgParam === "last") {
+ ui.pendingScrollOrdinal = -1;
+ ui.pendingScrollSession = sid;
+ } else {
+ const ordinal = parseInt(msgParam, 10);
+ if (Number.isFinite(ordinal)) {
+ ui.scrollToOrdinal(ordinal, sid);
+ }
+ }
+ }
+ } else if (router.route === "sessions") {
+ if (sessions.activeSessionId !== null) {
+ sessions.deselectSession();
+ }
+ }
+ });
+ });
+
+ // Resolve msg=last once messages are loaded.
+ $effect(() => {
+ const pending = ui.pendingScrollOrdinal;
+ const loading = messages.loading;
+ const msgs = messages.messages;
+ untrack(() => {
+ if (pending !== -1 || loading || msgs.length === 0) return;
+ const target = ui.pendingScrollSession;
+ if (target !== null && target !== messages.sessionId) return;
+ const lastOrdinal = msgs[msgs.length - 1]!.ordinal;
+ ui.scrollToOrdinal(lastOrdinal, target ?? undefined);
+ });
+ });
+
+ // Build URL params from current session filters.
+ function buildFilterParams(): Record {
+ const f = sessions.filters;
+ const p: Record = {};
+ if (f.project) p.project = f.project;
+ if (f.machine) p.machine = f.machine;
+ if (f.agent) p.agent = f.agent;
+ if (f.date) p.date = f.date;
+ if (f.dateFrom) p.date_from = f.dateFrom;
+ if (f.dateTo) p.date_to = f.dateTo;
+ if (f.recentlyActive) p.active_since = "true";
+ if (f.hideUnknownProject) p.exclude_project = "unknown";
+ if (f.minMessages > 0) p.min_messages = String(f.minMessages);
+ if (f.maxMessages > 0) p.max_messages = String(f.maxMessages);
+ if (f.minUserMessages > 0) p.min_user_messages = String(f.minUserMessages);
+ if (f.includeOneShot) p.include_one_shot = "true";
+ return p;
+ }
+
+ // Sync active session to URL.
+ $effect(() => {
+ const activeId = sessions.activeSessionId;
+ const currentUrlSessionId = router.sessionId;
+ untrack(() => {
+ if (router.route !== "sessions") return;
+ if (activeId === currentUrlSessionId) return;
+ if (activeId) {
+ router.navigateToSession(activeId);
+ } else {
+ router.navigateFromSession(buildFilterParams());
+ }
+ });
+ });
+
function showAbout() {
if (ui.activeModal === "resync" && sync.syncing) return;
ui.activeModal = "about";
diff --git a/frontend/src/lib/components/analytics/TopSessions.svelte b/frontend/src/lib/components/analytics/TopSessions.svelte
index 7d4b8330..6ac115fd 100644
--- a/frontend/src/lib/components/analytics/TopSessions.svelte
+++ b/frontend/src/lib/components/analytics/TopSessions.svelte
@@ -17,15 +17,11 @@
}
function handleSessionClick(id: string) {
- const params: Record = {};
- if (analytics.includeOneShot) {
- params["include_one_shot"] = "true";
- }
- sessions.pendingNavTarget = id;
- if (!router.navigate("sessions", params)) {
- sessions.pendingNavTarget = null;
- sessions.selectSession(id);
+ if (analytics.includeOneShot && !sessions.filters.includeOneShot) {
+ sessions.filters.includeOneShot = true;
+ sessions.invalidateFilterCaches();
}
+ router.navigateToSession(id);
}
diff --git a/frontend/src/lib/components/analytics/TopSessions.test.ts b/frontend/src/lib/components/analytics/TopSessions.test.ts
new file mode 100644
index 00000000..8bf37068
--- /dev/null
+++ b/frontend/src/lib/components/analytics/TopSessions.test.ts
@@ -0,0 +1,131 @@
+// @vitest-environment jsdom
+import {
+ describe,
+ it,
+ expect,
+ vi,
+ afterEach,
+ beforeEach,
+ type MockInstance,
+} from "vitest";
+import { mount, unmount, tick } from "svelte";
+// @ts-ignore
+import TopSessions from "./TopSessions.svelte";
+import { analytics } from "../../stores/analytics.svelte.js";
+import { sessions } from "../../stores/sessions.svelte.js";
+import { router } from "../../stores/router.svelte.js";
+
+describe("TopSessions", () => {
+ let cacheSpy: MockInstance;
+ let navSpy: MockInstance;
+
+ beforeEach(() => {
+ cacheSpy = vi
+ .spyOn(sessions, "invalidateFilterCaches")
+ .mockImplementation(() => {});
+ navSpy = vi
+ .spyOn(router, "navigateToSession")
+ .mockImplementation(() => {});
+ });
+
+ let savedLoading: typeof analytics.loading;
+ let savedErrors: typeof analytics.errors;
+
+ beforeEach(() => {
+ savedLoading = { ...analytics.loading };
+ savedErrors = { ...analytics.errors };
+ });
+
+ afterEach(() => {
+ cacheSpy.mockRestore();
+ navSpy.mockRestore();
+ analytics.includeOneShot = false;
+ analytics.topSessions = null;
+ // @ts-ignore
+ analytics.loading = savedLoading;
+ // @ts-ignore
+ analytics.errors = savedErrors;
+ sessions.filters.includeOneShot = false;
+ window.history.replaceState(null, "", "/");
+ });
+
+ function mountWithData() {
+ analytics.topSessions = {
+ metric: "messages",
+ sessions: [
+ {
+ id: "sess-1",
+ project: "proj",
+ first_message: "hello",
+ message_count: 10,
+ duration_min: 5,
+ },
+ ],
+ };
+ // @ts-ignore — loading is reactive state
+ analytics.loading = {
+ ...analytics.loading,
+ topSessions: false,
+ };
+ // @ts-ignore
+ analytics.errors = {
+ ...analytics.errors,
+ topSessions: null,
+ };
+
+ return mount(TopSessions, { target: document.body });
+ }
+
+ function clickRow() {
+ const row = document.querySelector(".session-row");
+ expect(row).toBeTruthy();
+ row!.dispatchEvent(
+ new MouseEvent("click", { bubbles: true }),
+ );
+ }
+
+ it("sets filter and navigates when analytics includeOneShot is enabled", async () => {
+ analytics.includeOneShot = true;
+ const component = mountWithData();
+ await tick();
+
+ clickRow();
+ await tick();
+
+ expect(sessions.filters.includeOneShot).toBe(true);
+ expect(cacheSpy).toHaveBeenCalledOnce();
+ expect(navSpy).toHaveBeenCalledWith("sess-1");
+
+ unmount(component);
+ });
+
+ it("skips invalidation but still navigates when filter already set", async () => {
+ analytics.includeOneShot = true;
+ sessions.filters.includeOneShot = true;
+ const component = mountWithData();
+ await tick();
+
+ clickRow();
+ await tick();
+
+ expect(cacheSpy).not.toHaveBeenCalled();
+ expect(navSpy).toHaveBeenCalledWith("sess-1");
+
+ unmount(component);
+ });
+
+ it("navigates without setting filter when analytics includeOneShot is off", async () => {
+ analytics.includeOneShot = false;
+ const component = mountWithData();
+ await tick();
+
+ clickRow();
+ await tick();
+
+ expect(sessions.filters.includeOneShot).toBe(false);
+ expect(cacheSpy).not.toHaveBeenCalled();
+ expect(navSpy).toHaveBeenCalledWith("sess-1");
+
+ unmount(component);
+ });
+});
diff --git a/frontend/src/lib/components/content/SubagentInline.svelte b/frontend/src/lib/components/content/SubagentInline.svelte
index ec8a4a3a..0f41d6ef 100644
--- a/frontend/src/lib/components/content/SubagentInline.svelte
+++ b/frontend/src/lib/components/content/SubagentInline.svelte
@@ -45,12 +45,7 @@
async function openAsSession(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
- if (router.route === "sessions") {
- await sessions.navigateToSession(sessionId);
- } else {
- sessions.pendingNavTarget = sessionId;
- router.navigate("sessions");
- }
+ router.navigateToSession(sessionId);
}
let agentLabel = $derived(sessionMeta?.agent ?? null);
@@ -88,7 +83,7 @@
{/if}
{
if (ui.isMobileViewport && router.route !== "sessions") {
- sessions.deselectSession();
router.navigate("sessions");
ui.sidebarOpen = true;
} else {
@@ -120,10 +119,7 @@