From 4f7116c6acb0abfc766c60470378ffa5a334e78c Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Wed, 28 Oct 2026 12:20:40 +0000 Subject: [PATCH 1/3] fix(web): No project's name and icon can be reset to their built-in defaults Settings had no name reset, and the icon reset cleared the scratch project's icon instead of restoring its built-in one. The name row now offers a reset to "No project" when every selected member is the scratch project, and the icon reset restores each member's own default: the built-in icon for scratch members, automatic for ordinary ones. Co-Authored-By: Claude Opus 5.5 --- .../src/project/ManagedProjectFolders.ts | 3 +- .../ProjectSettingsPanel.browser.test.tsx | 55 +++++++++++++++++++ .../settings/ProjectSettingsPanel.logic.ts | 23 ++++++++ .../settings/ProjectSettingsPanel.tsx | 52 +++++++++++++----- packages/contracts/src/project.ts | 6 ++ 5 files changed, 123 insertions(+), 16 deletions(-) diff --git a/apps/server/src/project/ManagedProjectFolders.ts b/apps/server/src/project/ManagedProjectFolders.ts index df6cb6f6b507e..74fbf4e746a7a 100644 --- a/apps/server/src/project/ManagedProjectFolders.ts +++ b/apps/server/src/project/ManagedProjectFolders.ts @@ -2,6 +2,7 @@ import { CommandId, type GitManagerServiceError, ProjectId, + SCRATCH_PROJECT_ICON, type ThreadId, VcsProcessSpawnError, } from "@t3tools/contracts"; @@ -288,7 +289,7 @@ const make = Effect.gen(function* () { .update({ commandId: CommandId.make(`scratch-project-icon:${id}`), projectId: bootstrapped.project.id, - projectIcon: { kind: "lucide", name: "message-square-dashed", color: "gray" }, + projectIcon: SCRATCH_PROJECT_ICON, }) .pipe(Effect.mapError((cause) => new ScratchProjectError({ workspaceRoot, cause }))); } diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx index 5cf1930d95de4..2cf3ad80bd795 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx @@ -457,6 +457,61 @@ describe("project settings permissions", () => { ); }); +describe("project identity reset", () => { + const scratchIcon = { kind: "lucide", name: "message-square-dashed", color: "gray" } as const; + const resetButton = (screen: Screen, label: string) => + screen.container.querySelector(`[aria-label="Reset ${label} to default"]`); + const asScratch = (project: Project): Project => ({ + ...project, + isScratch: true, + title: "Notes", + workspaceRoot: "/t3/scratch", + projectIcon: { kind: "emoji", emoji: "📝" }, + }); + + it("restores a renamed scratch project's built-in name and icon", async () => { + setup(); + state.projects = [asScratch(state.projects[0]!)]; + await using screen = await mountPanel(); + + await userEvent.click(resetButton(screen, "project name")!); + await vi.waitFor(() => + expect(state.update).toHaveBeenCalledWith({ + environmentId: primaryId, + input: { projectId: state.projects[0]!.id, title: "No project" }, + }), + ); + await userEvent.click(resetButton(screen, "project icon")!); + await vi.waitFor(() => + expect(state.update).toHaveBeenLastCalledWith({ + environmentId: primaryId, + input: { projectId: state.projects[0]!.id, faviconPath: null, projectIcon: scratchIcon }, + }), + ); + }); + + it("resets each member of a mixed group to its own default icon", async () => { + setup(); + const [primary, remote] = state.projects; + state.projects = [asScratch(primary!), { ...remote!, faviconPath: "logo.png" }]; + await using screen = await mountPanel(); + + expect(resetButton(screen, "project name")).toBeNull(); + await userEvent.click(resetButton(screen, "project icon")!); + await vi.waitFor(() => expect(state.update).toHaveBeenCalledTimes(2)); + expect(state.update.mock.calls.map(([request]) => request)).toEqual([ + { + environmentId: primaryId, + input: { projectId: primary!.id, faviconPath: null, projectIcon: scratchIcon }, + }, + { + environmentId: remoteId, + input: { projectId: remote!.id, faviconPath: null, projectIcon: null }, + }, + ]); + }); +}); + describe("project settings scope", () => { const environmentMenu = () => page.getByRole("button", { name: /^Environment scope:/ }); const primaryOption = () => page.getByRole("menuitemradio", { name: /^primary/ }); diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.logic.ts b/apps/web/src/components/settings/ProjectSettingsPanel.logic.ts index 17ff824099fba..71810209f89dd 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.logic.ts +++ b/apps/web/src/components/settings/ProjectSettingsPanel.logic.ts @@ -1,3 +1,9 @@ +import { SCRATCH_PROJECT_ICON } from "@t3tools/contracts"; + +import type { Project } from "../../types"; + +export const SCRATCH_PROJECT_TITLE = "No project"; + export function projectGroupTitleNeedsUpdate( memberTitles: ReadonlyArray, nextTitle: string, @@ -5,3 +11,20 @@ export function projectGroupTitleNeedsUpdate( ): boolean { return wasEdited && memberTitles.some((title) => title !== nextTitle); } + +export function defaultProjectIcon(project: Pick) { + return { faviconPath: null, projectIcon: project.isScratch ? SCRATCH_PROJECT_ICON : null }; +} + +export function hasDefaultProjectIcon( + project: Pick, +): boolean { + if (project.faviconPath != null) return false; + const icon = project.projectIcon ?? null; + if (!project.isScratch) return icon === null; + return ( + icon?.kind === "lucide" && + icon.name === SCRATCH_PROJECT_ICON.name && + icon.color === SCRATCH_PROJECT_ICON.color + ); +} diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index d4095c299e621..ab4d9b3c6dbed 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -46,7 +46,12 @@ import { } from "./ProjectFaviconPickerDialog"; import { ProjectActionsSettings } from "./ProjectActionsSettings"; import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; -import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic"; +import { + defaultProjectIcon, + hasDefaultProjectIcon, + projectGroupTitleNeedsUpdate, + SCRATCH_PROJECT_TITLE, +} from "./ProjectSettingsPanel.logic"; import { useSettingsProjectGroups } from "./useSettingsProjectGroups"; import { readUnloadedThreadCount } from "../../state/olderSettledShelf"; import { settingsScopeEnvironmentLabels } from "./settingsScopeAxis"; @@ -169,6 +174,12 @@ export function ProjectSettingsPanel({ ); } +type ProjectIdentityUpdate = Partial<{ + title: string; + faviconPath: string | null; + projectIcon: ProjectIconOverride | null; +}>; + function ProjectDetail({ group, hasOtherMembers, @@ -243,11 +254,7 @@ function ProjectDetail({ }; const updateAllMembers = async ( - input: Partial<{ - title: string; - faviconPath: string | null; - projectIcon: ProjectIconOverride | null; - }>, + input: ProjectIdentityUpdate | ((member: SidebarProjectGroupMember) => ProjectIdentityUpdate), failureTitle: string, ): Promise> => { const denied = checkProjectAccess(group.memberProjects, failureTitle); @@ -270,7 +277,10 @@ function ProjectDetail({ const result = mapAtomCommandResult( await updateProject({ environmentId: member.environmentId, - input: { projectId: member.id, ...input }, + input: { + projectId: member.id, + ...(typeof input === "function" ? input(member) : input), + }, }), () => undefined, ); @@ -309,10 +319,7 @@ function ProjectDetail({ const [iconPickerOpen, setIconPickerOpen] = useComposerMenuState(!canEditGroup); const [isSavingFavicon, setIsSavingFavicon] = useState(false); const savingFaviconRef = useRef(false); - const setProjectIcon = async (input: { - faviconPath: string | null; - projectIcon: ProjectIconOverride | null; - }) => { + const setProjectIcon = async (input: Parameters[0]) => { if (savingFaviconRef.current) return; savingFaviconRef.current = true; setIsSavingFavicon(true); @@ -322,6 +329,9 @@ function ProjectDetail({ }; const hasMultipleCheckouts = group.memberProjects.length > 1; + const canResetScratchTitle = + group.memberProjects.every((member) => member.isScratch) && + group.memberProjects.some((member) => member.title !== SCRATCH_PROJECT_TITLE); const removeMembers = async (members: ReadonlyArray) => { if (checkProjectAccess(members, "Failed to remove project")) return; @@ -489,6 +499,20 @@ function ProjectDetail({ + void updateAllMembers( + { title: SCRATCH_PROJECT_TITLE }, + "Failed to rename project", + ) + } + /> + ) : null + } control={ member.faviconPath != null || member.projectIcon != null, - ) ? ( + group.memberProjects.some((member) => !hasDefaultProjectIcon(member)) ? ( void setProjectIcon({ faviconPath: null, projectIcon: null })} + onClick={() => void setProjectIcon(defaultProjectIcon)} /> ) : null } diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 79e164b71e93b..0a25f29ce932e 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -106,6 +106,12 @@ export const ProjectIconOverride = Schema.Union([ ]); export type ProjectIconOverride = typeof ProjectIconOverride.Type; +export const SCRATCH_PROJECT_ICON = { + kind: "lucide", + name: "message-square-dashed", + color: "gray", +} as const satisfies ProjectIconOverride; + const ProjectLucideIconWithLegacyMonogram = Schema.Struct({ ...ProjectLucideIcon.fields, monogramText: Schema.optional(ProjectMonogramText), From 01d39fa184cd65e2b5a8c2a1e08fafe0179daef6 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Wed, 28 Oct 2026 12:21:53 +0000 Subject: [PATCH 2/3] refactor(web): keep scratch reset defaults in project settings --- apps/server/src/project/ManagedProjectFolders.ts | 3 +-- .../src/components/settings/ProjectSettingsPanel.logic.ts | 7 ++++++- packages/contracts/src/project.ts | 6 ------ 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/server/src/project/ManagedProjectFolders.ts b/apps/server/src/project/ManagedProjectFolders.ts index 74fbf4e746a7a..df6cb6f6b507e 100644 --- a/apps/server/src/project/ManagedProjectFolders.ts +++ b/apps/server/src/project/ManagedProjectFolders.ts @@ -2,7 +2,6 @@ import { CommandId, type GitManagerServiceError, ProjectId, - SCRATCH_PROJECT_ICON, type ThreadId, VcsProcessSpawnError, } from "@t3tools/contracts"; @@ -289,7 +288,7 @@ const make = Effect.gen(function* () { .update({ commandId: CommandId.make(`scratch-project-icon:${id}`), projectId: bootstrapped.project.id, - projectIcon: SCRATCH_PROJECT_ICON, + projectIcon: { kind: "lucide", name: "message-square-dashed", color: "gray" }, }) .pipe(Effect.mapError((cause) => new ScratchProjectError({ workspaceRoot, cause }))); } diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.logic.ts b/apps/web/src/components/settings/ProjectSettingsPanel.logic.ts index 71810209f89dd..0c4650978c92f 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.logic.ts +++ b/apps/web/src/components/settings/ProjectSettingsPanel.logic.ts @@ -1,8 +1,13 @@ -import { SCRATCH_PROJECT_ICON } from "@t3tools/contracts"; +import type { ProjectIconOverride } from "@t3tools/contracts"; import type { Project } from "../../types"; export const SCRATCH_PROJECT_TITLE = "No project"; +const SCRATCH_PROJECT_ICON = { + kind: "lucide", + name: "message-square-dashed", + color: "gray", +} as const satisfies ProjectIconOverride; export function projectGroupTitleNeedsUpdate( memberTitles: ReadonlyArray, diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 0a25f29ce932e..79e164b71e93b 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -106,12 +106,6 @@ export const ProjectIconOverride = Schema.Union([ ]); export type ProjectIconOverride = typeof ProjectIconOverride.Type; -export const SCRATCH_PROJECT_ICON = { - kind: "lucide", - name: "message-square-dashed", - color: "gray", -} as const satisfies ProjectIconOverride; - const ProjectLucideIconWithLegacyMonogram = Schema.Struct({ ...ProjectLucideIcon.fields, monogramText: Schema.optional(ProjectMonogramText), From bf5c332be3a5d3191d39795c5364fa3a426fc915 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Wed, 28 Oct 2026 12:28:24 +0000 Subject: [PATCH 3/3] test(web): cover resetting a missing scratch icon --- .../components/settings/ProjectSettingsPanel.browser.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx index 2cf3ad80bd795..ad8777ca52ae5 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx @@ -471,7 +471,7 @@ describe("project identity reset", () => { it("restores a renamed scratch project's built-in name and icon", async () => { setup(); - state.projects = [asScratch(state.projects[0]!)]; + state.projects = [{ ...asScratch(state.projects[0]!), faviconPath: null, projectIcon: null }]; await using screen = await mountPanel(); await userEvent.click(resetButton(screen, "project name")!);