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;