Skip to content

fix(frontend): keep the open workspace/channel across a reload#250

Merged
haowei2000 merged 1 commit into
developfrom
claude/frontend-state-loss-refresh-d169b8
Jul 17, 2026
Merged

fix(frontend): keep the open workspace/channel across a reload#250
haowei2000 merged 1 commit into
developfrom
claude/frontend-state-loss-refresh-d169b8

Conversation

@haowei2000

Copy link
Copy Markdown
Collaborator

Change description

Refreshing the page dropped you back into the personal workspace, no matter which team workspace you were reading.

The chat route was /chat/* and carried no selection, while selectedWorkspaceId lived only in memory (chatStore persists just lastChannelByWorkspace). After a reload it was therefore always null, and ChatLayout's bootstrap fell back to the personal workspace unconditionally. The same gap meant a channel could never be linked or shared — the URL was always just /chat.

This puts the selection in the path (/chat/:workspaceId?/:channelId?) and makes that the source of truth across reloads.

The store stays the sole selection API, so all 11 call sites (rail, sidebar, dialogs, invite/register flows) are untouched. ChatLayout reconciles the two in one place: path → store on mount and back/forward, store → path for in-app selections. hydrateSelection applies an explicit pair without selectWorkspace's last-channel derivation, which would otherwise clobber the channel the path names.

Two things worth a reviewer's attention

The mirror navigates with replace, not push — a deliberate trade-off. On mobile the conversation screen is itself a pushed history entry (openChatScreen), so adding one per channel switch would make Back land on the previous channel instead of popping to the channel list, breaking the existing back gesture. Refresh-restore and shareable links both work; browser back/forward between channels is knowingly not wired up, since that needs the mobile history model reworked first. location.state is carried through the mirror for the same reason.

A latent race in loadWorkspaces — it read selectedWorkspaceId through its mount-time closure, which the path now races: the captured null would land when the request resolved and overwrite the workspace the URL had just restored. It now reads fresh via getState(), and also validates membership, so a stale or foreign link falls back home rather than resting on a workspace that renders empty.

Change type

  • Bug fix (fix)

Test

  • Unit tests added/updated
  • Front-end npm run build no error reported
  • Verified end-to-end against the kind stack (vite dev → in-cluster gateway, real login)

Manual verification:

Scenario Result
Reload while on team workspace parity-ws Stays on parity-ws (previously bounced to Personal)
Reload while #claude-smoke is open Channel restored, cold-load deep link works
Non-member workspace id in the path Falls back to personal workspace and corrects the URL

pytest is N/A — the Python backend is gone (gateway-only per docs/arch/ARCHITECTURE_OVERVIEW.md).

Note for anyone pulling this branch: the worktree's node_modules was stale and missing yaml, which white-screened vite and broke tsc. npm install fixes it; package-lock.json is unchanged. The two pre-existing PdfViewer.tsx pdf.js type errors are untouched and out of scope.

Database migration

  • Does not involve database changes

Security and Release Impact

  • Does not contain .env, logs, databases, uploaded files, private keys, tokens or production configurations
  • Updated documentation when it comes to configuration, deployment, permissions, file uploads, or Agent Bridge — N/A, no config/deploy surface touched

Related Issues

Closes #

🤖 Generated with Claude Code

The chat route was `/chat/*` and carried no selection, while
`selectedWorkspaceId` lived only in memory (chatStore persists just
`lastChannelByWorkspace`). So after every reload it was necessarily
null and ChatLayout's bootstrap unconditionally fell back to the
personal workspace — a user reading a team workspace got dumped back
home, and a channel could never be linked or shared.

Put the selection in the path (`/chat/:workspaceId?/:channelId?`) and
make that the source of truth across reloads. The store stays the sole
selection API — all 11 call sites (rail, sidebar, dialogs, invite and
register flows) are untouched — with ChatLayout reconciling the two in
one place: path → store on mount and back/forward, store → path for
in-app selections. `hydrateSelection` applies an explicit pair without
selectWorkspace's last-channel derivation, which would otherwise
clobber the channel the path names.

Two things worth flagging for review:

- The mirror navigates with `replace`, not `push`. On mobile the
  conversation screen is itself a pushed history entry, so an entry per
  channel switch would make Back land on the previous channel instead of
  popping to the list. Refresh-restore and shareable links both work;
  browser back/forward *between channels* is deliberately not wired up,
  as that needs the mobile history model reworked first.

- loadWorkspaces read `selectedWorkspaceId` through its mount-time
  closure, which the path now races: the captured null would overwrite
  the workspace just restored. It reads fresh via getState() and also
  validates membership, so a stale or foreign link falls back home
  instead of resting on a workspace that renders empty.

Verified against the kind stack (vite dev → in-cluster gateway): reload
on a team workspace stays put; reload on a channel restores it; a
non-member workspace id in the path falls back and corrects the URL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@haowei2000
haowei2000 merged commit 5213053 into develop Jul 17, 2026
5 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.

1 participant