Skip to content

Commit d692c8f

Browse files
authored
🤖 feat: separate workspace title from name (#1049)
_Generated with `mux`_ ## Summary This PR introduces a separation between workspace **name** (internal identifier) and **title** (human-readable description): | Field | Format | Purpose | Display Location | |-------|--------|---------|------------------| | **name** | Short noun + 4-char suffix (e.g., `plan-a1b2`) | Git branch, directory name | Top bar | | **title** | Verb-noun phrase (e.g., "Fix plan mode over SSH") | User-friendly description | Sidebar, window title | ## Key Changes ### Schema Changes - Added `title` field (optional for backwards compat) to: - `WorkspaceMetadataSchema` - `WorkspaceConfigSchema` ### Name/Title Generation - AI now generates both name and title in a single call - Name: 1-2 word noun, git-safe, with 4-char random suffix - Title: 2-5 word description in verb-noun format ### UI Updates - **Sidebar**: Shows title (falls back to name for legacy workspaces) - **Top bar**: Continues showing `projectName / name` (git branch) - **Window title**: Uses title instead of name - **Creation form**: Now labeled "Title" instead of "Name" ### Rename → Title Edit - The "rename" operation now only updates the title - No filesystem changes (no git branch rename, no directory move) - **Can be performed even while streaming is active** - Added new `updateTitle` API endpoint ## Backwards Compatibility - Existing workspaces without `title` continue to work - Legacy workspaces display their `name` in sidebar - No migration needed - `title` field is optional
1 parent 50ff249 commit d692c8f

File tree

17 files changed

+388
-222
lines changed

17 files changed

+388
-222
lines changed

src/browser/App.tsx

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { SettingsProvider, useSettings } from "./contexts/SettingsContext";
4242
import { SettingsModal } from "./components/Settings/SettingsModal";
4343
import { TutorialProvider } from "./contexts/TutorialContext";
4444
import { TooltipProvider } from "./components/ui/tooltip";
45+
import { getWorkspaceSidebarKey } from "./utils/workspace";
4546

4647
const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"];
4748

@@ -137,10 +138,10 @@ function AppInner() {
137138
window.history.replaceState(null, "", newHash);
138139
}
139140

140-
// Update window title with workspace name
141+
// Update window title with workspace title (or name for legacy workspaces)
141142
const metadata = workspaceMetadata.get(selectedWorkspace.workspaceId);
142-
const workspaceName = metadata?.name ?? selectedWorkspace.workspaceId;
143-
const title = `${workspaceName} - ${selectedWorkspace.projectName} - mux`;
143+
const workspaceTitle = metadata?.title ?? metadata?.name ?? selectedWorkspace.workspaceId;
144+
const title = `${workspaceTitle} - ${selectedWorkspace.projectName} - mux`;
144145
// Set document.title locally for browser mode, call backend for Electron
145146
document.title = title;
146147
void api?.window.setTitle({ title });
@@ -206,15 +207,12 @@ function AppInner() {
206207
(prev, next) =>
207208
compareMaps(prev, next, (a, b) => {
208209
if (a.length !== b.length) return false;
209-
// Check ID, name, and status to detect changes
210210
return a.every((meta, i) => {
211211
const other = b[i];
212-
return (
213-
other &&
214-
meta.id === other.id &&
215-
meta.name === other.name &&
216-
meta.status === other.status
217-
);
212+
// Compare all fields that affect sidebar display.
213+
// If you add a new display-relevant field to WorkspaceMetadata,
214+
// add it to getWorkspaceSidebarKey() in src/browser/utils/workspace.ts
215+
return other && getWorkspaceSidebarKey(meta) === getWorkspaceSidebarKey(other);
218216
});
219217
}),
220218
[projects, workspaceMetadata, workspaceRecency]

src/browser/components/ChatInput/CreationCenterContent.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import React from "react";
33
interface CreationCenterContentProps {
44
projectName: string;
55
isSending: boolean;
6-
/** The confirmed workspace name (null while name generation is in progress) */
6+
/** The confirmed workspace name (null while generation is in progress) */
77
workspaceName?: string | null;
8+
/** The confirmed workspace title (null while generation is in progress) */
9+
workspaceTitle?: string | null;
810
}
911

1012
/**
@@ -22,6 +24,9 @@ export function CreationCenterContent(props: CreationCenterContentProps) {
2224
{props.workspaceName ? (
2325
<>
2426
<code className="bg-separator rounded px-1">{props.workspaceName}</code>
27+
{props.workspaceTitle && (
28+
<span className="text-muted-foreground ml-1">{props.workspaceTitle}</span>
29+
)}
2530
</>
2631
) : (
2732
"Generating name…"

src/browser/components/ChatInput/CreationControls.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ interface CreationControlsProps {
1818
onSetDefaultRuntime: (mode: RuntimeMode) => void;
1919
onSshHostChange: (host: string) => void;
2020
disabled: boolean;
21-
/** Workspace name generation state and actions */
21+
/** Workspace name/title generation state and actions */
2222
nameState: WorkspaceNameState;
2323
}
2424

src/browser/components/ChatInput/index.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1352,7 +1352,14 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
13521352
projectName={props.projectName}
13531353
isSending={creationState.isSending || isSending}
13541354
workspaceName={
1355-
creationState.isSending || isSending ? creationState.creatingWithName : undefined
1355+
creationState.isSending || isSending
1356+
? creationState.creatingWithIdentity?.name
1357+
: undefined
1358+
}
1359+
workspaceTitle={
1360+
creationState.isSending || isSending
1361+
? creationState.creatingWithIdentity?.title
1362+
: undefined
13561363
}
13571364
/>
13581365
)}

src/browser/components/ChatInput/useCreationWorkspace.ts

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ import type { Toast } from "@/browser/components/ChatInputToast";
1919
import { createErrorToast } from "@/browser/components/ChatInputToasts";
2020
import { useAPI } from "@/browser/contexts/API";
2121
import type { ImagePart } from "@/common/orpc/types";
22-
import { useWorkspaceName, type WorkspaceNameState } from "@/browser/hooks/useWorkspaceName";
22+
import {
23+
useWorkspaceName,
24+
type WorkspaceNameState,
25+
type WorkspaceIdentity,
26+
} from "@/browser/hooks/useWorkspaceName";
2327

2428
interface UseCreationWorkspaceOptions {
2529
projectPath: string;
@@ -69,10 +73,10 @@ interface UseCreationWorkspaceReturn {
6973
setToast: (toast: Toast | null) => void;
7074
isSending: boolean;
7175
handleSend: (message: string, imageParts?: ImagePart[]) => Promise<boolean>;
72-
/** Workspace name generation state and actions (for CreationControls) */
76+
/** Workspace name/title generation state and actions (for CreationControls) */
7377
nameState: WorkspaceNameState;
74-
/** The confirmed name being used for creation (null until name generation resolves) */
75-
creatingWithName: string | null;
78+
/** The confirmed identity being used for creation (null until generation resolves) */
79+
creatingWithIdentity: WorkspaceIdentity | null;
7680
}
7781

7882
/**
@@ -93,8 +97,8 @@ export function useCreationWorkspace({
9397
const [recommendedTrunk, setRecommendedTrunk] = useState<string | null>(null);
9498
const [toast, setToast] = useState<Toast | null>(null);
9599
const [isSending, setIsSending] = useState(false);
96-
// The confirmed name being used for workspace creation (set after waitForGeneration resolves)
97-
const [creatingWithName, setCreatingWithName] = useState<string | null>(null);
100+
// The confirmed identity being used for workspace creation (set after waitForGeneration resolves)
101+
const [creatingWithIdentity, setCreatingWithIdentity] = useState<WorkspaceIdentity | null>(null);
98102

99103
// Centralized draft workspace settings with automatic persistence
100104
const {
@@ -147,19 +151,19 @@ export function useCreationWorkspace({
147151

148152
setIsSending(true);
149153
setToast(null);
150-
setCreatingWithName(null);
154+
setCreatingWithIdentity(null);
151155

152156
try {
153-
// Wait for name generation to complete (blocks if still in progress)
154-
// Returns empty string if generation failed or manual name is empty (error already set in hook)
155-
const workspaceName = await waitForGeneration();
156-
if (!workspaceName) {
157+
// Wait for identity generation to complete (blocks if still in progress)
158+
// Returns null if generation failed or manual name is empty (error already set in hook)
159+
const identity = await waitForGeneration();
160+
if (!identity) {
157161
setIsSending(false);
158162
return false;
159163
}
160164

161-
// Set the confirmed name for UI display
162-
setCreatingWithName(workspaceName);
165+
// Set the confirmed identity for splash UI display
166+
setCreatingWithIdentity(identity);
163167

164168
// Get runtime config from options
165169
const runtimeString = getRuntimeString();
@@ -172,11 +176,12 @@ export function useCreationWorkspace({
172176
// in usePersistedState can delay state updates after model selection)
173177
const sendMessageOptions = getSendOptionsFromStorage(projectScopeId);
174178

175-
// Create the workspace with the generated/manual name first
179+
// Create the workspace with the generated name and title
176180
const createResult = await api.workspace.create({
177181
projectPath,
178-
branchName: workspaceName,
182+
branchName: identity.name,
179183
trunkBranch: settings.trunkBranch,
184+
title: identity.title,
180185
runtimeConfig,
181186
});
182187

@@ -256,9 +261,9 @@ export function useCreationWorkspace({
256261
setToast,
257262
isSending,
258263
handleSend,
259-
// Workspace name state (for CreationControls)
264+
// Workspace name/title state (for CreationControls)
260265
nameState: workspaceNameState,
261-
// The confirmed name being used for creation (null until waitForGeneration resolves)
262-
creatingWithName,
266+
// The confirmed identity being used for creation (null until generation resolves)
267+
creatingWithIdentity,
263268
};
264269
}

src/browser/components/WorkspaceListItem.tsx

Lines changed: 43 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -44,57 +44,58 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
4444
onToggleUnread: _onToggleUnread,
4545
}) => {
4646
// Destructure metadata for convenience
47-
const { id: workspaceId, name: workspaceName, namedWorkspacePath, status } = metadata;
47+
const { id: workspaceId, namedWorkspacePath, status } = metadata;
4848
const isCreating = status === "creating";
4949
const isDisabled = isCreating || isDeleting;
5050
const gitStatus = useGitStatus(workspaceId);
5151

52-
// Get rename context
52+
// Get title edit context (renamed from rename context since we now edit titles, not names)
5353
const { editingWorkspaceId, requestRename, confirmRename, cancelRename } = useRename();
5454

55-
// Local state for rename
56-
const [editingName, setEditingName] = useState<string>("");
57-
const [renameError, setRenameError] = useState<string | null>(null);
55+
// Local state for title editing
56+
const [editingTitle, setEditingTitle] = useState<string>("");
57+
const [titleError, setTitleError] = useState<string | null>(null);
5858

59-
const displayName = workspaceName;
59+
// Display title (fallback to name for legacy workspaces without title)
60+
const displayTitle = metadata.title ?? metadata.name;
6061
const isEditing = editingWorkspaceId === workspaceId;
6162

62-
const startRenaming = () => {
63-
if (requestRename(workspaceId, displayName)) {
64-
setEditingName(displayName);
65-
setRenameError(null);
63+
const startEditing = () => {
64+
if (requestRename(workspaceId, displayTitle)) {
65+
setEditingTitle(displayTitle);
66+
setTitleError(null);
6667
}
6768
};
6869

69-
const handleConfirmRename = async () => {
70-
if (!editingName.trim()) {
71-
setRenameError("Name cannot be empty");
70+
const handleConfirmEdit = async () => {
71+
if (!editingTitle.trim()) {
72+
setTitleError("Title cannot be empty");
7273
return;
7374
}
7475

75-
const result = await confirmRename(workspaceId, editingName);
76+
const result = await confirmRename(workspaceId, editingTitle);
7677
if (!result.success) {
77-
setRenameError(result.error ?? "Failed to rename workspace");
78+
setTitleError(result.error ?? "Failed to update title");
7879
} else {
79-
setRenameError(null);
80+
setTitleError(null);
8081
}
8182
};
8283

83-
const handleCancelRename = () => {
84+
const handleCancelEdit = () => {
8485
cancelRename();
85-
setEditingName("");
86-
setRenameError(null);
86+
setEditingTitle("");
87+
setTitleError(null);
8788
};
8889

89-
const handleRenameKeyDown = (e: React.KeyboardEvent) => {
90+
const handleEditKeyDown = (e: React.KeyboardEvent) => {
91+
// Always stop propagation to prevent parent div's onKeyDown from interfering
92+
e.stopPropagation();
9093
if (e.key === "Enter") {
9194
e.preventDefault();
92-
void handleConfirmRename();
95+
void handleConfirmEdit();
9396
} else if (e.key === "Escape") {
94-
// Stop propagation to prevent global Escape handler from interrupting stream
9597
e.preventDefault();
96-
e.stopPropagation();
97-
handleCancelRename();
98+
handleCancelEdit();
9899
}
99100
};
100101

@@ -121,7 +122,7 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
121122
});
122123
}}
123124
onKeyDown={(e) => {
124-
if (isDisabled) return;
125+
if (isDisabled || isEditing) return;
125126
if (e.key === "Enter" || e.key === " ") {
126127
e.preventDefault();
127128
onSelectWorkspace({
@@ -137,10 +138,10 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
137138
aria-current={isSelected ? "true" : undefined}
138139
aria-label={
139140
isCreating
140-
? `Creating workspace ${displayName}`
141+
? `Creating workspace ${displayTitle}`
141142
: isDeleting
142-
? `Deleting workspace ${displayName}`
143-
: `Select workspace ${displayName}`
143+
? `Deleting workspace ${displayTitle}`
144+
: `Select workspace ${displayTitle}`
144145
}
145146
aria-disabled={isDisabled}
146147
data-workspace-path={namedWorkspacePath}
@@ -157,7 +158,7 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
157158
e.stopPropagation();
158159
void onRemoveWorkspace(workspaceId, e.currentTarget);
159160
}}
160-
aria-label={`Remove workspace ${displayName}`}
161+
aria-label={`Remove workspace ${displayTitle}`}
161162
data-workspace-id={workspaceId}
162163
>
163164
×
@@ -169,14 +170,14 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
169170
<RuntimeBadge runtimeConfig={metadata.runtimeConfig} isWorking={canInterrupt} />
170171
{isEditing ? (
171172
<input
172-
className="bg-input-bg text-input-text border-input-border font-inherit focus:border-input-border-focus min-w-0 rounded-sm border px-1 text-left text-[13px] outline-none"
173-
value={editingName}
174-
onChange={(e) => setEditingName(e.target.value)}
175-
onKeyDown={handleRenameKeyDown}
176-
onBlur={() => void handleConfirmRename()}
173+
className="bg-input-bg text-input-text border-input-border font-inherit focus:border-input-border-focus col-span-2 min-w-0 flex-1 rounded-sm border px-1 text-left text-[13px] outline-none"
174+
value={editingTitle}
175+
onChange={(e) => setEditingTitle(e.target.value)}
176+
onKeyDown={handleEditKeyDown}
177+
onBlur={() => void handleConfirmEdit()}
177178
autoFocus
178179
onClick={(e) => e.stopPropagation()}
179-
aria-label={`Rename workspace ${displayName}`}
180+
aria-label={`Edit title for workspace ${displayTitle}`}
180181
data-workspace-id={workspaceId}
181182
/>
182183
) : (
@@ -190,20 +191,20 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
190191
onDoubleClick={(e) => {
191192
if (isDisabled) return;
192193
e.stopPropagation();
193-
startRenaming();
194+
startEditing();
194195
}}
195-
title={isDisabled ? undefined : "Double-click to rename"}
196+
title={isDisabled ? undefined : "Double-click to edit title"}
196197
>
197198
{canInterrupt || isCreating ? (
198199
<Shimmer className="w-full truncate" colorClass="var(--color-foreground)">
199-
{displayName}
200+
{displayTitle}
200201
</Shimmer>
201202
) : (
202-
displayName
203+
displayTitle
203204
)}
204205
</span>
205206
</TooltipTrigger>
206-
<TooltipContent align="start">Double-click to rename</TooltipContent>
207+
<TooltipContent align="start">Double-click to edit title</TooltipContent>
207208
</Tooltip>
208209
)}
209210

@@ -230,9 +231,9 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
230231
)}
231232
</div>
232233
</div>
233-
{renameError && isEditing && (
234+
{titleError && isEditing && (
234235
<div className="bg-error-bg border-error text-error absolute top-full right-8 left-8 z-10 mt-1 rounded-sm border px-2 py-1.5 text-xs">
235-
{renameError}
236+
{titleError}
236237
</div>
237238
)}
238239
</React.Fragment>

0 commit comments

Comments
 (0)