From 235a305cd848ea2b71e8f1058aa16a709fcb11e5 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Tue, 11 Aug 2026 23:26:10 +0200 Subject: [PATCH 1/4] fix(web): remove projects with archived threads Archived threads are omitted from the live shell snapshot, so project removal incorrectly skipped force deletion when no unarchived threads were visible. Always force confirmed project deletion and make the destructive copy explicit. --- apps/web/src/components/LegacySidebar.tsx | 24 ++++++++--------- .../settings/ProjectSettingsPanel.tsx | 27 ++++++++----------- apps/web/src/lib/projectRemoval.test.ts | 13 +++++++++ apps/web/src/lib/projectRemoval.ts | 9 +++++++ docs/user/project-settings.md | 13 ++++++++- 5 files changed, 57 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/lib/projectRemoval.test.ts create mode 100644 apps/web/src/lib/projectRemoval.ts diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 916e3214ac16..09decea52460 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -74,6 +74,7 @@ import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { isElectron } from "../env"; import { useOpenPrLink } from "../lib/openPullRequestLink"; +import { projectDeleteCommandInput } from "../lib/projectRemoval"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isMacPlatform } from "../lib/utils"; import { @@ -1444,14 +1445,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ); const removeProject = useCallback( - async (member: SidebarProjectGroupMember, options: { force?: boolean } = {}) => { + async (member: SidebarProjectGroupMember) => { const memberProjectRef = scopeProjectRef(member.environmentId, member.id); const result = await deleteProject({ environmentId: member.environmentId, - input: { - projectId: member.id, - ...(options.force === true ? { force: true } : {}), - }, + input: projectDeleteCommandInput(member.id), }); if (result._tag === "Failure") { return result; @@ -1502,24 +1500,25 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const confirmed = await api.dialogs.confirm( latestProjectThreads.length > 0 ? [ - `Remove project "${member.title}" and delete its ${latestProjectThreads.length} thread${ - latestProjectThreads.length === 1 ? "" : "s" - }?`, + `Remove project "${member.title}" and permanently delete every thread attached to it, including archived threads?`, `Path: ${member.workspaceRoot}`, ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []), - "This permanently clears conversation history for those threads.", + `This includes ${latestProjectThreads.length} unarchived thread${ + latestProjectThreads.length === 1 ? "" : "s" + } and permanently clears their conversation history.`, "This removes only this project entry.", "This action cannot be undone.", ].join("\n") : [ - `Remove project "${member.title}"?`, + `Remove project "${member.title}" and permanently delete every thread attached to it, including archived threads?`, `Path: ${member.workspaceRoot}`, ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []), "This removes only this project entry.", + "This action cannot be undone.", ].join("\n"), { variant: "destructive" }, ); @@ -1527,7 +1526,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return; } - const result = await removeProject(member, { force: true }); + const result = await removeProject(member); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( @@ -1565,10 +1564,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } const message = [ - `Remove project "${member.title}"?`, + `Remove project "${member.title}" and permanently delete every thread attached to it, including archived threads?`, `Path: ${member.workspaceRoot}`, ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []), "This removes only this project entry.", + "This action cannot be undone.", ].join("\n"); const confirmed = await api.dialogs.confirm(message, { variant: "destructive" }); if (!confirmed) { diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 50cf9c318040..e5c3e9f88190 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -46,6 +46,7 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts"; import { shortcutLabelForCommand } from "../../keybindings"; import { keybindingValueForCommand } from "../../lib/projectScriptKeybindings"; +import { projectDeleteCommandInput } from "../../lib/projectRemoval"; import { readLocalApi } from "../../localApi"; import { buildProjectScript, @@ -681,9 +682,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 +692,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} unarchived 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 +709,10 @@ 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 result = mapAtomCommandResult( await deleteProject({ environmentId: member.environmentId, - input: { - projectId: member.id, - ...(memberThreads.length > 0 ? { force: true } : {}), - }, + input: projectDeleteCommandInput(member.id), }), () => undefined, ); @@ -955,8 +950,8 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
{selectedCheckoutThreadCount === 1 - ? "1 thread" - : `${selectedCheckoutThreadCount} threads`} + ? "1 unarchived thread" + : `${selectedCheckoutThreadCount} unarchived threads`}
@@ -1148,8 +1143,8 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { } description={ group.memberProjects.length > 1 - ? `Deletes all ${group.memberProjects.length} checkout entries and their threads on every machine. Files on disk are not touched.` - : "Deletes the project entry and its threads. Files on disk are not touched." + ? `Deletes all ${group.memberProjects.length} checkout entries and all their threads, including archived threads, on every machine. Files on disk are not touched.` + : "Deletes the project entry and all its threads, including archived threads. Files on disk are not touched." } control={ } /> From 2a918c28a8fcce37744fcf2321740d06fa29763b Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Wed, 12 Aug 2026 00:48:06 +0200 Subject: [PATCH 3/4] fix(web): preserve environment-scoped project removal --- apps/web/src/components/LegacySidebar.tsx | 24 ++++----- .../settings/ProjectSettingsPanel.tsx | 51 +++++++++++++++---- apps/web/src/lib/projectRemoval.test.ts | 44 ++++++++++++++-- apps/web/src/lib/projectRemoval.ts | 38 ++++++++++++-- 4 files changed, 128 insertions(+), 29 deletions(-) diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 09decea52460..916e3214ac16 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -74,7 +74,6 @@ import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { isElectron } from "../env"; import { useOpenPrLink } from "../lib/openPullRequestLink"; -import { projectDeleteCommandInput } from "../lib/projectRemoval"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isMacPlatform } from "../lib/utils"; import { @@ -1445,11 +1444,14 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ); const removeProject = useCallback( - async (member: SidebarProjectGroupMember) => { + async (member: SidebarProjectGroupMember, options: { force?: boolean } = {}) => { const memberProjectRef = scopeProjectRef(member.environmentId, member.id); const result = await deleteProject({ environmentId: member.environmentId, - input: projectDeleteCommandInput(member.id), + input: { + projectId: member.id, + ...(options.force === true ? { force: true } : {}), + }, }); if (result._tag === "Failure") { return result; @@ -1500,25 +1502,24 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const confirmed = await api.dialogs.confirm( latestProjectThreads.length > 0 ? [ - `Remove project "${member.title}" and permanently delete every thread attached to it, including archived threads?`, + `Remove project "${member.title}" and delete its ${latestProjectThreads.length} thread${ + latestProjectThreads.length === 1 ? "" : "s" + }?`, `Path: ${member.workspaceRoot}`, ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []), - `This includes ${latestProjectThreads.length} unarchived thread${ - latestProjectThreads.length === 1 ? "" : "s" - } and permanently clears their conversation history.`, + "This permanently clears conversation history for those threads.", "This removes only this project entry.", "This action cannot be undone.", ].join("\n") : [ - `Remove project "${member.title}" and permanently delete every thread attached to it, including archived threads?`, + `Remove project "${member.title}"?`, `Path: ${member.workspaceRoot}`, ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []), "This removes only this project entry.", - "This action cannot be undone.", ].join("\n"), { variant: "destructive" }, ); @@ -1526,7 +1527,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return; } - const result = await removeProject(member); + const result = await removeProject(member, { force: true }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( @@ -1564,11 +1565,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } const message = [ - `Remove project "${member.title}" and permanently delete every thread attached to it, including archived threads?`, + `Remove project "${member.title}"?`, `Path: ${member.workspaceRoot}`, ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []), "This removes only this project entry.", - "This action cannot be undone.", ].join("\n"); const confirmed = await api.dialogs.confirm(message, { variant: "destructive" }); if (!confirmed) { diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index d9e9443113f4..903bbbb62ab1 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -45,8 +45,9 @@ 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 { projectDeleteCommandInput } from "../../lib/projectRemoval"; +import { projectDeleteCommandInput, projectThreadCount } from "../../lib/projectRemoval"; import { readLocalApi } from "../../localApi"; import { buildProjectScript, @@ -309,6 +310,23 @@ 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, + 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, { @@ -340,12 +358,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); @@ -672,8 +690,19 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const api = readLocalApi(); if (!api) return; + if (archivedThreadsError) { + 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; @@ -693,7 +722,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { : [`This removes ${members.length} grouped project entries.`]), ...(projectThreads.length > 0 ? [ - `This includes ${projectThreads.length} unarchived thread${projectThreads.length === 1 ? "" : "s"}.`, + `This includes ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}.`, ] : []), "This permanently clears all associated conversation history.", @@ -709,10 +738,11 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const draftStore = useComposerDraftStore.getState(); for (const member of members) { + const memberThreadCount = projectThreadCount(member, projectThreads); const result = mapAtomCommandResult( await deleteProject({ environmentId: member.environmentId, - input: projectDeleteCommandInput(member.id), + input: projectDeleteCommandInput(member.id, memberThreadCount), }), () => undefined, ); @@ -735,12 +765,13 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { } }, [ + archivedThreadsError, deleteProject, group.displayName, group.memberProjects.length, + knownThreads, navigate, reportFailure, - threads, ], ); @@ -950,8 +981,8 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
{selectedCheckoutThreadCount === 1 - ? "1 unarchived thread" - : `${selectedCheckoutThreadCount} unarchived threads`} + ? "1 thread" + : `${selectedCheckoutThreadCount} threads`}
@@ -1004,6 +1035,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {