Skip to content

feat(chat): per-chat URL routing + browser tab title mirroring#134

Open
constkolesnyak wants to merge 4 commits into
ClickHouse:mainfrom
constkolesnyak:upstream/per-chat-urls
Open

feat(chat): per-chat URL routing + browser tab title mirroring#134
constkolesnyak wants to merge 4 commits into
ClickHouse:mainfrom
constkolesnyak:upstream/per-chat-urls

Conversation

@constkolesnyak

Copy link
Copy Markdown
Contributor

Summary

Each chat now has its own URL (/chat/<sessionId>), the sidebar renders sessions as <Link> so they're middle-clickable / Vimium-hintable / shareable, and the browser tab mirrors the active session's title. Race conditions around URL ↔ activeSession sync are handled explicitly rather than via mirror effects.

Independent of the broader keyboard-shortcut work (those parts of the original commits were lifted out — this PR is the URL routing + tab title only).

Changes

  1. Sidebar sessions as <Link to="/chat/<id>">SessionSidebar.tsx. Previously <div onClick={switchSession}>, so Vimium couldn't hint them and there was no shareable URL per chat (address bar always read /chat).

  2. URL → activeSession is the only mirror directionChatPage.tsx. Reading the URL into activeSession happens in the useEffect[sessionId] block. Going the other way (mirror activeSession into URL via effect) is intentionally NOT done — it races with loadSessions() (which starts with sessions=[], so any "URL is unknown" check is unreliable on a fresh tab) and with the server's session_switched WS message that fires before our store knows the URL's session exists. Instead each call-site that changes the active session navigates explicitly:

    • handleCreateSession — creates session, then navigate('/chat/<new>')
    • handleDeleteSession — deletes, then navigate to the new active or to /chat
  3. Cmd+click opens the correct chat in a new tab — fresh-tab opened with /chat/A used to briefly bounce to a different chat because two effects raced before loadSessions() resolved. Fixed by collapsing the duplicate URL-mirror effect (see (2)) so the fresh tab just trusts the URL.

  4. Browser tab title mirrors active session titleChatPage.tsx. Same strip rules as the sidebar (leading # and Implement: removed). Falls back to "Nerve" when there is no active session or when ChatPage unmounts, so other pages don't inherit a stale chat title.

Tests

npm run build clean (✓ built in 8.78s). No new tests — pure web feature. Verified by:

  • Cmd+click on a session in the sidebar → new tab opens with the correct /chat/<id> and stays on that chat
  • Switching sessions updates the browser tab title
  • Refreshing on /chat/<id> loads the right session even before loadSessions() resolves
  • Navigating away from /chat resets the title to "Nerve"

Files

web/src/components/Chat/SessionSidebar.tsx       (-18 / +14)  sessions render as <Link>
web/src/pages/ChatPage.tsx                       (-3 / +44)   handleCreateSession/handleDeleteSession + title effect + URL sync
web/src/stores/handlers/sessionHandlers.ts       (-3 / +8)    don't yank URL when switchSession resolves before the route change

Note

The original four commits (dd578d9, d04eac7, bd63a27, f399c5d) bundled this change with chat-scoped keyboard shortcuts (useKeyboardShortcuts(chatShortcuts)). Those parts are tracked separately in the keyboard-shortcuts PR (#133) and were dropped here so this PR stands alone.

Generated with Claude Code

Session rows used to be <div onClick={switchSession}>, so Vimium couldn't
hint them and there was no shareable URL per chat — the address bar always
read /chat.

- SessionSidebar: wrap each session row (conversations + system) in a
  react-router <Link to={`/chat/${id}`}>. Menu buttons inside the row now
  preventDefault on click so opening the kebab doesn't navigate.
- ChatPage: drop the onSelect prop; add a navigate() effect that mirrors
  activeSession into the URL (replace), so createSession / WS-driven
  switches / deleteSession auto-pick all update the address bar too.
The previous "mirror activeSession into the URL" effect ran on every
click: the <Link> set sessionId=A instantly, but activeSession was still
B until switchSession() resolved a tick later. The effect saw the
mismatch and yanked the URL back to /chat/B, which then re-triggered
switchSession, which re-triggered the mirror — infinite flip.

Guard the mirror: only push activeSession into the URL when the URL
can't be the source of truth (no sessionId, or sessionId points to a
session that's no longer in the list — i.e. just deleted). On a normal
click the URL is valid, so the effect stays out of the way and lets
switchSession catch up naturally.
A fresh tab opened with /chat/A would briefly bounce to a different chat
because two effects raced before loadSessions() resolved:
- the URL-mirror useEffect ran with sessions=[], decided "URL points to
  unknown session", and yanked URL → /chat/<store-default>
- the WS session_switched handler also ran on connect with activeSession=""
  and switched to the server-side default, which then propagated to URL

- ChatPage: drop the mirror effect entirely; instead navigate explicitly
  from handleCreateSession and handleDeleteSession (the two places that
  change activeSession without a URL change). useCallback for stability.
- App.tsx: global new-chat shortcut awaits createSession() and navigates
  to /chat/<new-id> with replace so the URL reflects the just-made chat.
- sessionHandlers: handleSessionSwitched no longer overrides when the
  URL already names a specific chat — ChatPage's useEffect[sessionId]
  will switch to it. Prevents the new-tab flash through the server
  default.
Sets document.title to the cleaned session title (same strip rules as
the sidebar: leading '#' and 'Implement:' removed). Falls back to plain
"Nerve" when there is no active session or when ChatPage unmounts, so
other pages don't inherit a stale chat title.
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.

1 participant