| author | |
| committer | |
| log | 2d645df474f0af5e261edb56a79f7a2a9a2d442e |
| tree | a3c2b69c0152a665eff56d3cc9da54a5d2ee5c33 |
| parent | 29d03ec556e94d5f847644730daf75ba4aa20678 |
| signature | Signed by PGP key B5690EEEBB952194 |
27 files changed, 824 insertions(+), 57 deletions(-)
apps/server/src/environment/ServerEnvironment.test.ts+1| ... | ... | @@ -167,6 +167,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { |
| 167 | 167 | expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 }); |
| 168 | 168 | expect(second.capabilities.pullRequests).toBe(true); |
| 169 | 169 | expect(second.capabilities.usagePriceOverrides).toBe(true); |
| 170 | expect(second.capabilities.threadActiveReorder).toBe(true); | |
| 170 | 171 | expect(second.capabilities.threadTitleRegeneration).toBe(true); |
| 171 | 172 | expect(second.capabilities.threadPullRequestLinking).toBe(true); |
| 172 | 173 | expect(second.capabilities.agentActivityPublishing).toBe(false); |
apps/server/src/environment/ServerEnvironment.ts+1| ... | ... | @@ -226,6 +226,7 @@ export const make = Effect.gen(function* () { |
| 226 | 226 | usagePriceOverrides: true, |
| 227 | 227 | threadPinning: true, |
| 228 | 228 | threadPinReorder: true, |
| 229 | threadActiveReorder: true, | |
| 229 | 230 | threadTitleRegeneration: true, |
| 230 | 231 | threadPullRequestLinking: true, |
| 231 | 232 | environmentIcon: true, |
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+55-3| ... | ... | @@ -488,6 +488,48 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 488 | 488 | yield* sql`DROP TRIGGER count_thread_shell_updates`; |
| 489 | 489 | yield* sql`DROP TABLE thread_shell_updates`; |
| 490 | 490 | |
| 491 | // Replayed order events must survive later lifecycle upserts, whose | |
| 492 | // complete SQL row writes otherwise risk dropping the placement. | |
| 493 | const orderUpdatedAt = "2026-01-01T00:00:00.200Z"; | |
| 494 | const orderEvents = [ | |
| 495 | { type: "thread.meta-updated", payload: { activeOrderKey: "gm" } }, | |
| 496 | { type: "thread.pinned", payload: { pinnedAt: now, pinOrderKey: "m" } }, | |
| 497 | { | |
| 498 | type: "thread.snoozed", | |
| 499 | payload: { snoozedAt: now, snoozedUntil: "2026-01-02T00:00:00.000Z" }, | |
| 500 | }, | |
| 501 | { type: "thread.unsnoozed", payload: { reason: "user" } }, | |
| 502 | { type: "thread.unpinned", payload: {} }, | |
| 503 | { type: "thread.meta-updated", payload: { title: "Renamed" } }, | |
| 504 | ] as const; | |
| 505 | for (const [index, event] of orderEvents.entries()) { | |
| 506 | yield* eventStore.append({ | |
| 507 | type: event.type, | |
| 508 | eventId: EventId.make(`evt-active-order-${index}`), | |
| 509 | aggregateKind: "thread", | |
| 510 | aggregateId: ThreadId.make("thread-1"), | |
| 511 | occurredAt: "2026-01-01T00:00:00.500Z", | |
| 512 | commandId: CommandId.make(`cmd-active-order-${index}`), | |
| 513 | causationEventId: null, | |
| 514 | correlationId: null, | |
| 515 | metadata: {}, | |
| 516 | payload: { | |
| 517 | ...event.payload, | |
| 518 | threadId: ThreadId.make("thread-1"), | |
| 519 | updatedAt: orderUpdatedAt, | |
| 520 | }, | |
| 521 | }); | |
| 522 | yield* projectionPipeline.bootstrap; | |
| 523 | const rows = yield* sql<{ | |
| 524 | readonly activeOrderKey: string | null; | |
| 525 | readonly updatedAt: string; | |
| 526 | }>` | |
| 527 | SELECT active_order_key AS "activeOrderKey", updated_at AS "updatedAt" | |
| 528 | FROM projection_threads WHERE thread_id = 'thread-1' | |
| 529 | `; | |
| 530 | assert.deepEqual(rows, [{ activeOrderKey: "gm", updatedAt: orderUpdatedAt }]); | |
| 531 | } | |
| 532 | ||
| 491 | 533 | // Settled lifecycle through the DB pipeline: thread.settled writes the |
| 492 | 534 | // override + timestamp, thread.unsettled(user) flips to the active pin. |
| 493 | 535 | yield* eventStore.append({ |
| ... | ... | @@ -512,16 +554,23 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 512 | 554 | readonly settledOverride: string | null; |
| 513 | 555 | readonly settledAt: string | null; |
| 514 | 556 | readonly unsettledAt: string | null; |
| 557 | readonly activeOrderKey: string | null; | |
| 515 | 558 | }>` |
| 516 | 559 | SELECT |
| 517 | 560 | settled_override AS "settledOverride", |
| 518 | 561 | settled_at AS "settledAt", |
| 519 | unsettled_at AS "unsettledAt" | |
| 562 | unsettled_at AS "unsettledAt", | |
| 563 | active_order_key AS "activeOrderKey" | |
| 520 | 564 | FROM projection_threads |
| 521 | 565 | WHERE thread_id = 'thread-1' |
| 522 | 566 | `; |
| 523 | 567 | assert.deepEqual(settledRows, [ |
| 524 | { settledOverride: "settled", settledAt: "2026-01-01T00:00:01.000Z", unsettledAt: null }, | |
| 568 | { | |
| 569 | settledOverride: "settled", | |
| 570 | settledAt: "2026-01-01T00:00:01.000Z", | |
| 571 | unsettledAt: null, | |
| 572 | activeOrderKey: null, | |
| 573 | }, | |
| 525 | 574 | ]); |
| 526 | 575 | |
| 527 | 576 | yield* eventStore.append({ |
| ... | ... | @@ -546,11 +595,13 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 546 | 595 | readonly settledOverride: string | null; |
| 547 | 596 | readonly settledAt: string | null; |
| 548 | 597 | readonly unsettledAt: string | null; |
| 598 | readonly activeOrderKey: string | null; | |
| 549 | 599 | }>` |
| 550 | 600 | SELECT |
| 551 | 601 | settled_override AS "settledOverride", |
| 552 | 602 | settled_at AS "settledAt", |
| 553 | unsettled_at AS "unsettledAt" | |
| 603 | unsettled_at AS "unsettledAt", | |
| 604 | active_order_key AS "activeOrderKey" | |
| 554 | 605 | FROM projection_threads |
| 555 | 606 | WHERE thread_id = 'thread-1' |
| 556 | 607 | `; |
| ... | ... | @@ -561,6 +612,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 561 | 612 | settledOverride: "active", |
| 562 | 613 | settledAt: null, |
| 563 | 614 | unsettledAt: "2026-01-01T00:00:02.000Z", |
| 615 | activeOrderKey: null, | |
| 564 | 616 | }, |
| 565 | 617 | ]); |
| 566 | 618 | }), |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+5| ... | ... | @@ -617,6 +617,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 617 | 617 | snoozedAt: null, |
| 618 | 618 | pinnedAt: null, |
| 619 | 619 | pinOrderKey: null, |
| 620 | activeOrderKey: null, | |
| 620 | 621 | titleRegenerationRequestId: null, |
| 621 | 622 | titleRegenerationStartedAt: null, |
| 622 | 623 | latestUserMessageAt: null, |
| ... | ... | @@ -671,6 +672,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 671 | 672 | settledOverride: "settled", |
| 672 | 673 | settledAt: event.payload.settledAt, |
| 673 | 674 | unsettledAt: null, |
| 675 | activeOrderKey: null, | |
| 674 | 676 | updatedAt: event.payload.updatedAt, |
| 675 | 677 | }); |
| 676 | 678 | return; |
| ... | ... | @@ -790,6 +792,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 790 | 792 | yield* projectionThreadRepository.upsert({ |
| 791 | 793 | ...existingRow.value, |
| 792 | 794 | ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), |
| 795 | ...(event.payload.activeOrderKey !== undefined | |
| 796 | ? { activeOrderKey: event.payload.activeOrderKey } | |
| 797 | : {}), | |
| 793 | 798 | ...(event.payload.titleRegeneration !== undefined |
| 794 | 799 | ? { |
| 795 | 800 | titleRegenerationRequestId: event.payload.titleRegeneration?.requestId ?? null, |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+6| ... | ... | @@ -110,6 +110,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 110 | 110 | has_actionable_proposed_plan, |
| 111 | 111 | pinned_at, |
| 112 | 112 | pin_order_key, |
| 113 | active_order_key, | |
| 113 | 114 | created_at, |
| 114 | 115 | updated_at, |
| 115 | 116 | deleted_at |
| ... | ... | @@ -132,6 +133,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 132 | 133 | 0, |
| 133 | 134 | '2026-02-24T00:00:01.000Z', |
| 134 | 135 | 'gm', |
| 136 | 'hq', | |
| 135 | 137 | '2026-02-24T00:00:02.000Z', |
| 136 | 138 | '2026-02-24T00:00:03.000Z', |
| 137 | 139 | NULL |
| ... | ... | @@ -358,6 +360,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 358 | 360 | snoozedAt: null, |
| 359 | 361 | pinnedAt: "2026-02-24T00:00:01.000Z", |
| 360 | 362 | pinOrderKey: "gm", |
| 363 | activeOrderKey: "hq", | |
| 361 | 364 | titleRegeneration: null, |
| 362 | 365 | deletedAt: null, |
| 363 | 366 | messages: [ |
| ... | ... | @@ -487,6 +490,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 487 | 490 | snoozedAt: null, |
| 488 | 491 | pinnedAt: "2026-02-24T00:00:01.000Z", |
| 489 | 492 | pinOrderKey: "gm", |
| 493 | activeOrderKey: "hq", | |
| 490 | 494 | titleRegeneration: null, |
| 491 | 495 | session: { |
| 492 | 496 | threadId: ThreadId.make("thread-1"), |
| ... | ... | @@ -513,6 +517,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 513 | 517 | } |
| 514 | 518 | |
| 515 | 519 | const commandSnapshot = yield* snapshotQuery.getCommandReadModel(); |
| 520 | assert.equal(commandSnapshot.threads[0]?.activeOrderKey, "hq"); | |
| 516 | 521 | assert.deepEqual(commandSnapshot.threads[0]?.branchPullRequest, branchPullRequest); |
| 517 | 522 | const threadShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1")); |
| 518 | 523 | assert.equal(threadShell._tag, "Some"); |
| ... | ... | @@ -560,6 +565,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 560 | 565 | ); |
| 561 | 566 | assert.equal(detailWithoutActivities._tag, "Some"); |
| 562 | 567 | if (detailWithoutActivities._tag === "Some") { |
| 568 | assert.equal(detailWithoutActivities.value.activeOrderKey, "hq"); | |
| 563 | 569 | assert.deepEqual(detailWithoutActivities.value.activities, []); |
| 564 | 570 | assert.deepEqual(detailWithoutActivities.value.messages, snapshot.threads[0]?.messages); |
| 565 | 571 | assert.deepEqual( |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+10| ... | ... | @@ -513,6 +513,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 513 | 513 | snoozed_at AS "snoozedAt", |
| 514 | 514 | pinned_at AS "pinnedAt", |
| 515 | 515 | pin_order_key AS "pinOrderKey", |
| 516 | active_order_key AS "activeOrderKey", | |
| 516 | 517 | title_regeneration_request_id AS "titleRegenerationRequestId", |
| 517 | 518 | title_regeneration_started_at AS "titleRegenerationStartedAt", |
| 518 | 519 | latest_user_message_at AS "latestUserMessageAt", |
| ... | ... | @@ -552,6 +553,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 552 | 553 | snoozed_at AS "snoozedAt", |
| 553 | 554 | pinned_at AS "pinnedAt", |
| 554 | 555 | pin_order_key AS "pinOrderKey", |
| 556 | active_order_key AS "activeOrderKey", | |
| 555 | 557 | title_regeneration_request_id AS "titleRegenerationRequestId", |
| 556 | 558 | title_regeneration_started_at AS "titleRegenerationStartedAt", |
| 557 | 559 | latest_user_message_at AS "latestUserMessageAt", |
| ... | ... | @@ -593,6 +595,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 593 | 595 | snoozed_at AS "snoozedAt", |
| 594 | 596 | pinned_at AS "pinnedAt", |
| 595 | 597 | pin_order_key AS "pinOrderKey", |
| 598 | active_order_key AS "activeOrderKey", | |
| 596 | 599 | title_regeneration_request_id AS "titleRegenerationRequestId", |
| 597 | 600 | title_regeneration_started_at AS "titleRegenerationStartedAt", |
| 598 | 601 | latest_user_message_at AS "latestUserMessageAt", |
| ... | ... | @@ -1083,6 +1086,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1083 | 1086 | snoozed_at AS "snoozedAt", |
| 1084 | 1087 | pinned_at AS "pinnedAt", |
| 1085 | 1088 | pin_order_key AS "pinOrderKey", |
| 1089 | active_order_key AS "activeOrderKey", | |
| 1086 | 1090 | title_regeneration_request_id AS "titleRegenerationRequestId", |
| 1087 | 1091 | title_regeneration_started_at AS "titleRegenerationStartedAt", |
| 1088 | 1092 | latest_user_message_at AS "latestUserMessageAt", |
| ... | ... | @@ -2082,6 +2086,7 @@ pending_approval_requests AS ( |
| 2082 | 2086 | snoozedAt: row.snoozedAt, |
| 2083 | 2087 | pinnedAt: row.pinnedAt, |
| 2084 | 2088 | pinOrderKey: row.pinOrderKey ?? null, |
| 2089 | activeOrderKey: row.activeOrderKey ?? null, | |
| 2085 | 2090 | titleRegeneration: mapTitleRegeneration(row), |
| 2086 | 2091 | deletedAt: row.deletedAt, |
| 2087 | 2092 | messages: messagesByThread.get(row.threadId) ?? [], |
| ... | ... | @@ -2296,6 +2301,7 @@ pending_approval_requests AS ( |
| 2296 | 2301 | snoozedAt: row.snoozedAt, |
| 2297 | 2302 | pinnedAt: row.pinnedAt, |
| 2298 | 2303 | pinOrderKey: row.pinOrderKey ?? null, |
| 2304 | activeOrderKey: row.activeOrderKey ?? null, | |
| 2299 | 2305 | titleRegeneration: mapTitleRegeneration(row), |
| 2300 | 2306 | deletedAt: row.deletedAt, |
| 2301 | 2307 | messages: [], |
| ... | ... | @@ -2437,6 +2443,7 @@ pending_approval_requests AS ( |
| 2437 | 2443 | snoozedAt: row.snoozedAt, |
| 2438 | 2444 | pinnedAt: row.pinnedAt, |
| 2439 | 2445 | pinOrderKey: row.pinOrderKey ?? null, |
| 2446 | activeOrderKey: row.activeOrderKey ?? null, | |
| 2440 | 2447 | titleRegeneration: mapTitleRegeneration(row), |
| 2441 | 2448 | session: sessionByThread.get(row.threadId) ?? null, |
| 2442 | 2449 | latestUserMessageAt: row.latestUserMessageAt, |
| ... | ... | @@ -2586,6 +2593,7 @@ pending_approval_requests AS ( |
| 2586 | 2593 | snoozedAt: row.snoozedAt, |
| 2587 | 2594 | pinnedAt: row.pinnedAt, |
| 2588 | 2595 | pinOrderKey: row.pinOrderKey ?? null, |
| 2596 | activeOrderKey: row.activeOrderKey ?? null, | |
| 2589 | 2597 | titleRegeneration: mapTitleRegeneration(row), |
| 2590 | 2598 | session: sessionByThread.get(row.threadId) ?? null, |
| 2591 | 2599 | latestUserMessageAt: row.latestUserMessageAt, |
| ... | ... | @@ -2908,6 +2916,7 @@ pending_approval_requests AS ( |
| 2908 | 2916 | snoozedAt: threadRow.value.snoozedAt, |
| 2909 | 2917 | pinnedAt: threadRow.value.pinnedAt, |
| 2910 | 2918 | pinOrderKey: threadRow.value.pinOrderKey ?? null, |
| 2919 | activeOrderKey: threadRow.value.activeOrderKey ?? null, | |
| 2911 | 2920 | titleRegeneration: mapTitleRegeneration(threadRow.value), |
| 2912 | 2921 | session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, |
| 2913 | 2922 | latestUserMessageAt: threadRow.value.latestUserMessageAt, |
| ... | ... | @@ -3190,6 +3199,7 @@ pending_approval_requests AS ( |
| 3190 | 3199 | snoozedAt: threadRow.value.snoozedAt, |
| 3191 | 3200 | pinnedAt: threadRow.value.pinnedAt, |
| 3192 | 3201 | pinOrderKey: threadRow.value.pinOrderKey ?? null, |
| 3202 | activeOrderKey: threadRow.value.activeOrderKey ?? null, | |
| 3193 | 3203 | titleRegeneration: mapTitleRegeneration(threadRow.value), |
| 3194 | 3204 | deletedAt: null, |
| 3195 | 3205 | messages: messageRows.map((row) => { |
apps/server/src/orchestration/decider.active-order.test.ts created+200| ... | ... | @@ -0,0 +1,200 @@ |
| 1 | import { | |
| 2 | CommandId, | |
| 3 | ProjectId, | |
| 4 | ProviderInstanceId, | |
| 5 | ThreadId, | |
| 6 | type OrchestrationCommand, | |
| 7 | type OrchestrationReadModel, | |
| 8 | type OrchestrationThread, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 11 | import { expect, it } from "@effect/vitest"; | |
| 12 | import * as Effect from "effect/Effect"; | |
| 13 | ||
| 14 | import { decideOrchestrationCommand } from "./decider.ts"; | |
| 15 | import { projectEvent } from "./projector.ts"; | |
| 16 | ||
| 17 | const NOW = "2026-01-01T00:00:00.000Z"; | |
| 18 | // The Effect test clock starts at the epoch. | |
| 19 | const BEFORE_NOW = "1969-12-30T00:00:00.000Z"; | |
| 20 | const SNOOZED_AT = "1969-12-31T00:00:00.000Z"; | |
| 21 | const FUTURE_WAKE = "1970-01-02T00:00:00.000Z"; | |
| 22 | const THREAD_ID = ThreadId.make("thread-1"); | |
| 23 | ||
| 24 | function makeReadModel(overrides: Partial<OrchestrationThread> = {}): OrchestrationReadModel { | |
| 25 | return { | |
| 26 | snapshotSequence: 0, | |
| 27 | projects: [], | |
| 28 | threads: [ | |
| 29 | { | |
| 30 | id: THREAD_ID, | |
| 31 | projectId: ProjectId.make("project-1"), | |
| 32 | title: "Thread", | |
| 33 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | |
| 34 | runtimeMode: "full-access", | |
| 35 | interactionMode: "default", | |
| 36 | branch: null, | |
| 37 | worktreePath: null, | |
| 38 | latestTurn: null, | |
| 39 | createdAt: NOW, | |
| 40 | updatedAt: NOW, | |
| 41 | archivedAt: null, | |
| 42 | settledOverride: null, | |
| 43 | settledAt: null, | |
| 44 | unsettledAt: null, | |
| 45 | activeOrderKey: null, | |
| 46 | snoozedUntil: null, | |
| 47 | snoozedAt: null, | |
| 48 | pinnedAt: null, | |
| 49 | pinOrderKey: null, | |
| 50 | deletedAt: null, | |
| 51 | messages: [], | |
| 52 | proposedPlans: [], | |
| 53 | activities: [], | |
| 54 | checkpoints: [], | |
| 55 | session: null, | |
| 56 | ...overrides, | |
| 57 | }, | |
| 58 | ], | |
| 59 | updatedAt: NOW, | |
| 60 | }; | |
| 61 | } | |
| 62 | ||
| 63 | const reorderCommand = { | |
| 64 | type: "thread.active.reorder", | |
| 65 | commandId: CommandId.make("cmd-active-reorder"), | |
| 66 | threadId: THREAD_ID, | |
| 67 | orderKey: "m", | |
| 68 | } as const; | |
| 69 | ||
| 70 | it.layer(NodeServices.layer)("active thread ordering", (it) => { | |
| 71 | it.effect("persists changed and repeated slots without changing thread activity timestamps", () => | |
| 72 | Effect.gen(function* () { | |
| 73 | let readModel = makeReadModel({ unsettledAt: BEFORE_NOW }); | |
| 74 | for (const orderKey of ["m", "m", "g"]) { | |
| 75 | const decided = yield* decideOrchestrationCommand({ | |
| 76 | command: { ...reorderCommand, orderKey }, | |
| 77 | readModel, | |
| 78 | }); | |
| 79 | const events = Array.isArray(decided) ? decided : [decided]; | |
| 80 | expect(events).toHaveLength(1); | |
| 81 | expect(events[0]).toMatchObject({ | |
| 82 | type: "thread.meta-updated", | |
| 83 | payload: { threadId: THREAD_ID, activeOrderKey: orderKey, updatedAt: NOW }, | |
| 84 | }); | |
| 85 | for (const event of events) { | |
| 86 | readModel = yield* projectEvent(readModel, { | |
| 87 | ...event, | |
| 88 | sequence: readModel.snapshotSequence + 1, | |
| 89 | }); | |
| 90 | } | |
| 91 | expect(readModel.threads[0]).toMatchObject({ | |
| 92 | activeOrderKey: orderKey, | |
| 93 | updatedAt: NOW, | |
| 94 | createdAt: NOW, | |
| 95 | unsettledAt: BEFORE_NOW, | |
| 96 | }); | |
| 97 | } | |
| 98 | }), | |
| 99 | ); | |
| 100 | ||
| 101 | for (const [label, overrides] of [ | |
| 102 | ["archived", { archivedAt: NOW }], | |
| 103 | ["deleted", { deletedAt: NOW }], | |
| 104 | ["pinned", { pinnedAt: NOW }], | |
| 105 | ["settled", { settledOverride: "settled", settledAt: NOW }], | |
| 106 | ] satisfies ReadonlyArray<readonly [string, Partial<OrchestrationThread>]>) { | |
| 107 | it.effect(`rejects reordering a ${label} thread`, () => | |
| 108 | Effect.gen(function* () { | |
| 109 | const error = yield* decideOrchestrationCommand({ | |
| 110 | command: reorderCommand, | |
| 111 | readModel: makeReadModel(overrides), | |
| 112 | }).pipe(Effect.flip); | |
| 113 | expect(error._tag).toBe("OrchestrationCommandInvariantError"); | |
| 114 | }), | |
| 115 | ); | |
| 116 | } | |
| 117 | ||
| 118 | it.effect("reorders a running thread without affecting its session", () => | |
| 119 | Effect.gen(function* () { | |
| 120 | const readModel = makeReadModel({ | |
| 121 | session: { | |
| 122 | threadId: THREAD_ID, | |
| 123 | status: "running", | |
| 124 | providerName: "codex", | |
| 125 | runtimeMode: "full-access", | |
| 126 | activeTurnId: null, | |
| 127 | lastError: null, | |
| 128 | updatedAt: NOW, | |
| 129 | }, | |
| 130 | }); | |
| 131 | const decided = yield* decideOrchestrationCommand({ command: reorderCommand, readModel }); | |
| 132 | const events = Array.isArray(decided) ? decided : [decided]; | |
| 133 | expect(events).toHaveLength(1); | |
| 134 | for (const event of events) { | |
| 135 | const projected = yield* projectEvent(readModel, { ...event, sequence: 1 }); | |
| 136 | expect(projected.threads[0]).toEqual({ ...readModel.threads[0], activeOrderKey: "m" }); | |
| 137 | } | |
| 138 | }), | |
| 139 | ); | |
| 140 | ||
| 141 | it.effect( | |
| 142 | "changes a snoozed thread's retained slot without waking it or changing timestamps", | |
| 143 | () => | |
| 144 | Effect.gen(function* () { | |
| 145 | const readModel = makeReadModel({ | |
| 146 | activeOrderKey: "g", | |
| 147 | snoozedAt: SNOOZED_AT, | |
| 148 | snoozedUntil: FUTURE_WAKE, | |
| 149 | unsettledAt: BEFORE_NOW, | |
| 150 | }); | |
| 151 | const decided = yield* decideOrchestrationCommand({ command: reorderCommand, readModel }); | |
| 152 | const events = Array.isArray(decided) ? decided : [decided]; | |
| 153 | expect(events).toHaveLength(1); | |
| 154 | for (const event of events) { | |
| 155 | const projected = yield* projectEvent(readModel, { ...event, sequence: 1 }); | |
| 156 | expect(projected.threads[0]).toEqual({ ...readModel.threads[0], activeOrderKey: "m" }); | |
| 157 | } | |
| 158 | }), | |
| 159 | ); | |
| 160 | ||
| 161 | it.effect("keeps placement through metadata, pin and snooze, then resets it on settlement", () => | |
| 162 | Effect.gen(function* () { | |
| 163 | let readModel = makeReadModel(); | |
| 164 | const steps = [ | |
| 165 | [reorderCommand, "m"], | |
| 166 | [{ type: "thread.meta.update", title: "Renamed" }, "m"], | |
| 167 | [{ type: "thread.pin", orderKey: "g" }, "m"], | |
| 168 | [{ type: "thread.snooze", snoozedUntil: FUTURE_WAKE }, "m"], | |
| 169 | [{ type: "thread.unsnooze", reason: "user" }, "m"], | |
| 170 | [{ type: "thread.unpin" }, "m"], | |
| 171 | [{ type: "thread.settle" }, null], | |
| 172 | [{ type: "thread.unsettle", reason: "user" }, null], | |
| 173 | [{ type: "thread.active.reorder", orderKey: "s" }, "s"], | |
| 174 | ] as const; | |
| 175 | for (const [index, [step, expectedKey]] of steps.entries()) { | |
| 176 | const command: OrchestrationCommand = { | |
| 177 | ...step, | |
| 178 | commandId: CommandId.make(`lifecycle-${index}`), | |
| 179 | threadId: THREAD_ID, | |
| 180 | }; | |
| 181 | const decided = yield* decideOrchestrationCommand({ command, readModel }); | |
| 182 | const events = Array.isArray(decided) ? decided : [decided]; | |
| 183 | for (const event of events) { | |
| 184 | readModel = yield* projectEvent(readModel, { | |
| 185 | ...event, | |
| 186 | sequence: readModel.snapshotSequence + 1, | |
| 187 | }); | |
| 188 | } | |
| 189 | expect(readModel.threads[0]?.activeOrderKey, command.type).toBe(expectedKey); | |
| 190 | } | |
| 191 | expect(readModel.threads[0]).toMatchObject({ | |
| 192 | title: "Renamed", | |
| 193 | settledOverride: "active", | |
| 194 | settledAt: null, | |
| 195 | snoozedUntil: null, | |
| 196 | pinnedAt: null, | |
| 197 | }); | |
| 198 | }), | |
| 199 | ); | |
| 200 | }); |
apps/server/src/orchestration/decider.ts+36| ... | ... | @@ -788,6 +788,42 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 788 | 788 | }; |
| 789 | 789 | } |
| 790 | 790 | |
| 791 | case "thread.active.reorder": { | |
| 792 | const thread = yield* requireThreadNotArchived({ | |
| 793 | readModel, | |
| 794 | command, | |
| 795 | threadId: command.threadId, | |
| 796 | }); | |
| 797 | const occurredAt = yield* nowIso; | |
| 798 | // Snooze retains this slot. Changing it cannot wake the thread, and | |
| 799 | // accepting it handles races with snooze and retained wake timestamps. | |
| 800 | if ( | |
| 801 | thread.deletedAt !== null || | |
| 802 | thread.pinnedAt != null || | |
| 803 | thread.settledOverride === "settled" | |
| 804 | ) { | |
| 805 | return yield* new OrchestrationCommandInvariantError({ | |
| 806 | commandType: command.type, | |
| 807 | detail: `thread ${command.threadId} is not active and cannot be reordered`, | |
| 808 | }); | |
| 809 | } | |
| 810 | return { | |
| 811 | ...(yield* withEventBase({ | |
| 812 | aggregateKind: "thread", | |
| 813 | aggregateId: command.threadId, | |
| 814 | occurredAt, | |
| 815 | commandId: command.commandId, | |
| 816 | })), | |
| 817 | type: "thread.meta-updated", | |
| 818 | payload: { | |
| 819 | threadId: command.threadId, | |
| 820 | activeOrderKey: command.orderKey, | |
| 821 | // Arranging the list is not thread activity or a lifecycle transition. | |
| 822 | updatedAt: thread.updatedAt, | |
| 823 | }, | |
| 824 | }; | |
| 825 | } | |
| 826 | ||
| 791 | 827 | case "thread.meta.update": { |
| 792 | 828 | const thread = yield* requireThread({ |
| 793 | 829 | readModel, |
apps/server/src/orchestration/projector.test.ts+1| ... | ... | @@ -91,6 +91,7 @@ describe("orchestration projector", () => { |
| 91 | 91 | createdAt: now, |
| 92 | 92 | updatedAt: now, |
| 93 | 93 | archivedAt: null, |
| 94 | activeOrderKey: null, | |
| 94 | 95 | settledOverride: null, |
| 95 | 96 | settledAt: null, |
| 96 | 97 | unsettledAt: null, |
apps/server/src/orchestration/projector.ts+5| ... | ... | @@ -344,6 +344,7 @@ export function projectEvent( |
| 344 | 344 | settledOverride: null, |
| 345 | 345 | settledAt: null, |
| 346 | 346 | unsettledAt: null, |
| 347 | activeOrderKey: null, | |
| 347 | 348 | snoozedUntil: null, |
| 348 | 349 | snoozedAt: null, |
| 349 | 350 | deletedAt: null, |
| ... | ... | @@ -406,6 +407,7 @@ export function projectEvent( |
| 406 | 407 | settledOverride: "settled", |
| 407 | 408 | settledAt: payload.settledAt, |
| 408 | 409 | unsettledAt: null, |
| 410 | activeOrderKey: null, | |
| 409 | 411 | updatedAt: payload.updatedAt, |
| 410 | 412 | }), |
| 411 | 413 | })), |
| ... | ... | @@ -500,6 +502,9 @@ export function projectEvent( |
| 500 | 502 | ...nextBase, |
| 501 | 503 | threads: updateThread(nextBase.threads, payload.threadId, { |
| 502 | 504 | ...(payload.title !== undefined ? { title: payload.title } : {}), |
| 505 | ...(payload.activeOrderKey !== undefined | |
| 506 | ? { activeOrderKey: payload.activeOrderKey } | |
| 507 | : {}), | |
| 503 | 508 | ...(payload.titleRegeneration !== undefined |
| 504 | 509 | ? { titleRegeneration: payload.titleRegeneration } |
| 505 | 510 | : {}), |
apps/server/src/persistence/Layers/ProjectionThreads.ts+5| ... | ... | @@ -54,6 +54,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 54 | 54 | snoozed_at, |
| 55 | 55 | pinned_at, |
| 56 | 56 | pin_order_key, |
| 57 | active_order_key, | |
| 57 | 58 | title_regeneration_request_id, |
| 58 | 59 | title_regeneration_started_at, |
| 59 | 60 | latest_user_message_at, |
| ... | ... | @@ -84,6 +85,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 84 | 85 | ${row.snoozedAt}, |
| 85 | 86 | ${row.pinnedAt}, |
| 86 | 87 | ${row.pinOrderKey ?? null}, |
| 88 | ${row.activeOrderKey ?? null}, | |
| 87 | 89 | ${row.titleRegenerationRequestId ?? null}, |
| 88 | 90 | ${row.titleRegenerationStartedAt ?? null}, |
| 89 | 91 | ${row.latestUserMessageAt}, |
| ... | ... | @@ -114,6 +116,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 114 | 116 | snoozed_at = excluded.snoozed_at, |
| 115 | 117 | pinned_at = excluded.pinned_at, |
| 116 | 118 | pin_order_key = excluded.pin_order_key, |
| 119 | active_order_key = excluded.active_order_key, | |
| 117 | 120 | title_regeneration_request_id = excluded.title_regeneration_request_id, |
| 118 | 121 | title_regeneration_started_at = excluded.title_regeneration_started_at, |
| 119 | 122 | latest_user_message_at = excluded.latest_user_message_at, |
| ... | ... | @@ -151,6 +154,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 151 | 154 | snoozed_at AS "snoozedAt", |
| 152 | 155 | pinned_at AS "pinnedAt", |
| 153 | 156 | pin_order_key AS "pinOrderKey", |
| 157 | active_order_key AS "activeOrderKey", | |
| 154 | 158 | title_regeneration_request_id AS "titleRegenerationRequestId", |
| 155 | 159 | title_regeneration_started_at AS "titleRegenerationStartedAt", |
| 156 | 160 | latest_user_message_at AS "latestUserMessageAt", |
| ... | ... | @@ -190,6 +194,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 190 | 194 | snoozed_at AS "snoozedAt", |
| 191 | 195 | pinned_at AS "pinnedAt", |
| 192 | 196 | pin_order_key AS "pinOrderKey", |
| 197 | active_order_key AS "activeOrderKey", | |
| 193 | 198 | title_regeneration_request_id AS "titleRegenerationRequestId", |
| 194 | 199 | title_regeneration_started_at AS "titleRegenerationStartedAt", |
| 195 | 200 | latest_user_message_at AS "latestUserMessageAt", |
apps/server/src/persistence/Migrations.ts+2| ... | ... | @@ -60,6 +60,7 @@ import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts"; |
| 60 | 60 | import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps.ts"; |
| 61 | 61 | import Migration0047 from "./Migrations/047_ProjectionProjectIcon.ts"; |
| 62 | 62 | import Migration0048 from "./Migrations/048_ProjectionThreadBranchPullRequest.ts"; |
| 63 | import Migration0049 from "./Migrations/049_ProjectionThreadsActiveOrderKey.ts"; | |
| 63 | 64 | |
| 64 | 65 | /** |
| 65 | 66 | * Migration loader with all migrations defined inline. |
| ... | ... | @@ -120,6 +121,7 @@ export const migrationEntries = [ |
| 120 | 121 | [46, "RepairAutomaticSettlementTimestamps", Migration0046], |
| 121 | 122 | [47, "ProjectionProjectIcon", Migration0047], |
| 122 | 123 | [48, "ProjectionThreadBranchPullRequest", Migration0048], |
| 124 | [49, "ProjectionThreadsActiveOrderKey", Migration0049], | |
| 123 | 125 | ] as const; |
| 124 | 126 | |
| 125 | 127 | export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); |
apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.test.ts created+44| ... | ... | @@ -0,0 +1,44 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 4 | import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; | |
| 5 | ||
| 6 | import { runMigrations } from "../Migrations.ts"; | |
| 7 | import migrateActiveOrderKey from "./049_ProjectionThreadsActiveOrderKey.ts"; | |
| 8 | ||
| 9 | it.layer(NodeSqliteClient.layerMemory())("049_ProjectionThreadsActiveOrderKey", (it) => { | |
| 10 | it.effect("migrates old threads without changing their timestamps or assigning an order", () => | |
| 11 | Effect.gen(function* () { | |
| 12 | const sql = yield* SqlClient.SqlClient; | |
| 13 | yield* runMigrations({ toMigrationInclusive: 48 }); | |
| 14 | const now = "2026-01-01T00:00:00.000Z"; | |
| 15 | yield* sql` | |
| 16 | INSERT INTO projection_threads ( | |
| 17 | thread_id, project_id, title, model_selection_json, runtime_mode, | |
| 18 | created_at, updated_at | |
| 19 | ) VALUES ( | |
| 20 | 'thread-1', 'project-1', 'Existing thread', | |
| 21 | '{"instanceId":"codex","model":"gpt-5.4"}', 'full-access', ${now}, ${now} | |
| 22 | ) | |
| 23 | `; | |
| 24 | yield* runMigrations({ toMigrationInclusive: 49 }); | |
| 25 | const migrated = yield* sql<{ readonly activeOrderKey: string | null }>` | |
| 26 | SELECT active_order_key AS "activeOrderKey" FROM projection_threads WHERE thread_id = 'thread-1' | |
| 27 | `; | |
| 28 | assert.deepEqual(migrated, [{ activeOrderKey: null }]); | |
| 29 | // Recovery may run the same migration against a database that already | |
| 30 | // has the column, including a placement written after the upgrade. | |
| 31 | yield* sql`UPDATE projection_threads SET active_order_key = 'gm' WHERE thread_id = 'thread-1'`; | |
| 32 | yield* migrateActiveOrderKey; | |
| 33 | const rows = yield* sql<{ | |
| 34 | readonly activeOrderKey: string | null; | |
| 35 | readonly createdAt: string; | |
| 36 | readonly updatedAt: string; | |
| 37 | }>` | |
| 38 | SELECT active_order_key AS "activeOrderKey", created_at AS "createdAt", updated_at AS "updatedAt" | |
| 39 | FROM projection_threads WHERE thread_id = 'thread-1' | |
| 40 | `; | |
| 41 | assert.deepEqual(rows, [{ activeOrderKey: "gm", createdAt: now, updatedAt: now }]); | |
| 42 | }), | |
| 43 | ); | |
| 44 | }); |
apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.ts created+15| ... | ... | @@ -0,0 +1,15 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | ||
| 4 | export default Effect.gen(function* () { | |
| 5 | const sql = yield* SqlClient.SqlClient; | |
| 6 | const columns = yield* sql<{ readonly name: string }>` | |
| 7 | PRAGMA table_info(projection_threads) | |
| 8 | `; | |
| 9 | if (!columns.some((column) => column.name === "active_order_key")) { | |
| 10 | yield* sql` | |
| 11 | ALTER TABLE projection_threads | |
| 12 | ADD COLUMN active_order_key TEXT | |
| 13 | `; | |
| 14 | } | |
| 15 | }); |
apps/server/src/persistence/Services/ProjectionThreads.ts+1| ... | ... | @@ -47,6 +47,7 @@ export const ProjectionThread = Schema.Struct({ |
| 47 | 47 | snoozedAt: Schema.NullOr(IsoDateTime), |
| 48 | 48 | pinnedAt: Schema.NullOr(IsoDateTime), |
| 49 | 49 | pinOrderKey: Schema.optional(Schema.NullOr(Schema.String)), |
| 50 | activeOrderKey: Schema.optional(Schema.NullOr(Schema.String)), | |
| 50 | 51 | titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)), |
| 51 | 52 | titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)), |
| 52 | 53 | latestUserMessageAt: Schema.NullOr(IsoDateTime), |
packages/client-runtime/src/operations/commands.test.ts+21| ... | ... | @@ -24,6 +24,7 @@ import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; |
| 24 | 24 | import { |
| 25 | 25 | archiveThread, |
| 26 | 26 | createProject, |
| 27 | reorderActiveThread, | |
| 27 | 28 | settleThread, |
| 28 | 29 | stopThreadSession, |
| 29 | 30 | unsettleThread, |
| ... | ... | @@ -172,4 +173,24 @@ describe("environment commands", () => { |
| 172 | 173 | ]); |
| 173 | 174 | }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), |
| 174 | 175 | ); |
| 176 | ||
| 177 | it.effect("sends an active order key without changing activity timestamps", () => | |
| 178 | Effect.gen(function* () { | |
| 179 | const dispatched: ClientOrchestrationCommand[] = []; | |
| 180 | const supervisor = yield* makeSupervisor(dispatched); | |
| 181 | yield* reorderActiveThread({ | |
| 182 | commandId: CommandId.make("reorder-command"), | |
| 183 | threadId: ThreadId.make("thread-1"), | |
| 184 | orderKey: "mf", | |
| 185 | }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); | |
| 186 | expect(dispatched).toEqual([ | |
| 187 | { | |
| 188 | type: "thread.active.reorder", | |
| 189 | commandId: "reorder-command", | |
| 190 | threadId: "thread-1", | |
| 191 | orderKey: "mf", | |
| 192 | }, | |
| 193 | ]); | |
| 194 | }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), | |
| 195 | ); | |
| 175 | 196 | }); |
packages/client-runtime/src/operations/commands.ts+11| ... | ... | @@ -42,6 +42,7 @@ export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">; |
| 42 | 42 | export type PinThreadInput = CommandInput<"thread.pin">; |
| 43 | 43 | export type UnpinThreadInput = CommandInput<"thread.unpin">; |
| 44 | 44 | export type ReorderPinnedThreadInput = CommandInput<"thread.pin.reorder">; |
| 45 | export type ReorderActiveThreadInput = CommandInput<"thread.active.reorder">; | |
| 45 | 46 | export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; |
| 46 | 47 | export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; |
| 47 | 48 | export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; |
| ... | ... | @@ -230,6 +231,16 @@ export const reorderPinnedThread: (input: ReorderPinnedThreadInput) => CommandEf |
| 230 | 231 | }); |
| 231 | 232 | }); |
| 232 | 233 | |
| 234 | export const reorderActiveThread: (input: ReorderActiveThreadInput) => CommandEffect = Effect.fn( | |
| 235 | "EnvironmentCommands.reorderActiveThread", | |
| 236 | )(function* (input) { | |
| 237 | return yield* dispatch({ | |
| 238 | ...input, | |
| 239 | type: "thread.active.reorder", | |
| 240 | commandId: yield* commandId(input), | |
| 241 | }); | |
| 242 | }); | |
| 243 | ||
| 233 | 244 | export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn( |
| 234 | 245 | "EnvironmentCommands.updateThreadMetadata", |
| 235 | 246 | )(function* (input) { |
packages/client-runtime/src/state/entities.test.ts+6| ... | ... | @@ -208,6 +208,8 @@ describe("environment entity projections", () => { |
| 208 | 208 | title: "Cached thread", |
| 209 | 209 | branch: "stale-branch", |
| 210 | 210 | worktreePath: "/repo/stale-worktree", |
| 211 | activeOrderKey: "t", | |
| 212 | unsettledAt: "2026-03-09T10:00:00.000Z", | |
| 211 | 213 | deletedAt: null, |
| 212 | 214 | messages, |
| 213 | 215 | proposedPlans: [], |
| ... | ... | @@ -220,6 +222,8 @@ describe("environment entity projections", () => { |
| 220 | 222 | title: "Current thread", |
| 221 | 223 | branch: "current-branch", |
| 222 | 224 | worktreePath: "/repo/current-worktree", |
| 225 | activeOrderKey: "f", | |
| 226 | unsettledAt: "2026-03-09T12:00:00.000Z", | |
| 223 | 227 | }; |
| 224 | 228 | |
| 225 | 229 | const merged = mergeEnvironmentThread(detail, shell); |
| ... | ... | @@ -228,6 +232,8 @@ describe("environment entity projections", () => { |
| 228 | 232 | title: "Current thread", |
| 229 | 233 | branch: "current-branch", |
| 230 | 234 | worktreePath: "/repo/current-worktree", |
| 235 | activeOrderKey: "f", | |
| 236 | unsettledAt: "2026-03-09T12:00:00.000Z", | |
| 231 | 237 | }); |
| 232 | 238 | expect(merged?.messages).toBe(messages); |
| 233 | 239 | }); |
packages/client-runtime/src/state/threadCommands.ts+9| ... | ... | @@ -19,6 +19,7 @@ import { |
| 19 | 19 | type SetThreadRuntimeModeInput, |
| 20 | 20 | type PinThreadInput, |
| 21 | 21 | type ReorderPinnedThreadInput, |
| 22 | type ReorderActiveThreadInput, | |
| 22 | 23 | type SettleThreadInput, |
| 23 | 24 | type SnoozeThreadInput, |
| 24 | 25 | type StartThreadTurnInput, |
| ... | ... | @@ -39,6 +40,7 @@ import { |
| 39 | 40 | setThreadRuntimeMode, |
| 40 | 41 | pinThread, |
| 41 | 42 | reorderPinnedThread, |
| 43 | reorderActiveThread, | |
| 42 | 44 | settleThread, |
| 43 | 45 | snoozeThread, |
| 44 | 46 | startThreadTurn, |
| ... | ... | @@ -63,6 +65,7 @@ export type { |
| 63 | 65 | SetThreadRuntimeModeInput, |
| 64 | 66 | PinThreadInput, |
| 65 | 67 | ReorderPinnedThreadInput, |
| 68 | ReorderActiveThreadInput, | |
| 66 | 69 | SettleThreadInput, |
| 67 | 70 | SnoozeThreadInput, |
| 68 | 71 | StartThreadTurnInput, |
| ... | ... | @@ -150,6 +153,12 @@ export function createThreadEnvironmentAtoms<R, E>( |
| 150 | 153 | scheduler, |
| 151 | 154 | concurrency, |
| 152 | 155 | }), |
| 156 | reorderActive: createEnvironmentCommand(runtime, { | |
| 157 | label: "environment-data:commands:thread:reorder-active", | |
| 158 | execute: (input: ReorderActiveThreadInput) => reorderActiveThread(input), | |
| 159 | scheduler, | |
| 160 | concurrency, | |
| 161 | }), | |
| 153 | 162 | updateMetadata: createEnvironmentCommand(runtime, { |
| 154 | 163 | label: "environment-data:commands:thread:update-metadata", |
| 155 | 164 | execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input), |
packages/client-runtime/src/state/threadDetail.ts+2| ... | ... | @@ -58,6 +58,8 @@ export function mergeEnvironmentThread( |
| 58 | 58 | archivedAt: shell.archivedAt, |
| 59 | 59 | settledOverride: shell.settledOverride, |
| 60 | 60 | settledAt: shell.settledAt, |
| 61 | unsettledAt: shell.unsettledAt, | |
| 62 | activeOrderKey: shell.activeOrderKey, | |
| 61 | 63 | snoozedUntil: shell.snoozedUntil, |
| 62 | 64 | snoozedAt: shell.snoozedAt, |
| 63 | 65 | pinnedAt: shell.pinnedAt, |
packages/client-runtime/src/state/threadReducer.test.ts+76-37| ... | ... | @@ -178,24 +178,28 @@ describe("applyThreadDetailEvent", () => { |
| 178 | 178 | describe("thread.settled / thread.unsettled", () => { |
| 179 | 179 | it("sets the settled override and timestamp", () => { |
| 180 | 180 | const settledAt = "2026-04-01T05:00:00.000Z"; |
| 181 | const result = applyThreadDetailEvent(baseThread, { | |
| 182 | ...baseEventFields, | |
| 183 | sequence: 5, | |
| 184 | occurredAt: settledAt, | |
| 185 | aggregateKind: "thread", | |
| 186 | aggregateId: ThreadId.make("thread-1"), | |
| 187 | type: "thread.settled", | |
| 188 | payload: { | |
| 189 | threadId: ThreadId.make("thread-1"), | |
| 190 | settledAt, | |
| 191 | updatedAt: settledAt, | |
| 181 | const result = applyThreadDetailEvent( | |
| 182 | { ...baseThread, activeOrderKey: "m" }, | |
| 183 | { | |
| 184 | ...baseEventFields, | |
| 185 | sequence: 5, | |
| 186 | occurredAt: settledAt, | |
| 187 | aggregateKind: "thread", | |
| 188 | aggregateId: ThreadId.make("thread-1"), | |
| 189 | type: "thread.settled", | |
| 190 | payload: { | |
| 191 | threadId: ThreadId.make("thread-1"), | |
| 192 | settledAt, | |
| 193 | updatedAt: settledAt, | |
| 194 | }, | |
| 192 | 195 | }, |
| 193 | }); | |
| 196 | ); | |
| 194 | 197 | |
| 195 | 198 | expect(result.kind).toBe("updated"); |
| 196 | 199 | if (result.kind === "updated") { |
| 197 | 200 | expect(result.thread.settledOverride).toBe("settled"); |
| 198 | 201 | expect(result.thread.settledAt).toBe(settledAt); |
| 202 | expect(result.thread.activeOrderKey).toBeNull(); | |
| 199 | 203 | } |
| 200 | 204 | }); |
| 201 | 205 | |
| ... | ... | @@ -234,23 +238,27 @@ describe("applyThreadDetailEvent", () => { |
| 234 | 238 | describe("thread.pinned / thread.unpinned", () => { |
| 235 | 239 | it("sets pinnedAt", () => { |
| 236 | 240 | const pinnedAt = "2026-04-01T05:00:00.000Z"; |
| 237 | const result = applyThreadDetailEvent(baseThread, { | |
| 238 | ...baseEventFields, | |
| 239 | sequence: 5, | |
| 240 | occurredAt: pinnedAt, | |
| 241 | aggregateKind: "thread", | |
| 242 | aggregateId: ThreadId.make("thread-1"), | |
| 243 | type: "thread.pinned", | |
| 244 | payload: { | |
| 245 | threadId: ThreadId.make("thread-1"), | |
| 246 | pinnedAt, | |
| 247 | updatedAt: pinnedAt, | |
| 241 | const result = applyThreadDetailEvent( | |
| 242 | { ...baseThread, activeOrderKey: "m" }, | |
| 243 | { | |
| 244 | ...baseEventFields, | |
| 245 | sequence: 5, | |
| 246 | occurredAt: pinnedAt, | |
| 247 | aggregateKind: "thread", | |
| 248 | aggregateId: ThreadId.make("thread-1"), | |
| 249 | type: "thread.pinned", | |
| 250 | payload: { | |
| 251 | threadId: ThreadId.make("thread-1"), | |
| 252 | pinnedAt, | |
| 253 | updatedAt: pinnedAt, | |
| 254 | }, | |
| 248 | 255 | }, |
| 249 | }); | |
| 256 | ); | |
| 250 | 257 | |
| 251 | 258 | expect(result.kind).toBe("updated"); |
| 252 | 259 | if (result.kind === "updated") { |
| 253 | 260 | expect(result.thread.pinnedAt).toBe(pinnedAt); |
| 261 | expect(result.thread.activeOrderKey).toBe("m"); | |
| 254 | 262 | } |
| 255 | 263 | }); |
| 256 | 264 | |
| ... | ... | @@ -281,26 +289,57 @@ describe("applyThreadDetailEvent", () => { |
| 281 | 289 | }); |
| 282 | 290 | |
| 283 | 291 | describe("thread.meta-updated", () => { |
| 292 | it.each(["f", null] as const)( | |
| 293 | "updates the active key to %s without activity", | |
| 294 | (activeOrderKey) => { | |
| 295 | const result = applyThreadDetailEvent( | |
| 296 | { ...baseThread, activeOrderKey: "m" }, | |
| 297 | { | |
| 298 | ...baseEventFields, | |
| 299 | sequence: 5, | |
| 300 | occurredAt: "2026-04-01T05:00:00.000Z", | |
| 301 | aggregateKind: "thread", | |
| 302 | aggregateId: baseThread.id, | |
| 303 | type: "thread.meta-updated", | |
| 304 | payload: { | |
| 305 | threadId: baseThread.id, | |
| 306 | activeOrderKey, | |
| 307 | updatedAt: baseThread.updatedAt, | |
| 308 | }, | |
| 309 | }, | |
| 310 | ); | |
| 311 | expect(result.kind).toBe("updated"); | |
| 312 | if (result.kind === "updated") { | |
| 313 | expect(result.thread.activeOrderKey).toBe(activeOrderKey); | |
| 314 | expect(result.thread.updatedAt).toBe(baseThread.updatedAt); | |
| 315 | } | |
| 316 | }, | |
| 317 | ); | |
| 318 | ||
| 284 | 319 | it("patches title and branch", () => { |
| 285 | const result = applyThreadDetailEvent(baseThread, { | |
| 286 | ...baseEventFields, | |
| 287 | sequence: 5, | |
| 288 | occurredAt: "2026-04-01T05:00:00.000Z", | |
| 289 | aggregateKind: "thread", | |
| 290 | aggregateId: ThreadId.make("thread-1"), | |
| 291 | type: "thread.meta-updated", | |
| 292 | payload: { | |
| 293 | threadId: ThreadId.make("thread-1"), | |
| 294 | title: "Updated Title", | |
| 295 | branch: "feature/demo", | |
| 296 | updatedAt: "2026-04-01T05:00:00.000Z", | |
| 320 | const result = applyThreadDetailEvent( | |
| 321 | { ...baseThread, activeOrderKey: "m" }, | |
| 322 | { | |
| 323 | ...baseEventFields, | |
| 324 | sequence: 5, | |
| 325 | occurredAt: "2026-04-01T05:00:00.000Z", | |
| 326 | aggregateKind: "thread", | |
| 327 | aggregateId: ThreadId.make("thread-1"), | |
| 328 | type: "thread.meta-updated", | |
| 329 | payload: { | |
| 330 | threadId: ThreadId.make("thread-1"), | |
| 331 | title: "Updated Title", | |
| 332 | branch: "feature/demo", | |
| 333 | updatedAt: "2026-04-01T05:00:00.000Z", | |
| 334 | }, | |
| 297 | 335 | }, |
| 298 | }); | |
| 336 | ); | |
| 299 | 337 | |
| 300 | 338 | expect(result.kind).toBe("updated"); |
| 301 | 339 | if (result.kind === "updated") { |
| 302 | 340 | expect(result.thread.title).toBe("Updated Title"); |
| 303 | 341 | expect(result.thread.branch).toBe("feature/demo"); |
| 342 | expect(result.thread.activeOrderKey).toBe("m"); | |
| 304 | 343 | // Model selection should be unchanged since it wasn't in the payload |
| 305 | 344 | expect(result.thread.modelSelection).toEqual(baseThread.modelSelection); |
| 306 | 345 | } |
packages/client-runtime/src/state/threadReducer.ts+5| ... | ... | @@ -105,6 +105,7 @@ export function applyThreadDetailEvent( |
| 105 | 105 | settledOverride: null, |
| 106 | 106 | settledAt: null, |
| 107 | 107 | unsettledAt: null, |
| 108 | activeOrderKey: null, | |
| 108 | 109 | snoozedUntil: null, |
| 109 | 110 | snoozedAt: null, |
| 110 | 111 | deletedAt: null, |
| ... | ... | @@ -144,6 +145,7 @@ export function applyThreadDetailEvent( |
| 144 | 145 | settledOverride: "settled", |
| 145 | 146 | settledAt: event.payload.settledAt, |
| 146 | 147 | unsettledAt: null, |
| 148 | activeOrderKey: null, | |
| 147 | 149 | updatedAt: event.payload.updatedAt, |
| 148 | 150 | }, |
| 149 | 151 | }; |
| ... | ... | @@ -244,6 +246,9 @@ export function applyThreadDetailEvent( |
| 244 | 246 | ...(event.payload.branchPullRequest !== undefined |
| 245 | 247 | ? { branchPullRequest: event.payload.branchPullRequest } |
| 246 | 248 | : {}), |
| 249 | ...(event.payload.activeOrderKey !== undefined | |
| 250 | ? { activeOrderKey: event.payload.activeOrderKey } | |
| 251 | : {}), | |
| 247 | 252 | updatedAt: event.payload.updatedAt, |
| 248 | 253 | }, |
| 249 | 254 | }; |
packages/client-runtime/src/state/threadSort.test.ts+176| ... | ... | @@ -1,8 +1,12 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | generateSpreadPinOrderKeys, | |
| 5 | pinOrderKeyBetween, | |
| 4 | 6 | planPinnedMove, |
| 7 | planPinnedReorder, | |
| 5 | 8 | resolveSettledThreadTimestamp, |
| 9 | sortActiveThreadsByOrderKey, | |
| 6 | 10 | sortPinnedThreadsByOrderKey, |
| 7 | 11 | sortThreads, |
| 8 | 12 | type ThreadSortInput, |
| ... | ... | @@ -108,6 +112,44 @@ describe("sortThreads", () => { |
| 108 | 112 | }); |
| 109 | 113 | }); |
| 110 | 114 | |
| 115 | describe("planPinnedReorder with hidden rows", () => { | |
| 116 | it("keeps hidden slots available when inserting between visible neighbors", () => { | |
| 117 | const midpoint = pinOrderKeyBetween("f", "t")!; | |
| 118 | const keysById = new Map([ | |
| 119 | ["a", "f"], | |
| 120 | ["b", "t"], | |
| 121 | ["moved", "z"], | |
| 122 | ["snoozed", midpoint], | |
| 123 | ]); | |
| 124 | const assignments = planPinnedReorder({ | |
| 125 | orderedIds: ["a", "moved", "b"], | |
| 126 | keysById, | |
| 127 | movedId: "moved", | |
| 128 | }); | |
| 129 | expect(assignments).toHaveLength(1); | |
| 130 | const key = assignments[0]!.orderKey; | |
| 131 | expect(key > "f" && key < "t").toBe(true); | |
| 132 | expect(key).not.toBe(midpoint); | |
| 133 | expect(assignments[0]!.id).toBe("moved"); | |
| 134 | }); | |
| 135 | ||
| 136 | it("materializes keyless rows without overwriting hidden slots", () => { | |
| 137 | const reserved = generateSpreadPinOrderKeys(6); | |
| 138 | const keysById = new Map<string, string | null>([ | |
| 139 | ["a", null], | |
| 140 | ["b", null], | |
| 141 | ["c", null], | |
| 142 | ...reserved.map((key, i) => [`hidden-${i}`, key] as const), | |
| 143 | ]); | |
| 144 | const assignments = planPinnedReorder({ orderedIds: ["c", "a", "b"], keysById, movedId: "c" }); | |
| 145 | expect(assignments.map(({ id }) => id)).toEqual(["c", "a", "b"]); | |
| 146 | const keys = assignments.map(({ orderKey }) => orderKey); | |
| 147 | expect(keys).toEqual([...keys].sort()); | |
| 148 | expect(new Set(keys).size).toBe(3); | |
| 149 | expect(keys.every((key) => !reserved.includes(key))).toBe(true); | |
| 150 | }); | |
| 151 | }); | |
| 152 | ||
| 111 | 153 | describe("planPinnedMove", () => { |
| 112 | 154 | it("moves a thread up with a single key write", () => { |
| 113 | 155 | const assignments = planPinnedMove({ |
| ... | ... | @@ -173,3 +215,137 @@ describe("sortPinnedThreadsByOrderKey", () => { |
| 173 | 215 | expect(sorted.map((thread) => thread.environmentId)).toEqual(["env-a", "env-b"]); |
| 174 | 216 | }); |
| 175 | 217 | }); |
| 218 | ||
| 219 | describe("generateSpreadPinOrderKeys", () => { | |
| 220 | it.each([0, 1, 650, 675, 676, 1_001, 2_000])( | |
| 221 | "leaves unique, insertable keys for %i threads", | |
| 222 | (count) => { | |
| 223 | const keys = generateSpreadPinOrderKeys(count); | |
| 224 | expect(keys).toHaveLength(count); | |
| 225 | expect(new Set(keys).size).toBe(count); | |
| 226 | expect([...keys].sort()).toEqual(keys); | |
| 227 | for (let index = 0; index < keys.length; index += 1) { | |
| 228 | const before = keys[index - 1] ?? null; | |
| 229 | const after = keys[index]!; | |
| 230 | expect(after).toMatch(/^[a-z]*[b-z]$/); | |
| 231 | const between = pinOrderKeyBetween(before, after); | |
| 232 | expect(between).not.toBeNull(); | |
| 233 | expect(between! < after).toBe(true); | |
| 234 | if (before !== null) expect(between! > before).toBe(true); | |
| 235 | } | |
| 236 | }, | |
| 237 | ); | |
| 238 | }); | |
| 239 | ||
| 240 | describe("sortActiveThreadsByOrderKey", () => { | |
| 241 | it("keeps new and reopened threads ahead of the saved order", () => { | |
| 242 | const sorted = sortActiveThreadsByOrderKey([ | |
| 243 | { | |
| 244 | id: "arranged-first", | |
| 245 | createdAt: "2026-03-09T09:00:00.000Z", | |
| 246 | activeOrderKey: "f", | |
| 247 | }, | |
| 248 | { | |
| 249 | id: "new", | |
| 250 | createdAt: "2026-03-09T11:00:00.000Z", | |
| 251 | activeOrderKey: null, | |
| 252 | }, | |
| 253 | { | |
| 254 | id: "arranged-last", | |
| 255 | createdAt: "2026-03-09T12:00:00.000Z", | |
| 256 | unsettledAt: "2026-03-09T13:00:00.000Z", | |
| 257 | activeOrderKey: "t", | |
| 258 | }, | |
| 259 | { | |
| 260 | id: "reopened", | |
| 261 | createdAt: "2026-03-01T09:00:00.000Z", | |
| 262 | unsettledAt: "2026-03-09T12:00:00.000Z", | |
| 263 | }, | |
| 264 | ]); | |
| 265 | expect(sorted.map((thread) => thread.id)).toEqual([ | |
| 266 | "reopened", | |
| 267 | "new", | |
| 268 | "arranged-first", | |
| 269 | "arranged-last", | |
| 270 | ]); | |
| 271 | }); | |
| 272 | ||
| 273 | it("breaks equal order keys and timestamps by thread then environment", () => { | |
| 274 | for (const activeOrderKey of [null, "m"]) { | |
| 275 | const threads = [ | |
| 276 | { id: "thread-b", environmentId: "env-a" }, | |
| 277 | { id: "thread-a", environmentId: "env-b" }, | |
| 278 | { id: "thread-a", environmentId: "env-a" }, | |
| 279 | ].map((thread) => ({ | |
| 280 | ...thread, | |
| 281 | createdAt: "2026-03-09T10:00:00.000Z", | |
| 282 | activeOrderKey, | |
| 283 | })); | |
| 284 | expect( | |
| 285 | sortActiveThreadsByOrderKey(threads).map( | |
| 286 | (thread) => `${thread.id}:${thread.environmentId}`, | |
| 287 | ), | |
| 288 | ).toEqual(["thread-a:env-a", "thread-a:env-b", "thread-b:env-a"]); | |
| 289 | } | |
| 290 | }); | |
| 291 | ||
| 292 | it("applies every move across a mixed keyless and keyed section", () => { | |
| 293 | const threads = Array.from({ length: 6 }, (_, index) => ({ | |
| 294 | id: String(index), | |
| 295 | createdAt: `2026-03-09T0${6 - index}:00:00.000Z`, | |
| 296 | activeOrderKey: index < 3 ? null : ["f", "m", "t"][index - 3]!, | |
| 297 | })); | |
| 298 | const ids = threads.map((thread) => thread.id); | |
| 299 | const keysById = new Map(threads.map((thread) => [thread.id, thread.activeOrderKey])); | |
| 300 | for (const movedId of ids) { | |
| 301 | for (let targetIndex = 0; targetIndex < ids.length; targetIndex += 1) { | |
| 302 | const desired = ids.filter((id) => id !== movedId); | |
| 303 | desired.splice(targetIndex, 0, movedId); | |
| 304 | const assignments = planPinnedReorder({ orderedIds: desired, keysById, movedId }); | |
| 305 | const nextKeys = new Map( | |
| 306 | assignments.map((assignment) => [assignment.id, assignment.orderKey]), | |
| 307 | ); | |
| 308 | const updated = threads.map((thread) => ({ | |
| 309 | ...thread, | |
| 310 | activeOrderKey: nextKeys.get(thread.id) ?? thread.activeOrderKey, | |
| 311 | })); | |
| 312 | expect(sortActiveThreadsByOrderKey(updated).map((thread) => thread.id)).toEqual(desired); | |
| 313 | } | |
| 314 | } | |
| 315 | }); | |
| 316 | ||
| 317 | it("moves a keyless thread into the arranged run with one write", () => { | |
| 318 | const assignments = planPinnedMove({ | |
| 319 | orderedIds: ["new", "reopened", "first", "last"], | |
| 320 | keysById: new Map([ | |
| 321 | ["new", null], | |
| 322 | ["reopened", null], | |
| 323 | ["first", "f"], | |
| 324 | ["last", "t"], | |
| 325 | ]), | |
| 326 | movedId: "reopened", | |
| 327 | direction: "down", | |
| 328 | }); | |
| 329 | expect(assignments).toHaveLength(1); | |
| 330 | expect(assignments![0]!.id).toBe("reopened"); | |
| 331 | expect(assignments![0]!.orderKey > "f").toBe(true); | |
| 332 | expect(assignments![0]!.orderKey < "t").toBe(true); | |
| 333 | }); | |
| 334 | ||
| 335 | it("materializes a large active list without changing the requested order", () => { | |
| 336 | const threads = Array.from({ length: 1_200 }, (_, index) => ({ | |
| 337 | id: String(index), | |
| 338 | createdAt: "2026-03-09T10:00:00.000Z", | |
| 339 | activeOrderKey: null as string | null, | |
| 340 | })); | |
| 341 | const orderedIds = threads.map((thread) => thread.id).toReversed(); | |
| 342 | const assignments = planPinnedReorder({ | |
| 343 | orderedIds, | |
| 344 | movedId: orderedIds[0]!, | |
| 345 | keysById: new Map(threads.map((thread) => [thread.id, thread.activeOrderKey])), | |
| 346 | }); | |
| 347 | const keys = new Map(assignments.map((assignment) => [assignment.id, assignment.orderKey])); | |
| 348 | const updated = threads.map((thread) => ({ ...thread, activeOrderKey: keys.get(thread.id) })); | |
| 349 | expect(sortActiveThreadsByOrderKey(updated).map((thread) => thread.id)).toEqual(orderedIds); | |
| 350 | }); | |
| 351 | }); |
packages/client-runtime/src/state/threadSort.ts+58-17| ... | ... | @@ -205,25 +205,27 @@ export function pinOrderKeyBetween(before: string | null, after: string | null): |
| 205 | 205 | return pinOrderMidpoint(a, b); |
| 206 | 206 | } |
| 207 | 207 | |
| 208 | /** Evenly spaced keys for rewriting a whole pinned section (used when a | |
| 209 | drop lands next to keyless threads, so single-key insertion has nothing | |
| 210 | to anchor on). Two base-26 digits give 675 slots — far beyond any real | |
| 211 | pinned section — with monotonicity enforced as a belt-and-braces. */ | |
| 212 | function generateSpreadPinOrderKeys(count: number): string[] { | |
| 213 | const space = PIN_ORDER_DIGITS.length * PIN_ORDER_DIGITS.length; | |
| 208 | /** Evenly spaced keys for materializing an order. Wider keys keep a large | |
| 209 | active list from exhausting the space between two-digit keys. */ | |
| 210 | export function generateSpreadPinOrderKeys(count: number): string[] { | |
| 211 | let width = 2; | |
| 212 | let space = PIN_ORDER_DIGITS.length ** width; | |
| 213 | while (space <= (count + 1) * 2) { | |
| 214 | width += 1; | |
| 215 | space *= PIN_ORDER_DIGITS.length; | |
| 216 | } | |
| 214 | 217 | const step = space / (count + 1); |
| 215 | 218 | const keys: string[] = []; |
| 216 | let previous = 0; | |
| 217 | 219 | for (let i = 0; i < count; i += 1) { |
| 218 | let value = Math.max(Math.round(step * (i + 1)), previous + 1); | |
| 220 | let value = Math.round(step * (i + 1)); | |
| 219 | 221 | // Skip values whose low digit is the minimum (a trailing "a" key). |
| 220 | 222 | if (value % PIN_ORDER_DIGITS.length === 0) value += 1; |
| 221 | value = Math.min(value, space - 1); | |
| 222 | previous = value; | |
| 223 | keys.push( | |
| 224 | PIN_ORDER_DIGITS.charAt(Math.floor(value / PIN_ORDER_DIGITS.length)) + | |
| 225 | PIN_ORDER_DIGITS.charAt(value % PIN_ORDER_DIGITS.length), | |
| 226 | ); | |
| 223 | let key = ""; | |
| 224 | for (let digit = 0; digit < width; digit += 1) { | |
| 225 | key = PIN_ORDER_DIGITS.charAt(value % PIN_ORDER_DIGITS.length) + key; | |
| 226 | value = Math.floor(value / PIN_ORDER_DIGITS.length); | |
| 227 | } | |
| 228 | keys.push(key); | |
| 227 | 229 | } |
| 228 | 230 | return keys; |
| 229 | 231 | } |
| ... | ... | @@ -233,15 +235,21 @@ function generateSpreadPinOrderKeys(count: number): string[] { |
| 233 | 235 | * sits between two keyed (or absent) neighbors, this is a single write to |
| 234 | 236 | * the moved thread. When a neighbor is keyless (threads pinned before |
| 235 | 237 | * reordering shipped), the whole section gets fresh spread keys — a |
| 236 | * one-time materialization; every move after that is single-write. | |
| 238 | * one-time materialization; every move after that is single-write. Active | |
| 239 | * reordering uses the same planner with activeOrderKey values. | |
| 237 | 240 | */ |
| 238 | 241 | export function planPinnedReorder(input: { |
| 239 | 242 | /** Thread ids in the desired visual order (after the move). */ |
| 240 | 243 | readonly orderedIds: readonly string[]; |
| 244 | /** Include retained keys from hidden rows; only orderedIds receive writes. */ | |
| 241 | 245 | readonly keysById: ReadonlyMap<string, string | null | undefined>; |
| 242 | 246 | readonly movedId: string; |
| 243 | 247 | }): ReadonlyArray<{ readonly id: string; readonly orderKey: string }> { |
| 244 | 248 | const { orderedIds, keysById, movedId } = input; |
| 249 | const visibleIds = new Set(orderedIds); | |
| 250 | const reservedKeys = new Set( | |
| 251 | [...keysById].flatMap(([id, key]) => (!visibleIds.has(id) && key != null ? [key] : [])), | |
| 252 | ); | |
| 245 | 253 | const movedIndex = orderedIds.indexOf(movedId); |
| 246 | 254 | if (movedIndex === -1) return []; |
| 247 | 255 | const beforeId = movedIndex > 0 ? orderedIds[movedIndex - 1] : null; |
| ... | ... | @@ -251,11 +259,14 @@ export function planPinnedReorder(input: { |
| 251 | 259 | const beforeUsable = beforeId === null || beforeKey != null; |
| 252 | 260 | const afterUsable = afterId === null || afterKey != null; |
| 253 | 261 | if (beforeUsable && afterUsable) { |
| 254 | const key = pinOrderKeyBetween(beforeKey, afterKey); | |
| 262 | let key = pinOrderKeyBetween(beforeKey, afterKey); | |
| 263 | while (key !== null && reservedKeys.has(key)) key = pinOrderKeyBetween(key, afterKey); | |
| 255 | 264 | if (key !== null) return [{ id: movedId, orderKey: key }]; |
| 256 | 265 | } |
| 257 | 266 | // Keyless neighbor (or corrupt keys): rewrite the section in the new order. |
| 258 | const keys = generateSpreadPinOrderKeys(orderedIds.length); | |
| 267 | const keys = generateSpreadPinOrderKeys(orderedIds.length + reservedKeys.size) | |
| 268 | .filter((key) => !reservedKeys.has(key)) | |
| 269 | .slice(0, orderedIds.length); | |
| 259 | 270 | return orderedIds.flatMap((id, index) => { |
| 260 | 271 | const key = keys[index]!; |
| 261 | 272 | return keysById.get(id) === key ? [] : [{ id, orderKey: key }]; |
| ... | ... | @@ -303,6 +314,36 @@ export function sortPinnedThreadsByOrderKey< |
| 303 | 314 | return [...keyed, ...keyless]; |
| 304 | 315 | } |
| 305 | 316 | |
| 317 | /** New and reopened threads lead the active list. Arranged threads follow | |
| 318 | their saved keys; activity leaves both groups in place. */ | |
| 319 | export function sortActiveThreadsByOrderKey< | |
| 320 | T extends { | |
| 321 | readonly id: string; | |
| 322 | readonly createdAt: string; | |
| 323 | readonly unsettledAt?: string | null | undefined; | |
| 324 | readonly activeOrderKey?: string | null | undefined; | |
| 325 | readonly environmentId?: string | undefined; | |
| 326 | }, | |
| 327 | >(threads: readonly T[]): T[] { | |
| 328 | return [...threads].sort((left, right) => { | |
| 329 | const leftKey = left.activeOrderKey; | |
| 330 | const rightKey = right.activeOrderKey; | |
| 331 | if (leftKey == null && rightKey != null) return -1; | |
| 332 | if (leftKey != null && rightKey == null) return 1; | |
| 333 | let order = 0; | |
| 334 | if (leftKey != null && rightKey != null) { | |
| 335 | order = leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0; | |
| 336 | } else { | |
| 337 | order = activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left); | |
| 338 | } | |
| 339 | return ( | |
| 340 | order || | |
| 341 | left.id.localeCompare(right.id) || | |
| 342 | (left.environmentId ?? "").localeCompare(right.environmentId ?? "") | |
| 343 | ); | |
| 344 | }); | |
| 345 | } | |
| 346 | ||
| 306 | 347 | /** |
| 307 | 348 | * planPinnedReorder specialized for mobile's Move up / Move down menu |
| 308 | 349 | * actions: swap the moved thread with its displayed neighbor. Null when the |
packages/contracts/src/environment.ts+2| ... | ... | @@ -116,6 +116,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ |
| 116 | 116 | /** Server understands thread.pin.reorder (and orderKey on thread.pin). |
| 117 | 117 | Same version-skew contract as threadSettlement. */ |
| 118 | 118 | threadPinReorder: Schema.optionalKey(Schema.Boolean), |
| 119 | /** Server persists manual Active order through thread.active.reorder. */ | |
| 120 | threadActiveReorder: Schema.optionalKey(Schema.Boolean), | |
| 119 | 121 | /** Server understands regenerateTitle on thread.meta.update. Absent on |
| 120 | 122 | older servers, so clients hide the action instead of sending it. */ |
| 121 | 123 | threadTitleRegeneration: Schema.optionalKey(Schema.Boolean), |
packages/contracts/src/orchestration.test.ts+55| ... | ... | @@ -784,6 +784,61 @@ it.effect("accepts a title seed in thread.turn.start", () => |
| 784 | 784 | }), |
| 785 | 785 | ); |
| 786 | 786 | |
| 787 | it.effect("decodes active reorder commands through client and orchestration boundaries", () => | |
| 788 | Effect.gen(function* () { | |
| 789 | const input = { | |
| 790 | type: "thread.active.reorder", | |
| 791 | commandId: "cmd-active-reorder", | |
| 792 | threadId: "thread-1", | |
| 793 | orderKey: "gm", | |
| 794 | }; | |
| 795 | const clientCommand = yield* decodeClientOrchestrationCommand(input); | |
| 796 | const command = yield* decodeOrchestrationCommand(input); | |
| 797 | for (const decoded of [clientCommand, command]) { | |
| 798 | assert.strictEqual(decoded.type, "thread.active.reorder"); | |
| 799 | if (decoded.type === "thread.active.reorder") { | |
| 800 | assert.strictEqual(decoded.threadId, "thread-1"); | |
| 801 | assert.strictEqual(decoded.orderKey, "gm"); | |
| 802 | } | |
| 803 | } | |
| 804 | const emptyKey = yield* Effect.exit( | |
| 805 | decodeClientOrchestrationCommand({ ...input, orderKey: " " }), | |
| 806 | ); | |
| 807 | assert.isTrue(Exit.isFailure(emptyKey)); | |
| 808 | }), | |
| 809 | ); | |
| 810 | ||
| 811 | it.effect("decodes active placement on existing metadata events while accepting old payloads", () => | |
| 812 | Effect.gen(function* () { | |
| 813 | const payload = { threadId: "thread-1", updatedAt: "2026-01-01T00:00:00.000Z" }; | |
| 814 | const oldPayload = yield* decodeThreadMetaUpdatedPayload(payload); | |
| 815 | assert.strictEqual(oldPayload.activeOrderKey, undefined); | |
| 816 | const resetPayload = yield* decodeThreadMetaUpdatedPayload({ | |
| 817 | ...payload, | |
| 818 | activeOrderKey: null, | |
| 819 | }); | |
| 820 | assert.strictEqual(resetPayload.activeOrderKey, null); | |
| 821 | const event = yield* decodeOrchestrationEvent({ | |
| 822 | type: "thread.meta-updated", | |
| 823 | sequence: 1, | |
| 824 | eventId: "event-active-reorder", | |
| 825 | aggregateKind: "thread", | |
| 826 | aggregateId: "thread-1", | |
| 827 | occurredAt: "2026-01-02T00:00:00.000Z", | |
| 828 | commandId: "cmd-active-reorder", | |
| 829 | causationEventId: null, | |
| 830 | correlationId: null, | |
| 831 | metadata: {}, | |
| 832 | payload: { ...payload, activeOrderKey: "gm" }, | |
| 833 | }); | |
| 834 | assert.strictEqual(event.type, "thread.meta-updated"); | |
| 835 | if (event.type === "thread.meta-updated") { | |
| 836 | assert.strictEqual(event.payload.activeOrderKey, "gm"); | |
| 837 | assert.strictEqual(event.payload.updatedAt, payload.updatedAt); | |
| 838 | } | |
| 839 | }), | |
| 840 | ); | |
| 841 | ||
| 787 | 842 | it.effect("accepts a title regeneration intent in thread.meta.update", () => |
| 788 | 843 | Effect.gen(function* () { |
| 789 | 844 | const parsed = yield* decodeOrchestrationCommand({ |
packages/contracts/src/orchestration.ts+16| ... | ... | @@ -523,6 +523,9 @@ export const OrchestrationThread = Schema.Struct({ |
| 523 | 523 | // servers never need each other's threads to agree on the merged list. |
| 524 | 524 | // Optional so payloads from pre-reorder servers still decode. |
| 525 | 525 | pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), |
| 526 | // Manual Active placement. Keyless threads retain their creation/re-entry | |
| 527 | // order above the arranged run. Settling clears this slot. | |
| 528 | activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | |
| 526 | 529 | // Pending-only state. Optional so older servers remain compatible. |
| 527 | 530 | titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), |
| 528 | 531 | deletedAt: Schema.NullOr(IsoDateTime), |
| ... | ... | @@ -588,6 +591,7 @@ export const OrchestrationThreadShell = Schema.Struct({ |
| 588 | 591 | snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)), |
| 589 | 592 | pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), |
| 590 | 593 | pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), |
| 594 | activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | |
| 591 | 595 | titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), |
| 592 | 596 | session: Schema.NullOr(OrchestrationSession), |
| 593 | 597 | latestUserMessageAt: Schema.NullOr(IsoDateTime), |
| ... | ... | @@ -895,6 +899,13 @@ const ThreadPinReorderCommand = Schema.Struct({ |
| 895 | 899 | orderKey: TrimmedNonEmptyString, |
| 896 | 900 | }); |
| 897 | 901 | |
| 902 | const ThreadActiveReorderCommand = Schema.Struct({ | |
| 903 | type: Schema.Literal("thread.active.reorder"), | |
| 904 | commandId: CommandId, | |
| 905 | threadId: ThreadId, | |
| 906 | orderKey: TrimmedNonEmptyString, | |
| 907 | }); | |
| 908 | ||
| 898 | 909 | const ThreadMetaUpdateCommand = Schema.Struct({ |
| 899 | 910 | type: Schema.Literal("thread.meta.update"), |
| 900 | 911 | commandId: CommandId, |
| ... | ... | @@ -1058,6 +1069,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ |
| 1058 | 1069 | ThreadPinCommand, |
| 1059 | 1070 | ThreadUnpinCommand, |
| 1060 | 1071 | ThreadPinReorderCommand, |
| 1072 | ThreadActiveReorderCommand, | |
| 1061 | 1073 | ThreadMetaUpdateCommand, |
| 1062 | 1074 | ThreadRuntimeModeSetCommand, |
| 1063 | 1075 | ThreadInteractionModeSetCommand, |
| ... | ... | @@ -1086,6 +1098,7 @@ export const ClientOrchestrationCommand = Schema.Union([ |
| 1086 | 1098 | ThreadPinCommand, |
| 1087 | 1099 | ThreadUnpinCommand, |
| 1088 | 1100 | ThreadPinReorderCommand, |
| 1101 | ThreadActiveReorderCommand, | |
| 1089 | 1102 | ThreadMetaUpdateCommand, |
| 1090 | 1103 | ThreadRuntimeModeSetCommand, |
| 1091 | 1104 | ThreadInteractionModeSetCommand, |
| ... | ... | @@ -1375,6 +1388,9 @@ export const ThreadPinReorderedPayload = Schema.Struct({ |
| 1375 | 1388 | |
| 1376 | 1389 | export const ThreadMetaUpdatedPayload = Schema.Struct({ |
| 1377 | 1390 | threadId: ThreadId, |
| 1391 | // Order updates use this existing event so older clients can ignore the | |
| 1392 | // new field while continuing to decode the event stream. | |
| 1393 | activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | |
| 1378 | 1394 | title: Schema.optional(TrimmedNonEmptyString), |
| 1379 | 1395 | /** Intent marker consumed by the title-generation reactor. Keeping this on |
| 1380 | 1396 | the existing event lets older clients safely ignore the new field. */ |