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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"group": "Workspaces",
"pages": [
"workspaces",
"workspaces/scratch-chats",
"workspaces/fork",
"workspaces/muxignore",
{
Expand Down
30 changes: 30 additions & 0 deletions docs/workspaces/scratch-chats.mdx
Original file line number Diff line number Diff line change
@@ -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/<workspace-id>`. 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.
2 changes: 1 addition & 1 deletion scripts/gen_builtin_skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
124 changes: 60 additions & 64 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -159,6 +163,7 @@ function AppInner() {
pendingNewWorkspaceProject,
pendingNewWorkspaceSubProjectPath,
pendingNewWorkspaceDraftId,
createWorkspaceDraft,
beginWorkspaceCreation,
} = useWorkspaceContext();
const {
Expand Down Expand Up @@ -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<WorkspaceSelection | null>(null);
// Ref for selectedWorkspace to access in callbacks without stale closures
Expand Down Expand Up @@ -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<ThinkingLevel | undefined>(
getThinkingLevelByModelKey(model),
undefined
);
if (legacy !== undefined && THINKING_LEVELS.includes(legacy)) {
updatePersistedState(scopedKey, legacy);
return legacy;
}

Expand All @@ -438,7 +466,6 @@ function AppInner() {
undefined
);
if (canonicalLegacy !== undefined && THINKING_LEVELS.includes(canonicalLegacy)) {
updatePersistedState(scopedKey, canonicalLegacy);
return canonicalLegacy;
}
}
Expand Down Expand Up @@ -618,6 +645,10 @@ function AppInner() {

const registerParamsRef = useRef<BuildSourcesParams | null>(null);

const openNewScratchFromPalette = useCallback(() => {
createWorkspaceDraft(SCRATCH_PROJECT_CONFIG_KEY);
Comment thread
ibetitsmike marked this conversation as resolved.
}, [createWorkspaceDraft]);

const openNewWorkspaceFromPalette = useCallback(
(projectPath: string) => {
startWorkspaceCreation(projectPath);
Expand Down Expand Up @@ -854,6 +885,7 @@ function AppInner() {
providersConfig,
getRouteForModel,
getMinThinkingOverride,
onStartScratchCreation: openNewScratchFromPalette,
onStartWorkspaceCreation: openNewWorkspaceFromPalette,
onStartMultiProjectWorkspaceCreation: openNewMultiProjectWorkspaceFromPalette,
multiProjectWorkspacesEnabled,
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -978,6 +1013,7 @@ function AppInner() {
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [
openNewScratchFromPalette,
handleNavigateWorkspace,
setSidebarCollapsed,
isCommandPaletteOpen,
Expand Down Expand Up @@ -1288,67 +1324,27 @@ function AppInner() {
onToggleLeftSidebarCollapsed={handleToggleSidebar}
/>
)
) : creationProjectPath === SCRATCH_PROJECT_CONFIG_KEY ? (
<ScratchPage
leftSidebarCollapsed={sidebarCollapsed}
onToggleLeftSidebarCollapsed={handleToggleSidebar}
pendingDraftId={pendingNewWorkspaceDraftId}
onWorkspaceCreated={handleWorkspaceCreated}
/>
) : creationProjectPath ? (
(() => {
const projectPath = creationProjectPath;
const projectName =
projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "Project";
return (
<ProjectPage
projectPath={projectPath}
projectName={projectName}
leftSidebarCollapsed={sidebarCollapsed}
onToggleLeftSidebarCollapsed={handleToggleSidebar}
pendingSubProjectPath={pendingNewWorkspaceSubProjectPath}
pendingDraftId={pendingNewWorkspaceDraftId}
onWorkspaceCreated={(metadata, options) => {
// 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.
}}
/>
);
})()
<ProjectPage
projectPath={creationProjectPath}
projectName={
creationProjectPath.split("/").pop() ??
creationProjectPath.split("\\").pop() ??
"Project"
}
leftSidebarCollapsed={sidebarCollapsed}
onToggleLeftSidebarCollapsed={handleToggleSidebar}
pendingSubProjectPath={pendingNewWorkspaceSubProjectPath}
pendingDraftId={pendingNewWorkspaceDraftId}
onWorkspaceCreated={handleWorkspaceCreated}
/>
) : (
// The dedicated Mux home page was removed. Keep `/` as a minimal shell so
// WorkspaceContext can redirect it to a concrete project route when possible,
Expand Down
14 changes: 11 additions & 3 deletions src/browser/components/AgentListItem/AgentListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
? () => {
Expand Down
31 changes: 19 additions & 12 deletions src/browser/components/ChatPane/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ import {
useBackgroundBashActions,
useBackgroundBashError,
} from "@/browser/contexts/BackgroundBashContext";
import { hasWorkspaceRepository } from "@/browser/utils/workspaceCapabilities";
import {
buildEditingStateFromDisplayed,
canEditDisplayedUserMessage,
Expand Down Expand Up @@ -315,6 +316,7 @@ const ChatPaneContent: React.FC<ChatPaneContentProps> = (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);
Expand Down Expand Up @@ -1380,18 +1382,20 @@ const ChatPaneContent: React.FC<ChatPaneContentProps> = (props) => {
<div className="text-placeholder flex h-full flex-1 flex-col items-center justify-center text-center [&_h3]:m-0 [&_h3]:mb-2.5 [&_h3]:text-base [&_h3]:font-medium [&_p]:m-0 [&_p]:text-[13px]">
<h3>No Messages Yet</h3>
<p>Send a message below to begin</p>
<p className="text-muted mt-5 flex items-start gap-2 text-xs">
<Lightbulb aria-hidden="true" className="mt-0.5 h-3 w-3 shrink-0" />
<span>
Tip: Add a{" "}
<code className="bg-inline-code-dark-bg text-code-string rounded-[3px] px-1.5 py-0.5 font-mono text-[11px]">
.mux/init
</code>{" "}
hook to your project to run setup commands
<br />
(e.g., install dependencies, build) when creating new workspaces
</span>
</p>
{hasRepository && (
<p className="text-muted mt-5 flex items-start gap-2 text-xs">
<Lightbulb aria-hidden="true" className="mt-0.5 h-3 w-3 shrink-0" />
<span>
Tip: Add a{" "}
<code className="bg-inline-code-dark-bg text-code-string rounded-[3px] px-1.5 py-0.5 font-mono text-[11px]">
.mux/init
</code>{" "}
hook to your project to run setup commands
<br />
(e.g., install dependencies, build) when creating new workspaces
</span>
</p>
)}
</div>
) : (
<BashCollapsedSummaryModeProvider>
Expand Down Expand Up @@ -1615,6 +1619,7 @@ const ChatPaneContent: React.FC<ChatPaneContentProps> = (props) => {
<TranscriptOnlyNoticePane />
) : (
<ChatInputPane
kind={meta?.kind}
workspaceId={workspaceId}
projectName={projectName}
workspaceName={workspaceName}
Expand Down Expand Up @@ -1677,6 +1682,7 @@ const TranscriptOnlyNoticePane: React.FC = () => {
};

interface ChatInputPaneProps {
kind?: "scratch";
workspaceId: string;
projectName: string;
workspaceName: string;
Expand Down Expand Up @@ -1828,6 +1834,7 @@ const ChatInputPane: React.FC<ChatInputPaneProps> = (props) => {
<ChatInput
key={props.workspaceId}
variant="workspace"
kind={props.kind}
workspaceId={props.workspaceId}
runtimeType={getRuntimeTypeForTelemetry(props.runtimeConfig)}
onMessageSendStarted={props.onMessageSendStarted}
Expand Down
9 changes: 1 addition & 8 deletions src/browser/components/ProjectPage/ProjectPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ import { isWorkspaceArchived } from "@/common/utils/archive";
import { GitInitBanner } from "../GitInitBanner/GitInitBanner";
import { ConfiguredProvidersBar } from "../ConfiguredProvidersBar/ConfiguredProvidersBar";
import { ConfigureProvidersPrompt } from "../ConfigureProvidersPrompt/ConfigureProvidersPrompt";
import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig";
import type { ProvidersConfigMap } from "@/common/orpc/types";
import { hasConfiguredProvider, useProvidersConfig } from "@/browser/hooks/useProvidersConfig";
import { AgentsInitBanner } from "../AgentsInitBanner/AgentsInitBanner";
import {
usePersistedState,
Expand Down Expand Up @@ -59,12 +58,6 @@ function archivedListsEqual(
return next.every((w) => 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.
Expand Down
Loading
Loading