From 758b382c4a16e815fa2bd1c7df719577ae05e871 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Tue, 24 Mar 2026 19:19:46 -0500 Subject: [PATCH 01/18] docs: add design spec for URL-based session linking (#180) - feat: add URL sync for session deep linking and msg param (#180) - feat: migrate router from hash-based to History API (#180) - docs: add implementation plan for URL-based session linking (#180) - feat: migrate all callers to path-based routing (#180) - refactor: remove pendingNavTarget, replaced by URL sync (#180) - fix: prevent filter reset when URL sync deselects a session (#180) - fix: address code review findings for URL routing (#180) --- .../plans/2026-03-24-url-session-linking.md | 909 ++++++++++++++++++ .../2026-03-24-url-session-linking-design.md | 146 +++ frontend/src/App.svelte | 91 +- .../components/analytics/TopSessions.svelte | 11 +- .../components/content/SubagentInline.svelte | 12 +- .../lib/components/layout/AppHeader.svelte | 11 +- .../layout/ThreeColumnLayout.svelte | 3 - .../lib/components/pinned/PinnedPage.svelte | 4 +- frontend/src/lib/stores/router.svelte.ts | 134 ++- frontend/src/lib/stores/router.test.ts | 260 +++-- frontend/src/lib/stores/sessions.svelte.ts | 10 +- frontend/src/lib/utils/keyboard.ts | 1 - 12 files changed, 1411 insertions(+), 181 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-24-url-session-linking.md create mode 100644 docs/superpowers/specs/2026-03-24-url-session-linking-design.md diff --git a/docs/superpowers/plans/2026-03-24-url-session-linking.md b/docs/superpowers/plans/2026-03-24-url-session-linking.md new file mode 100644 index 00000000..53e2624f --- /dev/null +++ b/docs/superpowers/plans/2026-03-24-url-session-linking.md @@ -0,0 +1,909 @@ +# URL-Based Session Linking Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps +> use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add path-based URL routing so users can share direct links to +sessions and messages (e.g. `/sessions/{id}?msg=last`). + +**Architecture:** Migrate the existing hash-based router to use the +History API (pushState/popstate). Add a two-way sync effect in +App.svelte that reflects `sessions.activeSessionId` into the URL and +vice versa. The `msg` query parameter supports numeric ordinals and +`last`. + +**Tech Stack:** Svelte 5 (runes), TypeScript, Vitest (jsdom), History +API + +**Spec:** +`docs/superpowers/specs/2026-03-24-url-session-linking-design.md` + +--- + +## File Map + +| File | Change | Responsibility | +| --- | --- | --- | +| `frontend/src/lib/stores/router.svelte.ts` | Rewrite | Hash→path parsing, pushState navigation, `sessionId` field | +| `frontend/src/lib/stores/router.test.ts` | Rewrite | Tests for `parsePath`, `RouterStore` with History API | +| `frontend/src/App.svelte` | Modify | URL sync effect, `msg` param handling, initial deep link | +| `frontend/src/lib/components/analytics/TopSessions.svelte` | Modify | Replace `pendingNavTarget` pattern with `selectSession` | +| `frontend/src/lib/components/pinned/PinnedPage.svelte` | Modify | Replace `pendingNavTarget` pattern | +| `frontend/src/lib/components/content/SubagentInline.svelte` | Modify | Replace `pendingNavTarget` pattern, fix href | +| `frontend/src/lib/components/layout/AppHeader.svelte` | Modify | Update navigate calls | +| `frontend/src/lib/components/layout/ThreeColumnLayout.svelte` | Modify | Update navigate call | +| `frontend/src/lib/components/analytics/AgentComparison.svelte` | Modify | Update navigate call | +| `frontend/src/lib/components/analytics/SessionShape.svelte` | Modify | Update navigate call | +| `frontend/src/lib/utils/keyboard.ts` | Modify | Update navigate/route checks | +| `frontend/src/lib/utils/keyboard.test.ts` | Modify | Update test assertions | +| `frontend/src/lib/stores/sessions.svelte.ts` | Modify | Remove `pendingNavTarget` | + +--- + +### Task 1: Rewrite `parsePath` and its tests + +**Files:** + +- Modify: `frontend/src/lib/stores/router.svelte.ts` +- Modify: `frontend/src/lib/stores/router.test.ts` + +This task replaces `parseHash()` with `parsePath()`. The new function +reads `window.location.pathname` and `window.location.search` instead +of `window.location.hash`. It also extracts a `sessionId` from +`/sessions/{id}` paths. + +A `getBasePath()` helper reads the `` tag (injected by the +Go server for reverse-proxy setups) and strips it from the pathname +before parsing. + +- [ ] **Step 1: Write failing tests for `parsePath`** + +Replace the `parseHash` describe block in `router.test.ts` with tests +for `parsePath`: + +```typescript +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, +} from "vitest"; +import { + parsePath, + RouterStore, +} from "./router.svelte.js"; + +function setURL(path: string) { + window.history.replaceState(null, "", path); +} + +describe("parsePath", () => { + afterEach(() => { + setURL("/"); + }); + + it("returns default route for root path", () => { + setURL("/"); + const result = parsePath(); + expect(result.route).toBe("sessions"); + expect(result.sessionId).toBeNull(); + expect(result.params).toEqual({}); + }); + + it("parses /sessions with query params", () => { + setURL("/sessions?project=myproj&machine=laptop"); + const result = parsePath(); + expect(result.route).toBe("sessions"); + expect(result.sessionId).toBeNull(); + expect(result.params).toEqual({ + project: "myproj", + machine: "laptop", + }); + }); + + it("parses /sessions/{id}", () => { + setURL("/sessions/abc-123"); + const result = parsePath(); + expect(result.route).toBe("sessions"); + expect(result.sessionId).toBe("abc-123"); + expect(result.params).toEqual({}); + }); + + it("parses /sessions/{id} with msg param", () => { + setURL("/sessions/abc-123?msg=5"); + const result = parsePath(); + expect(result.route).toBe("sessions"); + expect(result.sessionId).toBe("abc-123"); + expect(result.params).toEqual({ msg: "5" }); + }); + + it("parses /sessions/{id} with msg=last", () => { + setURL("/sessions/abc-123?msg=last"); + const result = parsePath(); + expect(result.sessionId).toBe("abc-123"); + expect(result.params).toEqual({ msg: "last" }); + }); + + it("parses page routes", () => { + for (const route of [ + "insights", + "pinned", + "trash", + "settings", + ]) { + setURL(`/${route}`); + const result = parsePath(); + expect(result.route).toBe(route); + expect(result.sessionId).toBeNull(); + } + }); + + it("falls back to default for unknown routes", () => { + setURL("/unknown"); + const result = parsePath(); + expect(result.route).toBe("sessions"); + expect(result.sessionId).toBeNull(); + }); + + it("strips basePath from pathname", () => { + const base = document.createElement("base"); + base.href = "/agentsview/"; + document.head.appendChild(base); + try { + setURL("/agentsview/sessions/abc"); + const result = parsePath(); + expect(result.route).toBe("sessions"); + expect(result.sessionId).toBe("abc"); + } finally { + base.remove(); + } + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd frontend && npx vitest run src/lib/stores/router.test.ts` +Expected: FAIL — `parsePath` is not exported + +- [ ] **Step 3: Implement `parsePath` and `getBasePath`** + +Replace the `parseHash` function in `router.svelte.ts`: + +```typescript +type Route = + | "sessions" + | "insights" + | "pinned" + | "trash" + | "settings"; + +const VALID_ROUTES: ReadonlySet = new Set([ + "sessions", + "insights", + "pinned", + "trash", + "settings", +]); + +const DEFAULT_ROUTE: Route = "sessions"; + +function getBasePath(): string { + const base = document.querySelector("base"); + if (!base) return ""; + const href = base.getAttribute("href") ?? ""; + // Normalize: strip trailing slash, keep leading + return href.replace(/\/+$/, ""); +} + +export function parsePath(): { + route: Route; + sessionId: string | null; + params: Record; +} { + const basePath = getBasePath(); + let pathname = window.location.pathname; + if (basePath && pathname.startsWith(basePath)) { + pathname = pathname.slice(basePath.length); + } + if (!pathname.startsWith("/")) pathname = "/" + pathname; + + const segments = pathname + .split("/") + .filter((s) => s.length > 0); + const routeStr = segments[0] ?? ""; + const route: Route = VALID_ROUTES.has(routeStr) + ? (routeStr as Route) + : DEFAULT_ROUTE; + + // Extract session ID from /sessions/{id} + const sessionId = + route === "sessions" && segments.length >= 2 + ? segments[1]! + : null; + + const params = Object.fromEntries( + new URLSearchParams(window.location.search), + ); + + return { route, sessionId, params }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd frontend && npx vitest run src/lib/stores/router.test.ts` +Expected: `parsePath` tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/lib/stores/router.svelte.ts \ + frontend/src/lib/stores/router.test.ts +git commit -m "feat: replace parseHash with parsePath for path-based routing (#180)" +``` + +--- + +### Task 2: Migrate RouterStore to History API + +**Files:** + +- Modify: `frontend/src/lib/stores/router.svelte.ts` +- Modify: `frontend/src/lib/stores/router.test.ts` + +Migrate `RouterStore` from hash-based to History API: `popstate` instead +of `hashchange`, `pushState`/`replaceState` instead of setting +`window.location.hash`. Add reactive `sessionId` field. Keep the same +public API shape (`route`, `params`, `navigate()`). + +- [ ] **Step 1: Write failing tests for migrated RouterStore** + +Replace the `RouterStore` describe block in `router.test.ts`: + +```typescript +describe("RouterStore", () => { + let store: RouterStore; + + afterEach(() => { + store?.destroy(); + setURL("/"); + }); + + it("initializes with parsed path", () => { + setURL("/sessions?project=test"); + store = new RouterStore(); + expect(store.route).toBe("sessions"); + expect(store.params).toEqual({ project: "test" }); + expect(store.sessionId).toBeNull(); + }); + + it("initializes sessionId from path", () => { + setURL("/sessions/abc-123"); + store = new RouterStore(); + expect(store.route).toBe("sessions"); + expect(store.sessionId).toBe("abc-123"); + }); + + it("falls back to default on invalid route", () => { + setURL("/bogus"); + store = new RouterStore(); + expect(store.route).toBe("sessions"); + }); + + it("navigate updates URL via pushState", () => { + setURL("/"); + store = new RouterStore(); + const spy = vi.spyOn(window.history, "pushState"); + store.navigate("insights"); + expect(spy).toHaveBeenCalled(); + expect(store.route).toBe("insights"); + spy.mockRestore(); + }); + + it("navigate returns false on same URL (no-op)", () => { + setURL("/sessions"); + store = new RouterStore(); + const result = store.navigate("sessions"); + expect(result).toBe(false); + }); + + it("navigate with params builds query string", () => { + setURL("/"); + store = new RouterStore(); + store.navigate("sessions", { project: "foo" }); + expect(window.location.pathname).toBe("/sessions"); + expect(window.location.search).toBe("?project=foo"); + }); + + it("navigateToSession updates URL to /sessions/{id}", () => { + setURL("/sessions"); + store = new RouterStore(); + store.navigateToSession("abc-123"); + expect(window.location.pathname).toBe("/sessions/abc-123"); + expect(store.sessionId).toBe("abc-123"); + }); + + it("navigateToSession with msg param", () => { + setURL("/sessions"); + store = new RouterStore(); + store.navigateToSession("abc-123", { msg: "last" }); + expect(window.location.pathname).toBe("/sessions/abc-123"); + expect(window.location.search).toBe("?msg=last"); + }); + + it("navigateFromSession returns to /sessions", () => { + setURL("/sessions/abc-123"); + store = new RouterStore(); + store.navigateFromSession(); + expect(window.location.pathname).toBe("/sessions"); + expect(store.sessionId).toBeNull(); + }); + + it("navigateFromSession preserves filter params", () => { + setURL("/sessions/abc-123"); + store = new RouterStore(); + store.navigateFromSession({ project: "myproj" }); + expect(window.location.pathname).toBe("/sessions"); + expect(window.location.search).toBe("?project=myproj"); + }); + + it("responds to popstate events", () => { + setURL("/sessions"); + store = new RouterStore(); + // Simulate navigating then going back + setURL("/insights"); + window.dispatchEvent(new PopStateEvent("popstate")); + expect(store.route).toBe("insights"); + }); + + it("destroy removes popstate listener", () => { + setURL("/"); + const addSpy = vi.spyOn(window, "addEventListener"); + store = new RouterStore(); + const registeredCb = addSpy.mock.calls.find( + ([event]) => event === "popstate", + )?.[1]; + addSpy.mockRestore(); + + const removeSpy = vi.spyOn( + window, + "removeEventListener", + ); + store.destroy(); + expect(removeSpy).toHaveBeenCalledWith( + "popstate", + registeredCb, + ); + removeSpy.mockRestore(); + }); + + it("replaceParams uses replaceState", () => { + setURL("/sessions"); + store = new RouterStore(); + const spy = vi.spyOn(window.history, "replaceState"); + store.replaceParams({ project: "bar" }); + expect(spy).toHaveBeenCalled(); + expect(window.location.search).toBe("?project=bar"); + spy.mockRestore(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd frontend && npx vitest run src/lib/stores/router.test.ts` +Expected: FAIL — `navigateToSession`, `navigateFromSession`, +`replaceParams`, `sessionId` don't exist yet + +- [ ] **Step 3: Rewrite RouterStore implementation** + +Replace the `RouterStore` class in `router.svelte.ts`: + +```typescript +export class RouterStore { + route: Route = $state("sessions"); + params: Record = $state({}); + sessionId: string | null = $state(null); + #onPopState: () => void; + + constructor() { + const initial = parsePath(); + this.route = initial.route; + this.params = initial.params; + this.sessionId = initial.sessionId; + + this.#onPopState = () => { + const parsed = parsePath(); + this.route = parsed.route; + this.params = parsed.params; + this.sessionId = parsed.sessionId; + }; + window.addEventListener("popstate", this.#onPopState); + } + + destroy() { + window.removeEventListener("popstate", this.#onPopState); + } + + #buildUrl( + path: string, + params: Record = {}, + ): string { + const basePath = getBasePath(); + const qs = new URLSearchParams(params).toString(); + const full = basePath + path; + return qs ? `${full}?${qs}` : full; + } + + navigate( + route: Route, + params: Record = {}, + ): boolean { + const url = this.#buildUrl(`/${route}`, params); + if ( + url === + window.location.pathname + window.location.search + ) { + return false; + } + this.route = route; + this.params = params; + this.sessionId = null; + window.history.pushState(null, "", url); + return true; + } + + navigateToSession( + id: string, + params: Record = {}, + ) { + const url = this.#buildUrl( + `/sessions/${encodeURIComponent(id)}`, + params, + ); + this.route = "sessions"; + this.params = params; + this.sessionId = id; + window.history.pushState(null, "", url); + } + + navigateFromSession( + params: Record = {}, + ) { + const url = this.#buildUrl("/sessions", params); + this.route = "sessions"; + this.params = params; + this.sessionId = null; + window.history.pushState(null, "", url); + } + + /** Update query params without creating a history entry. */ + replaceParams(params: Record) { + const path = this.sessionId + ? `/sessions/${encodeURIComponent(this.sessionId)}` + : `/${this.route}`; + const url = this.#buildUrl(path, params); + this.params = params; + window.history.replaceState(null, "", url); + } +} + +export const router = new RouterStore(); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd frontend && npx vitest run src/lib/stores/router.test.ts` +Expected: All PASS + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/lib/stores/router.svelte.ts \ + frontend/src/lib/stores/router.test.ts +git commit -m "feat: migrate RouterStore from hash to History API (#180)" +``` + +--- + +### Task 3: Add URL sync effect and `msg` handling in App.svelte + +**Files:** + +- Modify: `frontend/src/App.svelte` +- Modify: `frontend/src/lib/stores/router.svelte.ts` (if minor + adjustments needed) + +Add two sync behaviors: + +1. **State to URL**: when `sessions.activeSessionId` changes, push the + URL to `/sessions/{id}` or `/sessions`. +2. **URL to State**: on initial load and popstate, if + `router.sessionId` is set, select the session. If `msg` param is + present, queue scroll. + +The `msg=last` case resolves after messages finish loading by watching +`messages.messages` and resolving "last" to the final ordinal. + +**Note:** The `urlSyncSuppressed` flag (introduced in Step 3) must be +declared before both effects that reference it. Declare it as a +module-level `let` at the top of the new effect block. + +- [ ] **Step 1: Add URL-to-State effect for initial deep link and popstate** + +In `App.svelte`, add a new effect after the existing route-change +effect (around line 185). This watches `router.sessionId` and +`router.params.msg`: + +```typescript +// 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") { + // "last" resolves once messages load; store marker + ui.pendingScrollOrdinal = -1; // sentinel for "last" + 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(); + } + } + }); +}); +``` + +- [ ] **Step 2: Add `msg=last` resolution effect** + +Add an effect that resolves the `pendingScrollOrdinal = -1` sentinel +once messages finish loading: + +```typescript +// 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 lastOrdinal = msgs[msgs.length - 1]!.ordinal; + ui.scrollToOrdinal(lastOrdinal, ui.pendingScrollSession ?? undefined); + }); +}); +``` + +- [ ] **Step 3: Add State-to-URL sync effect** + +Add an effect that watches `sessions.activeSessionId` and pushes the +URL. Use a flag to avoid circular triggers with the URL-to-State +effect: + +```typescript +// Sync active session to URL. +let urlSyncSuppressed = false; + +$effect(() => { + const activeId = sessions.activeSessionId; + const currentUrlSessionId = router.sessionId; + untrack(() => { + if (urlSyncSuppressed) return; + if (router.route !== "sessions") return; + if (activeId === currentUrlSessionId) return; + if (activeId) { + router.navigateToSession(activeId); + } else { + router.navigateFromSession(); + } + }); +}); +``` + +In the URL-to-State effect, wrap session selection with +`urlSyncSuppressed = true` / `false` to prevent the State-to-URL +effect from redundantly pushing: + +```typescript +urlSyncSuppressed = true; +sessions.navigateToSession(sid); +urlSyncSuppressed = false; +``` + +- [ ] **Step 4: Update the route-change effect to skip re-init when session is in URL** + +The existing effect (lines 176-185) calls `sessions.initFromParams()` +on every route change. When a session is in the URL (deep link), we +don't want `initFromParams` to clear the active session. Add a guard: + +```typescript +$effect(() => { + const _route = router.route; + const params = router.params; + const sid = router.sessionId; + untrack(() => { + // Don't reset filters when deep-linked to a session + if (!sid) { + sessions.initFromParams(params); + } + sessions.load(); + sessions.loadProjects(); + sessions.loadAgents(); + }); +}); +``` + +- [ ] **Step 5: Run the dev server and manually verify** + +Run: `cd frontend && npm run dev` (in parallel with `make dev`) + +Test these URLs in a browser: + +- `/` — shows session list + analytics +- `/sessions` — same as above +- `/sessions/{valid-id}` — selects that session +- `/sessions/{valid-id}?msg=last` — selects and scrolls to last msg +- `/sessions/{valid-id}?msg=3` — selects and scrolls to ordinal 3 +- `/insights`, `/pinned`, `/trash`, `/settings` — page routes work +- Click a session in sidebar — URL updates to `/sessions/{id}` +- Click back in breadcrumb — URL returns to `/sessions` +- Browser back/forward — navigates correctly + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/App.svelte +git commit -m "feat: add URL sync for session deep linking and msg param (#180)" +``` + +--- + +### Task 4: Migrate callers to path-based routing + +**Files:** + +- Modify: + `frontend/src/lib/components/analytics/TopSessions.svelte` +- Modify: `frontend/src/lib/components/pinned/PinnedPage.svelte` +- Modify: + `frontend/src/lib/components/content/SubagentInline.svelte` +- Modify: `frontend/src/lib/components/layout/AppHeader.svelte` +- Modify: + `frontend/src/lib/components/layout/ThreeColumnLayout.svelte` +- Modify: + `frontend/src/lib/components/analytics/AgentComparison.svelte` +- Modify: + `frontend/src/lib/components/analytics/SessionShape.svelte` +- Modify: `frontend/src/lib/utils/keyboard.ts` +- Modify: `frontend/src/lib/utils/keyboard.test.ts` + +All callers that used the `pendingNavTarget` + `router.navigate()` +pattern can be simplified. Since the URL sync effect in App.svelte now +handles State-to-URL, callers just need to select the session directly +and let the effect update the URL. + +- [ ] **Step 1: Migrate TopSessions.svelte** + +The `handleSessionClick` function currently uses `pendingNavTarget` to +survive the `initFromParams` reset. With URL sync, it can simply +select the session: + +```typescript +function handleSessionClick(id: string) { + if (router.route !== "sessions") { + const params: Record = {}; + if (analytics.includeOneShot) { + params["include_one_shot"] = "true"; + } + router.navigate("sessions", params); + } + sessions.selectSession(id); +} +``` + +- [ ] **Step 2: Migrate PinnedPage.svelte** + +Replace `navigateToPin`: + +```typescript +function navigateToPin( + sessionId: string, + ordinal: number, +) { + ui.scrollToOrdinal(ordinal, sessionId); + if (router.route !== "sessions") { + router.navigate("sessions"); + } + sessions.navigateToSession(sessionId); +} +``` + +Remove the `pendingNavTarget` import/usage (it was accessed via the +sessions store, so just stop setting it). + +- [ ] **Step 3: Migrate SubagentInline.svelte** + +Simplify `openAsSession`: + +```typescript +async function openAsSession(e: MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + if (router.route !== "sessions") { + router.navigate("sessions"); + } + await sessions.navigateToSession(sessionId); +} +``` + +Update the anchor href from `href="#{sessionId}"` to +`href="/sessions/{sessionId}"` using a derived value: + +```svelte + +``` + +- [ ] **Step 4: Migrate AppHeader.svelte** + +The header has several places that call +`sessions.deselectSession(); router.navigate("sessions")`. These can +stay as-is since `navigate("sessions")` now uses `pushState`. The +deselect will trigger the URL sync effect, but `navigate` will also +push the URL — verify there's no double push. If there is, simplify +to just `router.navigate("sessions")` and let the URL-to-State effect +handle deselection. + +Review each call site: + +- Lines 76-77: Sessions button — `deselectSession()` + + `navigate("sessions")` → keep, but URL sync handles deselect + already; simplify to just `router.navigate("sessions")`. +- Lines 93-94: Same pattern → simplify. +- Lines 118-119: Same pattern → simplify. +- Lines 133, 146, 160, 344: Non-session routes — these are fine as-is. + +- [ ] **Step 5: Migrate ThreeColumnLayout.svelte** + +Read the current `mobileNav()` helper. Update the `navigate` call — +should work without changes since `navigate()` signature is the same. + +- [ ] **Step 6: Migrate AgentComparison.svelte and SessionShape.svelte** + +These use `router.navigate("sessions", { ...filterParams })`. The +API hasn't changed, so these should work as-is. Verify. + +- [ ] **Step 7: Migrate keyboard.ts** + +Update references: + +- Line 212: `router.navigate("sessions")` — same API, works as-is. +- Lines checking `router.route === "sessions"` — works as-is. + +- [ ] **Step 8: Update keyboard.test.ts** + +Update test assertions that reference `router.navigate("sessions")` or +check hash values. Replace any `window.location.hash` assertions with +`window.location.pathname` assertions. + +- [ ] **Step 9: Run all frontend tests** + +Run: `cd frontend && npx vitest run` +Expected: All PASS + +- [ ] **Step 10: Commit** + +```bash +git add frontend/src/lib/components/ \ + frontend/src/lib/utils/keyboard.ts \ + frontend/src/lib/utils/keyboard.test.ts +git commit -m "feat: migrate all callers to path-based routing (#180)" +``` + +--- + +### Task 5: Remove `pendingNavTarget` and clean up + +**Files:** + +- Modify: `frontend/src/lib/stores/sessions.svelte.ts` +- Modify: `frontend/src/lib/stores/sessions.test.ts` (if tests + reference pendingNavTarget) + +- [ ] **Step 1: Remove `pendingNavTarget` from SessionsStore** + +In `sessions.svelte.ts`: + +- Remove line 66: `pendingNavTarget: string | null = null;` +- Remove lines 170-175 in `initFromParams()`: + +```typescript +// Remove: +if (this.pendingNavTarget) { + this.setActiveSession(this.pendingNavTarget); + this.pendingNavTarget = null; +} else { + this.setActiveSession(null); +} +// Replace with: +this.setActiveSession(null); +``` + +- [ ] **Step 2: Verify no remaining references to pendingNavTarget** + +Run: `cd frontend && grep -r "pendingNavTarget" src/` +Expected: No results + +- [ ] **Step 3: Run all frontend tests** + +Run: `cd frontend && npx vitest run` +Expected: All PASS + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/lib/stores/sessions.svelte.ts \ + frontend/src/lib/stores/sessions.test.ts +git commit -m "refactor: remove pendingNavTarget, replaced by URL sync (#180)" +``` + +--- + +### Task 6: Run full test suite and manual verification + +**Files:** None (verification only) + +- [ ] **Step 1: Run Go tests** + +Run: `make test-short` +Expected: All PASS (no Go changes, but verify nothing broke) + +- [ ] **Step 2: Run frontend unit tests** + +Run: `cd frontend && npx vitest run` +Expected: All PASS + +- [ ] **Step 3: Run lint and vet** + +Run: `make lint && make vet` +Expected: Clean + +- [ ] **Step 4: Build full binary** + +Run: `make build` +Expected: Build succeeds with embedded frontend + +- [ ] **Step 5: Manual smoke test with built binary** + +Start the built binary and verify: + +- Direct URL `/sessions/{id}` loads the session +- `?msg=last` scrolls to last message +- `?msg=5` scrolls to ordinal 5 +- Sidebar click updates URL +- Back/forward navigation works +- `/insights`, `/pinned`, `/trash`, `/settings` all work +- Filters on `/sessions?project=foo` work +- Session deselect returns URL to `/sessions` + +- [ ] **Step 6: Final commit if any fixes needed** diff --git a/docs/superpowers/specs/2026-03-24-url-session-linking-design.md b/docs/superpowers/specs/2026-03-24-url-session-linking-design.md new file mode 100644 index 00000000..edfb96aa --- /dev/null +++ b/docs/superpowers/specs/2026-03-24-url-session-linking-design.md @@ -0,0 +1,146 @@ +# URL-Based Session Linking + +**Issue**: [#180](https://github.com/wesm/agentsview/issues/180) +**Date**: 2026-03-24 + +## Goal + +Allow users of a hosted AgentsView instance to share links and navigate +directly to a session and/or message in the application. + +## URL Structure + +| URL | View | +| -------------------------------- | ------------------------------------- | +| `/sessions` | Session list + analytics | +| `/sessions?project=foo` | Session list with filters | +| `/sessions/{id}` | Session selected, messages visible | +| `/sessions/{id}?msg=5` | Scrolled to message ordinal 5 | +| `/sessions/{id}?msg=last` | Scrolled to the last message | +| `/insights` | Insights page | +| `/pinned` | Pinned messages page | +| `/trash` | Trash page | +| `/settings` | Settings page | + +The `msg` query parameter supports: + +- **Numeric ordinal** (`?msg=5`): scroll to the message at that ordinal. +- **`last`** (`?msg=last`): scroll to the final message in the session. + +Filter query params (`project`, `machine`, `agent`, `include_one_shot`, +etc.) remain on `/sessions` as they work today, without the `#/` prefix. + +## Approach: Additive URL Sync + +The existing `sessions.activeSessionId` remains the source of truth for +session selection. URL syncing is a new layer added on top. + +### What stays the same + +- `RouterStore` continues to own page-level routing + (sessions/insights/pinned/trash/settings). +- `sessions.selectSession()`, `deselectSession()`, + `navigateToSession()` all work as before. +- All existing callers are unchanged. + +### What changes + +#### 1. Router migration from hash to path + +`parseHash()` becomes `parsePath()`. The `hashchange` listener becomes +`popstate`. `window.location.hash = ...` becomes +`history.pushState(...)`. Same shape, different API. + +#### 2. Session ID in URL + +The router parses `/sessions/{id}` and exposes a reactive `sessionId` +field. Page-only routes (`/insights`, etc.) have `sessionId = null`. + +#### 3. URL sync effect (App.svelte) + +Two-way sync between `sessions.activeSessionId` and the URL: + +- **State to URL**: when `activeSessionId` changes (sidebar click, + keyboard nav, etc.), call `history.pushState` to update the URL to + `/sessions/{id}` or `/sessions` (if deselected). +- **URL to State**: on initial load and `popstate` (back/forward), if + the URL contains a session ID, call + `sessions.navigateToSession(id)`. If the URL has `?msg=...`, queue + the scroll via `ui.scrollToOrdinal()`. + +#### 4. `msg` param handling + +On load/popstate, parse `?msg=last` or `?msg={ordinal}` and trigger +scroll. `msg=last` resolves after messages load by watching +`messages.messages.length`. + +## Detailed Behavior + +### Initial page load with `/sessions/{id}?msg=last` + +1. Router parses path: `route: "sessions"`, `sessionId: "abc"`, + `params: { msg: "last" }`. +2. Session list loads normally via `sessions.load()`. +3. URL sync effect sees `router.sessionId` is set and calls + `sessions.navigateToSession("abc")` (handles sessions not in the + loaded list by fetching from API). +4. `msg=last` is stored as pending; once messages finish loading, + resolve "last" to the final ordinal and call + `ui.scrollToOrdinal()`. + +### Selecting a session via sidebar click + +1. `sessions.selectSession(id)` sets `activeSessionId` as today. +2. URL sync effect detects the change and calls + `history.pushState(null, "", "/sessions/{id}")`. +3. No `msg` param added. + +### Deselecting a session + +1. `sessions.deselectSession()` sets `activeSessionId = null`. +2. URL sync effect calls + `history.pushState(null, "", "/sessions")` preserving active + filter params. + +### Browser back/forward + +1. `popstate` fires, router re-parses the URL. +2. If `sessionId` changed, sync effect selects or deselects. +3. If `msg` param is present, queue scroll. + +### Navigating to a different page + +1. `router.navigate("insights")` pushes `/insights`. +2. `router.sessionId` becomes `null`; session is deselected. + +### Filter params + +- When on `/sessions` (no session selected), filter changes update + query params via `replaceState` (avoids polluting history). +- When a session is selected at `/sessions/{id}`, filters are not in + the URL. + +### basePath + +- Read from the `` tag (already injected by the Go server + for reverse proxy support). +- Strip from pathname when parsing; prepend when constructing + pushState URLs. + +### Session not found + +If `/sessions/{id}` targets a nonexistent session, +`navigateToSession` fails silently and no session is selected. The +user sees the analytics/empty state. + +## Files to Change + +| File | Change | +| --------------------------------------------- | --------------------------------------------------- | +| `frontend/src/lib/stores/router.svelte.ts` | Hash to path migration, add `sessionId` field | +| `frontend/src/App.svelte` | Add URL sync effect, `msg` param handling | +| `frontend/src/lib/stores/sessions.svelte.ts` | Minor: remove `pendingNavTarget` if no longer needed | +| Callers of `router.navigate("sessions", ...)` | Update to use path-based params | + +No Go server changes are required; the SPA fallback handler already +serves `index.html` for all unknown paths. diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 6b353179..be9fa756 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -189,18 +189,105 @@ 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. + let urlSyncSuppressed = false; + + $effect(() => { + const sid = router.sessionId; + const msgParam = router.params["msg"] ?? null; + untrack(() => { + if (sid) { + if (sid !== sessions.activeSessionId) { + urlSyncSuppressed = true; + sessions.navigateToSession(sid); + urlSyncSuppressed = false; + } + 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) { + urlSyncSuppressed = true; + sessions.deselectSession(); + urlSyncSuppressed = false; + } + } + }); + }); + + // 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 lastOrdinal = msgs[msgs.length - 1]!.ordinal; + ui.scrollToOrdinal(lastOrdinal, ui.pendingScrollSession ?? 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 (urlSyncSuppressed) return; + 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..115910fa 100644 --- a/frontend/src/lib/components/analytics/TopSessions.svelte +++ b/frontend/src/lib/components/analytics/TopSessions.svelte @@ -17,15 +17,8 @@ } 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); - } + router.navigateToSession(id); + sessions.selectSession(id); } diff --git a/frontend/src/lib/components/content/SubagentInline.svelte b/frontend/src/lib/components/content/SubagentInline.svelte index ec8a4a3a..09ca38a4 100644 --- a/frontend/src/lib/components/content/SubagentInline.svelte +++ b/frontend/src/lib/components/content/SubagentInline.svelte @@ -6,7 +6,7 @@ import { formatTokenCount } from "../../utils/format.js"; import { computeMainModel } from "../../utils/model.js"; import { sessions } from "../../stores/sessions.svelte.js"; - import { router } from "../../stores/router.svelte.js"; + import { router, buildSessionHref } from "../../stores/router.svelte.js"; import MessageContent from "./MessageContent.svelte"; interface Props { @@ -45,12 +45,8 @@ 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); + await sessions.navigateToSession(sessionId); } let agentLabel = $derived(sessionMeta?.agent ?? null); @@ -88,7 +84,7 @@ {/if} { if (ui.isMobileViewport && router.route !== "sessions") { - sessions.deselectSession(); router.navigate("sessions"); ui.sidebarOpen = true; } else { @@ -120,10 +119,7 @@