Skip to content

Commit 9375702

Browse files
mariusvniekerkwesmclaude
authored
feat: add URL-based session linking (#180) (#225)
## Summary Migrate the frontend router from hash-based to HTML5 History API path-based routing, enabling shareable deep links to sessions and messages. ### Router overhaul - Replace hash routing with path-based URLs: `/sessions`, `/sessions/{id}`, `/insights`, `/pinned`, `/trash`, `/settings` - Add `?msg=N` and `?msg=last` query params for deep-linking to specific messages within a session - URL-driven session selection: the `App.svelte` URL sync effect is the single source of truth for session activation, eliminating duplicate navigation calls that caused race conditions - Browser back/forward navigation works via `popstate` handler ### Sticky query params - Bootstrap params like `desktop` are preserved across all navigations without callers needing to pass them explicitly - Separate `#updateSticky` (programmatic navigations, partial update) and `#replaceSticky` (popstate, full replacement) to handle the distinct semantics correctly - `router.params` reflects the merged URL query string including sticky params ### Copy session link - Add a link icon button to the session breadcrumb bar (next to find and actions buttons) - Copies the full session URL including sticky params to the clipboard - Checkmark confirmation scoped by session ID with timer restart on re-click ### Deep-link reliability - Set `activeSessionId` synchronously in `navigateToSession` before the async metadata fetch, preventing the URL sync effect from bouncing back to `/sessions` - Guard `decodeURIComponent` in `parsePath` against malformed percent escapes - `buildSessionHref` moved to a `RouterStore` method so it includes sticky params for middle-click / copy-link on subagent session links ### Build and asset fixes - Change Vite `base` from `"./"` to `"/"` so assets use absolute paths — relative paths broke on any route deeper than `/` - Make favicon href root-relative (`/favicon.svg`) for the same reason - Go server `handleSPA` already serves `index.html` for unknown paths; `serveIndexWithBase` rewrites absolute paths for reverse-proxy base path deployments --------- Co-authored-by: Wes McKinney <wesmckinn+git@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0821d03 commit 9375702

16 files changed

+808
-176
lines changed

frontend/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<head>
44
<meta charset="UTF-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6-
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
6+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
77
<link rel="preconnect" href="https://fonts.googleapis.com">
88
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
99
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400..700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">

frontend/src/App.svelte

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,18 +189,100 @@
189189
messageListRef?.scrollToOrdinal(next.ordinals[0]!);
190190
}
191191
192-
// React to route changes: initialize session filters from URL params
192+
// React to route changes: initialize session filters from URL params.
193+
// Only track route and params — NOT sessionId. When the URL sync
194+
// effect deselects a session (changing sessionId), we must not
195+
// re-run initFromParams or it will reset filters the user just set.
193196
$effect(() => {
194197
const _route = router.route;
195198
const params = router.params;
196199
untrack(() => {
197-
sessions.initFromParams(params);
200+
const sid = router.sessionId;
201+
if (!sid) {
202+
sessions.initFromParams(params);
203+
}
198204
sessions.load();
199205
sessions.loadProjects();
200206
sessions.loadAgents();
201207
});
202208
});
203209
210+
// Deep-link: select session from URL and handle ?msg param.
211+
$effect(() => {
212+
const sid = router.sessionId;
213+
const msgParam = router.params["msg"] ?? null;
214+
untrack(() => {
215+
if (sid) {
216+
if (sid !== sessions.activeSessionId) {
217+
sessions.navigateToSession(sid);
218+
}
219+
if (msgParam) {
220+
if (msgParam === "last") {
221+
ui.pendingScrollOrdinal = -1;
222+
ui.pendingScrollSession = sid;
223+
} else {
224+
const ordinal = parseInt(msgParam, 10);
225+
if (Number.isFinite(ordinal)) {
226+
ui.scrollToOrdinal(ordinal, sid);
227+
}
228+
}
229+
}
230+
} else if (router.route === "sessions") {
231+
if (sessions.activeSessionId !== null) {
232+
sessions.deselectSession();
233+
}
234+
}
235+
});
236+
});
237+
238+
// Resolve msg=last once messages are loaded.
239+
$effect(() => {
240+
const pending = ui.pendingScrollOrdinal;
241+
const loading = messages.loading;
242+
const msgs = messages.messages;
243+
untrack(() => {
244+
if (pending !== -1 || loading || msgs.length === 0) return;
245+
const target = ui.pendingScrollSession;
246+
if (target !== null && target !== messages.sessionId) return;
247+
const lastOrdinal = msgs[msgs.length - 1]!.ordinal;
248+
ui.scrollToOrdinal(lastOrdinal, target ?? undefined);
249+
});
250+
});
251+
252+
// Build URL params from current session filters.
253+
function buildFilterParams(): Record<string, string> {
254+
const f = sessions.filters;
255+
const p: Record<string, string> = {};
256+
if (f.project) p.project = f.project;
257+
if (f.machine) p.machine = f.machine;
258+
if (f.agent) p.agent = f.agent;
259+
if (f.date) p.date = f.date;
260+
if (f.dateFrom) p.date_from = f.dateFrom;
261+
if (f.dateTo) p.date_to = f.dateTo;
262+
if (f.recentlyActive) p.active_since = "true";
263+
if (f.hideUnknownProject) p.exclude_project = "unknown";
264+
if (f.minMessages > 0) p.min_messages = String(f.minMessages);
265+
if (f.maxMessages > 0) p.max_messages = String(f.maxMessages);
266+
if (f.minUserMessages > 0) p.min_user_messages = String(f.minUserMessages);
267+
if (f.includeOneShot) p.include_one_shot = "true";
268+
return p;
269+
}
270+
271+
// Sync active session to URL.
272+
$effect(() => {
273+
const activeId = sessions.activeSessionId;
274+
const currentUrlSessionId = router.sessionId;
275+
untrack(() => {
276+
if (router.route !== "sessions") return;
277+
if (activeId === currentUrlSessionId) return;
278+
if (activeId) {
279+
router.navigateToSession(activeId);
280+
} else {
281+
router.navigateFromSession(buildFilterParams());
282+
}
283+
});
284+
});
285+
204286
function showAbout() {
205287
if (ui.activeModal === "resync" && sync.syncing) return;
206288
ui.activeModal = "about";

frontend/src/lib/components/analytics/TopSessions.svelte

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,11 @@
1717
}
1818
1919
function handleSessionClick(id: string) {
20-
const params: Record<string, string> = {};
21-
if (analytics.includeOneShot) {
22-
params["include_one_shot"] = "true";
23-
}
24-
sessions.pendingNavTarget = id;
25-
if (!router.navigate("sessions", params)) {
26-
sessions.pendingNavTarget = null;
27-
sessions.selectSession(id);
20+
if (analytics.includeOneShot && !sessions.filters.includeOneShot) {
21+
sessions.filters.includeOneShot = true;
22+
sessions.invalidateFilterCaches();
2823
}
24+
router.navigateToSession(id);
2925
}
3026
</script>
3127

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// @vitest-environment jsdom
2+
import {
3+
describe,
4+
it,
5+
expect,
6+
vi,
7+
afterEach,
8+
beforeEach,
9+
type MockInstance,
10+
} from "vitest";
11+
import { mount, unmount, tick } from "svelte";
12+
// @ts-ignore
13+
import TopSessions from "./TopSessions.svelte";
14+
import { analytics } from "../../stores/analytics.svelte.js";
15+
import { sessions } from "../../stores/sessions.svelte.js";
16+
import { router } from "../../stores/router.svelte.js";
17+
18+
describe("TopSessions", () => {
19+
let cacheSpy: MockInstance;
20+
let navSpy: MockInstance;
21+
22+
beforeEach(() => {
23+
cacheSpy = vi
24+
.spyOn(sessions, "invalidateFilterCaches")
25+
.mockImplementation(() => {});
26+
navSpy = vi
27+
.spyOn(router, "navigateToSession")
28+
.mockImplementation(() => {});
29+
});
30+
31+
let savedLoading: typeof analytics.loading;
32+
let savedErrors: typeof analytics.errors;
33+
34+
beforeEach(() => {
35+
savedLoading = { ...analytics.loading };
36+
savedErrors = { ...analytics.errors };
37+
});
38+
39+
afterEach(() => {
40+
cacheSpy.mockRestore();
41+
navSpy.mockRestore();
42+
analytics.includeOneShot = false;
43+
analytics.topSessions = null;
44+
// @ts-ignore
45+
analytics.loading = savedLoading;
46+
// @ts-ignore
47+
analytics.errors = savedErrors;
48+
sessions.filters.includeOneShot = false;
49+
window.history.replaceState(null, "", "/");
50+
});
51+
52+
function mountWithData() {
53+
analytics.topSessions = {
54+
metric: "messages",
55+
sessions: [
56+
{
57+
id: "sess-1",
58+
project: "proj",
59+
first_message: "hello",
60+
message_count: 10,
61+
duration_min: 5,
62+
},
63+
],
64+
};
65+
// @ts-ignore — loading is reactive state
66+
analytics.loading = {
67+
...analytics.loading,
68+
topSessions: false,
69+
};
70+
// @ts-ignore
71+
analytics.errors = {
72+
...analytics.errors,
73+
topSessions: null,
74+
};
75+
76+
return mount(TopSessions, { target: document.body });
77+
}
78+
79+
function clickRow() {
80+
const row = document.querySelector(".session-row");
81+
expect(row).toBeTruthy();
82+
row!.dispatchEvent(
83+
new MouseEvent("click", { bubbles: true }),
84+
);
85+
}
86+
87+
it("sets filter and navigates when analytics includeOneShot is enabled", async () => {
88+
analytics.includeOneShot = true;
89+
const component = mountWithData();
90+
await tick();
91+
92+
clickRow();
93+
await tick();
94+
95+
expect(sessions.filters.includeOneShot).toBe(true);
96+
expect(cacheSpy).toHaveBeenCalledOnce();
97+
expect(navSpy).toHaveBeenCalledWith("sess-1");
98+
99+
unmount(component);
100+
});
101+
102+
it("skips invalidation but still navigates when filter already set", async () => {
103+
analytics.includeOneShot = true;
104+
sessions.filters.includeOneShot = true;
105+
const component = mountWithData();
106+
await tick();
107+
108+
clickRow();
109+
await tick();
110+
111+
expect(cacheSpy).not.toHaveBeenCalled();
112+
expect(navSpy).toHaveBeenCalledWith("sess-1");
113+
114+
unmount(component);
115+
});
116+
117+
it("navigates without setting filter when analytics includeOneShot is off", async () => {
118+
analytics.includeOneShot = false;
119+
const component = mountWithData();
120+
await tick();
121+
122+
clickRow();
123+
await tick();
124+
125+
expect(sessions.filters.includeOneShot).toBe(false);
126+
expect(cacheSpy).not.toHaveBeenCalled();
127+
expect(navSpy).toHaveBeenCalledWith("sess-1");
128+
129+
unmount(component);
130+
});
131+
});

frontend/src/lib/components/content/SubagentInline.svelte

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,7 @@
4545
async function openAsSession(e: MouseEvent) {
4646
e.preventDefault();
4747
e.stopPropagation();
48-
if (router.route === "sessions") {
49-
await sessions.navigateToSession(sessionId);
50-
} else {
51-
sessions.pendingNavTarget = sessionId;
52-
router.navigate("sessions");
53-
}
48+
router.navigateToSession(sessionId);
5449
}
5550
5651
let agentLabel = $derived(sessionMeta?.agent ?? null);
@@ -88,7 +83,7 @@
8883
{/if}
8984
</button>
9085
<a
91-
href="#{sessionId}"
86+
href={router.buildSessionHref(sessionId)}
9287
class="open-session-link"
9388
onclick={openAsSession}
9489
title="Open as full session"

frontend/src/lib/components/layout/AppHeader.svelte

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,6 @@
104104
class="hamburger"
105105
onclick={() => {
106106
if (ui.isMobileViewport && router.route !== "sessions") {
107-
sessions.deselectSession();
108107
router.navigate("sessions");
109108
ui.sidebarOpen = true;
110109
} else {
@@ -120,10 +119,7 @@
120119
</button>
121120
<button
122121
class="header-home"
123-
onclick={() => {
124-
sessions.deselectSession();
125-
router.navigate("sessions");
126-
}}
122+
onclick={() => router.navigate("sessions")}
127123
title="Home"
128124
>
129125
<svg class="header-logo" width="18" height="18" viewBox="0 0 32 32" aria-hidden="true">
@@ -145,10 +141,7 @@
145141
<button
146142
class="nav-btn"
147143
class:active={router.route === "sessions"}
148-
onclick={() => {
149-
sessions.deselectSession();
150-
router.navigate("sessions");
151-
}}
144+
onclick={() => router.navigate("sessions")}
152145
title="Sessions"
153146
aria-label="Sessions"
154147
>

0 commit comments

Comments
 (0)