authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-25 23:59:56-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-25 23:59:56-07:00
log3b86ef941c218a000a2ea9920480f469b726f437
tree24a2ddffd600b216440518b02018325bde285f8b
parentb0a0281269156295e2202d31198829bd3b500bdf
signature Signed by PGP key B5690EEEBB952194

fix(app): un-settled threads return to the top of the list (#8231)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

22 files changed, 271 insertions(+), 29 deletions(-)

apps/mobile/src/features/threads/threadListV2.test.ts+13
...@@ -303,6 +303,19 @@ describe("sortThreadsForListV2", () => {...@@ -303,6 +303,19 @@ describe("sortThreadsForListV2", () => {
303 ]);303 ]);
304 expect(sorted.map((thread) => thread.id)).toEqual(["newest", "middle", "oldest"]);304 expect(sorted.map((thread) => thread.id)).toEqual(["newest", "middle", "oldest"]);
305 });305 });
306
307 it("surfaces an un-settled thread at the top via its re-entry stamp", () => {
308 const sorted = sortThreadsForListV2([
309 {
310 id: "old-unsettled",
311 createdAt: "2026-06-01T08:00:00.000Z",
312 unsettledAt: "2026-06-01T13:00:00.000Z",
313 },
314 { id: "newest", createdAt: "2026-06-01T12:00:00.000Z" },
315 { id: "middle", createdAt: "2026-06-01T10:00:00.000Z" },
316 ]);
317 expect(sorted.map((thread) => thread.id)).toEqual(["old-unsettled", "newest", "middle"]);
318 });
306});319});
307320
308describe("buildThreadListV2Items", () => {321describe("buildThreadListV2Items", () => {
apps/mobile/src/features/threads/threadListV2.ts+18-9
...@@ -12,7 +12,10 @@ import type {...@@ -12,7 +12,10 @@ import type {
12} from "@t3tools/client-runtime/state/thread-settled";12} from "@t3tools/client-runtime/state/thread-settled";
13import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";13import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
14import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";14import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
15import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";15import {
16 activeThreadAnchorTimestampMs,
17 sortPinnedThreadsByOrderKey,
18} from "@t3tools/client-runtime/state/thread-sort";
16import type { EnvironmentId, ProjectId, ThreadLinkedPullRequest } from "@t3tools/contracts";19import type { EnvironmentId, ProjectId, ThreadLinkedPullRequest } from "@t3tools/contracts";
1720
18import type { PendingNewTask } from "../../state/use-pending-new-tasks";21import type { PendingNewTask } from "../../state/use-pending-new-tasks";
...@@ -191,19 +194,25 @@ function firstValidTimestampMs(...candidates: ReadonlyArray<string | null | unde...@@ -191,19 +194,25 @@ function firstValidTimestampMs(...candidates: ReadonlyArray<string | null | unde
191}194}
192195
193/**196/**
194 * v2 sort: static creation order, newest thread on top. Activity NEVER197 * v2 sort: static order, newest anchor on top. Activity NEVER reorders the
195 * reorders the list — a row holds its position from open until settled, so198 * list — a row holds its position between lifecycle transitions. The anchor
196 * the screen only moves at lifecycle transitions. Mirrors web's199 * is creation time until an un-settle re-anchors it (see
197 * sortThreadsForSidebarV2.200 * activeThreadAnchorTimestampMs), so an un-settled thread surfaces at the
201 * top instead of sinking back to its creation-order slot. Mirrors web's
202 * sortThreadsForSidebar.
198 */203 */
199export function sortThreadsForListV2<T extends { readonly id: string; readonly createdAt: string }>(204export function sortThreadsForListV2<
200 threads: readonly T[],205 T extends {
201): T[] {206 readonly id: string;
207 readonly createdAt: string;
208 readonly unsettledAt?: string | null | undefined;
209 },
210>(threads: readonly T[]): T[] {
202 // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023211 // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023
203 // change-by-copy array methods.212 // change-by-copy array methods.
204 return [...threads].sort(213 return [...threads].sort(
205 (left, right) =>214 (left, right) =>
206 parseTimestampMs(right.createdAt) - parseTimestampMs(left.createdAt) ||215 activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left) ||
207 left.id.localeCompare(right.id),216 left.id.localeCompare(right.id),
208 );217 );
209}218}
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+16-4
...@@ -269,15 +269,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {...@@ -269,15 +269,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
269 const settledRows = yield* sql<{269 const settledRows = yield* sql<{
270 readonly settledOverride: string | null;270 readonly settledOverride: string | null;
271 readonly settledAt: string | null;271 readonly settledAt: string | null;
272 readonly unsettledAt: string | null;
272 }>`273 }>`
273 SELECT274 SELECT
274 settled_override AS "settledOverride",275 settled_override AS "settledOverride",
275 settled_at AS "settledAt"276 settled_at AS "settledAt",
277 unsettled_at AS "unsettledAt"
276 FROM projection_threads278 FROM projection_threads
277 WHERE thread_id = 'thread-1'279 WHERE thread_id = 'thread-1'
278 `;280 `;
279 assert.deepEqual(settledRows, [281 assert.deepEqual(settledRows, [
280 { settledOverride: "settled", settledAt: "2026-01-01T00:00:01.000Z" },282 { settledOverride: "settled", settledAt: "2026-01-01T00:00:01.000Z", unsettledAt: null },
281 ]);283 ]);
282284
283 yield* eventStore.append({285 yield* eventStore.append({
...@@ -301,14 +303,24 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {...@@ -301,14 +303,24 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
301 const unsettledRows = yield* sql<{303 const unsettledRows = yield* sql<{
302 readonly settledOverride: string | null;304 readonly settledOverride: string | null;
303 readonly settledAt: string | null;305 readonly settledAt: string | null;
306 readonly unsettledAt: string | null;
304 }>`307 }>`
305 SELECT308 SELECT
306 settled_override AS "settledOverride",309 settled_override AS "settledOverride",
307 settled_at AS "settledAt"310 settled_at AS "settledAt",
311 unsettled_at AS "unsettledAt"
308 FROM projection_threads312 FROM projection_threads
309 WHERE thread_id = 'thread-1'313 WHERE thread_id = 'thread-1'
310 `;314 `;
311 assert.deepEqual(unsettledRows, [{ settledOverride: "active", settledAt: null }]);315 // The un-settle stamps the active-list re-entry time so clients can
316 // surface the thread at the top of the list.
317 assert.deepEqual(unsettledRows, [
318 {
319 settledOverride: "active",
320 settledAt: null,
321 unsettledAt: "2026-01-01T00:00:02.000Z",
322 },
323 ]);
312 }),324 }),
313 );325 );
314});326});
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+9
...@@ -641,6 +641,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -641,6 +641,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
641 archivedAt: null,641 archivedAt: null,
642 settledOverride: null,642 settledOverride: null,
643 settledAt: null,643 settledAt: null,
644 unsettledAt: null,
644 snoozedUntil: null,645 snoozedUntil: null,
645 snoozedAt: null,646 snoozedAt: null,
646 pinnedAt: null,647 pinnedAt: null,
...@@ -698,6 +699,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -698,6 +699,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
698 ...existingRow.value,699 ...existingRow.value,
699 settledOverride: "settled",700 settledOverride: "settled",
700 settledAt: event.payload.settledAt,701 settledAt: event.payload.settledAt,
702 unsettledAt: null,
701 updatedAt: event.payload.updatedAt,703 updatedAt: event.payload.updatedAt,
702 });704 });
703 return;705 return;
...@@ -714,6 +716,13 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -714,6 +716,13 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
714 ...existingRow.value,716 ...existingRow.value,
715 settledOverride: event.payload.reason === "user" ? "active" : null,717 settledOverride: event.payload.reason === "user" ? "active" : null,
716 settledAt: null,718 settledAt: null,
719 // Re-entry stamp for active-list ordering. A thread already pinned
720 // active keeps its stamp: the activity reset that clears the pin
721 // is not a re-entry and must not reorder the list.
722 unsettledAt:
723 existingRow.value.settledOverride === "active"
724 ? existingRow.value.unsettledAt
725 : event.payload.updatedAt,
717 updatedAt: event.payload.updatedAt,726 updatedAt: event.payload.updatedAt,
718 });727 });
719 return;728 return;
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+2
...@@ -329,6 +329,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -329,6 +329,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
329 archivedAt: null,329 archivedAt: null,
330 settledOverride: null,330 settledOverride: null,
331 settledAt: null,331 settledAt: null,
332 unsettledAt: null,
332 snoozedUntil: null,333 snoozedUntil: null,
333 snoozedAt: null,334 snoozedAt: null,
334 pinnedAt: "2026-02-24T00:00:01.000Z",335 pinnedAt: "2026-02-24T00:00:01.000Z",
...@@ -454,6 +455,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -454,6 +455,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
454 archivedAt: null,455 archivedAt: null,
455 settledOverride: null,456 settledOverride: null,
456 settledAt: null,457 settledAt: null,
458 unsettledAt: null,
457 snoozedUntil: null,459 snoozedUntil: null,
458 snoozedAt: null,460 snoozedAt: null,
459 pinnedAt: "2026-02-24T00:00:01.000Z",461 pinnedAt: "2026-02-24T00:00:01.000Z",
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+10
...@@ -431,6 +431,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -431,6 +431,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
431 archived_at AS "archivedAt",431 archived_at AS "archivedAt",
432 settled_override AS "settledOverride",432 settled_override AS "settledOverride",
433 settled_at AS "settledAt",433 settled_at AS "settledAt",
434 unsettled_at AS "unsettledAt",
434 snoozed_until AS "snoozedUntil",435 snoozed_until AS "snoozedUntil",
435 snoozed_at AS "snoozedAt",436 snoozed_at AS "snoozedAt",
436 pinned_at AS "pinnedAt",437 pinned_at AS "pinnedAt",
...@@ -468,6 +469,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -468,6 +469,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
468 archived_at AS "archivedAt",469 archived_at AS "archivedAt",
469 settled_override AS "settledOverride",470 settled_override AS "settledOverride",
470 settled_at AS "settledAt",471 settled_at AS "settledAt",
472 unsettled_at AS "unsettledAt",
471 snoozed_until AS "snoozedUntil",473 snoozed_until AS "snoozedUntil",
472 snoozed_at AS "snoozedAt",474 snoozed_at AS "snoozedAt",
473 pinned_at AS "pinnedAt",475 pinned_at AS "pinnedAt",
...@@ -507,6 +509,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -507,6 +509,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
507 archived_at AS "archivedAt",509 archived_at AS "archivedAt",
508 settled_override AS "settledOverride",510 settled_override AS "settledOverride",
509 settled_at AS "settledAt",511 settled_at AS "settledAt",
512 unsettled_at AS "unsettledAt",
510 snoozed_until AS "snoozedUntil",513 snoozed_until AS "snoozedUntil",
511 snoozed_at AS "snoozedAt",514 snoozed_at AS "snoozedAt",
512 pinned_at AS "pinnedAt",515 pinned_at AS "pinnedAt",
...@@ -950,6 +953,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -950,6 +953,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
950 archived_at AS "archivedAt",953 archived_at AS "archivedAt",
951 settled_override AS "settledOverride",954 settled_override AS "settledOverride",
952 settled_at AS "settledAt",955 settled_at AS "settledAt",
956 unsettled_at AS "unsettledAt",
953 snoozed_until AS "snoozedUntil",957 snoozed_until AS "snoozedUntil",
954 snoozed_at AS "snoozedAt",958 snoozed_at AS "snoozedAt",
955 pinned_at AS "pinnedAt",959 pinned_at AS "pinnedAt",
...@@ -1709,6 +1713,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1709,6 +1713,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1709 archivedAt: row.archivedAt,1713 archivedAt: row.archivedAt,
1710 settledOverride: row.settledOverride,1714 settledOverride: row.settledOverride,
1711 settledAt: row.settledAt,1715 settledAt: row.settledAt,
1716 unsettledAt: row.unsettledAt,
1712 snoozedUntil: row.snoozedUntil,1717 snoozedUntil: row.snoozedUntil,
1713 snoozedAt: row.snoozedAt,1718 snoozedAt: row.snoozedAt,
1714 pinnedAt: row.pinnedAt,1719 pinnedAt: row.pinnedAt,
...@@ -1919,6 +1924,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1919,6 +1924,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1919 archivedAt: row.archivedAt,1924 archivedAt: row.archivedAt,
1920 settledOverride: row.settledOverride,1925 settledOverride: row.settledOverride,
1921 settledAt: row.settledAt,1926 settledAt: row.settledAt,
1927 unsettledAt: row.unsettledAt,
1922 snoozedUntil: row.snoozedUntil,1928 snoozedUntil: row.snoozedUntil,
1923 snoozedAt: row.snoozedAt,1929 snoozedAt: row.snoozedAt,
1924 pinnedAt: row.pinnedAt,1930 pinnedAt: row.pinnedAt,
...@@ -2058,6 +2064,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2058,6 +2064,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2058 archivedAt: row.archivedAt,2064 archivedAt: row.archivedAt,
2059 settledOverride: row.settledOverride,2065 settledOverride: row.settledOverride,
2060 settledAt: row.settledAt,2066 settledAt: row.settledAt,
2067 unsettledAt: row.unsettledAt,
2061 snoozedUntil: row.snoozedUntil,2068 snoozedUntil: row.snoozedUntil,
2062 snoozedAt: row.snoozedAt,2069 snoozedAt: row.snoozedAt,
2063 pinnedAt: row.pinnedAt,2070 pinnedAt: row.pinnedAt,
...@@ -2206,6 +2213,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2206,6 +2213,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2206 archivedAt: row.archivedAt,2213 archivedAt: row.archivedAt,
2207 settledOverride: row.settledOverride,2214 settledOverride: row.settledOverride,
2208 settledAt: row.settledAt,2215 settledAt: row.settledAt,
2216 unsettledAt: row.unsettledAt,
2209 snoozedUntil: row.snoozedUntil,2217 snoozedUntil: row.snoozedUntil,
2210 snoozedAt: row.snoozedAt,2218 snoozedAt: row.snoozedAt,
2211 pinnedAt: row.pinnedAt,2219 pinnedAt: row.pinnedAt,
...@@ -2488,6 +2496,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2488,6 +2496,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2488 archivedAt: threadRow.value.archivedAt,2496 archivedAt: threadRow.value.archivedAt,
2489 settledOverride: threadRow.value.settledOverride,2497 settledOverride: threadRow.value.settledOverride,
2490 settledAt: threadRow.value.settledAt,2498 settledAt: threadRow.value.settledAt,
2499 unsettledAt: threadRow.value.unsettledAt,
2491 snoozedUntil: threadRow.value.snoozedUntil,2500 snoozedUntil: threadRow.value.snoozedUntil,
2492 snoozedAt: threadRow.value.snoozedAt,2501 snoozedAt: threadRow.value.snoozedAt,
2493 pinnedAt: threadRow.value.pinnedAt,2502 pinnedAt: threadRow.value.pinnedAt,
...@@ -2632,6 +2641,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2632,6 +2641,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2632 archivedAt: threadRow.value.archivedAt,2641 archivedAt: threadRow.value.archivedAt,
2633 settledOverride: threadRow.value.settledOverride,2642 settledOverride: threadRow.value.settledOverride,
2634 settledAt: threadRow.value.settledAt,2643 settledAt: threadRow.value.settledAt,
2644 unsettledAt: threadRow.value.unsettledAt,
2635 snoozedUntil: threadRow.value.snoozedUntil,2645 snoozedUntil: threadRow.value.snoozedUntil,
2636 snoozedAt: threadRow.value.snoozedAt,2646 snoozedAt: threadRow.value.snoozedAt,
2637 pinnedAt: threadRow.value.pinnedAt,2647 pinnedAt: threadRow.value.pinnedAt,
apps/server/src/orchestration/decider.settled.test.ts+38
...@@ -5,6 +5,7 @@ import {...@@ -5,6 +5,7 @@ import {
5 ProjectId,5 ProjectId,
6 ProviderInstanceId,6 ProviderInstanceId,
7 ThreadId,7 ThreadId,
8 type OrchestrationEvent,
8 type OrchestrationReadModel,9 type OrchestrationReadModel,
9 type OrchestrationSession,10 type OrchestrationSession,
10 type OrchestrationThread,11 type OrchestrationThread,
...@@ -14,6 +15,7 @@ import { expect, it } from "@effect/vitest";...@@ -14,6 +15,7 @@ import { expect, it } from "@effect/vitest";
14import * as Effect from "effect/Effect";15import * as Effect from "effect/Effect";
1516
16import { decideOrchestrationCommand } from "./decider.ts";17import { decideOrchestrationCommand } from "./decider.ts";
18import { projectEvent } from "./projector.ts";
1719
18const NOW = "2026-01-01T00:00:00.000Z";20const NOW = "2026-01-01T00:00:00.000Z";
19const SETTLED_AT = "2025-12-30T00:00:00.000Z";21const SETTLED_AT = "2025-12-30T00:00:00.000Z";
...@@ -428,6 +430,42 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => {...@@ -428,6 +430,42 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => {
428 }),430 }),
429 );431 );
430432
433 // Command-to-projection: an accepted un-settle must land as the re-entry
434 // stamp clients sort by (max of createdAt and unsettledAt, see
435 // activeThreadAnchorTimestampMs in client-runtime), so the thread surfaces
436 // above threads created after it. The projector tests feed events directly;
437 // this one proves the decider actually emits what they consume.
438 it.effect("an accepted un-settle re-anchors the thread for the active list", () =>
439 Effect.gen(function* () {
440 const readModel = makeReadModel("settled");
441 const result = yield* decideOrchestrationCommand({
442 command: {
443 type: "thread.unsettle",
444 commandId: CommandId.make("cmd-unsettle-anchor"),
445 threadId: ThreadId.make("thread-1"),
446 reason: "user",
447 },
448 readModel,
449 });
450 const events = Array.isArray(result) ? result : [result];
451 const unsettled = events[0]!;
452 expect(unsettled.type).toBe("thread.unsettled");
453
454 const projected = yield* projectEvent(readModel, {
455 ...unsettled,
456 sequence: readModel.snapshotSequence + 1,
457 } as OrchestrationEvent);
458 const thread = projected.threads[0]!;
459 expect(thread.settledOverride).toBe("active");
460 // The stamp is the decider's accept time: every thread created before
461 // the un-settle anchors below it.
462 expect(thread.unsettledAt).toBe(unsettled.occurredAt);
463 if (unsettled.type === "thread.unsettled") {
464 expect(thread.unsettledAt).toBe(unsettled.payload.updatedAt);
465 }
466 }),
467 );
468
431 it.effect("prepends activity unsets for turn starts and live session updates", () =>469 it.effect("prepends activity unsets for turn starts and live session updates", () =>
432 Effect.gen(function* () {470 Effect.gen(function* () {
433 const turnResult = yield* decideOrchestrationCommand({471 const turnResult = yield* decideOrchestrationCommand({
apps/server/src/orchestration/projector.settled.test.ts+37-2
...@@ -62,27 +62,62 @@ it.effect("projects settled lifecycle events", () =>...@@ -62,27 +62,62 @@ it.effect("projects settled lifecycle events", () =>
62 );62 );
63 expect(settled.threads[0]?.settledOverride).toBe("settled");63 expect(settled.threads[0]?.settledOverride).toBe("settled");
64 expect(settled.threads[0]?.settledAt).toBe(now);64 expect(settled.threads[0]?.settledAt).toBe(now);
65 expect(settled.threads[0]?.unsettledAt).toBeNull();
6566
67 const unsettleAt = "2026-01-02T00:00:00.000Z";
66 const userUnsettled = yield* projectEvent(68 const userUnsettled = yield* projectEvent(
67 settled,69 settled,
68 makeEvent({70 makeEvent({
69 sequence: 3,71 sequence: 3,
70 type: "thread.unsettled",72 type: "thread.unsettled",
71 payload: { threadId: ThreadId.make("thread-1"), reason: "user", updatedAt: now },73 payload: { threadId: ThreadId.make("thread-1"), reason: "user", updatedAt: unsettleAt },
72 }),74 }),
73 );75 );
74 expect(userUnsettled.threads[0]?.settledOverride).toBe("active");76 expect(userUnsettled.threads[0]?.settledOverride).toBe("active");
75 expect(userUnsettled.threads[0]?.settledAt).toBeNull();77 expect(userUnsettled.threads[0]?.settledAt).toBeNull();
78 expect(userUnsettled.threads[0]?.unsettledAt).toBe(unsettleAt);
7679
80 // Clearing the keep-active pin on activity is not a re-entry: the thread
81 // is already in the active list, so the stamp must not move it.
82 const activityAt = "2026-01-03T00:00:00.000Z";
77 const activityUnsettled = yield* projectEvent(83 const activityUnsettled = yield* projectEvent(
78 userUnsettled,84 userUnsettled,
79 makeEvent({85 makeEvent({
80 sequence: 4,86 sequence: 4,
81 type: "thread.unsettled",87 type: "thread.unsettled",
82 payload: { threadId: ThreadId.make("thread-1"), reason: "activity", updatedAt: now },88 payload: { threadId: ThreadId.make("thread-1"), reason: "activity", updatedAt: activityAt },
83 }),89 }),
84 );90 );
85 expect(activityUnsettled.threads[0]?.settledOverride).toBeNull();91 expect(activityUnsettled.threads[0]?.settledOverride).toBeNull();
86 expect(activityUnsettled.threads[0]?.settledAt).toBeNull();92 expect(activityUnsettled.threads[0]?.settledAt).toBeNull();
93 expect(activityUnsettled.threads[0]?.unsettledAt).toBe(unsettleAt);
94
95 const resettledAt = "2026-01-04T00:00:00.000Z";
96 const resettled = yield* projectEvent(
97 activityUnsettled,
98 makeEvent({
99 sequence: 5,
100 type: "thread.settled",
101 payload: {
102 threadId: ThreadId.make("thread-1"),
103 settledAt: resettledAt,
104 updatedAt: resettledAt,
105 },
106 }),
107 );
108 expect(resettled.threads[0]?.unsettledAt).toBeNull();
109
110 // Waking a settled thread on activity IS a re-entry and stamps.
111 const wakeAt = "2026-01-05T00:00:00.000Z";
112 const woke = yield* projectEvent(
113 resettled,
114 makeEvent({
115 sequence: 6,
116 type: "thread.unsettled",
117 payload: { threadId: ThreadId.make("thread-1"), reason: "activity", updatedAt: wakeAt },
118 }),
119 );
120 expect(woke.threads[0]?.settledOverride).toBeNull();
121 expect(woke.threads[0]?.unsettledAt).toBe(wakeAt);
87 }),122 }),
88);123);
apps/server/src/orchestration/projector.test.ts+1
...@@ -91,6 +91,7 @@ describe("orchestration projector", () => {...@@ -91,6 +91,7 @@ describe("orchestration projector", () => {
91 archivedAt: null,91 archivedAt: null,
92 settledOverride: null,92 settledOverride: null,
93 settledAt: null,93 settledAt: null,
94 unsettledAt: null,
94 snoozedUntil: null,95 snoozedUntil: null,
95 snoozedAt: null,96 snoozedAt: null,
96 deletedAt: null,97 deletedAt: null,
apps/server/src/orchestration/projector.ts+20-8
...@@ -303,6 +303,7 @@ export function projectEvent(...@@ -303,6 +303,7 @@ export function projectEvent(
303 archivedAt: null,303 archivedAt: null,
304 settledOverride: null,304 settledOverride: null,
305 settledAt: null,305 settledAt: null,
306 unsettledAt: null,
306 snoozedUntil: null,307 snoozedUntil: null,
307 snoozedAt: null,308 snoozedAt: null,
308 deletedAt: null,309 deletedAt: null,
...@@ -364,6 +365,7 @@ export function projectEvent(...@@ -364,6 +365,7 @@ export function projectEvent(
364 threads: updateThread(nextBase.threads, payload.threadId, {365 threads: updateThread(nextBase.threads, payload.threadId, {
365 settledOverride: "settled",366 settledOverride: "settled",
366 settledAt: payload.settledAt,367 settledAt: payload.settledAt,
368 unsettledAt: null,
367 updatedAt: payload.updatedAt,369 updatedAt: payload.updatedAt,
368 }),370 }),
369 })),371 })),
...@@ -371,14 +373,24 @@ export function projectEvent(...@@ -371,14 +373,24 @@ export function projectEvent(
371373
372 case "thread.unsettled":374 case "thread.unsettled":
373 return decodeForEvent(ThreadUnsettledPayload, event.payload, event.type, "payload").pipe(375 return decodeForEvent(ThreadUnsettledPayload, event.payload, event.type, "payload").pipe(
374 Effect.map((payload) => ({376 Effect.map((payload) => {
375 ...nextBase,377 const existing = nextBase.threads.find((thread) => thread.id === payload.threadId);
376 threads: updateThread(nextBase.threads, payload.threadId, {378 return {
377 settledOverride: payload.reason === "user" ? "active" : null,379 ...nextBase,
378 settledAt: null,380 threads: updateThread(nextBase.threads, payload.threadId, {
379 updatedAt: payload.updatedAt,381 settledOverride: payload.reason === "user" ? "active" : null,
380 }),382 settledAt: null,
381 })),383 // Re-entry stamp for active-list ordering. A thread already
384 // pinned active keeps its stamp: the activity reset that clears
385 // the pin is not a re-entry and must not reorder the list.
386 unsettledAt:
387 existing?.settledOverride === "active"
388 ? (existing.unsettledAt ?? null)
389 : payload.updatedAt,
390 updatedAt: payload.updatedAt,
391 }),
392 };
393 }),
382 );394 );
383395
384 case "thread.snoozed":396 case "thread.snoozed":
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+5
...@@ -94,6 +94,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {...@@ -94,6 +94,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
94 archivedAt: null,94 archivedAt: null,
95 settledOverride: null,95 settledOverride: null,
96 settledAt: null,96 settledAt: null,
97 unsettledAt: null,
97 snoozedUntil: null,98 snoozedUntil: null,
98 snoozedAt: null,99 snoozedAt: null,
99 pinnedAt: null,100 pinnedAt: null,
...@@ -157,6 +158,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {...@@ -157,6 +158,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
157 archivedAt: null,158 archivedAt: null,
158 settledOverride: "settled",159 settledOverride: "settled",
159 settledAt: "2026-03-25T00:00:00.000Z",160 settledAt: "2026-03-25T00:00:00.000Z",
161 unsettledAt: null,
160 snoozedUntil: "2026-03-26T09:00:00.000Z",162 snoozedUntil: "2026-03-26T09:00:00.000Z",
161 snoozedAt: "2026-03-25T00:00:00.000Z",163 snoozedAt: "2026-03-25T00:00:00.000Z",
162 pinnedAt: "2026-03-25T00:00:00.000Z",164 pinnedAt: "2026-03-25T00:00:00.000Z",
...@@ -186,6 +188,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {...@@ -186,6 +188,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
186 ...row,188 ...row,
187 settledOverride: "active",189 settledOverride: "active",
188 settledAt: null,190 settledAt: null,
191 unsettledAt: "2026-03-26T00:00:00.000Z",
189 snoozedUntil: null,192 snoozedUntil: null,
190 snoozedAt: null,193 snoozedAt: null,
191 pinnedAt: null,194 pinnedAt: null,
...@@ -196,6 +199,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {...@@ -196,6 +199,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
196 const updated = Option.getOrNull(repersisted);199 const updated = Option.getOrNull(repersisted);
197 assert.strictEqual(updated?.settledOverride, "active");200 assert.strictEqual(updated?.settledOverride, "active");
198 assert.strictEqual(updated?.settledAt, null);201 assert.strictEqual(updated?.settledAt, null);
202 assert.strictEqual(updated?.unsettledAt, "2026-03-26T00:00:00.000Z");
199 assert.strictEqual(updated?.snoozedUntil, null);203 assert.strictEqual(updated?.snoozedUntil, null);
200 assert.strictEqual(updated?.snoozedAt, null);204 assert.strictEqual(updated?.snoozedAt, null);
201 assert.strictEqual(updated?.pinnedAt, null);205 assert.strictEqual(updated?.pinnedAt, null);
...@@ -231,6 +235,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {...@@ -231,6 +235,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
231 archivedAt: null,235 archivedAt: null,
232 settledOverride: null,236 settledOverride: null,
233 settledAt: null,237 settledAt: null,
238 unsettledAt: null,
234 snoozedUntil: null,239 snoozedUntil: null,
235 snoozedAt: null,240 snoozedAt: null,
236 pinnedAt: null,241 pinnedAt: null,
apps/server/src/persistence/Layers/ProjectionThreads.ts+5
...@@ -47,6 +47,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -47,6 +47,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
47 archived_at,47 archived_at,
48 settled_override,48 settled_override,
49 settled_at,49 settled_at,
50 unsettled_at,
50 snoozed_until,51 snoozed_until,
51 snoozed_at,52 snoozed_at,
52 pinned_at,53 pinned_at,
...@@ -75,6 +76,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -75,6 +76,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
75 ${row.archivedAt},76 ${row.archivedAt},
76 ${row.settledOverride},77 ${row.settledOverride},
77 ${row.settledAt},78 ${row.settledAt},
79 ${row.unsettledAt},
78 ${row.snoozedUntil},80 ${row.snoozedUntil},
79 ${row.snoozedAt},81 ${row.snoozedAt},
80 ${row.pinnedAt},82 ${row.pinnedAt},
...@@ -103,6 +105,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -103,6 +105,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
103 archived_at = excluded.archived_at,105 archived_at = excluded.archived_at,
104 settled_override = excluded.settled_override,106 settled_override = excluded.settled_override,
105 settled_at = excluded.settled_at,107 settled_at = excluded.settled_at,
108 unsettled_at = excluded.unsettled_at,
106 snoozed_until = excluded.snoozed_until,109 snoozed_until = excluded.snoozed_until,
107 snoozed_at = excluded.snoozed_at,110 snoozed_at = excluded.snoozed_at,
108 pinned_at = excluded.pinned_at,111 pinned_at = excluded.pinned_at,
...@@ -138,6 +141,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -138,6 +141,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
138 archived_at AS "archivedAt",141 archived_at AS "archivedAt",
139 settled_override AS "settledOverride",142 settled_override AS "settledOverride",
140 settled_at AS "settledAt",143 settled_at AS "settledAt",
144 unsettled_at AS "unsettledAt",
141 snoozed_until AS "snoozedUntil",145 snoozed_until AS "snoozedUntil",
142 snoozed_at AS "snoozedAt",146 snoozed_at AS "snoozedAt",
143 pinned_at AS "pinnedAt",147 pinned_at AS "pinnedAt",
...@@ -175,6 +179,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -175,6 +179,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
175 archived_at AS "archivedAt",179 archived_at AS "archivedAt",
176 settled_override AS "settledOverride",180 settled_override AS "settledOverride",
177 settled_at AS "settledAt",181 settled_at AS "settledAt",
182 unsettled_at AS "unsettledAt",
178 snoozed_until AS "snoozedUntil",183 snoozed_until AS "snoozedUntil",
179 snoozed_at AS "snoozedAt",184 snoozed_at AS "snoozedAt",
180 pinned_at AS "pinnedAt",185 pinned_at AS "pinnedAt",
apps/server/src/persistence/Migrations.ts+2
...@@ -55,6 +55,7 @@ import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMo...@@ -55,6 +55,7 @@ import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMo
55import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts";55import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts";
56import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts";56import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts";
57import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts";57import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts";
58import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts";
5859
59/**60/**
60 * Migration loader with all migrations defined inline.61 * Migration loader with all migrations defined inline.
...@@ -109,6 +110,7 @@ export const migrationEntries = [...@@ -109,6 +110,7 @@ export const migrationEntries = [
109 [40, "ProjectionProjectFaviconPath", Migration0040],110 [40, "ProjectionProjectFaviconPath", Migration0040],
110 [41, "AuthSessionClientConnection", Migration0041],111 [41, "AuthSessionClientConnection", Migration0041],
111 [42, "ProjectionThreadLinkedPullRequest", Migration0042],112 [42, "ProjectionThreadLinkedPullRequest", Migration0042],
113 [43, "ProjectionThreadsUnsettledAt", Migration0043],
112] as const;114] as const;
113115
114export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);116export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
apps/server/src/persistence/Migrations/043_ProjectionThreadsUnsettledAt.ts created+16
...@@ -0,0 +1,16 @@
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
10 if (!columns.some((column) => column.name === "unsettled_at")) {
11 yield* sql`
12 ALTER TABLE projection_threads
13 ADD COLUMN unsettled_at TEXT
14 `;
15 }
16});
apps/server/src/persistence/Services/ProjectionThreads.ts+1
...@@ -41,6 +41,7 @@ export const ProjectionThread = Schema.Struct({...@@ -41,6 +41,7 @@ export const ProjectionThread = Schema.Struct({
41 archivedAt: Schema.NullOr(IsoDateTime),41 archivedAt: Schema.NullOr(IsoDateTime),
42 settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])),42 settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])),
43 settledAt: Schema.NullOr(IsoDateTime),43 settledAt: Schema.NullOr(IsoDateTime),
44 unsettledAt: Schema.NullOr(IsoDateTime),
44 snoozedUntil: Schema.NullOr(IsoDateTime),45 snoozedUntil: Schema.NullOr(IsoDateTime),
45 snoozedAt: Schema.NullOr(IsoDateTime),46 snoozedAt: Schema.NullOr(IsoDateTime),
46 pinnedAt: Schema.NullOr(IsoDateTime),47 pinnedAt: Schema.NullOr(IsoDateTime),
apps/web/src/components/Sidebar.logic.test.ts+27
...@@ -807,6 +807,33 @@ describe("sortThreadsForSidebar", () => {...@@ -807,6 +807,33 @@ describe("sortThreadsForSidebar", () => {
807807
808 expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]);808 expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]);
809 });809 });
810
811 it("surfaces an un-settled thread at the top via its re-entry stamp", () => {
812 const sorted = sortThreadsForSidebar([
813 {
814 id: "old-unsettled",
815 createdAt: "2026-03-09T08:00:00.000Z",
816 unsettledAt: "2026-03-09T13:00:00.000Z",
817 },
818 sortable({ id: "newest", createdAt: "2026-03-09T12:00:00.000Z" }),
819 sortable({ id: "middle", createdAt: "2026-03-09T10:00:00.000Z" }),
820 ]);
821
822 expect(sorted.map((thread) => thread.id)).toEqual(["old-unsettled", "newest", "middle"]);
823 });
824
825 it("ignores a re-entry stamp older than the thread's creation", () => {
826 const sorted = sortThreadsForSidebar([
827 {
828 id: "stale-stamp",
829 createdAt: "2026-03-09T10:00:00.000Z",
830 unsettledAt: "2026-03-09T09:00:00.000Z",
831 },
832 sortable({ id: "newest", createdAt: "2026-03-09T12:00:00.000Z" }),
833 ]);
834
835 expect(sorted.map((thread) => thread.id)).toEqual(["newest", "stale-stamp"]);
836 });
810});837});
811838
812describe("pinOrderKeyBetween", () => {839describe("pinOrderKeyBetween", () => {
apps/web/src/components/Sidebar.logic.ts+14-6
...@@ -3,6 +3,7 @@ import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit...@@ -3,6 +3,7 @@ import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit
3import type { ContextMenuItem } from "@t3tools/contracts";3import type { ContextMenuItem } from "@t3tools/contracts";
4import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";4import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
5import {5import {
6 activeThreadAnchorTimestampMs,
6 getThreadSortTimestamp,7 getThreadSortTimestamp,
7 sortThreads,8 sortThreads,
8 toSortableTimestamp,9 toSortableTimestamp,
...@@ -538,16 +539,23 @@ export function firstValidTimestamp(...@@ -538,16 +539,23 @@ export function firstValidTimestamp(
538 return null;539 return null;
539}540}
540541
541// Sidebar sort: static creation order, newest thread on top. Activity NEVER542// Sidebar sort: static order, newest anchor on top. Activity NEVER reorders
542// reorders the list — a row holds its position from open until settled, so543// the list — a row holds its position between lifecycle transitions, so the
543// the screen only moves at lifecycle transitions. Status (including pending544// screen only moves when a thread enters or leaves the active list. The
544// approval) is carried by each card's edge strip, not by position.545// anchor is creation time until an un-settle re-anchors it (see
546// activeThreadAnchorTimestampMs), so an un-settled thread surfaces at the
547// top instead of sinking back to its creation-order slot. Status (including
548// pending approval) is carried by each card's edge strip, not by position.
545export function sortThreadsForSidebar<549export function sortThreadsForSidebar<
546 T extends { readonly id: string; readonly createdAt: string },550 T extends {
551 readonly id: string;
552 readonly createdAt: string;
553 readonly unsettledAt?: string | null | undefined;
554 },
547>(threads: readonly T[]): T[] {555>(threads: readonly T[]): T[] {
548 return [...threads].toSorted(556 return [...threads].toSorted(
549 (left, right) =>557 (left, right) =>
550 parseTimestampMs(right.createdAt) - parseTimestampMs(left.createdAt) ||558 activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left) ||
551 left.id.localeCompare(right.id),559 left.id.localeCompare(right.id),
552 );560 );
553}561}
apps/web/src/lib/threadSort.ts+1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1export {1export {
2 activeThreadAnchorTimestampMs,
2 getLatestThreadForProject,3 getLatestThreadForProject,
3 getThreadSortTimestamp,4 getThreadSortTimestamp,
4 sortThreads,5 sortThreads,
docs/user/thread-sidebar.md+3
...@@ -7,6 +7,9 @@ one environment....@@ -7,6 +7,9 @@ one environment.
7Pinned threads still move to **Settled** when they become inactive. They also move when their pull7Pinned threads still move to **Settled** when they become inactive. They also move when their pull
8request merges if **Auto-settle merged threads** is enabled.8request merges if **Auto-settle merged threads** is enabled.
99
10When you un-settle a thread, it returns to the top of the active list so you can find it right
11away. Its timestamps do not change. Other threads keep their positions.
12
10Right-click a pull request link in a thread and choose **Link to thread** to show that pull request13Right-click a pull request link in a thread and choose **Link to thread** to show that pull request
11in the sidebar. The thread settles when the linked pull request merges if **Auto-settle merged14in the sidebar. The thread settles when the linked pull request merges if **Auto-settle merged
12threads** is enabled. Right-click the same link and choose **Unlink from thread** to remove it.15threads** is enabled. Right-click the same link and choose **Unlink from thread** to remove it.
packages/client-runtime/src/state/threadReducer.ts+8
...@@ -92,6 +92,7 @@ export function applyThreadDetailEvent(...@@ -92,6 +92,7 @@ export function applyThreadDetailEvent(
92 archivedAt: null,92 archivedAt: null,
93 settledOverride: null,93 settledOverride: null,
94 settledAt: null,94 settledAt: null,
95 unsettledAt: null,
95 snoozedUntil: null,96 snoozedUntil: null,
96 snoozedAt: null,97 snoozedAt: null,
97 deletedAt: null,98 deletedAt: null,
...@@ -130,6 +131,7 @@ export function applyThreadDetailEvent(...@@ -130,6 +131,7 @@ export function applyThreadDetailEvent(
130 ...thread,131 ...thread,
131 settledOverride: "settled",132 settledOverride: "settled",
132 settledAt: event.payload.settledAt,133 settledAt: event.payload.settledAt,
134 unsettledAt: null,
133 updatedAt: event.payload.updatedAt,135 updatedAt: event.payload.updatedAt,
134 },136 },
135 };137 };
...@@ -141,6 +143,12 @@ export function applyThreadDetailEvent(...@@ -141,6 +143,12 @@ export function applyThreadDetailEvent(
141 ...thread,143 ...thread,
142 settledOverride: event.payload.reason === "user" ? "active" : null,144 settledOverride: event.payload.reason === "user" ? "active" : null,
143 settledAt: null,145 settledAt: null,
146 // A thread already pinned active keeps its re-entry stamp: the
147 // activity reset that clears the pin must not reorder the list.
148 unsettledAt:
149 thread.settledOverride === "active"
150 ? (thread.unsettledAt ?? null)
151 : event.payload.updatedAt,
144 updatedAt: event.payload.updatedAt,152 updatedAt: event.payload.updatedAt,
145 },153 },
146 };154 };
packages/client-runtime/src/state/threadSort.ts+18
...@@ -69,6 +69,24 @@ export function getThreadSortTimestamp(...@@ -69,6 +69,24 @@ export function getThreadSortTimestamp(
69 return getLatestUserMessageTimestamp(thread);69 return getLatestUserMessageTimestamp(thread);
70}70}
7171
72/**
73 * Sort anchor for the active thread list: creation time, re-anchored to
74 * unsettledAt when the thread last re-entered the active list (an explicit
75 * un-settle, or a settled thread waking on activity). The list stays static
76 * between lifecycle transitions, but an un-settled thread surfaces at the
77 * top instead of sinking back to its creation-order slot. Shared by web and
78 * mobile so both render the same order. Malformed timestamps sink to 0.
79 */
80export function activeThreadAnchorTimestampMs(thread: {
81 readonly createdAt: string;
82 readonly unsettledAt?: string | null | undefined;
83}): number {
84 return Math.max(
85 toSortableTimestamp(thread.createdAt) ?? 0,
86 toSortableTimestamp(thread.unsettledAt ?? undefined) ?? 0,
87 );
88}
89
72export function sortThreads<T extends { readonly id: string } & ThreadSortInput>(90export function sortThreads<T extends { readonly id: string } & ThreadSortInput>(
73 threads: readonly T[],91 threads: readonly T[],
74 sortOrder: SidebarThreadSortOrder,92 sortOrder: SidebarThreadSortOrder,
packages/contracts/src/orchestration.ts+7
...@@ -415,6 +415,11 @@ export const OrchestrationThread = Schema.Struct({...@@ -415,6 +415,11 @@ export const OrchestrationThread = Schema.Struct({
415 Schema.withDecodingDefault(Effect.succeed(null)),415 Schema.withDecodingDefault(Effect.succeed(null)),
416 ),416 ),
417 settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))),417 settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))),
418 // When the thread last re-entered the active list (any thread.unsettled).
419 // Anchors the active-list sort so an unsettled thread surfaces at the top
420 // instead of sinking back to its creation-order slot. Cleared on settle.
421 // Optional so payloads from pre-stamp servers still decode.
422 unsettledAt: Schema.optional(Schema.NullOr(IsoDateTime)),
418 // Snooze is an overlay on the active lifecycle, not a fourth destination:423 // Snooze is an overlay on the active lifecycle, not a fourth destination:
419 // a snoozed thread stays "active" in the model and is only suppressed from424 // a snoozed thread stays "active" in the model and is only suppressed from
420 // the inbox until snoozedUntil passes (or the thread raises its hand).425 // the inbox until snoozedUntil passes (or the thread raises its hand).
...@@ -486,6 +491,8 @@ export const OrchestrationThreadShell = Schema.Struct({...@@ -486,6 +491,8 @@ export const OrchestrationThreadShell = Schema.Struct({
486 Schema.withDecodingDefault(Effect.succeed(null)),491 Schema.withDecodingDefault(Effect.succeed(null)),
487 ),492 ),
488 settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))),493 settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))),
494 // See OrchestrationThread.unsettledAt: last re-entry into the active list.
495 unsettledAt: Schema.optional(Schema.NullOr(IsoDateTime)),
489 snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)),496 snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)),
490 snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)),497 snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
491 pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)),498 pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)),