| author | |
| committer | |
| log | 3b86ef941c218a000a2ea9920480f469b726f437 |
| tree | 24a2ddffd600b216440518b02018325bde285f8b |
| parent | b0a0281269156295e2202d31198829bd3b500bdf |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 303 | ]); |
| 304 | 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 | }); |
| 307 | 320 | |
| 308 | 321 | describe("buildThreadListV2Items", () => { |
apps/mobile/src/features/threads/threadListV2.ts+18-9| ... | ... | @@ -12,7 +12,10 @@ import type { |
| 12 | 12 | } from "@t3tools/client-runtime/state/thread-settled"; |
| 13 | 13 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 14 | 14 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; |
| 15 | import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; | |
| 15 | import { | |
| 16 | activeThreadAnchorTimestampMs, | |
| 17 | sortPinnedThreadsByOrderKey, | |
| 18 | } from "@t3tools/client-runtime/state/thread-sort"; | |
| 16 | 19 | import type { EnvironmentId, ProjectId, ThreadLinkedPullRequest } from "@t3tools/contracts"; |
| 17 | 20 | |
| 18 | 21 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| ... | ... | @@ -191,19 +194,25 @@ function firstValidTimestampMs(...candidates: ReadonlyArray<string | null | unde |
| 191 | 194 | } |
| 192 | 195 | |
| 193 | 196 | /** |
| 194 | * v2 sort: static creation order, newest thread on top. Activity NEVER | |
| 195 | * reorders the list — a row holds its position from open until settled, so | |
| 196 | * the screen only moves at lifecycle transitions. Mirrors web's | |
| 197 | * sortThreadsForSidebarV2. | |
| 197 | * v2 sort: static order, newest anchor on top. Activity NEVER reorders the | |
| 198 | * list — a row holds its position between lifecycle transitions. The anchor | |
| 199 | * is creation time until an un-settle re-anchors it (see | |
| 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 | */ |
| 199 | export function sortThreadsForListV2<T extends { readonly id: string; readonly createdAt: string }>( | |
| 200 | threads: readonly T[], | |
| 201 | ): T[] { | |
| 204 | export function sortThreadsForListV2< | |
| 205 | T extends { | |
| 206 | readonly id: string; | |
| 207 | readonly createdAt: string; | |
| 208 | readonly unsettledAt?: string | null | undefined; | |
| 209 | }, | |
| 210 | >(threads: readonly T[]): T[] { | |
| 202 | 211 | // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023 |
| 203 | 212 | // change-by-copy array methods. |
| 204 | 213 | return [...threads].sort( |
| 205 | 214 | (left, right) => |
| 206 | parseTimestampMs(right.createdAt) - parseTimestampMs(left.createdAt) || | |
| 215 | activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left) || | |
| 207 | 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 | 269 | const settledRows = yield* sql<{ |
| 270 | 270 | readonly settledOverride: string | null; |
| 271 | 271 | readonly settledAt: string | null; |
| 272 | readonly unsettledAt: string | null; | |
| 272 | 273 | }>` |
| 273 | 274 | SELECT |
| 274 | 275 | settled_override AS "settledOverride", |
| 275 | settled_at AS "settledAt" | |
| 276 | settled_at AS "settledAt", | |
| 277 | unsettled_at AS "unsettledAt" | |
| 276 | 278 | FROM projection_threads |
| 277 | 279 | WHERE thread_id = 'thread-1' |
| 278 | 280 | `; |
| 279 | 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 | ]); |
| 282 | 284 | |
| 283 | 285 | yield* eventStore.append({ |
| ... | ... | @@ -301,14 +303,24 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 301 | 303 | const unsettledRows = yield* sql<{ |
| 302 | 304 | readonly settledOverride: string | null; |
| 303 | 305 | readonly settledAt: string | null; |
| 306 | readonly unsettledAt: string | null; | |
| 304 | 307 | }>` |
| 305 | 308 | SELECT |
| 306 | 309 | settled_override AS "settledOverride", |
| 307 | settled_at AS "settledAt" | |
| 310 | settled_at AS "settledAt", | |
| 311 | unsettled_at AS "unsettledAt" | |
| 308 | 312 | FROM projection_threads |
| 309 | 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 | 641 | archivedAt: null, |
| 642 | 642 | settledOverride: null, |
| 643 | 643 | settledAt: null, |
| 644 | unsettledAt: null, | |
| 644 | 645 | snoozedUntil: null, |
| 645 | 646 | snoozedAt: null, |
| 646 | 647 | pinnedAt: null, |
| ... | ... | @@ -698,6 +699,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 698 | 699 | ...existingRow.value, |
| 699 | 700 | settledOverride: "settled", |
| 700 | 701 | settledAt: event.payload.settledAt, |
| 702 | unsettledAt: null, | |
| 701 | 703 | updatedAt: event.payload.updatedAt, |
| 702 | 704 | }); |
| 703 | 705 | return; |
| ... | ... | @@ -714,6 +716,13 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 714 | 716 | ...existingRow.value, |
| 715 | 717 | settledOverride: event.payload.reason === "user" ? "active" : null, |
| 716 | 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 | 726 | updatedAt: event.payload.updatedAt, |
| 718 | 727 | }); |
| 719 | 728 | return; |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+2| ... | ... | @@ -329,6 +329,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 329 | 329 | archivedAt: null, |
| 330 | 330 | settledOverride: null, |
| 331 | 331 | settledAt: null, |
| 332 | unsettledAt: null, | |
| 332 | 333 | snoozedUntil: null, |
| 333 | 334 | snoozedAt: null, |
| 334 | 335 | pinnedAt: "2026-02-24T00:00:01.000Z", |
| ... | ... | @@ -454,6 +455,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 454 | 455 | archivedAt: null, |
| 455 | 456 | settledOverride: null, |
| 456 | 457 | settledAt: null, |
| 458 | unsettledAt: null, | |
| 457 | 459 | snoozedUntil: null, |
| 458 | 460 | snoozedAt: null, |
| 459 | 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 | 431 | archived_at AS "archivedAt", |
| 432 | 432 | settled_override AS "settledOverride", |
| 433 | 433 | settled_at AS "settledAt", |
| 434 | unsettled_at AS "unsettledAt", | |
| 434 | 435 | snoozed_until AS "snoozedUntil", |
| 435 | 436 | snoozed_at AS "snoozedAt", |
| 436 | 437 | pinned_at AS "pinnedAt", |
| ... | ... | @@ -468,6 +469,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 468 | 469 | archived_at AS "archivedAt", |
| 469 | 470 | settled_override AS "settledOverride", |
| 470 | 471 | settled_at AS "settledAt", |
| 472 | unsettled_at AS "unsettledAt", | |
| 471 | 473 | snoozed_until AS "snoozedUntil", |
| 472 | 474 | snoozed_at AS "snoozedAt", |
| 473 | 475 | pinned_at AS "pinnedAt", |
| ... | ... | @@ -507,6 +509,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 507 | 509 | archived_at AS "archivedAt", |
| 508 | 510 | settled_override AS "settledOverride", |
| 509 | 511 | settled_at AS "settledAt", |
| 512 | unsettled_at AS "unsettledAt", | |
| 510 | 513 | snoozed_until AS "snoozedUntil", |
| 511 | 514 | snoozed_at AS "snoozedAt", |
| 512 | 515 | pinned_at AS "pinnedAt", |
| ... | ... | @@ -950,6 +953,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 950 | 953 | archived_at AS "archivedAt", |
| 951 | 954 | settled_override AS "settledOverride", |
| 952 | 955 | settled_at AS "settledAt", |
| 956 | unsettled_at AS "unsettledAt", | |
| 953 | 957 | snoozed_until AS "snoozedUntil", |
| 954 | 958 | snoozed_at AS "snoozedAt", |
| 955 | 959 | pinned_at AS "pinnedAt", |
| ... | ... | @@ -1709,6 +1713,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1709 | 1713 | archivedAt: row.archivedAt, |
| 1710 | 1714 | settledOverride: row.settledOverride, |
| 1711 | 1715 | settledAt: row.settledAt, |
| 1716 | unsettledAt: row.unsettledAt, | |
| 1712 | 1717 | snoozedUntil: row.snoozedUntil, |
| 1713 | 1718 | snoozedAt: row.snoozedAt, |
| 1714 | 1719 | pinnedAt: row.pinnedAt, |
| ... | ... | @@ -1919,6 +1924,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1919 | 1924 | archivedAt: row.archivedAt, |
| 1920 | 1925 | settledOverride: row.settledOverride, |
| 1921 | 1926 | settledAt: row.settledAt, |
| 1927 | unsettledAt: row.unsettledAt, | |
| 1922 | 1928 | snoozedUntil: row.snoozedUntil, |
| 1923 | 1929 | snoozedAt: row.snoozedAt, |
| 1924 | 1930 | pinnedAt: row.pinnedAt, |
| ... | ... | @@ -2058,6 +2064,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 2058 | 2064 | archivedAt: row.archivedAt, |
| 2059 | 2065 | settledOverride: row.settledOverride, |
| 2060 | 2066 | settledAt: row.settledAt, |
| 2067 | unsettledAt: row.unsettledAt, | |
| 2061 | 2068 | snoozedUntil: row.snoozedUntil, |
| 2062 | 2069 | snoozedAt: row.snoozedAt, |
| 2063 | 2070 | pinnedAt: row.pinnedAt, |
| ... | ... | @@ -2206,6 +2213,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 2206 | 2213 | archivedAt: row.archivedAt, |
| 2207 | 2214 | settledOverride: row.settledOverride, |
| 2208 | 2215 | settledAt: row.settledAt, |
| 2216 | unsettledAt: row.unsettledAt, | |
| 2209 | 2217 | snoozedUntil: row.snoozedUntil, |
| 2210 | 2218 | snoozedAt: row.snoozedAt, |
| 2211 | 2219 | pinnedAt: row.pinnedAt, |
| ... | ... | @@ -2488,6 +2496,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 2488 | 2496 | archivedAt: threadRow.value.archivedAt, |
| 2489 | 2497 | settledOverride: threadRow.value.settledOverride, |
| 2490 | 2498 | settledAt: threadRow.value.settledAt, |
| 2499 | unsettledAt: threadRow.value.unsettledAt, | |
| 2491 | 2500 | snoozedUntil: threadRow.value.snoozedUntil, |
| 2492 | 2501 | snoozedAt: threadRow.value.snoozedAt, |
| 2493 | 2502 | pinnedAt: threadRow.value.pinnedAt, |
| ... | ... | @@ -2632,6 +2641,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 2632 | 2641 | archivedAt: threadRow.value.archivedAt, |
| 2633 | 2642 | settledOverride: threadRow.value.settledOverride, |
| 2634 | 2643 | settledAt: threadRow.value.settledAt, |
| 2644 | unsettledAt: threadRow.value.unsettledAt, | |
| 2635 | 2645 | snoozedUntil: threadRow.value.snoozedUntil, |
| 2636 | 2646 | snoozedAt: threadRow.value.snoozedAt, |
| 2637 | 2647 | pinnedAt: threadRow.value.pinnedAt, |
apps/server/src/orchestration/decider.settled.test.ts+38| ... | ... | @@ -5,6 +5,7 @@ import { |
| 5 | 5 | ProjectId, |
| 6 | 6 | ProviderInstanceId, |
| 7 | 7 | ThreadId, |
| 8 | type OrchestrationEvent, | |
| 8 | 9 | type OrchestrationReadModel, |
| 9 | 10 | type OrchestrationSession, |
| 10 | 11 | type OrchestrationThread, |
| ... | ... | @@ -14,6 +15,7 @@ import { expect, it } from "@effect/vitest"; |
| 14 | 15 | import * as Effect from "effect/Effect"; |
| 15 | 16 | |
| 16 | 17 | import { decideOrchestrationCommand } from "./decider.ts"; |
| 18 | import { projectEvent } from "./projector.ts"; | |
| 17 | 19 | |
| 18 | 20 | const NOW = "2026-01-01T00:00:00.000Z"; |
| 19 | 21 | const SETTLED_AT = "2025-12-30T00:00:00.000Z"; |
| ... | ... | @@ -428,6 +430,42 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { |
| 428 | 430 | }), |
| 429 | 431 | ); |
| 430 | 432 | |
| 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 | 469 | it.effect("prepends activity unsets for turn starts and live session updates", () => |
| 432 | 470 | Effect.gen(function* () { |
| 433 | 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 | 62 | ); |
| 63 | 63 | expect(settled.threads[0]?.settledOverride).toBe("settled"); |
| 64 | 64 | expect(settled.threads[0]?.settledAt).toBe(now); |
| 65 | expect(settled.threads[0]?.unsettledAt).toBeNull(); | |
| 65 | 66 | |
| 67 | const unsettleAt = "2026-01-02T00:00:00.000Z"; | |
| 66 | 68 | const userUnsettled = yield* projectEvent( |
| 67 | 69 | settled, |
| 68 | 70 | makeEvent({ |
| 69 | 71 | sequence: 3, |
| 70 | 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 | 76 | expect(userUnsettled.threads[0]?.settledOverride).toBe("active"); |
| 75 | 77 | expect(userUnsettled.threads[0]?.settledAt).toBeNull(); |
| 78 | expect(userUnsettled.threads[0]?.unsettledAt).toBe(unsettleAt); | |
| 76 | 79 | |
| 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 | 83 | const activityUnsettled = yield* projectEvent( |
| 78 | 84 | userUnsettled, |
| 79 | 85 | makeEvent({ |
| 80 | 86 | sequence: 4, |
| 81 | 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 | 91 | expect(activityUnsettled.threads[0]?.settledOverride).toBeNull(); |
| 86 | 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 | 91 | archivedAt: null, |
| 92 | 92 | settledOverride: null, |
| 93 | 93 | settledAt: null, |
| 94 | unsettledAt: null, | |
| 94 | 95 | snoozedUntil: null, |
| 95 | 96 | snoozedAt: null, |
| 96 | 97 | deletedAt: null, |
apps/server/src/orchestration/projector.ts+20-8| ... | ... | @@ -303,6 +303,7 @@ export function projectEvent( |
| 303 | 303 | archivedAt: null, |
| 304 | 304 | settledOverride: null, |
| 305 | 305 | settledAt: null, |
| 306 | unsettledAt: null, | |
| 306 | 307 | snoozedUntil: null, |
| 307 | 308 | snoozedAt: null, |
| 308 | 309 | deletedAt: null, |
| ... | ... | @@ -364,6 +365,7 @@ export function projectEvent( |
| 364 | 365 | threads: updateThread(nextBase.threads, payload.threadId, { |
| 365 | 366 | settledOverride: "settled", |
| 366 | 367 | settledAt: payload.settledAt, |
| 368 | unsettledAt: null, | |
| 367 | 369 | updatedAt: payload.updatedAt, |
| 368 | 370 | }), |
| 369 | 371 | })), |
| ... | ... | @@ -371,14 +373,24 @@ export function projectEvent( |
| 371 | 373 | |
| 372 | 374 | case "thread.unsettled": |
| 373 | 375 | return decodeForEvent(ThreadUnsettledPayload, event.payload, event.type, "payload").pipe( |
| 374 | Effect.map((payload) => ({ | |
| 375 | ...nextBase, | |
| 376 | threads: updateThread(nextBase.threads, payload.threadId, { | |
| 377 | settledOverride: payload.reason === "user" ? "active" : null, | |
| 378 | settledAt: null, | |
| 379 | updatedAt: payload.updatedAt, | |
| 380 | }), | |
| 381 | })), | |
| 376 | Effect.map((payload) => { | |
| 377 | const existing = nextBase.threads.find((thread) => thread.id === payload.threadId); | |
| 378 | return { | |
| 379 | ...nextBase, | |
| 380 | threads: updateThread(nextBase.threads, payload.threadId, { | |
| 381 | settledOverride: payload.reason === "user" ? "active" : null, | |
| 382 | settledAt: null, | |
| 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 | ); |
| 383 | 395 | |
| 384 | 396 | case "thread.snoozed": |
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+5| ... | ... | @@ -94,6 +94,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 94 | 94 | archivedAt: null, |
| 95 | 95 | settledOverride: null, |
| 96 | 96 | settledAt: null, |
| 97 | unsettledAt: null, | |
| 97 | 98 | snoozedUntil: null, |
| 98 | 99 | snoozedAt: null, |
| 99 | 100 | pinnedAt: null, |
| ... | ... | @@ -157,6 +158,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 157 | 158 | archivedAt: null, |
| 158 | 159 | settledOverride: "settled", |
| 159 | 160 | settledAt: "2026-03-25T00:00:00.000Z", |
| 161 | unsettledAt: null, | |
| 160 | 162 | snoozedUntil: "2026-03-26T09:00:00.000Z", |
| 161 | 163 | snoozedAt: "2026-03-25T00:00:00.000Z", |
| 162 | 164 | pinnedAt: "2026-03-25T00:00:00.000Z", |
| ... | ... | @@ -186,6 +188,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 186 | 188 | ...row, |
| 187 | 189 | settledOverride: "active", |
| 188 | 190 | settledAt: null, |
| 191 | unsettledAt: "2026-03-26T00:00:00.000Z", | |
| 189 | 192 | snoozedUntil: null, |
| 190 | 193 | snoozedAt: null, |
| 191 | 194 | pinnedAt: null, |
| ... | ... | @@ -196,6 +199,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 196 | 199 | const updated = Option.getOrNull(repersisted); |
| 197 | 200 | assert.strictEqual(updated?.settledOverride, "active"); |
| 198 | 201 | assert.strictEqual(updated?.settledAt, null); |
| 202 | assert.strictEqual(updated?.unsettledAt, "2026-03-26T00:00:00.000Z"); | |
| 199 | 203 | assert.strictEqual(updated?.snoozedUntil, null); |
| 200 | 204 | assert.strictEqual(updated?.snoozedAt, null); |
| 201 | 205 | assert.strictEqual(updated?.pinnedAt, null); |
| ... | ... | @@ -231,6 +235,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 231 | 235 | archivedAt: null, |
| 232 | 236 | settledOverride: null, |
| 233 | 237 | settledAt: null, |
| 238 | unsettledAt: null, | |
| 234 | 239 | snoozedUntil: null, |
| 235 | 240 | snoozedAt: null, |
| 236 | 241 | pinnedAt: null, |
apps/server/src/persistence/Layers/ProjectionThreads.ts+5| ... | ... | @@ -47,6 +47,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 47 | 47 | archived_at, |
| 48 | 48 | settled_override, |
| 49 | 49 | settled_at, |
| 50 | unsettled_at, | |
| 50 | 51 | snoozed_until, |
| 51 | 52 | snoozed_at, |
| 52 | 53 | pinned_at, |
| ... | ... | @@ -75,6 +76,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 75 | 76 | ${row.archivedAt}, |
| 76 | 77 | ${row.settledOverride}, |
| 77 | 78 | ${row.settledAt}, |
| 79 | ${row.unsettledAt}, | |
| 78 | 80 | ${row.snoozedUntil}, |
| 79 | 81 | ${row.snoozedAt}, |
| 80 | 82 | ${row.pinnedAt}, |
| ... | ... | @@ -103,6 +105,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 103 | 105 | archived_at = excluded.archived_at, |
| 104 | 106 | settled_override = excluded.settled_override, |
| 105 | 107 | settled_at = excluded.settled_at, |
| 108 | unsettled_at = excluded.unsettled_at, | |
| 106 | 109 | snoozed_until = excluded.snoozed_until, |
| 107 | 110 | snoozed_at = excluded.snoozed_at, |
| 108 | 111 | pinned_at = excluded.pinned_at, |
| ... | ... | @@ -138,6 +141,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 138 | 141 | archived_at AS "archivedAt", |
| 139 | 142 | settled_override AS "settledOverride", |
| 140 | 143 | settled_at AS "settledAt", |
| 144 | unsettled_at AS "unsettledAt", | |
| 141 | 145 | snoozed_until AS "snoozedUntil", |
| 142 | 146 | snoozed_at AS "snoozedAt", |
| 143 | 147 | pinned_at AS "pinnedAt", |
| ... | ... | @@ -175,6 +179,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 175 | 179 | archived_at AS "archivedAt", |
| 176 | 180 | settled_override AS "settledOverride", |
| 177 | 181 | settled_at AS "settledAt", |
| 182 | unsettled_at AS "unsettledAt", | |
| 178 | 183 | snoozed_until AS "snoozedUntil", |
| 179 | 184 | snoozed_at AS "snoozedAt", |
| 180 | 185 | pinned_at AS "pinnedAt", |
apps/server/src/persistence/Migrations.ts+2| ... | ... | @@ -55,6 +55,7 @@ import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMo |
| 55 | 55 | import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; |
| 56 | 56 | import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts"; |
| 57 | 57 | import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts"; |
| 58 | import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts"; | |
| 58 | 59 | |
| 59 | 60 | /** |
| 60 | 61 | * Migration loader with all migrations defined inline. |
| ... | ... | @@ -109,6 +110,7 @@ export const migrationEntries = [ |
| 109 | 110 | [40, "ProjectionProjectFaviconPath", Migration0040], |
| 110 | 111 | [41, "AuthSessionClientConnection", Migration0041], |
| 111 | 112 | [42, "ProjectionThreadLinkedPullRequest", Migration0042], |
| 113 | [43, "ProjectionThreadsUnsettledAt", Migration0043], | |
| 112 | 114 | ] as const; |
| 113 | 115 | |
| 114 | 116 | export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); |
apps/server/src/persistence/Migrations/043_ProjectionThreadsUnsettledAt.ts created+16| ... | ... | @@ -0,0 +1,16 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | ||
| 4 | export default Effect.gen(function* () { | |
| 5 | const sql = yield* SqlClient.SqlClient; | |
| 6 | const columns = yield* sql<{ readonly name: string }>` | |
| 7 | PRAGMA table_info(projection_threads) | |
| 8 | `; | |
| 9 | ||
| 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 | 41 | archivedAt: Schema.NullOr(IsoDateTime), |
| 42 | 42 | settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])), |
| 43 | 43 | settledAt: Schema.NullOr(IsoDateTime), |
| 44 | unsettledAt: Schema.NullOr(IsoDateTime), | |
| 44 | 45 | snoozedUntil: Schema.NullOr(IsoDateTime), |
| 45 | 46 | snoozedAt: Schema.NullOr(IsoDateTime), |
| 46 | 47 | pinnedAt: Schema.NullOr(IsoDateTime), |
apps/web/src/components/Sidebar.logic.test.ts+27| ... | ... | @@ -807,6 +807,33 @@ describe("sortThreadsForSidebar", () => { |
| 807 | 807 | |
| 808 | 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 | }); |
| 811 | 838 | |
| 812 | 839 | describe("pinOrderKeyBetween", () => { |
apps/web/src/components/Sidebar.logic.ts+14-6| ... | ... | @@ -3,6 +3,7 @@ import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit |
| 3 | 3 | import type { ContextMenuItem } from "@t3tools/contracts"; |
| 4 | 4 | import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; |
| 5 | 5 | import { |
| 6 | activeThreadAnchorTimestampMs, | |
| 6 | 7 | getThreadSortTimestamp, |
| 7 | 8 | sortThreads, |
| 8 | 9 | toSortableTimestamp, |
| ... | ... | @@ -538,16 +539,23 @@ export function firstValidTimestamp( |
| 538 | 539 | return null; |
| 539 | 540 | } |
| 540 | 541 | |
| 541 | // Sidebar sort: static creation order, newest thread on top. Activity NEVER | |
| 542 | // reorders the list — a row holds its position from open until settled, so | |
| 543 | // the screen only moves at lifecycle transitions. Status (including pending | |
| 544 | // approval) is carried by each card's edge strip, not by position. | |
| 542 | // Sidebar sort: static order, newest anchor on top. Activity NEVER reorders | |
| 543 | // the list — a row holds its position between lifecycle transitions, so the | |
| 544 | // screen only moves when a thread enters or leaves the active list. The | |
| 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. | |
| 545 | 549 | export 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 | 555 | >(threads: readonly T[]): T[] { |
| 548 | 556 | return [...threads].toSorted( |
| 549 | 557 | (left, right) => |
| 550 | parseTimestampMs(right.createdAt) - parseTimestampMs(left.createdAt) || | |
| 558 | activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left) || | |
| 551 | 559 | left.id.localeCompare(right.id), |
| 552 | 560 | ); |
| 553 | 561 | } |
apps/web/src/lib/threadSort.ts+1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | export { |
| 2 | activeThreadAnchorTimestampMs, | |
| 2 | 3 | getLatestThreadForProject, |
| 3 | 4 | getThreadSortTimestamp, |
| 4 | 5 | sortThreads, |
docs/user/thread-sidebar.md+3| ... | ... | @@ -7,6 +7,9 @@ one environment. |
| 7 | 7 | Pinned threads still move to **Settled** when they become inactive. They also move when their pull |
| 8 | 8 | request merges if **Auto-settle merged threads** is enabled. |
| 9 | 9 | |
| 10 | When you un-settle a thread, it returns to the top of the active list so you can find it right | |
| 11 | away. Its timestamps do not change. Other threads keep their positions. | |
| 12 | ||
| 10 | 13 | Right-click a pull request link in a thread and choose **Link to thread** to show that pull request |
| 11 | 14 | in the sidebar. The thread settles when the linked pull request merges if **Auto-settle merged |
| 12 | 15 | threads** 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 | 92 | archivedAt: null, |
| 93 | 93 | settledOverride: null, |
| 94 | 94 | settledAt: null, |
| 95 | unsettledAt: null, | |
| 95 | 96 | snoozedUntil: null, |
| 96 | 97 | snoozedAt: null, |
| 97 | 98 | deletedAt: null, |
| ... | ... | @@ -130,6 +131,7 @@ export function applyThreadDetailEvent( |
| 130 | 131 | ...thread, |
| 131 | 132 | settledOverride: "settled", |
| 132 | 133 | settledAt: event.payload.settledAt, |
| 134 | unsettledAt: null, | |
| 133 | 135 | updatedAt: event.payload.updatedAt, |
| 134 | 136 | }, |
| 135 | 137 | }; |
| ... | ... | @@ -141,6 +143,12 @@ export function applyThreadDetailEvent( |
| 141 | 143 | ...thread, |
| 142 | 144 | settledOverride: event.payload.reason === "user" ? "active" : null, |
| 143 | 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 | 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 | 69 | return getLatestUserMessageTimestamp(thread); |
| 70 | 70 | } |
| 71 | 71 | |
| 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 | */ | |
| 80 | export 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 | ||
| 72 | 90 | export function sortThreads<T extends { readonly id: string } & ThreadSortInput>( |
| 73 | 91 | threads: readonly T[], |
| 74 | 92 | sortOrder: SidebarThreadSortOrder, |
packages/contracts/src/orchestration.ts+7| ... | ... | @@ -415,6 +415,11 @@ export const OrchestrationThread = Schema.Struct({ |
| 415 | 415 | Schema.withDecodingDefault(Effect.succeed(null)), |
| 416 | 416 | ), |
| 417 | 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 | 423 | // Snooze is an overlay on the active lifecycle, not a fourth destination: |
| 419 | 424 | // a snoozed thread stays "active" in the model and is only suppressed from |
| 420 | 425 | // the inbox until snoozedUntil passes (or the thread raises its hand). |
| ... | ... | @@ -486,6 +491,8 @@ export const OrchestrationThreadShell = Schema.Struct({ |
| 486 | 491 | Schema.withDecodingDefault(Effect.succeed(null)), |
| 487 | 492 | ), |
| 488 | 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 | 496 | snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)), |
| 490 | 497 | snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)), |
| 491 | 498 | pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), |