diff --git a/packages/app/src/hooks/use-projects.test.ts b/packages/app/src/hooks/use-projects.test.ts
index 86b42550f..9b10bc789 100644
--- a/packages/app/src/hooks/use-projects.test.ts
+++ b/packages/app/src/hooks/use-projects.test.ts
@@ -255,6 +255,7 @@ describe("useProjects", () => {
"hostCount",
"hosts",
"onlineHostCount",
+ "projectCustomName",
"projectKey",
"projectName",
"totalWorkspaceCount",
diff --git a/packages/app/src/screens/project-settings-screen.tsx b/packages/app/src/screens/project-settings-screen.tsx
index b62ff3c84..caee16873 100644
--- a/packages/app/src/screens/project-settings-screen.tsx
+++ b/packages/app/src/screens/project-settings-screen.tsx
@@ -3,7 +3,7 @@ import { Image, Pressable, Text, TextInput, View } from "react-native";
import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { ArrowLeft, ChevronDown, MoreVertical, Plus } from "lucide-react-native";
+import { ArrowLeft, Check, ChevronDown, MoreVertical, Pencil, Plus, X } from "lucide-react-native";
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
import type {
PaseoConfigRaw,
@@ -234,7 +234,7 @@ function ProjectSettingsBody({
- {project.projectName}
+
@@ -771,6 +771,124 @@ function ResolveSpinnerColor(): string {
return styles.spinnerColor.color;
}
+interface ProjectNameEditorProps {
+ project: ProjectSummary;
+ client: DaemonClient;
+}
+
+function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
+ const queryClient = useQueryClient();
+ const toast = useToast();
+ const [isEditing, setIsEditing] = useState(false);
+ const [value, setValue] = useState(project.projectCustomName ?? "");
+
+ const renameMutation = useMutation({
+ mutationFn: (customName: string | null) => client.renameProject(project.projectKey, customName),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["projects"] });
+ setIsEditing(false);
+ toast.show("Project renamed", { variant: "success" });
+ },
+ onError: (error) => {
+ const message = error instanceof Error ? error.message : "Couldn't rename project";
+ toast.show(message, { variant: "error" });
+ },
+ });
+
+ const handleStartEdit = useCallback(() => {
+ setValue(project.projectCustomName ?? "");
+ setIsEditing(true);
+ }, [project.projectCustomName]);
+
+ const handleCancel = useCallback(() => {
+ setIsEditing(false);
+ setValue(project.projectCustomName ?? "");
+ }, [project.projectCustomName]);
+
+ const handleSave = useCallback(() => {
+ const trimmed = value.trim();
+ const next = trimmed.length === 0 ? null : trimmed;
+ if (next === (project.projectCustomName ?? null)) {
+ setIsEditing(false);
+ return;
+ }
+ renameMutation.mutate(next);
+ }, [value, project.projectCustomName, renameMutation]);
+
+ const handleReset = useCallback(() => {
+ renameMutation.mutate(null);
+ }, [renameMutation]);
+
+ if (!isEditing) {
+ return (
+
+
+ {project.projectName}
+
+
+
+
+ {project.projectCustomName ? (
+
+ Reset
+
+ ) : null}
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
+
function ProjectTitleIcon({ host, projectName }: { host: ProjectHostEntry; projectName: string }) {
const initial = projectName.trim().charAt(0).toUpperCase() || "?";
const { icon } = useProjectIconQuery({ serverId: host.serverId, cwd: host.repoRoot });
@@ -1140,6 +1258,37 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.medium,
flexShrink: 1,
},
+ nameEditorRow: {
+ flex: 1,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[2],
+ minWidth: 0,
+ },
+ nameEditorIconButton: {
+ padding: theme.spacing[1],
+ },
+ nameEditorInput: {
+ flex: 1,
+ color: theme.colors.foreground,
+ fontSize: theme.fontSize.lg,
+ fontWeight: theme.fontWeight.medium,
+ paddingVertical: theme.spacing[1],
+ paddingHorizontal: theme.spacing[2],
+ borderRadius: theme.borderRadius.md,
+ borderWidth: 1,
+ borderColor: theme.colors.border,
+ backgroundColor: theme.colors.surface2,
+ minWidth: 0,
+ },
+ nameEditorResetButton: {
+ paddingVertical: theme.spacing[1],
+ paddingHorizontal: theme.spacing[2],
+ },
+ nameEditorResetText: {
+ color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.xs,
+ },
titleIcon: {
width: 28,
height: 28,
diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts
index fa50e2efe..a601e0ace 100644
--- a/packages/app/src/stores/session-store.ts
+++ b/packages/app/src/stores/session-store.ts
@@ -113,6 +113,7 @@ export interface WorkspaceDescriptor {
id: string;
projectId: string;
projectDisplayName: string;
+ projectCustomName?: string | null;
projectRootPath: string;
workspaceDirectory: string;
projectKind: WorkspaceDescriptorPayload["projectKind"];
@@ -134,6 +135,7 @@ export function normalizeWorkspaceDescriptor(
id: normalizeWorkspaceOpaqueId(payload.id) ?? payload.id,
projectId: payload.projectId,
projectDisplayName: payload.projectDisplayName,
+ projectCustomName: payload.projectCustomName ?? null,
projectRootPath: payload.projectRootPath,
workspaceDirectory: payload.workspaceDirectory,
projectKind: payload.projectKind,
diff --git a/packages/app/src/utils/projects.ts b/packages/app/src/utils/projects.ts
index 5df2b3403..cedbd7afd 100644
--- a/packages/app/src/utils/projects.ts
+++ b/packages/app/src/utils/projects.ts
@@ -22,6 +22,7 @@ export interface ProjectHostEntry {
export interface ProjectSummary {
projectKey: string;
projectName: string;
+ projectCustomName?: string | null;
hosts: ProjectHostEntry[];
totalWorkspaceCount: number;
hostCount: number;
@@ -56,6 +57,7 @@ interface HostGroup {
interface ProjectGroup {
projectKey: string;
projectName: string;
+ projectCustomName: string | null;
hostsByServerId: Map;
}
@@ -119,6 +121,7 @@ function toProjectSummary(draft: ProjectGroup): ProjectSummary {
return {
projectKey: draft.projectKey,
projectName: draft.projectName,
+ projectCustomName: draft.projectCustomName,
hosts,
totalWorkspaceCount,
hostCount: hosts.length,
@@ -139,9 +142,13 @@ export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
group = {
projectKey,
projectName: workspace.projectDisplayName,
+ projectCustomName: workspace.projectCustomName ?? null,
hostsByServerId: new Map(),
};
groups.set(projectKey, group);
+ } else if (workspace.projectCustomName && !group.projectCustomName) {
+ group.projectCustomName = workspace.projectCustomName;
+ group.projectName = workspace.projectDisplayName;
}
let hostGroup = group.hostsByServerId.get(host.serverId);
diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts
index c7f62a6c9..fc44413d3 100644
--- a/packages/server/src/client/daemon-client.ts
+++ b/packages/server/src/client/daemon-client.ts
@@ -1938,6 +1938,27 @@ export class DaemonClient {
}
}
+ async renameProject(
+ projectId: string,
+ customName: string | null,
+ requestId?: string,
+ ): Promise<{ customName: string | null }> {
+ const payload = await this.sendCorrelatedSessionRequest({
+ requestId,
+ message: {
+ type: "project.rename.request",
+ projectId,
+ customName,
+ },
+ responseType: "project.rename.response",
+ timeout: 10000,
+ });
+ if (!payload.accepted) {
+ throw new Error(payload.error ?? "renameProject rejected");
+ }
+ return { customName: payload.customName };
+ }
+
async resumeAgent(
handle: AgentPersistenceHandle,
overrides?: Partial,
diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts
index 4a04ef5b3..a66480525 100644
--- a/packages/server/src/server/session.ts
+++ b/packages/server/src/server/session.ts
@@ -153,6 +153,7 @@ import {
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
+ resolveProjectDisplayName,
type PersistedProjectRecord,
type PersistedWorkspaceRecord,
type ProjectRegistry,
@@ -1576,7 +1577,7 @@ export class Session {
const checkout = buildWorkspaceCheckout(workspace, project);
return {
projectKey: project.projectId,
- projectName: project.displayName,
+ projectName: resolveProjectDisplayName(project),
checkout,
};
}
@@ -1806,6 +1807,8 @@ export class Session {
return this.handleCloseItemsRequest(msg);
case "update_agent_request":
return this.handleUpdateAgentRequest(msg.agentId, msg.name, msg.labels, msg.requestId);
+ case "project.rename.request":
+ return this.handleProjectRenameRequest(msg.projectId, msg.customName, msg.requestId);
case "send_agent_message_request":
return this.handleSendAgentMessageRequest(msg);
case "wait_for_finish_request":
@@ -2495,6 +2498,90 @@ export class Session {
}
}
+ private async handleProjectRenameRequest(
+ projectId: string,
+ customName: string | null,
+ requestId: string,
+ ): Promise {
+ this.sessionLogger.info(
+ { projectId, requestId, hasCustomName: typeof customName === "string" },
+ "session: project.rename.request",
+ );
+
+ try {
+ const existing = await this.projectRegistry.get(projectId);
+ if (!existing) {
+ this.emit({
+ type: "project.rename.response",
+ payload: {
+ requestId,
+ projectId,
+ accepted: false,
+ customName: null,
+ error: "Project not found",
+ },
+ });
+ return;
+ }
+
+ const trimmed = customName?.trim() ?? "";
+ const nextCustomName = trimmed.length === 0 ? null : trimmed;
+
+ await this.projectRegistry.upsert({
+ ...existing,
+ customName: nextCustomName,
+ updatedAt: new Date().toISOString(),
+ });
+
+ this.emit({
+ type: "project.rename.response",
+ payload: {
+ requestId,
+ projectId,
+ accepted: true,
+ customName: nextCustomName,
+ error: null,
+ },
+ });
+
+ // Re-emit descriptors for every workspace under this project so the new
+ // resolved name lands in the UI immediately.
+ const workspaces = await this.workspaceRegistry.list();
+ const affectedWorkspaceIds = workspaces
+ .filter((workspace) => workspace.projectId === projectId)
+ .map((workspace) => workspace.workspaceId);
+ if (affectedWorkspaceIds.length > 0) {
+ await this.emitWorkspaceUpdatesForWorkspaceIds(affectedWorkspaceIds, {
+ skipReconcile: true,
+ });
+ }
+ } catch (error) {
+ this.sessionLogger.error(
+ { err: error, projectId, requestId },
+ "session: project.rename.request error",
+ );
+ this.emit({
+ type: "activity_log",
+ payload: {
+ id: uuidv4(),
+ timestamp: new Date(),
+ type: "error",
+ content: `Failed to rename project: ${getErrorMessage(error)}`,
+ },
+ });
+ this.emit({
+ type: "project.rename.response",
+ payload: {
+ requestId,
+ projectId,
+ accepted: false,
+ customName: null,
+ error: getErrorMessageOr(error, "Failed to rename project"),
+ },
+ });
+ }
+ }
+
private toVoiceFeatureUnavailableContext(
state: SpeechReadinessState,
): VoiceFeatureUnavailableContext {
@@ -6010,7 +6097,10 @@ export class Session {
return {
id: workspace.workspaceId,
projectId: workspace.projectId,
- projectDisplayName: resolvedProjectRecord?.displayName ?? workspace.projectId,
+ projectDisplayName: resolvedProjectRecord
+ ? resolveProjectDisplayName(resolvedProjectRecord)
+ : workspace.projectId,
+ projectCustomName: resolvedProjectRecord?.customName ?? null,
projectRootPath: resolvedProjectRecord?.rootPath ?? workspace.cwd,
workspaceDirectory: workspace.cwd,
projectKind: (resolvedProjectRecord?.kind ?? "directory") === "git" ? "git" : "non_git",
@@ -6098,7 +6188,10 @@ export class Session {
return {
id: result.workspace.workspaceId,
projectId: result.workspace.projectId,
- projectDisplayName: projectRecord?.displayName ?? result.workspace.projectId,
+ projectDisplayName: projectRecord
+ ? resolveProjectDisplayName(projectRecord)
+ : result.workspace.projectId,
+ projectCustomName: projectRecord?.customName ?? null,
projectRootPath: projectRecord?.rootPath ?? result.repoRoot,
workspaceDirectory: result.workspace.cwd,
projectKind: "git",
diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts
index 810f4d525..05a4ae0eb 100644
--- a/packages/server/src/server/session.workspaces.test.ts
+++ b/packages/server/src/server/session.workspaces.test.ts
@@ -4395,6 +4395,145 @@ test("subscribed fetch_workspaces includes git enrichment in the initial snapsho
);
});
+test("project.rename.request stores customName and emits an updated workspace descriptor", async () => {
+ const emitted: SessionOutboundMessage[] = [];
+ const session = asTestSession(
+ createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }),
+ );
+
+ const project = createPersistedProjectRecord({
+ projectId: "remote:github.com/acme/repo",
+ rootPath: REPO_CWD,
+ kind: "git",
+ displayName: "acme/repo",
+ createdAt: "2026-03-01T12:00:00.000Z",
+ updatedAt: "2026-03-01T12:00:00.000Z",
+ });
+ const workspace = createPersistedWorkspaceRecord({
+ workspaceId: "ws-1",
+ projectId: project.projectId,
+ cwd: REPO_CWD,
+ kind: "local_checkout",
+ displayName: "main",
+ createdAt: "2026-03-01T12:00:00.000Z",
+ updatedAt: "2026-03-01T12:00:00.000Z",
+ });
+
+ const projects = new Map([[project.projectId, project]]);
+ session.projectRegistry.get = async (id: string) => projects.get(id) ?? null;
+ session.projectRegistry.list = async () => Array.from(projects.values());
+ session.projectRegistry.upsert = async (record: unknown) => {
+ const parsed = record as typeof project;
+ projects.set(parsed.projectId, parsed);
+ };
+ session.workspaceRegistry.list = async () => [workspace];
+ session.workspaceRegistry.get = async (id: string) =>
+ id === workspace.workspaceId ? workspace : null;
+
+ session.workspaceUpdatesSubscription = {
+ subscriptionId: "sub-workspaces",
+ filter: {},
+ isBootstrapping: false,
+ lastEmittedByWorkspaceId: new Map(),
+ pendingUpdatesByWorkspaceId: new Map(),
+ };
+
+ await session.handleMessage({
+ type: "project.rename.request",
+ projectId: project.projectId,
+ customName: " My Fork ",
+ requestId: "req-rename-1",
+ });
+
+ const response = findByType(emitted, "project.rename.response");
+ expect(response?.payload).toEqual({
+ requestId: "req-rename-1",
+ projectId: project.projectId,
+ accepted: true,
+ customName: "My Fork",
+ error: null,
+ });
+
+ expect(projects.get(project.projectId)?.customName).toBe("My Fork");
+
+ const update = findByType(emitted, "workspace_update");
+ expect(update?.payload).toMatchObject({
+ kind: "upsert",
+ workspace: {
+ id: "ws-1",
+ projectDisplayName: "My Fork",
+ projectCustomName: "My Fork",
+ },
+ });
+});
+
+test("project.rename.request with whitespace-only customName clears the override", async () => {
+ const emitted: SessionOutboundMessage[] = [];
+ const session = asTestSession(
+ createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }),
+ );
+
+ const project = createPersistedProjectRecord({
+ projectId: "remote:github.com/acme/repo",
+ rootPath: REPO_CWD,
+ kind: "git",
+ displayName: "acme/repo",
+ customName: "My Fork",
+ createdAt: "2026-03-01T12:00:00.000Z",
+ updatedAt: "2026-03-01T12:00:00.000Z",
+ });
+
+ const projects = new Map([[project.projectId, project]]);
+ session.projectRegistry.get = async (id: string) => projects.get(id) ?? null;
+ session.projectRegistry.list = async () => Array.from(projects.values());
+ session.projectRegistry.upsert = async (record: unknown) => {
+ const parsed = record as typeof project;
+ projects.set(parsed.projectId, parsed);
+ };
+ session.workspaceRegistry.list = async () => [];
+
+ await session.handleMessage({
+ type: "project.rename.request",
+ projectId: project.projectId,
+ customName: " ",
+ requestId: "req-rename-clear",
+ });
+
+ const response = findByType(emitted, "project.rename.response");
+ expect(response?.payload).toEqual({
+ requestId: "req-rename-clear",
+ projectId: project.projectId,
+ accepted: true,
+ customName: null,
+ error: null,
+ });
+ expect(projects.get(project.projectId)?.customName).toBeNull();
+});
+
+test("project.rename.request returns accepted=false when project is not found", async () => {
+ const emitted: SessionOutboundMessage[] = [];
+ const session = asTestSession(
+ createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }),
+ );
+ session.projectRegistry.get = async () => null;
+
+ await session.handleMessage({
+ type: "project.rename.request",
+ projectId: "does-not-exist",
+ customName: "X",
+ requestId: "req-rename-missing",
+ });
+
+ const response = findByType(emitted, "project.rename.response");
+ expect(response?.payload).toMatchObject({
+ requestId: "req-rename-missing",
+ projectId: "does-not-exist",
+ accepted: false,
+ customName: null,
+ });
+ expect(response?.payload.error).toBeTruthy();
+});
+
test("resolveRegisteredWorkspaceIdForCwd does not match home directory as a prefix", () => {
const session = createSessionForWorkspaceTests();
const home = homedir();
diff --git a/packages/server/src/server/workspace-reconciliation-service.test.ts b/packages/server/src/server/workspace-reconciliation-service.test.ts
index f327e2771..0342e7975 100644
--- a/packages/server/src/server/workspace-reconciliation-service.test.ts
+++ b/packages/server/src/server/workspace-reconciliation-service.test.ts
@@ -393,6 +393,62 @@ describe("WorkspaceReconciliationService", () => {
expect(projects.get("p1")!.displayName).toBe("new-owner/new-repo");
});
+ test("preserves customName even when the derived displayName changes", async () => {
+ const dir = createTempGitRepo("reconcile-customname-");
+ tempDirs.push(dir);
+
+ const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
+
+ projects.set(
+ "p1",
+ createPersistedProjectRecord({
+ projectId: "p1",
+ rootPath: dir,
+ kind: "git",
+ displayName: "old-owner/old-repo",
+ customName: "My Fork",
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ }),
+ );
+ workspaces.set(
+ "w1",
+ createPersistedWorkspaceRecord({
+ workspaceId: "w1",
+ projectId: "p1",
+ cwd: dir,
+ kind: "local_checkout",
+ displayName: "main",
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ }),
+ );
+
+ execFileSync("git", ["remote", "add", "origin", "git@github.com:new-owner/new-repo.git"], {
+ cwd: dir,
+ stdio: "ignore",
+ });
+
+ const service = new WorkspaceReconciliationService({
+ projectRegistry,
+ workspaceRegistry,
+ logger: createTestLogger(),
+ workspaceGitService: createWorkspaceGitServiceStub({
+ [dir]: {
+ projectKind: "git",
+ projectDisplayName: "new-owner/new-repo",
+ workspaceDisplayName: "main",
+ gitRemote: "git@github.com:new-owner/new-repo.git",
+ },
+ }),
+ });
+
+ await service.runOnce();
+
+ expect(projects.get("p1")!.displayName).toBe("new-owner/new-repo");
+ expect(projects.get("p1")!.customName).toBe("My Fork");
+ });
+
test("updates workspace display name when branch changes", async () => {
const dir = createTempGitRepo("reconcile-branch-");
tempDirs.push(dir);
diff --git a/packages/server/src/server/workspace-registry.test.ts b/packages/server/src/server/workspace-registry.test.ts
index b4abe506b..f9b8b9158 100644
--- a/packages/server/src/server/workspace-registry.test.ts
+++ b/packages/server/src/server/workspace-registry.test.ts
@@ -68,6 +68,83 @@ describe("workspace registries", () => {
expect(await projectRegistry.list()).toEqual([]);
});
+ test("PIN: two checkouts of the same git remote collapse into a single project record", async () => {
+ // Reproduces the situation in #987: two directories that share a git remote
+ // both derive the same projectKey/displayName. Because the registry is keyed
+ // by projectId, the second upsert overwrites the first — so the registry can
+ // only ever hold one record per remote, and there is no way to distinguish
+ // the two checkouts in the UI.
+ await projectRegistry.initialize();
+
+ const remoteKey = "remote:github.com/acme/repo";
+
+ await projectRegistry.upsert(
+ createPersistedProjectRecord({
+ projectId: remoteKey,
+ rootPath: "/home/me/work/repo",
+ kind: "git",
+ displayName: "acme/repo",
+ createdAt: "2026-03-01T00:00:00.000Z",
+ updatedAt: "2026-03-01T00:00:00.000Z",
+ }),
+ );
+
+ await projectRegistry.upsert(
+ createPersistedProjectRecord({
+ projectId: remoteKey,
+ rootPath: "/home/me/scratch/repo",
+ kind: "git",
+ displayName: "acme/repo",
+ createdAt: "2026-03-01T00:00:00.000Z",
+ updatedAt: "2026-03-02T00:00:00.000Z",
+ }),
+ );
+
+ const all = await projectRegistry.list();
+ expect(all).toHaveLength(1);
+ expect(all[0]?.displayName).toBe("acme/repo");
+ // Second upsert wins — the first rootPath is lost.
+ expect(all[0]?.rootPath).toBe("/home/me/scratch/repo");
+ });
+
+ test("project record schema accepts records without customName (legacy on-disk records)", async () => {
+ await projectRegistry.initialize();
+
+ await projectRegistry.upsert(
+ createPersistedProjectRecord({
+ projectId: "remote:github.com/acme/repo",
+ rootPath: "/tmp/repo",
+ kind: "git",
+ displayName: "acme/repo",
+ createdAt: "2026-03-01T00:00:00.000Z",
+ updatedAt: "2026-03-01T00:00:00.000Z",
+ }),
+ );
+
+ const record = await projectRegistry.get("remote:github.com/acme/repo");
+ expect(record?.customName).toBeNull();
+ });
+
+ test("project record persists a customName override", async () => {
+ await projectRegistry.initialize();
+
+ await projectRegistry.upsert(
+ createPersistedProjectRecord({
+ projectId: "remote:github.com/acme/repo",
+ rootPath: "/home/me/work/repo",
+ kind: "git",
+ displayName: "acme/repo",
+ customName: "Acme (work)",
+ createdAt: "2026-03-01T00:00:00.000Z",
+ updatedAt: "2026-03-01T00:00:00.000Z",
+ }),
+ );
+
+ const record = await projectRegistry.get("remote:github.com/acme/repo");
+ expect(record?.customName).toBe("Acme (work)");
+ expect(record?.displayName).toBe("acme/repo");
+ });
+
test("creates, updates, archives, deletes, and lists workspace records", async () => {
await workspaceRegistry.initialize();
await workspaceRegistry.upsert(
diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts
index dcd7fb411..401afa610 100644
--- a/packages/server/src/server/workspace-registry.ts
+++ b/packages/server/src/server/workspace-registry.ts
@@ -12,6 +12,13 @@ const PersistedProjectRecordSchema = z.object({
rootPath: z.string(),
kind: z.enum(["git", "non_git"]),
displayName: z.string(),
+ // User-set override layered over the derived displayName. Reconciliation
+ // never touches this. Null means "use the derived name". Added for #987.
+ customName: z
+ .string()
+ .nullable()
+ .optional()
+ .transform((value) => value ?? null),
createdAt: z.string(),
updatedAt: z.string(),
archivedAt: z.string().nullable(),
@@ -56,7 +63,7 @@ type RegistryRecord = PersistedProjectRecord | PersistedWorkspaceRecord;
class FileBackedRegistry {
private readonly filePath: string;
private readonly logger: Logger;
- private readonly schema: z.ZodSchema;
+ private readonly schema: z.ZodType;
private readonly getId: (record: TRecord) => string;
private loaded = false;
private readonly cache = new Map();
@@ -65,7 +72,7 @@ class FileBackedRegistry {
constructor(options: {
filePath: string;
logger: Logger;
- schema: z.ZodSchema;
+ schema: z.ZodType;
getId: (record: TRecord) => string;
component: string;
}) {
@@ -202,16 +209,22 @@ export function createPersistedProjectRecord(input: {
rootPath: string;
kind: PersistedProjectKind;
displayName: string;
+ customName?: string | null;
createdAt: string;
updatedAt: string;
archivedAt?: string | null;
}): PersistedProjectRecord {
return PersistedProjectRecordSchema.parse({
...input,
+ customName: input.customName ?? null,
archivedAt: input.archivedAt ?? null,
});
}
+export function resolveProjectDisplayName(record: PersistedProjectRecord): string {
+ return record.customName ?? record.displayName;
+}
+
export function createPersistedWorkspaceRecord(input: {
workspaceId: string;
projectId: string;
diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts
index b63a5ec81..9cf5a5507 100644
--- a/packages/server/src/shared/messages.ts
+++ b/packages/server/src/shared/messages.ts
@@ -741,6 +741,14 @@ export const UpdateAgentRequestMessageSchema = z.object({
requestId: z.string(),
});
+export const ProjectRenameRequestSchema = z.object({
+ type: z.literal("project.rename.request"),
+ projectId: z.string(),
+ // Null or empty string clears the override and reverts to the derived name.
+ customName: z.string().nullable(),
+ requestId: z.string(),
+});
+
export const SetVoiceModeMessageSchema = z.object({
type: z.literal("set_voice_mode"),
enabled: z.boolean(),
@@ -1203,6 +1211,19 @@ export const UpdateAgentResponseMessageSchema = z.object({
payload: AgentActionResponsePayloadSchema,
});
+export const ProjectRenameResponsePayloadSchema = z.object({
+ requestId: z.string(),
+ projectId: z.string(),
+ accepted: z.boolean(),
+ customName: z.string().nullable(),
+ error: z.string().nullable(),
+});
+
+export const ProjectRenameResponseSchema = z.object({
+ type: z.literal("project.rename.response"),
+ payload: ProjectRenameResponsePayloadSchema,
+});
+
export const SetVoiceModeResponseMessageSchema = z.object({
type: z.literal("set_voice_mode_response"),
payload: z.object({
@@ -1730,6 +1751,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
ArchiveAgentRequestMessageSchema,
CloseItemsRequestMessageSchema,
UpdateAgentRequestMessageSchema,
+ ProjectRenameRequestSchema,
SetVoiceModeMessageSchema,
SendAgentMessageRequestSchema,
WaitForFinishRequestSchema,
@@ -2234,6 +2256,11 @@ export const WorkspaceDescriptorPayloadSchema = z
id: z.string(),
projectId: z.string(),
projectDisplayName: z.string(),
+ // COMPAT(projectCustomName): added in v0.1.76, drop the optional gate when floor >= v0.1.76.
+ // When the user has renamed a project, projectDisplayName carries the resolved
+ // value (customName) and projectCustomName mirrors the raw override so the
+ // settings UI can prefill its input and offer a "reset" action.
+ projectCustomName: z.string().nullable().optional(),
projectRootPath: z.string(),
workspaceDirectory: z.string().optional(),
projectKind: z.enum(["git", "non_git", "directory"]),
@@ -3454,6 +3481,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
SetAgentThinkingResponseMessageSchema,
SetAgentFeatureResponseMessageSchema,
UpdateAgentResponseMessageSchema,
+ ProjectRenameResponseSchema,
WaitForFinishResponseMessageSchema,
AgentPermissionRequestMessageSchema,
AgentPermissionResolvedMessageSchema,
@@ -3590,6 +3618,8 @@ export type SetAgentModelResponseMessage = z.infer;
export type SetAgentFeatureResponseMessage = z.infer;
export type UpdateAgentResponseMessage = z.infer;
+export type ProjectRenameResponse = z.infer;
+export type ProjectRenameResponsePayload = z.infer;
export type WaitForFinishResponseMessage = z.infer;
export type AgentPermissionRequestMessage = z.infer;
export type AgentPermissionResolvedMessage = z.infer;
@@ -3701,6 +3731,7 @@ export type LoopStopRequest = z.infer;
export type ResumeAgentRequestMessage = z.infer;
export type DeleteAgentRequestMessage = z.infer;
export type UpdateAgentRequestMessage = z.infer;
+export type ProjectRenameRequest = z.infer;
export type SetAgentModeRequestMessage = z.infer;
export type SetAgentModelRequestMessage = z.infer;
export type SetAgentThinkingRequestMessage = z.infer;