diff --git a/src/browser/App.tsx b/src/browser/App.tsx index b06870061a..c37e5198ec 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -104,7 +104,7 @@ function AppInner() { }, [sidebarCollapsed]); const defaultProjectPath = getFirstProjectPath(projects); const creationProjectPath = !selectedWorkspace - ? (pendingNewWorkspaceProject ?? (projects.size === 1 ? defaultProjectPath : null)) + ? (pendingNewWorkspaceProject ?? defaultProjectPath) : null; const startWorkspaceCreation = useStartWorkspaceCreation({ @@ -758,7 +758,7 @@ function AppInner() {

Welcome to Mux

-

Select a workspace from the sidebar or add a new one to get started.

+

Add a project from the sidebar to get started.

)} diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index 3a6bd706a5..e084c30d1d 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -10,6 +10,9 @@ import { SelectValue, } from "../ui/select"; import { Loader2, Wand2 } from "lucide-react"; +import { PlatformPaths } from "@/common/utils/paths"; +import { useProjectContext } from "@/browser/contexts/ProjectContext"; +import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; import { cn } from "@/common/lib/utils"; import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip"; import { SSHIcon, WorktreeIcon, LocalIcon, DockerIcon } from "../icons/RuntimeIcons"; @@ -32,6 +35,8 @@ interface CreationControlsProps { onSelectedRuntimeChange: (runtime: ParsedRuntime) => void; onSetDefaultRuntime: (mode: RuntimeMode) => void; disabled: boolean; + /** Project path to display (and used for project selector) */ + projectPath: string; /** Project name to display as header */ projectName: string; /** Workspace name/title generation state and actions */ @@ -243,6 +248,8 @@ function RuntimeButtonGroup(props: RuntimeButtonGroupProps) { * Displays project name as header, workspace name with magic wand, and runtime/branch selectors. */ export function CreationControls(props: CreationControlsProps) { + const { projects } = useProjectContext(); + const { beginWorkspaceCreation } = useWorkspaceContext(); const { nameState, runtimeAvailability } = props; // Extract mode from discriminated union for convenience @@ -289,7 +296,41 @@ export function CreationControls(props: CreationControlsProps) {
{/* Project name / workspace name header row - wraps on narrow viewports */}
-

{props.projectName}

+ {projects.size > 1 ? ( + beginWorkspaceCreation(path)} + > + + + + + + + {props.projectPath} + + + {Array.from(projects.keys()).map((path) => ( + + {PlatformPaths.basename(path)} + + ))} + + + ) : ( + + +

+ {props.projectName} +

+
+ {props.projectPath} +
+ )} / {/* Name input with magic wand - uses grid overlay technique for auto-sizing */} diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 5a2d885c23..6b79d6e48e 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -611,6 +611,7 @@ const ChatInputInner: React.FC = (props) => { onSelectedRuntimeChange: creationState.setSelectedRuntime, onSetDefaultRuntime: creationState.setDefaultRuntimeMode, disabled: isSendInFlight, + projectPath: props.projectPath, projectName: props.projectName, nameState: creationState.nameState, runtimeAvailability: creationState.runtimeAvailability, diff --git a/src/browser/contexts/RouterContext.tsx b/src/browser/contexts/RouterContext.tsx index 2680afa0dd..9e59ec3e5c 100644 --- a/src/browser/contexts/RouterContext.tsx +++ b/src/browser/contexts/RouterContext.tsx @@ -10,6 +10,7 @@ import { import { MemoryRouter, useLocation, useNavigate, useSearchParams } from "react-router-dom"; import { readPersistedState } from "@/browser/hooks/usePersistedState"; import { SELECTED_WORKSPACE_KEY } from "@/common/constants/storage"; +import { getProjectRouteId } from "@/common/utils/projectRouteId"; import type { WorkspaceSelection } from "@/browser/components/ProjectSidebar"; export interface RouterContext { @@ -17,7 +18,13 @@ export interface RouterContext { navigateToProject: (projectPath: string, sectionId?: string) => void; navigateToHome: () => void; currentWorkspaceId: string | null; - currentProjectPath: string | null; + + /** Project identifier from URL (does not include full filesystem path). */ + currentProjectId: string | null; + + /** Optional project path carried via in-memory navigation state (not persisted on refresh). */ + currentProjectPathFromState: string | null; + /** Section ID for pending workspace creation (from URL) */ pendingSectionId: string | null; } @@ -71,6 +78,13 @@ function useUrlSync(): void { } function RouterContextInner(props: { children: ReactNode }) { + function getProjectPathFromLocationState(state: unknown): string | null { + if (!state || typeof state !== "object") return null; + if (!("projectPath" in state)) return null; + const projectPath = (state as { projectPath?: unknown }).projectPath; + return typeof projectPath === "string" ? projectPath : null; + } + const navigate = useNavigate(); const navigateRef = useRef(navigate); useEffect(() => { @@ -83,7 +97,30 @@ function RouterContextInner(props: { children: ReactNode }) { const workspaceMatch = /^\/workspace\/(.+)$/.exec(location.pathname); const currentWorkspaceId = workspaceMatch ? decodeURIComponent(workspaceMatch[1]) : null; - const currentProjectPath = location.pathname === "/project" ? searchParams.get("path") : null; + const currentProjectId = + location.pathname === "/project" + ? (searchParams.get("project") ?? searchParams.get("path")) + : null; + const currentProjectPathFromState = + location.pathname === "/project" ? getProjectPathFromLocationState(location.state) : null; + + // Back-compat: if we ever land on a legacy deep link (/project?path=), + // immediately replace it with the non-path project id URL. + useEffect(() => { + if (location.pathname !== "/project") return; + + const params = new URLSearchParams(location.search); + const legacyPath = params.get("path"); + const projectParam = params.get("project"); + if (!projectParam && legacyPath) { + const section = params.get("section"); + const projectId = getProjectRouteId(legacyPath); + const url = section + ? `/project?project=${encodeURIComponent(projectId)}§ion=${encodeURIComponent(section)}` + : `/project?project=${encodeURIComponent(projectId)}`; + void navigateRef.current(url, { replace: true, state: { projectPath: legacyPath } }); + } + }, [location.pathname, location.search]); const pendingSectionId = location.pathname === "/project" ? searchParams.get("section") : null; const navigateToWorkspace = useCallback((id: string) => { @@ -91,10 +128,11 @@ function RouterContextInner(props: { children: ReactNode }) { }, []); const navigateToProject = useCallback((path: string, sectionId?: string) => { + const projectId = getProjectRouteId(path); const url = sectionId - ? `/project?path=${encodeURIComponent(path)}§ion=${encodeURIComponent(sectionId)}` - : `/project?path=${encodeURIComponent(path)}`; - void navigateRef.current(url, { replace: true }); + ? `/project?project=${encodeURIComponent(projectId)}§ion=${encodeURIComponent(sectionId)}` + : `/project?project=${encodeURIComponent(projectId)}`; + void navigateRef.current(url, { replace: true, state: { projectPath: path } }); }, []); const navigateToHome = useCallback(() => { @@ -107,14 +145,16 @@ function RouterContextInner(props: { children: ReactNode }) { navigateToProject, navigateToHome, currentWorkspaceId, - currentProjectPath, + currentProjectId, + currentProjectPathFromState, pendingSectionId, }), [ navigateToHome, navigateToProject, navigateToWorkspace, - currentProjectPath, + currentProjectId, + currentProjectPathFromState, currentWorkspaceId, pendingSectionId, ] diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index 4343fada6a..82e282c8b7 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -30,6 +30,7 @@ import { useWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; import { normalizeAgentAiDefaults } from "@/common/types/agentAiDefaults"; import { normalizeModeAiDefaults } from "@/common/types/modeAiDefaults"; import { isWorkspaceArchived } from "@/common/utils/archive"; +import { getProjectRouteId } from "@/common/utils/projectRouteId"; import { useRouter } from "@/browser/contexts/RouterContext"; /** @@ -210,14 +211,15 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { }); }, [api]); // Get project refresh function from ProjectContext - const { refreshProjects } = useProjectContext(); + const { projects, refreshProjects } = useProjectContext(); // Get router navigation functions and current route state const { navigateToWorkspace, navigateToProject, navigateToHome, currentWorkspaceId, - currentProjectPath, + currentProjectId, + currentProjectPathFromState, pendingSectionId, } = useRouter(); @@ -242,7 +244,26 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { ); const [loading, setLoading] = useState(true); - // pendingNewWorkspaceProject is derived from currentProjectPath in URL + const currentProjectPath = useMemo(() => { + if (currentProjectPathFromState) return currentProjectPathFromState; + if (!currentProjectId) return null; + + // Legacy: older deep links stored the full path under ?path=... + if (projects.has(currentProjectId)) { + return currentProjectId; + } + + // Current: project ids are derived from the configured project path. + for (const projectPath of projects.keys()) { + if (getProjectRouteId(projectPath) === currentProjectId) { + return projectPath; + } + } + + return null; + }, [currentProjectId, currentProjectPathFromState, projects]); + + // pendingNewWorkspaceProject is derived from current project in URL/state const pendingNewWorkspaceProject = currentProjectPath; // pendingNewWorkspaceSectionId is derived from section URL param const pendingNewWorkspaceSectionId = pendingSectionId; @@ -335,7 +356,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { }, [loadWorkspaceMetadata, refreshProjects]); // URL restoration is now handled by RouterContext which parses the URL on load - // and provides currentWorkspaceId/currentProjectPath that we derive state from. + // and provides currentWorkspaceId/currentProjectId that we derive state from. // Check for launch project from server (for --add-project flag) // This only applies in server mode, runs after metadata loads diff --git a/src/common/utils/projectRouteId.ts b/src/common/utils/projectRouteId.ts new file mode 100644 index 0000000000..b4ab684d6f --- /dev/null +++ b/src/common/utils/projectRouteId.ts @@ -0,0 +1,28 @@ +import { PlatformPaths } from "@/common/utils/paths"; + +function hashStringDjb2(str: string): number { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) + hash) ^ str.charCodeAt(i); + } + return hash >>> 0; // Unsigned 32-bit +} + +function slugify(input: string): string { + // Keep it URL-friendly and stable across platforms. + // NOTE: This is for routing only (not user-facing display). + const slug = input + .normalize("NFKD") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/-{2,}/g, "-"); + + return slug || "project"; +} + +export function getProjectRouteId(projectPath: string): string { + const name = PlatformPaths.basename(projectPath); + const hash = hashStringDjb2(projectPath).toString(16).padStart(8, "0"); + return `${slugify(name)}-${hash}`; +}