authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-04-17 18:05:56-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-17 15:05:56-07:00
log4e0c003e78b451c25b69f43f9c7b93e760f6c371
tree1081f0906bfcd16c749a8543c093154e2217ba57
parent60387f67e99d68eba414d2c2e00c8ccdb145b0c3
signature Signed by PGP key B5690EEEBB952194

fix(web): allow deleting non-empty projects from the warning toast (#1264)

Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com>

15 files changed, 679 insertions(+), 52 deletions(-)

apps/server/integration/OrchestrationEngineHarness.integration.ts+7
......@@ -58,6 +58,7 @@ import {
5858 OrchestrationEngineService,
5959 type OrchestrationEngineShape,
6060} from "../src/orchestration/Services/OrchestrationEngine.ts";
61import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts";
6162import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts";
6263import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts";
6364import {
......@@ -351,6 +352,12 @@ export const makeOrchestrationIntegrationHarness = (
351352 Layer.provideMerge(runtimeIngestionLayer),
352353 Layer.provideMerge(providerCommandReactorLayer),
353354 Layer.provideMerge(checkpointReactorLayer),
355 Layer.provideMerge(
356 Layer.succeed(ThreadDeletionReactor, {
357 start: () => Effect.void,
358 drain: Effect.void,
359 }),
360 ),
354361 );
355362 const layer = Layer.empty.pipe(
356363 Layer.provideMerge(runtimeServicesLayer),
apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts+13-2
......@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it } from "vitest";
44import { CheckpointReactor } from "../Services/CheckpointReactor.ts";
55import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
66import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
7import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";
78import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts";
89import { makeOrchestrationReactor } from "./OrchestrationReactor.ts";
910
......@@ -17,7 +18,7 @@ describe("OrchestrationReactor", () => {
1718 runtime = null;
1819 });
1920
20 it("starts provider ingestion, provider command, and checkpoint reactors", async () => {
21 it("starts provider ingestion, provider command, checkpoint, and thread deletion reactors", async () => {
2122 const started: string[] = [];
2223
2324 runtime = ManagedRuntime.make(
......@@ -49,10 +50,19 @@ describe("OrchestrationReactor", () => {
4950 drain: Effect.void,
5051 }),
5152 ),
53 Layer.provideMerge(
54 Layer.succeed(ThreadDeletionReactor, {
55 start: () => {
56 started.push("thread-deletion-reactor");
57 return Effect.void;
58 },
59 drain: Effect.void,
60 }),
61 ),
5262 ),
5363 );
5464
55 const reactor = await runtime.runPromise(Effect.service(OrchestrationReactor));
65 const reactor = await runtime!.runPromise(Effect.service(OrchestrationReactor));
5666 const scope = await Effect.runPromise(Scope.make("sequential"));
5767 await Effect.runPromise(reactor.start().pipe(Scope.provide(scope)));
5868
......@@ -60,6 +70,7 @@ describe("OrchestrationReactor", () => {
6070 "provider-runtime-ingestion",
6171 "provider-command-reactor",
6272 "checkpoint-reactor",
73 "thread-deletion-reactor",
6374 ]);
6475
6576 await Effect.runPromise(Scope.close(scope, Exit.void));
apps/server/src/orchestration/Layers/OrchestrationReactor.ts+3
......@@ -7,16 +7,19 @@ import {
77import { CheckpointReactor } from "../Services/CheckpointReactor.ts";
88import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
99import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
10import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";
1011
1112export const makeOrchestrationReactor = Effect.gen(function* () {
1213 const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService;
1314 const providerCommandReactor = yield* ProviderCommandReactor;
1415 const checkpointReactor = yield* CheckpointReactor;
16 const threadDeletionReactor = yield* ThreadDeletionReactor;
1517
1618 const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () {
1719 yield* providerRuntimeIngestion.start();
1820 yield* providerCommandReactor.start();
1921 yield* checkpointReactor.start();
22 yield* threadDeletionReactor.start();
2023 });
2124
2225 return {
apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts created+36
......@@ -0,0 +1,36 @@
1import { ThreadId } from "@t3tools/contracts";
2import { Cause, Effect, Exit } from "effect";
3import { describe, expect, it } from "vitest";
4
5import { logCleanupCauseUnlessInterrupted } from "./ThreadDeletionReactor.ts";
6
7describe("logCleanupCauseUnlessInterrupted", () => {
8 const threadId = ThreadId.make("thread-deletion-reactor-test");
9
10 it("swallows ordinary cleanup failures", async () => {
11 const exit = await Effect.runPromiseExit(
12 logCleanupCauseUnlessInterrupted({
13 effect: Effect.fail("cleanup failed"),
14 message: "thread deletion cleanup skipped provider session stop",
15 threadId,
16 }),
17 );
18
19 expect(Exit.isSuccess(exit)).toBe(true);
20 });
21
22 it("preserves interrupt causes", async () => {
23 const exit = await Effect.runPromiseExit(
24 logCleanupCauseUnlessInterrupted({
25 effect: Effect.interrupt,
26 message: "thread deletion cleanup skipped provider session stop",
27 threadId,
28 }),
29 );
30
31 expect(Exit.isFailure(exit)).toBe(true);
32 if (Exit.isFailure(exit)) {
33 expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true);
34 }
35 });
36});
apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts created+96
......@@ -0,0 +1,96 @@
1import type { OrchestrationEvent } from "@t3tools/contracts";
2import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
3import { Cause, Effect, Layer, Stream } from "effect";
4
5import { ProviderService } from "../../provider/Services/ProviderService.ts";
6import { TerminalManager } from "../../terminal/Services/Manager.ts";
7import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
8import {
9 ThreadDeletionReactor,
10 type ThreadDeletionReactorShape,
11} from "../Services/ThreadDeletionReactor.ts";
12
13type ThreadDeletedEvent = Extract<OrchestrationEvent, { type: "thread.deleted" }>;
14
15export const logCleanupCauseUnlessInterrupted = <R, E>({
16 effect,
17 message,
18 threadId,
19}: {
20 readonly effect: Effect.Effect<void, E, R>;
21 readonly message: string;
22 readonly threadId: ThreadDeletedEvent["payload"]["threadId"];
23}): Effect.Effect<void, E, R> =>
24 effect.pipe(
25 Effect.catchCause((cause) => {
26 if (Cause.hasInterruptsOnly(cause)) {
27 return Effect.failCause(cause);
28 }
29 return Effect.logDebug(message, {
30 threadId,
31 cause: Cause.pretty(cause),
32 });
33 }),
34 );
35
36const make = Effect.gen(function* () {
37 const orchestrationEngine = yield* OrchestrationEngineService;
38 const providerService = yield* ProviderService;
39 const terminalManager = yield* TerminalManager;
40
41 const stopProviderSession = (threadId: ThreadDeletedEvent["payload"]["threadId"]) =>
42 logCleanupCauseUnlessInterrupted({
43 effect: providerService.stopSession({ threadId }),
44 message: "thread deletion cleanup skipped provider session stop",
45 threadId,
46 });
47
48 const closeThreadTerminals = (threadId: ThreadDeletedEvent["payload"]["threadId"]) =>
49 logCleanupCauseUnlessInterrupted({
50 effect: terminalManager.close({ threadId, deleteHistory: true }),
51 message: "thread deletion cleanup skipped terminal close",
52 threadId,
53 });
54
55 const processThreadDeleted = Effect.fn("processThreadDeleted")(function* (
56 event: ThreadDeletedEvent,
57 ) {
58 const { threadId } = event.payload;
59 yield* stopProviderSession(threadId);
60 yield* closeThreadTerminals(threadId);
61 });
62
63 const processThreadDeletedSafely = (event: ThreadDeletedEvent) =>
64 processThreadDeleted(event).pipe(
65 Effect.catchCause((cause) => {
66 if (Cause.hasInterruptsOnly(cause)) {
67 return Effect.failCause(cause);
68 }
69 return Effect.logWarning("thread deletion reactor failed to process event", {
70 eventType: event.type,
71 threadId: event.payload.threadId,
72 cause: Cause.pretty(cause),
73 });
74 }),
75 );
76
77 const worker = yield* makeDrainableWorker(processThreadDeletedSafely);
78
79 const start: ThreadDeletionReactorShape["start"] = Effect.fn("start")(function* () {
80 yield* Effect.forkScoped(
81 Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => {
82 if (event.type !== "thread.deleted") {
83 return Effect.void;
84 }
85 return worker.enqueue(event);
86 }),
87 );
88 });
89
90 return {
91 start,
92 drain: worker.drain,
93 } satisfies ThreadDeletionReactorShape;
94});
95
96export const ThreadDeletionReactorLive = Layer.effect(ThreadDeletionReactor, make);
apps/server/src/orchestration/Services/ThreadDeletionReactor.ts created+37
......@@ -0,0 +1,37 @@
1/**
2 * ThreadDeletionReactor - Thread deletion cleanup reactor service interface.
3 *
4 * Owns background workers that react to thread deletion domain events and
5 * perform best-effort runtime cleanup for provider sessions and terminals.
6 *
7 * @module ThreadDeletionReactor
8 */
9import { Context } from "effect";
10import type { Effect, Scope } from "effect";
11
12/**
13 * ThreadDeletionReactorShape - Service API for thread deletion cleanup.
14 */
15export interface ThreadDeletionReactorShape {
16 /**
17 * Start reacting to thread.deleted orchestration domain events.
18 *
19 * The returned effect must be run in a scope so all worker fibers can be
20 * finalized on shutdown.
21 */
22 readonly start: () => Effect.Effect<void, never, Scope.Scope>;
23
24 /**
25 * Resolves when the internal processing queue is empty and idle.
26 * Intended for test use to replace timing-sensitive sleeps.
27 */
28 readonly drain: Effect.Effect<void>;
29}
30
31/**
32 * ThreadDeletionReactor - Service tag for thread deletion cleanup workers.
33 */
34export class ThreadDeletionReactor extends Context.Service<
35 ThreadDeletionReactor,
36 ThreadDeletionReactorShape
37>()("t3/orchestration/Services/ThreadDeletionReactor") {}
apps/server/src/orchestration/decider.delete.test.ts created+226
......@@ -0,0 +1,226 @@
1import {
2 CommandId,
3 DEFAULT_PROVIDER_INTERACTION_MODE,
4 EventId,
5 ProjectId,
6 ThreadId,
7 type OrchestrationCommand,
8 type OrchestrationEvent,
9 type OrchestrationReadModel,
10} from "@t3tools/contracts";
11import { Effect } from "effect";
12import { describe, expect, it } from "vitest";
13
14import { decideOrchestrationCommand } from "./decider.ts";
15import { createEmptyReadModel, projectEvent } from "./projector.ts";
16
17const asCommandId = (value: string): CommandId => CommandId.make(value);
18const asEventId = (value: string): EventId => EventId.make(value);
19const asProjectId = (value: string): ProjectId => ProjectId.make(value);
20const asThreadId = (value: string): ThreadId => ThreadId.make(value);
21
22async function seedReadModel(): Promise<OrchestrationReadModel> {
23 const now = new Date().toISOString();
24 const initial = createEmptyReadModel(now);
25 const withProject = await Effect.runPromise(
26 projectEvent(initial, {
27 sequence: 1,
28 eventId: asEventId("evt-project-create"),
29 aggregateKind: "project",
30 aggregateId: asProjectId("project-delete"),
31 type: "project.created",
32 occurredAt: now,
33 commandId: asCommandId("cmd-project-create"),
34 causationEventId: null,
35 correlationId: asCommandId("cmd-project-create"),
36 metadata: {},
37 payload: {
38 projectId: asProjectId("project-delete"),
39 title: "Project Delete",
40 workspaceRoot: "/tmp/project-delete",
41 defaultModelSelection: null,
42 scripts: [],
43 createdAt: now,
44 updatedAt: now,
45 },
46 }),
47 );
48
49 const withFirstThread = await Effect.runPromise(
50 projectEvent(withProject, {
51 sequence: 2,
52 eventId: asEventId("evt-thread-create-1"),
53 aggregateKind: "thread",
54 aggregateId: asThreadId("thread-delete-1"),
55 type: "thread.created",
56 occurredAt: now,
57 commandId: asCommandId("cmd-thread-create-1"),
58 causationEventId: null,
59 correlationId: asCommandId("cmd-thread-create-1"),
60 metadata: {},
61 payload: {
62 threadId: asThreadId("thread-delete-1"),
63 projectId: asProjectId("project-delete"),
64 title: "Thread Delete 1",
65 modelSelection: {
66 provider: "codex",
67 model: "gpt-5-codex",
68 },
69 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
70 runtimeMode: "approval-required",
71 branch: null,
72 worktreePath: null,
73 createdAt: now,
74 updatedAt: now,
75 },
76 }),
77 );
78
79 return Effect.runPromise(
80 projectEvent(withFirstThread, {
81 sequence: 3,
82 eventId: asEventId("evt-thread-create-2"),
83 aggregateKind: "thread",
84 aggregateId: asThreadId("thread-delete-2"),
85 type: "thread.created",
86 occurredAt: now,
87 commandId: asCommandId("cmd-thread-create-2"),
88 causationEventId: null,
89 correlationId: asCommandId("cmd-thread-create-2"),
90 metadata: {},
91 payload: {
92 threadId: asThreadId("thread-delete-2"),
93 projectId: asProjectId("project-delete"),
94 title: "Thread Delete 2",
95 modelSelection: {
96 provider: "codex",
97 model: "gpt-5-codex",
98 },
99 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
100 runtimeMode: "approval-required",
101 branch: null,
102 worktreePath: null,
103 createdAt: now,
104 updatedAt: now,
105 },
106 }),
107 );
108}
109
110type PlannedEvent = Omit<OrchestrationEvent, "sequence">;
111
112function normalizeDeleteEvent(event: PlannedEvent | ReadonlyArray<PlannedEvent>) {
113 const events = Array.isArray(event) ? event : [event];
114 return events.map((entry) => {
115 switch (entry.type) {
116 case "thread.deleted":
117 return {
118 type: entry.type,
119 aggregateKind: entry.aggregateKind,
120 aggregateId: entry.aggregateId,
121 commandId: entry.commandId,
122 correlationId: entry.correlationId,
123 payload: {
124 threadId: entry.payload.threadId,
125 },
126 };
127 case "project.deleted":
128 return {
129 type: entry.type,
130 aggregateKind: entry.aggregateKind,
131 aggregateId: entry.aggregateId,
132 commandId: entry.commandId,
133 correlationId: entry.correlationId,
134 payload: {
135 projectId: entry.payload.projectId,
136 },
137 };
138 default:
139 return entry;
140 }
141 });
142}
143
144describe("decider deletion flows", () => {
145 it("rejects deleting a non-empty project without force", async () => {
146 const readModel = await seedReadModel();
147
148 await expect(
149 Effect.runPromise(
150 decideOrchestrationCommand({
151 command: {
152 type: "project.delete",
153 commandId: asCommandId("cmd-project-delete-no-force"),
154 projectId: asProjectId("project-delete"),
155 },
156 readModel,
157 }),
158 ),
159 ).rejects.toThrow("cannot be deleted without force=true");
160 });
161
162 it("reuses thread.delete semantics when force-deleting a non-empty project", async () => {
163 const readModel = await seedReadModel();
164 const projectDeleteCommand: Extract<OrchestrationCommand, { type: "project.delete" }> = {
165 type: "project.delete",
166 commandId: asCommandId("cmd-project-delete-force"),
167 projectId: asProjectId("project-delete"),
168 force: true,
169 };
170
171 const forcedResult = await Effect.runPromise(
172 decideOrchestrationCommand({
173 command: projectDeleteCommand,
174 readModel,
175 }),
176 );
177 const forcedEvents = Array.isArray(forcedResult) ? forcedResult : [forcedResult];
178
179 expect(forcedEvents.map((event) => event.type)).toEqual([
180 "thread.deleted",
181 "thread.deleted",
182 "project.deleted",
183 ]);
184
185 let sequentialReadModel = readModel;
186 let nextSequence = readModel.snapshotSequence;
187 const sequentialEvents: PlannedEvent[] = [];
188 for (const nextCommand of [
189 {
190 type: "thread.delete",
191 commandId: projectDeleteCommand.commandId,
192 threadId: asThreadId("thread-delete-1"),
193 },
194 {
195 type: "thread.delete",
196 commandId: projectDeleteCommand.commandId,
197 threadId: asThreadId("thread-delete-2"),
198 },
199 {
200 type: "project.delete",
201 commandId: projectDeleteCommand.commandId,
202 projectId: asProjectId("project-delete"),
203 },
204 ] satisfies ReadonlyArray<OrchestrationCommand>) {
205 const decided = await Effect.runPromise(
206 decideOrchestrationCommand({
207 command: nextCommand,
208 readModel: sequentialReadModel,
209 }),
210 );
211 const nextEvents = Array.isArray(decided) ? decided : [decided];
212 sequentialEvents.push(...nextEvents);
213 for (const nextEvent of nextEvents) {
214 nextSequence += 1;
215 sequentialReadModel = await Effect.runPromise(
216 projectEvent(sequentialReadModel, {
217 ...nextEvent,
218 sequence: nextSequence,
219 }),
220 );
221 }
222 }
223
224 expect(normalizeDeleteEvent(forcedResult)).toEqual(normalizeDeleteEvent(sequentialEvents));
225 });
226});
apps/server/src/orchestration/decider.ts+69-5
......@@ -7,6 +7,7 @@ import { Effect } from "effect";
77
88import { OrchestrationCommandInvariantError } from "./Errors.ts";
99import {
10 listThreadsByProjectId,
1011 requireProject,
1112 requireProjectAbsent,
1213 requireThread,
......@@ -14,6 +15,7 @@ import {
1415 requireThreadAbsent,
1516 requireThreadNotArchived,
1617} from "./commandInvariants.ts";
18import { projectEvent } from "./projector.ts";
1719
1820const nowIso = () => new Date().toISOString();
1921const defaultMetadata: Omit<OrchestrationEvent, "sequence" | "type" | "payload"> = {
......@@ -47,16 +49,49 @@ function withEventBase(
4749 };
4850}
4951
52type PlannedOrchestrationEvent = Omit<OrchestrationEvent, "sequence">;
53
54type DecideOrchestrationCommandResult =
55 | PlannedOrchestrationEvent
56 | ReadonlyArray<PlannedOrchestrationEvent>;
57
58const decideCommandSequence = Effect.fn("decideCommandSequence")(function* ({
59 commands,
60 readModel,
61}: {
62 readonly commands: ReadonlyArray<OrchestrationCommand>;
63 readonly readModel: OrchestrationReadModel;
64}): Effect.fn.Return<ReadonlyArray<PlannedOrchestrationEvent>, OrchestrationCommandInvariantError> {
65 let nextReadModel = readModel;
66 let nextSequence = readModel.snapshotSequence;
67 const plannedEvents: PlannedOrchestrationEvent[] = [];
68
69 for (const nextCommand of commands) {
70 const decided = yield* decideOrchestrationCommand({
71 command: nextCommand,
72 readModel: nextReadModel,
73 });
74 const nextEvents = Array.isArray(decided) ? decided : [decided];
75 for (const nextEvent of nextEvents) {
76 plannedEvents.push(nextEvent);
77 nextSequence += 1;
78 nextReadModel = yield* projectEvent(nextReadModel, {
79 ...nextEvent,
80 sequence: nextSequence,
81 }).pipe(Effect.orDie);
82 }
83 }
84
85 return plannedEvents;
86});
87
5088export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand")(function* ({
5189 command,
5290 readModel,
5391}: {
5492 readonly command: OrchestrationCommand;
5593 readonly readModel: OrchestrationReadModel;
56}): Effect.fn.Return<
57 Omit<OrchestrationEvent, "sequence"> | ReadonlyArray<Omit<OrchestrationEvent, "sequence">>,
58 OrchestrationCommandInvariantError
59> {
94}): Effect.fn.Return<DecideOrchestrationCommandResult, OrchestrationCommandInvariantError> {
6095 switch (command.type) {
6196 case "project.create": {
6297 yield* requireProjectAbsent({
......@@ -119,6 +154,35 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
119154 command,
120155 projectId: command.projectId,
121156 });
157 const activeThreads = listThreadsByProjectId(readModel, command.projectId).filter(
158 (thread) => thread.deletedAt === null,
159 );
160 if (activeThreads.length > 0 && command.force !== true) {
161 return yield* new OrchestrationCommandInvariantError({
162 commandType: command.type,
163 detail: `Project '${command.projectId}' is not empty and cannot be deleted without force=true.`,
164 });
165 }
166 if (activeThreads.length > 0) {
167 return yield* decideCommandSequence({
168 readModel,
169 commands: [
170 ...activeThreads.map(
171 (thread): Extract<OrchestrationCommand, { type: "thread.delete" }> => ({
172 type: "thread.delete",
173 commandId: command.commandId,
174 threadId: thread.id,
175 }),
176 ),
177 {
178 type: "project.delete",
179 commandId: command.commandId,
180 projectId: command.projectId,
181 },
182 ],
183 });
184 }
185
122186 const occurredAt = nowIso();
123187 return {
124188 ...withEventBase({
......@@ -127,7 +191,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
127191 occurredAt,
128192 commandId: command.commandId,
129193 }),
130 type: "project.deleted",
194 type: "project.deleted" as const,
131195 payload: {
132196 projectId: command.projectId,
133197 deletedAt: occurredAt,
apps/server/src/server.ts+2
......@@ -40,6 +40,7 @@ import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus.
4040import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion.ts";
4141import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts";
4242import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts";
43import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts";
4344import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";
4445import { ServerSettingsLive } from "./serverSettings.ts";
4546import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";
......@@ -128,6 +129,7 @@ const ReactorLayerLive = Layer.empty.pipe(
128129 Layer.provideMerge(ProviderRuntimeIngestionLive),
129130 Layer.provideMerge(ProviderCommandReactorLive),
130131 Layer.provideMerge(CheckpointReactorLive),
132 Layer.provideMerge(ThreadDeletionReactorLive),
131133 Layer.provideMerge(RuntimeReceiptBusLive),
132134);
133135
apps/web/src/components/Sidebar.tsx+92-34
......@@ -951,13 +951,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
951951 const removeFromSelection = useThreadSelectionStore((state) => state.removeFromSelection);
952952 const setSelectionAnchor = useThreadSelectionStore((state) => state.setAnchor);
953953 const selectedThreadCount = useThreadSelectionStore((state) => state.selectedThreadKeys.size);
954 const clearComposerDraftForThread = useComposerDraftStore((state) => state.clearDraftThread);
955 const getDraftThreadByProjectRef = useComposerDraftStore(
956 (state) => state.getDraftThreadByProjectRef,
957 );
958 const clearProjectDraftThreadId = useComposerDraftStore(
959 (state) => state.clearProjectDraftThreadId,
960 );
961954 const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{
962955 threadId: ThreadId;
963956 }>({
......@@ -1283,6 +1276,31 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
12831276 [projectGroupingSettings.sidebarProjectGroupingOverrides],
12841277 );
12851278
1279 const removeProject = useCallback(
1280 async (member: SidebarProjectGroupMember, options: { force?: boolean } = {}): Promise<void> => {
1281 const memberProjectRef = scopeProjectRef(member.environmentId, member.id);
1282 const draftStore = useComposerDraftStore.getState();
1283 const projectDraftThread = draftStore.getDraftThreadByProjectRef(memberProjectRef);
1284 if (projectDraftThread) {
1285 draftStore.clearDraftThread(projectDraftThread.draftId);
1286 }
1287 draftStore.clearProjectDraftThreadId(memberProjectRef);
1288
1289 const projectApi = readEnvironmentApi(member.environmentId);
1290 if (!projectApi) {
1291 throw new Error("Project API unavailable.");
1292 }
1293
1294 await projectApi.orchestration.dispatchCommand({
1295 type: "project.delete",
1296 commandId: newCommandId(),
1297 projectId: member.id,
1298 ...(options.force === true ? { force: true } : {}),
1299 });
1300 },
1301 [],
1302 );
1303
12861304 const handleRemoveProject = useCallback(
12871305 async (member: SidebarProjectGroupMember) => {
12881306 const api = readLocalApi();
......@@ -1290,11 +1308,74 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
12901308 return;
12911309 }
12921310
1293 if ((memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0) > 0) {
1294 toastManager.add({
1311 const memberProjectRef = scopeProjectRef(member.environmentId, member.id);
1312 const memberThreadCount = memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0;
1313 if (memberThreadCount > 0) {
1314 const warningToastId = toastManager.add({
12951315 type: "warning",
12961316 title: "Project is not empty",
12971317 description: "Delete all threads in this project before removing it.",
1318 data: {
1319 actionLayout: "stacked-end",
1320 actionVariant: "destructive",
1321 },
1322 actionProps: {
1323 children: "Delete anyway",
1324 onClick: () => {
1325 void (async () => {
1326 toastManager.close(warningToastId);
1327 await new Promise<void>((resolve) => {
1328 window.setTimeout(resolve, 180);
1329 });
1330
1331 const latestProjectThreads = selectSidebarThreadsForProjectRefs(
1332 useStore.getState(),
1333 [memberProjectRef],
1334 );
1335 const confirmed = await api.dialogs.confirm(
1336 latestProjectThreads.length > 0
1337 ? [
1338 `Remove project "${member.name}" and delete its ${latestProjectThreads.length} thread${
1339 latestProjectThreads.length === 1 ? "" : "s"
1340 }?`,
1341 `Path: ${member.cwd}`,
1342 ...(member.environmentLabel
1343 ? [`Environment: ${member.environmentLabel}`]
1344 : []),
1345 "This permanently clears conversation history for those threads.",
1346 "This removes only this project entry.",
1347 "This action cannot be undone.",
1348 ].join("\n")
1349 : [
1350 `Remove project "${member.name}"?`,
1351 `Path: ${member.cwd}`,
1352 ...(member.environmentLabel
1353 ? [`Environment: ${member.environmentLabel}`]
1354 : []),
1355 "This removes only this project entry.",
1356 ].join("\n"),
1357 );
1358 if (!confirmed) {
1359 return;
1360 }
1361
1362 await removeProject(member, { force: true });
1363 })().catch((error) => {
1364 const message =
1365 error instanceof Error ? error.message : "Unknown error removing project.";
1366 console.error("Failed to remove project", {
1367 projectId: member.id,
1368 environmentId: member.environmentId,
1369 error,
1370 });
1371 toastManager.add({
1372 type: "error",
1373 title: `Failed to remove "${member.name}"`,
1374 description: message,
1375 });
1376 });
1377 },
1378 },
12981379 });
12991380 return;
13001381 }
......@@ -1310,23 +1391,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
13101391 return;
13111392 }
13121393
1313 const memberProjectRef = scopeProjectRef(member.environmentId, member.id);
1314
13151394 try {
1316 const projectDraftThread = getDraftThreadByProjectRef(memberProjectRef);
1317 if (projectDraftThread) {
1318 clearComposerDraftForThread(projectDraftThread.draftId);
1319 }
1320 clearProjectDraftThreadId(memberProjectRef);
1321 const projectApi = readEnvironmentApi(member.environmentId);
1322 if (!projectApi) {
1323 throw new Error("Project API unavailable.");
1324 }
1325 await projectApi.orchestration.dispatchCommand({
1326 type: "project.delete",
1327 commandId: newCommandId(),
1328 projectId: member.id,
1329 });
1395 await removeProject(member);
13301396 } catch (error) {
13311397 const message = error instanceof Error ? error.message : "Unknown error removing project.";
13321398 console.error("Failed to remove project", {
......@@ -1341,12 +1407,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
13411407 });
13421408 }
13431409 },
1344 [
1345 clearComposerDraftForThread,
1346 clearProjectDraftThreadId,
1347 getDraftThreadByProjectRef,
1348 memberThreadCountByPhysicalKey,
1349 ],
1410 [memberThreadCountByPhysicalKey, removeProject],
13501411 );
13511412
13521413 const handleProjectButtonContextMenu = useCallback(
......@@ -1429,8 +1490,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
14291490 buildTargetedItem("copy-path", "Copy Project Path"),
14301491 buildTargetedItem("delete", "Remove project", {
14311492 destructive: true,
1432 isDisabled: (member) =>
1433 (memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0) > 0,
14341493 }),
14351494 ],
14361495 {
......@@ -1449,7 +1508,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
14491508 [
14501509 copyPathToClipboard,
14511510 handleRemoveProject,
1452 memberThreadCountByPhysicalKey,
14531511 openProjectGroupingDialog,
14541512 openProjectRenameDialog,
14551513 project.groupedProjectCount,
apps/web/src/components/ui/toast.tsx+37-4
......@@ -31,6 +31,15 @@ export type ThreadToastData = {
3131 tooltipStyle?: boolean;
3232 dismissAfterVisibleMs?: number;
3333 hideCopyButton?: boolean;
34 actionLayout?: "inline" | "stacked-end";
35 actionVariant?:
36 | "default"
37 | "destructive"
38 | "destructive-outline"
39 | "ghost"
40 | "link"
41 | "outline"
42 | "secondary";
3443};
3544
3645const toastManager = Toast.createToastManager<ThreadToastData>();
......@@ -232,6 +241,9 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
232241 visibleIndex,
233242 visibleToastLayout.items.length,
234243 );
244 const stackedActionLayout =
245 toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end";
246 const actionVariant = toast.data?.actionVariant ?? "default";
235247
236248 return (
237249 <Toast.Root
......@@ -304,7 +316,10 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
304316 />
305317 <Toast.Content
306318 className={cn(
307 "pointer-events-auto flex items-center justify-between gap-1.5 overflow-hidden px-3.5 py-3 text-sm transition-opacity duration-250 data-expanded:opacity-100",
319 "pointer-events-auto overflow-hidden px-3.5 text-sm transition-opacity duration-250 data-expanded:opacity-100",
320 stackedActionLayout
321 ? "flex flex-col gap-2 py-2.5"
322 : "flex items-center justify-between gap-1.5 py-3",
308323 hideCollapsedContent &&
309324 "not-data-expanded:pointer-events-none not-data-expanded:opacity-0",
310325 )}
......@@ -337,7 +352,11 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
337352 </div>
338353 {toast.actionProps && (
339354 <Toast.Action
340 className={cn(buttonVariants({ size: "xs" }), "shrink-0")}
355 className={cn(
356 buttonVariants({ size: "xs", variant: actionVariant }),
357 "shrink-0",
358 stackedActionLayout && "self-end",
359 )}
341360 data-slot="toast-action"
342361 >
343362 {toast.actionProps.children}
......@@ -374,6 +393,9 @@ function AnchoredToasts() {
374393 const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null;
375394 const tooltipStyle = toast.data?.tooltipStyle ?? false;
376395 const positionerProps = toast.positionerProps;
396 const stackedActionLayout =
397 toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end";
398 const actionVariant = toast.data?.actionVariant ?? "default";
377399
378400 if (!positionerProps?.anchor) {
379401 return null;
......@@ -402,7 +424,14 @@ function AnchoredToasts() {
402424 <Toast.Title data-slot="toast-title" />
403425 </Toast.Content>
404426 ) : (
405 <Toast.Content className="pointer-events-auto flex items-center justify-between gap-1.5 overflow-hidden px-3.5 py-3 text-sm">
427 <Toast.Content
428 className={cn(
429 "pointer-events-auto overflow-hidden px-3.5 text-sm",
430 stackedActionLayout
431 ? "flex flex-col gap-2 py-2.5"
432 : "flex items-center justify-between gap-1.5 py-3",
433 )}
434 >
406435 <div className="flex min-w-0 flex-1 gap-2">
407436 {Icon && (
408437 <div
......@@ -433,7 +462,11 @@ function AnchoredToasts() {
433462 </div>
434463 {toast.actionProps && (
435464 <Toast.Action
436 className={cn(buttonVariants({ size: "xs" }), "shrink-0")}
465 className={cn(
466 buttonVariants({ size: "xs", variant: actionVariant }),
467 "shrink-0",
468 stackedActionLayout && "self-end",
469 )}
437470 data-slot="toast-action"
438471 >
439472 {toast.actionProps.children}
apps/web/src/composerDraftStore.test.ts+43
......@@ -561,6 +561,49 @@ describe("composerDraftStore project draft thread mapping", () => {
561561 expect(draftByKey(draftId)).toBeUndefined();
562562 });
563563
564 it("revokes draft image blob URLs when clearing a project's draft thread", () => {
565 const store = useComposerDraftStore.getState();
566 const originalRevokeObjectUrl = URL.revokeObjectURL;
567 const revokeSpy = vi.fn<(url: string) => void>();
568 URL.revokeObjectURL = revokeSpy;
569
570 try {
571 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
572 store.addImage(draftId, makeImage({ id: "img-project-clear", previewUrl: "blob:clear" }));
573
574 store.clearProjectDraftThreadId(projectRef);
575
576 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();
577 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull();
578 expect(revokeSpy).toHaveBeenCalledWith("blob:clear");
579 } finally {
580 URL.revokeObjectURL = originalRevokeObjectUrl;
581 }
582 });
583
584 it("revokes draft image blob URLs when clearing a matching project draft thread by id", () => {
585 const store = useComposerDraftStore.getState();
586 const originalRevokeObjectUrl = URL.revokeObjectURL;
587 const revokeSpy = vi.fn<(url: string) => void>();
588 URL.revokeObjectURL = revokeSpy;
589
590 try {
591 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
592 store.addImage(
593 draftId,
594 makeImage({ id: "img-project-clear-by-id", previewUrl: "blob:clear-by-id" }),
595 );
596
597 store.clearProjectDraftThreadById(projectRef, draftId);
598
599 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();
600 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull();
601 expect(revokeSpy).toHaveBeenCalledWith("blob:clear-by-id");
602 } finally {
603 URL.revokeObjectURL = originalRevokeObjectUrl;
604 }
605 });
606
564607 it("clears orphaned composer drafts when remapping a project to a new draft thread", () => {
565608 const store = useComposerDraftStore.getState();
566609 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
apps/web/src/composerDraftStore.ts+11-7
......@@ -782,6 +782,15 @@ function revokeObjectPreviewUrl(previewUrl: string): void {
782782 URL.revokeObjectURL(previewUrl);
783783}
784784
785function revokeDraftThreadPreviewUrls(draft: ComposerThreadDraftState | undefined): void {
786 if (!draft) {
787 return;
788 }
789 for (const image of draft.images) {
790 revokeObjectPreviewUrl(image.previewUrl);
791 }
792}
793
785794function normalizePersistedAttachment(value: unknown): PersistedComposerImageAttachment | null {
786795 if (!value || typeof value !== "object") {
787796 return null;
......@@ -1101,7 +1110,8 @@ function removeDraftThreadReferences(
11011110 ) as Record<string, string>;
11021111 const { [threadKey]: _removedDraftThread, ...restDraftThreadsByThreadKey } =
11031112 state.draftThreadsByThreadKey;
1104 const { [threadKey]: _removedComposerDraft, ...restDraftsByThreadKey } = state.draftsByThreadKey;
1113 const { [threadKey]: removedComposerDraft, ...restDraftsByThreadKey } = state.draftsByThreadKey;
1114 revokeDraftThreadPreviewUrls(removedComposerDraft);
11051115 return {
11061116 draftsByThreadKey: restDraftsByThreadKey,
11071117 draftThreadsByThreadKey: restDraftThreadsByThreadKey,
......@@ -2068,12 +2078,6 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
20682078 if (threadKey.length === 0) {
20692079 return;
20702080 }
2071 const existing = get().draftsByThreadKey[threadKey];
2072 if (existing) {
2073 for (const image of existing.images) {
2074 revokeObjectPreviewUrl(image.previewUrl);
2075 }
2076 }
20772081 set((state) => {
20782082 const hasDraftThread = state.draftThreadsByThreadKey[threadKey] !== undefined;
20792083 const hasLogicalProjectMapping = Object.values(
apps/web/src/environments/runtime/service.ts+6
......@@ -14,6 +14,7 @@ import {
1414 createKnownEnvironment,
1515 getKnownEnvironmentWsBaseUrl,
1616 scopedThreadKey,
17 scopeProjectRef,
1718 scopeThreadRef,
1819} from "@t3tools/client-runtime";
1920
......@@ -571,6 +572,11 @@ function applyRecoveredEventBatch(
571572 .getState()
572573 .clearThreadUi(scopedThreadKey(scopeThreadRef(environmentId, threadId)));
573574 }
575 for (const event of events) {
576 if (event.type === "project.deleted") {
577 draftStore.clearProjectDraftThreadId(scopeProjectRef(environmentId, event.payload.projectId));
578 }
579 }
574580 for (const threadId of batchEffects.removeTerminalStateThreadIds) {
575581 useTerminalStateStore.getState().removeTerminalState(scopeThreadRef(environmentId, threadId));
576582 }
packages/contracts/src/orchestration.ts+1
......@@ -432,6 +432,7 @@ const ProjectDeleteCommand = Schema.Struct({
432432 type: Schema.Literal("project.delete"),
433433 commandId: CommandId,
434434 projectId: ProjectId,
435 force: Schema.optional(Schema.Boolean),
435436});
436437
437438const ThreadCreateCommand = Schema.Struct({