Skip to content

feat: add URL-based session linking (#180)#225

Merged
wesm merged 18 commits intowesm:mainfrom
mariusvniekerk:Marius-van-Niekerk/issue-180
Mar 25, 2026
Merged

feat: add URL-based session linking (#180)#225
wesm merged 18 commits intowesm:mainfrom
mariusvniekerk:Marius-van-Niekerk/issue-180

Conversation

@mariusvniekerk
Copy link
Contributor

@mariusvniekerk mariusvniekerk commented Mar 24, 2026

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

@roborev-ci
Copy link

roborev-ci bot commented Mar 24, 2026

roborev: Combined Review (24998c0)

Summary Verdict: This PR implements History API routing and deep linking, but introduces a high-severity infinite scroll bug, unhandled URI decoding exceptions, and missing validation for URL parameters that
need to be addressed.

High

  • Unceasing Scroll Bug
    • Location: frontend/src/App.svelte (lines 228-238)
    • Problem: ui.pendingScrollOrdinal is never cleared after the
      scroll resolves. Because the msgs array is tracked outside untrack, any subsequent message appended re-triggers the effect, evaluates pending !== -1 as false, and forces the user to the bottom indefinitely.
    • Fix: Add ui.pendingScrollOrdinal = null; (
      or similar reset) inside the untrack block before or after calling ui.scrollToOrdinal.

Medium

  • Unhandled URIError in Path Parsing

    • Location: frontend/src/lib/stores/router.svelte.ts (line 55)

    • Problem: decodeURIComponent(segments[1]!) will throw an unhandled URIError if the URL contains malformed percent-encoding. Because parsePath() is called in the store constructor, this will crash the initialization and result in a blank screen (client-side DoS
      ).

    • Fix: Wrap the decode in a try/catch block and treat invalid encodings as an absent/invalid session ID, falling back to /sessions instead of letting the exception abort app initialization.

  • Missing Validation for Session ID Routing

    • Location
      :
      frontend/src/lib/stores/router.svelte.ts (line 53), frontend/src/App.svelte (line 196)
    • Problem: URL routing makes sessionId attacker-controlled input, percent-decoding it and forwarding it
      into sessions.navigateToSession(sid) without schema validation. Encoded /, \, .., and other reserved characters survive the decode step, which could lead to path/request manipulation downstream.
    • Fix: Validate sessionId at the routing boundary against the real session ID format before
      calling navigateToSession, and reject values containing slashes, backslashes, dot-segments, control characters, or anything outside the allowed charset.
  • Missing Pending Scroll Cleanup on Navigation

    • Location: frontend/src/App.svelte (line 201)

    • Problem: The new msg=last flow sets ui.pendingScrollOrdinal = -1, but there is no clearing path when the user navigates to a URL without msg before messages finish loading. A stale pending sentinel can cause an unexpected later scroll on a different navigation.

    • Fix: Clear pendingScrollOrdinal and pendingScrollSession whenever msg is absent/invalid or the deep-linked session changes away from the pending target.

  • Deep Link Fallback Bug

    • Location: frontend/src/lib/ stores/router.svelte.ts (line 37)
    • Problem: parsePath() falls back unknown first segments to the "sessions" route before extracting sessionId, so a URL like /unknown/abc is parsed as session abc instead of the default sessions page.
      That turns arbitrary two-segment unknown paths into deep links.
    • Fix: Only extract sessionId when the first path segment is literally "sessions".
  • Duplicate Session Load Race Condition

    • Location: frontend/src/lib/components/pinned/Pinned Page.svelte (line 27), frontend/src/lib/components/content/SubagentInline.svelte (line 48), frontend/src/App.svelte (line 194)
    • Problem: These handlers call router.navigateToSession (...) and then sessions.navigateToSession(...), while App.svelte also calls sessions.navigateToSession(...) whenever router.sessionId changes. From non-session routes this can trigger duplicate async session loads and race the same state update.
    • Fix: Choose a single source
      of truth for session navigation: either let the URL sync effect react to router.sessionId, or have the component select the session and let the state-to-URL effect update the URL, but not both.

Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

mariusvniekerk and others added 11 commits March 24, 2026 19:20
- feat: add URL sync for session deep linking and msg param (wesm#180)
- feat: migrate router from hash-based to History API (wesm#180)
- docs: add implementation plan for URL-based session linking (wesm#180)
- feat: migrate all callers to path-based routing (wesm#180)
- refactor: remove pendingNavTarget, replaced by URL sync (wesm#180)
- fix: prevent filter reset when URL sync deselects a session (wesm#180)
- fix: address code review findings for URL routing (wesm#180)
- Remove duplicate session navigation in PinnedPage and SubagentInline;
  let the App.svelte URL sync effect be the single source of session
  selection to prevent racing fetches and duplicate list entries.
- Preserve non-routing query params (e.g. desktop) across navigations
  by capturing sticky bootstrap params at init and merging them in
  #buildUrl.
- Remove ineffective urlSyncSuppressed flag — Svelte 5 effects run
  asynchronously so the synchronous set/unset was a no-op; the
  activeId === currentUrlSessionId guard already prevents loops.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…sm#180)

- Add #refreshSticky to update sticky params whenever params change:
  after every navigation method and on popstate. Prevents stale snapshot
  from silently reverting a changed desktop value.
- Move buildSessionHref from standalone function to RouterStore method
  so it includes sticky params. Middle-click / copy-link on subagent
  "Open session" links now preserves desktop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Split #refreshSticky into #updateSticky (programmatic navigations) and
#replaceSticky (popstate). Programmatic navigations only update sticky
keys that are explicitly provided, preserving omitted ones. Popstate
does full replacement since it reflects complete URL state. Fixes bug
where two consecutive navigate() calls without desktop would drop it
after the first hop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a chain-link icon button next to the find and actions buttons in the
session breadcrumb bar. Clicking it copies the full session URL
(including sticky params like desktop) to the clipboard, with a brief
checkmark confirmation matching existing copy feedback patterns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Set activeSessionId synchronously in navigateToSession before the
  async fetch. Prevents the URL-sync effect from seeing null and
  bouncing back to /sessions while the session metadata loads.
- Wrap decodeURIComponent in parsePath with try/catch to handle
  malformed percent escapes (e.g. /sessions/foo%) without crashing.
- Merge sticky params into this.params in all navigation methods so
  router.params matches the actual URL query string.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Track the copied state by session ID instead of a boolean, matching the
existing copiedSessionId pattern. Prevents the checkmark from persisting
if the user navigates to a different session before the timeout expires.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Change Vite base from "./" to "/" so asset paths are absolute. With
relative paths, navigating to /sessions/{id} caused the browser to
resolve ./assets/main.js as /sessions/assets/main.js, breaking direct
URL navigation and deep links. The Go server's serveIndexWithBase
already rewrites absolute paths for reverse-proxy base path deployments.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
wesm#180)

- Add store.params assertions to sticky param router tests so the
  params-sync fix has direct coverage beyond just window.location.
- Add navigateToSession regression test verifying activeSessionId is
  set synchronously before the async fetch resolves, preventing the
  deep-link bounce.
- Clear/restart the copy-link timer on re-copy so the earlier timer
  cannot prematurely clear the confirmation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The favicon was still using a relative path (./favicon.svg) which
resolves to /sessions/favicon.svg on deep-link routes. The Go server's
serveIndexWithBase already rewrites href="/ for base-path deployments.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@wesm wesm force-pushed the Marius-van-Niekerk/issue-180 branch from 24998c0 to b284546 Compare March 25, 2026 01:17
Mounts the component, clicks the copy-link button twice with a 1s gap,
and verifies the confirmation stays visible for 1.5s after the second
click (not cleared early by the first timer). Uses vi.useFakeTimers to
control time advancement.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@roborev-ci
Copy link

roborev-ci bot commented Mar 25, 2026

roborev: Combined Review (6c503e3)

Verdict: The PR introduces state management bugs related to deep-linking and session navigation that need to be addressed
.

Medium Severity

  • Location: frontend/src/lib/components/analytics/TopSessions.svelte:20

    • Problem: handleSessionClick() calls sessions.selectSession(id) immediately after router.navigateToSession(id). This prevents the app-
      level deep-link effect from calling sessions.navigateToSession(id), meaning sessions not already in sessions.sessions are no longer fetched into the store before rendering. Opening a top session from analytics can leave the session header/metadata missing or stale.
    • Fix: Remove the direct selectSession() call and
      let the URL-driven effect call sessions.navigateToSession(id), or call sessions.navigateToSession(id) directly instead.
  • Location: frontend/src/App.svelte:228

    • Problem: The new msg=last resolution effect only watches messages.loading and messages.messages; it does not verify that the loaded message list still belongs to ui.pendingScrollSession. If the user navigates away before the original session finishes loading, the pending deep-link can resolve against a different session’s messages and scroll to the wrong ordinal.
    • Fix: Gate
      the resolution on the currently loaded session matching ui.pendingScrollSession (or clear the pending msg=last state when the active session changes without a matching msg param).

Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

…#180)

- Remove sessions.selectSession() call in TopSessions.handleSessionClick
  so the App.svelte URL sync effect handles session selection and
  metadata fetching. Same single-source pattern as PinnedPage and
  SubagentInline.
- Guard the msg=last resolution effect against resolving on a different
  session's messages by checking messages.sessionId matches the pending
  scroll target.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@roborev-ci
Copy link

roborev-ci bot commented Mar 25, 2026

roborev: Combined Review (d1602ee)

Verdict: The routing migration introduces medium severity state regressions related to URL filter persistence on
deep links and analytics session clicks.

Medium

  • frontend/src/App.svelte:198
    Problem: Session URLs with query filters now
    ignore those filters entirely because sessions.initFromParams(params) only runs when there is no sessionId. A direct visit or popstate to /sessions/<id>?project=...&include_one_shot=true will load the session, but the sidebar/list state stays at stale/default filters
    , so the UI no longer matches the URL and closing the session drops the encoded filter context.
    Fix: Apply filter params on route/param changes even when sessionId is present, and solve the deselect loop by narrowing the guard to the specific self-triggered transition instead of skipping all deep-linked
    session URLs.

  • frontend/src/lib/components/analytics/TopSessions.svelte:19
    Problem:
    Clicking a top session from analytics no longer forwards include_one_shot=true. That regresses the previous behavior for one-shot sessions: the deep link opens, but returning to the sessions list can immediately filter that session back out, and the URL no longer preserves the analytics state that produced the result.

    Fix: Pass { include_one_shot: "true" } into router.navigateToSession(...) when analytics.includeOneShot is enabled.


Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

wesm and others added 5 commits March 24, 2026 20:34
…#180)

The original handleSessionClick passed include_one_shot into the router
params when analytics had it enabled. The migration to URL-driven
navigation dropped this, so one-shot sessions clicked from TopSessions
could be filtered out of the list when navigating back. Now sets the
sessions filter directly and invalidates caches before navigating.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
wesm#180)

Tests the guard logic from TopSessions.handleSessionClick: verifies
the sessions filter is set and caches invalidated when analytics has
includeOneShot enabled, and that redundant invalidation is skipped
when the filter is already set.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…sm#180)

Remove the inline logic re-implementation from sessions.test.ts. Add a
proper component test that mounts TopSessions, clicks a session row, and
asserts the real handler sets sessions.filters.includeOneShot, calls
invalidateFilterCaches, and navigates via router.navigateToSession.
Also covers the skip-invalidation and disabled-analytics paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…hs (wesm#180)

Stub invalidateFilterCaches and navigateToSession with mockImplementation
to prevent real API calls and history mutations. Reset all mutated
singleton state (analytics.topSessions, filters, URL) in afterEach.
Assert router.navigateToSession("sess-1") in all three test branches.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…sm#180)

Snapshot loading/errors state in beforeEach and restore in afterEach so
mountWithData mutations to topSessions loading and error state don't
leak into other test files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@roborev-ci
Copy link

roborev-ci bot commented Mar 25, 2026

roborev: Combined Review (d93f850)

The PR successfully migrates
to path-based routing and introduces deep-link handling, but requires fixes for base path configuration, filter state preservation, and unintended auto-scrolling.

Medium Severity Findings

  • Location: frontend/vite.config.ts:16, frontend/index.html:6, frontend/ src/lib/stores/router.svelte.ts:15
    Problem: The router now has code to honor a base path, but the build was hard-coded to root URLs (base: "/" and /favicon.svg) and the app does not add a <base> element.
    In any non-root deployment, asset URLs and generated session links will point at /... instead of the mounted subpath, so deep links and static assets break even though parsePath()/buildSessionHref() appear to support a base path.
    Fix: Drive routing and asset generation from the
    same configured base URL (import.meta.env.BASE_URL or equivalent), and either emit a matching <base> tag or stop depending on one for subpath support.

  • Location: frontend/src/lib/components/analytics/TopSessions.svelte:20, frontend/ src/App.svelte:198, frontend/src/App.svelte:244
    Problem: Clicking a top session with analytics.includeOneShot enabled now mutates the in-memory filter and navigates to /sessions/{id} without preserving include_one _shot=true in the URL. That means a reload, new tab, or copied browser URL loses the filter state, and returning to the sessions list no longer reproduces the analytics-derived view that previously survived navigation via query params.
    Fix: Include the active filter params when navigating to a session,
    or initialize filters from query params even when router.sessionId is present so session URLs remain reproducible.

  • Location: frontend/src/App.svelte (in the "Resolve msg=last once messages are loaded" $effect)
    Problem: ui.pendingScrollOrdinal is
    not cleared after resolving msg=last. Because it remains -1, the $effect will repeatedly trigger on subsequent message loads, causing unintended auto-scrolling to the bottom whenever a new message arrives or if the user later revisits the same session.
    Fix: Clear the pending state (e.
    g., ui.pendingScrollOrdinal = null; ui.pendingScrollSession = null;) immediately after calling ui.scrollToOrdinal(lastOrdinal, target ?? undefined);.


Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

@wesm
Copy link
Owner

wesm commented Mar 25, 2026

Invalid findings. Will merge once CI finishes

@wesm wesm merged commit 9375702 into wesm:main Mar 25, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants