Skip to content
Merged
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
4 changes: 2 additions & 2 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -758,7 +758,7 @@ function AppInner() {
<h2 style={{ fontSize: "clamp(24px, 5vw, 36px)", letterSpacing: "-1px" }}>
Welcome to Mux
</h2>
<p>Select a workspace from the sidebar or add a new one to get started.</p>
<p>Add a project from the sidebar to get started.</p>
</div>
</div>
)}
Expand Down
43 changes: 42 additions & 1 deletion src/browser/components/ChatInput/CreationControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 */
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -289,7 +296,41 @@ export function CreationControls(props: CreationControlsProps) {
<div className="mb-3 flex flex-col gap-4">
{/* Project name / workspace name header row - wraps on narrow viewports */}
<div className="flex flex-wrap items-center gap-y-2" data-component="WorkspaceNameGroup">
<h2 className="text-foreground shrink-0 text-lg font-semibold">{props.projectName}</h2>
{projects.size > 1 ? (
<RadixSelect
value={props.projectPath}
onValueChange={(path) => beginWorkspaceCreation(path)}
>
<Tooltip>
<TooltipTrigger asChild>
<SelectTrigger
aria-label="Select project"
data-testid="project-selector"
className="text-foreground hover:bg-toggle-bg/70 h-7 w-auto max-w-[280px] shrink-0 border-transparent bg-transparent px-0 text-lg font-semibold shadow-none"
>
<SelectValue placeholder={props.projectName} />
</SelectTrigger>
</TooltipTrigger>
<TooltipContent align="start">{props.projectPath}</TooltipContent>
</Tooltip>
<SelectContent>
{Array.from(projects.keys()).map((path) => (
<SelectItem key={path} value={path}>
{PlatformPaths.basename(path)}
</SelectItem>
))}
</SelectContent>
</RadixSelect>
) : (
<Tooltip>
<TooltipTrigger asChild>
<h2 className="text-foreground shrink-0 text-lg font-semibold">
{props.projectName}
</h2>
</TooltipTrigger>
<TooltipContent align="start">{props.projectPath}</TooltipContent>
</Tooltip>
)}
<span className="text-muted-foreground mx-2 text-lg">/</span>

{/* Name input with magic wand - uses grid overlay technique for auto-sizing */}
Expand Down
1 change: 1 addition & 0 deletions src/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
onSelectedRuntimeChange: creationState.setSelectedRuntime,
onSetDefaultRuntime: creationState.setDefaultRuntimeMode,
disabled: isSendInFlight,
projectPath: props.projectPath,
projectName: props.projectName,
nameState: creationState.nameState,
runtimeAvailability: creationState.runtimeAvailability,
Expand Down
54 changes: 47 additions & 7 deletions src/browser/contexts/RouterContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,21 @@ 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 {
navigateToWorkspace: (workspaceId: string) => void;
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;
}
Expand Down Expand Up @@ -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(() => {
Expand All @@ -83,18 +97,42 @@ 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=<full 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)}&section=${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) => {
void navigateRef.current(`/workspace/${encodeURIComponent(id)}`, { replace: true });
}, []);

const navigateToProject = useCallback((path: string, sectionId?: string) => {
const projectId = getProjectRouteId(path);
const url = sectionId
? `/project?path=${encodeURIComponent(path)}&section=${encodeURIComponent(sectionId)}`
: `/project?path=${encodeURIComponent(path)}`;
void navigateRef.current(url, { replace: true });
? `/project?project=${encodeURIComponent(projectId)}&section=${encodeURIComponent(sectionId)}`
: `/project?project=${encodeURIComponent(projectId)}`;
void navigateRef.current(url, { replace: true, state: { projectPath: path } });
}, []);

const navigateToHome = useCallback(() => {
Expand All @@ -107,14 +145,16 @@ function RouterContextInner(props: { children: ReactNode }) {
navigateToProject,
navigateToHome,
currentWorkspaceId,
currentProjectPath,
currentProjectId,
currentProjectPathFromState,
pendingSectionId,
}),
[
navigateToHome,
navigateToProject,
navigateToWorkspace,
currentProjectPath,
currentProjectId,
currentProjectPathFromState,
currentWorkspaceId,
pendingSectionId,
]
Expand Down
29 changes: 25 additions & 4 deletions src/browser/contexts/WorkspaceContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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();

Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions src/common/utils/projectRouteId.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
Loading