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 {...@@ -58,6 +58,7 @@ import {
58 OrchestrationEngineService,58 OrchestrationEngineService,
59 type OrchestrationEngineShape,59 type OrchestrationEngineShape,
60} from "../src/orchestration/Services/OrchestrationEngine.ts";60} from "../src/orchestration/Services/OrchestrationEngine.ts";
61import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts";
61import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts";62import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts";
62import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts";63import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts";
63import {64import {
...@@ -351,6 +352,12 @@ export const makeOrchestrationIntegrationHarness = (...@@ -351,6 +352,12 @@ export const makeOrchestrationIntegrationHarness = (
351 Layer.provideMerge(runtimeIngestionLayer),352 Layer.provideMerge(runtimeIngestionLayer),
352 Layer.provideMerge(providerCommandReactorLayer),353 Layer.provideMerge(providerCommandReactorLayer),
353 Layer.provideMerge(checkpointReactorLayer),354 Layer.provideMerge(checkpointReactorLayer),
355 Layer.provideMerge(
356 Layer.succeed(ThreadDeletionReactor, {
357 start: () => Effect.void,
358 drain: Effect.void,
359 }),
360 ),
354 );361 );
355 const layer = Layer.empty.pipe(362 const layer = Layer.empty.pipe(
356 Layer.provideMerge(runtimeServicesLayer),363 Layer.provideMerge(runtimeServicesLayer),
apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts+13-2
...@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it } from "vitest";...@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it } from "vitest";
4import { CheckpointReactor } from "../Services/CheckpointReactor.ts";4import { CheckpointReactor } from "../Services/CheckpointReactor.ts";
5import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";5import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
6import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";6import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
7import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";
7import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts";8import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts";
8import { makeOrchestrationReactor } from "./OrchestrationReactor.ts";9import { makeOrchestrationReactor } from "./OrchestrationReactor.ts";
910
...@@ -17,7 +18,7 @@ describe("OrchestrationReactor", () => {...@@ -17,7 +18,7 @@ describe("OrchestrationReactor", () => {
17 runtime = null;18 runtime = null;
18 });19 });
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 () => {
21 const started: string[] = [];22 const started: string[] = [];
2223
23 runtime = ManagedRuntime.make(24 runtime = ManagedRuntime.make(
...@@ -49,10 +50,19 @@ describe("OrchestrationReactor", () => {...@@ -49,10 +50,19 @@ describe("OrchestrationReactor", () => {
49 drain: Effect.void,50 drain: Effect.void,
50 }),51 }),
51 ),52 ),
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 ),
52 ),62 ),
53 );63 );
5464
55 const reactor = await runtime.runPromise(Effect.service(OrchestrationReactor));65 const reactor = await runtime!.runPromise(Effect.service(OrchestrationReactor));
56 const scope = await Effect.runPromise(Scope.make("sequential"));66 const scope = await Effect.runPromise(Scope.make("sequential"));
57 await Effect.runPromise(reactor.start().pipe(Scope.provide(scope)));67 await Effect.runPromise(reactor.start().pipe(Scope.provide(scope)));
5868
...@@ -60,6 +70,7 @@ describe("OrchestrationReactor", () => {...@@ -60,6 +70,7 @@ describe("OrchestrationReactor", () => {
60 "provider-runtime-ingestion",70 "provider-runtime-ingestion",
61 "provider-command-reactor",71 "provider-command-reactor",
62 "checkpoint-reactor",72 "checkpoint-reactor",
73 "thread-deletion-reactor",
63 ]);74 ]);
6475
65 await Effect.runPromise(Scope.close(scope, Exit.void));76 await Effect.runPromise(Scope.close(scope, Exit.void));
apps/server/src/orchestration/Layers/OrchestrationReactor.ts+3
...@@ -7,16 +7,19 @@ import {...@@ -7,16 +7,19 @@ import {
7import { CheckpointReactor } from "../Services/CheckpointReactor.ts";7import { CheckpointReactor } from "../Services/CheckpointReactor.ts";
8import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";8import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
9import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";9import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
10import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";
1011
11export const makeOrchestrationReactor = Effect.gen(function* () {12export const makeOrchestrationReactor = Effect.gen(function* () {
12 const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService;13 const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService;
13 const providerCommandReactor = yield* ProviderCommandReactor;14 const providerCommandReactor = yield* ProviderCommandReactor;
14 const checkpointReactor = yield* CheckpointReactor;15 const checkpointReactor = yield* CheckpointReactor;
16 const threadDeletionReactor = yield* ThreadDeletionReactor;
1517
16 const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () {18 const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () {
17 yield* providerRuntimeIngestion.start();19 yield* providerRuntimeIngestion.start();
18 yield* providerCommandReactor.start();20 yield* providerCommandReactor.start();
19 yield* checkpointReactor.start();21 yield* checkpointReactor.start();
22 yield* threadDeletionReactor.start();
20 });23 });
2124
22 return {25 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";...@@ -7,6 +7,7 @@ import { Effect } from "effect";
77
8import { OrchestrationCommandInvariantError } from "./Errors.ts";8import { OrchestrationCommandInvariantError } from "./Errors.ts";
9import {9import {
10 listThreadsByProjectId,
10 requireProject,11 requireProject,
11 requireProjectAbsent,12 requireProjectAbsent,
12 requireThread,13 requireThread,
...@@ -14,6 +15,7 @@ import {...@@ -14,6 +15,7 @@ import {
14 requireThreadAbsent,15 requireThreadAbsent,
15 requireThreadNotArchived,16 requireThreadNotArchived,
16} from "./commandInvariants.ts";17} from "./commandInvariants.ts";
18import { projectEvent } from "./projector.ts";
1719
18const nowIso = () => new Date().toISOString();20const nowIso = () => new Date().toISOString();
19const defaultMetadata: Omit<OrchestrationEvent, "sequence" | "type" | "payload"> = {21const defaultMetadata: Omit<OrchestrationEvent, "sequence" | "type" | "payload"> = {
...@@ -47,16 +49,49 @@ function withEventBase(...@@ -47,16 +49,49 @@ function withEventBase(
47 };49 };
48}50}
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
50export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand")(function* ({88export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand")(function* ({
51 command,89 command,
52 readModel,90 readModel,
53}: {91}: {
54 readonly command: OrchestrationCommand;92 readonly command: OrchestrationCommand;
55 readonly readModel: OrchestrationReadModel;93 readonly readModel: OrchestrationReadModel;
56}): Effect.fn.Return<94}): Effect.fn.Return<DecideOrchestrationCommandResult, OrchestrationCommandInvariantError> {
57 Omit<OrchestrationEvent, "sequence"> | ReadonlyArray<Omit<OrchestrationEvent, "sequence">>,
58 OrchestrationCommandInvariantError
59> {
60 switch (command.type) {95 switch (command.type) {
61 case "project.create": {96 case "project.create": {
62 yield* requireProjectAbsent({97 yield* requireProjectAbsent({
...@@ -119,6 +154,35 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -119,6 +154,35 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
119 command,154 command,
120 projectId: command.projectId,155 projectId: command.projectId,
121 });156 });
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
122 const occurredAt = nowIso();186 const occurredAt = nowIso();
123 return {187 return {
124 ...withEventBase({188 ...withEventBase({
...@@ -127,7 +191,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -127,7 +191,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
127 occurredAt,191 occurredAt,
128 commandId: command.commandId,192 commandId: command.commandId,
129 }),193 }),
130 type: "project.deleted",194 type: "project.deleted" as const,
131 payload: {195 payload: {
132 projectId: command.projectId,196 projectId: command.projectId,
133 deletedAt: occurredAt,197 deletedAt: occurredAt,
apps/server/src/server.ts+2
...@@ -40,6 +40,7 @@ import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus....@@ -40,6 +40,7 @@ import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus.
40import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion.ts";40import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion.ts";
41import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts";41import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts";
42import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts";42import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts";
43import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts";
43import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";44import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";
44import { ServerSettingsLive } from "./serverSettings.ts";45import { ServerSettingsLive } from "./serverSettings.ts";
45import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";46import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";
...@@ -128,6 +129,7 @@ const ReactorLayerLive = Layer.empty.pipe(...@@ -128,6 +129,7 @@ const ReactorLayerLive = Layer.empty.pipe(
128 Layer.provideMerge(ProviderRuntimeIngestionLive),129 Layer.provideMerge(ProviderRuntimeIngestionLive),
129 Layer.provideMerge(ProviderCommandReactorLive),130 Layer.provideMerge(ProviderCommandReactorLive),
130 Layer.provideMerge(CheckpointReactorLive),131 Layer.provideMerge(CheckpointReactorLive),
132 Layer.provideMerge(ThreadDeletionReactorLive),
131 Layer.provideMerge(RuntimeReceiptBusLive),133 Layer.provideMerge(RuntimeReceiptBusLive),
132);134);
133135
apps/web/src/components/Sidebar.tsx+92-34
...@@ -951,13 +951,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -951,13 +951,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
951 const removeFromSelection = useThreadSelectionStore((state) => state.removeFromSelection);951 const removeFromSelection = useThreadSelectionStore((state) => state.removeFromSelection);
952 const setSelectionAnchor = useThreadSelectionStore((state) => state.setAnchor);952 const setSelectionAnchor = useThreadSelectionStore((state) => state.setAnchor);
953 const selectedThreadCount = useThreadSelectionStore((state) => state.selectedThreadKeys.size);953 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 );
961 const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{954 const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{
962 threadId: ThreadId;955 threadId: ThreadId;
963 }>({956 }>({
...@@ -1283,6 +1276,31 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1283,6 +1276,31 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1283 [projectGroupingSettings.sidebarProjectGroupingOverrides],1276 [projectGroupingSettings.sidebarProjectGroupingOverrides],
1284 );1277 );
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
1286 const handleRemoveProject = useCallback(1304 const handleRemoveProject = useCallback(
1287 async (member: SidebarProjectGroupMember) => {1305 async (member: SidebarProjectGroupMember) => {
1288 const api = readLocalApi();1306 const api = readLocalApi();
...@@ -1290,11 +1308,74 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1290,11 +1308,74 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1290 return;1308 return;
1291 }1309 }
12921310
1293 if ((memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0) > 0) {1311 const memberProjectRef = scopeProjectRef(member.environmentId, member.id);
1294 toastManager.add({1312 const memberThreadCount = memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0;
1313 if (memberThreadCount > 0) {
1314 const warningToastId = toastManager.add({
1295 type: "warning",1315 type: "warning",
1296 title: "Project is not empty",1316 title: "Project is not empty",
1297 description: "Delete all threads in this project before removing it.",1317 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 },
1298 });1379 });
1299 return;1380 return;
1300 }1381 }
...@@ -1310,23 +1391,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1310,23 +1391,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1310 return;1391 return;
1311 }1392 }
13121393
1313 const memberProjectRef = scopeProjectRef(member.environmentId, member.id);
1314
1315 try {1394 try {
1316 const projectDraftThread = getDraftThreadByProjectRef(memberProjectRef);1395 await removeProject(member);
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 });
1330 } catch (error) {1396 } catch (error) {
1331 const message = error instanceof Error ? error.message : "Unknown error removing project.";1397 const message = error instanceof Error ? error.message : "Unknown error removing project.";
1332 console.error("Failed to remove project", {1398 console.error("Failed to remove project", {
...@@ -1341,12 +1407,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1341,12 +1407,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1341 });1407 });
1342 }1408 }
1343 },1409 },
1344 [1410 [memberThreadCountByPhysicalKey, removeProject],
1345 clearComposerDraftForThread,
1346 clearProjectDraftThreadId,
1347 getDraftThreadByProjectRef,
1348 memberThreadCountByPhysicalKey,
1349 ],
1350 );1411 );
13511412
1352 const handleProjectButtonContextMenu = useCallback(1413 const handleProjectButtonContextMenu = useCallback(
...@@ -1429,8 +1490,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1429,8 +1490,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1429 buildTargetedItem("copy-path", "Copy Project Path"),1490 buildTargetedItem("copy-path", "Copy Project Path"),
1430 buildTargetedItem("delete", "Remove project", {1491 buildTargetedItem("delete", "Remove project", {
1431 destructive: true,1492 destructive: true,
1432 isDisabled: (member) =>
1433 (memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0) > 0,
1434 }),1493 }),
1435 ],1494 ],
1436 {1495 {
...@@ -1449,7 +1508,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1449,7 +1508,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1449 [1508 [
1450 copyPathToClipboard,1509 copyPathToClipboard,
1451 handleRemoveProject,1510 handleRemoveProject,
1452 memberThreadCountByPhysicalKey,
1453 openProjectGroupingDialog,1511 openProjectGroupingDialog,
1454 openProjectRenameDialog,1512 openProjectRenameDialog,
1455 project.groupedProjectCount,1513 project.groupedProjectCount,
apps/web/src/components/ui/toast.tsx+37-4
...@@ -31,6 +31,15 @@ export type ThreadToastData = {...@@ -31,6 +31,15 @@ export type ThreadToastData = {
31 tooltipStyle?: boolean;31 tooltipStyle?: boolean;
32 dismissAfterVisibleMs?: number;32 dismissAfterVisibleMs?: number;
33 hideCopyButton?: boolean;33 hideCopyButton?: boolean;
34 actionLayout?: "inline" | "stacked-end";
35 actionVariant?:
36 | "default"
37 | "destructive"
38 | "destructive-outline"
39 | "ghost"
40 | "link"
41 | "outline"
42 | "secondary";
34};43};
3544
36const toastManager = Toast.createToastManager<ThreadToastData>();45const toastManager = Toast.createToastManager<ThreadToastData>();
...@@ -232,6 +241,9 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {...@@ -232,6 +241,9 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
232 visibleIndex,241 visibleIndex,
233 visibleToastLayout.items.length,242 visibleToastLayout.items.length,
234 );243 );
244 const stackedActionLayout =
245 toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end";
246 const actionVariant = toast.data?.actionVariant ?? "default";
235247
236 return (248 return (
237 <Toast.Root249 <Toast.Root
...@@ -304,7 +316,10 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {...@@ -304,7 +316,10 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
304 />316 />
305 <Toast.Content317 <Toast.Content
306 className={cn(318 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",
308 hideCollapsedContent &&323 hideCollapsedContent &&
309 "not-data-expanded:pointer-events-none not-data-expanded:opacity-0",324 "not-data-expanded:pointer-events-none not-data-expanded:opacity-0",
310 )}325 )}
...@@ -337,7 +352,11 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {...@@ -337,7 +352,11 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
337 </div>352 </div>
338 {toast.actionProps && (353 {toast.actionProps && (
339 <Toast.Action354 <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 )}
341 data-slot="toast-action"360 data-slot="toast-action"
342 >361 >
343 {toast.actionProps.children}362 {toast.actionProps.children}
...@@ -374,6 +393,9 @@ function AnchoredToasts() {...@@ -374,6 +393,9 @@ function AnchoredToasts() {
374 const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null;393 const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null;
375 const tooltipStyle = toast.data?.tooltipStyle ?? false;394 const tooltipStyle = toast.data?.tooltipStyle ?? false;
376 const positionerProps = toast.positionerProps;395 const positionerProps = toast.positionerProps;
396 const stackedActionLayout =
397 toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end";
398 const actionVariant = toast.data?.actionVariant ?? "default";
377399
378 if (!positionerProps?.anchor) {400 if (!positionerProps?.anchor) {
379 return null;401 return null;
...@@ -402,7 +424,14 @@ function AnchoredToasts() {...@@ -402,7 +424,14 @@ function AnchoredToasts() {
402 <Toast.Title data-slot="toast-title" />424 <Toast.Title data-slot="toast-title" />
403 </Toast.Content>425 </Toast.Content>
404 ) : (426 ) : (
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 >
406 <div className="flex min-w-0 flex-1 gap-2">435 <div className="flex min-w-0 flex-1 gap-2">
407 {Icon && (436 {Icon && (
408 <div437 <div
...@@ -433,7 +462,11 @@ function AnchoredToasts() {...@@ -433,7 +462,11 @@ function AnchoredToasts() {
433 </div>462 </div>
434 {toast.actionProps && (463 {toast.actionProps && (
435 <Toast.Action464 <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 )}
437 data-slot="toast-action"470 data-slot="toast-action"
438 >471 >
439 {toast.actionProps.children}472 {toast.actionProps.children}
apps/web/src/composerDraftStore.test.ts+43
...@@ -561,6 +561,49 @@ describe("composerDraftStore project draft thread mapping", () => {...@@ -561,6 +561,49 @@ describe("composerDraftStore project draft thread mapping", () => {
561 expect(draftByKey(draftId)).toBeUndefined();561 expect(draftByKey(draftId)).toBeUndefined();
562 });562 });
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
564 it("clears orphaned composer drafts when remapping a project to a new draft thread", () => {607 it("clears orphaned composer drafts when remapping a project to a new draft thread", () => {
565 const store = useComposerDraftStore.getState();608 const store = useComposerDraftStore.getState();
566 store.setProjectDraftThreadId(projectRef, draftId, { threadId });609 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
apps/web/src/composerDraftStore.ts+11-7
...@@ -782,6 +782,15 @@ function revokeObjectPreviewUrl(previewUrl: string): void {...@@ -782,6 +782,15 @@ function revokeObjectPreviewUrl(previewUrl: string): void {
782 URL.revokeObjectURL(previewUrl);782 URL.revokeObjectURL(previewUrl);
783}783}
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
785function normalizePersistedAttachment(value: unknown): PersistedComposerImageAttachment | null {794function normalizePersistedAttachment(value: unknown): PersistedComposerImageAttachment | null {
786 if (!value || typeof value !== "object") {795 if (!value || typeof value !== "object") {
787 return null;796 return null;
...@@ -1101,7 +1110,8 @@ function removeDraftThreadReferences(...@@ -1101,7 +1110,8 @@ function removeDraftThreadReferences(
1101 ) as Record<string, string>;1110 ) as Record<string, string>;
1102 const { [threadKey]: _removedDraftThread, ...restDraftThreadsByThreadKey } =1111 const { [threadKey]: _removedDraftThread, ...restDraftThreadsByThreadKey } =
1103 state.draftThreadsByThreadKey;1112 state.draftThreadsByThreadKey;
1104 const { [threadKey]: _removedComposerDraft, ...restDraftsByThreadKey } = state.draftsByThreadKey;1113 const { [threadKey]: removedComposerDraft, ...restDraftsByThreadKey } = state.draftsByThreadKey;
1114 revokeDraftThreadPreviewUrls(removedComposerDraft);
1105 return {1115 return {
1106 draftsByThreadKey: restDraftsByThreadKey,1116 draftsByThreadKey: restDraftsByThreadKey,
1107 draftThreadsByThreadKey: restDraftThreadsByThreadKey,1117 draftThreadsByThreadKey: restDraftThreadsByThreadKey,
...@@ -2068,12 +2078,6 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -2068,12 +2078,6 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
2068 if (threadKey.length === 0) {2078 if (threadKey.length === 0) {
2069 return;2079 return;
2070 }2080 }
2071 const existing = get().draftsByThreadKey[threadKey];
2072 if (existing) {
2073 for (const image of existing.images) {
2074 revokeObjectPreviewUrl(image.previewUrl);
2075 }
2076 }
2077 set((state) => {2081 set((state) => {
2078 const hasDraftThread = state.draftThreadsByThreadKey[threadKey] !== undefined;2082 const hasDraftThread = state.draftThreadsByThreadKey[threadKey] !== undefined;
2079 const hasLogicalProjectMapping = Object.values(2083 const hasLogicalProjectMapping = Object.values(
apps/web/src/environments/runtime/service.ts+6
...@@ -14,6 +14,7 @@ import {...@@ -14,6 +14,7 @@ import {
14 createKnownEnvironment,14 createKnownEnvironment,
15 getKnownEnvironmentWsBaseUrl,15 getKnownEnvironmentWsBaseUrl,
16 scopedThreadKey,16 scopedThreadKey,
17 scopeProjectRef,
17 scopeThreadRef,18 scopeThreadRef,
18} from "@t3tools/client-runtime";19} from "@t3tools/client-runtime";
1920
...@@ -571,6 +572,11 @@ function applyRecoveredEventBatch(...@@ -571,6 +572,11 @@ function applyRecoveredEventBatch(
571 .getState()572 .getState()
572 .clearThreadUi(scopedThreadKey(scopeThreadRef(environmentId, threadId)));573 .clearThreadUi(scopedThreadKey(scopeThreadRef(environmentId, threadId)));
573 }574 }
575 for (const event of events) {
576 if (event.type === "project.deleted") {
577 draftStore.clearProjectDraftThreadId(scopeProjectRef(environmentId, event.payload.projectId));
578 }
579 }
574 for (const threadId of batchEffects.removeTerminalStateThreadIds) {580 for (const threadId of batchEffects.removeTerminalStateThreadIds) {
575 useTerminalStateStore.getState().removeTerminalState(scopeThreadRef(environmentId, threadId));581 useTerminalStateStore.getState().removeTerminalState(scopeThreadRef(environmentId, threadId));
576 }582 }
packages/contracts/src/orchestration.ts+1
...@@ -432,6 +432,7 @@ const ProjectDeleteCommand = Schema.Struct({...@@ -432,6 +432,7 @@ const ProjectDeleteCommand = Schema.Struct({
432 type: Schema.Literal("project.delete"),432 type: Schema.Literal("project.delete"),
433 commandId: CommandId,433 commandId: CommandId,
434 projectId: ProjectId,434 projectId: ProjectId,
435 force: Schema.optional(Schema.Boolean),
435});436});
436437
437const ThreadCreateCommand = Schema.Struct({438const ThreadCreateCommand = Schema.Struct({