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", () => {
303303 ]);
304304 expect(sorted.map((thread) => thread.id)).toEqual(["newest", "middle", "oldest"]);
305305 });
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 });
306319});
307320
308321describe("buildThreadListV2Items", () => {
apps/mobile/src/features/threads/threadListV2.ts+18-9
......@@ -12,7 +12,10 @@ import type {
1212} from "@t3tools/client-runtime/state/thread-settled";
1313import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
1414import { 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";
1619import type { EnvironmentId, ProjectId, ThreadLinkedPullRequest } from "@t3tools/contracts";
1720
1821import type { PendingNewTask } from "../../state/use-pending-new-tasks";
......@@ -191,19 +194,25 @@ function firstValidTimestampMs(...candidates: ReadonlyArray<string | null | unde
191194}
192195
193196/**
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.
198203 */
199export function sortThreadsForListV2<T extends { readonly id: string; readonly createdAt: string }>(
200 threads: readonly T[],
201): T[] {
204export 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[] {
202211 // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023
203212 // change-by-copy array methods.
204213 return [...threads].sort(
205214 (left, right) =>
206 parseTimestampMs(right.createdAt) - parseTimestampMs(left.createdAt) ||
215 activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left) ||
207216 left.id.localeCompare(right.id),
208217 );
209218}
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+16-4
......@@ -269,15 +269,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
269269 const settledRows = yield* sql<{
270270 readonly settledOverride: string | null;
271271 readonly settledAt: string | null;
272 readonly unsettledAt: string | null;
272273 }>`
273274 SELECT
274275 settled_override AS "settledOverride",
275 settled_at AS "settledAt"
276 settled_at AS "settledAt",
277 unsettled_at AS "unsettledAt"
276278 FROM projection_threads
277279 WHERE thread_id = 'thread-1'
278280 `;
279281 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 },
281283 ]);
282284
283285 yield* eventStore.append({
......@@ -301,14 +303,24 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
301303 const unsettledRows = yield* sql<{
302304 readonly settledOverride: string | null;
303305 readonly settledAt: string | null;
306 readonly unsettledAt: string | null;
304307 }>`
305308 SELECT
306309 settled_override AS "settledOverride",
307 settled_at AS "settledAt"
310 settled_at AS "settledAt",
311 unsettled_at AS "unsettledAt"
308312 FROM projection_threads
309313 WHERE thread_id = 'thread-1'
310314 `;
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 ]);
312324 }),
313325 );
314326});
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+9
......@@ -641,6 +641,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
641641 archivedAt: null,
642642 settledOverride: null,
643643 settledAt: null,
644 unsettledAt: null,
644645 snoozedUntil: null,
645646 snoozedAt: null,
646647 pinnedAt: null,
......@@ -698,6 +699,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
698699 ...existingRow.value,
699700 settledOverride: "settled",
700701 settledAt: event.payload.settledAt,
702 unsettledAt: null,
701703 updatedAt: event.payload.updatedAt,
702704 });
703705 return;
......@@ -714,6 +716,13 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
714716 ...existingRow.value,
715717 settledOverride: event.payload.reason === "user" ? "active" : null,
716718 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,
717726 updatedAt: event.payload.updatedAt,
718727 });
719728 return;
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+2
......@@ -329,6 +329,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
329329 archivedAt: null,
330330 settledOverride: null,
331331 settledAt: null,
332 unsettledAt: null,
332333 snoozedUntil: null,
333334 snoozedAt: null,
334335 pinnedAt: "2026-02-24T00:00:01.000Z",
......@@ -454,6 +455,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
454455 archivedAt: null,
455456 settledOverride: null,
456457 settledAt: null,
458 unsettledAt: null,
457459 snoozedUntil: null,
458460 snoozedAt: null,
459461 pinnedAt: "2026-02-24T00:00:01.000Z",
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+10
......@@ -431,6 +431,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
431431 archived_at AS "archivedAt",
432432 settled_override AS "settledOverride",
433433 settled_at AS "settledAt",
434 unsettled_at AS "unsettledAt",
434435 snoozed_until AS "snoozedUntil",
435436 snoozed_at AS "snoozedAt",
436437 pinned_at AS "pinnedAt",
......@@ -468,6 +469,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
468469 archived_at AS "archivedAt",
469470 settled_override AS "settledOverride",
470471 settled_at AS "settledAt",
472 unsettled_at AS "unsettledAt",
471473 snoozed_until AS "snoozedUntil",
472474 snoozed_at AS "snoozedAt",
473475 pinned_at AS "pinnedAt",
......@@ -507,6 +509,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
507509 archived_at AS "archivedAt",
508510 settled_override AS "settledOverride",
509511 settled_at AS "settledAt",
512 unsettled_at AS "unsettledAt",
510513 snoozed_until AS "snoozedUntil",
511514 snoozed_at AS "snoozedAt",
512515 pinned_at AS "pinnedAt",
......@@ -950,6 +953,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
950953 archived_at AS "archivedAt",
951954 settled_override AS "settledOverride",
952955 settled_at AS "settledAt",
956 unsettled_at AS "unsettledAt",
953957 snoozed_until AS "snoozedUntil",
954958 snoozed_at AS "snoozedAt",
955959 pinned_at AS "pinnedAt",
......@@ -1709,6 +1713,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
17091713 archivedAt: row.archivedAt,
17101714 settledOverride: row.settledOverride,
17111715 settledAt: row.settledAt,
1716 unsettledAt: row.unsettledAt,
17121717 snoozedUntil: row.snoozedUntil,
17131718 snoozedAt: row.snoozedAt,
17141719 pinnedAt: row.pinnedAt,
......@@ -1919,6 +1924,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
19191924 archivedAt: row.archivedAt,
19201925 settledOverride: row.settledOverride,
19211926 settledAt: row.settledAt,
1927 unsettledAt: row.unsettledAt,
19221928 snoozedUntil: row.snoozedUntil,
19231929 snoozedAt: row.snoozedAt,
19241930 pinnedAt: row.pinnedAt,
......@@ -2058,6 +2064,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
20582064 archivedAt: row.archivedAt,
20592065 settledOverride: row.settledOverride,
20602066 settledAt: row.settledAt,
2067 unsettledAt: row.unsettledAt,
20612068 snoozedUntil: row.snoozedUntil,
20622069 snoozedAt: row.snoozedAt,
20632070 pinnedAt: row.pinnedAt,
......@@ -2206,6 +2213,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
22062213 archivedAt: row.archivedAt,
22072214 settledOverride: row.settledOverride,
22082215 settledAt: row.settledAt,
2216 unsettledAt: row.unsettledAt,
22092217 snoozedUntil: row.snoozedUntil,
22102218 snoozedAt: row.snoozedAt,
22112219 pinnedAt: row.pinnedAt,
......@@ -2488,6 +2496,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
24882496 archivedAt: threadRow.value.archivedAt,
24892497 settledOverride: threadRow.value.settledOverride,
24902498 settledAt: threadRow.value.settledAt,
2499 unsettledAt: threadRow.value.unsettledAt,
24912500 snoozedUntil: threadRow.value.snoozedUntil,
24922501 snoozedAt: threadRow.value.snoozedAt,
24932502 pinnedAt: threadRow.value.pinnedAt,
......@@ -2632,6 +2641,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
26322641 archivedAt: threadRow.value.archivedAt,
26332642 settledOverride: threadRow.value.settledOverride,
26342643 settledAt: threadRow.value.settledAt,
2644 unsettledAt: threadRow.value.unsettledAt,
26352645 snoozedUntil: threadRow.value.snoozedUntil,
26362646 snoozedAt: threadRow.value.snoozedAt,
26372647 pinnedAt: threadRow.value.pinnedAt,
apps/server/src/orchestration/decider.settled.test.ts+38
......@@ -5,6 +5,7 @@ import {
55 ProjectId,
66 ProviderInstanceId,
77 ThreadId,
8 type OrchestrationEvent,
89 type OrchestrationReadModel,
910 type OrchestrationSession,
1011 type OrchestrationThread,
......@@ -14,6 +15,7 @@ import { expect, it } from "@effect/vitest";
1415import * as Effect from "effect/Effect";
1516
1617import { decideOrchestrationCommand } from "./decider.ts";
18import { projectEvent } from "./projector.ts";
1719
1820const NOW = "2026-01-01T00:00:00.000Z";
1921const SETTLED_AT = "2025-12-30T00:00:00.000Z";
......@@ -428,6 +430,42 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => {
428430 }),
429431 );
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
431469 it.effect("prepends activity unsets for turn starts and live session updates", () =>
432470 Effect.gen(function* () {
433471 const turnResult = yield* decideOrchestrationCommand({
apps/server/src/orchestration/projector.settled.test.ts+37-2
......@@ -62,27 +62,62 @@ it.effect("projects settled lifecycle events", () =>
6262 );
6363 expect(settled.threads[0]?.settledOverride).toBe("settled");
6464 expect(settled.threads[0]?.settledAt).toBe(now);
65 expect(settled.threads[0]?.unsettledAt).toBeNull();
6566
67 const unsettleAt = "2026-01-02T00:00:00.000Z";
6668 const userUnsettled = yield* projectEvent(
6769 settled,
6870 makeEvent({
6971 sequence: 3,
7072 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 },
7274 }),
7375 );
7476 expect(userUnsettled.threads[0]?.settledOverride).toBe("active");
7577 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";
7783 const activityUnsettled = yield* projectEvent(
7884 userUnsettled,
7985 makeEvent({
8086 sequence: 4,
8187 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 },
8389 }),
8490 );
8591 expect(activityUnsettled.threads[0]?.settledOverride).toBeNull();
8692 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);
87122 }),
88123);
apps/server/src/orchestration/projector.test.ts+1
......@@ -91,6 +91,7 @@ describe("orchestration projector", () => {
9191 archivedAt: null,
9292 settledOverride: null,
9393 settledAt: null,
94 unsettledAt: null,
9495 snoozedUntil: null,
9596 snoozedAt: null,
9697 deletedAt: null,
apps/server/src/orchestration/projector.ts+20-8
......@@ -303,6 +303,7 @@ export function projectEvent(
303303 archivedAt: null,
304304 settledOverride: null,
305305 settledAt: null,
306 unsettledAt: null,
306307 snoozedUntil: null,
307308 snoozedAt: null,
308309 deletedAt: null,
......@@ -364,6 +365,7 @@ export function projectEvent(
364365 threads: updateThread(nextBase.threads, payload.threadId, {
365366 settledOverride: "settled",
366367 settledAt: payload.settledAt,
368 unsettledAt: null,
367369 updatedAt: payload.updatedAt,
368370 }),
369371 })),
......@@ -371,14 +373,24 @@ export function projectEvent(
371373
372374 case "thread.unsettled":
373375 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 }),
382394 );
383395
384396 case "thread.snoozed":
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+5
......@@ -94,6 +94,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
9494 archivedAt: null,
9595 settledOverride: null,
9696 settledAt: null,
97 unsettledAt: null,
9798 snoozedUntil: null,
9899 snoozedAt: null,
99100 pinnedAt: null,
......@@ -157,6 +158,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
157158 archivedAt: null,
158159 settledOverride: "settled",
159160 settledAt: "2026-03-25T00:00:00.000Z",
161 unsettledAt: null,
160162 snoozedUntil: "2026-03-26T09:00:00.000Z",
161163 snoozedAt: "2026-03-25T00:00:00.000Z",
162164 pinnedAt: "2026-03-25T00:00:00.000Z",
......@@ -186,6 +188,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
186188 ...row,
187189 settledOverride: "active",
188190 settledAt: null,
191 unsettledAt: "2026-03-26T00:00:00.000Z",
189192 snoozedUntil: null,
190193 snoozedAt: null,
191194 pinnedAt: null,
......@@ -196,6 +199,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
196199 const updated = Option.getOrNull(repersisted);
197200 assert.strictEqual(updated?.settledOverride, "active");
198201 assert.strictEqual(updated?.settledAt, null);
202 assert.strictEqual(updated?.unsettledAt, "2026-03-26T00:00:00.000Z");
199203 assert.strictEqual(updated?.snoozedUntil, null);
200204 assert.strictEqual(updated?.snoozedAt, null);
201205 assert.strictEqual(updated?.pinnedAt, null);
......@@ -231,6 +235,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
231235 archivedAt: null,
232236 settledOverride: null,
233237 settledAt: null,
238 unsettledAt: null,
234239 snoozedUntil: null,
235240 snoozedAt: null,
236241 pinnedAt: null,
apps/server/src/persistence/Layers/ProjectionThreads.ts+5
......@@ -47,6 +47,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
4747 archived_at,
4848 settled_override,
4949 settled_at,
50 unsettled_at,
5051 snoozed_until,
5152 snoozed_at,
5253 pinned_at,
......@@ -75,6 +76,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
7576 ${row.archivedAt},
7677 ${row.settledOverride},
7778 ${row.settledAt},
79 ${row.unsettledAt},
7880 ${row.snoozedUntil},
7981 ${row.snoozedAt},
8082 ${row.pinnedAt},
......@@ -103,6 +105,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
103105 archived_at = excluded.archived_at,
104106 settled_override = excluded.settled_override,
105107 settled_at = excluded.settled_at,
108 unsettled_at = excluded.unsettled_at,
106109 snoozed_until = excluded.snoozed_until,
107110 snoozed_at = excluded.snoozed_at,
108111 pinned_at = excluded.pinned_at,
......@@ -138,6 +141,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
138141 archived_at AS "archivedAt",
139142 settled_override AS "settledOverride",
140143 settled_at AS "settledAt",
144 unsettled_at AS "unsettledAt",
141145 snoozed_until AS "snoozedUntil",
142146 snoozed_at AS "snoozedAt",
143147 pinned_at AS "pinnedAt",
......@@ -175,6 +179,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
175179 archived_at AS "archivedAt",
176180 settled_override AS "settledOverride",
177181 settled_at AS "settledAt",
182 unsettled_at AS "unsettledAt",
178183 snoozed_until AS "snoozedUntil",
179184 snoozed_at AS "snoozedAt",
180185 pinned_at AS "pinnedAt",
apps/server/src/persistence/Migrations.ts+2
......@@ -55,6 +55,7 @@ import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMo
5555import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts";
5656import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts";
5757import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts";
58import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts";
5859
5960/**
6061 * Migration loader with all migrations defined inline.
......@@ -109,6 +110,7 @@ export const migrationEntries = [
109110 [40, "ProjectionProjectFaviconPath", Migration0040],
110111 [41, "AuthSessionClientConnection", Migration0041],
111112 [42, "ProjectionThreadLinkedPullRequest", Migration0042],
113 [43, "ProjectionThreadsUnsettledAt", Migration0043],
112114] as const;
113115
114116export 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({
4141 archivedAt: Schema.NullOr(IsoDateTime),
4242 settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])),
4343 settledAt: Schema.NullOr(IsoDateTime),
44 unsettledAt: Schema.NullOr(IsoDateTime),
4445 snoozedUntil: Schema.NullOr(IsoDateTime),
4546 snoozedAt: Schema.NullOr(IsoDateTime),
4647 pinnedAt: Schema.NullOr(IsoDateTime),
apps/web/src/components/Sidebar.logic.test.ts+27
......@@ -807,6 +807,33 @@ describe("sortThreadsForSidebar", () => {
807807
808808 expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]);
809809 });
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 });
810837});
811838
812839describe("pinOrderKeyBetween", () => {
apps/web/src/components/Sidebar.logic.ts+14-6
......@@ -3,6 +3,7 @@ import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit
33import type { ContextMenuItem } from "@t3tools/contracts";
44import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
55import {
6 activeThreadAnchorTimestampMs,
67 getThreadSortTimestamp,
78 sortThreads,
89 toSortableTimestamp,
......@@ -538,16 +539,23 @@ export function firstValidTimestamp(
538539 return null;
539540}
540541
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.
545549export 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 },
547555>(threads: readonly T[]): T[] {
548556 return [...threads].toSorted(
549557 (left, right) =>
550 parseTimestampMs(right.createdAt) - parseTimestampMs(left.createdAt) ||
558 activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left) ||
551559 left.id.localeCompare(right.id),
552560 );
553561}
apps/web/src/lib/threadSort.ts+1
......@@ -1,4 +1,5 @@
11export {
2 activeThreadAnchorTimestampMs,
23 getLatestThreadForProject,
34 getThreadSortTimestamp,
45 sortThreads,
docs/user/thread-sidebar.md+3
......@@ -7,6 +7,9 @@ one environment.
77Pinned threads still move to **Settled** when they become inactive. They also move when their pull
88request 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
1013Right-click a pull request link in a thread and choose **Link to thread** to show that pull request
1114in the sidebar. The thread settles when the linked pull request merges if **Auto-settle merged
1215threads** 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(
9292 archivedAt: null,
9393 settledOverride: null,
9494 settledAt: null,
95 unsettledAt: null,
9596 snoozedUntil: null,
9697 snoozedAt: null,
9798 deletedAt: null,
......@@ -130,6 +131,7 @@ export function applyThreadDetailEvent(
130131 ...thread,
131132 settledOverride: "settled",
132133 settledAt: event.payload.settledAt,
134 unsettledAt: null,
133135 updatedAt: event.payload.updatedAt,
134136 },
135137 };
......@@ -141,6 +143,12 @@ export function applyThreadDetailEvent(
141143 ...thread,
142144 settledOverride: event.payload.reason === "user" ? "active" : null,
143145 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,
144152 updatedAt: event.payload.updatedAt,
145153 },
146154 };
packages/client-runtime/src/state/threadSort.ts+18
......@@ -69,6 +69,24 @@ export function getThreadSortTimestamp(
6969 return getLatestUserMessageTimestamp(thread);
7070}
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
7290export function sortThreads<T extends { readonly id: string } & ThreadSortInput>(
7391 threads: readonly T[],
7492 sortOrder: SidebarThreadSortOrder,
packages/contracts/src/orchestration.ts+7
......@@ -415,6 +415,11 @@ export const OrchestrationThread = Schema.Struct({
415415 Schema.withDecodingDefault(Effect.succeed(null)),
416416 ),
417417 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)),
418423 // Snooze is an overlay on the active lifecycle, not a fourth destination:
419424 // a snoozed thread stays "active" in the model and is only suppressed from
420425 // the inbox until snoozedUntil passes (or the thread raises its hand).
......@@ -486,6 +491,8 @@ export const OrchestrationThreadShell = Schema.Struct({
486491 Schema.withDecodingDefault(Effect.succeed(null)),
487492 ),
488493 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)),
489496 snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)),
490497 snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
491498 pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)),