authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-06 11:10:13-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 11:10:13-07:00
log2d645df474f0af5e261edb56a79f7a2a9a2d442e
treea3c2b69c0152a665eff56d3cc9da54a5d2ee5c33
parent29d03ec556e94d5f847644730daf75ba4aa20678
signature Signed by PGP key B5690EEEBB952194

feat(threads): persist manual active thread order (#9729)


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,6 +167,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
167 expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 });167 expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 });
168 expect(second.capabilities.pullRequests).toBe(true);168 expect(second.capabilities.pullRequests).toBe(true);
169 expect(second.capabilities.usagePriceOverrides).toBe(true);169 expect(second.capabilities.usagePriceOverrides).toBe(true);
170 expect(second.capabilities.threadActiveReorder).toBe(true);
170 expect(second.capabilities.threadTitleRegeneration).toBe(true);171 expect(second.capabilities.threadTitleRegeneration).toBe(true);
171 expect(second.capabilities.threadPullRequestLinking).toBe(true);172 expect(second.capabilities.threadPullRequestLinking).toBe(true);
172 expect(second.capabilities.agentActivityPublishing).toBe(false);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,6 +226,7 @@ export const make = Effect.gen(function* () {
226 usagePriceOverrides: true,226 usagePriceOverrides: true,
227 threadPinning: true,227 threadPinning: true,
228 threadPinReorder: true,228 threadPinReorder: true,
229 threadActiveReorder: true,
229 threadTitleRegeneration: true,230 threadTitleRegeneration: true,
230 threadPullRequestLinking: true,231 threadPullRequestLinking: true,
231 environmentIcon: true,232 environmentIcon: true,
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+55-3
...@@ -488,6 +488,48 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {...@@ -488,6 +488,48 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
488 yield* sql`DROP TRIGGER count_thread_shell_updates`;488 yield* sql`DROP TRIGGER count_thread_shell_updates`;
489 yield* sql`DROP TABLE thread_shell_updates`;489 yield* sql`DROP TABLE thread_shell_updates`;
490490
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 // Settled lifecycle through the DB pipeline: thread.settled writes the533 // Settled lifecycle through the DB pipeline: thread.settled writes the
492 // override + timestamp, thread.unsettled(user) flips to the active pin.534 // override + timestamp, thread.unsettled(user) flips to the active pin.
493 yield* eventStore.append({535 yield* eventStore.append({
...@@ -512,16 +554,23 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {...@@ -512,16 +554,23 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
512 readonly settledOverride: string | null;554 readonly settledOverride: string | null;
513 readonly settledAt: string | null;555 readonly settledAt: string | null;
514 readonly unsettledAt: string | null;556 readonly unsettledAt: string | null;
557 readonly activeOrderKey: string | null;
515 }>`558 }>`
516 SELECT559 SELECT
517 settled_override AS "settledOverride",560 settled_override AS "settledOverride",
518 settled_at AS "settledAt",561 settled_at AS "settledAt",
519 unsettled_at AS "unsettledAt"562 unsettled_at AS "unsettledAt",
563 active_order_key AS "activeOrderKey"
520 FROM projection_threads564 FROM projection_threads
521 WHERE thread_id = 'thread-1'565 WHERE thread_id = 'thread-1'
522 `;566 `;
523 assert.deepEqual(settledRows, [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 ]);
526575
527 yield* eventStore.append({576 yield* eventStore.append({
...@@ -546,11 +595,13 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {...@@ -546,11 +595,13 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
546 readonly settledOverride: string | null;595 readonly settledOverride: string | null;
547 readonly settledAt: string | null;596 readonly settledAt: string | null;
548 readonly unsettledAt: string | null;597 readonly unsettledAt: string | null;
598 readonly activeOrderKey: string | null;
549 }>`599 }>`
550 SELECT600 SELECT
551 settled_override AS "settledOverride",601 settled_override AS "settledOverride",
552 settled_at AS "settledAt",602 settled_at AS "settledAt",
553 unsettled_at AS "unsettledAt"603 unsettled_at AS "unsettledAt",
604 active_order_key AS "activeOrderKey"
554 FROM projection_threads605 FROM projection_threads
555 WHERE thread_id = 'thread-1'606 WHERE thread_id = 'thread-1'
556 `;607 `;
...@@ -561,6 +612,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {...@@ -561,6 +612,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
561 settledOverride: "active",612 settledOverride: "active",
562 settledAt: null,613 settledAt: null,
563 unsettledAt: "2026-01-01T00:00:02.000Z",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,6 +617,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
617 snoozedAt: null,617 snoozedAt: null,
618 pinnedAt: null,618 pinnedAt: null,
619 pinOrderKey: null,619 pinOrderKey: null,
620 activeOrderKey: null,
620 titleRegenerationRequestId: null,621 titleRegenerationRequestId: null,
621 titleRegenerationStartedAt: null,622 titleRegenerationStartedAt: null,
622 latestUserMessageAt: null,623 latestUserMessageAt: null,
...@@ -671,6 +672,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -671,6 +672,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
671 settledOverride: "settled",672 settledOverride: "settled",
672 settledAt: event.payload.settledAt,673 settledAt: event.payload.settledAt,
673 unsettledAt: null,674 unsettledAt: null,
675 activeOrderKey: null,
674 updatedAt: event.payload.updatedAt,676 updatedAt: event.payload.updatedAt,
675 });677 });
676 return;678 return;
...@@ -790,6 +792,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -790,6 +792,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
790 yield* projectionThreadRepository.upsert({792 yield* projectionThreadRepository.upsert({
791 ...existingRow.value,793 ...existingRow.value,
792 ...(event.payload.title !== undefined ? { title: event.payload.title } : {}),794 ...(event.payload.title !== undefined ? { title: event.payload.title } : {}),
795 ...(event.payload.activeOrderKey !== undefined
796 ? { activeOrderKey: event.payload.activeOrderKey }
797 : {}),
793 ...(event.payload.titleRegeneration !== undefined798 ...(event.payload.titleRegeneration !== undefined
794 ? {799 ? {
795 titleRegenerationRequestId: event.payload.titleRegeneration?.requestId ?? null,800 titleRegenerationRequestId: event.payload.titleRegeneration?.requestId ?? null,
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+6
...@@ -110,6 +110,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -110,6 +110,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
110 has_actionable_proposed_plan,110 has_actionable_proposed_plan,
111 pinned_at,111 pinned_at,
112 pin_order_key,112 pin_order_key,
113 active_order_key,
113 created_at,114 created_at,
114 updated_at,115 updated_at,
115 deleted_at116 deleted_at
...@@ -132,6 +133,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -132,6 +133,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
132 0,133 0,
133 '2026-02-24T00:00:01.000Z',134 '2026-02-24T00:00:01.000Z',
134 'gm',135 'gm',
136 'hq',
135 '2026-02-24T00:00:02.000Z',137 '2026-02-24T00:00:02.000Z',
136 '2026-02-24T00:00:03.000Z',138 '2026-02-24T00:00:03.000Z',
137 NULL139 NULL
...@@ -358,6 +360,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -358,6 +360,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
358 snoozedAt: null,360 snoozedAt: null,
359 pinnedAt: "2026-02-24T00:00:01.000Z",361 pinnedAt: "2026-02-24T00:00:01.000Z",
360 pinOrderKey: "gm",362 pinOrderKey: "gm",
363 activeOrderKey: "hq",
361 titleRegeneration: null,364 titleRegeneration: null,
362 deletedAt: null,365 deletedAt: null,
363 messages: [366 messages: [
...@@ -487,6 +490,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -487,6 +490,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
487 snoozedAt: null,490 snoozedAt: null,
488 pinnedAt: "2026-02-24T00:00:01.000Z",491 pinnedAt: "2026-02-24T00:00:01.000Z",
489 pinOrderKey: "gm",492 pinOrderKey: "gm",
493 activeOrderKey: "hq",
490 titleRegeneration: null,494 titleRegeneration: null,
491 session: {495 session: {
492 threadId: ThreadId.make("thread-1"),496 threadId: ThreadId.make("thread-1"),
...@@ -513,6 +517,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -513,6 +517,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
513 }517 }
514518
515 const commandSnapshot = yield* snapshotQuery.getCommandReadModel();519 const commandSnapshot = yield* snapshotQuery.getCommandReadModel();
520 assert.equal(commandSnapshot.threads[0]?.activeOrderKey, "hq");
516 assert.deepEqual(commandSnapshot.threads[0]?.branchPullRequest, branchPullRequest);521 assert.deepEqual(commandSnapshot.threads[0]?.branchPullRequest, branchPullRequest);
517 const threadShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1"));522 const threadShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1"));
518 assert.equal(threadShell._tag, "Some");523 assert.equal(threadShell._tag, "Some");
...@@ -560,6 +565,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -560,6 +565,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
560 );565 );
561 assert.equal(detailWithoutActivities._tag, "Some");566 assert.equal(detailWithoutActivities._tag, "Some");
562 if (detailWithoutActivities._tag === "Some") {567 if (detailWithoutActivities._tag === "Some") {
568 assert.equal(detailWithoutActivities.value.activeOrderKey, "hq");
563 assert.deepEqual(detailWithoutActivities.value.activities, []);569 assert.deepEqual(detailWithoutActivities.value.activities, []);
564 assert.deepEqual(detailWithoutActivities.value.messages, snapshot.threads[0]?.messages);570 assert.deepEqual(detailWithoutActivities.value.messages, snapshot.threads[0]?.messages);
565 assert.deepEqual(571 assert.deepEqual(
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+10
...@@ -513,6 +513,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -513,6 +513,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
513 snoozed_at AS "snoozedAt",513 snoozed_at AS "snoozedAt",
514 pinned_at AS "pinnedAt",514 pinned_at AS "pinnedAt",
515 pin_order_key AS "pinOrderKey",515 pin_order_key AS "pinOrderKey",
516 active_order_key AS "activeOrderKey",
516 title_regeneration_request_id AS "titleRegenerationRequestId",517 title_regeneration_request_id AS "titleRegenerationRequestId",
517 title_regeneration_started_at AS "titleRegenerationStartedAt",518 title_regeneration_started_at AS "titleRegenerationStartedAt",
518 latest_user_message_at AS "latestUserMessageAt",519 latest_user_message_at AS "latestUserMessageAt",
...@@ -552,6 +553,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -552,6 +553,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
552 snoozed_at AS "snoozedAt",553 snoozed_at AS "snoozedAt",
553 pinned_at AS "pinnedAt",554 pinned_at AS "pinnedAt",
554 pin_order_key AS "pinOrderKey",555 pin_order_key AS "pinOrderKey",
556 active_order_key AS "activeOrderKey",
555 title_regeneration_request_id AS "titleRegenerationRequestId",557 title_regeneration_request_id AS "titleRegenerationRequestId",
556 title_regeneration_started_at AS "titleRegenerationStartedAt",558 title_regeneration_started_at AS "titleRegenerationStartedAt",
557 latest_user_message_at AS "latestUserMessageAt",559 latest_user_message_at AS "latestUserMessageAt",
...@@ -593,6 +595,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -593,6 +595,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
593 snoozed_at AS "snoozedAt",595 snoozed_at AS "snoozedAt",
594 pinned_at AS "pinnedAt",596 pinned_at AS "pinnedAt",
595 pin_order_key AS "pinOrderKey",597 pin_order_key AS "pinOrderKey",
598 active_order_key AS "activeOrderKey",
596 title_regeneration_request_id AS "titleRegenerationRequestId",599 title_regeneration_request_id AS "titleRegenerationRequestId",
597 title_regeneration_started_at AS "titleRegenerationStartedAt",600 title_regeneration_started_at AS "titleRegenerationStartedAt",
598 latest_user_message_at AS "latestUserMessageAt",601 latest_user_message_at AS "latestUserMessageAt",
...@@ -1083,6 +1086,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1083,6 +1086,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1083 snoozed_at AS "snoozedAt",1086 snoozed_at AS "snoozedAt",
1084 pinned_at AS "pinnedAt",1087 pinned_at AS "pinnedAt",
1085 pin_order_key AS "pinOrderKey",1088 pin_order_key AS "pinOrderKey",
1089 active_order_key AS "activeOrderKey",
1086 title_regeneration_request_id AS "titleRegenerationRequestId",1090 title_regeneration_request_id AS "titleRegenerationRequestId",
1087 title_regeneration_started_at AS "titleRegenerationStartedAt",1091 title_regeneration_started_at AS "titleRegenerationStartedAt",
1088 latest_user_message_at AS "latestUserMessageAt",1092 latest_user_message_at AS "latestUserMessageAt",
...@@ -2082,6 +2086,7 @@ pending_approval_requests AS (...@@ -2082,6 +2086,7 @@ pending_approval_requests AS (
2082 snoozedAt: row.snoozedAt,2086 snoozedAt: row.snoozedAt,
2083 pinnedAt: row.pinnedAt,2087 pinnedAt: row.pinnedAt,
2084 pinOrderKey: row.pinOrderKey ?? null,2088 pinOrderKey: row.pinOrderKey ?? null,
2089 activeOrderKey: row.activeOrderKey ?? null,
2085 titleRegeneration: mapTitleRegeneration(row),2090 titleRegeneration: mapTitleRegeneration(row),
2086 deletedAt: row.deletedAt,2091 deletedAt: row.deletedAt,
2087 messages: messagesByThread.get(row.threadId) ?? [],2092 messages: messagesByThread.get(row.threadId) ?? [],
...@@ -2296,6 +2301,7 @@ pending_approval_requests AS (...@@ -2296,6 +2301,7 @@ pending_approval_requests AS (
2296 snoozedAt: row.snoozedAt,2301 snoozedAt: row.snoozedAt,
2297 pinnedAt: row.pinnedAt,2302 pinnedAt: row.pinnedAt,
2298 pinOrderKey: row.pinOrderKey ?? null,2303 pinOrderKey: row.pinOrderKey ?? null,
2304 activeOrderKey: row.activeOrderKey ?? null,
2299 titleRegeneration: mapTitleRegeneration(row),2305 titleRegeneration: mapTitleRegeneration(row),
2300 deletedAt: row.deletedAt,2306 deletedAt: row.deletedAt,
2301 messages: [],2307 messages: [],
...@@ -2437,6 +2443,7 @@ pending_approval_requests AS (...@@ -2437,6 +2443,7 @@ pending_approval_requests AS (
2437 snoozedAt: row.snoozedAt,2443 snoozedAt: row.snoozedAt,
2438 pinnedAt: row.pinnedAt,2444 pinnedAt: row.pinnedAt,
2439 pinOrderKey: row.pinOrderKey ?? null,2445 pinOrderKey: row.pinOrderKey ?? null,
2446 activeOrderKey: row.activeOrderKey ?? null,
2440 titleRegeneration: mapTitleRegeneration(row),2447 titleRegeneration: mapTitleRegeneration(row),
2441 session: sessionByThread.get(row.threadId) ?? null,2448 session: sessionByThread.get(row.threadId) ?? null,
2442 latestUserMessageAt: row.latestUserMessageAt,2449 latestUserMessageAt: row.latestUserMessageAt,
...@@ -2586,6 +2593,7 @@ pending_approval_requests AS (...@@ -2586,6 +2593,7 @@ pending_approval_requests AS (
2586 snoozedAt: row.snoozedAt,2593 snoozedAt: row.snoozedAt,
2587 pinnedAt: row.pinnedAt,2594 pinnedAt: row.pinnedAt,
2588 pinOrderKey: row.pinOrderKey ?? null,2595 pinOrderKey: row.pinOrderKey ?? null,
2596 activeOrderKey: row.activeOrderKey ?? null,
2589 titleRegeneration: mapTitleRegeneration(row),2597 titleRegeneration: mapTitleRegeneration(row),
2590 session: sessionByThread.get(row.threadId) ?? null,2598 session: sessionByThread.get(row.threadId) ?? null,
2591 latestUserMessageAt: row.latestUserMessageAt,2599 latestUserMessageAt: row.latestUserMessageAt,
...@@ -2908,6 +2916,7 @@ pending_approval_requests AS (...@@ -2908,6 +2916,7 @@ pending_approval_requests AS (
2908 snoozedAt: threadRow.value.snoozedAt,2916 snoozedAt: threadRow.value.snoozedAt,
2909 pinnedAt: threadRow.value.pinnedAt,2917 pinnedAt: threadRow.value.pinnedAt,
2910 pinOrderKey: threadRow.value.pinOrderKey ?? null,2918 pinOrderKey: threadRow.value.pinOrderKey ?? null,
2919 activeOrderKey: threadRow.value.activeOrderKey ?? null,
2911 titleRegeneration: mapTitleRegeneration(threadRow.value),2920 titleRegeneration: mapTitleRegeneration(threadRow.value),
2912 session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null,2921 session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null,
2913 latestUserMessageAt: threadRow.value.latestUserMessageAt,2922 latestUserMessageAt: threadRow.value.latestUserMessageAt,
...@@ -3190,6 +3199,7 @@ pending_approval_requests AS (...@@ -3190,6 +3199,7 @@ pending_approval_requests AS (
3190 snoozedAt: threadRow.value.snoozedAt,3199 snoozedAt: threadRow.value.snoozedAt,
3191 pinnedAt: threadRow.value.pinnedAt,3200 pinnedAt: threadRow.value.pinnedAt,
3192 pinOrderKey: threadRow.value.pinOrderKey ?? null,3201 pinOrderKey: threadRow.value.pinOrderKey ?? null,
3202 activeOrderKey: threadRow.value.activeOrderKey ?? null,
3193 titleRegeneration: mapTitleRegeneration(threadRow.value),3203 titleRegeneration: mapTitleRegeneration(threadRow.value),
3194 deletedAt: null,3204 deletedAt: null,
3195 messages: messageRows.map((row) => {3205 messages: messageRows.map((row) => {
apps/server/src/orchestration/decider.active-order.test.ts created+200
...@@ -0,0 +1,200 @@
1import {
2 CommandId,
3 ProjectId,
4 ProviderInstanceId,
5 ThreadId,
6 type OrchestrationCommand,
7 type OrchestrationReadModel,
8 type OrchestrationThread,
9} from "@t3tools/contracts";
10import * as NodeServices from "@effect/platform-node/NodeServices";
11import { expect, it } from "@effect/vitest";
12import * as Effect from "effect/Effect";
13
14import { decideOrchestrationCommand } from "./decider.ts";
15import { projectEvent } from "./projector.ts";
16
17const NOW = "2026-01-01T00:00:00.000Z";
18// The Effect test clock starts at the epoch.
19const BEFORE_NOW = "1969-12-30T00:00:00.000Z";
20const SNOOZED_AT = "1969-12-31T00:00:00.000Z";
21const FUTURE_WAKE = "1970-01-02T00:00:00.000Z";
22const THREAD_ID = ThreadId.make("thread-1");
23
24function 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
63const reorderCommand = {
64 type: "thread.active.reorder",
65 commandId: CommandId.make("cmd-active-reorder"),
66 threadId: THREAD_ID,
67 orderKey: "m",
68} as const;
69
70it.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,6 +788,42 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
788 };788 };
789 }789 }
790790
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 case "thread.meta.update": {827 case "thread.meta.update": {
792 const thread = yield* requireThread({828 const thread = yield* requireThread({
793 readModel,829 readModel,
apps/server/src/orchestration/projector.test.ts+1
...@@ -91,6 +91,7 @@ describe("orchestration projector", () => {...@@ -91,6 +91,7 @@ describe("orchestration projector", () => {
91 createdAt: now,91 createdAt: now,
92 updatedAt: now,92 updatedAt: now,
93 archivedAt: null,93 archivedAt: null,
94 activeOrderKey: null,
94 settledOverride: null,95 settledOverride: null,
95 settledAt: null,96 settledAt: null,
96 unsettledAt: null,97 unsettledAt: null,
apps/server/src/orchestration/projector.ts+5
...@@ -344,6 +344,7 @@ export function projectEvent(...@@ -344,6 +344,7 @@ export function projectEvent(
344 settledOverride: null,344 settledOverride: null,
345 settledAt: null,345 settledAt: null,
346 unsettledAt: null,346 unsettledAt: null,
347 activeOrderKey: null,
347 snoozedUntil: null,348 snoozedUntil: null,
348 snoozedAt: null,349 snoozedAt: null,
349 deletedAt: null,350 deletedAt: null,
...@@ -406,6 +407,7 @@ export function projectEvent(...@@ -406,6 +407,7 @@ export function projectEvent(
406 settledOverride: "settled",407 settledOverride: "settled",
407 settledAt: payload.settledAt,408 settledAt: payload.settledAt,
408 unsettledAt: null,409 unsettledAt: null,
410 activeOrderKey: null,
409 updatedAt: payload.updatedAt,411 updatedAt: payload.updatedAt,
410 }),412 }),
411 })),413 })),
...@@ -500,6 +502,9 @@ export function projectEvent(...@@ -500,6 +502,9 @@ export function projectEvent(
500 ...nextBase,502 ...nextBase,
501 threads: updateThread(nextBase.threads, payload.threadId, {503 threads: updateThread(nextBase.threads, payload.threadId, {
502 ...(payload.title !== undefined ? { title: payload.title } : {}),504 ...(payload.title !== undefined ? { title: payload.title } : {}),
505 ...(payload.activeOrderKey !== undefined
506 ? { activeOrderKey: payload.activeOrderKey }
507 : {}),
503 ...(payload.titleRegeneration !== undefined508 ...(payload.titleRegeneration !== undefined
504 ? { titleRegeneration: payload.titleRegeneration }509 ? { titleRegeneration: payload.titleRegeneration }
505 : {}),510 : {}),
apps/server/src/persistence/Layers/ProjectionThreads.ts+5
...@@ -54,6 +54,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -54,6 +54,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
54 snoozed_at,54 snoozed_at,
55 pinned_at,55 pinned_at,
56 pin_order_key,56 pin_order_key,
57 active_order_key,
57 title_regeneration_request_id,58 title_regeneration_request_id,
58 title_regeneration_started_at,59 title_regeneration_started_at,
59 latest_user_message_at,60 latest_user_message_at,
...@@ -84,6 +85,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -84,6 +85,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
84 ${row.snoozedAt},85 ${row.snoozedAt},
85 ${row.pinnedAt},86 ${row.pinnedAt},
86 ${row.pinOrderKey ?? null},87 ${row.pinOrderKey ?? null},
88 ${row.activeOrderKey ?? null},
87 ${row.titleRegenerationRequestId ?? null},89 ${row.titleRegenerationRequestId ?? null},
88 ${row.titleRegenerationStartedAt ?? null},90 ${row.titleRegenerationStartedAt ?? null},
89 ${row.latestUserMessageAt},91 ${row.latestUserMessageAt},
...@@ -114,6 +116,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -114,6 +116,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
114 snoozed_at = excluded.snoozed_at,116 snoozed_at = excluded.snoozed_at,
115 pinned_at = excluded.pinned_at,117 pinned_at = excluded.pinned_at,
116 pin_order_key = excluded.pin_order_key,118 pin_order_key = excluded.pin_order_key,
119 active_order_key = excluded.active_order_key,
117 title_regeneration_request_id = excluded.title_regeneration_request_id,120 title_regeneration_request_id = excluded.title_regeneration_request_id,
118 title_regeneration_started_at = excluded.title_regeneration_started_at,121 title_regeneration_started_at = excluded.title_regeneration_started_at,
119 latest_user_message_at = excluded.latest_user_message_at,122 latest_user_message_at = excluded.latest_user_message_at,
...@@ -151,6 +154,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -151,6 +154,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
151 snoozed_at AS "snoozedAt",154 snoozed_at AS "snoozedAt",
152 pinned_at AS "pinnedAt",155 pinned_at AS "pinnedAt",
153 pin_order_key AS "pinOrderKey",156 pin_order_key AS "pinOrderKey",
157 active_order_key AS "activeOrderKey",
154 title_regeneration_request_id AS "titleRegenerationRequestId",158 title_regeneration_request_id AS "titleRegenerationRequestId",
155 title_regeneration_started_at AS "titleRegenerationStartedAt",159 title_regeneration_started_at AS "titleRegenerationStartedAt",
156 latest_user_message_at AS "latestUserMessageAt",160 latest_user_message_at AS "latestUserMessageAt",
...@@ -190,6 +194,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -190,6 +194,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
190 snoozed_at AS "snoozedAt",194 snoozed_at AS "snoozedAt",
191 pinned_at AS "pinnedAt",195 pinned_at AS "pinnedAt",
192 pin_order_key AS "pinOrderKey",196 pin_order_key AS "pinOrderKey",
197 active_order_key AS "activeOrderKey",
193 title_regeneration_request_id AS "titleRegenerationRequestId",198 title_regeneration_request_id AS "titleRegenerationRequestId",
194 title_regeneration_started_at AS "titleRegenerationStartedAt",199 title_regeneration_started_at AS "titleRegenerationStartedAt",
195 latest_user_message_at AS "latestUserMessageAt",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,6 +60,7 @@ import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts";
60import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps.ts";60import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps.ts";
61import Migration0047 from "./Migrations/047_ProjectionProjectIcon.ts";61import Migration0047 from "./Migrations/047_ProjectionProjectIcon.ts";
62import Migration0048 from "./Migrations/048_ProjectionThreadBranchPullRequest.ts";62import Migration0048 from "./Migrations/048_ProjectionThreadBranchPullRequest.ts";
63import Migration0049 from "./Migrations/049_ProjectionThreadsActiveOrderKey.ts";
6364
64/**65/**
65 * Migration loader with all migrations defined inline.66 * Migration loader with all migrations defined inline.
...@@ -120,6 +121,7 @@ export const migrationEntries = [...@@ -120,6 +121,7 @@ export const migrationEntries = [
120 [46, "RepairAutomaticSettlementTimestamps", Migration0046],121 [46, "RepairAutomaticSettlementTimestamps", Migration0046],
121 [47, "ProjectionProjectIcon", Migration0047],122 [47, "ProjectionProjectIcon", Migration0047],
122 [48, "ProjectionThreadBranchPullRequest", Migration0048],123 [48, "ProjectionThreadBranchPullRequest", Migration0048],
124 [49, "ProjectionThreadsActiveOrderKey", Migration0049],
123] as const;125] as const;
124126
125export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);127export 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 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as SqlClient from "effect/unstable/sql/SqlClient";
4import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
5
6import { runMigrations } from "../Migrations.ts";
7import migrateActiveOrderKey from "./049_ProjectionThreadsActiveOrderKey.ts";
8
9it.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 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export 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,6 +47,7 @@ export const ProjectionThread = Schema.Struct({
47 snoozedAt: Schema.NullOr(IsoDateTime),47 snoozedAt: Schema.NullOr(IsoDateTime),
48 pinnedAt: Schema.NullOr(IsoDateTime),48 pinnedAt: Schema.NullOr(IsoDateTime),
49 pinOrderKey: Schema.optional(Schema.NullOr(Schema.String)),49 pinOrderKey: Schema.optional(Schema.NullOr(Schema.String)),
50 activeOrderKey: Schema.optional(Schema.NullOr(Schema.String)),
50 titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)),51 titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)),
51 titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)),52 titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
52 latestUserMessageAt: Schema.NullOr(IsoDateTime),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,6 +24,7 @@ import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
24import {24import {
25 archiveThread,25 archiveThread,
26 createProject,26 createProject,
27 reorderActiveThread,
27 settleThread,28 settleThread,
28 stopThreadSession,29 stopThreadSession,
29 unsettleThread,30 unsettleThread,
...@@ -172,4 +173,24 @@ describe("environment commands", () => {...@@ -172,4 +173,24 @@ describe("environment commands", () => {
172 ]);173 ]);
173 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),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,6 +42,7 @@ export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">;
42export type PinThreadInput = CommandInput<"thread.pin">;42export type PinThreadInput = CommandInput<"thread.pin">;
43export type UnpinThreadInput = CommandInput<"thread.unpin">;43export type UnpinThreadInput = CommandInput<"thread.unpin">;
44export type ReorderPinnedThreadInput = CommandInput<"thread.pin.reorder">;44export type ReorderPinnedThreadInput = CommandInput<"thread.pin.reorder">;
45export type ReorderActiveThreadInput = CommandInput<"thread.active.reorder">;
45export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">;46export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">;
46export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">;47export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">;
47export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">;48export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">;
...@@ -230,6 +231,16 @@ export const reorderPinnedThread: (input: ReorderPinnedThreadInput) => CommandEf...@@ -230,6 +231,16 @@ export const reorderPinnedThread: (input: ReorderPinnedThreadInput) => CommandEf
230 });231 });
231});232});
232233
234export 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
233export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn(244export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn(
234 "EnvironmentCommands.updateThreadMetadata",245 "EnvironmentCommands.updateThreadMetadata",
235)(function* (input) {246)(function* (input) {
packages/client-runtime/src/state/entities.test.ts+6
...@@ -208,6 +208,8 @@ describe("environment entity projections", () => {...@@ -208,6 +208,8 @@ describe("environment entity projections", () => {
208 title: "Cached thread",208 title: "Cached thread",
209 branch: "stale-branch",209 branch: "stale-branch",
210 worktreePath: "/repo/stale-worktree",210 worktreePath: "/repo/stale-worktree",
211 activeOrderKey: "t",
212 unsettledAt: "2026-03-09T10:00:00.000Z",
211 deletedAt: null,213 deletedAt: null,
212 messages,214 messages,
213 proposedPlans: [],215 proposedPlans: [],
...@@ -220,6 +222,8 @@ describe("environment entity projections", () => {...@@ -220,6 +222,8 @@ describe("environment entity projections", () => {
220 title: "Current thread",222 title: "Current thread",
221 branch: "current-branch",223 branch: "current-branch",
222 worktreePath: "/repo/current-worktree",224 worktreePath: "/repo/current-worktree",
225 activeOrderKey: "f",
226 unsettledAt: "2026-03-09T12:00:00.000Z",
223 };227 };
224228
225 const merged = mergeEnvironmentThread(detail, shell);229 const merged = mergeEnvironmentThread(detail, shell);
...@@ -228,6 +232,8 @@ describe("environment entity projections", () => {...@@ -228,6 +232,8 @@ describe("environment entity projections", () => {
228 title: "Current thread",232 title: "Current thread",
229 branch: "current-branch",233 branch: "current-branch",
230 worktreePath: "/repo/current-worktree",234 worktreePath: "/repo/current-worktree",
235 activeOrderKey: "f",
236 unsettledAt: "2026-03-09T12:00:00.000Z",
231 });237 });
232 expect(merged?.messages).toBe(messages);238 expect(merged?.messages).toBe(messages);
233 });239 });
packages/client-runtime/src/state/threadCommands.ts+9
...@@ -19,6 +19,7 @@ import {...@@ -19,6 +19,7 @@ import {
19 type SetThreadRuntimeModeInput,19 type SetThreadRuntimeModeInput,
20 type PinThreadInput,20 type PinThreadInput,
21 type ReorderPinnedThreadInput,21 type ReorderPinnedThreadInput,
22 type ReorderActiveThreadInput,
22 type SettleThreadInput,23 type SettleThreadInput,
23 type SnoozeThreadInput,24 type SnoozeThreadInput,
24 type StartThreadTurnInput,25 type StartThreadTurnInput,
...@@ -39,6 +40,7 @@ import {...@@ -39,6 +40,7 @@ import {
39 setThreadRuntimeMode,40 setThreadRuntimeMode,
40 pinThread,41 pinThread,
41 reorderPinnedThread,42 reorderPinnedThread,
43 reorderActiveThread,
42 settleThread,44 settleThread,
43 snoozeThread,45 snoozeThread,
44 startThreadTurn,46 startThreadTurn,
...@@ -63,6 +65,7 @@ export type {...@@ -63,6 +65,7 @@ export type {
63 SetThreadRuntimeModeInput,65 SetThreadRuntimeModeInput,
64 PinThreadInput,66 PinThreadInput,
65 ReorderPinnedThreadInput,67 ReorderPinnedThreadInput,
68 ReorderActiveThreadInput,
66 SettleThreadInput,69 SettleThreadInput,
67 SnoozeThreadInput,70 SnoozeThreadInput,
68 StartThreadTurnInput,71 StartThreadTurnInput,
...@@ -150,6 +153,12 @@ export function createThreadEnvironmentAtoms<R, E>(...@@ -150,6 +153,12 @@ export function createThreadEnvironmentAtoms<R, E>(
150 scheduler,153 scheduler,
151 concurrency,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 updateMetadata: createEnvironmentCommand(runtime, {162 updateMetadata: createEnvironmentCommand(runtime, {
154 label: "environment-data:commands:thread:update-metadata",163 label: "environment-data:commands:thread:update-metadata",
155 execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input),164 execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input),
packages/client-runtime/src/state/threadDetail.ts+2
...@@ -58,6 +58,8 @@ export function mergeEnvironmentThread(...@@ -58,6 +58,8 @@ export function mergeEnvironmentThread(
58 archivedAt: shell.archivedAt,58 archivedAt: shell.archivedAt,
59 settledOverride: shell.settledOverride,59 settledOverride: shell.settledOverride,
60 settledAt: shell.settledAt,60 settledAt: shell.settledAt,
61 unsettledAt: shell.unsettledAt,
62 activeOrderKey: shell.activeOrderKey,
61 snoozedUntil: shell.snoozedUntil,63 snoozedUntil: shell.snoozedUntil,
62 snoozedAt: shell.snoozedAt,64 snoozedAt: shell.snoozedAt,
63 pinnedAt: shell.pinnedAt,65 pinnedAt: shell.pinnedAt,
packages/client-runtime/src/state/threadReducer.test.ts+76-37
...@@ -178,24 +178,28 @@ describe("applyThreadDetailEvent", () => {...@@ -178,24 +178,28 @@ describe("applyThreadDetailEvent", () => {
178 describe("thread.settled / thread.unsettled", () => {178 describe("thread.settled / thread.unsettled", () => {
179 it("sets the settled override and timestamp", () => {179 it("sets the settled override and timestamp", () => {
180 const settledAt = "2026-04-01T05:00:00.000Z";180 const settledAt = "2026-04-01T05:00:00.000Z";
181 const result = applyThreadDetailEvent(baseThread, {181 const result = applyThreadDetailEvent(
182 ...baseEventFields,182 { ...baseThread, activeOrderKey: "m" },
183 sequence: 5,183 {
184 occurredAt: settledAt,184 ...baseEventFields,
185 aggregateKind: "thread",185 sequence: 5,
186 aggregateId: ThreadId.make("thread-1"),186 occurredAt: settledAt,
187 type: "thread.settled",187 aggregateKind: "thread",
188 payload: {188 aggregateId: ThreadId.make("thread-1"),
189 threadId: ThreadId.make("thread-1"),189 type: "thread.settled",
190 settledAt,190 payload: {
191 updatedAt: settledAt,191 threadId: ThreadId.make("thread-1"),
192 settledAt,
193 updatedAt: settledAt,
194 },
192 },195 },
193 });196 );
194197
195 expect(result.kind).toBe("updated");198 expect(result.kind).toBe("updated");
196 if (result.kind === "updated") {199 if (result.kind === "updated") {
197 expect(result.thread.settledOverride).toBe("settled");200 expect(result.thread.settledOverride).toBe("settled");
198 expect(result.thread.settledAt).toBe(settledAt);201 expect(result.thread.settledAt).toBe(settledAt);
202 expect(result.thread.activeOrderKey).toBeNull();
199 }203 }
200 });204 });
201205
...@@ -234,23 +238,27 @@ describe("applyThreadDetailEvent", () => {...@@ -234,23 +238,27 @@ describe("applyThreadDetailEvent", () => {
234 describe("thread.pinned / thread.unpinned", () => {238 describe("thread.pinned / thread.unpinned", () => {
235 it("sets pinnedAt", () => {239 it("sets pinnedAt", () => {
236 const pinnedAt = "2026-04-01T05:00:00.000Z";240 const pinnedAt = "2026-04-01T05:00:00.000Z";
237 const result = applyThreadDetailEvent(baseThread, {241 const result = applyThreadDetailEvent(
238 ...baseEventFields,242 { ...baseThread, activeOrderKey: "m" },
239 sequence: 5,243 {
240 occurredAt: pinnedAt,244 ...baseEventFields,
241 aggregateKind: "thread",245 sequence: 5,
242 aggregateId: ThreadId.make("thread-1"),246 occurredAt: pinnedAt,
243 type: "thread.pinned",247 aggregateKind: "thread",
244 payload: {248 aggregateId: ThreadId.make("thread-1"),
245 threadId: ThreadId.make("thread-1"),249 type: "thread.pinned",
246 pinnedAt,250 payload: {
247 updatedAt: pinnedAt,251 threadId: ThreadId.make("thread-1"),
252 pinnedAt,
253 updatedAt: pinnedAt,
254 },
248 },255 },
249 });256 );
250257
251 expect(result.kind).toBe("updated");258 expect(result.kind).toBe("updated");
252 if (result.kind === "updated") {259 if (result.kind === "updated") {
253 expect(result.thread.pinnedAt).toBe(pinnedAt);260 expect(result.thread.pinnedAt).toBe(pinnedAt);
261 expect(result.thread.activeOrderKey).toBe("m");
254 }262 }
255 });263 });
256264
...@@ -281,26 +289,57 @@ describe("applyThreadDetailEvent", () => {...@@ -281,26 +289,57 @@ describe("applyThreadDetailEvent", () => {
281 });289 });
282290
283 describe("thread.meta-updated", () => {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 it("patches title and branch", () => {319 it("patches title and branch", () => {
285 const result = applyThreadDetailEvent(baseThread, {320 const result = applyThreadDetailEvent(
286 ...baseEventFields,321 { ...baseThread, activeOrderKey: "m" },
287 sequence: 5,322 {
288 occurredAt: "2026-04-01T05:00:00.000Z",323 ...baseEventFields,
289 aggregateKind: "thread",324 sequence: 5,
290 aggregateId: ThreadId.make("thread-1"),325 occurredAt: "2026-04-01T05:00:00.000Z",
291 type: "thread.meta-updated",326 aggregateKind: "thread",
292 payload: {327 aggregateId: ThreadId.make("thread-1"),
293 threadId: ThreadId.make("thread-1"),328 type: "thread.meta-updated",
294 title: "Updated Title",329 payload: {
295 branch: "feature/demo",330 threadId: ThreadId.make("thread-1"),
296 updatedAt: "2026-04-01T05:00:00.000Z",331 title: "Updated Title",
332 branch: "feature/demo",
333 updatedAt: "2026-04-01T05:00:00.000Z",
334 },
297 },335 },
298 });336 );
299337
300 expect(result.kind).toBe("updated");338 expect(result.kind).toBe("updated");
301 if (result.kind === "updated") {339 if (result.kind === "updated") {
302 expect(result.thread.title).toBe("Updated Title");340 expect(result.thread.title).toBe("Updated Title");
303 expect(result.thread.branch).toBe("feature/demo");341 expect(result.thread.branch).toBe("feature/demo");
342 expect(result.thread.activeOrderKey).toBe("m");
304 // Model selection should be unchanged since it wasn't in the payload343 // Model selection should be unchanged since it wasn't in the payload
305 expect(result.thread.modelSelection).toEqual(baseThread.modelSelection);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,6 +105,7 @@ export function applyThreadDetailEvent(
105 settledOverride: null,105 settledOverride: null,
106 settledAt: null,106 settledAt: null,
107 unsettledAt: null,107 unsettledAt: null,
108 activeOrderKey: null,
108 snoozedUntil: null,109 snoozedUntil: null,
109 snoozedAt: null,110 snoozedAt: null,
110 deletedAt: null,111 deletedAt: null,
...@@ -144,6 +145,7 @@ export function applyThreadDetailEvent(...@@ -144,6 +145,7 @@ export function applyThreadDetailEvent(
144 settledOverride: "settled",145 settledOverride: "settled",
145 settledAt: event.payload.settledAt,146 settledAt: event.payload.settledAt,
146 unsettledAt: null,147 unsettledAt: null,
148 activeOrderKey: null,
147 updatedAt: event.payload.updatedAt,149 updatedAt: event.payload.updatedAt,
148 },150 },
149 };151 };
...@@ -244,6 +246,9 @@ export function applyThreadDetailEvent(...@@ -244,6 +246,9 @@ export function applyThreadDetailEvent(
244 ...(event.payload.branchPullRequest !== undefined246 ...(event.payload.branchPullRequest !== undefined
245 ? { branchPullRequest: event.payload.branchPullRequest }247 ? { branchPullRequest: event.payload.branchPullRequest }
246 : {}),248 : {}),
249 ...(event.payload.activeOrderKey !== undefined
250 ? { activeOrderKey: event.payload.activeOrderKey }
251 : {}),
247 updatedAt: event.payload.updatedAt,252 updatedAt: event.payload.updatedAt,
248 },253 },
249 };254 };
packages/client-runtime/src/state/threadSort.test.ts+176
...@@ -1,8 +1,12 @@...@@ -1,8 +1,12 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import {3import {
4 generateSpreadPinOrderKeys,
5 pinOrderKeyBetween,
4 planPinnedMove,6 planPinnedMove,
7 planPinnedReorder,
5 resolveSettledThreadTimestamp,8 resolveSettledThreadTimestamp,
9 sortActiveThreadsByOrderKey,
6 sortPinnedThreadsByOrderKey,10 sortPinnedThreadsByOrderKey,
7 sortThreads,11 sortThreads,
8 type ThreadSortInput,12 type ThreadSortInput,
...@@ -108,6 +112,44 @@ describe("sortThreads", () => {...@@ -108,6 +112,44 @@ describe("sortThreads", () => {
108 });112 });
109});113});
110114
115describe("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
111describe("planPinnedMove", () => {153describe("planPinnedMove", () => {
112 it("moves a thread up with a single key write", () => {154 it("moves a thread up with a single key write", () => {
113 const assignments = planPinnedMove({155 const assignments = planPinnedMove({
...@@ -173,3 +215,137 @@ describe("sortPinnedThreadsByOrderKey", () => {...@@ -173,3 +215,137 @@ describe("sortPinnedThreadsByOrderKey", () => {
173 expect(sorted.map((thread) => thread.environmentId)).toEqual(["env-a", "env-b"]);215 expect(sorted.map((thread) => thread.environmentId)).toEqual(["env-a", "env-b"]);
174 });216 });
175});217});
218
219describe("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
240describe("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,25 +205,27 @@ export function pinOrderKeyBetween(before: string | null, after: string | null):
205 return pinOrderMidpoint(a, b);205 return pinOrderMidpoint(a, b);
206}206}
207207
208/** Evenly spaced keys for rewriting a whole pinned section (used when a208/** Evenly spaced keys for materializing an order. Wider keys keep a large
209 drop lands next to keyless threads, so single-key insertion has nothing209 active list from exhausting the space between two-digit keys. */
210 to anchor on). Two base-26 digits give 675 slots — far beyond any real210export function generateSpreadPinOrderKeys(count: number): string[] {
211 pinned section — with monotonicity enforced as a belt-and-braces. */211 let width = 2;
212function generateSpreadPinOrderKeys(count: number): string[] {212 let space = PIN_ORDER_DIGITS.length ** width;
213 const space = PIN_ORDER_DIGITS.length * PIN_ORDER_DIGITS.length;213 while (space <= (count + 1) * 2) {
214 width += 1;
215 space *= PIN_ORDER_DIGITS.length;
216 }
214 const step = space / (count + 1);217 const step = space / (count + 1);
215 const keys: string[] = [];218 const keys: string[] = [];
216 let previous = 0;
217 for (let i = 0; i < count; i += 1) {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 // Skip values whose low digit is the minimum (a trailing "a" key).221 // Skip values whose low digit is the minimum (a trailing "a" key).
220 if (value % PIN_ORDER_DIGITS.length === 0) value += 1;222 if (value % PIN_ORDER_DIGITS.length === 0) value += 1;
221 value = Math.min(value, space - 1);223 let key = "";
222 previous = value;224 for (let digit = 0; digit < width; digit += 1) {
223 keys.push(225 key = PIN_ORDER_DIGITS.charAt(value % PIN_ORDER_DIGITS.length) + key;
224 PIN_ORDER_DIGITS.charAt(Math.floor(value / PIN_ORDER_DIGITS.length)) +226 value = Math.floor(value / PIN_ORDER_DIGITS.length);
225 PIN_ORDER_DIGITS.charAt(value % PIN_ORDER_DIGITS.length),227 }
226 );228 keys.push(key);
227 }229 }
228 return keys;230 return keys;
229}231}
...@@ -233,15 +235,21 @@ function generateSpreadPinOrderKeys(count: number): string[] {...@@ -233,15 +235,21 @@ function generateSpreadPinOrderKeys(count: number): string[] {
233 * sits between two keyed (or absent) neighbors, this is a single write to235 * sits between two keyed (or absent) neighbors, this is a single write to
234 * the moved thread. When a neighbor is keyless (threads pinned before236 * the moved thread. When a neighbor is keyless (threads pinned before
235 * reordering shipped), the whole section gets fresh spread keys — a237 * 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 */
238export function planPinnedReorder(input: {241export function planPinnedReorder(input: {
239 /** Thread ids in the desired visual order (after the move). */242 /** Thread ids in the desired visual order (after the move). */
240 readonly orderedIds: readonly string[];243 readonly orderedIds: readonly string[];
244 /** Include retained keys from hidden rows; only orderedIds receive writes. */
241 readonly keysById: ReadonlyMap<string, string | null | undefined>;245 readonly keysById: ReadonlyMap<string, string | null | undefined>;
242 readonly movedId: string;246 readonly movedId: string;
243}): ReadonlyArray<{ readonly id: string; readonly orderKey: string }> {247}): ReadonlyArray<{ readonly id: string; readonly orderKey: string }> {
244 const { orderedIds, keysById, movedId } = input;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 const movedIndex = orderedIds.indexOf(movedId);253 const movedIndex = orderedIds.indexOf(movedId);
246 if (movedIndex === -1) return [];254 if (movedIndex === -1) return [];
247 const beforeId = movedIndex > 0 ? orderedIds[movedIndex - 1] : null;255 const beforeId = movedIndex > 0 ? orderedIds[movedIndex - 1] : null;
...@@ -251,11 +259,14 @@ export function planPinnedReorder(input: {...@@ -251,11 +259,14 @@ export function planPinnedReorder(input: {
251 const beforeUsable = beforeId === null || beforeKey != null;259 const beforeUsable = beforeId === null || beforeKey != null;
252 const afterUsable = afterId === null || afterKey != null;260 const afterUsable = afterId === null || afterKey != null;
253 if (beforeUsable && afterUsable) {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 if (key !== null) return [{ id: movedId, orderKey: key }];264 if (key !== null) return [{ id: movedId, orderKey: key }];
256 }265 }
257 // Keyless neighbor (or corrupt keys): rewrite the section in the new order.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 return orderedIds.flatMap((id, index) => {270 return orderedIds.flatMap((id, index) => {
260 const key = keys[index]!;271 const key = keys[index]!;
261 return keysById.get(id) === key ? [] : [{ id, orderKey: key }];272 return keysById.get(id) === key ? [] : [{ id, orderKey: key }];
...@@ -303,6 +314,36 @@ export function sortPinnedThreadsByOrderKey<...@@ -303,6 +314,36 @@ export function sortPinnedThreadsByOrderKey<
303 return [...keyed, ...keyless];314 return [...keyed, ...keyless];
304}315}
305316
317/** New and reopened threads lead the active list. Arranged threads follow
318 their saved keys; activity leaves both groups in place. */
319export 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 * planPinnedReorder specialized for mobile's Move up / Move down menu348 * planPinnedReorder specialized for mobile's Move up / Move down menu
308 * actions: swap the moved thread with its displayed neighbor. Null when the349 * 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,6 +116,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
116 /** Server understands thread.pin.reorder (and orderKey on thread.pin).116 /** Server understands thread.pin.reorder (and orderKey on thread.pin).
117 Same version-skew contract as threadSettlement. */117 Same version-skew contract as threadSettlement. */
118 threadPinReorder: Schema.optionalKey(Schema.Boolean),118 threadPinReorder: Schema.optionalKey(Schema.Boolean),
119 /** Server persists manual Active order through thread.active.reorder. */
120 threadActiveReorder: Schema.optionalKey(Schema.Boolean),
119 /** Server understands regenerateTitle on thread.meta.update. Absent on121 /** Server understands regenerateTitle on thread.meta.update. Absent on
120 older servers, so clients hide the action instead of sending it. */122 older servers, so clients hide the action instead of sending it. */
121 threadTitleRegeneration: Schema.optionalKey(Schema.Boolean),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,6 +784,61 @@ it.effect("accepts a title seed in thread.turn.start", () =>
784 }),784 }),
785);785);
786786
787it.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
811it.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
787it.effect("accepts a title regeneration intent in thread.meta.update", () =>842it.effect("accepts a title regeneration intent in thread.meta.update", () =>
788 Effect.gen(function* () {843 Effect.gen(function* () {
789 const parsed = yield* decodeOrchestrationCommand({844 const parsed = yield* decodeOrchestrationCommand({
packages/contracts/src/orchestration.ts+16
...@@ -523,6 +523,9 @@ export const OrchestrationThread = Schema.Struct({...@@ -523,6 +523,9 @@ export const OrchestrationThread = Schema.Struct({
523 // servers never need each other's threads to agree on the merged list.523 // servers never need each other's threads to agree on the merged list.
524 // Optional so payloads from pre-reorder servers still decode.524 // Optional so payloads from pre-reorder servers still decode.
525 pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),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 // Pending-only state. Optional so older servers remain compatible.529 // Pending-only state. Optional so older servers remain compatible.
527 titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),530 titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),
528 deletedAt: Schema.NullOr(IsoDateTime),531 deletedAt: Schema.NullOr(IsoDateTime),
...@@ -588,6 +591,7 @@ export const OrchestrationThreadShell = Schema.Struct({...@@ -588,6 +591,7 @@ export const OrchestrationThreadShell = Schema.Struct({
588 snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)),591 snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
589 pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)),592 pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
590 pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),593 pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
594 activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
591 titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),595 titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),
592 session: Schema.NullOr(OrchestrationSession),596 session: Schema.NullOr(OrchestrationSession),
593 latestUserMessageAt: Schema.NullOr(IsoDateTime),597 latestUserMessageAt: Schema.NullOr(IsoDateTime),
...@@ -895,6 +899,13 @@ const ThreadPinReorderCommand = Schema.Struct({...@@ -895,6 +899,13 @@ const ThreadPinReorderCommand = Schema.Struct({
895 orderKey: TrimmedNonEmptyString,899 orderKey: TrimmedNonEmptyString,
896});900});
897901
902const ThreadActiveReorderCommand = Schema.Struct({
903 type: Schema.Literal("thread.active.reorder"),
904 commandId: CommandId,
905 threadId: ThreadId,
906 orderKey: TrimmedNonEmptyString,
907});
908
898const ThreadMetaUpdateCommand = Schema.Struct({909const ThreadMetaUpdateCommand = Schema.Struct({
899 type: Schema.Literal("thread.meta.update"),910 type: Schema.Literal("thread.meta.update"),
900 commandId: CommandId,911 commandId: CommandId,
...@@ -1058,6 +1069,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([...@@ -1058,6 +1069,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([
1058 ThreadPinCommand,1069 ThreadPinCommand,
1059 ThreadUnpinCommand,1070 ThreadUnpinCommand,
1060 ThreadPinReorderCommand,1071 ThreadPinReorderCommand,
1072 ThreadActiveReorderCommand,
1061 ThreadMetaUpdateCommand,1073 ThreadMetaUpdateCommand,
1062 ThreadRuntimeModeSetCommand,1074 ThreadRuntimeModeSetCommand,
1063 ThreadInteractionModeSetCommand,1075 ThreadInteractionModeSetCommand,
...@@ -1086,6 +1098,7 @@ export const ClientOrchestrationCommand = Schema.Union([...@@ -1086,6 +1098,7 @@ export const ClientOrchestrationCommand = Schema.Union([
1086 ThreadPinCommand,1098 ThreadPinCommand,
1087 ThreadUnpinCommand,1099 ThreadUnpinCommand,
1088 ThreadPinReorderCommand,1100 ThreadPinReorderCommand,
1101 ThreadActiveReorderCommand,
1089 ThreadMetaUpdateCommand,1102 ThreadMetaUpdateCommand,
1090 ThreadRuntimeModeSetCommand,1103 ThreadRuntimeModeSetCommand,
1091 ThreadInteractionModeSetCommand,1104 ThreadInteractionModeSetCommand,
...@@ -1375,6 +1388,9 @@ export const ThreadPinReorderedPayload = Schema.Struct({...@@ -1375,6 +1388,9 @@ export const ThreadPinReorderedPayload = Schema.Struct({
13751388
1376export const ThreadMetaUpdatedPayload = Schema.Struct({1389export const ThreadMetaUpdatedPayload = Schema.Struct({
1377 threadId: ThreadId,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 title: Schema.optional(TrimmedNonEmptyString),1394 title: Schema.optional(TrimmedNonEmptyString),
1379 /** Intent marker consumed by the title-generation reactor. Keeping this on1395 /** Intent marker consumed by the title-generation reactor. Keeping this on
1380 the existing event lets older clients safely ignore the new field. */1396 the existing event lets older clients safely ignore the new field. */