diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 50cf9c318040..85ee0c938899 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -45,7 +45,13 @@ import { import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts"; import { shortcutLabelForCommand } from "../../keybindings"; +import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { keybindingValueForCommand } from "../../lib/projectScriptKeybindings"; +import { + hasArchivedThreadSnapshotFailure, + projectDeleteCommandInput, + projectThreadCount, +} from "../../lib/projectRemoval"; import { readLocalApi } from "../../localApi"; import { buildProjectScript, @@ -308,6 +314,24 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const serverProviders = useAtomValue(primaryServerProvidersAtom); const threads = useThreadShells(); + const groupEnvironmentIds = useMemo( + () => [...new Set(group.memberProjects.map((member) => member.environmentId))], + [group.memberProjects], + ); + const { + snapshots: archivedThreadSnapshots, + error: archivedThreadsError, + failedEnvironmentIds: archivedThreadsFailedEnvironmentIds, + isLoading: archivedThreadsLoading, + } = useArchivedThreadSnapshots(groupEnvironmentIds); + const archivedThreads = useMemo( + () => + archivedThreadSnapshots.flatMap(({ environmentId, snapshot }) => + snapshot.threads.map((thread) => ({ ...thread, environmentId })), + ), + [archivedThreadSnapshots], + ); + const knownThreads = useMemo(() => [...threads, ...archivedThreads], [archivedThreads, threads]); const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { @@ -339,12 +363,12 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const threadCountByMember = useMemo(() => { const counts = new Map(); - for (const thread of threads) { + for (const thread of knownThreads) { const key = `${thread.environmentId}:${thread.projectId}`; counts.set(key, (counts.get(key) ?? 0) + 1); } return counts; - }, [threads]); + }, [knownThreads]); const reportFailure = useCallback((title: string, result: AtomCommandResult) => { if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; const error = squashAtomCommandFailure(result); @@ -671,8 +695,22 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const api = readLocalApi(); if (!api) return; + if ( + archivedThreadsError && + hasArchivedThreadSnapshotFailure(members, archivedThreadsFailedEnvironmentIds) + ) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not check archived threads", + description: archivedThreadsError, + }), + ); + return; + } + const memberKeys = new Set(members.map(memberKey)); - const projectThreads = threads.filter((thread) => + const projectThreads = knownThreads.filter((thread) => memberKeys.has(`${thread.environmentId}:${thread.projectId}`), ); const isWholeGroup = members.length === group.memberProjects.length; @@ -681,9 +719,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const confirmed = await settlePromise(() => api.dialogs.confirm( [ - projectThreads.length > 0 - ? `Remove project "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?` - : `Remove project "${targetLabel}"?`, + `Remove "${targetLabel}" and permanently delete every thread attached to ${members.length === 1 ? "this project entry" : "these project entries"}, including archived threads?`, ...(singleMember ? [ `Path: ${singleMember.workspaceRoot}`, @@ -693,8 +729,11 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { ] : [`This removes ${members.length} grouped project entries.`]), ...(projectThreads.length > 0 - ? ["This permanently clears conversation history for those threads."] + ? [ + `This includes ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}.`, + ] : []), + "This permanently clears all associated conversation history.", isWholeGroup ? "This removes only the project entries, not the files on disk." : "Other entries in this grouped project are unaffected.", @@ -707,17 +746,11 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const draftStore = useComposerDraftStore.getState(); for (const member of members) { - const memberThreads = projectThreads.filter( - (thread) => - thread.environmentId === member.environmentId && thread.projectId === member.id, - ); + const memberThreadCount = projectThreadCount(member, projectThreads); const result = mapAtomCommandResult( await deleteProject({ environmentId: member.environmentId, - input: { - projectId: member.id, - ...(memberThreads.length > 0 ? { force: true } : {}), - }, + input: projectDeleteCommandInput(member.id, memberThreadCount), }), () => undefined, ); @@ -740,12 +773,14 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { } }, [ + archivedThreadsError, + archivedThreadsFailedEnvironmentIds, deleteProject, group.displayName, group.memberProjects.length, + knownThreads, navigate, reportFailure, - threads, ], ); @@ -1009,6 +1044,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { } /> diff --git a/apps/web/src/lib/archivedThreadsState.ts b/apps/web/src/lib/archivedThreadsState.ts index 2d52383c02c9..acb083161d72 100644 --- a/apps/web/src/lib/archivedThreadsState.ts +++ b/apps/web/src/lib/archivedThreadsState.ts @@ -29,6 +29,7 @@ export function refreshArchivedThreadsForEnvironment(environmentId: EnvironmentI export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray): { readonly snapshots: ReadonlyArray; readonly error: string | null; + readonly failedEnvironmentIds: ReadonlyArray; readonly isLoading: boolean; readonly refresh: () => void; } { diff --git a/apps/web/src/lib/projectRemoval.test.ts b/apps/web/src/lib/projectRemoval.test.ts new file mode 100644 index 000000000000..9ed7a24818ab --- /dev/null +++ b/apps/web/src/lib/projectRemoval.test.ts @@ -0,0 +1,68 @@ +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + hasArchivedThreadSnapshotFailure, + projectDeleteCommandInput, + projectThreadCount, +} from "./projectRemoval"; + +describe("hasArchivedThreadSnapshotFailure", () => { + it("only reports failures for environments being removed", () => { + const environmentOne = EnvironmentId.make("environment-1"); + const environmentTwo = EnvironmentId.make("environment-2"); + const failedEnvironmentIds = [environmentTwo]; + + expect([ + hasArchivedThreadSnapshotFailure([{ environmentId: environmentOne }], failedEnvironmentIds), + hasArchivedThreadSnapshotFailure([{ environmentId: environmentTwo }], failedEnvironmentIds), + ]).toEqual([false, true]); + }); +}); + +describe("projectDeleteCommandInput", () => { + it("force-deletes threads even when only archived threads exist", () => { + const project = { + environmentId: EnvironmentId.make("environment-1"), + id: ProjectId.make("project-1"), + }; + const archivedThreads = [ + { + environmentId: project.environmentId, + projectId: project.id, + }, + ]; + + expect( + projectDeleteCommandInput(project.id, projectThreadCount(project, archivedThreads)), + ).toEqual({ + projectId: "project-1", + force: true, + }); + }); + + it("does not force an empty project when the same project id has threads in another environment", () => { + const projectId = ProjectId.make("shared-project-id"); + const environmentOneProject = { + environmentId: EnvironmentId.make("environment-1"), + id: projectId, + }; + const environmentTwoProject = { + environmentId: EnvironmentId.make("environment-2"), + id: projectId, + }; + const threads = [ + { + environmentId: environmentTwoProject.environmentId, + projectId, + }, + ]; + + expect( + projectDeleteCommandInput(projectId, projectThreadCount(environmentOneProject, threads)), + ).toEqual({ projectId }); + expect( + projectDeleteCommandInput(projectId, projectThreadCount(environmentTwoProject, threads)), + ).toEqual({ projectId, force: true }); + }); +}); diff --git a/apps/web/src/lib/projectRemoval.ts b/apps/web/src/lib/projectRemoval.ts new file mode 100644 index 000000000000..efa263fa963c --- /dev/null +++ b/apps/web/src/lib/projectRemoval.ts @@ -0,0 +1,44 @@ +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; + +interface ProjectRemovalTarget { + readonly environmentId: EnvironmentId; + readonly id: ProjectId; +} + +interface ProjectRemovalThread { + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; +} + +export function hasArchivedThreadSnapshotFailure( + projects: ReadonlyArray<{ readonly environmentId: EnvironmentId }>, + failedEnvironmentIds: ReadonlyArray, +): boolean { + return projects.some((project) => failedEnvironmentIds.includes(project.environmentId)); +} + +export function projectThreadCount( + project: ProjectRemovalTarget, + threads: ReadonlyArray, +): number { + let count = 0; + for (const thread of threads) { + if (thread.environmentId === project.environmentId && thread.projectId === project.id) { + count += 1; + } + } + return count; +} + +export function projectDeleteCommandInput( + projectId: ProjectId, + threadCount: number, +): { + readonly projectId: ProjectId; + readonly force?: true; +} { + return { + projectId, + ...(threadCount > 0 ? { force: true as const } : {}), + }; +} diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 56675408fab8..087dfdd84532 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -1,4 +1,6 @@ -# Customize a project icon +# Project settings + +## Customize a project icon T3 Code selects a project icon automatically. It checks `t3.json`, common favicon and app icon paths, and icon links in project HTML files. @@ -14,3 +16,12 @@ T3 Code supports SVG, PNG, ICO, JPEG, GIF, AVIF, and WebP files. The selected pa each checkout in the project group and appears on your connected clients. To use automatic detection again, select **Automatic**. + +## Remove a project + +1. Open the project settings. +2. Under **Danger**, select **Remove project**. +3. Confirm the removal. + +Removing a project deletes its T3 Code entry and all associated conversation history, including +archived threads. Files in the project's workspace remain on disk. diff --git a/packages/client-runtime/src/state/archivedThreads.test.ts b/packages/client-runtime/src/state/archivedThreads.test.ts index aa16b9cadcd7..3cbe5dc10577 100644 --- a/packages/client-runtime/src/state/archivedThreads.test.ts +++ b/packages/client-runtime/src/state/archivedThreads.test.ts @@ -33,6 +33,7 @@ it("does not expose an archived snapshot failure message", () => { expect(registry.get(snapshotsAtom(makeArchivedThreadsEnvironmentKey([environmentId])))).toEqual({ snapshots: [], error: "Failed to load archived threads.", + failedEnvironmentIds: [environmentId], isLoading: false, }); diff --git a/packages/client-runtime/src/state/archivedThreads.ts b/packages/client-runtime/src/state/archivedThreads.ts index 8c64f1ae506d..726db7954089 100644 --- a/packages/client-runtime/src/state/archivedThreads.ts +++ b/packages/client-runtime/src/state/archivedThreads.ts @@ -13,6 +13,7 @@ export interface ArchivedSnapshotEntry { export interface ArchivedThreadSnapshotsState { readonly snapshots: ReadonlyArray; readonly error: string | null; + readonly failedEnvironmentIds: ReadonlyArray; readonly isLoading: boolean; } @@ -46,6 +47,7 @@ export function createArchivedThreadSnapshotsAtomFamily(options: { return Atom.family((environmentKey: string) => Atom.make((get): ArchivedThreadSnapshotsState => { const snapshots: ArchivedSnapshotEntry[] = []; + const failedEnvironmentIds: EnvironmentId[] = []; let error: string | null = null; let isLoading = false; @@ -58,12 +60,13 @@ export function createArchivedThreadSnapshotsAtomFamily(options: { snapshots.push({ environmentId, snapshot }); } - if (error === null && result._tag === "Failure") { - error = "Failed to load archived threads."; + if (result._tag === "Failure") { + failedEnvironmentIds.push(environmentId); + error ??= "Failed to load archived threads."; } } - return { snapshots, error, isLoading }; + return { snapshots, error, failedEnvironmentIds, isLoading }; }).pipe(Atom.withLabel(`${options.labelPrefix}:${environmentKey}`)), ); }