| 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,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 | }); |
| 307 | 320 | ||
| 308 | describe("buildThreadListV2Items", () => { | 321 | describe("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"; |
| 13 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | 13 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 14 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; | 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 | import type { EnvironmentId, ProjectId, ThreadLinkedPullRequest } from "@t3tools/contracts"; | 19 | import type { EnvironmentId, ProjectId, ThreadLinkedPullRequest } from "@t3tools/contracts"; |
| 17 | 20 | ||
| 18 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | 21 | import 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 | } |
| 192 | 195 | ||
| 193 | /** | 196 | /** |
| 194 | * v2 sort: static creation order, newest thread on top. Activity NEVER | 197 | * 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, so | 198 | * list — a row holds its position between lifecycle transitions. The anchor |
| 196 | * the screen only moves at lifecycle transitions. Mirrors web's | 199 | * 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 | */ |
| 199 | export function sortThreadsForListV2<T extends { readonly id: string; readonly createdAt: string }>( | 204 | export 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 ES2023 | 211 | // .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 | SELECT | 274 | 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_threads | 278 | 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 | ]); |
| 282 | 284 | ||
| 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 | SELECT | 308 | 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_threads | 312 | 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"; |
| 14 | import * as Effect from "effect/Effect"; | 15 | import * as Effect from "effect/Effect"; |
| 15 | 16 | ||
| 16 | import { decideOrchestrationCommand } from "./decider.ts"; | 17 | import { decideOrchestrationCommand } from "./decider.ts"; |
| 18 | import { projectEvent } from "./projector.ts"; | ||
| 17 | 19 | ||
| 18 | const NOW = "2026-01-01T00:00:00.000Z"; | 20 | const NOW = "2026-01-01T00:00:00.000Z"; |
| 19 | const SETTLED_AT = "2025-12-30T00:00:00.000Z"; | 21 | const 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 | ); |
| 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 | 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(); | ||
| 65 | 66 | ||
| 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); | ||
| 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 | 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( |
| 371 | 373 | ||
| 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 | ); |
| 383 | 395 | ||
| 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 |
| 55 | import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; | 55 | import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; |
| 56 | import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts"; | 56 | import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts"; |
| 57 | import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts"; | 57 | import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts"; |
| 58 | import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts"; | ||
| 58 | 59 | ||
| 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; |
| 113 | 115 | ||
| 114 | export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); | 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,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", () => { |
| 807 | 807 | ||
| 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 | }); |
| 811 | 838 | ||
| 812 | describe("pinOrderKeyBetween", () => { | 839 | describe("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 |
| 3 | import type { ContextMenuItem } from "@t3tools/contracts"; | 3 | import type { ContextMenuItem } from "@t3tools/contracts"; |
| 4 | import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; | 4 | import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; |
| 5 | import { | 5 | import { |
| 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 | } |
| 540 | 541 | ||
| 541 | // Sidebar sort: static creation order, newest thread on top. Activity NEVER | 542 | // Sidebar sort: static order, newest anchor on top. Activity NEVER reorders |
| 542 | // reorders the list — a row holds its position from open until settled, so | 543 | // the list — a row holds its position between lifecycle transitions, so the |
| 543 | // the screen only moves at lifecycle transitions. Status (including pending | 544 | // 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. | ||
| 545 | export function sortThreadsForSidebar< | 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 | >(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 @@ |
| 1 | export { | 1 | export { |
| 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. |
| 7 | Pinned threads still move to **Settled** when they become inactive. They also move when their pull | 7 | Pinned threads still move to **Settled** when they become inactive. They also move when their pull |
| 8 | request merges if **Auto-settle merged threads** is enabled. | 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 | Right-click a pull request link in a thread and choose **Link to thread** to show that pull request | 13 | Right-click a pull request link in a thread and choose **Link to thread** to show that pull request |
| 11 | in the sidebar. The thread settles when the linked pull request merges if **Auto-settle merged | 14 | in the sidebar. The thread settles when the linked pull request merges if **Auto-settle merged |
| 12 | threads** is enabled. Right-click the same link and choose **Unlink from thread** to remove it. | 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,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 | } |
| 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 | export function sortThreads<T extends { readonly id: string } & ThreadSortInput>( | 90 | export 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 from | 424 | // 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)), |