diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index da0df684..f75dbf14 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -559,7 +559,7 @@ export { export { API_PREFIX, API } from "./api.js"; export { normalizeAgentUrlKey, deriveAgentUrlKey, isUuidLike } from "./agent-url-key.js"; -export { deriveProjectUrlKey, normalizeProjectUrlKey } from "./project-url-key.js"; +export { deriveProjectUrlKey, normalizeProjectUrlKey, hasNonAsciiContent } from "./project-url-key.js"; export { AGENT_MENTION_SCHEME, PROJECT_MENTION_SCHEME, diff --git a/packages/shared/src/project-url-key.ts b/packages/shared/src/project-url-key.ts index 85cb8297..a367d417 100644 --- a/packages/shared/src/project-url-key.ts +++ b/packages/shared/src/project-url-key.ts @@ -1,5 +1,7 @@ const PROJECT_URL_KEY_DELIM_RE = /[^a-z0-9]+/g; const PROJECT_URL_KEY_TRIM_RE = /^-+|-+$/g; +const NON_ASCII_RE = /[^\x00-\x7F]/; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; export function normalizeProjectUrlKey(value: string | null | undefined): string | null { if (typeof value !== "string") return null; @@ -11,6 +13,24 @@ export function normalizeProjectUrlKey(value: string | null | undefined): string return normalized.length > 0 ? normalized : null; } -export function deriveProjectUrlKey(name: string | null | undefined, fallback?: string | null): string { - return normalizeProjectUrlKey(name) ?? normalizeProjectUrlKey(fallback) ?? "project"; +/** Check whether a string contains non-ASCII characters that normalization would strip. */ +export function hasNonAsciiContent(value: string | null | undefined): boolean { + if (typeof value !== "string") return false; + return NON_ASCII_RE.test(value); +} + +/** Extract the first 8 hex chars from a valid UUID, or null. */ +function shortIdFromUuid(value: string | null | undefined): string | null { + if (typeof value !== "string" || !UUID_RE.test(value.trim())) return null; + return value.trim().replace(/-/g, "").slice(0, 8).toLowerCase(); +} + +export function deriveProjectUrlKey(name: string | null | undefined, fallback?: string | null): string { + const base = normalizeProjectUrlKey(name); + if (base && !hasNonAsciiContent(name)) return base; + // Non-ASCII content was stripped — append short UUID suffix for uniqueness. + const shortId = shortIdFromUuid(fallback); + if (base && shortId) return `${base}-${shortId}`; + if (shortId) return shortId; + return base ?? normalizeProjectUrlKey(fallback) ?? "project"; } diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 7c7c4bb0..db786478 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -4,6 +4,7 @@ import { projects, projectGoals, goals, projectWorkspaces, workspaceRuntimeServi import { PROJECT_COLORS, deriveProjectUrlKey, + hasNonAsciiContent, isUuidLike, normalizeProjectUrlKey, type ProjectCodebase, @@ -343,6 +344,8 @@ export function resolveProjectNameForUniqueShortname( ): string { const requestedShortname = normalizeProjectUrlKey(requestedName); if (!requestedShortname) return requestedName; + // Non-ASCII names get a UUID suffix in deriveProjectUrlKey, making slugs inherently unique. + if (hasNonAsciiContent(requestedName)) return requestedName; const usedShortnames = new Set( existingProjects diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 76e18846..bf33606d 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -1,6 +1,6 @@ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; -import { deriveAgentUrlKey, deriveProjectUrlKey } from "@paperclipai/shared"; +import { deriveAgentUrlKey, deriveProjectUrlKey, normalizeProjectUrlKey, hasNonAsciiContent } from "@paperclipai/shared"; import type { BillingType, FinanceDirection, FinanceEventKind } from "@paperclipai/shared"; export function cn(...inputs: ClassValue[]) { @@ -156,9 +156,12 @@ export function agentUrl(agent: { id: string; urlKey?: string | null; name?: str return `/agents/${agentRouteRef(agent)}`; } -/** Build a project route reference using the short URL key when available. */ +/** Build a project route reference, falling back to UUID when the derived key is ambiguous. */ export function projectRouteRef(project: { id: string; urlKey?: string | null; name?: string | null }): string { - return project.urlKey ?? deriveProjectUrlKey(project.name, project.id); + const key = project.urlKey ?? deriveProjectUrlKey(project.name, project.id); + // Guard for rolling deploys or legacy data where the server returned a bare slug without UUID suffix. + if (key === normalizeProjectUrlKey(project.name) && hasNonAsciiContent(project.name)) return project.id; + return key; } /** Build a project URL using the short URL key when available. */