Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/src/orchestration-v2/ProjectCommands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const row = (overrides: Partial<ProjectRow> = {}): ProjectRow => ({
workspaceRoot: "/tmp/scripts",
defaultModelSelection: null,
defaultThreadEnvMode: null,
hasCustomTitle: false,
autoPull: false,
faviconPath: null,
projectIcon: null,
Expand Down
64 changes: 64 additions & 0 deletions apps/server/src/orchestration-v2/ProjectStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
}),
);
},
);
16 changes: 14 additions & 2 deletions apps/server/src/orchestration-v2/ProjectStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export class ProjectStoreV2Error extends Schema.TaggedError<ProjectStoreV2Error>
export const ProjectRow = Schema.Struct({
projectId: ProjectId,
title: Schema.String,
hasCustomTitle: Schema.Boolean,
workspaceRoot: Schema.String,
defaultModelSelection: Schema.NullOr(ModelSelection),
defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode),
Expand All @@ -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)),
Expand All @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand All @@ -165,6 +170,7 @@ export const make = Effect.gen(function* () {
VALUES (
${encoded.projectId},
${encoded.title},
${encoded.hasCustomTitle},
${encoded.workspaceRoot},
${encoded.defaultModelSelection},
${encoded.defaultThreadEnvMode},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
? {}
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/orchestration-v2/RuntimePolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ const layerWithPolicy = (policy?: ManagedPolicySummary) =>
workspaceRoot: "/project-root",
defaultModelSelection: null,
defaultThreadEnvMode: null,
hasCustomTitle: false,
autoPull: false,
faviconPath: null,
projectIcon: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ function makeHarness(options: HarnessOptions = {}) {
workspaceRoot: project.workspaceRoot,
defaultModelSelection: project.defaultModelSelection,
defaultThreadEnvMode: null,
hasCustomTitle: false,
autoPull: false,
faviconPath: null,
projectIcon: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ function makeHarness(
workspaceRoot: "/repo",
defaultModelSelection: modelSelection,
defaultThreadEnvMode: null,
hasCustomTitle: false,
autoPull: false,
faviconPath: null,
projectIcon: null,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/persistence/Migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 },
],
);
}),
);
});
Original file line number Diff line number Diff line change
@@ -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
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
WHERE aggregate_kind = 'project'
AND event_type = 'project.meta-updated'
AND json_type(payload_json, '$.title') = 'text'
)
`;
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -175,6 +176,7 @@ describe("V2 preview upgrade", () => {
[84, "LastExecutedRunIndex"],
[85, "TurnItemCounts"],
[86, "QueuedTurnInputIndex"],
[87, "ProjectionProjectsCustomTitle"],
]);
}).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,14 @@ export function scopeProjectSettingsGroup(
}

export function projectGroupTitleNeedsUpdate(
memberTitles: ReadonlyArray<string>,
members: ReadonlyArray<Pick<Project, "title" | "hasCustomTitle">>,
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<Project, "isScratch">) {
Expand Down
10 changes: 1 addition & 9 deletions apps/web/src/components/settings/ProjectSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
};

Expand Down
14 changes: 14 additions & 0 deletions packages/client-runtime/src/state/projectGrouping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<EnvironmentProject>) =>
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");
Expand Down
8 changes: 5 additions & 3 deletions packages/client-runtime/src/state/projectGrouping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,9 @@ export function resolveScratchProjectScopeKey(

export function deriveProjectGroupLabel(input: {
readonly representative: Pick<EnvironmentProject, "title" | "repositoryIdentity">;
readonly members: ReadonlyArray<Pick<EnvironmentProject, "title" | "repositoryIdentity">>;
readonly members: ReadonlyArray<
Pick<EnvironmentProject, "title" | "hasCustomTitle" | "repositoryIdentity">
>;
}): string {
const sharedTitles = uniqueNonEmptyValues(input.members.map((member) => member.title));
const sharedDisplayNames = uniqueNonEmptyValues(
Expand All @@ -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;
}
Expand Down
Loading
Loading