authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-05 00:32:27-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 00:32:27-07:00
log82c2b7ffb4d450572f5baf9a70693f1e25cfdfed
treeb6f91610eaba7c73ac58bd37e543d7ec4885ccfe
parentb7fc81dea2b7c963c34a365b82644ce963df19a2
signature Signed by PGP key B5690EEEBB952194

refactor(web): test environment-scoped draft promotion (#10010)


2 files changed, 18 insertions(+), 71 deletions(-)

apps/web/src/composerDraftStore.test.ts+18-36
...@@ -68,10 +68,7 @@ import {...@@ -68,10 +68,7 @@ import {
68 clearComposerDraftsEnvironment,68 clearComposerDraftsEnvironment,
69 composerDraftHasUserContent,69 composerDraftHasUserContent,
70 finalizePromotedDraftThreadByRef,70 finalizePromotedDraftThreadByRef,
71 markPromotedDraftThread,
72 markPromotedDraftThreadByRef,71 markPromotedDraftThreadByRef,
73 markPromotedDraftThreads,
74 markPromotedDraftThreadsByRef,
75 type ComposerFileAttachment,72 type ComposerFileAttachment,
76 type ComposerImageAttachment,73 type ComposerImageAttachment,
77 composerFileNeedsReattach,74 composerFileNeedsReattach,
...@@ -1200,7 +1197,7 @@ describe("composerDraftStore project draft thread mapping", () => {...@@ -1200,7 +1197,7 @@ describe("composerDraftStore project draft thread mapping", () => {
1200 interactionMode: "plan",1197 interactionMode: "plan",
1201 });1198 });
1202 store.setPrompt(draftId, "keep this prompt");1199 store.setPrompt(draftId, "keep this prompt");
1203 markPromotedDraftThread(threadId);1200 markPromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId));
12041201
1205 store.setLogicalProjectDraftThreadId(scopedProjectKey(projectRef), projectRef, draftId, {1202 store.setLogicalProjectDraftThreadId(scopedProjectKey(projectRef), projectRef, draftId, {
1206 threadId: retryThreadId,1203 threadId: retryThreadId,
...@@ -1363,12 +1360,12 @@ describe("composerDraftStore project draft thread mapping", () => {...@@ -1363,12 +1360,12 @@ describe("composerDraftStore project draft thread mapping", () => {
1363 expect(draftByKey(draftId)).toBeUndefined();1360 expect(draftByKey(draftId)).toBeUndefined();
1364 });1361 });
13651362
1366 it("marks a promoted draft by thread id without deleting composer state", () => {1363 it("marks a promoted draft by scoped ref without deleting composer state", () => {
1367 const store = useComposerDraftStore.getState();1364 const store = useComposerDraftStore.getState();
1368 store.setProjectDraftThreadId(projectRef, draftId, { threadId });1365 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
1369 store.setPrompt(draftId, "promote me");1366 store.setPrompt(draftId, "promote me");
13701367
1371 markPromotedDraftThread(threadId);1368 markPromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId));
13721369
1373 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();1370 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();
1374 expect(useComposerDraftStore.getState().getDraftThread(draftId)?.promotedTo).toEqual(1371 expect(useComposerDraftStore.getState().getDraftThread(draftId)?.promotedTo).toEqual(
...@@ -1393,20 +1390,20 @@ describe("composerDraftStore project draft thread mapping", () => {...@@ -1393,20 +1390,20 @@ describe("composerDraftStore project draft thread mapping", () => {
1393 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);1390 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
1394 store.setPrompt(threadRef, "keep me");1391 store.setPrompt(threadRef, "keep me");
13951392
1396 markPromotedDraftThread(threadId);1393 markPromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId));
13971394
1398 expect(useComposerDraftStore.getState().getDraftThread(threadRef)).toBeNull();1395 expect(useComposerDraftStore.getState().getDraftThread(threadRef)).toBeNull();
1399 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("keep me");1396 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("keep me");
1400 });1397 });
14011398
1402 it("marks promoted drafts from an iterable of server thread ids", () => {1399 it("promotes a draft without changing another thread's draft", () => {
1403 const store = useComposerDraftStore.getState();1400 const store = useComposerDraftStore.getState();
1404 store.setProjectDraftThreadId(projectRef, draftId, { threadId });1401 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
1405 store.setPrompt(draftId, "promote me");1402 store.setPrompt(draftId, "promote me");
1406 store.setProjectDraftThreadId(otherProjectRef, otherDraftId, { threadId: otherThreadId });1403 store.setProjectDraftThreadId(otherProjectRef, otherDraftId, { threadId: otherThreadId });
1407 store.setPrompt(otherDraftId, "keep me");1404 store.setPrompt(otherDraftId, "keep me");
14081405
1409 markPromotedDraftThreads([threadId]);1406 markPromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId));
14101407
1411 expect(useComposerDraftStore.getState().getDraftThread(draftId)?.promotedTo).toEqual(1408 expect(useComposerDraftStore.getState().getDraftThread(draftId)?.promotedTo).toEqual(
1412 scopeThreadRef(TEST_ENVIRONMENT_ID, threadId),1409 scopeThreadRef(TEST_ENVIRONMENT_ID, threadId),
...@@ -1418,7 +1415,7 @@ describe("composerDraftStore project draft thread mapping", () => {...@@ -1418,7 +1415,7 @@ describe("composerDraftStore project draft thread mapping", () => {
1418 expect(draftByKey(otherDraftId)?.prompt).toBe("keep me");1415 expect(draftByKey(otherDraftId)?.prompt).toBe("keep me");
1419 });1416 });
14201417
1421 it("marks every matching scoped draft when multiple environments share a thread id", () => {1418 it("promotes matching thread ids separately for each environment", () => {
1422 const store = useComposerDraftStore.getState();1419 const store = useComposerDraftStore.getState();
1423 const localThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);1420 const localThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
1424 const remoteThreadRef = scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, threadId);1421 const remoteThreadRef = scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, threadId);
...@@ -1428,7 +1425,16 @@ describe("composerDraftStore project draft thread mapping", () => {...@@ -1428,7 +1425,16 @@ describe("composerDraftStore project draft thread mapping", () => {
1428 store.setProjectDraftThreadId(remoteProjectRef, remoteDraftId, { threadId });1425 store.setProjectDraftThreadId(remoteProjectRef, remoteDraftId, { threadId });
1429 store.setPrompt(remoteDraftId, "remote draft");1426 store.setPrompt(remoteDraftId, "remote draft");
14301427
1431 markPromotedDraftThread(threadId);1428 markPromotedDraftThreadByRef(localThreadRef);
1429
1430 expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull();
1431 expect(store.getDraftThreadByProjectRef(remoteProjectRef)?.threadId).toBe(threadId);
1432 expect(store.getDraftThreadByRef(localThreadRef)?.promotedTo).toEqual(localThreadRef);
1433 expect(store.getDraftThreadByRef(remoteThreadRef)?.promotedTo).toBeNull();
1434 expect(draftByKey(localDraftId)?.prompt).toBe("local draft");
1435 expect(draftByKey(remoteDraftId)?.prompt).toBe("remote draft");
1436
1437 markPromotedDraftThreadByRef(remoteThreadRef);
14321438
1433 expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull();1439 expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull();
1434 expect(store.getDraftThreadByProjectRef(remoteProjectRef)).toBeNull();1440 expect(store.getDraftThreadByProjectRef(remoteProjectRef)).toBeNull();
...@@ -1451,34 +1457,10 @@ describe("composerDraftStore project draft thread mapping", () => {...@@ -1451,34 +1457,10 @@ describe("composerDraftStore project draft thread mapping", () => {
1451 expect(draftByKey(draftId)?.prompt).toBe("promote me");1457 expect(draftByKey(draftId)?.prompt).toBe("promote me");
1452 });1458 });
14531459
1454 it("only marks iterable promotion cleanup entries for the matching environment refs", () => {
1455 const store = useComposerDraftStore.getState();
1456 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
1457 store.setPrompt(draftId, "promote me");
1458
1459 markPromotedDraftThreadsByRef([scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, threadId)]);
1460
1461 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe(
1462 threadId,
1463 );
1464 expect(draftByKey(draftId)?.prompt).toBe("promote me");
1465 });
1466
1467 it("keeps existing server-thread composer drafts during iterable promotion cleanup", () => {
1468 const store = useComposerDraftStore.getState();
1469 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
1470 store.setPrompt(threadRef, "keep me");
1471
1472 markPromotedDraftThreads([threadId]);
1473
1474 expect(useComposerDraftStore.getState().getDraftThread(threadRef)).toBeNull();
1475 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("keep me");
1476 });
1477
1478 it("moves composer edits made during promotion to the canonical thread", () => {1460 it("moves composer edits made during promotion to the canonical thread", () => {
1479 const store = useComposerDraftStore.getState();1461 const store = useComposerDraftStore.getState();
1480 store.setProjectDraftThreadId(projectRef, draftId, { threadId });1462 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
1481 markPromotedDraftThread(threadId);1463 markPromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId));
1482 store.setPrompt(draftId, "typed during setup");1464 store.setPrompt(draftId, "typed during setup");
14831465
1484 finalizePromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId));1466 finalizePromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId));
apps/web/src/composerDraftStore.ts-35
...@@ -4106,29 +4106,6 @@ export function useEffectiveComposerModelState(input: {...@@ -4106,29 +4106,6 @@ export function useEffectiveComposerModelState(input: {
4106 );4106 );
4107}4107}
41084108
4109/**
4110 * Mark a draft thread as promoting once the server has materialized the same thread id.
4111 *
4112 * Use the single-thread helper for live `thread.created` events and the
4113 * iterable helper for bootstrap/recovery paths that discover multiple server
4114 * threads at once.
4115 */
4116export function markPromotedDraftThread(threadId: ThreadId): void {
4117 const store = useComposerDraftStore.getState();
4118 const draftThreadTargets: ComposerThreadTarget[] = [];
4119 for (const [draftId, draftThread] of Object.entries(store.draftThreadsByThreadKey)) {
4120 if (draftThread.threadId === threadId) {
4121 draftThreadTargets.push(DraftId.make(draftId));
4122 }
4123 }
4124 if (draftThreadTargets.length === 0) {
4125 return;
4126 }
4127 for (const draftThreadTarget of draftThreadTargets) {
4128 store.markDraftThreadPromoting(draftThreadTarget);
4129 }
4130}
4131
4132export function markPromotedDraftThreadByRef(threadRef: ScopedThreadRef): void {4109export function markPromotedDraftThreadByRef(threadRef: ScopedThreadRef): void {
4133 const draftStore = useComposerDraftStore.getState();4110 const draftStore = useComposerDraftStore.getState();
4134 for (const [draftId, draftThread] of Object.entries(draftStore.draftThreadsByThreadKey)) {4111 for (const [draftId, draftThread] of Object.entries(draftStore.draftThreadsByThreadKey)) {
...@@ -4141,18 +4118,6 @@ export function markPromotedDraftThreadByRef(threadRef: ScopedThreadRef): void {...@@ -4141,18 +4118,6 @@ export function markPromotedDraftThreadByRef(threadRef: ScopedThreadRef): void {
4141 }4118 }
4142}4119}
41434120
4144export function markPromotedDraftThreads(serverThreadIds: Iterable<ThreadId>): void {
4145 for (const threadId of serverThreadIds) {
4146 markPromotedDraftThread(threadId);
4147 }
4148}
4149
4150export function markPromotedDraftThreadsByRef(serverThreadRefs: Iterable<ScopedThreadRef>): void {
4151 for (const threadRef of serverThreadRefs) {
4152 markPromotedDraftThreadByRef(threadRef);
4153 }
4154}
4155
4156export function finalizePromotedDraftThreadByRef(threadRef: ScopedThreadRef): void {4121export function finalizePromotedDraftThreadByRef(threadRef: ScopedThreadRef): void {
4157 const draftStore = useComposerDraftStore.getState();4122 const draftStore = useComposerDraftStore.getState();
4158 for (const [draftId, draftThread] of Object.entries(draftStore.draftThreadsByThreadKey)) {4123 for (const [draftId, draftThread] of Object.entries(draftStore.draftThreadsByThreadKey)) {