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() {
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}`;
+}