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) => {
167167 expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 });
168168 expect(second.capabilities.pullRequests).toBe(true);
169169 expect(second.capabilities.usagePriceOverrides).toBe(true);
170 expect(second.capabilities.threadActiveReorder).toBe(true);
170171 expect(second.capabilities.threadTitleRegeneration).toBe(true);
171172 expect(second.capabilities.threadPullRequestLinking).toBe(true);
172173 expect(second.capabilities.agentActivityPublishing).toBe(false);
apps/server/src/environment/ServerEnvironment.ts+1
......@@ -226,6 +226,7 @@ export const make = Effect.gen(function* () {
226226 usagePriceOverrides: true,
227227 threadPinning: true,
228228 threadPinReorder: true,
229 threadActiveReorder: true,
229230 threadTitleRegeneration: true,
230231 threadPullRequestLinking: true,
231232 environmentIcon: true,
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+55-3
......@@ -488,6 +488,48 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
488488 yield* sql`DROP TRIGGER count_thread_shell_updates`;
489489 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
491533 // Settled lifecycle through the DB pipeline: thread.settled writes the
492534 // override + timestamp, thread.unsettled(user) flips to the active pin.
493535 yield* eventStore.append({
......@@ -512,16 +554,23 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
512554 readonly settledOverride: string | null;
513555 readonly settledAt: string | null;
514556 readonly unsettledAt: string | null;
557 readonly activeOrderKey: string | null;
515558 }>`
516559 SELECT
517560 settled_override AS "settledOverride",
518561 settled_at AS "settledAt",
519 unsettled_at AS "unsettledAt"
562 unsettled_at AS "unsettledAt",
563 active_order_key AS "activeOrderKey"
520564 FROM projection_threads
521565 WHERE thread_id = 'thread-1'
522566 `;
523567 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 },
525574 ]);
526575
527576 yield* eventStore.append({
......@@ -546,11 +595,13 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
546595 readonly settledOverride: string | null;
547596 readonly settledAt: string | null;
548597 readonly unsettledAt: string | null;
598 readonly activeOrderKey: string | null;
549599 }>`
550600 SELECT
551601 settled_override AS "settledOverride",
552602 settled_at AS "settledAt",
553 unsettled_at AS "unsettledAt"
603 unsettled_at AS "unsettledAt",
604 active_order_key AS "activeOrderKey"
554605 FROM projection_threads
555606 WHERE thread_id = 'thread-1'
556607 `;
......@@ -561,6 +612,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
561612 settledOverride: "active",
562613 settledAt: null,
563614 unsettledAt: "2026-01-01T00:00:02.000Z",
615 activeOrderKey: null,
564616 },
565617 ]);
566618 }),
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+5
......@@ -617,6 +617,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
617617 snoozedAt: null,
618618 pinnedAt: null,
619619 pinOrderKey: null,
620 activeOrderKey: null,
620621 titleRegenerationRequestId: null,
621622 titleRegenerationStartedAt: null,
622623 latestUserMessageAt: null,
......@@ -671,6 +672,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
671672 settledOverride: "settled",
672673 settledAt: event.payload.settledAt,
673674 unsettledAt: null,
675 activeOrderKey: null,
674676 updatedAt: event.payload.updatedAt,
675677 });
676678 return;
......@@ -790,6 +792,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
790792 yield* projectionThreadRepository.upsert({
791793 ...existingRow.value,
792794 ...(event.payload.title !== undefined ? { title: event.payload.title } : {}),
795 ...(event.payload.activeOrderKey !== undefined
796 ? { activeOrderKey: event.payload.activeOrderKey }
797 : {}),
793798 ...(event.payload.titleRegeneration !== undefined
794799 ? {
795800 titleRegenerationRequestId: event.payload.titleRegeneration?.requestId ?? null,
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+6
......@@ -110,6 +110,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
110110 has_actionable_proposed_plan,
111111 pinned_at,
112112 pin_order_key,
113 active_order_key,
113114 created_at,
114115 updated_at,
115116 deleted_at
......@@ -132,6 +133,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
132133 0,
133134 '2026-02-24T00:00:01.000Z',
134135 'gm',
136 'hq',
135137 '2026-02-24T00:00:02.000Z',
136138 '2026-02-24T00:00:03.000Z',
137139 NULL
......@@ -358,6 +360,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
358360 snoozedAt: null,
359361 pinnedAt: "2026-02-24T00:00:01.000Z",
360362 pinOrderKey: "gm",
363 activeOrderKey: "hq",
361364 titleRegeneration: null,
362365 deletedAt: null,
363366 messages: [
......@@ -487,6 +490,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
487490 snoozedAt: null,
488491 pinnedAt: "2026-02-24T00:00:01.000Z",
489492 pinOrderKey: "gm",
493 activeOrderKey: "hq",
490494 titleRegeneration: null,
491495 session: {
492496 threadId: ThreadId.make("thread-1"),
......@@ -513,6 +517,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
513517 }
514518
515519 const commandSnapshot = yield* snapshotQuery.getCommandReadModel();
520 assert.equal(commandSnapshot.threads[0]?.activeOrderKey, "hq");
516521 assert.deepEqual(commandSnapshot.threads[0]?.branchPullRequest, branchPullRequest);
517522 const threadShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1"));
518523 assert.equal(threadShell._tag, "Some");
......@@ -560,6 +565,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
560565 );
561566 assert.equal(detailWithoutActivities._tag, "Some");
562567 if (detailWithoutActivities._tag === "Some") {
568 assert.equal(detailWithoutActivities.value.activeOrderKey, "hq");
563569 assert.deepEqual(detailWithoutActivities.value.activities, []);
564570 assert.deepEqual(detailWithoutActivities.value.messages, snapshot.threads[0]?.messages);
565571 assert.deepEqual(
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+10
......@@ -513,6 +513,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
513513 snoozed_at AS "snoozedAt",
514514 pinned_at AS "pinnedAt",
515515 pin_order_key AS "pinOrderKey",
516 active_order_key AS "activeOrderKey",
516517 title_regeneration_request_id AS "titleRegenerationRequestId",
517518 title_regeneration_started_at AS "titleRegenerationStartedAt",
518519 latest_user_message_at AS "latestUserMessageAt",
......@@ -552,6 +553,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
552553 snoozed_at AS "snoozedAt",
553554 pinned_at AS "pinnedAt",
554555 pin_order_key AS "pinOrderKey",
556 active_order_key AS "activeOrderKey",
555557 title_regeneration_request_id AS "titleRegenerationRequestId",
556558 title_regeneration_started_at AS "titleRegenerationStartedAt",
557559 latest_user_message_at AS "latestUserMessageAt",
......@@ -593,6 +595,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
593595 snoozed_at AS "snoozedAt",
594596 pinned_at AS "pinnedAt",
595597 pin_order_key AS "pinOrderKey",
598 active_order_key AS "activeOrderKey",
596599 title_regeneration_request_id AS "titleRegenerationRequestId",
597600 title_regeneration_started_at AS "titleRegenerationStartedAt",
598601 latest_user_message_at AS "latestUserMessageAt",
......@@ -1083,6 +1086,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
10831086 snoozed_at AS "snoozedAt",
10841087 pinned_at AS "pinnedAt",
10851088 pin_order_key AS "pinOrderKey",
1089 active_order_key AS "activeOrderKey",
10861090 title_regeneration_request_id AS "titleRegenerationRequestId",
10871091 title_regeneration_started_at AS "titleRegenerationStartedAt",
10881092 latest_user_message_at AS "latestUserMessageAt",
......@@ -2082,6 +2086,7 @@ pending_approval_requests AS (
20822086 snoozedAt: row.snoozedAt,
20832087 pinnedAt: row.pinnedAt,
20842088 pinOrderKey: row.pinOrderKey ?? null,
2089 activeOrderKey: row.activeOrderKey ?? null,
20852090 titleRegeneration: mapTitleRegeneration(row),
20862091 deletedAt: row.deletedAt,
20872092 messages: messagesByThread.get(row.threadId) ?? [],
......@@ -2296,6 +2301,7 @@ pending_approval_requests AS (
22962301 snoozedAt: row.snoozedAt,
22972302 pinnedAt: row.pinnedAt,
22982303 pinOrderKey: row.pinOrderKey ?? null,
2304 activeOrderKey: row.activeOrderKey ?? null,
22992305 titleRegeneration: mapTitleRegeneration(row),
23002306 deletedAt: row.deletedAt,
23012307 messages: [],
......@@ -2437,6 +2443,7 @@ pending_approval_requests AS (
24372443 snoozedAt: row.snoozedAt,
24382444 pinnedAt: row.pinnedAt,
24392445 pinOrderKey: row.pinOrderKey ?? null,
2446 activeOrderKey: row.activeOrderKey ?? null,
24402447 titleRegeneration: mapTitleRegeneration(row),
24412448 session: sessionByThread.get(row.threadId) ?? null,
24422449 latestUserMessageAt: row.latestUserMessageAt,
......@@ -2586,6 +2593,7 @@ pending_approval_requests AS (
25862593 snoozedAt: row.snoozedAt,
25872594 pinnedAt: row.pinnedAt,
25882595 pinOrderKey: row.pinOrderKey ?? null,
2596 activeOrderKey: row.activeOrderKey ?? null,
25892597 titleRegeneration: mapTitleRegeneration(row),
25902598 session: sessionByThread.get(row.threadId) ?? null,
25912599 latestUserMessageAt: row.latestUserMessageAt,
......@@ -2908,6 +2916,7 @@ pending_approval_requests AS (
29082916 snoozedAt: threadRow.value.snoozedAt,
29092917 pinnedAt: threadRow.value.pinnedAt,
29102918 pinOrderKey: threadRow.value.pinOrderKey ?? null,
2919 activeOrderKey: threadRow.value.activeOrderKey ?? null,
29112920 titleRegeneration: mapTitleRegeneration(threadRow.value),
29122921 session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null,
29132922 latestUserMessageAt: threadRow.value.latestUserMessageAt,
......@@ -3190,6 +3199,7 @@ pending_approval_requests AS (
31903199 snoozedAt: threadRow.value.snoozedAt,
31913200 pinnedAt: threadRow.value.pinnedAt,
31923201 pinOrderKey: threadRow.value.pinOrderKey ?? null,
3202 activeOrderKey: threadRow.value.activeOrderKey ?? null,
31933203 titleRegeneration: mapTitleRegeneration(threadRow.value),
31943204 deletedAt: null,
31953205 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"
788788 };
789789 }
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
791827 case "thread.meta.update": {
792828 const thread = yield* requireThread({
793829 readModel,
apps/server/src/orchestration/projector.test.ts+1
......@@ -91,6 +91,7 @@ describe("orchestration projector", () => {
9191 createdAt: now,
9292 updatedAt: now,
9393 archivedAt: null,
94 activeOrderKey: null,
9495 settledOverride: null,
9596 settledAt: null,
9697 unsettledAt: null,
apps/server/src/orchestration/projector.ts+5
......@@ -344,6 +344,7 @@ export function projectEvent(
344344 settledOverride: null,
345345 settledAt: null,
346346 unsettledAt: null,
347 activeOrderKey: null,
347348 snoozedUntil: null,
348349 snoozedAt: null,
349350 deletedAt: null,
......@@ -406,6 +407,7 @@ export function projectEvent(
406407 settledOverride: "settled",
407408 settledAt: payload.settledAt,
408409 unsettledAt: null,
410 activeOrderKey: null,
409411 updatedAt: payload.updatedAt,
410412 }),
411413 })),
......@@ -500,6 +502,9 @@ export function projectEvent(
500502 ...nextBase,
501503 threads: updateThread(nextBase.threads, payload.threadId, {
502504 ...(payload.title !== undefined ? { title: payload.title } : {}),
505 ...(payload.activeOrderKey !== undefined
506 ? { activeOrderKey: payload.activeOrderKey }
507 : {}),
503508 ...(payload.titleRegeneration !== undefined
504509 ? { titleRegeneration: payload.titleRegeneration }
505510 : {}),
apps/server/src/persistence/Layers/ProjectionThreads.ts+5
......@@ -54,6 +54,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
5454 snoozed_at,
5555 pinned_at,
5656 pin_order_key,
57 active_order_key,
5758 title_regeneration_request_id,
5859 title_regeneration_started_at,
5960 latest_user_message_at,
......@@ -84,6 +85,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
8485 ${row.snoozedAt},
8586 ${row.pinnedAt},
8687 ${row.pinOrderKey ?? null},
88 ${row.activeOrderKey ?? null},
8789 ${row.titleRegenerationRequestId ?? null},
8890 ${row.titleRegenerationStartedAt ?? null},
8991 ${row.latestUserMessageAt},
......@@ -114,6 +116,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
114116 snoozed_at = excluded.snoozed_at,
115117 pinned_at = excluded.pinned_at,
116118 pin_order_key = excluded.pin_order_key,
119 active_order_key = excluded.active_order_key,
117120 title_regeneration_request_id = excluded.title_regeneration_request_id,
118121 title_regeneration_started_at = excluded.title_regeneration_started_at,
119122 latest_user_message_at = excluded.latest_user_message_at,
......@@ -151,6 +154,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
151154 snoozed_at AS "snoozedAt",
152155 pinned_at AS "pinnedAt",
153156 pin_order_key AS "pinOrderKey",
157 active_order_key AS "activeOrderKey",
154158 title_regeneration_request_id AS "titleRegenerationRequestId",
155159 title_regeneration_started_at AS "titleRegenerationStartedAt",
156160 latest_user_message_at AS "latestUserMessageAt",
......@@ -190,6 +194,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
190194 snoozed_at AS "snoozedAt",
191195 pinned_at AS "pinnedAt",
192196 pin_order_key AS "pinOrderKey",
197 active_order_key AS "activeOrderKey",
193198 title_regeneration_request_id AS "titleRegenerationRequestId",
194199 title_regeneration_started_at AS "titleRegenerationStartedAt",
195200 latest_user_message_at AS "latestUserMessageAt",
apps/server/src/persistence/Migrations.ts+2
......@@ -60,6 +60,7 @@ import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts";
6060import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps.ts";
6161import Migration0047 from "./Migrations/047_ProjectionProjectIcon.ts";
6262import Migration0048 from "./Migrations/048_ProjectionThreadBranchPullRequest.ts";
63import Migration0049 from "./Migrations/049_ProjectionThreadsActiveOrderKey.ts";
6364
6465/**
6566 * Migration loader with all migrations defined inline.
......@@ -120,6 +121,7 @@ export const migrationEntries = [
120121 [46, "RepairAutomaticSettlementTimestamps", Migration0046],
121122 [47, "ProjectionProjectIcon", Migration0047],
122123 [48, "ProjectionThreadBranchPullRequest", Migration0048],
124 [49, "ProjectionThreadsActiveOrderKey", Migration0049],
123125] as const;
124126
125127export 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({
4747 snoozedAt: Schema.NullOr(IsoDateTime),
4848 pinnedAt: Schema.NullOr(IsoDateTime),
4949 pinOrderKey: Schema.optional(Schema.NullOr(Schema.String)),
50 activeOrderKey: Schema.optional(Schema.NullOr(Schema.String)),
5051 titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)),
5152 titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
5253 latestUserMessageAt: Schema.NullOr(IsoDateTime),
packages/client-runtime/src/operations/commands.test.ts+21
......@@ -24,6 +24,7 @@ import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
2424import {
2525 archiveThread,
2626 createProject,
27 reorderActiveThread,
2728 settleThread,
2829 stopThreadSession,
2930 unsettleThread,
......@@ -172,4 +173,24 @@ describe("environment commands", () => {
172173 ]);
173174 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
174175 );
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 );
175196});
packages/client-runtime/src/operations/commands.ts+11
......@@ -42,6 +42,7 @@ export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">;
4242export type PinThreadInput = CommandInput<"thread.pin">;
4343export type UnpinThreadInput = CommandInput<"thread.unpin">;
4444export type ReorderPinnedThreadInput = CommandInput<"thread.pin.reorder">;
45export type ReorderActiveThreadInput = CommandInput<"thread.active.reorder">;
4546export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">;
4647export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">;
4748export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">;
......@@ -230,6 +231,16 @@ export const reorderPinnedThread: (input: ReorderPinnedThreadInput) => CommandEf
230231 });
231232});
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
233244export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn(
234245 "EnvironmentCommands.updateThreadMetadata",
235246)(function* (input) {
packages/client-runtime/src/state/entities.test.ts+6
......@@ -208,6 +208,8 @@ describe("environment entity projections", () => {
208208 title: "Cached thread",
209209 branch: "stale-branch",
210210 worktreePath: "/repo/stale-worktree",
211 activeOrderKey: "t",
212 unsettledAt: "2026-03-09T10:00:00.000Z",
211213 deletedAt: null,
212214 messages,
213215 proposedPlans: [],
......@@ -220,6 +222,8 @@ describe("environment entity projections", () => {
220222 title: "Current thread",
221223 branch: "current-branch",
222224 worktreePath: "/repo/current-worktree",
225 activeOrderKey: "f",
226 unsettledAt: "2026-03-09T12:00:00.000Z",
223227 };
224228
225229 const merged = mergeEnvironmentThread(detail, shell);
......@@ -228,6 +232,8 @@ describe("environment entity projections", () => {
228232 title: "Current thread",
229233 branch: "current-branch",
230234 worktreePath: "/repo/current-worktree",
235 activeOrderKey: "f",
236 unsettledAt: "2026-03-09T12:00:00.000Z",
231237 });
232238 expect(merged?.messages).toBe(messages);
233239 });
packages/client-runtime/src/state/threadCommands.ts+9
......@@ -19,6 +19,7 @@ import {
1919 type SetThreadRuntimeModeInput,
2020 type PinThreadInput,
2121 type ReorderPinnedThreadInput,
22 type ReorderActiveThreadInput,
2223 type SettleThreadInput,
2324 type SnoozeThreadInput,
2425 type StartThreadTurnInput,
......@@ -39,6 +40,7 @@ import {
3940 setThreadRuntimeMode,
4041 pinThread,
4142 reorderPinnedThread,
43 reorderActiveThread,
4244 settleThread,
4345 snoozeThread,
4446 startThreadTurn,
......@@ -63,6 +65,7 @@ export type {
6365 SetThreadRuntimeModeInput,
6466 PinThreadInput,
6567 ReorderPinnedThreadInput,
68 ReorderActiveThreadInput,
6669 SettleThreadInput,
6770 SnoozeThreadInput,
6871 StartThreadTurnInput,
......@@ -150,6 +153,12 @@ export function createThreadEnvironmentAtoms<R, E>(
150153 scheduler,
151154 concurrency,
152155 }),
156 reorderActive: createEnvironmentCommand(runtime, {
157 label: "environment-data:commands:thread:reorder-active",
158 execute: (input: ReorderActiveThreadInput) => reorderActiveThread(input),
159 scheduler,
160 concurrency,
161 }),
153162 updateMetadata: createEnvironmentCommand(runtime, {
154163 label: "environment-data:commands:thread:update-metadata",
155164 execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input),
packages/client-runtime/src/state/threadDetail.ts+2
......@@ -58,6 +58,8 @@ export function mergeEnvironmentThread(
5858 archivedAt: shell.archivedAt,
5959 settledOverride: shell.settledOverride,
6060 settledAt: shell.settledAt,
61 unsettledAt: shell.unsettledAt,
62 activeOrderKey: shell.activeOrderKey,
6163 snoozedUntil: shell.snoozedUntil,
6264 snoozedAt: shell.snoozedAt,
6365 pinnedAt: shell.pinnedAt,
packages/client-runtime/src/state/threadReducer.test.ts+76-37
......@@ -178,24 +178,28 @@ describe("applyThreadDetailEvent", () => {
178178 describe("thread.settled / thread.unsettled", () => {
179179 it("sets the settled override and timestamp", () => {
180180 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 },
192195 },
193 });
196 );
194197
195198 expect(result.kind).toBe("updated");
196199 if (result.kind === "updated") {
197200 expect(result.thread.settledOverride).toBe("settled");
198201 expect(result.thread.settledAt).toBe(settledAt);
202 expect(result.thread.activeOrderKey).toBeNull();
199203 }
200204 });
201205
......@@ -234,23 +238,27 @@ describe("applyThreadDetailEvent", () => {
234238 describe("thread.pinned / thread.unpinned", () => {
235239 it("sets pinnedAt", () => {
236240 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 },
248255 },
249 });
256 );
250257
251258 expect(result.kind).toBe("updated");
252259 if (result.kind === "updated") {
253260 expect(result.thread.pinnedAt).toBe(pinnedAt);
261 expect(result.thread.activeOrderKey).toBe("m");
254262 }
255263 });
256264
......@@ -281,26 +289,57 @@ describe("applyThreadDetailEvent", () => {
281289 });
282290
283291 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
284319 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 },
297335 },
298 });
336 );
299337
300338 expect(result.kind).toBe("updated");
301339 if (result.kind === "updated") {
302340 expect(result.thread.title).toBe("Updated Title");
303341 expect(result.thread.branch).toBe("feature/demo");
342 expect(result.thread.activeOrderKey).toBe("m");
304343 // Model selection should be unchanged since it wasn't in the payload
305344 expect(result.thread.modelSelection).toEqual(baseThread.modelSelection);
306345 }
packages/client-runtime/src/state/threadReducer.ts+5
......@@ -105,6 +105,7 @@ export function applyThreadDetailEvent(
105105 settledOverride: null,
106106 settledAt: null,
107107 unsettledAt: null,
108 activeOrderKey: null,
108109 snoozedUntil: null,
109110 snoozedAt: null,
110111 deletedAt: null,
......@@ -144,6 +145,7 @@ export function applyThreadDetailEvent(
144145 settledOverride: "settled",
145146 settledAt: event.payload.settledAt,
146147 unsettledAt: null,
148 activeOrderKey: null,
147149 updatedAt: event.payload.updatedAt,
148150 },
149151 };
......@@ -244,6 +246,9 @@ export function applyThreadDetailEvent(
244246 ...(event.payload.branchPullRequest !== undefined
245247 ? { branchPullRequest: event.payload.branchPullRequest }
246248 : {}),
249 ...(event.payload.activeOrderKey !== undefined
250 ? { activeOrderKey: event.payload.activeOrderKey }
251 : {}),
247252 updatedAt: event.payload.updatedAt,
248253 },
249254 };
packages/client-runtime/src/state/threadSort.test.ts+176
......@@ -1,8 +1,12 @@
11import { describe, expect, it } from "vite-plus/test";
22
33import {
4 generateSpreadPinOrderKeys,
5 pinOrderKeyBetween,
46 planPinnedMove,
7 planPinnedReorder,
58 resolveSettledThreadTimestamp,
9 sortActiveThreadsByOrderKey,
610 sortPinnedThreadsByOrderKey,
711 sortThreads,
812 type ThreadSortInput,
......@@ -108,6 +112,44 @@ describe("sortThreads", () => {
108112 });
109113});
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
111153describe("planPinnedMove", () => {
112154 it("moves a thread up with a single key write", () => {
113155 const assignments = planPinnedMove({
......@@ -173,3 +215,137 @@ describe("sortPinnedThreadsByOrderKey", () => {
173215 expect(sorted.map((thread) => thread.environmentId)).toEqual(["env-a", "env-b"]);
174216 });
175217});
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):
205205 return pinOrderMidpoint(a, b);
206206}
207207
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. */
212function 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. */
210export 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 }
214217 const step = space / (count + 1);
215218 const keys: string[] = [];
216 let previous = 0;
217219 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));
219221 // Skip values whose low digit is the minimum (a trailing "a" key).
220222 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);
227229 }
228230 return keys;
229231}
......@@ -233,15 +235,21 @@ function generateSpreadPinOrderKeys(count: number): string[] {
233235 * sits between two keyed (or absent) neighbors, this is a single write to
234236 * the moved thread. When a neighbor is keyless (threads pinned before
235237 * 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.
237240 */
238241export function planPinnedReorder(input: {
239242 /** Thread ids in the desired visual order (after the move). */
240243 readonly orderedIds: readonly string[];
244 /** Include retained keys from hidden rows; only orderedIds receive writes. */
241245 readonly keysById: ReadonlyMap<string, string | null | undefined>;
242246 readonly movedId: string;
243247}): ReadonlyArray<{ readonly id: string; readonly orderKey: string }> {
244248 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 );
245253 const movedIndex = orderedIds.indexOf(movedId);
246254 if (movedIndex === -1) return [];
247255 const beforeId = movedIndex > 0 ? orderedIds[movedIndex - 1] : null;
......@@ -251,11 +259,14 @@ export function planPinnedReorder(input: {
251259 const beforeUsable = beforeId === null || beforeKey != null;
252260 const afterUsable = afterId === null || afterKey != null;
253261 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);
255264 if (key !== null) return [{ id: movedId, orderKey: key }];
256265 }
257266 // 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);
259270 return orderedIds.flatMap((id, index) => {
260271 const key = keys[index]!;
261272 return keysById.get(id) === key ? [] : [{ id, orderKey: key }];
......@@ -303,6 +314,36 @@ export function sortPinnedThreadsByOrderKey<
303314 return [...keyed, ...keyless];
304315}
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
306347/**
307348 * planPinnedReorder specialized for mobile's Move up / Move down menu
308349 * 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({
116116 /** Server understands thread.pin.reorder (and orderKey on thread.pin).
117117 Same version-skew contract as threadSettlement. */
118118 threadPinReorder: Schema.optionalKey(Schema.Boolean),
119 /** Server persists manual Active order through thread.active.reorder. */
120 threadActiveReorder: Schema.optionalKey(Schema.Boolean),
119121 /** Server understands regenerateTitle on thread.meta.update. Absent on
120122 older servers, so clients hide the action instead of sending it. */
121123 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", () =>
784784 }),
785785);
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
787842it.effect("accepts a title regeneration intent in thread.meta.update", () =>
788843 Effect.gen(function* () {
789844 const parsed = yield* decodeOrchestrationCommand({
packages/contracts/src/orchestration.ts+16
......@@ -523,6 +523,9 @@ export const OrchestrationThread = Schema.Struct({
523523 // servers never need each other's threads to agree on the merged list.
524524 // Optional so payloads from pre-reorder servers still decode.
525525 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)),
526529 // Pending-only state. Optional so older servers remain compatible.
527530 titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),
528531 deletedAt: Schema.NullOr(IsoDateTime),
......@@ -588,6 +591,7 @@ export const OrchestrationThreadShell = Schema.Struct({
588591 snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
589592 pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
590593 pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
594 activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
591595 titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),
592596 session: Schema.NullOr(OrchestrationSession),
593597 latestUserMessageAt: Schema.NullOr(IsoDateTime),
......@@ -895,6 +899,13 @@ const ThreadPinReorderCommand = Schema.Struct({
895899 orderKey: TrimmedNonEmptyString,
896900});
897901
902const ThreadActiveReorderCommand = Schema.Struct({
903 type: Schema.Literal("thread.active.reorder"),
904 commandId: CommandId,
905 threadId: ThreadId,
906 orderKey: TrimmedNonEmptyString,
907});
908
898909const ThreadMetaUpdateCommand = Schema.Struct({
899910 type: Schema.Literal("thread.meta.update"),
900911 commandId: CommandId,
......@@ -1058,6 +1069,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([
10581069 ThreadPinCommand,
10591070 ThreadUnpinCommand,
10601071 ThreadPinReorderCommand,
1072 ThreadActiveReorderCommand,
10611073 ThreadMetaUpdateCommand,
10621074 ThreadRuntimeModeSetCommand,
10631075 ThreadInteractionModeSetCommand,
......@@ -1086,6 +1098,7 @@ export const ClientOrchestrationCommand = Schema.Union([
10861098 ThreadPinCommand,
10871099 ThreadUnpinCommand,
10881100 ThreadPinReorderCommand,
1101 ThreadActiveReorderCommand,
10891102 ThreadMetaUpdateCommand,
10901103 ThreadRuntimeModeSetCommand,
10911104 ThreadInteractionModeSetCommand,
......@@ -1375,6 +1388,9 @@ export const ThreadPinReorderedPayload = Schema.Struct({
13751388
13761389export const ThreadMetaUpdatedPayload = Schema.Struct({
13771390 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)),
13781394 title: Schema.optional(TrimmedNonEmptyString),
13791395 /** Intent marker consumed by the title-generation reactor. Keeping this on
13801396 the existing event lets older clients safely ignore the new field. */