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 @@