diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 6daa43ca42..6f9f4c6f44 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -58,6 +58,7 @@ import { OrchestrationEngineService, type OrchestrationEngineShape, } from "../src/orchestration/Services/OrchestrationEngine.ts"; +import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts"; import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; import { @@ -351,6 +352,12 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(runtimeIngestionLayer), Layer.provideMerge(providerCommandReactorLayer), Layer.provideMerge(checkpointReactorLayer), + Layer.provideMerge( + Layer.succeed(ThreadDeletionReactor, { + start: () => Effect.void, + drain: Effect.void, + }), + ), ); const layer = Layer.empty.pipe( Layer.provideMerge(runtimeServicesLayer), diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index d60f0cf722..5855e93eee 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; +import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; @@ -17,7 +18,7 @@ describe("OrchestrationReactor", () => { runtime = null; }); - it("starts provider ingestion, provider command, and checkpoint reactors", async () => { + it("starts provider ingestion, provider command, checkpoint, and thread deletion reactors", async () => { const started: string[] = []; runtime = ManagedRuntime.make( @@ -49,6 +50,15 @@ describe("OrchestrationReactor", () => { drain: Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(ThreadDeletionReactor, { + start: () => { + started.push("thread-deletion-reactor"); + return Effect.void; + }, + drain: Effect.void, + }), + ), ), ); @@ -60,6 +70,7 @@ describe("OrchestrationReactor", () => { "provider-runtime-ingestion", "provider-command-reactor", "checkpoint-reactor", + "thread-deletion-reactor", ]); await Effect.runPromise(Scope.close(scope, Exit.void)); diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts index 99d30c57a2..258294830e 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -7,16 +7,19 @@ import { import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; +import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; export const makeOrchestrationReactor = Effect.gen(function* () { const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService; const providerCommandReactor = yield* ProviderCommandReactor; const checkpointReactor = yield* CheckpointReactor; + const threadDeletionReactor = yield* ThreadDeletionReactor; const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { yield* providerRuntimeIngestion.start(); yield* providerCommandReactor.start(); yield* checkpointReactor.start(); + yield* threadDeletionReactor.start(); }); return { diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts new file mode 100644 index 0000000000..3c9b8fd17d --- /dev/null +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts @@ -0,0 +1,36 @@ +import { ThreadId } from "@t3tools/contracts"; +import { Cause, Effect, Exit } from "effect"; +import { describe, expect, it } from "vitest"; + +import { logCleanupCauseUnlessInterrupted } from "./ThreadDeletionReactor.ts"; + +describe("logCleanupCauseUnlessInterrupted", () => { + const threadId = ThreadId.makeUnsafe("thread-deletion-reactor-test"); + + it("swallows ordinary cleanup failures", async () => { + const exit = await Effect.runPromiseExit( + logCleanupCauseUnlessInterrupted({ + effect: Effect.fail("cleanup failed"), + message: "thread deletion cleanup skipped provider session stop", + threadId, + }), + ); + + expect(Exit.isSuccess(exit)).toBe(true); + }); + + it("preserves interrupt causes", async () => { + const exit = await Effect.runPromiseExit( + logCleanupCauseUnlessInterrupted({ + effect: Effect.interrupt, + message: "thread deletion cleanup skipped provider session stop", + threadId, + }), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true); + } + }); +}); diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts new file mode 100644 index 0000000000..db3d14fa6d --- /dev/null +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts @@ -0,0 +1,96 @@ +import type { OrchestrationEvent } from "@t3tools/contracts"; +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import { Cause, Effect, Layer, Stream } from "effect"; + +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { TerminalManager } from "../../terminal/Services/Manager.ts"; +import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { + ThreadDeletionReactor, + type ThreadDeletionReactorShape, +} from "../Services/ThreadDeletionReactor.ts"; + +type ThreadDeletedEvent = Extract; + +export const logCleanupCauseUnlessInterrupted = ({ + effect, + message, + threadId, +}: { + readonly effect: Effect.Effect; + readonly message: string; + readonly threadId: ThreadDeletedEvent["payload"]["threadId"]; +}): Effect.Effect => + effect.pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause); + } + return Effect.logDebug(message, { + threadId, + cause: Cause.pretty(cause), + }); + }), + ); + +const make = Effect.gen(function* () { + const orchestrationEngine = yield* OrchestrationEngineService; + const providerService = yield* ProviderService; + const terminalManager = yield* TerminalManager; + + const stopProviderSession = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => + logCleanupCauseUnlessInterrupted({ + effect: providerService.stopSession({ threadId }), + message: "thread deletion cleanup skipped provider session stop", + threadId, + }); + + const closeThreadTerminals = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => + logCleanupCauseUnlessInterrupted({ + effect: terminalManager.close({ threadId, deleteHistory: true }), + message: "thread deletion cleanup skipped terminal close", + threadId, + }); + + const processThreadDeleted = Effect.fn("processThreadDeleted")(function* ( + event: ThreadDeletedEvent, + ) { + const { threadId } = event.payload; + yield* stopProviderSession(threadId); + yield* closeThreadTerminals(threadId); + }); + + const processThreadDeletedSafely = (event: ThreadDeletedEvent) => + processThreadDeleted(event).pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause); + } + return Effect.logWarning("thread deletion reactor failed to process event", { + eventType: event.type, + threadId: event.payload.threadId, + cause: Cause.pretty(cause), + }); + }), + ); + + const worker = yield* makeDrainableWorker(processThreadDeletedSafely); + + const start: ThreadDeletionReactorShape["start"] = Effect.fn("start")(function* () { + yield* Effect.forkScoped( + Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { + if (event.type !== "thread.deleted") { + return Effect.void; + } + return worker.enqueue(event); + }), + ); + }); + + return { + start, + drain: worker.drain, + } satisfies ThreadDeletionReactorShape; +}); + +export const ThreadDeletionReactorLive = Layer.effect(ThreadDeletionReactor, make); diff --git a/apps/server/src/orchestration/Services/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Services/ThreadDeletionReactor.ts new file mode 100644 index 0000000000..7e0a223139 --- /dev/null +++ b/apps/server/src/orchestration/Services/ThreadDeletionReactor.ts @@ -0,0 +1,37 @@ +/** + * ThreadDeletionReactor - Thread deletion cleanup reactor service interface. + * + * Owns background workers that react to thread deletion domain events and + * perform best-effort runtime cleanup for provider sessions and terminals. + * + * @module ThreadDeletionReactor + */ +import { ServiceMap } from "effect"; +import type { Effect, Scope } from "effect"; + +/** + * ThreadDeletionReactorShape - Service API for thread deletion cleanup. + */ +export interface ThreadDeletionReactorShape { + /** + * Start reacting to thread.deleted orchestration domain events. + * + * The returned effect must be run in a scope so all worker fibers can be + * finalized on shutdown. + */ + readonly start: () => Effect.Effect; + + /** + * Resolves when the internal processing queue is empty and idle. + * Intended for test use to replace timing-sensitive sleeps. + */ + readonly drain: Effect.Effect; +} + +/** + * ThreadDeletionReactor - Service tag for thread deletion cleanup workers. + */ +export class ThreadDeletionReactor extends ServiceMap.Service< + ThreadDeletionReactor, + ThreadDeletionReactorShape +>()("t3/orchestration/Services/ThreadDeletionReactor") {} diff --git a/apps/server/src/orchestration/decider.delete.test.ts b/apps/server/src/orchestration/decider.delete.test.ts new file mode 100644 index 0000000000..d7f7e70de2 --- /dev/null +++ b/apps/server/src/orchestration/decider.delete.test.ts @@ -0,0 +1,225 @@ +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + EventId, + ProjectId, + ThreadId, + type OrchestrationCommand, + type OrchestrationEvent, + type OrchestrationReadModel, +} from "@t3tools/contracts"; +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +const asEventId = (value: string): EventId => EventId.makeUnsafe(value); +const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); +const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); + +async function seedReadModel(): Promise { + const now = new Date().toISOString(); + const initial = createEmptyReadModel(now); + const withProject = await Effect.runPromise( + projectEvent(initial, { + sequence: 1, + eventId: asEventId("evt-project-create"), + aggregateKind: "project", + aggregateId: asProjectId("project-delete"), + type: "project.created", + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-project-create"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-project-create"), + metadata: {}, + payload: { + projectId: asProjectId("project-delete"), + title: "Project Delete", + workspaceRoot: "/tmp/project-delete", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }), + ); + + const withFirstThread = await Effect.runPromise( + projectEvent(withProject, { + sequence: 2, + eventId: asEventId("evt-thread-create-1"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-delete-1"), + type: "thread.created", + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-thread-create-1"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-thread-create-1"), + metadata: {}, + payload: { + threadId: asThreadId("thread-delete-1"), + projectId: asProjectId("project-delete"), + title: "Thread Delete 1", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + + return Effect.runPromise( + projectEvent(withFirstThread, { + sequence: 3, + eventId: asEventId("evt-thread-create-2"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-delete-2"), + type: "thread.created", + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-thread-create-2"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-thread-create-2"), + metadata: {}, + payload: { + threadId: asThreadId("thread-delete-2"), + projectId: asProjectId("project-delete"), + title: "Thread Delete 2", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); +} + +type PlannedEvent = Omit; + +function normalizeDeleteEvent(event: PlannedEvent | ReadonlyArray) { + const events = Array.isArray(event) ? event : [event]; + return events.map((entry) => { + switch (entry.type) { + case "thread.deleted": + return { + type: entry.type, + aggregateKind: entry.aggregateKind, + aggregateId: entry.aggregateId, + commandId: entry.commandId, + correlationId: entry.correlationId, + payload: { + threadId: entry.payload.threadId, + }, + }; + case "project.deleted": + return { + type: entry.type, + aggregateKind: entry.aggregateKind, + aggregateId: entry.aggregateId, + commandId: entry.commandId, + correlationId: entry.correlationId, + payload: { + projectId: entry.payload.projectId, + }, + }; + default: + return entry; + } + }); +} + +describe("decider deletion flows", () => { + it("rejects deleting a non-empty project without force", async () => { + const readModel = await seedReadModel(); + + await expect( + Effect.runPromise( + decideOrchestrationCommand({ + command: { + type: "project.delete", + commandId: CommandId.makeUnsafe("cmd-project-delete-no-force"), + projectId: asProjectId("project-delete"), + }, + readModel, + }), + ), + ).rejects.toThrow("cannot be deleted without force=true"); + }); + + it("reuses thread.delete semantics when force-deleting a non-empty project", async () => { + const readModel = await seedReadModel(); + const projectDeleteCommand: Extract = { + type: "project.delete", + commandId: CommandId.makeUnsafe("cmd-project-delete-force"), + projectId: asProjectId("project-delete"), + force: true, + }; + + const forcedResult = await Effect.runPromise( + decideOrchestrationCommand({ + command: projectDeleteCommand, + readModel, + }), + ); + const forcedEvents = Array.isArray(forcedResult) ? forcedResult : [forcedResult]; + + expect(forcedEvents.map((event) => event.type)).toEqual([ + "thread.deleted", + "thread.deleted", + "project.deleted", + ]); + + let sequentialReadModel = readModel; + let nextSequence = readModel.snapshotSequence; + const sequentialEvents: PlannedEvent[] = []; + for (const nextCommand of [ + { + type: "thread.delete", + commandId: projectDeleteCommand.commandId, + threadId: asThreadId("thread-delete-1"), + }, + { + type: "thread.delete", + commandId: projectDeleteCommand.commandId, + threadId: asThreadId("thread-delete-2"), + }, + { + type: "project.delete", + commandId: projectDeleteCommand.commandId, + projectId: asProjectId("project-delete"), + }, + ] satisfies ReadonlyArray) { + const decided = await Effect.runPromise( + decideOrchestrationCommand({ + command: nextCommand, + readModel: sequentialReadModel, + }), + ); + const nextEvents = Array.isArray(decided) ? decided : [decided]; + sequentialEvents.push(...nextEvents); + for (const nextEvent of nextEvents) { + nextSequence += 1; + sequentialReadModel = await Effect.runPromise( + projectEvent(sequentialReadModel, { + ...nextEvent, + sequence: nextSequence, + }), + ); + } + } + + expect(normalizeDeleteEvent(forcedResult)).toEqual(normalizeDeleteEvent(sequentialEvents)); + }); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 22f5bcb280..9b6b1eb154 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -7,6 +7,7 @@ import { Effect } from "effect"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; import { + listThreadsByProjectId, requireProject, requireProjectAbsent, requireThread, @@ -14,6 +15,7 @@ import { requireThreadAbsent, requireThreadNotArchived, } from "./commandInvariants.ts"; +import { projectEvent } from "./projector.ts"; const nowIso = () => new Date().toISOString(); const defaultMetadata: Omit = { @@ -47,16 +49,49 @@ function withEventBase( }; } +type PlannedOrchestrationEvent = Omit; + +type DecideOrchestrationCommandResult = + | PlannedOrchestrationEvent + | ReadonlyArray; + +const decideCommandSequence = Effect.fn("decideCommandSequence")(function* ({ + commands, + readModel, +}: { + readonly commands: ReadonlyArray; + readonly readModel: OrchestrationReadModel; +}): Effect.fn.Return, OrchestrationCommandInvariantError> { + let nextReadModel = readModel; + let nextSequence = readModel.snapshotSequence; + const plannedEvents: PlannedOrchestrationEvent[] = []; + + for (const nextCommand of commands) { + const decided = yield* decideOrchestrationCommand({ + command: nextCommand, + readModel: nextReadModel, + }); + const nextEvents = Array.isArray(decided) ? decided : [decided]; + for (const nextEvent of nextEvents) { + plannedEvents.push(nextEvent); + nextSequence += 1; + nextReadModel = yield* projectEvent(nextReadModel, { + ...nextEvent, + sequence: nextSequence, + }).pipe(Effect.orDie); + } + } + + return plannedEvents; +}); + export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand")(function* ({ command, readModel, }: { readonly command: OrchestrationCommand; readonly readModel: OrchestrationReadModel; -}): Effect.fn.Return< - Omit | ReadonlyArray>, - OrchestrationCommandInvariantError -> { +}): Effect.fn.Return { switch (command.type) { case "project.create": { yield* requireProjectAbsent({ @@ -119,6 +154,35 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, projectId: command.projectId, }); + const activeThreads = listThreadsByProjectId(readModel, command.projectId).filter( + (thread) => thread.deletedAt === null, + ); + if (activeThreads.length > 0 && command.force !== true) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Project '${command.projectId}' is not empty and cannot be deleted without force=true.`, + }); + } + if (activeThreads.length > 0) { + return yield* decideCommandSequence({ + readModel, + commands: [ + ...activeThreads.map( + (thread): Extract => ({ + type: "thread.delete", + commandId: command.commandId, + threadId: thread.id, + }), + ), + { + type: "project.delete", + commandId: command.commandId, + projectId: command.projectId, + }, + ], + }); + } + const occurredAt = nowIso(); return { ...withEventBase({ @@ -127,7 +191,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" occurredAt, commandId: command.commandId, }), - type: "project.deleted", + type: "project.deleted" as const, payload: { projectId: command.projectId, deletedAt: occurredAt, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 23c53ad07f..03a71c6cf6 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -38,6 +38,7 @@ import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus" import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor"; import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor"; +import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor"; import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry"; import { ServerSettingsLive } from "./serverSettings"; import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver"; @@ -126,6 +127,7 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(ProviderRuntimeIngestionLive), Layer.provideMerge(ProviderCommandReactorLive), Layer.provideMerge(CheckpointReactorLive), + Layer.provideMerge(ThreadDeletionReactorLive), Layer.provideMerge(RuntimeReceiptBusLive), ); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index bb63db6fc0..203b850e5f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -986,13 +986,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const removeFromSelection = useThreadSelectionStore((state) => state.removeFromSelection); const setSelectionAnchor = useThreadSelectionStore((state) => state.setAnchor); const selectedThreadCount = useThreadSelectionStore((state) => state.selectedThreadKeys.size); - const clearComposerDraftForThread = useComposerDraftStore((state) => state.clearDraftThread); - const getDraftThreadByProjectRef = useComposerDraftStore( - (state) => state.getDraftThreadByProjectRef, - ); - const clearProjectDraftThreadId = useComposerDraftStore( - (state) => state.clearProjectDraftThreadId, - ); const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ threadId: ThreadId; }>({ @@ -1294,6 +1287,22 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec [suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef], ); + const removeProject = useCallback( + async (projectId: ProjectId, options: { force?: boolean } = {}): Promise => { + const projectApi = readEnvironmentApi(project.environmentId); + if (!projectApi) { + throw new Error("Project API unavailable."); + } + await projectApi.orchestration.dispatchCommand({ + type: "project.delete", + commandId: newCommandId(), + projectId, + ...(options.force === true ? { force: true } : {}), + }); + }, + [project.environmentId], + ); + const handleProjectButtonContextMenu = useCallback( (event: React.MouseEvent) => { event.preventDefault(); @@ -1318,11 +1327,55 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } if (clicked !== "delete") return; - if (projectThreads.length > 0) { - toastManager.add({ + const projectRef = scopeProjectRef(project.environmentId, project.id); + // Keep grouped projects conservative here: a grouped sidebar row should + // still warn when any member project currently has threads. + const projectThreadCount = projectThreads.length; + if (projectThreadCount > 0) { + const warningToastId = toastManager.add({ type: "warning", title: "Project is not empty", description: "Delete all threads in this project before removing it.", + data: { + actionLayout: "stacked-end", + actionVariant: "destructive", + }, + actionProps: { + children: "Delete anyway", + onClick: () => { + void (async () => { + toastManager.close(warningToastId); + await new Promise((resolve) => { + window.setTimeout(resolve, 180); + }); + const latestProjectThreads = selectSidebarThreadsForProjectRef( + useStore.getState(), + projectRef, + ); + const confirmed = await api.dialogs.confirm( + latestProjectThreads.length > 0 + ? [ + `Remove project "${project.name}" and delete its ${latestProjectThreads.length} thread${ + latestProjectThreads.length === 1 ? "" : "s" + }?`, + "This will permanently clear conversation history for those threads.", + "This action cannot be undone.", + ].join("\n") + : `Remove project "${project.name}"?`, + ); + if (!confirmed) return; + + await removeProject(project.id, { force: true }); + })().catch((error) => { + toastManager.add({ + type: "error", + title: `Failed to remove "${project.name}"`, + description: + error instanceof Error ? error.message : "Unknown error removing project.", + }); + }); + }, + }, }); return; } @@ -1331,22 +1384,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (!confirmed) return; try { - const projectDraftThread = getDraftThreadByProjectRef( - scopeProjectRef(project.environmentId, project.id), - ); - if (projectDraftThread) { - clearComposerDraftForThread(projectDraftThread.draftId); - } - clearProjectDraftThreadId(scopeProjectRef(project.environmentId, project.id)); - const projectApi = readEnvironmentApi(project.environmentId); - if (!projectApi) { - throw new Error("Project API unavailable."); - } - await projectApi.orchestration.dispatchCommand({ - type: "project.delete", - commandId: newCommandId(), - projectId: project.id, - }); + await removeProject(project.id); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error removing project."; @@ -1360,15 +1398,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec })(); }, [ - clearComposerDraftForThread, - clearProjectDraftThreadId, copyPathToClipboard, - getDraftThreadByProjectRef, project.cwd, project.environmentId, project.id, project.name, projectThreads.length, + removeProject, suppressProjectClickForContextMenuRef, ], ); diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index c0f75575ef..e2fa2af676 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -31,6 +31,15 @@ export type ThreadToastData = { tooltipStyle?: boolean; dismissAfterVisibleMs?: number; hideCopyButton?: boolean; + actionLayout?: "inline" | "stacked-end"; + actionVariant?: + | "default" + | "destructive" + | "destructive-outline" + | "ghost" + | "link" + | "outline" + | "secondary"; }; const toastManager = Toast.createToastManager(); @@ -232,6 +241,9 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { visibleIndex, visibleToastLayout.items.length, ); + const stackedActionLayout = + toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end"; + const actionVariant = toast.data?.actionVariant ?? "default"; return ( {toast.actionProps && ( {toast.actionProps.children} @@ -374,6 +393,9 @@ function AnchoredToasts() { const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; const tooltipStyle = toast.data?.tooltipStyle ?? false; const positionerProps = toast.positionerProps; + const stackedActionLayout = + toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end"; + const actionVariant = toast.data?.actionVariant ?? "default"; if (!positionerProps?.anchor) { return null; @@ -402,7 +424,14 @@ function AnchoredToasts() { ) : ( - +
{Icon && (
{toast.actionProps && ( {toast.actionProps.children} diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 2169bbf858..9bba2af640 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -561,6 +561,49 @@ describe("composerDraftStore project draft thread mapping", () => { expect(draftByKey(draftId)).toBeUndefined(); }); + it("revokes draft image blob URLs when clearing a project's draft thread", () => { + const store = useComposerDraftStore.getState(); + const originalRevokeObjectUrl = URL.revokeObjectURL; + const revokeSpy = vi.fn<(url: string) => void>(); + URL.revokeObjectURL = revokeSpy; + + try { + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.addImage(draftId, makeImage({ id: "img-project-clear", previewUrl: "blob:clear" })); + + store.clearProjectDraftThreadId(projectRef); + + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); + expect(revokeSpy).toHaveBeenCalledWith("blob:clear"); + } finally { + URL.revokeObjectURL = originalRevokeObjectUrl; + } + }); + + it("revokes draft image blob URLs when clearing a matching project draft thread by id", () => { + const store = useComposerDraftStore.getState(); + const originalRevokeObjectUrl = URL.revokeObjectURL; + const revokeSpy = vi.fn<(url: string) => void>(); + URL.revokeObjectURL = revokeSpy; + + try { + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.addImage( + draftId, + makeImage({ id: "img-project-clear-by-id", previewUrl: "blob:clear-by-id" }), + ); + + store.clearProjectDraftThreadById(projectRef, draftId); + + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); + expect(revokeSpy).toHaveBeenCalledWith("blob:clear-by-id"); + } finally { + URL.revokeObjectURL = originalRevokeObjectUrl; + } + }); + it("clears orphaned composer drafts when remapping a project to a new draft thread", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { threadId }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 231cf06566..8ed2203e0f 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -783,6 +783,15 @@ function revokeObjectPreviewUrl(previewUrl: string): void { URL.revokeObjectURL(previewUrl); } +function revokeDraftThreadPreviewUrls(draft: ComposerThreadDraftState | undefined): void { + if (!draft) { + return; + } + for (const image of draft.images) { + revokeObjectPreviewUrl(image.previewUrl); + } +} + function normalizePersistedAttachment(value: unknown): PersistedComposerImageAttachment | null { if (!value || typeof value !== "object") { return null; @@ -1102,7 +1111,8 @@ function removeDraftThreadReferences( ) as Record; const { [threadKey]: _removedDraftThread, ...restDraftThreadsByThreadKey } = state.draftThreadsByThreadKey; - const { [threadKey]: _removedComposerDraft, ...restDraftsByThreadKey } = state.draftsByThreadKey; + const { [threadKey]: removedComposerDraft, ...restDraftsByThreadKey } = state.draftsByThreadKey; + revokeDraftThreadPreviewUrls(removedComposerDraft); return { draftsByThreadKey: restDraftsByThreadKey, draftThreadsByThreadKey: restDraftThreadsByThreadKey, @@ -2064,12 +2074,6 @@ const composerDraftStore = create()( if (threadKey.length === 0) { return; } - const existing = get().draftsByThreadKey[threadKey]; - if (existing) { - for (const image of existing.images) { - revokeObjectPreviewUrl(image.previewUrl); - } - } set((state) => { const hasDraftThread = state.draftThreadsByThreadKey[threadKey] !== undefined; const hasLogicalProjectMapping = Object.values( diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index 5c8d2dacab..f2f5ff03fb 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -268,6 +268,11 @@ function applyRecoveredEventBatch( .getState() .clearThreadUi(scopedThreadKey(scopeThreadRef(environmentId, threadId))); } + for (const event of events) { + if (event.type === "project.deleted") { + draftStore.clearProjectDraftThreadId(scopeProjectRef(environmentId, event.payload.projectId)); + } + } for (const threadId of batchEffects.removeTerminalStateThreadIds) { useTerminalStateStore.getState().removeTerminalState(scopeThreadRef(environmentId, threadId)); } diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index c80367d158..73e8a90df8 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -332,6 +332,7 @@ const ProjectDeleteCommand = Schema.Struct({ type: Schema.Literal("project.delete"), commandId: CommandId, projectId: ProjectId, + force: Schema.optional(Schema.Boolean), }); const ThreadCreateCommand = Schema.Struct({