From 6ff071a6126365403c8dd08e98a9dbe0d91562df Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:27:24 +0000 Subject: [PATCH 01/12] =?UTF-8?q?=F0=9F=A4=96=20feat:=20project-less=20scr?= =?UTF-8?q?atch=20chats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a hidden _scratch system project bucket, kind: "scratch" workspace metadata, app-owned workdirs at ~/.mux/scratch/ on LocalRuntime, capability-gated git UI, sidebar Chats section, command palette + keyboard shortcut entry points, and docs. --- _Generated with `mux` • Model: `anthropic:claude-fable-5` • Thinking: `xhigh` • Cost: `$177.01`_ --- docs/docs.json | 1 + docs/workspaces/scratch-chats.mdx | 30 +++ scripts/gen_builtin_skills.ts | 2 +- src/browser/App.tsx | 124 ++++++------ src/browser/components/ChatPane/ChatPane.tsx | 31 +-- .../components/ProjectPage/ProjectPage.tsx | 9 +- .../ProjectSidebar/ProjectSidebar.test.tsx | 36 ++++ .../ProjectSidebar/ProjectSidebar.tsx | 179 +++++++++++++++++- .../components/ScratchPage/ScratchPage.tsx | 138 ++++++++++++++ .../WorkspaceMenuBar.stories.tsx | 60 +++++- .../WorkspaceMenuBar.test.tsx | 37 +++- .../WorkspaceMenuBar/WorkspaceMenuBar.tsx | 102 ++++++---- src/browser/contexts/RouterContext.test.tsx | 12 +- src/browser/contexts/RouterContext.tsx | 7 +- .../contexts/WorkspaceContext.test.tsx | 139 ++++++++++++++ src/browser/contexts/WorkspaceContext.tsx | 43 +++-- src/browser/features/ChatInput/index.tsx | 38 ++-- src/browser/features/ChatInput/types.ts | 2 + .../ChatInput/useCreationWorkspace.test.tsx | 74 +++++++- .../ChatInput/useCreationWorkspace.ts | 37 ++-- .../features/RightSidebar/RightSidebar.tsx | 53 ++++-- .../Settings/Sections/KeybindsSection.tsx | 2 + src/browser/hooks/useProvidersConfig.ts | 5 + src/browser/utils/commandIds.ts | 1 + src/browser/utils/commands/sources.test.ts | 1 + src/browser/utils/commands/sources.ts | 20 +- src/browser/utils/rightSidebarLayout.test.ts | 8 + src/browser/utils/rightSidebarLayout.ts | 6 + src/browser/utils/ui/keybinds.ts | 3 + src/browser/utils/workspaceCapabilities.ts | 7 + src/common/constants/scratch.ts | 5 + src/common/orpc/schemas/api.ts | 9 + src/common/orpc/schemas/workspace.ts | 3 + src/common/schemas/project.ts | 3 + src/node/builtinSkills/mux-docs.md | 103 +++++----- src/node/config.ts | 25 ++- src/node/orpc/router.ts | 10 + .../builtInSkillContent.generated.ts | 137 +++++++++----- src/node/services/aiService.ts | 3 +- src/node/services/gitPatchArtifactService.ts | 4 + src/node/services/systemMessage.ts | 5 + src/node/services/taskService.test.ts | 59 ++++++ src/node/services/taskService.ts | 101 ++++++---- src/node/services/workspaceService.test.ts | 112 +++++++++++ src/node/services/workspaceService.ts | 125 ++++++++++++ 45 files changed, 1559 insertions(+), 352 deletions(-) create mode 100644 docs/workspaces/scratch-chats.mdx create mode 100644 src/browser/components/ScratchPage/ScratchPage.tsx create mode 100644 src/browser/utils/workspaceCapabilities.ts create mode 100644 src/common/constants/scratch.ts diff --git a/docs/docs.json b/docs/docs.json index 2fd9ea3cbc..5fb4984156 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -45,6 +45,7 @@ "group": "Workspaces", "pages": [ "workspaces", + "workspaces/scratch-chats", "workspaces/fork", "workspaces/muxignore", { diff --git a/docs/workspaces/scratch-chats.mdx b/docs/workspaces/scratch-chats.mdx new file mode 100644 index 0000000000..d7dde486dd --- /dev/null +++ b/docs/workspaces/scratch-chats.mdx @@ -0,0 +1,30 @@ +--- +title: Scratch chats +description: Start a durable chat with an app-managed folder and no project or Git repository. +--- + +Scratch chats let you start working without adding a project. Each chat gets a private folder managed by Mux, so the agent can create and edit files even though the chat is not attached to an existing repository. + +## Start a scratch chat + +Use any of these entry points: + +- Select **New scratch chat** from the command palette. +- Select the **+** button in the **Chats** section of the sidebar. +- Press `Cmd+Shift+N` on macOS or `Ctrl+Shift+N` on Windows and Linux. + +Mux creates the chat and its folder when you send the first message. Closing an empty draft does not create a folder. + +## Files and Git + +Scratch chat files are stored under `~/.mux/scratch/`. The folder persists across app restarts. + +Mux does not initialize a Git repository for a scratch chat. Branch controls, Git status, and code review tools are hidden. You can still use the terminal and other tools that only need a working directory. + +Sub-agent tasks run in the same scratch folder. They do not receive an isolated Git worktree. + +## Archive or delete + +Archiving a scratch chat keeps its conversation history and files. Deleting the chat removes its managed folder after no remaining sub-agent workspace references it. + +Conversation forks and full workspace-turn tasks are not supported for scratch chats in this version. diff --git a/scripts/gen_builtin_skills.ts b/scripts/gen_builtin_skills.ts index 77228e0c96..ca5639f79d 100644 --- a/scripts/gen_builtin_skills.ts +++ b/scripts/gen_builtin_skills.ts @@ -184,7 +184,7 @@ function renderDocsTreeNode( throw new Error(`Missing docs page info for '${node}'`); } - const suffix = info.description ? ` — ${info.description}` : ""; + const suffix = info.description ? `: ${info.description}` : ""; lines.push(`${prefix}- ${info.title} (\`${info.route}\`) → \`${info.referencePath}\`${suffix}`); return; } diff --git a/src/browser/App.tsx b/src/browser/App.tsx index e8c5b67331..25ea1c8e65 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -83,6 +83,10 @@ import { } from "@/browser/utils/workspaceAiSettingsSync"; import { AuthTokenModal } from "@/browser/components/AuthTokenModal/AuthTokenModal"; +import { ScratchPage } from "@/browser/components/ScratchPage/ScratchPage"; +import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import type { WorkspaceCreatedOptions } from "@/browser/features/ChatInput/types"; +import { SCRATCH_PROJECT_CONFIG_KEY } from "@/common/constants/scratch"; import { ProjectPage } from "@/browser/components/ProjectPage/ProjectPage"; import { SettingsProvider, useSettings } from "./contexts/SettingsContext"; @@ -159,6 +163,7 @@ function AppInner() { pendingNewWorkspaceProject, pendingNewWorkspaceSubProjectPath, pendingNewWorkspaceDraftId, + createWorkspaceDraft, beginWorkspaceCreation, } = useWorkspaceContext(); const { @@ -263,6 +268,29 @@ function AppInner() { // Get workspace store for command palette const workspaceStore = useWorkspaceStoreRaw(); + const handleWorkspaceCreated = ( + metadata: FrontendWorkspaceMetadata, + options?: WorkspaceCreatedOptions + ) => { + workspaceStore.addWorkspace(metadata); + setWorkspaceMetadata((prev) => new Map(prev).set(metadata.id, metadata)); + + if (options?.autoNavigate !== false) { + let createdSelection: WorkspaceSelection | null = null; + setSelectedWorkspace((current) => { + if (current !== null) return current; + createdSelection = toWorkspaceSelection(metadata); + return createdSelection; + }); + + if (createdSelection && options?.markPendingInitialSend !== false) { + workspaceStore.markPendingInitialSend(metadata.id, options?.pendingStreamModel ?? null); + } + } + + telemetry.workspaceCreated(metadata.id, getRuntimeTypeForTelemetry(metadata.runtimeConfig)); + }; + // Track telemetry when workspace selection changes const prevWorkspaceRef = useRef(null); // Ref for selectedWorkspace to access in callbacks without stale closures @@ -419,14 +447,14 @@ function AppInner() { return THINKING_LEVELS.includes(scoped) ? scoped : "off"; } - // Migration: fall back to legacy per-model thinking and seed the workspace-scoped key. + // Keep this render-time palette lookup pure. ThinkingProvider owns migration to the + // workspace-scoped key, while the palette can read legacy values as a fallback. const model = getModelForWorkspace(workspaceId); const legacy = readPersistedState( getThinkingLevelByModelKey(model), undefined ); if (legacy !== undefined && THINKING_LEVELS.includes(legacy)) { - updatePersistedState(scopedKey, legacy); return legacy; } @@ -438,7 +466,6 @@ function AppInner() { undefined ); if (canonicalLegacy !== undefined && THINKING_LEVELS.includes(canonicalLegacy)) { - updatePersistedState(scopedKey, canonicalLegacy); return canonicalLegacy; } } @@ -618,6 +645,10 @@ function AppInner() { const registerParamsRef = useRef(null); + const openNewScratchFromPalette = useCallback(() => { + createWorkspaceDraft(SCRATCH_PROJECT_CONFIG_KEY); + }, [createWorkspaceDraft]); + const openNewWorkspaceFromPalette = useCallback( (projectPath: string) => { startWorkspaceCreation(projectPath); @@ -854,6 +885,7 @@ function AppInner() { providersConfig, getRouteForModel, getMinThinkingOverride, + onStartScratchCreation: openNewScratchFromPalette, onStartWorkspaceCreation: openNewWorkspaceFromPalette, onStartMultiProjectWorkspaceCreation: openNewMultiProjectWorkspaceFromPalette, multiProjectWorkspacesEnabled, @@ -932,7 +964,10 @@ function AppInner() { return; } - if (matchesKeybind(e, KEYBINDS.NEXT_WORKSPACE)) { + if (matchesKeybind(e, KEYBINDS.NEW_SCRATCH_CHAT)) { + e.preventDefault(); + openNewScratchFromPalette(); + } else if (matchesKeybind(e, KEYBINDS.NEXT_WORKSPACE)) { e.preventDefault(); handleNavigateWorkspace("next"); } else if (matchesKeybind(e, KEYBINDS.PREV_WORKSPACE)) { @@ -978,6 +1013,7 @@ function AppInner() { window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [ + openNewScratchFromPalette, handleNavigateWorkspace, setSidebarCollapsed, isCommandPaletteOpen, @@ -1288,67 +1324,27 @@ function AppInner() { onToggleLeftSidebarCollapsed={handleToggleSidebar} /> ) + ) : creationProjectPath === SCRATCH_PROJECT_CONFIG_KEY ? ( + ) : creationProjectPath ? ( - (() => { - const projectPath = creationProjectPath; - const projectName = - projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "Project"; - return ( - { - // IMPORTANT: Add workspace to store FIRST (synchronous) to ensure - // the store knows about it before React processes the state updates. - // This prevents race conditions where the UI tries to access the - // workspace before the store has created its aggregator. - workspaceStore.addWorkspace(metadata); - - // Add to workspace metadata map (triggers React state update) - setWorkspaceMetadata((prev) => new Map(prev).set(metadata.id, metadata)); - - if (options?.autoNavigate !== false) { - let createdSelection: WorkspaceSelection | null = null; - setSelectedWorkspace((current) => { - if (current !== null) { - // If the user picked another workspace before create/send resolved, - // keep their explicit selection and skip the optimistic starting barrier. - return current; - } - - createdSelection = toWorkspaceSelection(metadata); - return createdSelection; - }); - - // WorkspaceContext resolves functional selection updates synchronously - // against its latest ref, so by the time setSelectedWorkspace() returns we - // know whether this creation actually won and can safely mark the - // optimistic starting barrier outside the updater callback. - if (createdSelection && options?.markPendingInitialSend !== false) { - workspaceStore.markPendingInitialSend( - metadata.id, - options?.pendingStreamModel ?? null - ); - } - } - - // Track telemetry - telemetry.workspaceCreated( - metadata.id, - getRuntimeTypeForTelemetry(metadata.runtimeConfig) - ); - - // Note: No need to call clearPendingWorkspaceCreation() here. - // Navigating to the workspace URL automatically clears the pending - // state since pendingNewWorkspaceProject is derived from the URL. - }} - /> - ); - })() + ) : ( // The dedicated Mux home page was removed. Keep `/` as a minimal shell so // WorkspaceContext can redirect it to a concrete project route when possible, diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index 4b7b31cb0b..4490393c16 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -94,6 +94,7 @@ import { useBackgroundBashActions, useBackgroundBashError, } from "@/browser/contexts/BackgroundBashContext"; +import { hasWorkspaceRepository } from "@/browser/utils/workspaceCapabilities"; import { buildEditingStateFromDisplayed, canEditDisplayedUserMessage, @@ -315,6 +316,7 @@ const ChatPaneContent: React.FC = (props) => { // Transcript-only workspaces preserve historical chat and usage after the worktree is deleted, // so the transcript stays readable while new sends remain disabled. const meta = workspaceMetadata.get(workspaceId); + const hasRepository = hasWorkspaceRepository(meta); const transcriptOnly = meta?.transcriptOnly ?? false; const isPreStreamAgentTask = Boolean(meta?.parentWorkspaceId) && isBlockedPreStreamTaskStatus(meta?.taskStatus); @@ -1380,18 +1382,20 @@ const ChatPaneContent: React.FC = (props) => {

No Messages Yet

Send a message below to begin

-

-

+ {hasRepository && ( +

+

+ )}
) : ( @@ -1615,6 +1619,7 @@ const ChatPaneContent: React.FC = (props) => { ) : ( { }; interface ChatInputPaneProps { + kind?: "scratch"; workspaceId: string; projectName: string; workspaceName: string; @@ -1828,6 +1834,7 @@ const ChatInputPane: React.FC = (props) => { prevIds.has(w.id)); } -/** Check if any provider is configured (uses backend-computed isConfigured) */ -function hasConfiguredProvider(config: ProvidersConfigMap | null): boolean { - if (!config) return false; - return Object.values(config).some((provider) => provider?.isConfigured); -} - /** * Project page shown when a project is selected but no workspace is active. * Combines workspace creation with archived workspaces view. diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx index aa8ff2025c..d998d6222b 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx @@ -9,6 +9,7 @@ import * as ReactColorfulModule from "react-colorful"; import { installDom } from "../../../../tests/ui/dom"; import { EXPANDED_PROJECTS_KEY } from "@/common/constants/storage"; import { getDraftScopeId, getInputKey } from "@/common/constants/storage"; +import { SCRATCH_SIDEBAR_SECTION_ID } from "@/common/constants/scratch"; import { MULTI_PROJECT_SIDEBAR_SECTION_ID } from "@/common/constants/multiProject"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; import { DEFAULT_TASK_SETTINGS } from "@/common/types/tasks"; @@ -684,6 +685,41 @@ function createWorkspace( let cleanupDom: (() => void) | null = null; +describe("ProjectSidebar scratch chats", () => { + beforeEach(() => { + setupProjectSidebarDom(); + updatePersistedState(EXPANDED_PROJECTS_KEY, [SCRATCH_SIDEBAR_SECTION_ID]); + /* eslint-disable @typescript-eslint/no-require-imports */ + ({ default: ProjectSidebar } = require("./ProjectSidebar?project-sidebar-scratch-test=1") as { + default: typeof ProjectSidebarComponent; + }); + /* eslint-enable @typescript-eslint/no-require-imports */ + }); + + afterEach(cleanupProjectSidebarDom); + + test("renders scratch workspaces in the Chats section", () => { + const scratchPath = "/home/user/.mux/scratch/scratch-1"; + const workspace: FrontendWorkspaceMetadata = { + kind: "scratch", + id: "scratch-1", + name: "scratch-scratch-1", + title: "Explore an idea", + projectName: "Scratch", + projectPath: scratchPath, + namedWorkspacePath: scratchPath, + createdAt: "2026-01-01T00:00:00.000Z", + runtimeConfig: { type: "local" }, + }; + + const view = renderProjectSidebarForWorkspace(workspace); + + expect(view.getByText("Chats")).toBeTruthy(); + expect(view.getByTestId(agentItemTestId(workspace.id))).toBeTruthy(); + expect(view.getByText("Explore an idea")).toBeTruthy(); + }); +}); + describe("ProjectSidebar multi-project completed-subagent toggles", () => { beforeEach(() => { cleanupDom = installDom(); diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx index 84f9773331..9ac4694f34 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx @@ -134,6 +134,7 @@ import { getProjectDisplayName, getSubProjectsForParent } from "@/common/utils/s import { getErrorMessage } from "@/common/utils/errors"; import { isMultiProject } from "@/common/utils/multiProject"; import { isWorkspacePinnable, isWorkspacePinned } from "@/common/utils/pin"; +import { SCRATCH_PROJECT_CONFIG_KEY, SCRATCH_SIDEBAR_SECTION_ID } from "@/common/constants/scratch"; import { MULTI_PROJECT_SIDEBAR_SECTION_ID } from "@/common/constants/multiProject"; import { getProjectWorkspaceCounts } from "@/common/utils/projectRemoval"; import { useExperimentValue } from "@/browser/hooks/useExperiments"; @@ -156,6 +157,7 @@ interface SectionConfig { function getPinnedReorderGroup(projectPath: string, sectionId: string | undefined): string { return `${projectPath}\u0000${sectionId ?? ""}`; } +const SCRATCH_PINNED_REORDER_GROUP = "\u0000scratch"; const MULTI_PROJECT_PINNED_REORDER_GROUP = "\u0000multi-project"; // Re-export WorkspaceSelection for backwards compatibility @@ -1021,6 +1023,16 @@ const ProjectSidebarInner: React.FC = ({ [setExpandedProjectsArray] ); + const handleAddScratchWorkspace = useCallback(() => { + setExpandedProjectsArray((prev) => { + const expanded = Array.isArray(prev) ? prev : []; + return expanded.includes(SCRATCH_SIDEBAR_SECTION_ID) + ? expanded + : [...expanded, SCRATCH_SIDEBAR_SECTION_ID]; + }); + handleAddWorkspace(SCRATCH_PROJECT_CONFIG_KEY); + }, [handleAddWorkspace, setExpandedProjectsArray]); + const toggleSection = (projectPath: string, sectionId: string) => { const key = getSectionExpandedKey(projectPath, sectionId); setExpandedSections((prev) => ({ @@ -1687,6 +1699,7 @@ const ProjectSidebarInner: React.FC = ({ ); const singleProjectWorkspacesByProject = new Map(); + const scratchWorkspacesById = new Map(); const multiProjectWorkspacesById = new Map(); const workspaceAttentionById = new Map(); @@ -1698,6 +1711,10 @@ const ProjectSidebarInner: React.FC = ({ workspaceHasAttention(workspace) || (delegatedActivityByWorkspaceId.get(workspace.id)?.activeCount ?? 0) > 0 ); + if (workspace.kind === "scratch") { + scratchWorkspacesById.set(workspace.id, workspace); + continue; + } if (isMultiProject(workspace)) { if (multiProjectWorkspacesEnabled) { multiProjectWorkspacesById.set(workspace.id, workspace); @@ -1710,6 +1727,24 @@ const ProjectSidebarInner: React.FC = ({ singleProjectWorkspacesByProject.set(projectPath, singleProjectWorkspaces); } + const scratchWorkspaces = orderMultiProjectSectionRows( + Array.from(scratchWorkspacesById.values()) + ); + const scratchDepthByWorkspaceId = computeWorkspaceDepthMap(scratchWorkspaces); + const visibleScratchWorkspaces = filterVisibleAgentRows( + scratchWorkspaces, + expandedCompletedParentIds + ); + const scratchRowMetaByWorkspaceId = computeAgentRowRenderMeta( + scratchWorkspaces, + scratchDepthByWorkspaceId, + expandedCompletedParentIds + ); + const isScratchSectionExpanded = expandedProjectsList.includes(SCRATCH_SIDEBAR_SECTION_ID); + const scratchDrafts = (workspaceDraftsByProject[SCRATCH_PROJECT_CONFIG_KEY] ?? []) + .slice() + .sort((a, b) => b.createdAt - a.createdAt); + // Re-sort across primary-project buckets so pinned rows form one correctly // ordered block (cross-primary pinned reorders would otherwise snap back). const multiProjectWorkspaces = orderMultiProjectSectionRows( @@ -1801,10 +1836,14 @@ const ProjectSidebarInner: React.FC = ({ ([subPath]) => subPath ) ); - const subProjectPath = meta - ? resolveEffectiveSectionId(meta, byId, validSectionIds) - : undefined; - handleAddWorkspace(selectedWorkspace.projectPath, subProjectPath); + if (meta?.kind === "scratch") { + handleAddScratchWorkspace(); + } else { + const subProjectPath = meta + ? resolveEffectiveSectionId(meta, byId, validSectionIds) + : undefined; + handleAddWorkspace(selectedWorkspace.projectPath, subProjectPath); + } } else if (matchesKeybind(e, KEYBINDS.ARCHIVE_WORKSPACE) && selectedWorkspace) { e.preventDefault(); void handleArchiveWorkspace(selectedWorkspace.workspaceId); @@ -1836,6 +1875,7 @@ const ProjectSidebarInner: React.FC = ({ }, [ closeProjectContextMenu, selectedWorkspace, + handleAddScratchWorkspace, handleAddWorkspace, handleArchiveWorkspace, setWorkspacePinned, @@ -1893,6 +1933,117 @@ const ProjectSidebarInner: React.FC = ({ onViewportScroll={handleProjectListScroll} viewportClassName="overflow-x-hidden" > +
+
+ +
+ Chats + {(scratchWorkspaces.length > 0 || scratchDrafts.length > 0) && ( + + ({scratchWorkspaces.length + scratchDrafts.length}) + + )} +
+ + + + + New scratch chat + +
+ {isScratchSectionExpanded && ( +
+ {scratchDrafts.map((draft, index) => { + const isSelected = + pendingNewWorkspaceProject === SCRATCH_PROJECT_CONFIG_KEY && + pendingNewWorkspaceDraftId === draft.draftId; + return ( + { + handleDraftVisibilityChange( + SCRATCH_PROJECT_CONFIG_KEY, + draft.draftId, + isVisible + ); + }} + onOpen={() => + handleOpenWorkspaceDraft(SCRATCH_PROJECT_CONFIG_KEY, draft.draftId) + } + onDelete={() => { + if (isSelected) { + navigateToProject(SCRATCH_PROJECT_CONFIG_KEY); + } + deleteWorkspaceDraft(SCRATCH_PROJECT_CONFIG_KEY, draft.draftId); + }} + /> + ); + })} + {visibleScratchWorkspaces.map((metadata) => { + const rowRenderMeta = scratchRowMetaByWorkspaceId.get(metadata.id); + return ( + + ); + })} + {scratchWorkspaces.length === 0 && scratchDrafts.length === 0 && ( + + )} +
+ )} +
+ {multiProjectWorkspaces.length > 0 && (
@@ -1971,12 +2122,20 @@ const ProjectSidebarInner: React.FC = ({ {sortedProjectPaths.length === 0 && multiProjectWorkspaces.length === 0 ? (

No projects

- +
+ + +
) : ( sortedProjectPaths.map((projectPath) => { diff --git a/src/browser/components/ScratchPage/ScratchPage.tsx b/src/browser/components/ScratchPage/ScratchPage.tsx new file mode 100644 index 0000000000..61db0b5ecd --- /dev/null +++ b/src/browser/components/ScratchPage/ScratchPage.tsx @@ -0,0 +1,138 @@ +import { useEffect, useRef, useState } from "react"; +import { Menu } from "lucide-react"; +import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import { SCRATCH_PROJECT_CONFIG_KEY, SCRATCH_PROJECT_NAME } from "@/common/constants/scratch"; +import { cn } from "@/common/lib/utils"; +import { isDesktopMode } from "@/browser/hooks/useDesktopTitlebar"; +import { AgentProvider } from "@/browser/contexts/AgentContext"; +import { ThinkingProvider } from "@/browser/contexts/ThinkingContext"; +import { ChatInput } from "@/browser/features/ChatInput"; +import type { ChatInputAPI, WorkspaceCreatedOptions } from "@/browser/features/ChatInput/types"; +import { hasConfiguredProvider, useProvidersConfig } from "@/browser/hooks/useProvidersConfig"; +import { ConfigureProvidersPrompt } from "@/browser/components/ConfigureProvidersPrompt/ConfigureProvidersPrompt"; +import { ConfiguredProvidersBar } from "@/browser/components/ConfiguredProvidersBar/ConfiguredProvidersBar"; +import { useAPI, type APIClient } from "@/browser/contexts/API"; +import { ArchivedWorkspaces } from "@/browser/components/ArchivedWorkspaces/ArchivedWorkspaces"; +import { Button } from "@/browser/components/Button/Button"; +import { Skeleton } from "@/browser/components/Skeleton/Skeleton"; + +interface ScratchPageProps { + leftSidebarCollapsed: boolean; + onToggleLeftSidebarCollapsed: () => void; + pendingDraftId?: string | null; + onWorkspaceCreated: ( + metadata: FrontendWorkspaceMetadata, + options?: WorkspaceCreatedOptions + ) => void; +} + +async function listArchivedScratchWorkspaces(api: APIClient | null) { + if (!api) return []; + const archived = await api.workspace.list({ archived: true }); + return archived.filter((workspace) => workspace.kind === "scratch"); +} + +export function ScratchPage(props: ScratchPageProps) { + const { api } = useAPI(); + const [archivedWorkspaces, setArchivedWorkspaces] = useState([]); + const didAutoFocusRef = useRef(false); + const { config: providersConfig, loading: providersLoading } = useProvidersConfig(); + const hasProviders = hasConfiguredProvider(providersConfig); + + async function refreshArchivedWorkspaces() { + setArchivedWorkspaces(await listArchivedScratchWorkspaces(api)); + } + + useEffect(() => { + let ignore = false; + listArchivedScratchWorkspaces(api) + .then((archived) => { + if (!ignore) setArchivedWorkspaces(archived); + }) + .catch(() => undefined); + return () => { + ignore = true; + }; + }, [api]); + + function handleChatReady(api: ChatInputAPI) { + if (didAutoFocusRef.current) return; + didAutoFocusRef.current = true; + api.focus(); + } + + return ( + + +
+
+ {props.leftSidebarCollapsed && ( + + )} +
+
+
+
+ {!providersLoading && !hasProviders ? ( + + ) : ( + <> + {providersLoading ? ( +
+ +
+ ) : ( + providersConfig && ( + + ) + )} + + + )} +
+
+ {archivedWorkspaces.length > 0 && ( +
+
+ { + refreshArchivedWorkspaces().catch(() => undefined); + }} + /> +
+
+ )} +
+
+
+
+ ); +} diff --git a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.stories.tsx b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.stories.tsx index 32a8e4fdf7..756465da26 100644 --- a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.stories.tsx +++ b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.stories.tsx @@ -6,12 +6,13 @@ import { } from "@/browser/stories/meta.js"; import { createGitStatusExecutor } from "@/browser/stories/helpers/git"; import { - collapseLeftSidebar, collapseRightSidebar, + collapseLeftSidebar, expandProjects, selectWorkspace, } from "@/browser/stories/helpers/uiState"; import { createMockORPCClient } from "@/browser/stories/mocks/orpc"; +import { SCRATCH_PROJECT_CONFIG_KEY, SCRATCH_SIDEBAR_SECTION_ID } from "@/common/constants/scratch"; import { createWorkspace, groupWorkspacesByProject } from "@/browser/stories/mocks/workspaces"; export default { @@ -122,3 +123,60 @@ export const DevcontainerStopped: AppStory = { export const DevcontainerUnknown: AppStory = { render: () => createDevcontainerClient("unknown")} />, }; + +export const ScratchWorkspace: AppStory = { + globals: { + viewport: { value: "mobile1", isRotated: false }, + }, + parameters: { + chromatic: { + modes: { + desktop: { theme: "dark" }, + mobile: { theme: "light", viewport: "mobile1", hasTouch: true }, + }, + }, + }, + render: () => ( + { + const scratchPath = "/home/user/.mux/scratch/scratch-1"; + const workspace = { + ...createWorkspace({ + id: "scratch-1", + name: "scratch-scratch-1", + projectName: "Scratch", + projectPath: scratchPath, + runtimeConfig: { type: "local" }, + }), + kind: "scratch" as const, + namedWorkspacePath: scratchPath, + }; + selectWorkspace(workspace); + expandProjects([SCRATCH_SIDEBAR_SECTION_ID]); + collapseRightSidebar(); + + return createMockORPCClient({ + projects: new Map([ + [ + SCRATCH_PROJECT_CONFIG_KEY, + { + projectKind: "system", + trusted: true, + workspaces: [ + { + kind: "scratch", + path: scratchPath, + id: workspace.id, + name: workspace.name, + runtimeConfig: { type: "local" }, + }, + ], + }, + ], + ]), + workspaces: [workspace], + }); + }} + /> + ), +}; diff --git a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.test.tsx b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.test.tsx index 471842ea32..2a220f12f0 100644 --- a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.test.tsx +++ b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.test.tsx @@ -35,10 +35,12 @@ import * as WorkspaceActionsMenuContentModule from "../WorkspaceActionsMenuConte import * as WorkspaceTerminalIconModule from "../icons/WorkspaceTerminalIcon/WorkspaceTerminalIcon"; import * as SkillIndicatorModule from "../SkillIndicator/SkillIndicator"; +import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import { WORKSPACE_MENU_BAR_LEFT_SIDEBAR_COLLAPSED_PADDING_PX } from "@/constants/layout"; let WorkspaceMenuBar!: typeof WorkspaceMenuBarComponent; +let workspaceMetadata = new Map(); let cleanupDom: (() => void) | null = null; const workspaceId = "workspace-1"; @@ -120,7 +122,7 @@ function installWorkspaceMenuBarTestDoubles() { ); spyOn(WorkspaceContextModule, "useWorkspaceContext").mockImplementation( () => - ({ workspaceMetadata: new Map() }) as unknown as ReturnType< + ({ workspaceMetadata }) as unknown as ReturnType< typeof WorkspaceContextModule.useWorkspaceContext > ); @@ -275,6 +277,7 @@ const defaultProps: ComponentProps = { describe("WorkspaceMenuBar archive confirmations", () => { beforeEach(() => { + workspaceMetadata = new Map(); cleanupDom = installDom(); installWorkspaceMenuBarTestDoubles(); /* eslint-disable @typescript-eslint/no-require-imports */ @@ -311,6 +314,38 @@ describe("WorkspaceMenuBar archive confirmations", () => { cleanupDom = null; }); + it("hides repository controls for scratch workspaces", () => { + const scratchPath = "/home/user/.mux/scratch/workspace-1"; + workspaceMetadata.set(workspaceId, { + kind: "scratch", + id: workspaceId, + name: "scratch-workspace-1", + projectName: "Scratch", + projectPath: scratchPath, + namedWorkspacePath: scratchPath, + runtimeConfig: { type: "local" }, + createdAt: "2026-01-01T00:00:00.000Z", + }); + + const view = render( + + ); + + expect(view.getByText("Scratch")).toBeTruthy(); + expect(BranchSelectorModule.BranchSelector).not.toHaveBeenCalled(); + expect(GitStatusIndicatorModule.GitStatusIndicator).not.toHaveBeenCalled(); + expect( + MultiProjectGitStatusIndicatorModule.MultiProjectGitStatusIndicator + ).not.toHaveBeenCalled(); + }); + it("applies the collapsed-left-sidebar inset immediately from props", () => { const view = render(); diff --git a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx index 83bc9de14c..6f8d7b9200 100644 --- a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx +++ b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx @@ -52,6 +52,8 @@ import { useProjectContext } from "@/browser/contexts/ProjectContext"; import { formatProjectHierarchyLabel } from "@/common/utils/subProjects"; import { isMultiProject } from "@/common/utils/multiProject"; import { forkWorkspace } from "@/browser/utils/chatCommands"; +import { SCRATCH_PROJECT_CONFIG_KEY, SCRATCH_PROJECT_NAME } from "@/common/constants/scratch"; +import { hasWorkspaceRepository } from "@/browser/utils/workspaceCapabilities"; import { WORKSPACE_MENU_BAR_LEFT_SIDEBAR_COLLAPSED_PADDING_PX } from "@/constants/layout"; import type { AgentSkillDescriptor, AgentSkillIssue } from "@/common/types/agentSkill"; @@ -78,6 +80,52 @@ const COLLAPSED_LEFT_SIDEBAR_MENU_BAR_STYLE = { paddingLeft: `${WORKSPACE_MENU_BAR_LEFT_SIDEBAR_COLLAPSED_PADDING_PX}px`, } as const; +function WorkspaceRepositoryControls(props: { + workspaceId: string; + workspaceName: string; + projectPath: string; + isWorking: boolean; + showMultiProjectStatus: boolean; + devcontainerChip: ReturnType; +}) { + const gitStatus = useGitStatus(props.workspaceId); + + return ( +
+ + {props.devcontainerChip && ( + + {props.devcontainerChip.label} + + )} + {props.showMultiProjectStatus ? ( + + ) : ( + + )} +
+ ); +} + export const WorkspaceMenuBar: React.FC = ({ workspaceId, projectName, @@ -97,9 +145,9 @@ export const WorkspaceMenuBar: React.FC = ({ const workspaceHeartbeatsEnabled = useExperimentValue(EXPERIMENT_IDS.WORKSPACE_HEARTBEATS); const openTerminalPopout = useOpenTerminal(); const openInEditor = useOpenInEditor(); - const gitStatus = useGitStatus(workspaceId); const runtimeStatus = useRuntimeStatus(workspaceId); const workspaceEntry = workspaceMetadata.get(workspaceId); + const hasRepository = hasWorkspaceRepository(workspaceEntry); const showMultiProjectStatus = workspaceEntry != null && isMultiProject(workspaceEntry); // The workspace's metadata.projectName is the parent project (since worktrees // are owned by the top-most parent). When the workspace is scoped to a @@ -108,9 +156,11 @@ export const WorkspaceMenuBar: React.FC = ({ const { userProjects } = useProjectContext(); const subProjectPath = workspaceEntry?.subProjectPath; const projectLabel = - subProjectPath && userProjects.has(subProjectPath) - ? formatProjectHierarchyLabel(subProjectPath, userProjects) - : projectName; + workspaceEntry?.kind === "scratch" + ? SCRATCH_PROJECT_NAME + : subProjectPath && userProjects.has(subProjectPath) + ? formatProjectHierarchyLabel(subProjectPath, userProjects) + : projectName; const runtimeStatusStore = useRuntimeStatusStoreRaw(); const { canInterrupt, isStarting, awaitingUserQuestion, loadedSkills, skillLoadErrors } = useWorkspaceSidebarState(workspaceId); @@ -150,9 +200,11 @@ export const WorkspaceMenuBar: React.FC = ({ { listener: true } ); + const notificationScope = + workspaceEntry?.kind === "scratch" ? SCRATCH_PROJECT_CONFIG_KEY : projectPath; // Auto-enable notifications for new workspaces (project-level) const [autoEnableNotifications, setAutoEnableNotifications] = usePersistedState( - getNotifyOnResponseAutoEnableKey(projectPath), + getNotifyOnResponseAutoEnableKey(notificationScope), false, { listener: true } ); @@ -523,42 +575,16 @@ export const WorkspaceMenuBar: React.FC = ({ tooltipSide="bottom" /> {projectLabel} -
- {/* BranchSelector keeps workspace-scoped local UI state (current branch fallback, - open popover contents, remote expansion). Key it by workspace identity so - switching workspaces resets that state instead of leaking the previous - workspace's branch presentation into the next one. */} - - {devcontainerChip && ( - - {devcontainerChip.label} - - )} - {showMultiProjectStatus ? ( - - ) : ( - - )} -
+ )}
diff --git a/src/browser/contexts/RouterContext.test.tsx b/src/browser/contexts/RouterContext.test.tsx index 3ec65ea232..9bf5779863 100644 --- a/src/browser/contexts/RouterContext.test.tsx +++ b/src/browser/contexts/RouterContext.test.tsx @@ -137,8 +137,8 @@ describe("browser startup launch behavior", () => { globalThis.document = undefined as unknown as Document; }); - test("dashboard mode ignores a stale /workspace/:id URL", async () => { - installWindow("https://mux.example.com/workspace/stale-123"); + test("dashboard mode preserves a direct /workspace/:id URL", async () => { + installWindow("https://mux.example.com/workspace/direct-123"); window.localStorage.setItem(LAUNCH_BEHAVIOR_KEY, JSON.stringify("dashboard")); const view = render( @@ -148,7 +148,7 @@ describe("browser startup launch behavior", () => { ); await waitFor(() => { - expect(view.getByTestId("pathname").textContent).toBe("/"); + expect(view.getByTestId("pathname").textContent).toBe("/workspace/direct-123"); }); }); @@ -197,8 +197,8 @@ describe("browser startup launch behavior", () => { }); }); - test("default launch behavior ignores a stale /workspace/:id URL", async () => { - installWindow("https://mux.example.com/workspace/stale-123"); + test("default launch behavior preserves a direct /workspace/:id URL", async () => { + installWindow("https://mux.example.com/workspace/direct-default"); const view = render( @@ -207,7 +207,7 @@ describe("browser startup launch behavior", () => { ); await waitFor(() => { - expect(view.getByTestId("pathname").textContent).toBe("/"); + expect(view.getByTestId("pathname").textContent).toBe("/workspace/direct-default"); }); }); }); diff --git a/src/browser/contexts/RouterContext.tsx b/src/browser/contexts/RouterContext.tsx index 7468de8fdd..8b2bf30d43 100644 --- a/src/browser/contexts/RouterContext.tsx +++ b/src/browser/contexts/RouterContext.tsx @@ -115,6 +115,7 @@ function shouldRestoreWorkspaceUrlOnStartup(options: { return ( options.launchBehavior === "last-workspace" || + options.navigationType === "navigate" || isRouteRestoringNavigationType(options.navigationType) ); } @@ -192,9 +193,9 @@ function getInitialRoute(): string { } } - // In browser mode (not Storybook), read route directly from the current URL. Workspace - // routes are special: fresh launches may ignore them, but explicit restore-style navigations - // such as hard reload/back-forward should reopen the same chat. + // In browser mode (not Storybook), read route directly from the current URL. Standalone + // workspace launches remain special, while normal browser deep links and restore-style + // navigations such as hard reload/back-forward should reopen the same chat. if (window.location.protocol !== "file:" && !isStorybook) { const url = routePathname + window.location.search; // Only use URL if it's a valid route (starts with /, not just "/" or empty) diff --git a/src/browser/contexts/WorkspaceContext.test.tsx b/src/browser/contexts/WorkspaceContext.test.tsx index 9d2b07903e..09e40b0c54 100644 --- a/src/browser/contexts/WorkspaceContext.test.tsx +++ b/src/browser/contexts/WorkspaceContext.test.tsx @@ -17,6 +17,7 @@ import { getTerminalTitlesKey, getThinkingLevelKey, } from "@/common/constants/storage"; +import { SCRATCH_PROJECT_CONFIG_KEY } from "@/common/constants/scratch"; import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject"; import type { RecursivePartial } from "@/browser/testUtils"; import { readPersistedState } from "@/browser/hooks/usePersistedState"; @@ -243,6 +244,63 @@ describe("WorkspaceContext", () => { expect(localStorage.getItem(SELECTED_WORKSPACE_KEY)).toBeNull(); }); + test("navigates to scratch creation when selected scratch workspace is archived", async () => { + const workspaceId = "scratch-archive"; + const scratchWorkdir = "/tmp/mux/scratch/scratch-archive"; + const scratchMetadata = createProjectWorkspaceMetadata(workspaceId, scratchWorkdir, { + kind: "scratch", + projectName: "Scratch", + namedWorkspacePath: scratchWorkdir, + }); + + let emitArchive: + | ((event: { workspaceId: string; metadata: FrontendWorkspaceMetadata | null }) => void) + | null = null; + + createMockAPI({ + workspace: { + list: () => Promise.resolve([scratchMetadata]), + onMetadata: () => + Promise.resolve( + (async function* () { + const event = await new Promise<{ + workspaceId: string; + metadata: FrontendWorkspaceMetadata | null; + }>((resolve) => { + emitArchive = resolve; + }); + yield event; + })() as unknown as Awaited> + ), + }, + projects: { + list: () => Promise.resolve([]), + }, + localStorage: { + [LAUNCH_BEHAVIOR_KEY]: JSON.stringify("last-workspace"), + }, + locationPath: `/workspace/${workspaceId}`, + }); + + const ctx = await setup(); + + await waitFor(() => expect(ctx().selectedWorkspace?.workspaceId).toBe(workspaceId)); + await waitFor(() => expect(emitArchive).toBeTruthy()); + + act(() => { + emitArchive?.({ + workspaceId, + metadata: { + ...scratchMetadata, + archivedAt: "2025-02-01T00:00:00.000Z", + }, + }); + }); + + await waitFor(() => expect(ctx().pendingNewWorkspaceProject).toBe(SCRATCH_PROJECT_CONFIG_KEY)); + expect(ctx().selectedWorkspace).toBeNull(); + }); + test("archiving does not override a rapid manual workspace switch", async () => { const archivedId = "ws-archive-old"; const nextId = "ws-keep"; @@ -644,6 +702,36 @@ describe("WorkspaceContext", () => { await waitFor(() => expect(ctx().selectedWorkspace).toBeNull()); }); + test("removeWorkspace returns selected scratch workspace to scratch creation", async () => { + const workspaceId = "scratch-remove"; + const scratchWorkdir = "/tmp/mux/scratch/scratch-remove"; + + createMockAPI({ + workspace: { + list: () => + Promise.resolve([ + createProjectWorkspaceMetadata(workspaceId, scratchWorkdir, { + kind: "scratch", + projectName: "Scratch", + namedWorkspacePath: scratchWorkdir, + }), + ]), + }, + localStorage: { + [LAUNCH_BEHAVIOR_KEY]: JSON.stringify("last-workspace"), + }, + locationPath: `/workspace/${workspaceId}`, + }); + + const ctx = await setup(); + + await waitFor(() => expect(ctx().selectedWorkspace?.workspaceId).toBe(workspaceId)); + await ctx().removeWorkspace(workspaceId); + + await waitFor(() => expect(ctx().pendingNewWorkspaceProject).toBe(SCRATCH_PROJECT_CONFIG_KEY)); + expect(ctx().selectedWorkspace).toBeNull(); + }); + test("removeWorkspace handles failure gracefully", async () => { const { workspace: workspaceApi } = createMockAPI(); @@ -1009,6 +1097,57 @@ describe("WorkspaceContext", () => { expect(ctx().pendingNewWorkspaceProject).toBeNull(); }); + test("browser direct open restores an existing scratch workspace", async () => { + const workspaceId = "scratch-direct"; + const scratchWorkdir = "/tmp/mux/scratch/scratch-direct"; + + createMockAPI({ + workspace: { + list: () => + Promise.resolve([ + createProjectWorkspaceMetadata(workspaceId, scratchWorkdir, { + kind: "scratch", + projectName: "Scratch", + namedWorkspacePath: scratchWorkdir, + }), + ]), + }, + localStorage: { + [LAUNCH_BEHAVIOR_KEY]: JSON.stringify("dashboard"), + }, + locationPath: `/workspace/${workspaceId}`, + navigationType: "navigate", + }); + + const ctx = await setup(); + + await waitFor(() => expect(ctx().loading).toBe(false)); + await waitFor(() => expect(ctx().selectedWorkspace?.workspaceId).toBe(workspaceId)); + expect(ctx().pendingNewWorkspaceProject).toBeNull(); + }); + + test("browser direct open clears an unknown workspace after metadata loads", async () => { + createMockAPI({ + workspace: { + list: () => Promise.resolve([createProjectWorkspaceMetadata("ws-existing", "/existing")]), + }, + projects: { + list: () => Promise.resolve([["/existing", { workspaces: [] }]]), + }, + localStorage: { + [LAUNCH_BEHAVIOR_KEY]: JSON.stringify("dashboard"), + }, + locationPath: "/workspace/ws-missing", + navigationType: "navigate", + }); + + const ctx = await setup(); + + await waitFor(() => expect(ctx().loading).toBe(false)); + await waitFor(() => expect(ctx().pendingNewWorkspaceProject).toBe("/existing")); + expect(ctx().selectedWorkspace).toBeNull(); + }); + test("resolves system project route IDs for pending workspace creation", async () => { const systemProjectPath = "/system/internal-project"; const systemProjectId = getProjectRouteId(systemProjectPath); diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index 23014f9828..09722fd3fd 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -44,6 +44,7 @@ import { WORKSPACE_DRAFTS_BY_PROJECT_KEY, type LaunchBehavior, } from "@/common/constants/storage"; +import { SCRATCH_PROJECT_CONFIG_KEY } from "@/common/constants/scratch"; import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject"; import { useAPI } from "@/browser/contexts/API"; import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange"; @@ -562,6 +563,12 @@ function shouldBlockStartupAutoNavigation(options: { ); } +function getWorkspaceProjectRoutePath( + metadata: Pick +): string { + return metadata.kind === "scratch" ? SCRATCH_PROJECT_CONFIG_KEY : metadata.projectPath; +} + function getMostRecentVisibleWorkspaceScope( workspaceMetadata: Map, workspaceRecency: Record, @@ -569,6 +576,9 @@ function getMostRecentVisibleWorkspaceScope( ): WorkspaceCreationScope | null { const recentWorkspace = [...workspaceMetadata.values()] .filter((workspace) => { + if (workspace.kind === "scratch") { + return true; + } const projectConfig = getProjectConfig(workspace.projectPath); if (!projectConfig) { return false; @@ -595,7 +605,7 @@ function getMostRecentVisibleWorkspaceScope( return recentWorkspace ? { - projectPath: recentWorkspace.projectPath, + projectPath: getWorkspaceProjectRoutePath(recentWorkspace), subProjectPath: recentWorkspace.subProjectPath ?? null, } : null; @@ -1177,20 +1187,15 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { // in metadata (e.g., deleted since last session), clear the stale route and // persisted selection so the user isn't stuck in a restore loop. useEffect(() => { - if (loading) return; + if (loading || !loaded || loadError) return; if (hasCheckedStaleRouteRef.current) return; hasCheckedStaleRouteRef.current = true; if (!currentWorkspaceId) return; if (workspaceMetadata.has(currentWorkspaceId)) return; - // If metadata is empty, a transient backend failure may have caused - // workspace.list to return nothing — don't clear a potentially valid route. - if (workspaceMetadata.size === 0) return; - - // Workspace ID from initial route doesn't exist — clear stale state. setSelectedWorkspace(null); - }, [loading, currentWorkspaceId, workspaceMetadata, setSelectedWorkspace]); + }, [loading, loaded, loadError, currentWorkspaceId, workspaceMetadata, setSelectedWorkspace]); // Subscribe to metadata updates (for create/rename/delete operations) useEffect(() => { @@ -1226,16 +1231,18 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { const currentSelection = selectedWorkspaceRef.current; if (currentSelection?.workspaceId === event.workspaceId) { const nextId = findAdjacentWorkspaceId(event.workspaceId, { - preferredProjectPath: meta.projectPath, - getProjectPath: (workspaceId) => - workspaceMetadataRef.current.get(workspaceId)?.projectPath, + preferredProjectPath: getWorkspaceProjectRoutePath(meta), + getProjectPath: (workspaceId) => { + const workspace = workspaceMetadataRef.current.get(workspaceId); + return workspace ? getWorkspaceProjectRoutePath(workspace) : undefined; + }, }); const nextMeta = nextId ? workspaceMetadataRef.current.get(nextId) : null; if (nextMeta) { setSelectedWorkspace(toWorkspaceSelection(nextMeta)); } else { - clearSelectionToProject(meta.projectPath); + clearSelectionToProject(getWorkspaceProjectRoutePath(meta)); } } } @@ -1308,11 +1315,13 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { } // Try sibling workspace in same project - const projectPath = deletedMeta?.projectPath; + const projectPath = deletedMeta ? getWorkspaceProjectRoutePath(deletedMeta) : undefined; const fallbackMeta = (projectPath ? Array.from(workspaceMetadataRef.current.values()).find( - (meta) => meta.projectPath === projectPath && meta.id !== event.workspaceId + (meta) => + getWorkspaceProjectRoutePath(meta) === projectPath && + meta.id !== event.workspaceId ) : null) ?? Array.from(workspaceMetadataRef.current.values()).find( @@ -1396,7 +1405,10 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { // We check currentWorkspaceId (from URL) rather than selectedWorkspace // because it's the source of truth for what's actually selected. const wasSelected = currentWorkspaceId === workspaceId; - const projectPath = selectedWorkspace?.projectPath; + const metadata = workspaceMetadata.get(workspaceId); + const projectPath = metadata + ? getWorkspaceProjectRoutePath(metadata) + : selectedWorkspace?.projectPath; try { const result = await api.workspace.remove({ workspaceId, options }); @@ -1441,6 +1453,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { navigateToProject, refreshProjects, selectedWorkspace, + workspaceMetadata, api, setWorkspaceMetadata, ] diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 3b489d1b1e..d9019b4e88 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -285,7 +285,8 @@ const ChatInputInner: React.FC = (props) => { const memoryConsolidationExperimentEnabled = useExperimentValue( EXPERIMENT_IDS.MEMORY_CONSOLIDATION ); - const atMentionProjectPath = variant === "creation" ? props.projectPath : null; + const atMentionProjectPath = + variant === "creation" && props.kind !== "scratch" ? props.projectPath : null; const asyncCommandScopeRef = useRef<{ variant: typeof variant; workspaceId: string | null }>({ variant, workspaceId: variant === "workspace" ? props.workspaceId : null, @@ -925,6 +926,7 @@ const ChatInputInner: React.FC = (props) => { const creationState = useCreationWorkspace( variant === "creation" ? { + kind: props.kind, projectPath: creationParentProjectPath, subProjectPath: creationSubProjectPath, onWorkspaceCreated: props.onWorkspaceCreated, @@ -967,12 +969,13 @@ const ChatInputInner: React.FC = (props) => { }); const creationRuntimeError = - variant === "creation" + variant === "creation" && props.kind !== "scratch" ? validateCreationRuntime(creationState.selectedRuntime, coderState.presets.length) : null; const creationRuntimePolicyError = variant === "creation" && + props.kind !== "scratch" && effectivePolicy?.runtimes != null && !isParsedRuntimeAllowedByPolicy(effectivePolicy, creationState.selectedRuntime) ? creationState.selectedRuntime.mode === "ssh" && @@ -987,7 +990,7 @@ const ChatInputInner: React.FC = (props) => { variant === "creation" && hasAttemptedCreateSend ? (creationRuntimeError?.mode ?? null) : null; const creationControlsProps = - variant === "creation" + variant === "creation" && props.kind !== "scratch" ? ({ branches: creationState.branches, branchesLoaded: creationState.branchesLoaded, @@ -2515,12 +2518,14 @@ const ChatInputInner: React.FC = (props) => { setHasAttemptedCreateSend(true); - const runtimeError = validateCreationRuntime( - creationState.selectedRuntime, - coderState.presets.length - ); - if (runtimeError) { - return; + if (props.kind !== "scratch") { + const runtimeError = validateCreationRuntime( + creationState.selectedRuntime, + coderState.presets.length + ); + if (runtimeError) { + return; + } } // Creation variant: simple message send + workspace creation @@ -3034,7 +3039,9 @@ const ChatInputInner: React.FC = (props) => { const placeholder = (() => { // Creation view keeps the onboarding prompt; workspace stays concise for the inline hints. if (variant === "creation") { - return "Type your first message to create a workspace..."; + return props.kind === "scratch" + ? "Type your first message to start a scratch chat..." + : "Type your first message to create a workspace..."; } // Workspace variant placeholders @@ -3064,10 +3071,7 @@ const ChatInputInner: React.FC = (props) => { // slash-command tricks on a wall-clock bucket so switching workspaces // mid-bucket doesn't reroll the visible tip. See placeholderTips.ts. // - // Mobile gets the plain placeholder because the on-screen keyboard already - // squeezes the input and a long English sentence in the placeholder looks - // like a wall of grey text instead of a hint. - if (isMobileTouch) { + if (isMobileTouch || props.kind === "scratch") { return "Type a message..."; } return getPlaceholderTip(); @@ -3087,7 +3091,11 @@ const ChatInputInner: React.FC = (props) => { )} diff --git a/src/browser/features/ChatInput/types.ts b/src/browser/features/ChatInput/types.ts index 945ea36444..a77ea9044f 100644 --- a/src/browser/features/ChatInput/types.ts +++ b/src/browser/features/ChatInput/types.ts @@ -29,6 +29,7 @@ export interface WorkspaceCreatedOptions { // Workspace variant: full functionality for existing workspaces export interface ChatInputWorkspaceVariant { variant: "workspace"; + kind?: "scratch"; workspaceId: string; /** Runtime type for the workspace (for telemetry) - no sensitive details like SSH host */ runtimeType?: TelemetryRuntimeType; @@ -71,6 +72,7 @@ export interface ChatInputWorkspaceVariant { // Creation variant: simplified for first message / workspace creation export interface ChatInputCreationVariant { variant: "creation"; + kind?: "scratch"; projectPath: string; projectName: string; /** Sub-project path for parent-owned draft creation. */ diff --git a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx index 97a377b545..1c90324e57 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx @@ -32,6 +32,7 @@ import type { import { act, cleanup, render, waitFor } from "@testing-library/react"; import { afterAll, afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; import { GlobalWindow } from "happy-dom"; +import type { WorkspaceCreatedOptions } from "./types"; import { useCreationWorkspace, type CreationSendResult } from "./useCreationWorkspace"; const readPersistedStateCalls: Array<[string, unknown]> = []; @@ -250,6 +251,8 @@ type WorkflowStartArgs = Parameters[0]; type WorkflowStartResult = Awaited>; type WorkflowGetRunArgs = Parameters[0]; type WorkflowGetRunResult = Awaited>; +type WorkspaceCreateScratchArgs = Parameters[0]; +type WorkspaceCreateScratchResult = Awaited>; type WorkspaceCreateResult = Awaited>; type NameGenerationArgs = Parameters[0]; type NameGenerationResult = Awaited>; @@ -259,7 +262,7 @@ type MockOrpcProjectsClient = Pick< >; type MockOrpcWorkspaceClient = Pick< APIClient["workspace"], - "sendMessage" | "create" | "updateAgentAISettings" | "getGoal" | "setGoal" + "sendMessage" | "create" | "createScratch" | "updateAgentAISettings" | "getGoal" | "setGoal" >; type MockOrpcWorkflowsClient = Pick; type MockOrpcNameGenerationClient = Pick; @@ -307,6 +310,9 @@ interface SetupWindowOptions { workflowGetRun?: ReturnType< typeof mock<(args: WorkflowGetRunArgs) => Promise> >; + createScratch?: ReturnType< + typeof mock<(args: WorkspaceCreateScratchArgs) => Promise> + >; create?: ReturnType Promise>>; nameGeneration?: ReturnType< typeof mock<(args: NameGenerationArgs) => Promise> @@ -318,6 +324,7 @@ const setupWindow = ({ listBranches, sendMessage, create, + createScratch, updateAgentAISettings, getGoal, setGoal, @@ -407,6 +414,15 @@ const setupWindow = ({ } as WorkspaceCreateResult); }); + const createScratchMock = + createScratch ?? + mock<(args: WorkspaceCreateScratchArgs) => Promise>(() => { + return Promise.resolve({ + success: true, + metadata: { ...TEST_METADATA, kind: "scratch" }, + } as WorkspaceCreateScratchResult); + }); + const updateAgentAISettingsMock = updateAgentAISettings ?? mock< @@ -447,6 +463,7 @@ const setupWindow = ({ workspace: { sendMessage: (input: WorkspaceSendMessageArgs) => sendMessageMock(input), create: (input: WorkspaceCreateArgs) => createMock(input), + createScratch: (input: WorkspaceCreateScratchArgs) => createScratchMock(input), updateAgentAISettings: (input: WorkspaceUpdateAgentAISettingsArgs) => updateAgentAISettingsMock(input), getGoal: (input: WorkspaceGetGoalArgs) => getGoalMock(input), @@ -491,6 +508,7 @@ const setupWindow = ({ workspace: { list: rejectNotImplemented("workspace.list"), create: (args: WorkspaceCreateArgs) => createMock(args), + createScratch: (args: WorkspaceCreateScratchArgs) => createScratchMock(args), updateAgentAISettings: (args: WorkspaceUpdateAgentAISettingsArgs) => updateAgentAISettingsMock(args), remove: rejectNotImplemented("workspace.remove"), @@ -561,6 +579,7 @@ const setupWindow = ({ workspaceApi: { sendMessage: sendMessageMock, create: createMock, + createScratch: createScratchMock, updateAgentAISettings: updateAgentAISettingsMock, getGoal: getGoalMock, setGoal: setGoalMock, @@ -670,6 +689,58 @@ describe("useCreationWorkspace", () => { expect(getHook().branches).toEqual([]); }); + test("scratch creation skips project loading and uses createScratch", async () => { + const listBranchesMock = mock(() => + Promise.reject(new Error("scratch creation should not load branches")) + ); + const scratchMetadata: FrontendWorkspaceMetadata = { + ...TEST_METADATA, + kind: "scratch", + projectName: "Scratch", + projectPath: "/tmp/mux/scratch/ws-created", + namedWorkspacePath: "/tmp/mux/scratch/ws-created", + runtimeConfig: { type: "local" }, + }; + const createScratchMock = mock( + (_args: WorkspaceCreateScratchArgs): Promise => + Promise.resolve({ success: true, metadata: scratchMetadata }) + ); + const createMock = mock( + (_args: WorkspaceCreateArgs): Promise => + Promise.reject(new Error("regular create should not run")) + ); + const { workspaceApi } = setupWindow({ + listBranches: listBranchesMock, + create: createMock, + createScratch: createScratchMock, + }); + const onWorkspaceCreated = mock( + (metadata: FrontendWorkspaceMetadata, _options?: WorkspaceCreatedOptions) => metadata + ); + const getHook = renderUseCreationWorkspace({ + kind: "scratch", + projectPath: "_scratch", + onWorkspaceCreated, + message: "Inspect this idea", + }); + + let result: CreationSendResult | undefined; + await act(async () => { + result = await getHook().handleSend("Inspect this idea"); + }); + + expect(result).toEqual({ success: true }); + expect(listBranchesMock).not.toHaveBeenCalled(); + expect(workspaceApi.create).not.toHaveBeenCalled(); + expect(workspaceApi.createScratch).toHaveBeenCalledTimes(1); + expect(workspaceApi.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: scratchMetadata.id, message: "Inspect this idea" }) + ); + expect(onWorkspaceCreated).toHaveBeenCalledTimes(1); + expect(onWorkspaceCreated.mock.calls[0]?.[0]).toEqual(scratchMetadata); + expect(typeof onWorkspaceCreated.mock.calls[0]?.[1]?.pendingStreamModel).toBe("string"); + }); + test("handleSend creates workspace and sends message on success", async () => { const listBranchesMock = mock( (): Promise => @@ -1482,6 +1553,7 @@ function createDraftSettingsHarness( } interface HookOptions { + kind?: "scratch"; projectPath: string; onWorkspaceCreated: ( metadata: FrontendWorkspaceMetadata, diff --git a/src/browser/features/ChatInput/useCreationWorkspace.ts b/src/browser/features/ChatInput/useCreationWorkspace.ts index 38e6e0c79c..b47381b0e4 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.ts +++ b/src/browser/features/ChatInput/useCreationWorkspace.ts @@ -69,6 +69,7 @@ export type CreationSendResult = { success: true } | { success: false; error?: S export type CreationInitialSlashCommand = Extract; interface UseCreationWorkspaceOptions { + kind?: "scratch"; projectPath: string; onWorkspaceCreated: ( metadata: FrontendWorkspaceMetadata, @@ -236,6 +237,7 @@ export type RuntimeAvailabilityState = * - Message sending with workspace creation */ export function useCreationWorkspace({ + kind, projectPath, onWorkspaceCreated, message, @@ -316,7 +318,7 @@ export function useCreationWorkspace({ // Load branches - used on mount and after git init // Returns a cleanup function to track mounted state const loadBranches = useCallback(async () => { - if (!projectPath.length || !api) return; + if (kind === "scratch" || !projectPath.length || !api) return; setBranchesLoaded(false); try { const result = await api.projects.listBranches({ projectPath }); @@ -327,10 +329,14 @@ export function useCreationWorkspace({ } finally { setBranchesLoaded(true); } - }, [projectPath, api]); + }, [kind, projectPath, api]); // Load branches and runtime availability on mount with mounted guard useEffect(() => { + if (kind === "scratch") { + setBranchesLoaded(true); + return; + } if (!projectPath.length || !api) return; let mounted = true; setBranchesLoaded(false); @@ -364,7 +370,7 @@ export function useCreationWorkspace({ return () => { mounted = false; }; - }, [projectPath, api]); + }, [kind, projectPath, api]); // Cleanup: resolve trust prompt on unmount so handleSend doesn't wedge useEffect(() => { @@ -509,7 +515,7 @@ export function useCreationWorkspace({ // Gate: untrusted projects must be confirmed before workspace creation. // Skip while projects are still loading — the backend gate catches untrusted projects. - if (!projectsLoading && !getProjectConfig(projectPath)?.trusted) { + if (kind !== "scratch" && !projectsLoading && !getProjectConfig(projectPath)?.trusted) { const userConfirmed = await new Promise((resolve) => { setTrustPrompt({ resolve, projectPath }); }); @@ -522,15 +528,17 @@ export function useCreationWorkspace({ // Trust was confirmed and set, continue with creation } - // Create the workspace with the generated name and title - const createResult = await api.workspace.create({ - projectPath, - branchName: identity.name, - trunkBranch: settings.trunkBranch, - title: createTitle, - runtimeConfig, - subProjectPath: subProjectPath ?? undefined, - }); + const createResult = + kind === "scratch" + ? await api.workspace.createScratch({ title: createTitle }) + : await api.workspace.create({ + projectPath, + branchName: identity.name, + trunkBranch: settings.trunkBranch, + title: createTitle, + runtimeConfig, + subProjectPath: subProjectPath ?? undefined, + }); if (!createResult.success) { setToast({ @@ -620,7 +628,7 @@ export function useCreationWorkspace({ api, workspaceId: metadata.id, variant: "workspace", - projectPath, + projectPath: metadata.projectPath, rawInput: messageText, dynamicWorkflowsEnabled, sendMessageOptions, @@ -705,6 +713,7 @@ export function useCreationWorkspace({ } }, [ + kind, api, isSending, projectPath, diff --git a/src/browser/features/RightSidebar/RightSidebar.tsx b/src/browser/features/RightSidebar/RightSidebar.tsx index 2845d0c608..3812f0b373 100644 --- a/src/browser/features/RightSidebar/RightSidebar.tsx +++ b/src/browser/features/RightSidebar/RightSidebar.tsx @@ -29,6 +29,7 @@ import { loadGoalDefaults, resolveGoalSetIntent } from "@/browser/utils/goals/re import type { GoalCreateIntent } from "@/browser/features/RightSidebar/GoalTab"; import { usePopoverError } from "@/browser/hooks/usePopoverError"; import { PopoverError } from "@/browser/components/PopoverError/PopoverError"; +import { hasWorkspaceRepository } from "@/browser/utils/workspaceCapabilities"; import { getErrorMessage } from "@/common/utils/errors"; // Per-tab panel components are no longer imported here directly — the @@ -681,6 +682,7 @@ const RightSidebarComponent: React.FC = ({ const workspaceMetadataContext = useWorkspaceMetadata(); const currentWorkspaceMetadata = workspaceMetadataContext.workspaceMetadata.get(workspaceId) ?? null; + const canReviewDiffs = hasWorkspaceRepository(currentWorkspaceMetadata); const isChildWorkspaceForGoal = currentWorkspaceMetadata?.parentWorkspaceId != null; // Safe variant: storybook stories may render before addWorkspace() runs; the // optional hook returns null instead of throwing assertGet on the unregistered @@ -843,8 +845,9 @@ const RightSidebarComponent: React.FC = ({ // Read last-used focused tab for better defaults when initializing a new layout. const initialActiveTab = React.useMemo(() => { const raw = readPersistedState(RIGHT_SIDEBAR_TAB_KEY, "costs"); + if (!canReviewDiffs && raw === "review") return "costs"; return isTabType(raw) ? raw : "costs"; - }, []); + }, [canReviewDiffs]); const defaultLayout = React.useMemo( () => getDefaultRightSidebarLayoutState(initialActiveTab), @@ -890,10 +893,14 @@ const RightSidebarComponent: React.FC = ({ setLayoutDraft(null); }, [setLayoutRaw]); - const layout = React.useMemo( + const parsedLayout = React.useMemo( () => parseRightSidebarLayoutState(layoutDraft ?? layoutRaw, initialActiveTab), [layoutDraft, layoutRaw, initialActiveTab] ); + const layout = React.useMemo( + () => (canReviewDiffs ? parsedLayout : removeTabEverywhere(parsedLayout, "review")), + [canReviewDiffs, parsedLayout] + ); const hasReviewPanelMounted = React.useMemo( () => !collapsed && hasMountedReviewPanel(layout.root), @@ -1083,10 +1090,10 @@ const RightSidebarComponent: React.FC = ({ if (layoutDraft !== null) { return; } - if (layoutRaw !== layout) { - setLayoutRaw(layout); + if (layoutRaw !== parsedLayout) { + setLayoutRaw(parsedLayout); } - }, [layout, layoutDraft, layoutRaw, setLayoutRaw]); + }, [layoutDraft, layoutRaw, parsedLayout, setLayoutRaw]); const getBaseLayout = React.useCallback(() => { return ( @@ -1127,9 +1134,10 @@ const RightSidebarComponent: React.FC = ({ ); const selectOrOpenReviewTab = React.useCallback(() => { + if (!canReviewDiffs) return; setLayout((prev) => selectOrAddTab(prev, "review")); _setFocusTrigger((prev) => prev + 1); - }, [setLayout]); + }, [canReviewDiffs, setLayout]); React.useEffect(() => { const handleOpenGoalTab = (event: Event) => { @@ -1152,6 +1160,8 @@ const RightSidebarComponent: React.FC = ({ React.useEffect(() => { const handleOpenTouchReviewImmersive = (event: Event) => { const detail = (event as CustomEvent<{ workspaceId: string }>).detail; + if (!canReviewDiffs) return; + if (detail?.workspaceId !== workspaceId) { return; } @@ -1171,11 +1181,13 @@ const RightSidebarComponent: React.FC = ({ CUSTOM_EVENTS.OPEN_TOUCH_REVIEW_IMMERSIVE, handleOpenTouchReviewImmersive ); - }, [selectOrOpenReviewTab, setCollapsed, setIsReviewImmersive, workspaceId]); + }, [canReviewDiffs, selectOrOpenReviewTab, setCollapsed, setIsReviewImmersive, workspaceId]); React.useEffect(() => { const handleOpenReviewImmersive = (event: Event) => { const detail = (event as CustomEvent<{ workspaceId: string }>).detail; + if (!canReviewDiffs) return; + if (detail?.workspaceId !== workspaceId) { return; } @@ -1189,7 +1201,7 @@ const RightSidebarComponent: React.FC = ({ window.addEventListener(CUSTOM_EVENTS.OPEN_REVIEW_IMMERSIVE, handleOpenReviewImmersive); return () => window.removeEventListener(CUSTOM_EVENTS.OPEN_REVIEW_IMMERSIVE, handleOpenReviewImmersive); - }, [selectOrOpenReviewTab, setCollapsed, setIsReviewImmersive, workspaceId]); + }, [canReviewDiffs, selectOrOpenReviewTab, setCollapsed, setIsReviewImmersive, workspaceId]); // Keyboard shortcuts for tab switching by position (Cmd/Ctrl+1-9) // Auto-expands sidebar if collapsed @@ -1211,10 +1223,10 @@ const RightSidebarComponent: React.FC = ({ if (matchesKeybind(e, tabKeybinds[i])) { e.preventDefault(); - const currentLayout = parseRightSidebarLayoutState( - layoutRawRef.current, - initialActiveTab - ); + const parsedLayout = parseRightSidebarLayoutState(layoutRawRef.current, initialActiveTab); + const currentLayout = canReviewDiffs + ? parsedLayout + : removeTabEverywhere(parsedLayout, "review"); const allTabs = collectAllTabsWithTabset(currentLayout.root); const target = allTabs[i]; if (target && isTerminalTab(target.tab)) { @@ -1228,7 +1240,9 @@ const RightSidebarComponent: React.FC = ({ _setFocusTrigger((prev) => prev + 1); } - setLayout((prev) => selectTabByIndex(prev, i)); + setLayout((prev) => + selectTabByIndex(canReviewDiffs ? prev : removeTabEverywhere(prev, "review"), i) + ); setCollapsed(false); return; } @@ -1237,7 +1251,14 @@ const RightSidebarComponent: React.FC = ({ window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [initialActiveTab, setAutoFocusTerminalSession, setCollapsed, setLayout, _setFocusTrigger]); + }, [ + canReviewDiffs, + initialActiveTab, + setAutoFocusTerminalSession, + setCollapsed, + setLayout, + _setFocusTrigger, + ]); React.useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -1245,6 +1266,8 @@ const RightSidebarComponent: React.FC = ({ return; } + if (!canReviewDiffs) return; + if (isEditableElement(e.target)) { return; } @@ -1263,7 +1286,7 @@ const RightSidebarComponent: React.FC = ({ window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [selectOrOpenReviewTab, setCollapsed, setIsReviewImmersive]); + }, [canReviewDiffs, selectOrOpenReviewTab, setCollapsed, setIsReviewImmersive]); const baseId = `right-sidebar-${workspaceId}`; diff --git a/src/browser/features/Settings/Sections/KeybindsSection.tsx b/src/browser/features/Settings/Sections/KeybindsSection.tsx index 23f3770f78..c23bd8f7ee 100644 --- a/src/browser/features/Settings/Sections/KeybindsSection.tsx +++ b/src/browser/features/Settings/Sections/KeybindsSection.tsx @@ -21,6 +21,7 @@ const KEYBIND_LABELS: Record = { FOCUS_INPUT_I: "Focus input (i)", FOCUS_INPUT_A: "Focus input (a)", NEW_WORKSPACE: "New workspace", + NEW_SCRATCH_CHAT: "New scratch chat", EDIT_WORKSPACE_TITLE: "Edit workspace title", GENERATE_WORKSPACE_TITLE: "Generate new title", ARCHIVE_WORKSPACE: "Archive workspace", @@ -133,6 +134,7 @@ const KEYBIND_GROUPS: Array<{ label: string; keys: Array label: "Navigation", keys: [ "NEW_WORKSPACE", + "NEW_SCRATCH_CHAT", "EDIT_WORKSPACE_TITLE", "GENERATE_WORKSPACE_TITLE", "ARCHIVE_WORKSPACE", diff --git a/src/browser/hooks/useProvidersConfig.ts b/src/browser/hooks/useProvidersConfig.ts index 3b21f7651c..1cc3aabf75 100644 --- a/src/browser/hooks/useProvidersConfig.ts +++ b/src/browser/hooks/useProvidersConfig.ts @@ -1,5 +1,10 @@ import { useSyncExternalStore } from "react"; import { getProvidersConfigStore } from "@/browser/stores/ProvidersConfigStore"; +import type { ProvidersConfigMap } from "@/common/orpc/types"; + +export function hasConfiguredProvider(config: ProvidersConfigMap | null): boolean { + return config != null && Object.values(config).some((provider) => provider?.isConfigured); +} /** * Hook to get provider config with automatic refresh on config changes. diff --git a/src/browser/utils/commandIds.ts b/src/browser/utils/commandIds.ts index 92c715bff2..9468a89c6b 100644 --- a/src/browser/utils/commandIds.ts +++ b/src/browser/utils/commandIds.ts @@ -21,6 +21,7 @@ export const CommandIds = { workspaceSwitch: (workspaceId: string) => `${COMMAND_ID_PREFIXES.WS_SWITCH}${workspaceId}` as const, workspaceNew: () => "ws:new" as const, + workspaceNewScratch: () => "ws:new-scratch" as const, workspaceNewInProject: () => "ws:new-in-project" as const, workspaceNewMultiProject: () => "ws:new-multi-project" as const, workspaceRemove: () => "ws:remove" as const, diff --git a/src/browser/utils/commands/sources.test.ts b/src/browser/utils/commands/sources.test.ts index e55e5d6a9e..63642f617e 100644 --- a/src/browser/utils/commands/sources.test.ts +++ b/src/browser/utils/commands/sources.test.ts @@ -48,6 +48,7 @@ const mk = (over: Partial[0]> = {}) => { getReasoningMode: () => "standard", onToggleReasoningMode: () => undefined, onStartWorkspaceCreation: () => undefined, + onStartScratchCreation: () => undefined, onStartMultiProjectWorkspaceCreation: () => undefined, multiProjectWorkspacesEnabled: true, onArchiveMergedWorkspacesInProject: () => Promise.resolve(), diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index 47d619c969..62602dbf21 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -58,6 +58,7 @@ import type { WorkspaceState } from "@/browser/stores/WorkspaceStore"; import type { RuntimeConfig } from "@/common/types/runtime"; import { isGoalPendingPersistence, type GoalSetError, type GoalStatus } from "@/common/types/goal"; import { GOAL_OBJECTIVE_PLACEHOLDER } from "@/constants/goals"; +import { hasWorkspaceRepository } from "@/browser/utils/workspaceCapabilities"; import { getErrorMessage } from "@/common/utils/errors"; import { parseGoalBudgetCents } from "@/browser/utils/slashCommands/registry"; import { setGoalWithConflictRetry } from "@/browser/utils/goals/setGoalWithConflictRetry"; @@ -100,6 +101,7 @@ export interface BuildSourcesParams { */ getMinThinkingOverride?: (modelString: string) => ThinkingLevel | null | undefined; + onStartScratchCreation: () => void; onStartWorkspaceCreation: (projectPath: string) => void; onStartMultiProjectWorkspaceCreation: () => void; multiProjectWorkspacesEnabled: boolean; @@ -363,6 +365,15 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi actions.push(() => { const list: CommandAction[] = []; + list.push({ + id: CommandIds.workspaceNewScratch(), + title: "New Scratch Chat", + subtitle: "Start a chat without selecting a project", + section: section.workspaces, + shortcutHint: formatKeybind(KEYBINDS.NEW_SCRATCH_CHAT), + run: p.onStartScratchCreation, + }); + const selected = p.selectedWorkspace; if (selected) { list.push(createWorkspaceForSelectedProjectAction(selected)); @@ -683,6 +694,7 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi // Right sidebar layout commands require a selected workspace (layout is per-workspace) const wsId = p.selectedWorkspace?.workspaceId; if (wsId) { + const canReviewDiffs = hasWorkspaceRepository(p.workspaceMetadata.get(wsId)); list.push( // Generic per-tab "Hide/Show " commands are only for optional tabs. // Default-layout tabs (Stats/Review/Instructions) are auto-restored by @@ -751,7 +763,11 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi // Terminal is appended manually because it lives outside the static registry. getOptions: () => [ ...getOrderedBaseTabIds() - .filter((tabId) => getTabConfig(tabId).featureFlag == null) + .filter( + (tabId) => + getTabConfig(tabId).featureFlag == null && + (canReviewDiffs || tabId !== "review") + ) .map((tabId) => { const config = getTabConfig(tabId); return { @@ -768,6 +784,8 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi const tool = vals.tool; if (!isTabType(tool)) return; + if (tool === "review" && !canReviewDiffs) return; + // "terminal" is now an alias for "focus an existing terminal session tab". // Creating new terminal sessions is handled in the main UI ("+" button). if (tool === "terminal") { diff --git a/src/browser/utils/rightSidebarLayout.test.ts b/src/browser/utils/rightSidebarLayout.test.ts index 004674eb6b..d16921c206 100644 --- a/src/browser/utils/rightSidebarLayout.test.ts +++ b/src/browser/utils/rightSidebarLayout.test.ts @@ -7,6 +7,7 @@ import { getDefaultRightSidebarLayoutState, moveTabToTabset, parseRightSidebarLayoutState, + removeTabEverywhere, reorderTabInTabset, selectTabInFocusedTabset, splitFocusedTabset, @@ -23,6 +24,13 @@ test("default layout includes Instructions alongside Stats and Review", () => { expect(state.root.tabs).toContain("instructions"); }); +test("removeTabEverywhere preserves identity when the tab is absent", () => { + const state = getDefaultRightSidebarLayoutState("costs"); + const withoutBrowser = removeTabEverywhere(state, "browser"); + + expect(withoutBrowser).toBe(state); +}); + test("selectTabInFocusedTabset adds missing tool and makes it active", () => { let s = getDefaultRightSidebarLayoutState("costs"); // Start with a layout that only has costs. diff --git a/src/browser/utils/rightSidebarLayout.ts b/src/browser/utils/rightSidebarLayout.ts index fd8f29cfdc..ebcc2aaa5d 100644 --- a/src/browser/utils/rightSidebarLayout.ts +++ b/src/browser/utils/rightSidebarLayout.ts @@ -198,6 +198,8 @@ function removeTabFromNode( ): RightSidebarLayoutNode | null { if (node.type === "tabset") { const oldIndex = node.tabs.indexOf(tab); + if (oldIndex === -1) return node; + const tabs = node.tabs.filter((t) => t !== tab); if (tabs.length === 0) return null; @@ -222,6 +224,7 @@ function removeTabFromNode( // If one side goes empty, promote the other side to avoid empty panes. if (!left) return right; if (!right) return left; + if (left === node.children[0] && right === node.children[1]) return node; return { ...node, @@ -234,6 +237,9 @@ export function removeTabEverywhere( tab: TabType ): RightSidebarLayoutState { const nextRoot = removeTabFromNode(state.root, tab); + if (nextRoot === state.root) { + return state; + } if (!nextRoot) { return getDefaultRightSidebarLayoutState("costs"); } diff --git a/src/browser/utils/ui/keybinds.ts b/src/browser/utils/ui/keybinds.ts index 9e9518dc7f..c2090226f8 100644 --- a/src/browser/utils/ui/keybinds.ts +++ b/src/browser/utils/ui/keybinds.ts @@ -335,6 +335,9 @@ export const KEYBINDS = { /** Create new workspace for current project */ NEW_WORKSPACE: { key: "n", ctrl: true }, + /** Create a project-less scratch chat */ + NEW_SCRATCH_CHAT: { key: "n", ctrl: true, shift: true }, + /** Edit title of current workspace (inline edit) */ EDIT_WORKSPACE_TITLE: { key: "F2" }, diff --git a/src/browser/utils/workspaceCapabilities.ts b/src/browser/utils/workspaceCapabilities.ts new file mode 100644 index 0000000000..b8dd156fdd --- /dev/null +++ b/src/browser/utils/workspaceCapabilities.ts @@ -0,0 +1,7 @@ +import type { WorkspaceMetadata } from "@/common/types/workspace"; + +export function hasWorkspaceRepository( + metadata: Pick | null | undefined +): boolean { + return metadata != null && metadata.kind !== "scratch"; +} diff --git a/src/common/constants/scratch.ts b/src/common/constants/scratch.ts new file mode 100644 index 0000000000..50b7be96c3 --- /dev/null +++ b/src/common/constants/scratch.ts @@ -0,0 +1,5 @@ +export const SCRATCH_PROJECT_CONFIG_KEY = "_scratch"; + +export const SCRATCH_SIDEBAR_SECTION_ID = "__scratch__"; + +export const SCRATCH_PROJECT_NAME = "Scratch"; diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 7e759783fa..1078be7710 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -1098,6 +1098,15 @@ export const workspace = { z.object({ success: z.literal(false), error: z.string() }), ]), }, + createScratch: { + input: z.object({ + title: z.string().optional(), + }), + output: z.discriminatedUnion("success", [ + z.object({ success: z.literal(true), metadata: FrontendWorkspaceMetadataSchema }), + z.object({ success: z.literal(false), error: z.string() }), + ]), + }, createMultiProject: { input: z.object({ projects: z diff --git a/src/common/orpc/schemas/workspace.ts b/src/common/orpc/schemas/workspace.ts index 248f5c0d96..40e11bcce4 100644 --- a/src/common/orpc/schemas/workspace.ts +++ b/src/common/orpc/schemas/workspace.ts @@ -126,6 +126,9 @@ export const WorkflowTaskMetadataSchema = z.object({ }); export const WorkspaceMetadataSchema = z.object({ + kind: z.literal("scratch").optional().meta({ + description: "Marks an app-owned project-less scratch chat workspace.", + }), id: z.string().meta({ description: "Stable unique identifier (10 hex chars for new workspaces, legacy format for old)", diff --git a/src/common/schemas/project.ts b/src/common/schemas/project.ts index 47357e0d71..5399dc9add 100644 --- a/src/common/schemas/project.ts +++ b/src/common/schemas/project.ts @@ -63,6 +63,9 @@ export const WorkspaceConfigSchema = z.object({ path: z.string().meta({ description: "Absolute path to workspace directory - REQUIRED for backward compatibility", }), + kind: z.literal("scratch").optional().meta({ + description: "Marks an app-owned project-less scratch chat workspace.", + }), id: z.string().optional().meta({ description: "Stable workspace ID (10 hex chars for new workspaces) - optional for legacy", }), diff --git a/src/node/builtinSkills/mux-docs.md b/src/node/builtinSkills/mux-docs.md index a4afbb907e..a8a398302a 100644 --- a/src/node/builtinSkills/mux-docs.md +++ b/src/node/builtinSkills/mux-docs.md @@ -47,67 +47,68 @@ Use this index to find a page's: - **Documentation** - **Getting Started** - Introduction (`/`) → `references/docs/index.mdx` - - Install (`/install`) → `references/docs/install.mdx` — Download and install Mux for macOS, Linux, and Windows + - Install (`/install`) → `references/docs/install.mdx`: Download and install Mux for macOS, Linux, and Windows - **Models** - - Models (`/config/models`) → `references/docs/config/models.mdx` — Select and configure AI models in Mux - - Providers (`/config/providers`) → `references/docs/config/providers.mdx` — Configure API keys and settings for AI providers - - Why Parallelize? (`/getting-started/why-parallelize`) → `references/docs/getting-started/why-parallelize.mdx` — Use cases for running multiple AI agents in parallel - - Mux Gateway (`/getting-started/mux-gateway`) → `references/docs/getting-started/mux-gateway.mdx` — Log in to Mux Gateway to get evaluation credits - - CLI (`/reference/cli`) → `references/docs/reference/cli.mdx` — Run one-off agent tasks and durable workflows from the command line + - Models (`/config/models`) → `references/docs/config/models.mdx`: Select and configure AI models in Mux + - Providers (`/config/providers`) → `references/docs/config/providers.mdx`: Configure API keys and settings for AI providers + - Why Parallelize? (`/getting-started/why-parallelize`) → `references/docs/getting-started/why-parallelize.mdx`: Use cases for running multiple AI agents in parallel + - Mux Gateway (`/getting-started/mux-gateway`) → `references/docs/getting-started/mux-gateway.mdx`: Log in to Mux Gateway to get evaluation credits + - CLI (`/reference/cli`) → `references/docs/reference/cli.mdx`: Run one-off agent tasks and durable workflows from the command line - **Workspaces** - - Workspaces (`/workspaces`) → `references/docs/workspaces/index.mdx` — Isolated development environments for parallel agent work - - Forking Workspaces (`/workspaces/fork`) → `references/docs/workspaces/fork.mdx` — Clone workspaces with conversation history to explore alternatives - - .muxignore (`/workspaces/muxignore`) → `references/docs/workspaces/muxignore.mdx` — Sync gitignored files to worktree workspaces + - Workspaces (`/workspaces`) → `references/docs/workspaces/index.mdx`: Isolated development environments for parallel agent work + - Scratch chats (`/workspaces/scratch-chats`) → `references/docs/workspaces/scratch-chats.mdx`: Start a durable chat with an app-managed folder and no project or Git repository. + - Forking Workspaces (`/workspaces/fork`) → `references/docs/workspaces/fork.mdx`: Clone workspaces with conversation history to explore alternatives + - .muxignore (`/workspaces/muxignore`) → `references/docs/workspaces/muxignore.mdx`: Sync gitignored files to worktree workspaces - **Compaction** - - Compaction (`/workspaces/compaction`) → `references/docs/workspaces/compaction/index.mdx` — Managing conversation context size with compaction - - Manual Compaction (`/workspaces/compaction/manual`) → `references/docs/workspaces/compaction/manual.mdx` — Commands for manually managing conversation context - - Automatic Compaction (`/workspaces/compaction/automatic`) → `references/docs/workspaces/compaction/automatic.mdx` — Let Mux automatically compact your conversations based on usage or idle time - - Customization (`/workspaces/compaction/customization`) → `references/docs/workspaces/compaction/customization.mdx` — Customize the compaction system prompt + - Compaction (`/workspaces/compaction`) → `references/docs/workspaces/compaction/index.mdx`: Managing conversation context size with compaction + - Manual Compaction (`/workspaces/compaction/manual`) → `references/docs/workspaces/compaction/manual.mdx`: Commands for manually managing conversation context + - Automatic Compaction (`/workspaces/compaction/automatic`) → `references/docs/workspaces/compaction/automatic.mdx`: Let Mux automatically compact your conversations based on usage or idle time + - Customization (`/workspaces/compaction/customization`) → `references/docs/workspaces/compaction/customization.mdx`: Customize the compaction system prompt - **Runtimes** - - Runtimes (`/runtime`) → `references/docs/runtime/index.mdx` — Configure where and how Mux executes agent workspaces - - Local Runtime (`/runtime/local`) → `references/docs/runtime/local.mdx` — Run agents directly in your project directory - - Worktree Runtime (`/runtime/worktree`) → `references/docs/runtime/worktree.mdx` — Isolated git worktree environments for parallel agent work - - SSH Runtime (`/runtime/ssh`) → `references/docs/runtime/ssh.mdx` — Run agents on remote hosts over SSH for security and performance - - Coder Runtime (`/runtime/coder`) → `references/docs/runtime/coder.mdx` — Run agents on Coder workspaces - - Docker Runtime (`/runtime/docker`) → `references/docs/runtime/docker.mdx` — Run agents in isolated Docker containers - - Dev Container Runtime (`/runtime/devcontainer`) → `references/docs/runtime/devcontainer.mdx` — Run agents in containers defined by devcontainer.json + - Runtimes (`/runtime`) → `references/docs/runtime/index.mdx`: Configure where and how Mux executes agent workspaces + - Local Runtime (`/runtime/local`) → `references/docs/runtime/local.mdx`: Run agents directly in your project directory + - Worktree Runtime (`/runtime/worktree`) → `references/docs/runtime/worktree.mdx`: Isolated git worktree environments for parallel agent work + - SSH Runtime (`/runtime/ssh`) → `references/docs/runtime/ssh.mdx`: Run agents on remote hosts over SSH for security and performance + - Coder Runtime (`/runtime/coder`) → `references/docs/runtime/coder.mdx`: Run agents on Coder workspaces + - Docker Runtime (`/runtime/docker`) → `references/docs/runtime/docker.mdx`: Run agents in isolated Docker containers + - Dev Container Runtime (`/runtime/devcontainer`) → `references/docs/runtime/devcontainer.mdx`: Run agents in containers defined by devcontainer.json - **Hooks** - - Init Hooks (`/hooks/init`) → `references/docs/hooks/init.mdx` — Run setup commands automatically when creating new workspaces - - Tool Hooks (`/hooks/tools`) → `references/docs/hooks/tools.mdx` — Block dangerous commands, lint after edits, and set up your environment - - Environment Variables (`/hooks/environment-variables`) → `references/docs/hooks/environment-variables.mdx` — Environment variables available in agent bash commands and hooks + - Init Hooks (`/hooks/init`) → `references/docs/hooks/init.mdx`: Run setup commands automatically when creating new workspaces + - Tool Hooks (`/hooks/tools`) → `references/docs/hooks/tools.mdx`: Block dangerous commands, lint after edits, and set up your environment + - Environment Variables (`/hooks/environment-variables`) → `references/docs/hooks/environment-variables.mdx`: Environment variables available in agent bash commands and hooks - **Agents** - - Agents (`/agents`) → `references/docs/agents/index.mdx` — Define custom agents (modes + subagents) as Markdown files - - Instruction Files (`/agents/instruction-files`) → `references/docs/agents/instruction-files.mdx` — Configure agent behavior with AGENTS.md files - - Agent Skills (`/agents/agent-skills`) → `references/docs/agents/agent-skills.mdx` — Share reusable workflows and references with skills - - Plan Mode (`/agents/plan-mode`) → `references/docs/agents/plan-mode.mdx` — Review and collaborate on plans before execution - - System Prompt (`/agents/system-prompt`) → `references/docs/agents/system-prompt.mdx` — How Mux constructs the system prompt for AI models - - Prompting Tips (`/agents/prompting-tips`) → `references/docs/agents/prompting-tips.mdx` — Tips and tricks for getting the most out of your AI agents - - Best of N (`/agents/best-of-n`) → `references/docs/agents/best-of-n.mdx` — Improve plans, analysis, and reviews by asking Mux to explore multiple candidate answers in parallel + - Agents (`/agents`) → `references/docs/agents/index.mdx`: Define custom agents (modes + subagents) as Markdown files + - Instruction Files (`/agents/instruction-files`) → `references/docs/agents/instruction-files.mdx`: Configure agent behavior with AGENTS.md files + - Agent Skills (`/agents/agent-skills`) → `references/docs/agents/agent-skills.mdx`: Share reusable workflows and references with skills + - Plan Mode (`/agents/plan-mode`) → `references/docs/agents/plan-mode.mdx`: Review and collaborate on plans before execution + - System Prompt (`/agents/system-prompt`) → `references/docs/agents/system-prompt.mdx`: How Mux constructs the system prompt for AI models + - Prompting Tips (`/agents/prompting-tips`) → `references/docs/agents/prompting-tips.mdx`: Tips and tricks for getting the most out of your AI agents + - Best of N (`/agents/best-of-n`) → `references/docs/agents/best-of-n.mdx`: Improve plans, analysis, and reviews by asking Mux to explore multiple candidate answers in parallel - **Configuration** - - MCP Servers (`/config/mcp-servers`) → `references/docs/config/mcp-servers.mdx` — Extend agent capabilities with Model Context Protocol servers - - Policy File (`/config/policy-file`) → `references/docs/config/policy-file.mdx` — Admin-enforced restrictions for providers, models, MCP, and runtimes - - Project Secrets (`/config/project-secrets`) → `references/docs/config/project-secrets.mdx` — Manage environment variables and API keys for your projects - - Agentic Git Identity (`/config/agentic-git-identity`) → `references/docs/config/agentic-git-identity.mdx` — Configure a separate Git identity for AI-generated commits - - Keyboard Shortcuts (`/config/keybinds`) → `references/docs/config/keybinds.mdx` — Complete keyboard shortcut reference for Mux - - Notifications (`/config/notifications`) → `references/docs/config/notifications.mdx` — Configure how agents notify you about important events - - Server Access (`/config/server-access`) → `references/docs/config/server-access.mdx` — Configure authentication and session controls for mux server/browser mode - - Vim Mode (`/config/vim-mode`) → `references/docs/config/vim-mode.mdx` — Vim-style editing in the Mux chat input + - MCP Servers (`/config/mcp-servers`) → `references/docs/config/mcp-servers.mdx`: Extend agent capabilities with Model Context Protocol servers + - Policy File (`/config/policy-file`) → `references/docs/config/policy-file.mdx`: Admin-enforced restrictions for providers, models, MCP, and runtimes + - Project Secrets (`/config/project-secrets`) → `references/docs/config/project-secrets.mdx`: Manage environment variables and API keys for your projects + - Agentic Git Identity (`/config/agentic-git-identity`) → `references/docs/config/agentic-git-identity.mdx`: Configure a separate Git identity for AI-generated commits + - Keyboard Shortcuts (`/config/keybinds`) → `references/docs/config/keybinds.mdx`: Complete keyboard shortcut reference for Mux + - Notifications (`/config/notifications`) → `references/docs/config/notifications.mdx`: Configure how agents notify you about important events + - Server Access (`/config/server-access`) → `references/docs/config/server-access.mdx`: Configure authentication and session controls for mux server/browser mode + - Vim Mode (`/config/vim-mode`) → `references/docs/config/vim-mode.mdx`: Vim-style editing in the Mux chat input - **Guides** - - GitHub Actions (`/guides/github-actions`) → `references/docs/guides/github-actions.mdx` — Automate your workflows with mux run in GitHub Actions - - Symbol Shortcuts (`/guides/symbol-shortcuts`) → `references/docs/guides/symbol-shortcuts.mdx` — Insert math and trading symbols in the Mux chat input with LaTeX-style backslash commands - - Agentic Git Identity (`/config/agentic-git-identity`) → `references/docs/config/agentic-git-identity.mdx` — Configure a separate Git identity for AI-generated commits - - Prompting Tips (`/agents/prompting-tips`) → `references/docs/agents/prompting-tips.mdx` — Tips and tricks for getting the most out of your AI agents + - GitHub Actions (`/guides/github-actions`) → `references/docs/guides/github-actions.mdx`: Automate your workflows with mux run in GitHub Actions + - Symbol Shortcuts (`/guides/symbol-shortcuts`) → `references/docs/guides/symbol-shortcuts.mdx`: Insert math and trading symbols in the Mux chat input with LaTeX-style backslash commands + - Agentic Git Identity (`/config/agentic-git-identity`) → `references/docs/config/agentic-git-identity.mdx`: Configure a separate Git identity for AI-generated commits + - Prompting Tips (`/agents/prompting-tips`) → `references/docs/agents/prompting-tips.mdx`: Tips and tricks for getting the most out of your AI agents - **Integrations** - - VS Code Extension (`/integrations/vscode-extension`) → `references/docs/integrations/vscode-extension.mdx` — Pair Mux workspaces with VS Code and Cursor editors - - ACP (Editor Integrations) (`/integrations/acp`) → `references/docs/integrations/acp.mdx` — Connect Mux to Zed, Neovim, and JetBrains via the Agent Client Protocol + - VS Code Extension (`/integrations/vscode-extension`) → `references/docs/integrations/vscode-extension.mdx`: Pair Mux workspaces with VS Code and Cursor editors + - ACP (Editor Integrations) (`/integrations/acp`) → `references/docs/integrations/acp.mdx`: Connect Mux to Zed, Neovim, and JetBrains via the Agent Client Protocol - **Reference** - - Debugging (`/reference/debugging`) → `references/docs/reference/debugging.mdx` — View live backend logs and diagnose issues - - Telemetry (`/reference/telemetry`) → `references/docs/reference/telemetry.mdx` — What Mux collects, what it doesn’t, and how to disable it - - Storybook (`/reference/storybook`) → `references/docs/reference/storybook.mdx` — Develop and test Mux UI states in isolation - - Terminal Benchmarking (`/reference/benchmarking`) → `references/docs/reference/benchmarking.mdx` — Run Terminal-Bench benchmarks with the Mux adapter - - Context Boundaries for Compaction and Reset (`/adr/0003-context-boundaries-for-compaction-and-reset`) → `references/docs/adr/0003-context-boundaries-for-compaction-and-reset.md` — Architecture decision for modeling provider context windows separately from transcript history - - CLI Goal Runs are not strict /goal aliases (`/adr/0004-cli-goal-runs-are-not-strict-goal-aliases`) → `references/docs/adr/0004-cli-goal-runs-are-not-strict-goal-aliases.md` — Architecture decision for giving mux run --goal CLI-specific completion and limit semantics - - AGENTS.md (`/AGENTS`) → `references/docs/AGENTS.md` — Agent instructions for AI assistants working on the Mux codebase + - Debugging (`/reference/debugging`) → `references/docs/reference/debugging.mdx`: View live backend logs and diagnose issues + - Telemetry (`/reference/telemetry`) → `references/docs/reference/telemetry.mdx`: What Mux collects, what it doesn’t, and how to disable it + - Storybook (`/reference/storybook`) → `references/docs/reference/storybook.mdx`: Develop and test Mux UI states in isolation + - Terminal Benchmarking (`/reference/benchmarking`) → `references/docs/reference/benchmarking.mdx`: Run Terminal-Bench benchmarks with the Mux adapter + - Context Boundaries for Compaction and Reset (`/adr/0003-context-boundaries-for-compaction-and-reset`) → `references/docs/adr/0003-context-boundaries-for-compaction-and-reset.md`: Architecture decision for modeling provider context windows separately from transcript history + - CLI Goal Runs are not strict /goal aliases (`/adr/0004-cli-goal-runs-are-not-strict-goal-aliases`) → `references/docs/adr/0004-cli-goal-runs-are-not-strict-goal-aliases.md`: Architecture decision for giving mux run --goal CLI-specific completion and limit semantics + - AGENTS.md (`/AGENTS`) → `references/docs/AGENTS.md`: Agent instructions for AI assistants working on the Mux codebase 1. Read the docs navigation (source of truth for which pages exist): diff --git a/src/node/config.ts b/src/node/config.ts index 91a70c1561..79c426e4c2 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -41,6 +41,7 @@ import { RUNTIME_ENABLEMENT_IDS, type RuntimeEnablementId, } from "@/common/types/runtime"; +import { SCRATCH_PROJECT_NAME } from "@/common/constants/scratch"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; import { isIncompatibleRuntimeConfig } from "@/common/utils/runtimeCompatibility"; import { getMuxHome } from "@/common/constants/paths"; @@ -716,7 +717,7 @@ export class Config { return priority.length > 1 ? priority : undefined; } - loadConfigOrDefault(): ProjectsConfig { + loadConfigOrDefault(options?: { throwOnError?: boolean }): ProjectsConfig { try { if (fs.existsSync(this.configFile)) { const data = fs.readFileSync(this.configFile, "utf-8"); @@ -1130,6 +1131,9 @@ export class Config { } } catch (error) { log.error("Error loading config:", error); + if (options?.throwOnError) { + throw error; + } } // Return default config @@ -1768,15 +1772,22 @@ export class Config { const workspaceProjects = workspace.projects?.length ? workspace.projects : undefined; const primaryWorkspaceProject = workspaceProjects?.[0]; - const resolvedProjectPath = primaryWorkspaceProject?.projectPath ?? projectPath; - const resolvedProjectName = workspaceProjects - ? workspaceProjects.map((projectRef) => projectRef.projectName).join("+") - : projectName; + const resolvedProjectPath = + workspace.kind === "scratch" + ? workspace.path + : (primaryWorkspaceProject?.projectPath ?? projectPath); + const resolvedProjectName = + workspace.kind === "scratch" + ? SCRATCH_PROJECT_NAME + : workspaceProjects + ? workspaceProjects.map((projectRef) => projectRef.projectName).join("+") + : projectName; try { // NEW FORMAT: If workspace has metadata in config, use it directly if (workspace.id && workspace.name) { const metadata: WorkspaceMetadata = { + kind: workspace.kind, id: workspace.id, name: workspace.name, title: workspace.title, @@ -1897,6 +1908,7 @@ export class Config { metadata.runtimeConfig ??= DEFAULT_RUNTIME_CONFIG; // Preserve any config-only fields that may not exist in legacy metadata.json + metadata.kind ??= workspace.kind; metadata.aiSettingsByAgent ??= workspace.aiSettingsByAgent ?? (workspace.aiSettings @@ -1984,6 +1996,7 @@ export class Config { if (!metadataFound) { const legacyId = this.generateLegacyId(projectPath, workspace.path); const metadata: WorkspaceMetadata = { + kind: workspace.kind, // Prefer already-persisted identity fields: the queued ??= migration below // keeps existing config values, so returning a generated legacyId for a // partially-migrated entry that already has an id would expose an ID that @@ -2048,6 +2061,7 @@ export class Config { // Fallback to basic metadata if migration fails const legacyId = this.generateLegacyId(projectPath, workspace.path); const metadata: WorkspaceMetadata = { + kind: workspace.kind, // Same invariant as the branches above: never return an ID/name that // diverges from a value already persisted in config. id: workspace.id ?? legacyId, @@ -2149,6 +2163,7 @@ export class Config { metadata.namedWorkspacePath ?? path.join(this.srcDir, projectName, metadata.name); const workspaceEntry: Workspace = { path: workspacePath, + kind: metadata.kind, id: metadata.id, name: metadata.name, title: metadata.title, diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 266e099a6a..db984388cd 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -3977,6 +3977,16 @@ export const router = (authToken?: string) => { } return { success: true, metadata: result.data.metadata }; }), + createScratch: t + .input(schemas.workspace.createScratch.input) + .output(schemas.workspace.createScratch.output) + .handler(async ({ context, input }) => { + const result = await context.workspaceService.createScratch(input.title); + if (!result.success) { + return { success: false, error: result.error }; + } + return { success: true, metadata: result.data.metadata }; + }), createMultiProject: t .input(schemas.workspace.createMultiProject.input) .output(schemas.workspace.createMultiProject.output) diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 85452b2108..824421e244 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -4211,6 +4211,7 @@ export const BUILTIN_SKILL_FILES: Record> = { ' "group": "Workspaces",', ' "pages": [', ' "workspaces",', + ' "workspaces/scratch-chats",', ' "workspaces/fork",', ' "workspaces/muxignore",', " {", @@ -7378,6 +7379,39 @@ export const BUILTIN_SKILL_FILES: Record> = { "Place `.muxignore` in the project root, next to your `.gitignore`. You may want to commit it to your repo so all collaborators share the same sync rules.", "", ].join("\n"), + "references/docs/workspaces/scratch-chats.mdx": [ + "---", + "title: Scratch chats", + "description: Start a durable chat with an app-managed folder and no project or Git repository.", + "---", + "", + "Scratch chats let you start working without adding a project. Each chat gets a private folder managed by Mux, so the agent can create and edit files even though the chat is not attached to an existing repository.", + "", + "## Start a scratch chat", + "", + "Use any of these entry points:", + "", + "- Select **New scratch chat** from the command palette.", + "- Select the **+** button in the **Chats** section of the sidebar.", + "- Press `Cmd+Shift+N` on macOS or `Ctrl+Shift+N` on Windows and Linux.", + "", + "Mux creates the chat and its folder when you send the first message. Closing an empty draft does not create a folder.", + "", + "## Files and Git", + "", + "Scratch chat files are stored under `~/.mux/scratch/`. The folder persists across app restarts.", + "", + "Mux does not initialize a Git repository for a scratch chat. Branch controls, Git status, and code review tools are hidden. You can still use the terminal and other tools that only need a working directory.", + "", + "Sub-agent tasks run in the same scratch folder. They do not receive an isolated Git worktree.", + "", + "## Archive or delete", + "", + "Archiving a scratch chat keeps its conversation history and files. Deleting the chat removes its managed folder after no remaining sub-agent workspace references it.", + "", + "Conversation forks and full workspace-turn tasks are not supported for scratch chats in this version.", + "", + ].join("\n"), "SKILL.md": [ "---", "name: mux-docs", @@ -7428,67 +7462,68 @@ export const BUILTIN_SKILL_FILES: Record> = { "- **Documentation**", " - **Getting Started**", " - Introduction (`/`) → `references/docs/index.mdx`", - " - Install (`/install`) → `references/docs/install.mdx` — Download and install Mux for macOS, Linux, and Windows", + " - Install (`/install`) → `references/docs/install.mdx`: Download and install Mux for macOS, Linux, and Windows", " - **Models**", - " - Models (`/config/models`) → `references/docs/config/models.mdx` — Select and configure AI models in Mux", - " - Providers (`/config/providers`) → `references/docs/config/providers.mdx` — Configure API keys and settings for AI providers", - " - Why Parallelize? (`/getting-started/why-parallelize`) → `references/docs/getting-started/why-parallelize.mdx` — Use cases for running multiple AI agents in parallel", - " - Mux Gateway (`/getting-started/mux-gateway`) → `references/docs/getting-started/mux-gateway.mdx` — Log in to Mux Gateway to get evaluation credits", - " - CLI (`/reference/cli`) → `references/docs/reference/cli.mdx` — Run one-off agent tasks and durable workflows from the command line", + " - Models (`/config/models`) → `references/docs/config/models.mdx`: Select and configure AI models in Mux", + " - Providers (`/config/providers`) → `references/docs/config/providers.mdx`: Configure API keys and settings for AI providers", + " - Why Parallelize? (`/getting-started/why-parallelize`) → `references/docs/getting-started/why-parallelize.mdx`: Use cases for running multiple AI agents in parallel", + " - Mux Gateway (`/getting-started/mux-gateway`) → `references/docs/getting-started/mux-gateway.mdx`: Log in to Mux Gateway to get evaluation credits", + " - CLI (`/reference/cli`) → `references/docs/reference/cli.mdx`: Run one-off agent tasks and durable workflows from the command line", " - **Workspaces**", - " - Workspaces (`/workspaces`) → `references/docs/workspaces/index.mdx` — Isolated development environments for parallel agent work", - " - Forking Workspaces (`/workspaces/fork`) → `references/docs/workspaces/fork.mdx` — Clone workspaces with conversation history to explore alternatives", - " - .muxignore (`/workspaces/muxignore`) → `references/docs/workspaces/muxignore.mdx` — Sync gitignored files to worktree workspaces", + " - Workspaces (`/workspaces`) → `references/docs/workspaces/index.mdx`: Isolated development environments for parallel agent work", + " - Scratch chats (`/workspaces/scratch-chats`) → `references/docs/workspaces/scratch-chats.mdx`: Start a durable chat with an app-managed folder and no project or Git repository.", + " - Forking Workspaces (`/workspaces/fork`) → `references/docs/workspaces/fork.mdx`: Clone workspaces with conversation history to explore alternatives", + " - .muxignore (`/workspaces/muxignore`) → `references/docs/workspaces/muxignore.mdx`: Sync gitignored files to worktree workspaces", " - **Compaction**", - " - Compaction (`/workspaces/compaction`) → `references/docs/workspaces/compaction/index.mdx` — Managing conversation context size with compaction", - " - Manual Compaction (`/workspaces/compaction/manual`) → `references/docs/workspaces/compaction/manual.mdx` — Commands for manually managing conversation context", - " - Automatic Compaction (`/workspaces/compaction/automatic`) → `references/docs/workspaces/compaction/automatic.mdx` — Let Mux automatically compact your conversations based on usage or idle time", - " - Customization (`/workspaces/compaction/customization`) → `references/docs/workspaces/compaction/customization.mdx` — Customize the compaction system prompt", + " - Compaction (`/workspaces/compaction`) → `references/docs/workspaces/compaction/index.mdx`: Managing conversation context size with compaction", + " - Manual Compaction (`/workspaces/compaction/manual`) → `references/docs/workspaces/compaction/manual.mdx`: Commands for manually managing conversation context", + " - Automatic Compaction (`/workspaces/compaction/automatic`) → `references/docs/workspaces/compaction/automatic.mdx`: Let Mux automatically compact your conversations based on usage or idle time", + " - Customization (`/workspaces/compaction/customization`) → `references/docs/workspaces/compaction/customization.mdx`: Customize the compaction system prompt", " - **Runtimes**", - " - Runtimes (`/runtime`) → `references/docs/runtime/index.mdx` — Configure where and how Mux executes agent workspaces", - " - Local Runtime (`/runtime/local`) → `references/docs/runtime/local.mdx` — Run agents directly in your project directory", - " - Worktree Runtime (`/runtime/worktree`) → `references/docs/runtime/worktree.mdx` — Isolated git worktree environments for parallel agent work", - " - SSH Runtime (`/runtime/ssh`) → `references/docs/runtime/ssh.mdx` — Run agents on remote hosts over SSH for security and performance", - " - Coder Runtime (`/runtime/coder`) → `references/docs/runtime/coder.mdx` — Run agents on Coder workspaces", - " - Docker Runtime (`/runtime/docker`) → `references/docs/runtime/docker.mdx` — Run agents in isolated Docker containers", - " - Dev Container Runtime (`/runtime/devcontainer`) → `references/docs/runtime/devcontainer.mdx` — Run agents in containers defined by devcontainer.json", + " - Runtimes (`/runtime`) → `references/docs/runtime/index.mdx`: Configure where and how Mux executes agent workspaces", + " - Local Runtime (`/runtime/local`) → `references/docs/runtime/local.mdx`: Run agents directly in your project directory", + " - Worktree Runtime (`/runtime/worktree`) → `references/docs/runtime/worktree.mdx`: Isolated git worktree environments for parallel agent work", + " - SSH Runtime (`/runtime/ssh`) → `references/docs/runtime/ssh.mdx`: Run agents on remote hosts over SSH for security and performance", + " - Coder Runtime (`/runtime/coder`) → `references/docs/runtime/coder.mdx`: Run agents on Coder workspaces", + " - Docker Runtime (`/runtime/docker`) → `references/docs/runtime/docker.mdx`: Run agents in isolated Docker containers", + " - Dev Container Runtime (`/runtime/devcontainer`) → `references/docs/runtime/devcontainer.mdx`: Run agents in containers defined by devcontainer.json", " - **Hooks**", - " - Init Hooks (`/hooks/init`) → `references/docs/hooks/init.mdx` — Run setup commands automatically when creating new workspaces", - " - Tool Hooks (`/hooks/tools`) → `references/docs/hooks/tools.mdx` — Block dangerous commands, lint after edits, and set up your environment", - " - Environment Variables (`/hooks/environment-variables`) → `references/docs/hooks/environment-variables.mdx` — Environment variables available in agent bash commands and hooks", + " - Init Hooks (`/hooks/init`) → `references/docs/hooks/init.mdx`: Run setup commands automatically when creating new workspaces", + " - Tool Hooks (`/hooks/tools`) → `references/docs/hooks/tools.mdx`: Block dangerous commands, lint after edits, and set up your environment", + " - Environment Variables (`/hooks/environment-variables`) → `references/docs/hooks/environment-variables.mdx`: Environment variables available in agent bash commands and hooks", " - **Agents**", - " - Agents (`/agents`) → `references/docs/agents/index.mdx` — Define custom agents (modes + subagents) as Markdown files", - " - Instruction Files (`/agents/instruction-files`) → `references/docs/agents/instruction-files.mdx` — Configure agent behavior with AGENTS.md files", - " - Agent Skills (`/agents/agent-skills`) → `references/docs/agents/agent-skills.mdx` — Share reusable workflows and references with skills", - " - Plan Mode (`/agents/plan-mode`) → `references/docs/agents/plan-mode.mdx` — Review and collaborate on plans before execution", - " - System Prompt (`/agents/system-prompt`) → `references/docs/agents/system-prompt.mdx` — How Mux constructs the system prompt for AI models", - " - Prompting Tips (`/agents/prompting-tips`) → `references/docs/agents/prompting-tips.mdx` — Tips and tricks for getting the most out of your AI agents", - " - Best of N (`/agents/best-of-n`) → `references/docs/agents/best-of-n.mdx` — Improve plans, analysis, and reviews by asking Mux to explore multiple candidate answers in parallel", + " - Agents (`/agents`) → `references/docs/agents/index.mdx`: Define custom agents (modes + subagents) as Markdown files", + " - Instruction Files (`/agents/instruction-files`) → `references/docs/agents/instruction-files.mdx`: Configure agent behavior with AGENTS.md files", + " - Agent Skills (`/agents/agent-skills`) → `references/docs/agents/agent-skills.mdx`: Share reusable workflows and references with skills", + " - Plan Mode (`/agents/plan-mode`) → `references/docs/agents/plan-mode.mdx`: Review and collaborate on plans before execution", + " - System Prompt (`/agents/system-prompt`) → `references/docs/agents/system-prompt.mdx`: How Mux constructs the system prompt for AI models", + " - Prompting Tips (`/agents/prompting-tips`) → `references/docs/agents/prompting-tips.mdx`: Tips and tricks for getting the most out of your AI agents", + " - Best of N (`/agents/best-of-n`) → `references/docs/agents/best-of-n.mdx`: Improve plans, analysis, and reviews by asking Mux to explore multiple candidate answers in parallel", " - **Configuration**", - " - MCP Servers (`/config/mcp-servers`) → `references/docs/config/mcp-servers.mdx` — Extend agent capabilities with Model Context Protocol servers", - " - Policy File (`/config/policy-file`) → `references/docs/config/policy-file.mdx` — Admin-enforced restrictions for providers, models, MCP, and runtimes", - " - Project Secrets (`/config/project-secrets`) → `references/docs/config/project-secrets.mdx` — Manage environment variables and API keys for your projects", - " - Agentic Git Identity (`/config/agentic-git-identity`) → `references/docs/config/agentic-git-identity.mdx` — Configure a separate Git identity for AI-generated commits", - " - Keyboard Shortcuts (`/config/keybinds`) → `references/docs/config/keybinds.mdx` — Complete keyboard shortcut reference for Mux", - " - Notifications (`/config/notifications`) → `references/docs/config/notifications.mdx` — Configure how agents notify you about important events", - " - Server Access (`/config/server-access`) → `references/docs/config/server-access.mdx` — Configure authentication and session controls for mux server/browser mode", - " - Vim Mode (`/config/vim-mode`) → `references/docs/config/vim-mode.mdx` — Vim-style editing in the Mux chat input", + " - MCP Servers (`/config/mcp-servers`) → `references/docs/config/mcp-servers.mdx`: Extend agent capabilities with Model Context Protocol servers", + " - Policy File (`/config/policy-file`) → `references/docs/config/policy-file.mdx`: Admin-enforced restrictions for providers, models, MCP, and runtimes", + " - Project Secrets (`/config/project-secrets`) → `references/docs/config/project-secrets.mdx`: Manage environment variables and API keys for your projects", + " - Agentic Git Identity (`/config/agentic-git-identity`) → `references/docs/config/agentic-git-identity.mdx`: Configure a separate Git identity for AI-generated commits", + " - Keyboard Shortcuts (`/config/keybinds`) → `references/docs/config/keybinds.mdx`: Complete keyboard shortcut reference for Mux", + " - Notifications (`/config/notifications`) → `references/docs/config/notifications.mdx`: Configure how agents notify you about important events", + " - Server Access (`/config/server-access`) → `references/docs/config/server-access.mdx`: Configure authentication and session controls for mux server/browser mode", + " - Vim Mode (`/config/vim-mode`) → `references/docs/config/vim-mode.mdx`: Vim-style editing in the Mux chat input", " - **Guides**", - " - GitHub Actions (`/guides/github-actions`) → `references/docs/guides/github-actions.mdx` — Automate your workflows with mux run in GitHub Actions", - " - Symbol Shortcuts (`/guides/symbol-shortcuts`) → `references/docs/guides/symbol-shortcuts.mdx` — Insert math and trading symbols in the Mux chat input with LaTeX-style backslash commands", - " - Agentic Git Identity (`/config/agentic-git-identity`) → `references/docs/config/agentic-git-identity.mdx` — Configure a separate Git identity for AI-generated commits", - " - Prompting Tips (`/agents/prompting-tips`) → `references/docs/agents/prompting-tips.mdx` — Tips and tricks for getting the most out of your AI agents", + " - GitHub Actions (`/guides/github-actions`) → `references/docs/guides/github-actions.mdx`: Automate your workflows with mux run in GitHub Actions", + " - Symbol Shortcuts (`/guides/symbol-shortcuts`) → `references/docs/guides/symbol-shortcuts.mdx`: Insert math and trading symbols in the Mux chat input with LaTeX-style backslash commands", + " - Agentic Git Identity (`/config/agentic-git-identity`) → `references/docs/config/agentic-git-identity.mdx`: Configure a separate Git identity for AI-generated commits", + " - Prompting Tips (`/agents/prompting-tips`) → `references/docs/agents/prompting-tips.mdx`: Tips and tricks for getting the most out of your AI agents", " - **Integrations**", - " - VS Code Extension (`/integrations/vscode-extension`) → `references/docs/integrations/vscode-extension.mdx` — Pair Mux workspaces with VS Code and Cursor editors", - " - ACP (Editor Integrations) (`/integrations/acp`) → `references/docs/integrations/acp.mdx` — Connect Mux to Zed, Neovim, and JetBrains via the Agent Client Protocol", + " - VS Code Extension (`/integrations/vscode-extension`) → `references/docs/integrations/vscode-extension.mdx`: Pair Mux workspaces with VS Code and Cursor editors", + " - ACP (Editor Integrations) (`/integrations/acp`) → `references/docs/integrations/acp.mdx`: Connect Mux to Zed, Neovim, and JetBrains via the Agent Client Protocol", " - **Reference**", - " - Debugging (`/reference/debugging`) → `references/docs/reference/debugging.mdx` — View live backend logs and diagnose issues", - " - Telemetry (`/reference/telemetry`) → `references/docs/reference/telemetry.mdx` — What Mux collects, what it doesn’t, and how to disable it", - " - Storybook (`/reference/storybook`) → `references/docs/reference/storybook.mdx` — Develop and test Mux UI states in isolation", - " - Terminal Benchmarking (`/reference/benchmarking`) → `references/docs/reference/benchmarking.mdx` — Run Terminal-Bench benchmarks with the Mux adapter", - " - Context Boundaries for Compaction and Reset (`/adr/0003-context-boundaries-for-compaction-and-reset`) → `references/docs/adr/0003-context-boundaries-for-compaction-and-reset.md` — Architecture decision for modeling provider context windows separately from transcript history", - " - CLI Goal Runs are not strict /goal aliases (`/adr/0004-cli-goal-runs-are-not-strict-goal-aliases`) → `references/docs/adr/0004-cli-goal-runs-are-not-strict-goal-aliases.md` — Architecture decision for giving mux run --goal CLI-specific completion and limit semantics", - " - AGENTS.md (`/AGENTS`) → `references/docs/AGENTS.md` — Agent instructions for AI assistants working on the Mux codebase", + " - Debugging (`/reference/debugging`) → `references/docs/reference/debugging.mdx`: View live backend logs and diagnose issues", + " - Telemetry (`/reference/telemetry`) → `references/docs/reference/telemetry.mdx`: What Mux collects, what it doesn’t, and how to disable it", + " - Storybook (`/reference/storybook`) → `references/docs/reference/storybook.mdx`: Develop and test Mux UI states in isolation", + " - Terminal Benchmarking (`/reference/benchmarking`) → `references/docs/reference/benchmarking.mdx`: Run Terminal-Bench benchmarks with the Mux adapter", + " - Context Boundaries for Compaction and Reset (`/adr/0003-context-boundaries-for-compaction-and-reset`) → `references/docs/adr/0003-context-boundaries-for-compaction-and-reset.md`: Architecture decision for modeling provider context windows separately from transcript history", + " - CLI Goal Runs are not strict /goal aliases (`/adr/0004-cli-goal-runs-are-not-strict-goal-aliases`) → `references/docs/adr/0004-cli-goal-runs-are-not-strict-goal-aliases.md`: Architecture decision for giving mux run --goal CLI-specific completion and limit semantics", + " - AGENTS.md (`/AGENTS`) → `references/docs/AGENTS.md`: Agent instructions for AI assistants working on the Mux codebase", "", "", "1. Read the docs navigation (source of truth for which pages exist):", diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 2d915016db..eb2f6b7562 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -1808,7 +1808,8 @@ export class AIService extends EventEmitter { thinkingLevel: thinkingLevel ?? "off", costsUsd: sessionCostsUsd, }); - const getWorkflowProjectTrusted = () => isProjectTrusted(this.config, metadata.projectPath); + const getWorkflowProjectTrusted = () => + metadata.kind === "scratch" || isProjectTrusted(this.config, metadata.projectPath); const workflowService = dynamicWorkflowsExperimentEnabled && this.taskService != null diff --git a/src/node/services/gitPatchArtifactService.ts b/src/node/services/gitPatchArtifactService.ts index 3d50e4db4f..e1d8624882 100644 --- a/src/node/services/gitPatchArtifactService.ts +++ b/src/node/services/gitPatchArtifactService.ts @@ -335,6 +335,10 @@ export class GitPatchArtifactService { const cfg = this.config.loadConfigOrDefault(); const childEntry = findWorkspaceEntry(cfg, childWorkspaceId); + if (childEntry?.workspace.kind === "scratch") { + return; + } + // Only exec-like subagents are expected to make commits that should be handed back to the parent. // NOTE: Custom agents can inherit from exec (base: exec). Those should also generate patches, // but read-only subagents (e.g. explore) should not. diff --git a/src/node/services/systemMessage.ts b/src/node/services/systemMessage.ts index 2f58670d7b..7eda6cf0fa 100644 --- a/src/node/services/systemMessage.ts +++ b/src/node/services/systemMessage.ts @@ -532,6 +532,11 @@ export async function buildSystemMessage( metadata.bestOf )}`; + if (metadata.kind === "scratch") { + systemMessage += + "\n\n\nThis is a project-less scratch chat. The workspace directory is app-managed and is not a Git repository unless the user initializes one.\n"; + } + // Add MCP context if servers are configured if (mcpServers && Object.keys(mcpServers).length > 0) { systemMessage += buildMCPContext(mcpServers); diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 3675651530..810a2f2bb4 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -43,6 +43,7 @@ import { createRuntime } from "@/node/runtime/runtimeFactory"; import * as runtimeFactory from "@/node/runtime/runtimeFactory"; import * as forkOrchestrator from "@/node/services/utils/forkOrchestrator"; import { Ok, Err, type Result } from "@/common/types/result"; +import { SCRATCH_PROJECT_CONFIG_KEY } from "@/common/constants/scratch"; import { STRUCTURED_WORKFLOW_REPORT_PLACEHOLDER_MARKDOWN } from "@/common/constants/workflowReports"; import { defaultModel } from "@/common/utils/ai/models"; import { enforceThinkingPolicy } from "@/common/utils/thinking/policy"; @@ -568,6 +569,64 @@ describe("TaskService", () => { await fsPromises.rm(rootDir, { recursive: true, force: true }); }); + test("scratch tasks share the managed workdir and stay in the scratch config bucket", async () => { + const config = await createTestConfig(rootDir); + const parentId = "1111111111"; + const childId = "2222222222"; + const scratchPath = path.join(config.rootDir, "scratch", parentId); + await fsPromises.mkdir(scratchPath, { recursive: true }); + await saveTestConfig( + config, + [ + [ + SCRATCH_PROJECT_CONFIG_KEY, + { + projectKind: "system", + trusted: true, + workspaces: [ + { + kind: "scratch", + path: scratchPath, + id: parentId, + name: `scratch-${parentId}`, + createdAt: new Date().toISOString(), + runtimeConfig: { type: "local" }, + aiSettings: { + model: "anthropic:claude-opus-4-6", + thinkingLevel: "high", + }, + }, + ], + }, + ], + ], + { taskSettings: testTaskSettings() } + ); + stubStableIds(config, [childId]); + + const workspaceMocks = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { + workspaceService: workspaceMocks.workspaceService, + }); + + const result = await createAgentTask(taskService, parentId, "Inspect the scratch files"); + + expect(result).toEqual(Ok({ taskId: childId, kind: "agent", status: "running" })); + const scratchProject = config.loadConfigOrDefault().projects.get(SCRATCH_PROJECT_CONFIG_KEY); + const child = scratchProject?.workspaces.find((workspace) => workspace.id === childId); + expect(child?.kind).toBe("scratch"); + expect(child?.path).toBe(scratchPath); + expect(child?.taskIsolation).toBe("none"); + expect(child?.parentWorkspaceId).toBe(parentId); + expect(config.loadConfigOrDefault().projects.has(scratchPath)).toBe(false); + expect(workspaceMocks.sendMessage).toHaveBeenCalledWith( + childId, + "Inspect the scratch files", + expect.any(Object), + { agentInitiated: true } + ); + }); + async function startWorkspaceTurnForTest( options: { stableIds?: string[]; diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index cbd904ceb4..0ce8e67097 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -63,6 +63,7 @@ import { } from "@/node/services/utils/messageIds"; import { defaultModel, normalizeToCanonical } from "@/common/utils/ai/models"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; +import { SCRATCH_PROJECT_CONFIG_KEY, SCRATCH_PROJECT_NAME } from "@/common/constants/scratch"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; import { runtimeModeSupportsSharedTaskWorkspace, type RuntimeConfig } from "@/common/types/runtime"; import type { ProjectRef, WorkspaceMetadata } from "@/common/types/workspace"; @@ -573,6 +574,8 @@ interface TaskLaunchPlan { createdAt: string; taskRuntimeConfig: RuntimeConfig; parentRuntimeConfig: RuntimeConfig; + configProjectPath: string; + workspaceKind?: "scratch"; taskModelString: string; canonicalModel: string; effectiveThinkingLevel?: ThinkingLevel; @@ -2313,15 +2316,18 @@ export class TaskService { return Err(`Task.createMany: parent workspace not found (${parentMetaResult.error})`); } const parentMeta = parentMetaResult.data; - - const taskProjectConfig = cfg.projects.get(stripTrailingSlashes(parentMeta.projectPath)); + const parentEntry = findWorkspaceEntry(cfg, parentWorkspaceId); + const parentIsScratch = parentEntry?.workspace.kind === "scratch"; + const configProjectPath = parentIsScratch + ? SCRATCH_PROJECT_CONFIG_KEY + : stripTrailingSlashes(parentMeta.projectPath); + const taskProjectConfig = cfg.projects.get(configProjectPath); if (!taskProjectConfig?.trusted) { return Err( "This project must be trusted before creating workspaces. Trust the project in Settings → Security, or create a workspace from the project page." ); } - const parentEntry = findWorkspaceEntry(cfg, parentWorkspaceId); if (parentEntry?.workspace.taskStatus === "reported") { return Err("Task.createMany: cannot spawn new tasks after agent_report"); } @@ -2376,9 +2382,10 @@ export class TaskService { const taskRuntimeMode = getRuntimeType(taskRuntimeConfig); const parentIsMultiProject = (parentMeta.projects?.length ?? 0) > 1; const useSharedWorkspace = - args.isolation === "none" && - runtimeModeSupportsSharedTaskWorkspace(taskRuntimeMode) && - !parentIsMultiProject; + parentIsScratch || + (args.isolation === "none" && + runtimeModeSupportsSharedTaskWorkspace(taskRuntimeMode) && + !parentIsMultiProject); const sharedWorkspacePath = useSharedWorkspace ? parentWorkspacePath : undefined; // Branch actually checked out in the parent's checkout (see create() for rationale). const parentIsSharedTask = parentEntry?.workspace.taskIsolation === "none"; @@ -2471,6 +2478,8 @@ export class TaskService { createdAt, taskRuntimeConfig, parentRuntimeConfig, + configProjectPath, + workspaceKind: parentIsScratch ? "scratch" : undefined, taskModelString, canonicalModel, effectiveThinkingLevel, @@ -2517,12 +2526,13 @@ export class TaskService { if (!trunkBranch) { throw new Error("Task.createMany: parent workspace name missing"); } - let projectConfig = config.projects.get(plan.parentMeta.projectPath); + let projectConfig = config.projects.get(plan.configProjectPath); if (!projectConfig) { projectConfig = { workspaces: [] }; - config.projects.set(plan.parentMeta.projectPath, projectConfig); + config.projects.set(plan.configProjectPath, projectConfig); } projectConfig.workspaces.push({ + kind: plan.workspaceKind, path: workspacePath, id: plan.taskId, name: plan.workspaceName, @@ -2722,9 +2732,7 @@ export class TaskService { ? { preferredTrunkBranch: plan.preferredTrunkBranch } : {}), trusted: - this.config - .loadConfigOrDefault() - .projects.get(stripTrailingSlashes(plan.parentMeta.projectPath))?.trusted ?? false, + this.config.loadConfigOrDefault().projects.get(plan.configProjectPath)?.trusted ?? false, multiProjectExperimentEnabled: this.workspaceService.isExperimentEnabled( EXPERIMENT_IDS.MULTI_PROJECT_WORKSPACES ), @@ -2935,9 +2943,8 @@ export class TaskService { env: secrets, skipInitHook: plan.skipInitHook, trusted: - this.config - .loadConfigOrDefault() - .projects.get(stripTrailingSlashes(plan.parentMeta.projectPath))?.trusted ?? false, + this.config.loadConfigOrDefault().projects.get(plan.configProjectPath)?.trusted ?? + false, }, plan.taskId ); @@ -3011,6 +3018,10 @@ export class TaskService { const parentMeta = parentMetaResult.data; const cfg = this.config.loadConfigOrDefault(); const taskSettings = cfg.taskSettings ?? DEFAULT_TASK_SETTINGS; + const parentEntry = findWorkspaceEntry(cfg, ownerWorkspaceId); + if (parentEntry?.workspace.kind === "scratch") { + return Err("Task.createWorkspaceTurn: scratch workspace turns are not supported yet"); + } const taskProjectConfig = cfg.projects.get(stripTrailingSlashes(parentMeta.projectPath)); if ((parentMeta.projects?.length ?? 0) > 1) { // WorkspaceService.create only materializes one project checkout; fail loudly instead of @@ -3346,18 +3357,22 @@ export class TaskService { // Enforce nesting depth. const cfg = this.config.loadConfigOrDefault(); const taskSettings = cfg.taskSettings ?? DEFAULT_TASK_SETTINGS; + const parentEntry = findWorkspaceEntry(cfg, parentWorkspaceId); + const parentIsScratch = parentEntry?.workspace.kind === "scratch"; + const configProjectPath = parentIsScratch + ? SCRATCH_PROJECT_CONFIG_KEY + : stripTrailingSlashes(parentMeta.projectPath); // Trust gate: block task creation for untrusted projects. // The frontend shows a confirmation dialog for primary workspace creation, // but task spawning bypasses the UI — enforce trust here as defense-in-depth. - const taskProjectConfig = cfg.projects.get(stripTrailingSlashes(parentMeta.projectPath)); + const taskProjectConfig = cfg.projects.get(configProjectPath); if (!taskProjectConfig?.trusted) { return Err( "This project must be trusted before creating workspaces. Trust the project in Settings → Security, or create a workspace from the project page." ); } - const parentEntry = findWorkspaceEntry(cfg, parentWorkspaceId); if (parentEntry?.workspace.taskStatus === "reported") { return Err("Task.create: cannot spawn new tasks after agent_report"); } @@ -3423,9 +3438,10 @@ export class TaskService { const taskRuntimeMode = getRuntimeType(taskRuntimeConfig); const parentIsMultiProject = (parentMeta.projects?.length ?? 0) > 1; const useSharedWorkspace = - args.isolation === "none" && - runtimeModeSupportsSharedTaskWorkspace(taskRuntimeMode) && - !parentIsMultiProject; + parentIsScratch || + (args.isolation === "none" && + runtimeModeSupportsSharedTaskWorkspace(taskRuntimeMode) && + !parentIsMultiProject); // The branch actually checked out in the parent's checkout. When the parent is itself an // isolation: "none" task, parentMeta.name is a synthetic agent workspace name with no real // branch — the shared checkout sits on the parent's own persisted taskTrunkBranch. Persisting @@ -3558,13 +3574,14 @@ export class TaskService { }); await this.config.editConfig((config) => { - let projectConfig = config.projects.get(parentMeta.projectPath); + let projectConfig = config.projects.get(configProjectPath); if (!projectConfig) { projectConfig = { workspaces: [] }; - config.projects.set(parentMeta.projectPath, projectConfig); + config.projects.set(configProjectPath, projectConfig); } projectConfig.workspaces.push({ + kind: parentIsScratch ? "scratch" : undefined, path: workspacePath, id: taskId, name: workspaceName, @@ -3668,9 +3685,7 @@ export class TaskService { ? { preferredTrunkBranch: parentBranchName } : {}), trusted: - this.config - .loadConfigOrDefault() - .projects.get(stripTrailingSlashes(parentMeta.projectPath))?.trusted ?? false, + this.config.loadConfigOrDefault().projects.get(configProjectPath)?.trusted ?? false, multiProjectExperimentEnabled: this.workspaceService.isExperimentEnabled( EXPERIMENT_IDS.MULTI_PROJECT_WORKSPACES ), @@ -3722,13 +3737,14 @@ export class TaskService { // Persist workspace entry before starting work so it's durable across crashes. await this.config.editConfig((config) => { - let projectConfig = config.projects.get(parentMeta.projectPath); + let projectConfig = config.projects.get(configProjectPath); if (!projectConfig) { projectConfig = { workspaces: [] }; - config.projects.set(parentMeta.projectPath, projectConfig); + config.projects.set(configProjectPath, projectConfig); } projectConfig.workspaces.push({ + kind: parentIsScratch ? "scratch" : undefined, path: workspacePath, id: taskId, name: workspaceName, @@ -3782,9 +3798,7 @@ export class TaskService { env: secrets, skipInitHook, trusted: - this.config - .loadConfigOrDefault() - .projects.get(stripTrailingSlashes(parentMeta.projectPath))?.trusted ?? false, + this.config.loadConfigOrDefault().projects.get(configProjectPath)?.trusted ?? false, }, taskId ); @@ -7437,21 +7451,28 @@ export class TaskService { continue; } + const parentRuntimeProjectPath = + parentEntry.workspace.kind === "scratch" + ? parentEntry.workspace.path + : parentEntry.projectPath; const parentMetaResult = await this.aiService.getWorkspaceMetadata(parentWorkspaceId); const parentMeta = parentMetaResult.success ? parentMetaResult.data : ({ id: parentWorkspaceId, name: parentWorkspaceName, - projectPath: parentEntry.projectPath, + kind: parentEntry.workspace.kind, + projectPath: parentRuntimeProjectPath, projectName: - parentEntry.workspace.projects?.find( - (project) => - stripTrailingSlashes(project.projectPath) === - stripTrailingSlashes(parentEntry.projectPath) - )?.projectName ?? - parentEntry.projectPath.split("/").filter(Boolean).at(-1) ?? - parentEntry.projectPath, + parentEntry.workspace.kind === "scratch" + ? SCRATCH_PROJECT_NAME + : (parentEntry.workspace.projects?.find( + (project) => + stripTrailingSlashes(project.projectPath) === + stripTrailingSlashes(parentEntry.projectPath) + )?.projectName ?? + parentEntry.projectPath.split("/").filter(Boolean).at(-1) ?? + parentEntry.projectPath), runtimeConfig: parentRuntimeConfig, projects: parentEntry.workspace.projects, } satisfies WorkspaceMetadata); @@ -7462,12 +7483,12 @@ export class TaskService { try { const parentRuntime = createRuntimeForWorkspace({ runtimeConfig: parentRuntimeConfig, - projectPath: parentEntry.projectPath, + projectPath: parentRuntimeProjectPath, name: parentWorkspaceName, }); const parentWorkspacePath = coerceNonEmptyString(parentEntry.workspace.path) ?? - parentRuntime.getWorkspacePath(parentEntry.projectPath, parentWorkspaceName); + parentRuntime.getWorkspacePath(parentRuntimeProjectPath, parentWorkspaceName); const frontmatter = await resolveAgentFrontmatter( parentRuntime, parentWorkspacePath, @@ -7509,6 +7530,8 @@ export class TaskService { createdAt, taskRuntimeConfig, parentRuntimeConfig, + configProjectPath: normalizedTaskProjectPath, + workspaceKind: task.kind, taskModelString: task.taskModelString ?? defaultModel, canonicalModel, effectiveThinkingLevel: task.taskThinkingLevel, diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 2126b580c9..8af52e34c4 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -9,6 +9,7 @@ import * as fsPromises from "fs/promises"; import { tmpdir } from "os"; import path from "path"; import { Err, Ok, type Result } from "@/common/types/result"; +import { SCRATCH_PROJECT_CONFIG_KEY } from "@/common/constants/scratch"; import { DEFAULT_TASK_SETTINGS } from "@/common/types/tasks"; import type { SendMessageError } from "@/common/types/errors"; import type { ProjectsConfig } from "@/common/types/project"; @@ -119,6 +120,7 @@ function createDeferred() { const mockInitStateManager: Partial = { on: mock(() => undefined as unknown as InitStateManager), + off: mock(() => undefined as unknown as InitStateManager), getInitState: mock(() => undefined), waitForInit: mock(() => Promise.resolve()), clearInMemoryState: mock(() => undefined), @@ -3780,6 +3782,31 @@ describe("WorkspaceService initialize", () => { expect(startStartupRecoverySpy).not.toHaveBeenCalled(); }); + test("preserves scratch workdirs when config cannot be loaded", async () => { + const { config: realConfig, historyService, cleanup } = await createTestHistoryService(); + const scratchPath = path.join(realConfig.rootDir, "scratch", "existing-scratch"); + await fsPromises.mkdir(scratchPath, { recursive: true }); + await fsPromises.writeFile(path.join(realConfig.rootDir, "config.json"), "{invalid-json"); + + const aiService = { + on: mock(() => undefined), + off: mock(() => undefined), + } as unknown as AIService; + const service = createWorkspaceServiceForTest({ + config: realConfig, + historyService, + aiService, + initStateManager: mockInitStateManager as InitStateManager, + }); + + try { + await service.initialize(); + expect(await fsPromises.stat(scratchPath).then(() => true)).toBe(true); + } finally { + await cleanup(); + } + }); + test("disposes transient startup-recovery sessions that go idle", async () => { const dispose = mock(() => undefined); const fakeSession = { @@ -6464,6 +6491,24 @@ describe("WorkspaceService getProjectGitStatuses", () => { return { workspaceService, executeBashMock, getWorkspaceMetadataMock }; } + test("returns no entries for scratch workspaces without invoking git", async () => { + const metadata: WorkspaceMetadata = { + kind: "scratch", + id: "ws-scratch", + name: "scratch-ws-scratch", + projectName: "Scratch", + projectPath: "/tmp/mux/scratch/ws-scratch", + runtimeConfig: { type: "local" }, + }; + const { workspaceService, executeBashMock } = createServiceHarness({ + metadata, + executeBashImpl: () => Promise.reject(new Error("git should not run")), + }); + + expect(await workspaceService.getProjectGitStatuses(metadata.id)).toEqual([]); + expect(executeBashMock).not.toHaveBeenCalled(); + }); + test("returns a single entry for single-project workspaces", async () => { const metadata: WorkspaceMetadata = { id: "ws-single", @@ -10044,6 +10089,73 @@ describe("WorkspaceService init cancellation", () => { await cleanupHistory(); }); + test("scratch workspace deletion preserves shared workdirs until the last reference", async () => { + const { + config, + historyService: scratchHistoryService, + cleanup, + } = await createTestHistoryService(); + const parentId = "1111111111"; + const childId = "2222222222"; + const configWithStableId = config as unknown as { generateStableId: () => string }; + configWithStableId.generateStableId = () => parentId; + + const aiService = { + isStreaming: mock(() => false), + stopStream: mock(() => Promise.resolve(Ok(undefined))), + getWorkspaceMetadata: mock(async (workspaceId: string) => { + const metadata = (await config.getAllWorkspaceMetadata()).find( + (workspace) => workspace.id === workspaceId + ); + return metadata ? Ok(metadata) : Err("not found"); + }), + on: mock(() => undefined), + off: mock(() => undefined), + } as unknown as AIService; + + try { + const workspaceService = createWorkspaceServiceForTest({ + config, + historyService: scratchHistoryService, + aiService, + }); + const created = await workspaceService.createScratch("Scratch test"); + expect(created.success).toBe(true); + if (!created.success) return; + + const scratchPath = created.data.metadata.namedWorkspacePath; + await config.editConfig((current) => { + const scratchProject = current.projects.get(SCRATCH_PROJECT_CONFIG_KEY); + if (!scratchProject) throw new Error("Scratch project missing"); + scratchProject.workspaces.push({ + kind: "scratch", + path: scratchPath, + id: childId, + name: `agent-explore-${childId}`, + parentWorkspaceId: parentId, + taskIsolation: "none", + taskStatus: "reported", + createdAt: new Date().toISOString(), + runtimeConfig: { type: "local" }, + }); + return current; + }); + + expect(await fsPromises.stat(scratchPath).then(() => true)).toBe(true); + expect(await workspaceService.remove(parentId, true)).toEqual(Ok(undefined)); + expect(await fsPromises.stat(scratchPath).then(() => true)).toBe(true); + expect(await workspaceService.remove(childId, true)).toEqual(Ok(undefined)); + expect( + await fsPromises + .stat(scratchPath) + .then(() => true) + .catch(() => false) + ).toBe(false); + } finally { + await cleanup(); + } + }); + test("create() rejects untrusted projects", async () => { const projectPath = "/tmp/proj"; const generateStableIdMock = mock(() => "ws-untrusted"); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index a7647ea41b..e5b36fd85f 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -10,6 +10,7 @@ import { isWorkspacePinned, reassignPinnedTimestamps, } from "@/common/utils/pin"; +import { SCRATCH_PROJECT_CONFIG_KEY } from "@/common/constants/scratch"; import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject"; import type { CompactionCompletionMetadata } from "@/common/types/compaction"; import type { Config } from "@/node/config"; @@ -2338,6 +2339,9 @@ export class WorkspaceService extends EventEmitter { const startupStartedAt = Date.now(); try { + await this.cleanupOrphanScratchWorkdirs().catch((error: unknown) => { + log.debug("Failed to clean orphaned scratch workdirs", { error }); + }); const allMetadata = await this.config.getAllWorkspaceMetadata(); let scheduledCount = 0; let skippedTaskCount = 0; @@ -3328,6 +3332,97 @@ export class WorkspaceService extends EventEmitter { } } + private getScratchRoot(): string { + return path.join(this.config.rootDir, "scratch"); + } + + private getScratchWorkdir(workspaceId: string): string { + return path.join(this.getScratchRoot(), workspaceId); + } + + private isManagedScratchWorkdir(workspacePath: string): boolean { + const scratchRoot = path.resolve(this.getScratchRoot()); + const resolvedPath = path.resolve(workspacePath); + return path.dirname(resolvedPath) === scratchRoot && isPathInsideDir(scratchRoot, resolvedPath); + } + + private async cleanupOrphanScratchWorkdirs(): Promise { + const scratchRoot = this.getScratchRoot(); + await ensurePrivateDir(scratchRoot); + + // Never interpret a config read failure as an empty reference set, because that would + // turn best-effort orphan cleanup into deletion of valid scratch chats. + const config = this.config.loadConfigOrDefault({ throwOnError: true }); + const referencedScratchPaths = new Set( + (config.projects.get(SCRATCH_PROJECT_CONFIG_KEY)?.workspaces ?? []) + .filter((workspace) => workspace.kind === "scratch") + .map((workspace) => path.resolve(workspace.path)) + ); + + for (const entry of await fsPromises.readdir(scratchRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + + const candidatePath = path.resolve(scratchRoot, entry.name); + if (referencedScratchPaths.has(candidatePath)) continue; + + try { + await fsPromises.rm(candidatePath, { recursive: true, force: true }); + } catch (error: unknown) { + log.debug("Failed to clean orphaned scratch workdir", { candidatePath, error }); + } + } + } + + async createScratch(title?: string): Promise> { + const workspaceId = this.config.generateStableId(); + const workspaceName = `scratch-${workspaceId}`; + const workspacePath = this.getScratchWorkdir(workspaceId); + const createdAt = new Date().toISOString(); + + try { + await ensurePrivateDir(this.getScratchRoot()); + await ensurePrivateDir(workspacePath); + + await this.config.editConfig((config) => { + const scratchProject = config.projects.get(SCRATCH_PROJECT_CONFIG_KEY) ?? { + workspaces: [], + projectKind: "system" as const, + trusted: true, + }; + scratchProject.projectKind = "system"; + scratchProject.trusted = true; + scratchProject.workspaces.push({ + kind: "scratch", + path: workspacePath, + id: workspaceId, + name: workspaceName, + title, + createdAt, + runtimeConfig: { type: "local" }, + }); + config.projects.set(SCRATCH_PROJECT_CONFIG_KEY, scratchProject); + return config; + }); + + const completeMetadata = (await this.config.getAllWorkspaceMetadata()).find( + (metadata) => metadata.id === workspaceId + ); + if (!completeMetadata) { + await this.config.removeWorkspace(workspaceId); + await fsPromises.rm(workspacePath, { recursive: true, force: true }); + return Err("Failed to retrieve scratch workspace metadata"); + } + + const enrichedMetadata = this.enrichFrontendMetadata(completeMetadata); + this.getOrCreateSession(workspaceId).emitMetadata(enrichedMetadata); + return Ok({ metadata: enrichedMetadata }); + } catch (error) { + await this.config.removeWorkspace(workspaceId).catch(() => undefined); + await fsPromises.rm(workspacePath, { recursive: true, force: true }).catch(() => undefined); + return Err(`Failed to create scratch workspace: ${getErrorMessage(error)}`); + } + } + async create( projectPath: string, branchName: string | undefined, @@ -4331,6 +4426,29 @@ export class WorkspaceService extends EventEmitter { `Failed to fully delete multi-project workspace from disk, but force=true. Removing from config. Errors: ${deleteErrors.join("; ")}` ); } + } else if (metadata.kind === "scratch") { + if ( + persistedWorkspacePath == null || + !this.isManagedScratchWorkdir(persistedWorkspacePath) + ) { + return Err( + "Refusing to delete scratch workspace outside the managed scratch directory" + ); + } + + const resolvedScratchPath = path.resolve(persistedWorkspacePath); + const hasOtherScratchReference = Array.from(configSnapshot.projects.values()).some( + (project) => + project.workspaces.some( + (workspace) => + workspace.id !== workspaceId && + workspace.kind === "scratch" && + path.resolve(workspace.path) === resolvedScratchPath + ) + ); + if (!hasOtherScratchReference) { + await fsPromises.rm(persistedWorkspacePath, { recursive: true, force: true }); + } } else if (taskSharesParentCheckout) { // Shared checkout (isolation: "none"): do not touch the filesystem — the directory // belongs to the parent workspace. Config/session cleanup below still runs. @@ -6564,6 +6682,10 @@ export class WorkspaceService extends EventEmitter { const metadata = metadataResult.data; assert(metadata, `Workspace ${workspaceId} metadata is required for git status checks`); + if (metadata.kind === "scratch") { + return []; + } + const projects = getProjects(metadata); assert(projects.length > 0, `Workspace ${workspaceId} must include at least one project`); @@ -7137,6 +7259,9 @@ export class WorkspaceService extends EventEmitter { return Err(`Failed to get source workspace metadata: ${sourceMetadataResult.error}`); } const sourceMetadata = sourceMetadataResult.data; + if (sourceMetadata.kind === "scratch") { + return Err("Forking scratch chats is not supported yet"); + } const partialSnapshot = sourceMessageId == null ? await this.historyService.readPartial(sourceWorkspaceId) : null; const foundProjectPath = sourceMetadata.projectPath; From adbb4ed7506c89c0ecf69d03fd2943b213f170c9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:22:25 +0000 Subject: [PATCH 02/12] fix: address Codex review round 1 (scratch devcontainer preflight, scratch pinned reorder block) --- .../ChatInput/useCreationWorkspace.test.tsx | 38 +++++++++++++++++++ .../ChatInput/useCreationWorkspace.ts | 6 ++- src/browser/utils/ui/pinnedReorder.test.ts | 22 +++++++++++ src/browser/utils/ui/pinnedReorder.ts | 32 ++++++++++++---- 4 files changed, 89 insertions(+), 9 deletions(-) diff --git a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx index 1c90324e57..b34e62dfd5 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx @@ -741,6 +741,44 @@ describe("useCreationWorkspace", () => { expect(typeof onWorkspaceCreated.mock.calls[0]?.[1]?.pendingStreamModel).toBe("string"); }); + test("scratch creation skips the devcontainer preflight for a devcontainer default runtime", async () => { + // Scratch never loads runtime availability, so the devcontainer preflight + // would otherwise block forever in the "loading" availability state. + draftSettingsState = createDraftSettingsHarness({ + selectedRuntime: { mode: "devcontainer", configPath: "" }, + }); + const scratchMetadata: FrontendWorkspaceMetadata = { + ...TEST_METADATA, + kind: "scratch", + projectName: "Scratch", + projectPath: "/tmp/mux/scratch/ws-created", + namedWorkspacePath: "/tmp/mux/scratch/ws-created", + runtimeConfig: { type: "local" }, + }; + const createScratchMock = mock( + (_args: WorkspaceCreateScratchArgs): Promise => + Promise.resolve({ success: true, metadata: scratchMetadata }) + ); + const { workspaceApi } = setupWindow({ createScratch: createScratchMock }); + const onWorkspaceCreated = mock( + (metadata: FrontendWorkspaceMetadata, _options?: WorkspaceCreatedOptions) => metadata + ); + const getHook = renderUseCreationWorkspace({ + kind: "scratch", + projectPath: "_scratch", + onWorkspaceCreated, + message: "Inspect this idea", + }); + + let result: CreationSendResult | undefined; + await act(async () => { + result = await getHook().handleSend("Inspect this idea"); + }); + + expect(result).toEqual({ success: true }); + expect(workspaceApi.createScratch).toHaveBeenCalledTimes(1); + }); + test("handleSend creates workspace and sends message on success", async () => { const listBranchesMock = mock( (): Promise => diff --git a/src/browser/features/ChatInput/useCreationWorkspace.ts b/src/browser/features/ChatInput/useCreationWorkspace.ts index b47381b0e4..afa24bfa33 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.ts +++ b/src/browser/features/ChatInput/useCreationWorkspace.ts @@ -395,7 +395,11 @@ export function useCreationWorkspace({ // Build runtime config early (used later for workspace creation) let runtimeSelection = settings.selectedRuntime; - if (runtimeSelection.mode === RUNTIME_MODE.DEVCONTAINER) { + // Scratch chats always run on the local runtime and never load runtime + // availability, so the devcontainer preflight below would block creation + // forever (availability stays "loading") for users whose default runtime + // is Dev Container. createScratch() ignores runtimeConfig entirely. + if (kind !== "scratch" && runtimeSelection.mode === RUNTIME_MODE.DEVCONTAINER) { const devcontainerSelection = resolveDevcontainerSelection({ selectedRuntime: runtimeSelection, availabilityState: runtimeAvailabilityState, diff --git a/src/browser/utils/ui/pinnedReorder.test.ts b/src/browser/utils/ui/pinnedReorder.test.ts index 315b6ee9b4..309297a7a9 100644 --- a/src/browser/utils/ui/pinnedReorder.test.ts +++ b/src/browser/utils/ui/pinnedReorder.test.ts @@ -13,6 +13,7 @@ interface FixtureOptions { projectPath?: string; subProjectPath?: string; projects?: FrontendWorkspaceMetadata["projects"]; + kind?: FrontendWorkspaceMetadata["kind"]; } const createWorkspace = (id: string, options: FixtureOptions = {}): FrontendWorkspaceMetadata => { @@ -27,6 +28,7 @@ const createWorkspace = (id: string, options: FixtureOptions = {}): FrontendWork pinnedAt: options.pinnedAt, subProjectPath: options.subProjectPath, projects: options.projects, + kind: options.kind, }; }; @@ -136,6 +138,26 @@ describe("locatePinnedBlock", () => { const block = locatePinnedBlock(mA, sorted, new Map()); expect(block).toEqual({ fullOrder: ["mB", "mA"], blockIds: ["mB", "mA"] }); }); + + it("treats all pinned scratch rows as one block despite distinct workdir projectPaths", () => { + // Each scratch chat's projectPath is its own app-managed workdir, but the + // sidebar renders them together in the Chats section, so a reorder between + // two pinned scratch chats must resolve to one shared block. + const s1 = createWorkspace("s1", { + pinnedAt: "2026-01-01T00:00:00.000Z", + projectPath: "/home/user/.mux/scratch/s1", + kind: "scratch", + }); + const s2 = createWorkspace("s2", { + pinnedAt: "2026-01-01T00:00:01.000Z", + projectPath: "/home/user/.mux/scratch/s2", + kind: "scratch", + }); + const sorted = new Map([["_scratch", [s1, s2]]]); + + const block = locatePinnedBlock(s1, sorted, new Map()); + expect(block).toEqual({ fullOrder: ["s1", "s2"], blockIds: ["s1", "s2"] }); + }); }); describe("computePinnedMoveOrder", () => { diff --git a/src/browser/utils/ui/pinnedReorder.ts b/src/browser/utils/ui/pinnedReorder.ts index 802e2498a7..74e4e47dc3 100644 --- a/src/browser/utils/ui/pinnedReorder.ts +++ b/src/browser/utils/ui/pinnedReorder.ts @@ -27,18 +27,19 @@ export interface PinnedBlock { } /** - * Multi-project rows render as one flat section regardless of which bucket - * they came from. Rows are re-sorted with the same helper the sidebar uses so - * the resolved block order always matches the rendered order, even when rows - * were collected from different primary-project buckets. + * Multi-project and scratch rows each render as one flat section regardless of + * which bucket they came from. Rows are re-sorted with the same helper the + * sidebar uses so the resolved block order always matches the rendered order, + * even when rows were collected from different primary-project buckets. */ -function collectMultiProjectRows( - sortedWorkspacesByProject: Map +function collectFlatSectionRows( + sortedWorkspacesByProject: Map, + includeRow: (row: FrontendWorkspaceMetadata) => boolean ): FrontendWorkspaceMetadata[] { const byId = new Map(); for (const rows of sortedWorkspacesByProject.values()) { for (const row of rows) { - if (isMultiProject(row)) { + if (includeRow(row)) { byId.set(row.id, row); } } @@ -59,8 +60,23 @@ export function locatePinnedBlock( ): PinnedBlock | null { if (!isWorkspacePinned(meta)) return null; + // Scratch chats render as one flat "Chats" section, but each row's + // projectPath is its own app-managed workdir, so the per-projectPath + // partitioning below would isolate every row into a block of one and + // swallow reorders. Treat them like the multi-project section instead. + if (meta.kind === "scratch") { + const pinnedIds = collectFlatSectionRows( + sortedWorkspacesByProject, + (row) => row.kind === "scratch" + ) + .filter(isWorkspacePinned) + .map((row) => row.id); + if (!pinnedIds.includes(meta.id)) return null; + return { fullOrder: pinnedIds, blockIds: pinnedIds }; + } + if (isMultiProject(meta)) { - const pinnedIds = collectMultiProjectRows(sortedWorkspacesByProject) + const pinnedIds = collectFlatSectionRows(sortedWorkspacesByProject, isMultiProject) .filter(isWorkspacePinned) .map((row) => row.id); if (!pinnedIds.includes(meta.id)) return null; From 43ca660ba503b2cbb8cb645218e2a1f21f300f76 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:39:17 +0000 Subject: [PATCH 03/12] fix: address Codex review round 2 (gate fork/review menu actions on hasRepository) --- .../WorkspaceMenuBar.test.tsx | 44 +++++++++++++++++++ .../WorkspaceMenuBar/WorkspaceMenuBar.tsx | 19 +++++--- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.test.tsx b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.test.tsx index 2a220f12f0..9d1f58c57a 100644 --- a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.test.tsx +++ b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.test.tsx @@ -48,6 +48,25 @@ function TestWrapper(props: PropsWithChildren) { return <>{props.children}; } +// The WorkspaceActionsMenuContent test double records every render's props, so +// gating assertions read the latest call instead of clicking through the menu. +function getLastMenuContentProps() { + const spy = WorkspaceActionsMenuContentModule.WorkspaceActionsMenuContent as unknown as { + mock: { + calls: Array< + [ + { + onForkChat?: ((anchorEl: HTMLElement) => void) | null; + onEnterImmersiveReview?: (() => void) | null; + onOpenTouchFullscreenReview?: (() => void) | null; + }, + ] + >; + }; + }; + return spy.mock.calls.at(-1)?.[0]; +} + function resolveArchivePreflight( result: { kind: "ready" } | { kind: "confirm-lossy-untracked-files"; paths: string[] } = { kind: "ready", @@ -344,6 +363,31 @@ describe("WorkspaceMenuBar archive confirmations", () => { expect( MultiProjectGitStatusIndicatorModule.MultiProjectGitStatusIndicator ).not.toHaveBeenCalled(); + + // Repo-dependent More-menu actions must be hidden too: review events are + // ignored by RightSidebar for scratch and forking scratch is unsupported. + const scratchMenuProps = getLastMenuContentProps(); + expect(scratchMenuProps?.onForkChat).toBeNull(); + expect(scratchMenuProps?.onEnterImmersiveReview).toBeNull(); + expect(scratchMenuProps?.onOpenTouchFullscreenReview).toBeNull(); + }); + + it("offers fork and immersive review in the More menu for repo-backed workspaces", () => { + workspaceMetadata.set(workspaceId, { + id: workspaceId, + name: "feature-branch", + projectName: "demo", + projectPath: "/projects/demo", + namedWorkspacePath: "/projects/demo/workspaces/feature-branch", + runtimeConfig: { type: "worktree", srcBaseDir: "/tmp/src" }, + createdAt: "2026-01-01T00:00:00.000Z", + }); + + render(); + + const menuProps = getLastMenuContentProps(); + expect(typeof menuProps?.onForkChat).toBe("function"); + expect(typeof menuProps?.onEnterImmersiveReview).toBe("function"); }); it("applies the collapsed-left-sidebar inset immediately from props", () => { diff --git a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx index 6f8d7b9200..e8f2860037 100644 --- a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx +++ b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx @@ -769,13 +769,22 @@ export const WorkspaceMenuBar: React.FC = ({ workspaceHeartbeatsEnabled ? () => setHeartbeatModalOpen(true) : null } onOpenTouchFullscreenReview={ - isTouchMobileScreen ? handleOpenTouchFullscreenReview : null + hasRepository && isTouchMobileScreen ? handleOpenTouchFullscreenReview : null + } + onEnterImmersiveReview={ + hasRepository && !isTouchMobileScreen ? handleEnterImmersiveReview : null } - onEnterImmersiveReview={isTouchMobileScreen ? null : handleEnterImmersiveReview} onStopRuntime={isRuntimeRunning ? () => void handleStopRuntime() : null} - onForkChat={(anchorEl) => { - void handleForkChat(anchorEl); - }} + // Scratch chats have no repo: review events are ignored by + // RightSidebar and fork is unsupported on the backend, so hide + // both instead of offering dead menu items. + onForkChat={ + hasRepository + ? (anchorEl) => { + void handleForkChat(anchorEl); + } + : null + } onTogglePinned={ workspaceEntry && isWorkspacePinnable(workspaceEntry) ? () => { From 51170f1466f2b7a70273ce9722454f140e265bd3 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:00:39 +0000 Subject: [PATCH 04/12] fix: address Codex review round 3 (sidebar fork gating, empty-list route guard) --- .../AgentListItem/AgentListItem.tsx | 14 ++++++-- .../contexts/WorkspaceContext.test.tsx | 32 +++++++++++++++++++ src/browser/contexts/WorkspaceContext.tsx | 5 +++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/browser/components/AgentListItem/AgentListItem.tsx b/src/browser/components/AgentListItem/AgentListItem.tsx index 95c0da4f76..ad29a775bc 100644 --- a/src/browser/components/AgentListItem/AgentListItem.tsx +++ b/src/browser/components/AgentListItem/AgentListItem.tsx @@ -77,6 +77,7 @@ import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import type { PinnedDropEdge } from "@/browser/utils/ui/pinnedReorder"; import { WorkspaceHeartbeatModal } from "../WorkspaceHeartbeatModal"; import { WorkspaceActionsMenuContent } from "../WorkspaceActionsMenuContent/WorkspaceActionsMenuContent"; +import { hasWorkspaceRepository } from "@/browser/utils/workspaceCapabilities"; import { useAPI } from "@/browser/contexts/API"; import { useWorkspaceActionsOptional } from "@/browser/contexts/WorkspaceContext"; @@ -1092,9 +1093,16 @@ function RegularAgentListItemInner(props: AgentListItemProps) { ) : null } - onForkChat={(anchorEl) => { - void onForkWorkspace(workspaceId, anchorEl); - }} + // Scratch chats have no repo and the backend rejects + // forking them, so hide the action instead of offering a + // menu item that can only fail. + onForkChat={ + hasWorkspaceRepository(metadata) + ? (anchorEl) => { + void onForkWorkspace(workspaceId, anchorEl); + } + : null + } onTogglePinned={ isPinnable && setWorkspacePinned ? () => { diff --git a/src/browser/contexts/WorkspaceContext.test.tsx b/src/browser/contexts/WorkspaceContext.test.tsx index 09e40b0c54..a38226eded 100644 --- a/src/browser/contexts/WorkspaceContext.test.tsx +++ b/src/browser/contexts/WorkspaceContext.test.tsx @@ -1148,6 +1148,38 @@ describe("WorkspaceContext", () => { expect(ctx().selectedWorkspace).toBeNull(); }); + test("browser direct open keeps the persisted selection when the workspace list is empty", async () => { + // workspace.list swallows backend read failures and resolves [], so an + // empty list is not proof the workspace is gone; the stale-route cleanup + // must not wipe the persisted selection. (The startup fallback may still + // navigate to a project page; that pre-existing behavior is not under test.) + const persistedSelection = { + workspaceId: "ws-maybe-alive", + projectPath: "/existing", + projectName: "existing", + namedWorkspacePath: "/existing-main", + }; + createMockAPI({ + workspace: { + list: () => Promise.resolve([]), + }, + projects: { + list: () => Promise.resolve([["/existing", { workspaces: [] }]]), + }, + localStorage: { + [LAUNCH_BEHAVIOR_KEY]: JSON.stringify("dashboard"), + [SELECTED_WORKSPACE_KEY]: JSON.stringify(persistedSelection), + }, + locationPath: "/workspace/ws-maybe-alive", + navigationType: "navigate", + }); + + const ctx = await setup(); + + await waitFor(() => expect(ctx().loading).toBe(false)); + expect(localStorage.getItem(SELECTED_WORKSPACE_KEY)).toContain("ws-maybe-alive"); + }); + test("resolves system project route IDs for pending workspace creation", async () => { const systemProjectPath = "/system/internal-project"; const systemProjectId = getProjectRouteId(systemProjectPath); diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index 09722fd3fd..648f06456e 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -1194,6 +1194,11 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { if (!currentWorkspaceId) return; if (workspaceMetadata.has(currentWorkspaceId)) return; + // If metadata is empty, a transient backend failure may have caused + // workspace.list to return nothing (it swallows getAllWorkspaceMetadata + // errors and resolves []), so don't clear a potentially valid route. + if (workspaceMetadata.size === 0) return; + setSelectedWorkspace(null); }, [loading, loaded, loadError, currentWorkspaceId, workspaceMetadata, setSelectedWorkspace]); From 5b4f158bcd3997fd12c3741c579866d8e46563a2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:05:41 +0000 Subject: [PATCH 05/12] fix: address Codex review round 4 (policy gate for createScratch, scratch archive preflight test) --- src/node/services/workspaceService.test.ts | 62 ++++++++++++++++++++++ src/node/services/workspaceService.ts | 9 ++++ 2 files changed, 71 insertions(+) diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 8af52e34c4..a05a675ba0 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -9284,6 +9284,37 @@ describe("WorkspaceService preflightArchive and acknowledged archive", () => { await cleanupHistory(); }); + test("preflightArchive returns ready for scratch workspaces under snapshot behavior", async () => { + // Scratch chats run on the plain local runtime, so the worktree snapshot + // preflight must short-circuit instead of consulting the snapshot service + // (whose non-worktree path would reject and block archiving). + const scratchMetadata: WorkspaceMetadata = { + kind: "scratch", + id: workspaceId, + name: "ws-preflight-archive", + projectName: "Scratch", + projectPath: "/tmp/mux/scratch/ws-preflight-archive", + runtimeConfig: { type: "local" }, + }; + (workspaceService as unknown as { aiService: AIService }).aiService.getWorkspaceMetadata = mock( + () => Promise.resolve(Ok(scratchMetadata)) + ); + const getUnsupportedUntrackedPaths = mock(() => + Promise.resolve(Err("Archive snapshots are only supported for worktree runtimes")) + ); + workspaceService.setWorktreeArchiveSnapshotService({ + preflightSnapshotForArchive: mock(() => Promise.resolve(Ok(undefined))), + captureSnapshotForArchive: mock(() => Promise.resolve(Err("unused"))), + restoreSnapshotAfterUnarchive: mock(() => Promise.resolve(Ok("skipped" as const))), + getUnsupportedUntrackedPaths, + }); + + const result = await workspaceService.preflightArchive(workspaceId); + + expect(result).toEqual(Ok({ kind: "ready" })); + expect(getUnsupportedUntrackedPaths).not.toHaveBeenCalled(); + }); + test("preflightArchive returns ready when no untracked files", async () => { workspaceService.setWorktreeArchiveSnapshotService({ preflightSnapshotForArchive: mock(() => Promise.resolve(Ok(undefined))), @@ -10156,6 +10187,37 @@ describe("WorkspaceService init cancellation", () => { } }); + test("createScratch rejects when policy disallows the local runtime", async () => { + const { + config, + historyService: scratchHistoryService, + cleanup, + } = await createTestHistoryService(); + const policyService = { + isEnforced: mock(() => true), + isRuntimeAllowed: mock(() => false), + } as unknown as WorkspaceServiceArgs[7]; + + try { + const workspaceService = createWorkspaceServiceForTest({ + config, + historyService: scratchHistoryService, + policyService, + }); + + const result = await workspaceService.createScratch("Blocked scratch"); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("not allowed by policy"); + } + // No config entry or workdir may be left behind by the rejected create. + expect((await config.getAllWorkspaceMetadata()).length).toBe(0); + } finally { + await cleanup(); + } + }); + test("create() rejects untrusted projects", async () => { const projectPath = "/tmp/proj"; const generateStableIdMock = mock(() => "ws-untrusted"); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index e5b36fd85f..3947f2f24a 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -3374,6 +3374,15 @@ export class WorkspaceService extends EventEmitter { } async createScratch(title?: string): Promise> { + // Scratch chats always run on the local runtime; locked-down deployments + // that disallow local runtimes must not get a local tool-execution + // workspace through the scratch path either. + if (this.policyService?.isEnforced()) { + if (!this.policyService.isRuntimeAllowed({ type: "local" })) { + return Err("Scratch chats require the local runtime, which is not allowed by policy"); + } + } + const workspaceId = this.config.generateStableId(); const workspaceName = `scratch-${workspaceId}`; const workspacePath = this.getScratchWorkdir(workspaceId); From 79fa5fb3d166ea8aad35b49403a753ccf4631ef5 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:27:02 +0000 Subject: [PATCH 06/12] fix: address Codex review round 5 (hide response-level Fork for scratch chats) --- .../features/Messages/AssistantMessage.tsx | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/browser/features/Messages/AssistantMessage.tsx b/src/browser/features/Messages/AssistantMessage.tsx index 28d691a1b1..cef1e9a5ee 100644 --- a/src/browser/features/Messages/AssistantMessage.tsx +++ b/src/browser/features/Messages/AssistantMessage.tsx @@ -21,6 +21,7 @@ import { PopoverError } from "@/browser/components/PopoverError/PopoverError"; import { useAPI } from "@/browser/contexts/API"; import { Button } from "@/browser/components/Button/Button"; import { forkWorkspace } from "@/browser/utils/chatCommands"; +import { useWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; import React, { useState } from "react"; import { CompactingMessageContent } from "./CompactingMessageContent"; import { CompactionBackground } from "./CompactionBackground"; @@ -107,6 +108,14 @@ export const AssistantMessage: React.FC = ({ } }; + // Scratch chats cannot be forked (the backend rejects it), so hide the + // response-level Fork action instead of surfacing a guaranteed error. + // kind is immutable per workspace, so an imperative store read is safe here; + // missing metadata (stories, tests) keeps the button visible as before. + const workspaceStore = useWorkspaceStoreRaw(); + const isScratchWorkspace = + workspaceId != null && workspaceStore.getWorkspaceMetadata(workspaceId)?.kind === "scratch"; + const buttons: ButtonConfig[] = isStreaming ? [] : [copyButton]; if (!isStreaming && !isSideAnswer) { @@ -122,13 +131,15 @@ export const AssistantMessage: React.FC = ({ tooltip: "Start a new context from this message and preserve earlier chat history", icon: , }); - buttons.push({ - label: "Fork", - onClick: () => void handleForkFromResponse(), - disabled: !workspaceId || !api, - tooltip: "Fork a new workspace from this response", - icon: , - }); + if (!isScratchWorkspace) { + buttons.push({ + label: "Fork", + onClick: () => void handleForkFromResponse(), + disabled: !workspaceId || !api, + tooltip: "Fork a new workspace from this response", + icon: , + }); + } buttons.push({ label: showRaw ? "Show Markdown" : "Show Text", onClick: () => setShowRaw(!showRaw), From f0dc0333d004ec1b69ffa9d578b0846e992a5105 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:29:45 +0000 Subject: [PATCH 07/12] fix: address Codex review round 6 (hide generic create-workspace palette action for scratch) --- src/browser/utils/commands/sources.test.ts | 31 ++++++++++++++++++++++ src/browser/utils/commands/sources.ts | 5 +++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/browser/utils/commands/sources.test.ts b/src/browser/utils/commands/sources.test.ts index 63642f617e..ed8a31043f 100644 --- a/src/browser/utils/commands/sources.test.ts +++ b/src/browser/utils/commands/sources.test.ts @@ -370,6 +370,37 @@ test("selected-workspace create action targets the workspace sub-project", async expect(onStartWorkspaceCreation).toHaveBeenCalledWith("/repo/a/packages/api"); }); +test("selected scratch workspace omits the generic create-workspace action", () => { + // A scratch chat's projectPath is its app-managed workdir, not a configured + // project, so the generic action would target the wrong project. + const workspaceMetadata = new Map([ + [ + "ws-scratch", + { + id: "ws-scratch", + kind: "scratch", + name: "scratch-1", + projectName: "Scratch", + projectPath: "/home/user/.mux/scratch/ws-scratch", + namedWorkspacePath: "/home/user/.mux/scratch/ws-scratch", + runtimeConfig: { type: "local" }, + }, + ], + ]); + const actions = getActions({ + workspaceMetadata, + selectedWorkspace: { + projectPath: "/home/user/.mux/scratch/ws-scratch", + projectName: "Scratch", + namedWorkspacePath: "/home/user/.mux/scratch/ws-scratch", + workspaceId: "ws-scratch", + }, + }); + + expect(actions.find((action) => action.id === "ws:new")).toBeUndefined(); + expect(actions.find((action) => action.id === "ws:new-scratch")).toBeDefined(); +}); + test("buildCoreSources includes archive merged workspaces in project action", () => { const actions = getActions(); const archiveAction = actions.find((a) => a.id === "ws:archive-merged-in-project"); diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index 62602dbf21..da9b89a18f 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -375,7 +375,10 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi }); const selected = p.selectedWorkspace; - if (selected) { + // For scratch chats, selected.projectPath is the app-managed workdir (not + // a configured project), so the generic action would create a workspace in + // an unrelated project; New Scratch Chat above already covers creation. + if (selected && p.workspaceMetadata.get(selected.workspaceId)?.kind !== "scratch") { list.push(createWorkspaceForSelectedProjectAction(selected)); } From 901f89d907280503b77d2dee7f7953afc9fb13b3 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:00:35 +0000 Subject: [PATCH 08/12] fix: address Codex review round 7 (resolve scratch draft route statically after reload) --- src/browser/contexts/ProjectContext.tsx | 11 +++++++++++ .../contexts/WorkspaceContext.test.tsx | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/browser/contexts/ProjectContext.tsx b/src/browser/contexts/ProjectContext.tsx index c5ce8222ab..5493b45b97 100644 --- a/src/browser/contexts/ProjectContext.tsx +++ b/src/browser/contexts/ProjectContext.tsx @@ -23,6 +23,7 @@ import { } from "@/common/constants/storage"; import { getErrorMessage } from "@/common/utils/errors"; import { getProjectRouteId } from "@/common/utils/projectRouteId"; +import { SCRATCH_PROJECT_CONFIG_KEY } from "@/common/constants/scratch"; import { getFirstTopLevelProjectPath } from "@/common/utils/subProjects"; import { normalizeProjectPathForComparison, @@ -341,6 +342,16 @@ export function ProjectProvider(props: { children: ReactNode }) { const resolveProjectPath = useCallback( (query: ProjectQuery): string | null => { + // The scratch sentinel is not a configured project until the first + // scratch chat is created, so a reloaded scratch draft route cannot be + // resolved from projects.list; resolve it statically instead. + if ( + query.value === SCRATCH_PROJECT_CONFIG_KEY || + (query.type === "routeId" && query.value === getProjectRouteId(SCRATCH_PROJECT_CONFIG_KEY)) + ) { + return SCRATCH_PROJECT_CONFIG_KEY; + } + if (query.type === "path") { const platform = globalThis.window?.api?.platform; const normalizedTarget = normalizeProjectPathForComparison(query.value, platform); diff --git a/src/browser/contexts/WorkspaceContext.test.tsx b/src/browser/contexts/WorkspaceContext.test.tsx index a38226eded..75b87d7188 100644 --- a/src/browser/contexts/WorkspaceContext.test.tsx +++ b/src/browser/contexts/WorkspaceContext.test.tsx @@ -1198,6 +1198,25 @@ describe("WorkspaceContext", () => { expect(ctx().pendingNewWorkspaceProject).toBe(systemProjectPath); }); + test("reloaded scratch draft route resolves without a configured _scratch project", async () => { + // Before the first scratch chat is ever created, no _scratch bucket exists + // in config, and reloads drop the in-memory route state, so the route ID + // must resolve statically or the draft page cannot remount. + const scratchRouteId = getProjectRouteId(SCRATCH_PROJECT_CONFIG_KEY); + + createMockAPI({ + locationPath: `/project?project=${encodeURIComponent(scratchRouteId)}`, + projects: { + list: () => Promise.resolve([["/existing", { workspaces: [] }]]), + }, + }); + + const ctx = await setup(); + + await waitFor(() => expect(ctx().loading).toBe(false)); + expect(ctx().pendingNewWorkspaceProject).toBe(SCRATCH_PROJECT_CONFIG_KEY); + }); + test("browser: launch-project opens project creation on true first launch", async () => { createMockAPI({ workspace: { From 144e14dead10b72beaa63369bfba26acfaf2636a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:10:41 +0000 Subject: [PATCH 09/12] fix: address Codex review round 8 (Ctrl+N from scratch chats opens a scratch draft) --- .../ProjectSidebar/ProjectSidebar.test.tsx | 72 ++++++++++++++++++- .../ProjectSidebar/ProjectSidebar.tsx | 21 +++--- 2 files changed, 84 insertions(+), 9 deletions(-) diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx index d998d6222b..3cd5d6c8f7 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx @@ -9,7 +9,7 @@ import * as ReactColorfulModule from "react-colorful"; import { installDom } from "../../../../tests/ui/dom"; import { EXPANDED_PROJECTS_KEY } from "@/common/constants/storage"; import { getDraftScopeId, getInputKey } from "@/common/constants/storage"; -import { SCRATCH_SIDEBAR_SECTION_ID } from "@/common/constants/scratch"; +import { SCRATCH_PROJECT_CONFIG_KEY, SCRATCH_SIDEBAR_SECTION_ID } from "@/common/constants/scratch"; import { MULTI_PROJECT_SIDEBAR_SECTION_ID } from "@/common/constants/multiProject"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; import { DEFAULT_TASK_SETTINGS } from "@/common/types/tasks"; @@ -718,6 +718,76 @@ describe("ProjectSidebar scratch chats", () => { expect(view.getByTestId(agentItemTestId(workspace.id))).toBeTruthy(); expect(view.getByText("Explore an idea")).toBeTruthy(); }); + + test("Ctrl+N from a selected scratch chat opens a scratch draft, not a workdir project draft", () => { + // Scratch rows are bucketed under the scratch config key while the + // selection's projectPath is the app-managed workdir, so the keybind + // handler must resolve kind via the store instead of the bucket lookup. + const scratchPath = "/home/user/.mux/scratch/scratch-kb"; + const workspace: FrontendWorkspaceMetadata = { + kind: "scratch", + id: "scratch-kb", + name: "scratch-scratch-kb", + projectName: "Scratch", + projectPath: scratchPath, + namedWorkspacePath: scratchPath, + createdAt: "2026-01-01T00:00:00.000Z", + runtimeConfig: { type: "local" }, + }; + const createWorkspaceDraftMock = mock(() => undefined); + spyOn(WorkspaceContextModule, "useWorkspaceActions").mockImplementation( + () => + ({ + selectedWorkspace: { + workspaceId: workspace.id, + projectPath: scratchPath, + projectName: "Scratch", + namedWorkspacePath: scratchPath, + }, + setSelectedWorkspace: () => undefined, + preflightArchiveWorkspace: () => + Promise.resolve({ success: true, data: { kind: "ready" } }), + archiveWorkspace: () => Promise.resolve({ success: true, data: { kind: "archived" } }), + removeWorkspace: () => Promise.resolve({ success: true }), + updateWorkspaceTitle: () => Promise.resolve({ success: true }), + refreshWorkspaceMetadata: () => Promise.resolve(), + pendingNewWorkspaceProject: null, + pendingNewWorkspaceDraftId: null, + workspaceDraftsByProject: {}, + workspaceDraftPromotionsByProject: {}, + createWorkspaceDraft: createWorkspaceDraftMock, + openWorkspaceDraft: () => undefined, + deleteWorkspaceDraft: () => undefined, + }) as unknown as ReturnType + ); + spyOn(WorkspaceStoreModule, "useWorkspaceStoreRaw").mockImplementation( + () => + ({ + getWorkspaceMetadata: (id: string) => (id === workspace.id ? workspace : undefined), + getWorkspaceSidebarState: () => ({ + canInterrupt: false, + isStarting: false, + awaitingUserQuestion: false, + lastAbortReason: null, + }), + getAggregator: () => undefined, + subscribeKey: () => () => undefined, + }) as unknown as ReturnType + ); + + render( + undefined} + sortedWorkspacesByProject={new Map([[SCRATCH_PROJECT_CONFIG_KEY, [workspace]]])} + workspaceRecency={{ [workspace.id]: Date.now() }} + /> + ); + + fireEvent.keyDown(window, { key: "n", ctrlKey: true }); + + expect(createWorkspaceDraftMock).toHaveBeenCalledWith(SCRATCH_PROJECT_CONFIG_KEY, undefined); + }); }); describe("ProjectSidebar multi-project completed-subagent toggles", () => { diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx index 9ac4694f34..dfa55838ea 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx @@ -1827,6 +1827,15 @@ const ProjectSidebarInner: React.FC = ({ // otherwise inherit from the parent workspace. This keeps Ctrl+N in // lockstep with the visible section and avoids forwarding deleted // sub-project paths that workspace.create would reject. + // Scratch chats are bucketed under the scratch config key while their + // selection projectPath is the app-managed workdir, so the bucket + // lookup below misses them; check kind via the store by ID first. + if ( + workspaceStore.getWorkspaceMetadata(selectedWorkspace.workspaceId)?.kind === "scratch" + ) { + handleAddScratchWorkspace(); + return; + } const projectWorkspaces = sortedWorkspacesByProject.get(selectedWorkspace.projectPath) ?? []; const byId = new Map(projectWorkspaces.map((m) => [m.id, m])); @@ -1836,14 +1845,10 @@ const ProjectSidebarInner: React.FC = ({ ([subPath]) => subPath ) ); - if (meta?.kind === "scratch") { - handleAddScratchWorkspace(); - } else { - const subProjectPath = meta - ? resolveEffectiveSectionId(meta, byId, validSectionIds) - : undefined; - handleAddWorkspace(selectedWorkspace.projectPath, subProjectPath); - } + const subProjectPath = meta + ? resolveEffectiveSectionId(meta, byId, validSectionIds) + : undefined; + handleAddWorkspace(selectedWorkspace.projectPath, subProjectPath); } else if (matchesKeybind(e, KEYBINDS.ARCHIVE_WORKSPACE) && selectedWorkspace) { e.preventDefault(); void handleArchiveWorkspace(selectedWorkspace.workspaceId); From 1ac0fa15942d440d0d4d4bec397e424f34aa6346 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:23:46 +0000 Subject: [PATCH 10/12] fix: address Codex review round 9 (metadata-aware trust for scratch workspaces) --- src/node/orpc/router.ts | 16 ++++++++++----- src/node/services/aiService.ts | 9 ++++---- src/node/utils/projectTrust.test.ts | 32 +++++++++++++++++++++++++++++ src/node/utils/projectTrust.ts | 17 +++++++++++++++ 4 files changed, 64 insertions(+), 10 deletions(-) create mode 100644 src/node/utils/projectTrust.test.ts diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index db984388cd..426f913213 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -128,7 +128,7 @@ import { } from "@/node/services/workflows/WorkflowTaskServiceAdapter"; import { WorkflowArgsValidationError } from "@/node/services/workflows/workflowArgs"; import { resolveWorkflowScript } from "@/node/services/workflows/workflowScriptResolver"; -import { isProjectTrusted } from "@/node/utils/projectTrust"; +import { isProjectTrusted, isWorkspaceProjectTrusted } from "@/node/utils/projectTrust"; import { WORKFLOW_RESULT_METADATA_TYPE, @@ -373,7 +373,13 @@ export async function resolveWorkflowContext( const workflowProjectPath = hasRequestedWorkflowProjectPath ? requestedWorkflowProjectPath : metadata.projectPath; - const projectTrusted = isTrustedProjectPath(context, workflowProjectPath); + // Workspace-default trust must be metadata-aware: scratch chats are trusted + // by design but their projectPath is an app-managed workdir, not a config key. + const resolveWorkflowProjectTrusted = () => + hasRequestedWorkflowProjectPath + ? isTrustedProjectPath(context, workflowProjectPath) + : isWorkspaceProjectTrusted(context.config, metadata); + const projectTrusted = resolveWorkflowProjectTrusted(); const runtime = createRuntimeForWorkspace(metadata); const workspaceRootPath = resolveWorkspaceRootPath(metadata, runtime); const workflowExecutionProjectPath = hasRequestedWorkflowProjectPath @@ -411,7 +417,7 @@ export async function resolveWorkflowContext( workspaceSessionDir: context.config.getSessionDir(workspaceId), trusted: projectTrusted, }, - getProjectTrusted: () => isTrustedProjectPath(context, workflowProjectPath), + getProjectTrusted: resolveWorkflowProjectTrusted, experiments: { dynamicWorkflows: true, }, @@ -421,13 +427,13 @@ export async function resolveWorkflowContext( scriptPath, runtime, workspacePath, - projectTrusted: isTrustedProjectPath(context, workflowProjectPath), + projectTrusted: resolveWorkflowProjectTrusted(), }), onRunStatusChanged: (event) => context.workspaceService.emitWorkflowRunActivity(event), ...(options.onBackgroundRunTerminal != null ? { onBackgroundRunTerminal: options.onBackgroundRunTerminal } : {}), - getCurrentProjectTrusted: () => isTrustedProjectPath(context, workflowProjectPath), + getCurrentProjectTrusted: resolveWorkflowProjectTrusted, runnerId: `workflow-runner:${workspaceId}`, }), }; diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index eb2f6b7562..eab2fd7e5f 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -169,7 +169,7 @@ import { WorkflowTaskServiceAdapter, } from "@/node/services/workflows/WorkflowTaskServiceAdapter"; import { resolveWorkflowScript } from "@/node/services/workflows/workflowScriptResolver"; -import { isProjectTrusted } from "@/node/utils/projectTrust"; +import { isWorkspaceProjectTrusted } from "@/node/utils/projectTrust"; const STREAM_STARTUP_DIAGNOSTIC_THRESHOLD_MS = 1_000; @@ -1451,7 +1451,7 @@ export class AIService extends EventEmitter { effectiveToolPolicy, } = agentResult.data; const legacyModeForMetadata = getLegacyModeForAgentMetadata(effectiveAgentId, effectiveMode); - const projectTrusted = isProjectTrusted(this.config, metadata.projectPath); + const projectTrusted = isWorkspaceProjectTrusted(this.config, metadata); const sharedExecutionTrusted = isWorkspaceTrustedForSharedExecution(metadata, cfg.projects); const agentAdvisorEnabled = resolveAdvisorEnabledForAgent( effectiveAgentId, @@ -1808,8 +1808,7 @@ export class AIService extends EventEmitter { thinkingLevel: thinkingLevel ?? "off", costsUsd: sessionCostsUsd, }); - const getWorkflowProjectTrusted = () => - metadata.kind === "scratch" || isProjectTrusted(this.config, metadata.projectPath); + const getWorkflowProjectTrusted = () => isWorkspaceProjectTrusted(this.config, metadata); const workflowService = dynamicWorkflowsExperimentEnabled && this.taskService != null @@ -1955,7 +1954,7 @@ export class AIService extends EventEmitter { await waitForWorkflowContinuationRetry(); } }, - getCurrentProjectTrusted: () => isProjectTrusted(this.config, metadata.projectPath), + getCurrentProjectTrusted: () => isWorkspaceProjectTrusted(this.config, metadata), runnerId: `workflow-runner:${workspaceId}`, }) : undefined; diff --git a/src/node/utils/projectTrust.test.ts b/src/node/utils/projectTrust.test.ts new file mode 100644 index 0000000000..fdbbfd42d8 --- /dev/null +++ b/src/node/utils/projectTrust.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test"; +import type { Config } from "@/node/config"; +import { isProjectTrusted, isWorkspaceProjectTrusted } from "./projectTrust"; + +function configWithProjects(projects: Map): Config { + return { loadConfigOrDefault: () => ({ projects }) } as unknown as Config; +} + +describe("isWorkspaceProjectTrusted", () => { + test("treats scratch workspaces as trusted despite unregistered workdir projectPath", () => { + const config = configWithProjects(new Map()); + const trusted = isWorkspaceProjectTrusted(config, { + kind: "scratch", + projectPath: "/home/user/.mux/scratch/ws-1", + }); + expect(trusted).toBe(true); + // The raw path lookup alone would report untrusted; that is the bug this + // helper exists to fix. + expect(isProjectTrusted(config, "/home/user/.mux/scratch/ws-1")).toBe(false); + }); + + test("falls back to config trust for regular workspaces", () => { + const config = configWithProjects( + new Map([ + ["/repo/trusted", { trusted: true }], + ["/repo/untrusted", {}], + ]) + ); + expect(isWorkspaceProjectTrusted(config, { projectPath: "/repo/trusted" })).toBe(true); + expect(isWorkspaceProjectTrusted(config, { projectPath: "/repo/untrusted" })).toBe(false); + }); +}); diff --git a/src/node/utils/projectTrust.ts b/src/node/utils/projectTrust.ts index ff7c4d06aa..51d9da2c89 100644 --- a/src/node/utils/projectTrust.ts +++ b/src/node/utils/projectTrust.ts @@ -1,6 +1,23 @@ import type { Config } from "@/node/config"; +import type { WorkspaceMetadata } from "@/common/types/workspace"; import { stripTrailingSlashes } from "@/node/utils/pathUtils"; +/** + * Workspace-scoped trust. Scratch workspaces are app-owned (created trusted + * under the _scratch system bucket), but their metadata.projectPath is the + * per-chat workdir rather than a config key, so a plain path lookup would + * wrongly report them untrusted. + */ +export function isWorkspaceProjectTrusted( + config: Config, + metadata: Pick +): boolean { + if (metadata.kind === "scratch") { + return true; + } + return isProjectTrusted(config, metadata.projectPath); +} + /** * Repo-controlled configuration should only run or load after the user has * explicitly trusted the project. From 61ac82395784575e208f195e88f4c4584c6f4505 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:36:24 +0000 Subject: [PATCH 11/12] fix: address Codex review round 10 (scratch-aware shared-execution trust) --- .../services/utils/workspaceTrust.test.ts | 50 +++++++++++++++++++ src/node/services/utils/workspaceTrust.ts | 7 +++ 2 files changed, 57 insertions(+) create mode 100644 src/node/services/utils/workspaceTrust.test.ts diff --git a/src/node/services/utils/workspaceTrust.test.ts b/src/node/services/utils/workspaceTrust.test.ts new file mode 100644 index 0000000000..52b72bcb5f --- /dev/null +++ b/src/node/services/utils/workspaceTrust.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test"; +import type { ProjectsConfig } from "@/common/types/project"; +import type { WorkspaceMetadata } from "@/common/types/workspace"; +import { isWorkspaceTrustedForSharedExecution } from "./workspaceTrust"; + +const baseMetadata: WorkspaceMetadata = { + id: "ws-1", + name: "ws-1", + projectName: "proj", + projectPath: "/repo/proj", + runtimeConfig: { type: "local" }, +}; + +describe("isWorkspaceTrustedForSharedExecution", () => { + test("treats scratch workspaces as trusted despite unregistered workdir projectPath", () => { + const projects: ProjectsConfig["projects"] = new Map(); + const scratch: WorkspaceMetadata = { + ...baseMetadata, + kind: "scratch", + projectPath: "/home/user/.mux/scratch/ws-1", + }; + expect(isWorkspaceTrustedForSharedExecution(scratch, projects)).toBe(true); + }); + + test("single-project workspaces follow the config trust flag", () => { + const projects: ProjectsConfig["projects"] = new Map([ + ["/repo/proj", { workspaces: [], trusted: true }], + ]); + expect(isWorkspaceTrustedForSharedExecution(baseMetadata, projects)).toBe(true); + projects.set("/repo/proj", { workspaces: [] }); + expect(isWorkspaceTrustedForSharedExecution(baseMetadata, projects)).toBe(false); + }); + + test("multi-project workspaces require every project to be trusted", () => { + const projects: ProjectsConfig["projects"] = new Map([ + ["/repo/a", { workspaces: [], trusted: true }], + ["/repo/b", { workspaces: [] }], + ]); + const multi: WorkspaceMetadata = { + ...baseMetadata, + projects: [ + { projectPath: "/repo/a", projectName: "a" }, + { projectPath: "/repo/b", projectName: "b" }, + ], + }; + expect(isWorkspaceTrustedForSharedExecution(multi, projects)).toBe(false); + projects.set("/repo/b", { workspaces: [], trusted: true }); + expect(isWorkspaceTrustedForSharedExecution(multi, projects)).toBe(true); + }); +}); diff --git a/src/node/services/utils/workspaceTrust.ts b/src/node/services/utils/workspaceTrust.ts index 29031bdd0b..646056bfb8 100644 --- a/src/node/services/utils/workspaceTrust.ts +++ b/src/node/services/utils/workspaceTrust.ts @@ -7,6 +7,13 @@ export function isWorkspaceTrustedForSharedExecution( metadata: WorkspaceMetadata, projectsConfig: ProjectsConfig["projects"] ): boolean { + // Scratch chats are app-owned and created trusted under the _scratch system + // bucket; their metadata.projectPath is the per-chat workdir, which is never + // a config key, so the path lookup below would wrongly report untrusted. + if (metadata.kind === "scratch") { + return true; + } + if (!isMultiProject(metadata)) { return projectsConfig.get(stripTrailingSlashes(metadata.projectPath))?.trusted ?? false; } From 6ed14dfd95cb49196bb4941aeda5b0b1cdf0461a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:49:42 +0000 Subject: [PATCH 12/12] fix: address Codex review round 11 (scratch workdir ownership guard before deletion) --- src/node/services/workspaceService.test.ts | 65 ++++++++++++++++++++++ src/node/services/workspaceService.ts | 51 ++++++++++++++++- 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index a05a675ba0..4ccf0c91a8 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -10187,6 +10187,71 @@ describe("WorkspaceService init cancellation", () => { } }); + test("scratch removal refuses to delete a workdir the workspace does not own", async () => { + // A stale or hand-edited config entry can point at another chat's dir + // under the scratch root; removal must not recursively delete it. + const { + config, + historyService: scratchHistoryService, + cleanup, + } = await createTestHistoryService(); + const victimId = "3333333333"; + const malformedId = "4444444444"; + const configWithStableId = config as unknown as { generateStableId: () => string }; + configWithStableId.generateStableId = () => victimId; + + const aiService = { + isStreaming: mock(() => false), + stopStream: mock(() => Promise.resolve(Ok(undefined))), + getWorkspaceMetadata: mock(async (workspaceId: string) => { + const metadata = (await config.getAllWorkspaceMetadata()).find( + (workspace) => workspace.id === workspaceId + ); + return metadata ? Ok(metadata) : Err("not found"); + }), + on: mock(() => undefined), + off: mock(() => undefined), + } as unknown as AIService; + + try { + const workspaceService = createWorkspaceServiceForTest({ + config, + historyService: scratchHistoryService, + aiService, + }); + const created = await workspaceService.createScratch("Victim scratch"); + expect(created.success).toBe(true); + if (!created.success) return; + const victimPath = created.data.metadata.namedWorkspacePath; + + // Remove the victim's config entry (keep the dir) so the malformed + // entry is the workdir's only reference; then point the malformed + // root entry (no task ancestry) at the victim's dir. + await config.editConfig((current) => { + const scratchProject = current.projects.get(SCRATCH_PROJECT_CONFIG_KEY); + if (!scratchProject) throw new Error("Scratch project missing"); + scratchProject.workspaces = scratchProject.workspaces.filter( + (workspace) => workspace.id !== victimId + ); + scratchProject.workspaces.push({ + kind: "scratch", + path: victimPath, + id: malformedId, + name: `scratch-${malformedId}`, + createdAt: new Date().toISOString(), + runtimeConfig: { type: "local" }, + }); + return current; + }); + + expect(await workspaceService.remove(malformedId, true)).toEqual(Ok(undefined)); + // Config cleanup proceeded, but the victim's dir must survive. + expect(await fsPromises.stat(victimPath).then(() => true)).toBe(true); + } finally { + await cleanup(); + } + }); + test("createScratch rejects when policy disallows the local runtime", async () => { const { config, diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 3947f2f24a..2c459a8e30 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -3346,6 +3346,40 @@ export class WorkspaceService extends EventEmitter { return path.dirname(resolvedPath) === scratchRoot && isPathInsideDir(scratchRoot, resolvedPath); } + /** + * Scratch workdirs are named after the workspace that created them, and + * isolation "none" task children share an ancestor's workdir. Deletion is + * only safe when the dir basename matches the removed workspace or one of + * its task ancestors; a stale or hand-edited config entry pointing at some + * other chat's directory must never recursively delete it. + */ + private scratchWorkdirOwnedByWorkspace( + configSnapshot: ProjectsConfig, + metadata: WorkspaceMetadata, + workdirBasename: string + ): boolean { + if (workdirBasename === metadata.id) { + return true; + } + + const parentIdsByWorkspaceId = new Map(); + for (const project of configSnapshot.projects.values()) { + for (const workspace of project.workspaces) { + if (workspace.id) { + parentIdsByWorkspaceId.set(workspace.id, workspace.parentWorkspaceId); + } + } + } + let ancestorId = metadata.parentWorkspaceId; + for (let depth = 0; ancestorId != null && depth < 32; depth++) { + if (ancestorId === workdirBasename) { + return true; + } + ancestorId = parentIdsByWorkspaceId.get(ancestorId); + } + return false; + } + private async cleanupOrphanScratchWorkdirs(): Promise { const scratchRoot = this.getScratchRoot(); await ensurePrivateDir(scratchRoot); @@ -4456,7 +4490,22 @@ export class WorkspaceService extends EventEmitter { ) ); if (!hasOtherScratchReference) { - await fsPromises.rm(persistedWorkspacePath, { recursive: true, force: true }); + if ( + this.scratchWorkdirOwnedByWorkspace( + configSnapshot, + metadata, + path.basename(resolvedScratchPath) + ) + ) { + await fsPromises.rm(persistedWorkspacePath, { recursive: true, force: true }); + } else { + // Skip instead of failing: config cleanup still proceeds, and the + // startup orphan sweep reclaims the dir once nothing references it. + log.warn( + "Skipping scratch workdir deletion: basename matches neither the workspace nor its task ancestors", + { workspaceId, workspacePath: persistedWorkspacePath } + ); + } } } else if (taskSharesParentCheckout) { // Shared checkout (isolation: "none"): do not touch the filesystem — the directory