githubnext/chopinPublic

Let's compose rich plans together

Resolve channel links before mounting a document workspace #391

ClosedMaggieAppletonclosed 2 commits intomainfromrefs/pull/391/head
  1. MA

    Follow-up from the #344 review (blocking item 2) and shell audit finding 4.

    ## Why

    Opening a `/channels/:id` URL (the redirect after Add project, a document missing from the loaded sidebar list, or an old channel link) mounted a room under a `channel:` route layer. Then it canonicalized to the document path and mounted a **second** room under `DocumentWorkspaceHost`. The editor remounted, with a Loading flash, and the document was read twice.

    ## What changed

    - `channel-workspace.tsx` now only **resolves** a channel id to its canonical document path. It shows Loading, or the existing Failure with recovery and retry, and never mounts a `RoomWorkspace`. - `DocumentRouteSwap` canonicalizes from that resolution only while the channel route is still the requested one (`channelResolved` checks `requestedRoute.current.key`), so a stale resolution cannot take over a newer route (AGENTS.md: use stable request identity for async results). - The canonical document route then mounts exactly one `DocumentWorkspaceHost` under its `document:` key. Children of that parent share the key, so opening and closing a child keeps the same parent room. The `document → channel` alias that caused the remount in #344 no longer exists. - The channel effect depends on `source.id`, not the source object. - Compatible with #362: its `state.pending` guard in the publish effect is untouched. With this change a pending channel route never publishes a resolution anyway.

    ## Evidence (mount and fetch counts)

    Measured with a MutationObserver on `[data-workspace-room]` and a request log, on a fake-GitHub server.

    | Scenario | Before (main) | After | | --- | --- | --- | | Open `/channels/<parent>` | `MOUNT, MOUNT, UNMOUNT` (2 room mounts) | `MOUNT` (1) | | Then open child, then Escape | parent kept, but only after the double mount | parent kept, no parent mount or unmount | | Add project → landing redirect via `/channels/<id>` | `MOUNT, MOUNT, UNMOUNT` (2 mounts, ~440 ms apart) | 1 mount per room, 0 unmounts | | `GET /api/channels/<id>` per entry | 1 | 1 |

    There is no visual change beyond the removed remount, so screenshots show the settled states only:

    ![After: channel link entry settles on the document](https://github.com/user-attachments/assets/699918e7-4111-42af-bd77-02d36b7f208c)

    ## Testing

    - New e2e `research-child-surface.e2e.ts` › *a channel link opens one parent room that a child opens and closes over*: one parent mount and one `/api/channels/<parent>` read across channel entry, child open, and child close. **Fails on main** (parent remounted). - New e2e `hosted.e2e.ts` › *the Add project redirect through a channel link mounts its document once*: holds the project document list so landing goes through `/channels/<id>`, then asserts no unmounts and one mount and one read. **Fails on main** (`unmount <id>`). - Shared helper `e2e/room-mounts.ts`. - Local e2e is suspended for this audit, so I ran these files with Playwright against my own fake-GitHub server (port 8902). The new tests, the stale-route tests, the unknown-channel test, and the recovered-child test pass, twice. Across `hosted`, `research-child-surface`, `research-child-recovery`, `document-creation`, and `document-navigation`: 79 passed and 3 failed. The same 3 also fail on unmodified main against that server (it runs with `DEV_QUESTIONS`/`DEV_COMMENTS`), so they come from the environment. CI is the real check. - `bun run ci` ✅, `bun run types` ✅, `bun test apps/web` ✅ (529 pass). - Initial bundle unchanged: 247,205 B raw / 77,976 B gzip.

    🤖 Generated with [Claude Code](https://claude.com/claude-code)

  2. MA
    Author

    **Hold — please don't merge yet.** Routing change; a quick review is in progress. I'll post the verdict here.

    🤖 Generated with [Claude Code](https://claude.com/claude-code)

  3. MA
    Author

    **Review:** Looks good

    Checked in a real headless Chromium (own context) against the PR build on a fake-GitHub server, desktop 1440x900 and phone 390x844.

    **Behaviour** - Valid `/channels/<id>`: one `mount` of the room, one `GET /api/channels/<id>`, lands on `/documents/octo-org/score/<slug>`. No unmount. - History: canonicalization uses `replaceState`. Entering from a document and pressing Back returns to that document, Forward returns to the canonical document, and the `/channels/` entry never reappears (history length 2 to 3, no extra entry). - Archived document: opens the canonical path with the Archived / Restore state. Child channel id: opens the canonical `/children/<slug>` path, parent and child mount once each, Back returns to the parent. Deep link into a child by channel id from an open parent works. - Unknown id: "channel not found" Failure with recovery link. A channel whose repository the user cannot read shows the Failure card with Try again (the fake server returns 500 "channel storage failed" for my synthetic row; the server code is unchanged by this PR). - Rapid navigation: held `GET /api/channels/<id>`, navigated to another document (both from an SPA popstate and from a fresh channel entry), then released it. The URL and mounted room stay on the newer document; the stale resolution does not canonicalize. - Phone: channel link lands on the document with no horizontal overflow. - No page errors in any scenario.

    **Code** - `channelResolved` checks `requestedRoute.current.key === key`, matching the existing `ready` and `metadataPath` guards. The effect dependency change to `source.id` is correct. - The channel route never calls `ready` on success and never mounts a room. The next route request replaces it, so there is no settle leak.

    **#362 compatibility** - `hosted.tsx` merges cleanly. #362's `state.pending` guard sits in the publish effect, which this PR leaves alone. - Textual conflict in `apps/web/src/channel-workspace.tsx` only: #362 adds a comment above the component this PR rewrites. Resolve by taking this PR's version when the second one lands; the Failure/Loading props comment from #362 no longer applies. #362's new e2e should still pass, since a pending channel route cannot publish a resolution.

    Nits (non-blocking): none.

    `gh pr checks`: container, e2e, format/lint/types/tests all pass.

  4. MA
    Author

    Hold lifted.

  5. MaggieAppletonauto-rebase-enabledlast month on GitHub

  6. MaggieAppletonauto-merge-disabledlast month on GitHub

  7. MaggieAppletonreferenced 81c8ff84571dlast month on GitHub

  8. MaggieAppletonreferenced this in Calm entry and error states #344last month on GitHub

  9. MaggieAppletonreferenced 16258424cba9last month on GitHub

  10. MaggieAppletonreferenced 9261fa6bc6dblast month on GitHub

  11. MaggieAppletonclosedlast month on GitHub