Files
multica/packages/core/paths/paths.ts
Naiyuan Qing 75d12c26c5 feat: workspace URL refactor v2 + rollback-safe compat layer (#1138)
* Reapply "feat: workspace URL refactor + slug-first API identity (#1131)" (#1137)

This reverts commit 9b94914bc8.

* compat: legacy URL redirect + localStorage double-write for safe rollback

The first attempt at this refactor (#1131) was reverted because existing
users on old URLs (/issues, /projects, etc.) hit 404 immediately after
deploy, and rolling back left them with empty dashboards — the legacy
code reads localStorage["multica_workspace_id"] to attach a workspace
to API requests, but the new code had stopped writing that key.

Two compat layers added on top of the restored refactor:

1. proxy.ts now intercepts legacy route prefixes (/issues/*, /projects/*,
   /agents/*, /inbox/*, /my-issues/*, /autopilots/*, /runtimes/*,
   /skills/*, /settings/*). Logged-in users with a last_workspace_slug
   cookie are 302'd to /{slug}/{rest}, preserving their deep link. Users
   without the cookie bounce through / where the landing page picks a
   workspace client-side. Unauthenticated users go to /login.

2. Both layouts now double-write the workspace id to the legacy
   localStorage key on every workspace entry. New code ignores this key
   — it exists solely so that if this PR ever gets reverted again, the
   legacy build reading the key would still find the correct workspace
   and avoid the empty-dashboard symptom users saw during the rollback.

Net effect: any direction of deploy ↔ rollback is now cache-compatible,
and any direction of old bookmark → new route resolves without 404.

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

* fix(platform): defer rehydrateAllWorkspaceStores to a microtask

Same React 19 render-phase restriction that forced setCurrentWorkspace
to defer its subscriber notifications. rehydrateAllWorkspaceStores
synchronously calls each persist store's rehydrate, which setState()s
the store, which schedules updates on any subscribed component. When
the workspace layout's render-phase ref guard invoked this, React
complained that SearchCommand (a store subscriber) couldn't be
re-rendered while WorkspaceLayout was still rendering.

Fix: queueMicrotask the rehydrate loop and add a pending-flag guard so
rapid workspace switches coalesce into one rehydrate on the final slug.
Persist stores tolerate one microtask of staleness — they hold UI
preferences, not correctness-critical state.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:23:41 +08:00

56 lines
2.0 KiB
TypeScript

/**
* Centralized URL path builder. All navigation in shared packages (packages/views)
* MUST go through this module — no hardcoded string paths.
*
* Two kinds of paths:
* - workspace-scoped: paths.workspace(slug).xxx() — carry workspace in URL
* - global: paths.login(), paths.onboarding(), paths.invite(id) — pre-workspace routes
*
* Why pure functions + builder pattern:
* - Changing a route shape (e.g. adding workspace slug prefix) becomes a single-file edit
* - IDs are always URL-encoded here so callers can't forget
* - Zero runtime deps means this module is safe in Node (tests) and browsers
*/
const encode = (id: string) => encodeURIComponent(id);
function workspaceScoped(slug: string) {
const ws = `/${encode(slug)}`;
return {
root: () => `${ws}/issues`,
issues: () => `${ws}/issues`,
issueDetail: (id: string) => `${ws}/issues/${encode(id)}`,
projects: () => `${ws}/projects`,
projectDetail: (id: string) => `${ws}/projects/${encode(id)}`,
autopilots: () => `${ws}/autopilots`,
autopilotDetail: (id: string) => `${ws}/autopilots/${encode(id)}`,
agents: () => `${ws}/agents`,
inbox: () => `${ws}/inbox`,
myIssues: () => `${ws}/my-issues`,
runtimes: () => `${ws}/runtimes`,
skills: () => `${ws}/skills`,
settings: () => `${ws}/settings`,
};
}
export const paths = {
workspace: workspaceScoped,
// Global (pre-workspace) routes
login: () => "/login",
onboarding: () => "/onboarding",
invite: (id: string) => `/invite/${encode(id)}`,
authCallback: () => "/auth/callback",
root: () => "/",
};
export type WorkspacePaths = ReturnType<typeof workspaceScoped>;
// Prefixes — not slug names — because we match against full URL paths.
// A path is global if it equals or begins with any of these.
const GLOBAL_PREFIXES = ["/login", "/onboarding", "/invite/", "/auth/", "/logout", "/signup"];
export function isGlobalPath(path: string): boolean {
return GLOBAL_PREFIXES.some((p) => path === p || path.startsWith(p));
}