chat urls: a group is a path, a session decides its own directory

the chat group moves from ?cwd= to a path suffix behind a g/ discriminator
(/chat/g/home/me/project), and a session url carries no group at all.

the real fix is not the spelling. a session's working directory was read back
off the query string to decide where the agent executes, so the address bar was
the authority on where code runs. a pasted or refreshed /chat/<id> arrives with
no ?cwd= at all, so a turn sent before the resolve landed ran in the default
general_chat_sessions dir instead of the project; and a hand-edited ?cwd= could
name a group the session doesn't belong to, with nothing to reconcile them.

loadClaudeSessionById already resolves a session's cwd from the id alone, so the
id is the only source of truth there. it now travels on SelectedSession.cwd,
which is what the composer reads. the url can no longer contradict it.

the vocabulary lives in apps/ChatHistory/chat-routes.ts so a link built in a
panel and one built in a screen cannot drift.

also: startAgentRun no longer returns a literal chatUrl — it returns cwd and
AgentRunnerModal builds the link, so the server holds no copy of the frontend
url shape. and the post-turn permalink strips a stale ?cwd= instead of carrying
it forward onto the new session's url.

walkthrough doc gains item 13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 23:59:39 +00:00
co-authored by Claude Opus 5
parent 203f65d03f
commit 236541fa2a
12 changed files with 225 additions and 68 deletions
+63
View File
@@ -212,6 +212,69 @@ be built, but it is its own piece of work.
---
## 13. The chat URL: `?cwd=` is gone, and a session decides its own directory
**Where:** the address bar, everywhere in `/chat`. Also the **Run agent** dialog in the file browser
right-click menu.
**What to do:**
1. Open `/chat`, pick a project in the pwd dropdown. The URL is now `/chat/g/home/pastilhas/dockers/…`
a readable path, not `?cwd=%2Fhome%2Fpastilhas%2F…`.
2. Click a conversation. The URL becomes just `/chat/<uuid>`**no group at all**. Copy it.
3. Paste it into a fresh tab. It should open the same conversation, with the list beside it scoped to
that conversation's project, and the pwd dropdown showing that project.
4. **The one that used to be broken:** in that fresh tab, send a message _immediately_, before anything
settles. It should run in the project directory, not in the general chat directory.
5. Right-click a folder in the file browser → **Run agent** → run one → **Open in chat**. It should land
on that folder's group list.
6. Mobile: open a conversation, hit back. You should return to the project's list, not to an empty
default one.
**What changed, and why it is more than cosmetic.**
The group used to be a query parameter, and the session's working directory was read _back off that
query parameter_ to decide where the agent actually executes. So the address bar was the authority on
where code runs. Two consequences, both of which you had noticed as "sometimes it doesn't load in the
right place":
- A pasted or refreshed `/chat/<id>` arrives with **no `?cwd=` at all**. The screen then fetched the
session and wrote the cwd into the URL — but there was a window before that landed, and a turn sent in
that window ran in the default `general_chat_sessions` directory instead of the project.
- Even after it landed, the URL was hand-editable, so a `?cwd=` naming one project and a session
belonging to another could disagree. Nothing reconciled them; the URL simply won.
The fix is not really "path instead of query string" — that part is presentation. It is that **there is
now one source of truth for a session's directory: the session.** `loadClaudeSessionById` already scans
every project group and reads the real cwd out of the transcript, so the id alone determines it. The
directory now travels on the selection (`SelectedSession.cwd`), which is what the composer reads. The URL
no longer carries it for a session, so it cannot contradict it.
A group, on the other hand, genuinely _is_ addressable state and belongs in the URL — so it is a path
suffix behind a `g/` discriminator: `/chat/g/home/me/project`, `/chat/new/g/home/me/project`. Spelled as
a path rather than a percent-encoded blob because Officer always sits behind a reverse proxy and `%2F` is
exactly the character proxies normalise or reject.
The vocabulary lives in one file, `apps/ChatHistory/chat-routes.ts``chatListPath`, `chatNewPath`,
`chatSessionPath`, `cwdFromSplat` — so a link built in a panel and a link built in a screen cannot drift.
**The agentic side, which you flagged.** `startAgentRun` used to return a literal
`chatUrl: '/chat?cwd=…'` — the server holding an opinion about frontend URL shape, and therefore holding
a copy that goes stale the moment that shape changes. It now returns just `cwd` (which it already did),
and `AgentRunnerModal` builds the link with `chatListPath`. One less place that knows what a chat URL
looks like.
**Also fixed in passing:** the permalink written after a turn completes (`useChat`) used to carry the
whole query string forward, which re-attached a stale `?cwd=` to the new session's URL. It now strips
`cwd` and leaves everything else alone.
**Not verified:** as with everything else here, none of this has been through a browser. The typecheck is
clean and the route ranking has been checked against React Router 7's scoring (a `/chat/g/*` pattern
scores 23 against `/chat/:sessionId`'s 17, so a group path can never be mistaken for a session id) — but
step 4 above is the one I most want you to actually try, because it is the bug this was for.
---
## Things noticed and deliberately left alone
- **`useChatWebSocket` silently ignores unparseable frames.** That one is intentional and the comment