diff --git a/apps/server/src/orchestration-v2/ProjectCommands.test.ts b/apps/server/src/orchestration-v2/ProjectCommands.test.ts index 96249ece4c106..85cf6d08efc18 100644 --- a/apps/server/src/orchestration-v2/ProjectCommands.test.ts +++ b/apps/server/src/orchestration-v2/ProjectCommands.test.ts @@ -19,6 +19,7 @@ const row = (overrides: Partial = {}): ProjectRow => ({ workspaceRoot: "/tmp/scripts", defaultModelSelection: null, defaultThreadEnvMode: null, + hasCustomTitle: false, autoPull: false, faviconPath: null, projectIcon: null, diff --git a/apps/server/src/orchestration-v2/ProjectStore.test.ts b/apps/server/src/orchestration-v2/ProjectStore.test.ts index 3eeabb6f89a83..322ae0641758c 100644 --- a/apps/server/src/orchestration-v2/ProjectStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectStore.test.ts @@ -96,5 +96,69 @@ it.layer(ProjectStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemor assert.strictEqual(project?.archivedAt, at); }), ); + + it.effect("marks a project's title as custom only once it is renamed", () => + Effect.gen(function* () { + const projects = yield* ProjectStore.ProjectStoreV2; + const projectId = ProjectId.make("project-renamed"); + const event = { + aggregateKind: "project", + aggregateId: projectId, + occurredAt: "2026-03-24T00:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + } as const; + const hasCustomTitle = () => + projects + .getShell(projectId) + .pipe(Effect.map((shell) => Option.getOrNull(shell)?.hasCustomTitle)); + yield* projects.apply({ + ...event, + sequence: 1, + eventId: EventId.make("event-renamed-created"), + type: "project.created", + payload: { + projectId, + title: "repo", + workspaceRoot: "/tmp/repo", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-03-24T00:00:00.000Z", + updatedAt: "2026-03-24T00:00:00.000Z", + }, + }); + assert.strictEqual(yield* hasCustomTitle(), false); + yield* projects.apply({ + ...event, + sequence: 2, + eventId: EventId.make("event-renamed-title"), + type: "project.meta-updated", + payload: { projectId, title: "custom repo", updatedAt: "2026-03-24T00:00:02.000Z" }, + }); + assert.strictEqual(yield* hasCustomTitle(), true); + yield* projects.apply({ + ...event, + sequence: 3, + eventId: EventId.make("migration:39:project:project-renamed:baseline"), + type: "project.created", + payload: { + projectId, + title: "repo", + workspaceRoot: "/tmp/repo", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-03-24T00:00:00.000Z", + updatedAt: "2026-03-24T00:00:03.000Z", + }, + }); + assert.strictEqual(yield* hasCustomTitle(), true); + assert.strictEqual( + Option.getOrNull(yield* projects.getShell(projectId))?.title, + "custom repo", + ); + }), + ); }, ); diff --git a/apps/server/src/orchestration-v2/ProjectStore.ts b/apps/server/src/orchestration-v2/ProjectStore.ts index 2be0a765c784f..af39d565109f4 100644 --- a/apps/server/src/orchestration-v2/ProjectStore.ts +++ b/apps/server/src/orchestration-v2/ProjectStore.ts @@ -32,6 +32,7 @@ export class ProjectStoreV2Error extends Schema.TaggedError export const ProjectRow = Schema.Struct({ projectId: ProjectId, title: Schema.String, + hasCustomTitle: Schema.Boolean, workspaceRoot: Schema.String, defaultModelSelection: Schema.NullOr(ModelSelection), defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode), @@ -50,6 +51,7 @@ export type ProjectRow = typeof ProjectRow.Type; const ProjectDbRow = Schema.Struct({ ...ProjectRow.fields, defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), + hasCustomTitle: Schema.BooleanFromBit, autoPull: Schema.BooleanFromBit, projectIcon: Schema.NullOr(Schema.fromJsonString(StoredProjectIcon)), scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), @@ -59,6 +61,7 @@ function toShell(row: ProjectRow): OrchestrationProjectShell { return { id: row.projectId, title: row.title, + hasCustomTitle: row.hasCustomTitle, workspaceRoot: row.workspaceRoot, repositoryIdentity: null, defaultModelSelection: row.defaultModelSelection, @@ -117,6 +120,7 @@ export const make = Effect.gen(function* () { SELECT project_id AS "projectId", title, + has_custom_title AS "hasCustomTitle", workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", default_thread_env_mode AS "defaultThreadEnvMode", @@ -149,6 +153,7 @@ export const make = Effect.gen(function* () { INSERT INTO projection_projects ( project_id, title, + has_custom_title, workspace_root, default_model_selection_json, default_thread_env_mode, @@ -165,6 +170,7 @@ export const make = Effect.gen(function* () { VALUES ( ${encoded.projectId}, ${encoded.title}, + ${encoded.hasCustomTitle}, ${encoded.workspaceRoot}, ${encoded.defaultModelSelection}, ${encoded.defaultThreadEnvMode}, @@ -180,7 +186,12 @@ export const make = Effect.gen(function* () { ) ON CONFLICT (project_id) DO UPDATE SET - title = excluded.title, + title = CASE + WHEN projection_projects.has_custom_title = 1 AND excluded.has_custom_title = 0 + THEN projection_projects.title + ELSE excluded.title + END, + has_custom_title = MAX(projection_projects.has_custom_title, excluded.has_custom_title), workspace_root = excluded.workspace_root, default_model_selection_json = excluded.default_model_selection_json, default_thread_env_mode = excluded.default_thread_env_mode, @@ -231,6 +242,7 @@ export const make = Effect.gen(function* () { return yield* upsertRow({ projectId: payload.projectId, title: payload.title, + hasCustomTitle: false, workspaceRoot: payload.workspaceRoot, defaultModelSelection: payload.defaultModelSelection, defaultThreadEnvMode: payload.defaultThreadEnvMode ?? null, @@ -258,7 +270,7 @@ export const make = Effect.gen(function* () { const payload = event.payload; yield* upsertRow({ ...row, - ...(payload.title === undefined ? {} : { title: payload.title }), + ...(payload.title === undefined ? {} : { title: payload.title, hasCustomTitle: true }), ...(payload.workspaceRoot === undefined ? {} : { workspaceRoot: payload.workspaceRoot }), ...(payload.defaultModelSelection === undefined ? {} diff --git a/apps/server/src/orchestration-v2/RuntimePolicy.test.ts b/apps/server/src/orchestration-v2/RuntimePolicy.test.ts index ec610b093f7d3..6f9fbe53d911a 100644 --- a/apps/server/src/orchestration-v2/RuntimePolicy.test.ts +++ b/apps/server/src/orchestration-v2/RuntimePolicy.test.ts @@ -99,6 +99,7 @@ const layerWithPolicy = (policy?: ManagedPolicySummary) => workspaceRoot: "/project-root", defaultModelSelection: null, defaultThreadEnvMode: null, + hasCustomTitle: false, autoPull: false, faviconPath: null, projectIcon: null, diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 64acad6ecf794..2505a2b3813fa 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -257,6 +257,7 @@ function makeHarness(options: HarnessOptions = {}) { workspaceRoot: project.workspaceRoot, defaultModelSelection: project.defaultModelSelection, defaultThreadEnvMode: null, + hasCustomTitle: false, autoPull: false, faviconPath: null, projectIcon: null, diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts index 4ccdcc6e38a57..d56256372f73e 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts @@ -81,6 +81,7 @@ function makeHarness( workspaceRoot: "/repo", defaultModelSelection: modelSelection, defaultThreadEnvMode: null, + hasCustomTitle: false, autoPull: false, faviconPath: null, projectIcon: null, diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 9b6e2b3e0fc76..f54cbe8268f3f 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -89,6 +89,7 @@ import Migration0083 from "./Migrations/083_CommandReadIndexes.ts"; import Migration0084 from "./Migrations/084_LastExecutedRunIndex.ts"; import Migration0085 from "./Migrations/085_TurnItemCounts.ts"; import Migration0086 from "./Migrations/086_QueuedTurnInputIndex.ts"; +import Migration0087 from "./Migrations/087_ProjectionProjectsCustomTitle.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -178,6 +179,7 @@ const migrationEntries = [ [84, "LastExecutedRunIndex", Migration0084], [85, "TurnItemCounts", Migration0085], [86, "QueuedTurnInputIndex", Migration0086], + [87, "ProjectionProjectsCustomTitle", Migration0087], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/087_ProjectionProjectsCustomTitle.test.ts b/apps/server/src/persistence/Migrations/087_ProjectionProjectsCustomTitle.test.ts new file mode 100644 index 0000000000000..ca3d887a6cd13 --- /dev/null +++ b/apps/server/src/persistence/Migrations/087_ProjectionProjectsCustomTitle.test.ts @@ -0,0 +1,41 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; + +const layer = it.layer(NodeSqliteClient.layer({ filename: ":memory:" })); + +layer("087_ProjectionProjectsCustomTitle", (it) => { + it.effect("marks projects with a recorded title update as renamed", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 86 }); + for (const projectId of ["renamed", "untouched"]) { + yield* sql` + INSERT INTO projection_projects + (project_id, title, workspace_root, scripts_json, created_at, updated_at) + VALUES (${projectId}, 'repo', ${"/tmp/" + projectId}, '[]', '2026-10-01', '2026-10-01') + `; + yield* sql` + INSERT INTO orchestration_events + (event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at, + actor_kind, payload_json, metadata_json, application_event_version) + VALUES (${projectId}, 'project', ${projectId}, 1, 'project.meta-updated', '2026-10-01', + 'client', ${JSON.stringify(projectId === "renamed" ? { title: "repo" } : { autoPull: true })}, '{}', 2) + `; + } + // Builds of this change before renumbering already added the column. + yield* sql`ALTER TABLE projection_projects ADD COLUMN has_custom_title INTEGER NOT NULL DEFAULT 0`; + yield* runMigrations(); + assert.deepStrictEqual( + yield* sql`SELECT project_id, has_custom_title FROM projection_projects ORDER BY project_id`, + [ + { project_id: "renamed", has_custom_title: 1 }, + { project_id: "untouched", has_custom_title: 0 }, + ], + ); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/087_ProjectionProjectsCustomTitle.ts b/apps/server/src/persistence/Migrations/087_ProjectionProjectsCustomTitle.ts new file mode 100644 index 0000000000000..c193137e79c3b --- /dev/null +++ b/apps/server/src/persistence/Migrations/087_ProjectionProjectsCustomTitle.ts @@ -0,0 +1,26 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + // Earlier builds of this change shipped the column under another migration id. + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + if (!columns.some((column) => column.name === "has_custom_title")) { + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN has_custom_title INTEGER NOT NULL DEFAULT 0 + `; + } + yield* sql` + UPDATE projection_projects + SET has_custom_title = 1 + WHERE project_id IN ( + SELECT stream_id FROM orchestration_events + WHERE aggregate_kind = 'project' + AND event_type = 'project.meta-updated' + AND json_type(payload_json, '$.title') = 'text' + ) + `; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 98a79e7a7370d..e046cc4133dc6 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -66,6 +66,7 @@ describe("V2 preview upgrade", () => { [84, "LastExecutedRunIndex"], [85, "TurnItemCounts"], [86, "QueuedTurnInputIndex"], + [87, "ProjectionProjectsCustomTitle"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -175,6 +176,7 @@ describe("V2 preview upgrade", () => { [84, "LastExecutedRunIndex"], [85, "TurnItemCounts"], [86, "QueuedTurnInputIndex"], + [87, "ProjectionProjectsCustomTitle"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.logic.test.ts b/apps/web/src/components/settings/ProjectSettingsPanel.logic.test.ts index 41e1688d2fe98..c42c375a5e154 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.logic.test.ts +++ b/apps/web/src/components/settings/ProjectSettingsPanel.logic.test.ts @@ -4,7 +4,10 @@ import { planProjectRemoval } from "@t3tools/client-runtime/state/projects"; import { buildSidebarProjectSnapshots } from "../../sidebarProjectGrouping"; import type { Project } from "../../types"; -import { scopeProjectSettingsGroup } from "./ProjectSettingsPanel.logic"; +import { + projectGroupTitleNeedsUpdate, + scopeProjectSettingsGroup, +} from "./ProjectSettingsPanel.logic"; const localEnvironmentId = EnvironmentId.make("local"); const remoteEnvironmentId = EnvironmentId.make("remote"); @@ -171,3 +174,20 @@ describe("project removal confirmation", () => { ); }); }); + +describe("projectGroupTitleNeedsUpdate", () => { + const folderTitle = { title: "repo" }; + const renamed = { title: "repo", hasCustomTitle: true }; + + // regression: #15049 renaming to the default folder title was never saved + it("saves a title that matches the folder default so the rename is recorded", () => { + expect(projectGroupTitleNeedsUpdate([folderTitle, folderTitle], "repo", true)).toBe(true); + }); + + it("skips untouched fields and renames every member already carries", () => { + expect(projectGroupTitleNeedsUpdate([folderTitle, { title: "other" }], "repo", false)).toBe( + false, + ); + expect(projectGroupTitleNeedsUpdate([renamed, renamed], "repo", true)).toBe(false); + }); +}); diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.logic.ts b/apps/web/src/components/settings/ProjectSettingsPanel.logic.ts index 0c93b87ae162f..706162be00457 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.logic.ts +++ b/apps/web/src/components/settings/ProjectSettingsPanel.logic.ts @@ -25,11 +25,14 @@ export function scopeProjectSettingsGroup( } export function projectGroupTitleNeedsUpdate( - memberTitles: ReadonlyArray, + members: ReadonlyArray>, nextTitle: string, wasEdited: boolean, ): boolean { - return wasEdited && memberTitles.some((title) => title !== nextTitle); + return ( + wasEdited && + members.some((member) => member.title !== nextTitle || member.hasCustomTitle !== true) + ); } export function defaultProjectIcon(project: Pick) { diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index f8fbcdc9e59b4..c1e15267234b3 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -311,15 +311,7 @@ function ProjectDetail({ toastManager.add({ type: "warning", title: "Project title cannot be empty" }); return; } - if ( - !projectGroupTitleNeedsUpdate( - group.memberProjects.map((member) => member.title), - title, - wasEdited, - ) - ) { - return; - } + if (!projectGroupTitleNeedsUpdate(group.memberProjects, title, wasEdited)) return; await updateAllMembers({ title }, "Failed to rename project"); }; diff --git a/packages/client-runtime/src/state/projectGrouping.test.ts b/packages/client-runtime/src/state/projectGrouping.test.ts index a5957d06e1b01..f4edba2b8d456 100644 --- a/packages/client-runtime/src/state/projectGrouping.test.ts +++ b/packages/client-runtime/src/state/projectGrouping.test.ts @@ -289,6 +289,20 @@ describe("buildProjectGroups", () => { expect(groups[1]?.label).toBe("julius/t3code-fork"); }); + // regression: #15049 a rename to the repository name snapped back to the display path + it("labels a renamed group by its title even when it matches the repository name", () => { + const folderTitles = [ + makeProject("t3code", "/work/t3code", { title: "t3code" }), + makeProject("t3code-2", "/work/t3code-2", { title: "t3code" }), + ]; + const renamed = folderTitles.map((project) => ({ ...project, hasCustomTitle: true })); + + const label = (projects: ReadonlyArray) => + buildProjectGroups({ projects, settings: settings("repository") })[0]?.label; + expect(label(folderTitles)).toBe("T3 Code"); + expect(label(renamed)).toBe("t3code"); + }); + it("applies a physical-project override without dropping its siblings", () => { const first = makeProject("t3code", "/work/t3code"); const second = makeProject("t3code-2", "/work/t3code-2"); diff --git a/packages/client-runtime/src/state/projectGrouping.ts b/packages/client-runtime/src/state/projectGrouping.ts index 46fdc4e3dd3ae..c05a9724ce207 100644 --- a/packages/client-runtime/src/state/projectGrouping.ts +++ b/packages/client-runtime/src/state/projectGrouping.ts @@ -200,7 +200,9 @@ export function resolveScratchProjectScopeKey( export function deriveProjectGroupLabel(input: { readonly representative: Pick; - readonly members: ReadonlyArray>; + readonly members: ReadonlyArray< + Pick + >; }): string { const sharedTitles = uniqueNonEmptyValues(input.members.map((member) => member.title)); const sharedDisplayNames = uniqueNonEmptyValues( @@ -215,8 +217,8 @@ export function deriveProjectGroupLabel(input: { if ( sharedTitles.length === 1 && sharedTitle !== undefined && - !sharedDisplayNames.includes(sharedTitle) && - !sharedRepositoryNames.includes(sharedTitle) + (input.members.some((member) => member.hasCustomTitle === true) || + (!sharedDisplayNames.includes(sharedTitle) && !sharedRepositoryNames.includes(sharedTitle))) ) { return sharedTitle; } diff --git a/packages/contracts/src/orchestrationProject.ts b/packages/contracts/src/orchestrationProject.ts index d98c50e56e8d1..35cee805b8812 100644 --- a/packages/contracts/src/orchestrationProject.ts +++ b/packages/contracts/src/orchestrationProject.ts @@ -8,6 +8,9 @@ import { ProjectScript, ReceivedProjectIcon } from "./project.ts"; export const OrchestrationProjectShell = Schema.Struct({ id: ProjectId, title: TrimmedNonEmptyString, + // Set once someone renames the project. A folder-default title can equal the + // repository name, so grouped labels need this to tell a chosen name apart. + hasCustomTitle: Schema.optional(Schema.Boolean), workspaceRoot: TrimmedNonEmptyString, repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection),