Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
758b382
docs: add design spec for URL-based session linking (#180)
mariusvniekerk Mar 25, 2026
c0285ac
Remove specs
wesm Mar 25, 2026
31a9625
fix: address review findings for URL routing (#180)
wesm Mar 25, 2026
f43b135
fix: refresh sticky params and make buildSessionHref sticky-aware (#180)
wesm Mar 25, 2026
2d40a54
fix: prevent sticky param loss across consecutive navigations (#180)
wesm Mar 25, 2026
1fc9fac
feat: add copy-link button to session breadcrumb bar (#180)
wesm Mar 25, 2026
f8a10d2
fix: prevent deep-link bounce, guard URI decode, sync params (#180)
wesm Mar 25, 2026
79bf8a7
fix: scope copy-link confirmation to session ID (#180)
wesm Mar 25, 2026
12da4a7
fix: use absolute base path for path-based routing (#180)
wesm Mar 25, 2026
db4eb27
test: add regression coverage for sticky params, deep-link, copy time…
wesm Mar 25, 2026
b284546
fix: use root-relative favicon path for deep links (#180)
wesm Mar 25, 2026
6c503e3
test: add fake-timer regression test for copy-link re-click (#180)
wesm Mar 25, 2026
d1602ee
fix: remove direct selectSession in TopSessions, guard msg=last (#180)
wesm Mar 25, 2026
e951712
fix: restore include_one_shot filter on analytics session click (#180)
wesm Mar 25, 2026
63a0a19
test: add regression coverage for includeOneShot filter from analytic…
wesm Mar 25, 2026
2865e6d
test: replace indirect store test with TopSessions component test (#180)
wesm Mar 25, 2026
da904fd
test: fix TopSessions test pollution and assert navigation in all pat…
wesm Mar 25, 2026
d93f850
test: restore analytics.loading and analytics.errors in afterEach (#180)
wesm Mar 25, 2026
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
2 changes: 1 addition & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400..700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
Expand Down
86 changes: 84 additions & 2 deletions frontend/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
const f = sessions.filters;
const p: Record<string, string> = {};
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";
Expand Down
12 changes: 4 additions & 8 deletions frontend/src/lib/components/analytics/TopSessions.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,11 @@
}

function handleSessionClick(id: string) {
const params: Record<string, string> = {};
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);
}
</script>

Expand Down
131 changes: 131 additions & 0 deletions frontend/src/lib/components/analytics/TopSessions.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
9 changes: 2 additions & 7 deletions frontend/src/lib/components/content/SubagentInline.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -88,7 +83,7 @@
{/if}
</button>
<a
href="#{sessionId}"
href={router.buildSessionHref(sessionId)}
class="open-session-link"
onclick={openAsSession}
title="Open as full session"
Expand Down
11 changes: 2 additions & 9 deletions frontend/src/lib/components/layout/AppHeader.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,6 @@
class="hamburger"
onclick={() => {
if (ui.isMobileViewport && router.route !== "sessions") {
sessions.deselectSession();
router.navigate("sessions");
ui.sidebarOpen = true;
} else {
Expand All @@ -120,10 +119,7 @@
</button>
<button
class="header-home"
onclick={() => {
sessions.deselectSession();
router.navigate("sessions");
}}
onclick={() => router.navigate("sessions")}
title="Home"
>
<svg class="header-logo" width="18" height="18" viewBox="0 0 32 32" aria-hidden="true">
Expand All @@ -145,10 +141,7 @@
<button
class="nav-btn"
class:active={router.route === "sessions"}
onclick={() => {
sessions.deselectSession();
router.navigate("sessions");
}}
onclick={() => router.navigate("sessions")}
title="Sessions"
aria-label="Sessions"
>
Expand Down
Loading
Loading