Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion bun.lock
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "mux",
Expand Down Expand Up @@ -63,6 +62,7 @@
"posthog-node": "^5.17.0",
"quickjs-emscripten": "^0.31.0",
"quickjs-emscripten-core": "^0.31.0",
"react-router-dom": "^7.11.0",
"rehype-harden": "^1.1.5",
"rehype-sanitize": "^6.0.0",
"shescape": "^2.1.6",
Expand Down Expand Up @@ -3136,6 +3136,10 @@

"react-remove-scroll-bar": ["[email protected]", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],

"react-router": ["[email protected]", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ=="],

"react-router-dom": ["[email protected]", "", { "dependencies": { "react-router": "7.11.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g=="],

"react-style-singleton": ["[email protected]", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],

"read-binary-file-arch": ["[email protected]", "", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="],
Expand Down Expand Up @@ -3276,6 +3280,8 @@

"set-blocking": ["[email protected]", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],

"set-cookie-parser": ["[email protected]", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],

"set-function-length": ["[email protected]", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],

"set-function-name": ["[email protected]", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,15 +102,16 @@
"parse-duration": "^2.1.4",
"posthog-node": "^5.17.0",
"quickjs-emscripten": "^0.31.0",
"typescript": "^5.1.3",
"quickjs-emscripten-core": "^0.31.0",
"react-router-dom": "^7.11.0",
"rehype-harden": "^1.1.5",
"rehype-sanitize": "^6.0.0",
"shescape": "^2.1.6",
"source-map-support": "^0.5.21",
"streamdown": "1.6.10",
"trpc-cli": "^0.12.1",
"turndown": "^7.2.2",
"typescript": "^5.1.3",
"undici": "^7.16.0",
"write-file-atomic": "^6.0.0",
"ws": "^8.18.3",
Expand Down
32 changes: 7 additions & 25 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,6 @@ import { getWorkspaceSidebarKey } from "./utils/workspace";

const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high", "xhigh"];

function isStorybookIframe(): boolean {
return typeof window !== "undefined" && window.location.pathname.endsWith("iframe.html");
}

function AppInner() {
// Get workspace state from context
const {
Expand Down Expand Up @@ -139,21 +135,10 @@ function AppInner() {
// Auto-resume interrupted streams on app startup and when failures occur
useResumeManager();

// Sync selectedWorkspace with URL hash
// Update window title based on selected workspace
// URL syncing is now handled by RouterContext
useEffect(() => {
// Storybook's test runner treats hash updates as navigations and will retry play tests.
// The hash deep-linking isn't needed in Storybook, so skip it there.
const shouldSyncHash = !isStorybookIframe();

if (selectedWorkspace) {
// Update URL with workspace ID
if (shouldSyncHash) {
const newHash = `#workspace=${encodeURIComponent(selectedWorkspace.workspaceId)}`;
if (window.location.hash !== newHash) {
window.history.replaceState(null, "", newHash);
}
}

// Update window title with workspace title (or name for legacy workspaces)
const metadata = workspaceMetadata.get(selectedWorkspace.workspaceId);
const workspaceTitle = metadata?.title ?? metadata?.name ?? selectedWorkspace.workspaceId;
Expand All @@ -162,29 +147,26 @@ function AppInner() {
document.title = title;
void api?.window.setTitle({ title });
} else {
// Clear hash when no workspace selected
if (shouldSyncHash && window.location.hash) {
window.history.replaceState(null, "", window.location.pathname);
}
// Set document.title locally for browser mode, call backend for Electron
document.title = "mux";
void api?.window.setTitle({ title: "mux" });
}
}, [selectedWorkspace, workspaceMetadata, api]);

// Validate selected workspace exists and has all required fields
// Note: workspace validity is now primarily handled by RouterContext deriving
// selectedWorkspace from URL + metadata. This effect handles edge cases like
// stale localStorage or missing fields in legacy workspaces.
useEffect(() => {
if (selectedWorkspace) {
const metadata = workspaceMetadata.get(selectedWorkspace.workspaceId);

if (!metadata) {
// Workspace was deleted
// Workspace was deleted - navigate home (clears selection)
console.warn(
`Workspace ${selectedWorkspace.workspaceId} no longer exists, clearing selection`
);
setSelectedWorkspace(null);
if (!isStorybookIframe() && window.location.hash) {
window.history.replaceState(null, "", window.location.pathname);
}
} else if (!selectedWorkspace.namedWorkspacePath && metadata.namedWorkspacePath) {
// Old localStorage entry missing namedWorkspacePath - update it once
console.log(`Updating workspace ${selectedWorkspace.workspaceId} with missing fields`);
Expand Down
16 changes: 10 additions & 6 deletions src/browser/components/AppLoader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useGitStatusStoreRaw } from "../stores/GitStatusStore";
import { ProjectProvider, useProjectContext } from "../contexts/ProjectContext";
import { APIProvider, useAPI, type APIClient } from "@/browser/contexts/API";
import { WorkspaceProvider, useWorkspaceContext } from "../contexts/WorkspaceContext";
import { RouterProvider } from "../contexts/RouterContext";

interface AppLoaderProps {
/** Optional pre-created ORPC api?. If provided, skips internal connection setup. */
Expand All @@ -18,19 +19,22 @@ interface AppLoaderProps {
* 2. Sync stores with loaded data
* 3. Only render App when everything is ready
*
* WorkspaceContext handles workspace selection restoration (localStorage, URL hash, launch project).
* WorkspaceContext handles workspace selection restoration from URL.
* RouterProvider must wrap WorkspaceProvider since workspace state is derived from URL.
* WorkspaceProvider must be nested inside ProjectProvider so it can call useProjectContext().
* This ensures App.tsx can assume stores are always synced and removes
* the need for conditional guards in effects.
*/
export function AppLoader(props: AppLoaderProps) {
return (
<APIProvider client={props.client}>
<ProjectProvider>
<WorkspaceProvider>
<AppLoaderInner />
</WorkspaceProvider>
</ProjectProvider>
<RouterProvider>
<ProjectProvider>
<WorkspaceProvider>
<AppLoaderInner />
</WorkspaceProvider>
</ProjectProvider>
</RouterProvider>
</APIProvider>
);
}
Expand Down
78 changes: 78 additions & 0 deletions src/browser/contexts/RouterContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { createContext, useContext, type ReactNode } from "react";
import { MemoryRouter, useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { parseInitialUrl, useRouterUrlSync } from "../hooks/useRouterUrlSync";

// Navigation context value
export interface RouterContextValue {
// Navigation functions
navigateToWorkspace: (workspaceId: string) => void;
navigateToProject: (projectPath: string) => void;
navigateToHome: () => void;
// Current route state (derived from URL)
currentWorkspaceId: string | null;
currentProjectPath: string | null;
}

const RouterContext = createContext<RouterContextValue | null>(null);

export function useRouter(): RouterContextValue {
const ctx = useContext(RouterContext);
if (!ctx) {
throw new Error("useRouter must be used within RouterProvider");
}
return ctx;
}

/**
* Inner provider that has access to router hooks.
* Parses URL to derive current workspace/project state.
*/
function RouterContextInner(props: { children: ReactNode }) {
const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();

// Sync router state to browser URL
useRouterUrlSync();

// Parse workspace ID from pathname: /workspace/:workspaceId
let currentWorkspaceId: string | null = null;
const workspaceMatch = /^\/workspace\/(.+)$/.exec(location.pathname);
if (workspaceMatch) {
currentWorkspaceId = decodeURIComponent(workspaceMatch[1]);
}

// Parse project path from query: /project?path=...
const currentProjectPath = location.pathname === "/project" ? searchParams.get("path") : null;

const value: RouterContextValue = {
navigateToWorkspace: (workspaceId: string) => {
void navigate(`/workspace/${encodeURIComponent(workspaceId)}`, { replace: true });
},
navigateToProject: (projectPath: string) => {
void navigate(`/project?path=${encodeURIComponent(projectPath)}`, { replace: true });
},
navigateToHome: () => {
void navigate("/", { replace: true });
},
currentWorkspaceId,
currentProjectPath,
};

return <RouterContext.Provider value={value}>{props.children}</RouterContext.Provider>;
}

/**
* Main router provider that wraps app with MemoryRouter and context.
* Handles backward compatibility with legacy hash URLs.
*/
export function RouterProvider(props: { children: ReactNode }) {
// Parse initial URL for MemoryRouter (handles legacy hash URLs)
const initialEntry = parseInitialUrl();

return (
<MemoryRouter initialEntries={[initialEntry]}>
<RouterContextInner>{props.children}</RouterContextInner>
</MemoryRouter>
);
}
60 changes: 37 additions & 23 deletions src/browser/contexts/WorkspaceContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { GlobalWindow } from "happy-dom";
import type { WorkspaceContext } from "./WorkspaceContext";
import { WorkspaceProvider, useWorkspaceContext } from "./WorkspaceContext";
import { ProjectProvider } from "@/browser/contexts/ProjectContext";
import { RouterProvider } from "@/browser/contexts/RouterContext";
import { useWorkspaceStoreRaw as getWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore";
import {
SELECTED_WORKSPACE_KEY,
Expand Down Expand Up @@ -409,6 +410,18 @@ describe("WorkspaceContext", () => {

test("beginWorkspaceCreation clears selection and tracks pending state", async () => {
createMockAPI({
workspace: {
list: () =>
Promise.resolve([
createWorkspaceMetadata({
id: "ws-existing",
projectPath: "/existing",
projectName: "existing",
name: "main",
namedWorkspacePath: "/existing-main",
}),
]),
},
localStorage: {
selectedWorkspace: JSON.stringify({
workspaceId: "ws-existing",
Expand Down Expand Up @@ -464,6 +477,18 @@ describe("WorkspaceContext", () => {

test("selectedWorkspace restores from localStorage on mount", async () => {
createMockAPI({
workspace: {
list: () =>
Promise.resolve([
createWorkspaceMetadata({
id: "ws-restore",
projectPath: "/restore",
projectName: "restore",
name: "main",
namedWorkspacePath: "/restore-main",
}),
]),
},
localStorage: {
selectedWorkspace: JSON.stringify({
workspaceId: "ws-restore",
Expand All @@ -479,18 +504,13 @@ describe("WorkspaceContext", () => {
await waitFor(() => expect(ctx().selectedWorkspace?.workspaceId).toBe("ws-restore"));
});

test("launch project takes precedence over localStorage selection", async () => {
test("launch project auto-selects workspace when no URL hash", async () => {
// With the new router, URL takes precedence. When there's no URL hash,
// and localStorage has no saved workspace, the launch project kicks in.
createMockAPI({
workspace: {
list: () =>
Promise.resolve([
createWorkspaceMetadata({
id: "ws-existing",
projectPath: "/existing",
projectName: "existing",
name: "main",
namedWorkspacePath: "/existing-main",
}),
createWorkspaceMetadata({
id: "ws-launch",
projectPath: "/launch-project",
Expand All @@ -503,18 +523,10 @@ describe("WorkspaceContext", () => {
projects: {
list: () => Promise.resolve([]),
},
localStorage: {
selectedWorkspace: JSON.stringify({
workspaceId: "ws-existing",
projectPath: "/existing",
projectName: "existing",
namedWorkspacePath: "/existing-main",
}),
},
server: {
getLaunchProject: () => Promise.resolve("/launch-project"),
},
locationHash: "#/launch-project", // Simulate launch project via URL hash
// No locationHash, no localStorage - so launch project should kick in
});

const ctx = await setup();
Expand Down Expand Up @@ -685,13 +697,15 @@ async function setup() {
return null;
}

// WorkspaceProvider needs ProjectProvider to call useProjectContext
// WorkspaceProvider needs RouterProvider and ProjectProvider
render(
<ProjectProvider>
<WorkspaceProvider>
<ContextCapture />
</WorkspaceProvider>
</ProjectProvider>
<RouterProvider>
<ProjectProvider>
<WorkspaceProvider>
<ContextCapture />
</WorkspaceProvider>
</ProjectProvider>
</RouterProvider>
);

// Inject client immediately to handle race conditions where effects run before store update
Expand Down
Loading