import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";import { resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";import { CommandId, EnvironmentId, MessageId, ProjectId, ProviderInstanceId, RunId, ThreadId,} from "@t3tools/contracts";import { describe, expect, it } from "vite-plus/test"; import type { PendingNewTask } from "../../state/use-pending-new-tasks";import { makeThreadShellFixture } from "../../test-fixtures";import { buildThreadListV2Items, buildThreadListV2ListItems, resolveThreadListV2Enabled, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, resolveThreadListV2Status, resolveThreadListV2SwipeActions, sortThreadsForListV2,} from "./threadListV2"; const environmentId = EnvironmentId.make("environment-1"); function makeThread( input: Partial<EnvironmentThreadShell> & Pick<EnvironmentThreadShell, "id" | "title">,): EnvironmentThreadShell { return makeThreadShellFixture({ environmentId, ...input, });} const NOW = "2026-06-02T00:00:00.000Z"; const linkedPullRequest = { projectId: ProjectId.make("project-1"), repository: "pingdotgg/t3code", number: 42, url: "https://github.com/pingdotgg/t3code/pull/42",}; describe("resolveThreadListV2SnoozeMenuSelection", () => { it("accepts a displayed evening preset while its wake time is still future", () => { const menuOpenedAt = new Date(2026, 4, 8, 16, 59, 30); const selectedAt = new Date(2026, 4, 8, 17, 0, 30); const displayedPresets = resolveSnoozePresets(menuOpenedAt); const selection = resolveThreadListV2SnoozeMenuSelection({ event: "snooze:evening", displayedPresets, now: selectedAt, }); expect(selection).toEqual({ _tag: "selected", preset: displayedPresets.find((preset) => preset.id === "evening"), }); }); it("expires a displayed preset once its wake time has passed", () => { const displayedPresets = resolveSnoozePresets(new Date(2026, 4, 8, 16, 59, 30)); expect( resolveThreadListV2SnoozeMenuSelection({ event: "snooze:evening", displayedPresets, now: new Date(2026, 4, 8, 18, 0, 1), }), ).toEqual({ _tag: "expired" }); }); it("recomputes presets that remain available instead of using old timestamps", () => { const displayedPresets = resolveSnoozePresets(new Date(2026, 4, 8, 10)); const selectedAt = new Date(2026, 4, 8, 10, 30); const selection = resolveThreadListV2SnoozeMenuSelection({ event: "snooze:hour", displayedPresets, now: selectedAt, }); expect(selection._tag).toBe("selected"); if (selection._tag === "selected") { expect(selection.preset.snoozedUntil).toBe( new Date(selectedAt.getTime() + 60 * 60 * 1_000).toISOString(), ); } });}); describe("resolveThreadListV2Enabled", () => { it("defaults on when the device has never chosen", () => { expect( resolveThreadListV2Enabled({ legacyPreference: undefined, preferencesLoaded: true }), ).toBe(true); }); it("honors an explicit legacy opt-in", () => { expect(resolveThreadListV2Enabled({ legacyPreference: true, preferencesLoaded: true })).toBe( false, ); expect(resolveThreadListV2Enabled({ legacyPreference: false, preferencesLoaded: true })).toBe( true, ); }); it("holds the default while preferences are still loading so the list does not remount", () => { expect( resolveThreadListV2Enabled({ legacyPreference: undefined, preferencesLoaded: false }), ).toBe(true); });}); describe("resolveThreadListV2Status", () => { it("prioritizes approval over a running runtime", () => { const thread = makeThread({ id: ThreadId.make("t"), title: "t", hasPendingApprovals: true, runtime: { status: "running", activeRunId: RunId.make("run-t"), providerName: "Codex", providerInstanceId: ProviderInstanceId.make("codex"), lastError: null, updatedAt: NOW, }, }); expect(resolveThreadListV2Status(thread)).toBe("approval"); }); it("reports waiting when presentation parks runtime idle for background tasks", () => { expect( resolveThreadListV2Status( makeThread({ id: ThreadId.make("t"), title: "t", pendingBackgroundTasks: [{ taskId: "bg-1", description: "Run Codex review" }], runtime: { status: "idle", activeRunId: null, providerInstanceId: ProviderInstanceId.make("codex"), providerName: "Codex", lastError: null, updatedAt: NOW, }, }), ), ).toBe("waiting"); }); it("resolves ready for quiescent threads", () => { expect(resolveThreadListV2Status(makeThread({ id: ThreadId.make("t"), title: "t" }))).toBe( "ready", ); });}); describe("resolveThreadListV2SwipeActions", () => { it("offers settle and snooze for an active snoozable thread", () => { expect( resolveThreadListV2SwipeActions({ variant: "card", settlementSupported: true, snoozeSupported: true, snoozable: true, }), ).toEqual({ primary: "settle", secondary: "snooze" }); }); it("offers un-settle and snooze for settled history", () => { expect( resolveThreadListV2SwipeActions({ variant: "slim", settlementSupported: true, snoozeSupported: true, snoozable: true, }), ).toEqual({ primary: "unsettle", secondary: "snooze" }); }); it("omits snooze when the server or thread does not allow it", () => { expect( resolveThreadListV2SwipeActions({ variant: "card", settlementSupported: true, snoozeSupported: false, snoozable: true, }), ).toEqual({ primary: "settle", secondary: null }); expect( resolveThreadListV2SwipeActions({ variant: "card", settlementSupported: true, snoozeSupported: true, snoozable: false, }), ).toEqual({ primary: "settle", secondary: null }); }); it("falls back to archive only for a pre-lifecycle server", () => { expect( resolveThreadListV2SwipeActions({ variant: "card", settlementSupported: false, snoozeSupported: false, snoozable: true, }), ).toEqual({ primary: "archive", secondary: null }); }); it("offers wake and no snooze on a snoozed row", () => { expect( resolveThreadListV2SwipeActions({ variant: "slim", settlementSupported: true, snoozeSupported: true, snoozable: true, snoozed: true, }), ).toEqual({ primary: "unsnooze", secondary: null }); });}); describe("resolveThreadListV2SnoozeGateExpiryMs", () => { it("reports when an unadopted turn's grace window lapses", () => { const thread = makeThread({ id: ThreadId.make("t"), title: "t", latestUserMessageAt: "2026-06-02T00:00:30.000Z", }); expect(resolveThreadListV2SnoozeGateExpiryMs(thread, { now: "2026-06-02T00:01:00.000Z" })).toBe( Date.parse("2026-06-02T00:02:30.000Z"), ); }); it("returns null once the thread is snoozable or when only data can unblock it", () => { expect( resolveThreadListV2SnoozeGateExpiryMs( makeThread({ id: ThreadId.make("ready"), title: "Ready" }), { now: NOW }, ), ).toBe(null); expect( resolveThreadListV2SnoozeGateExpiryMs( makeThread({ id: ThreadId.make("blocked"), title: "Blocked", hasPendingApprovals: true, latestUserMessageAt: NOW, }), { now: NOW }, ), ).toBe(null); });}); describe("sortThreadsForListV2", () => { it("orders by creation time, newest first, ignoring activity", () => { const sorted = sortThreadsForListV2([ { id: "oldest", createdAt: "2026-06-01T08:00:00.000Z" }, { id: "newest", createdAt: "2026-06-01T12:00:00.000Z" }, { id: "middle", createdAt: "2026-06-01T10:00:00.000Z" }, ]); expect(sorted.map((thread) => thread.id)).toEqual(["newest", "middle", "oldest"]); });}); describe("buildThreadListV2Items", () => { it("places a persisted settled thread in the settled shelf", () => { const thread = makeThread({ id: ThreadId.make("linked-merged"), title: "Linked merged pull request", linkedPullRequest, settledOverride: "settled", settledAt: NOW, }); const layout = buildThreadListV2Items({ threads: [thread], environmentId: null, searchQuery: "", now: NOW, }); expect(layout.settledCount).toBe(1); expect(layout.items[0]?.variant).toBe("slim"); }); it("hides snoozed threads and counts them — visibility parity with web", () => { const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), makeThread({ id: ThreadId.make("snoozed"), title: "Snoozed", snoozedUntil: "2026-06-03T09:00:00.000Z", snoozedAt: "2026-06-01T12:00:00.000Z", }), makeThread({ id: ThreadId.make("woken"), title: "Woken", // Wake time already passed: back in the active list. snoozedUntil: "2026-06-01T18:00:00.000Z", snoozedAt: "2026-06-01T12:00:00.000Z", }), ], environmentId: null, searchQuery: "", now: NOW, }); // Same createdAt → static sort tiebreaks by id; the point is the woken // thread is BACK in the card block and the snoozed one is gone. expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "woken"]); expect(layout.snoozedCount).toBe(1); }); it("places settled pinned threads in the settled shelf", () => { const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), makeThread({ id: ThreadId.make("pinned-settled"), title: "Pinned while settled", pinnedAt: "2026-06-01T12:00:00.000Z", settledOverride: "settled", settledAt: "2026-06-01T12:00:00.000Z", }), ], environmentId: null, searchQuery: "", now: NOW, }); expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-settled"]); expect(layout.items.map((item) => item.pinned)).toEqual([false, false]); expect(layout.settledCount).toBe(1); }); it("keeps active pinned threads in the pinned block", () => { const pinned = makeThread({ id: ThreadId.make("pinned"), title: "Pinned thread", pinnedAt: "2026-06-01T12:00:00.000Z", }); const layout = buildThreadListV2Items({ threads: [pinned], environmentId: null, searchQuery: "", now: NOW, }); expect(layout.items[0]).toMatchObject({ thread: { id: "pinned" }, variant: "card", pinned: true, }); expect(layout.settledCount).toBe(0); }); it("hides snoozed threads and counts them — visibility parity with web", () => { const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), makeThread({ id: ThreadId.make("snoozed"), title: "Snoozed", snoozedUntil: "2026-06-03T09:00:00.000Z", snoozedAt: "2026-06-01T12:00:00.000Z", }), makeThread({ id: ThreadId.make("woken"), title: "Woken", // Wake time already passed: back in the active list. snoozedUntil: "2026-06-01T18:00:00.000Z", snoozedAt: "2026-06-01T12:00:00.000Z", }), ], environmentId: null, searchQuery: "", now: NOW, }); // Same createdAt → static sort tiebreaks by id; the point is the woken // thread is BACK in the card block and the snoozed one is gone. expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "woken"]); expect(layout.snoozedCount).toBe(1); }); it("moves a settled pinned thread into the settled shelf — parity with web (#7969)", () => { const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), makeThread({ id: ThreadId.make("pinned-settled"), title: "Pinned while settled", pinnedAt: "2026-06-01T12:00:00.000Z", // Stale settled state (the decider clears it on pin): the pin wins. settledOverride: "settled", settledAt: "2026-06-01T12:00:00.000Z", }), ], environmentId: null, searchQuery: "", now: NOW, }); // Since #7969 a settled thread leaves the active block even while pinned; // the pin re-applies when the thread is un-settled. expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-settled"]); expect(layout.items.map((item) => item.pinned)).toEqual([false, false]); expect(layout.settledCount).toBe(1); }); it("snooze hides a pinned thread and wake restores it to the pinned block", () => { const snoozedInput = { threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), makeThread({ id: ThreadId.make("pinned-snoozed"), title: "Pinned and snoozed", pinnedAt: "2026-06-01T12:00:00.000Z", snoozedUntil: "2026-06-03T09:00:00.000Z", snoozedAt: "2026-06-01T11:00:00.000Z", }), ], environmentId: null, searchQuery: "", }; // Before the wake time: the snooze wins; the pin holds underneath. const whileSnoozed = buildThreadListV2Items({ ...snoozedInput, now: NOW }); expect(whileSnoozed.items.map((item) => item.thread.id)).toEqual(["active"]); expect(whileSnoozed.snoozedCount).toBe(1); // After the wake time: the thread returns pinned, back on top. const afterWake = buildThreadListV2Items({ ...snoozedInput, now: "2026-06-03T10:00:00.000Z" }); expect(afterWake.items.map((item) => item.thread.id)).toEqual(["pinned-snoozed", "active"]); expect(afterWake.items[0]?.pinned).toBe(true); expect(afterWake.snoozedCount).toBe(0); }); it("classifies snooze with the second-precise clock and reports the next wake", () => { const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("just-woke"), title: "Just woke", // Woke 30s ago: hidden under the minute-floored clock, visible // under the precise one. snoozedUntil: "2026-06-02T00:00:30.000Z", snoozedAt: "2026-06-01T12:00:00.000Z", }), makeThread({ id: ThreadId.make("still-snoozed"), title: "Still snoozed", snoozedUntil: "2026-06-02T09:00:00.000Z", snoozedAt: "2026-06-01T12:00:00.000Z", }), ], environmentId: null, searchQuery: "", now: "2026-06-02T00:01:07.500Z", }); expect(layout.items.map((item) => item.thread.id)).toEqual(["just-woke"]); expect(layout.snoozedCount).toBe(1); expect(layout.nextSnoozeWakeAt).toBe("2026-06-02T09:00:00.000Z"); }); it("builds snoozed rows between active and settled when the shelf is expanded", () => { const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), makeThread({ id: ThreadId.make("settled"), title: "Settled", settledOverride: "settled", settledAt: NOW, }), makeThread({ id: ThreadId.make("later"), title: "Wakes later", snoozedUntil: "2026-06-03T09:00:00.000Z", snoozedAt: "2026-06-01T12:00:00.000Z", }), makeThread({ id: ThreadId.make("sooner"), title: "Wakes sooner", snoozedUntil: "2026-06-02T09:00:00.000Z", snoozedAt: "2026-06-01T12:00:00.000Z", }), ], environmentId: null, searchQuery: "",Lines 1–500 of 986. Load more
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; import { CommandId, EnvironmentId, MessageId, ProjectId, ProviderInstanceId, RunId, ThreadId, } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { makeThreadShellFixture } from "../../test-fixtures"; import { buildThreadListV2Items, buildThreadListV2ListItems, resolveThreadListV2Enabled, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, resolveThreadListV2Status, resolveThreadListV2SwipeActions, sortThreadsForListV2, } from "./threadListV2"; const environmentId = EnvironmentId.make("environment-1"); function makeThread( input: Partial<EnvironmentThreadShell> & Pick<EnvironmentThreadShell, "id" | "title">, ): EnvironmentThreadShell { return makeThreadShellFixture({ environmentId, ...input, }); } const NOW = "2026-06-02T00:00:00.000Z"; const linkedPullRequest = { projectId: ProjectId.make("project-1"), repository: "pingdotgg/t3code", number: 42, url: "https://github.com/pingdotgg/t3code/pull/42", }; describe("resolveThreadListV2SnoozeMenuSelection", () => { it("accepts a displayed evening preset while its wake time is still future", () => { const menuOpenedAt = new Date(2026, 4, 8, 16, 59, 30); const selectedAt = new Date(2026, 4, 8, 17, 0, 30); const displayedPresets = resolveSnoozePresets(menuOpenedAt); const selection = resolveThreadListV2SnoozeMenuSelection({ event: "snooze:evening", displayedPresets, now: selectedAt, }); expect(selection).toEqual({ _tag: "selected", preset: displayedPresets.find((preset) => preset.id === "evening"), }); }); it("expires a displayed preset once its wake time has passed", () => { const displayedPresets = resolveSnoozePresets(new Date(2026, 4, 8, 16, 59, 30)); expect( resolveThreadListV2SnoozeMenuSelection({ event: "snooze:evening", displayedPresets, now: new Date(2026, 4, 8, 18, 0, 1), }), ).toEqual({ _tag: "expired" }); }); it("recomputes presets that remain available instead of using old timestamps", () => { const displayedPresets = resolveSnoozePresets(new Date(2026, 4, 8, 10)); const selectedAt = new Date(2026, 4, 8, 10, 30); const selection = resolveThreadListV2SnoozeMenuSelection({ event: "snooze:hour", displayedPresets, now: selectedAt, }); expect(selection._tag).toBe("selected"); if (selection._tag === "selected") { expect(selection.preset.snoozedUntil).toBe( new Date(selectedAt.getTime() + 60 * 60 * 1_000).toISOString(), ); } }); }); describe("resolveThreadListV2Enabled", () => { it("defaults on when the device has never chosen", () => { expect( resolveThreadListV2Enabled({ legacyPreference: undefined, preferencesLoaded: true }), ).toBe(true); }); it("honors an explicit legacy opt-in", () => { expect(resolveThreadListV2Enabled({ legacyPreference: true, preferencesLoaded: true })).toBe( false, ); expect(resolveThreadListV2Enabled({ legacyPreference: false, preferencesLoaded: true })).toBe( true, ); }); it("holds the default while preferences are still loading so the list does not remount", () => { expect( resolveThreadListV2Enabled({ legacyPreference: undefined, preferencesLoaded: false }), ).toBe(true); }); }); describe("resolveThreadListV2Status", () => { it("prioritizes approval over a running runtime", () => { const thread = makeThread({ id: ThreadId.make("t"), title: "t", hasPendingApprovals: true, runtime: { status: "running", activeRunId: RunId.make("run-t"), providerName: "Codex", providerInstanceId: ProviderInstanceId.make("codex"), lastError: null, updatedAt: NOW, }, }); expect(resolveThreadListV2Status(thread)).toBe("approval"); }); it("reports waiting when presentation parks runtime idle for background tasks", () => { expect( resolveThreadListV2Status( makeThread({ id: ThreadId.make("t"), title: "t", pendingBackgroundTasks: [{ taskId: "bg-1", description: "Run Codex review" }], runtime: { status: "idle", activeRunId: null, providerInstanceId: ProviderInstanceId.make("codex"), providerName: "Codex", lastError: null, updatedAt: NOW, }, }), ), ).toBe("waiting"); }); it("resolves ready for quiescent threads", () => { expect(resolveThreadListV2Status(makeThread({ id: ThreadId.make("t"), title: "t" }))).toBe( "ready", ); }); }); describe("resolveThreadListV2SwipeActions", () => { it("offers settle and snooze for an active snoozable thread", () => { expect( resolveThreadListV2SwipeActions({ variant: "card", settlementSupported: true, snoozeSupported: true, snoozable: true, }), ).toEqual({ primary: "settle", secondary: "snooze" }); }); it("offers un-settle and snooze for settled history", () => { expect( resolveThreadListV2SwipeActions({ variant: "slim", settlementSupported: true, snoozeSupported: true, snoozable: true, }), ).toEqual({ primary: "unsettle", secondary: "snooze" }); }); it("omits snooze when the server or thread does not allow it", () => { expect( resolveThreadListV2SwipeActions({ variant: "card", settlementSupported: true, snoozeSupported: false, snoozable: true, }), ).toEqual({ primary: "settle", secondary: null }); expect( resolveThreadListV2SwipeActions({ variant: "card", settlementSupported: true, snoozeSupported: true, snoozable: false, }), ).toEqual({ primary: "settle", secondary: null }); }); it("falls back to archive only for a pre-lifecycle server", () => { expect( resolveThreadListV2SwipeActions({ variant: "card", settlementSupported: false, snoozeSupported: false, snoozable: true, }), ).toEqual({ primary: "archive", secondary: null }); }); it("offers wake and no snooze on a snoozed row", () => { expect( resolveThreadListV2SwipeActions({ variant: "slim", settlementSupported: true, snoozeSupported: true, snoozable: true, snoozed: true, }), ).toEqual({ primary: "unsnooze", secondary: null }); }); }); describe("resolveThreadListV2SnoozeGateExpiryMs", () => { it("reports when an unadopted turn's grace window lapses", () => { const thread = makeThread({ id: ThreadId.make("t"), title: "t", latestUserMessageAt: "2026-06-02T00:00:30.000Z", }); expect(resolveThreadListV2SnoozeGateExpiryMs(thread, { now: "2026-06-02T00:01:00.000Z" })).toBe( Date.parse("2026-06-02T00:02:30.000Z"), ); }); it("returns null once the thread is snoozable or when only data can unblock it", () => { expect( resolveThreadListV2SnoozeGateExpiryMs( makeThread({ id: ThreadId.make("ready"), title: "Ready" }), { now: NOW }, ), ).toBe(null); expect( resolveThreadListV2SnoozeGateExpiryMs( makeThread({ id: ThreadId.make("blocked"), title: "Blocked", hasPendingApprovals: true, latestUserMessageAt: NOW, }), { now: NOW }, ), ).toBe(null); }); }); describe("sortThreadsForListV2", () => { it("orders by creation time, newest first, ignoring activity", () => { const sorted = sortThreadsForListV2([ { id: "oldest", createdAt: "2026-06-01T08:00:00.000Z" }, { id: "newest", createdAt: "2026-06-01T12:00:00.000Z" }, { id: "middle", createdAt: "2026-06-01T10:00:00.000Z" }, ]); expect(sorted.map((thread) => thread.id)).toEqual(["newest", "middle", "oldest"]); }); }); describe("buildThreadListV2Items", () => { it("places a persisted settled thread in the settled shelf", () => { const thread = makeThread({ id: ThreadId.make("linked-merged"), title: "Linked merged pull request", linkedPullRequest, settledOverride: "settled", settledAt: NOW, }); const layout = buildThreadListV2Items({ threads: [thread], environmentId: null, searchQuery: "", now: NOW, }); expect(layout.settledCount).toBe(1); expect(layout.items[0]?.variant).toBe("slim"); }); it("hides snoozed threads and counts them — visibility parity with web", () => { const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), makeThread({ id: ThreadId.make("snoozed"), title: "Snoozed", snoozedUntil: "2026-06-03T09:00:00.000Z", snoozedAt: "2026-06-01T12:00:00.000Z", }), makeThread({ id: ThreadId.make("woken"), title: "Woken", // Wake time already passed: back in the active list. snoozedUntil: "2026-06-01T18:00:00.000Z", snoozedAt: "2026-06-01T12:00:00.000Z", }), ], environmentId: null, searchQuery: "", now: NOW, }); // Same createdAt → static sort tiebreaks by id; the point is the woken // thread is BACK in the card block and the snoozed one is gone. expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "woken"]); expect(layout.snoozedCount).toBe(1); }); it("places settled pinned threads in the settled shelf", () => { const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), makeThread({ id: ThreadId.make("pinned-settled"), title: "Pinned while settled", pinnedAt: "2026-06-01T12:00:00.000Z", settledOverride: "settled", settledAt: "2026-06-01T12:00:00.000Z", }), ], environmentId: null, searchQuery: "", now: NOW, }); expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-settled"]); expect(layout.items.map((item) => item.pinned)).toEqual([false, false]); expect(layout.settledCount).toBe(1); }); it("keeps active pinned threads in the pinned block", () => { const pinned = makeThread({ id: ThreadId.make("pinned"), title: "Pinned thread", pinnedAt: "2026-06-01T12:00:00.000Z", }); const layout = buildThreadListV2Items({ threads: [pinned], environmentId: null, searchQuery: "", now: NOW, }); expect(layout.items[0]).toMatchObject({ thread: { id: "pinned" }, variant: "card", pinned: true, }); expect(layout.settledCount).toBe(0); }); it("hides snoozed threads and counts them — visibility parity with web", () => { const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), makeThread({ id: ThreadId.make("snoozed"), title: "Snoozed", snoozedUntil: "2026-06-03T09:00:00.000Z", snoozedAt: "2026-06-01T12:00:00.000Z", }), makeThread({ id: ThreadId.make("woken"), title: "Woken", // Wake time already passed: back in the active list. snoozedUntil: "2026-06-01T18:00:00.000Z", snoozedAt: "2026-06-01T12:00:00.000Z", }), ], environmentId: null, searchQuery: "", now: NOW, }); // Same createdAt → static sort tiebreaks by id; the point is the woken // thread is BACK in the card block and the snoozed one is gone. expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "woken"]); expect(layout.snoozedCount).toBe(1); }); it("moves a settled pinned thread into the settled shelf — parity with web (#7969)", () => { const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), makeThread({ id: ThreadId.make("pinned-settled"), title: "Pinned while settled", pinnedAt: "2026-06-01T12:00:00.000Z", // Stale settled state (the decider clears it on pin): the pin wins. settledOverride: "settled", settledAt: "2026-06-01T12:00:00.000Z", }), ], environmentId: null, searchQuery: "", now: NOW, }); // Since #7969 a settled thread leaves the active block even while pinned; // the pin re-applies when the thread is un-settled. expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-settled"]); expect(layout.items.map((item) => item.pinned)).toEqual([false, false]); expect(layout.settledCount).toBe(1); }); it("snooze hides a pinned thread and wake restores it to the pinned block", () => { const snoozedInput = { threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), makeThread({ id: ThreadId.make("pinned-snoozed"), title: "Pinned and snoozed", pinnedAt: "2026-06-01T12:00:00.000Z", snoozedUntil: "2026-06-03T09:00:00.000Z", snoozedAt: "2026-06-01T11:00:00.000Z", }), ], environmentId: null, searchQuery: "", }; // Before the wake time: the snooze wins; the pin holds underneath. const whileSnoozed = buildThreadListV2Items({ ...snoozedInput, now: NOW }); expect(whileSnoozed.items.map((item) => item.thread.id)).toEqual(["active"]); expect(whileSnoozed.snoozedCount).toBe(1); // After the wake time: the thread returns pinned, back on top. const afterWake = buildThreadListV2Items({ ...snoozedInput, now: "2026-06-03T10:00:00.000Z" }); expect(afterWake.items.map((item) => item.thread.id)).toEqual(["pinned-snoozed", "active"]); expect(afterWake.items[0]?.pinned).toBe(true); expect(afterWake.snoozedCount).toBe(0); }); it("classifies snooze with the second-precise clock and reports the next wake", () => { const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("just-woke"), title: "Just woke", // Woke 30s ago: hidden under the minute-floored clock, visible // under the precise one. snoozedUntil: "2026-06-02T00:00:30.000Z", snoozedAt: "2026-06-01T12:00:00.000Z", }), makeThread({ id: ThreadId.make("still-snoozed"), title: "Still snoozed", snoozedUntil: "2026-06-02T09:00:00.000Z", snoozedAt: "2026-06-01T12:00:00.000Z", }), ], environmentId: null, searchQuery: "", now: "2026-06-02T00:01:07.500Z", }); expect(layout.items.map((item) => item.thread.id)).toEqual(["just-woke"]); expect(layout.snoozedCount).toBe(1); expect(layout.nextSnoozeWakeAt).toBe("2026-06-02T09:00:00.000Z"); }); it("builds snoozed rows between active and settled when the shelf is expanded", () => { const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), makeThread({ id: ThreadId.make("settled"), title: "Settled", settledOverride: "settled", settledAt: NOW, }), makeThread({ id: ThreadId.make("later"), title: "Wakes later", snoozedUntil: "2026-06-03T09:00:00.000Z", snoozedAt: "2026-06-01T12:00:00.000Z", }), makeThread({ id: ThreadId.make("sooner"), title: "Wakes sooner", snoozedUntil: "2026-06-02T09:00:00.000Z", snoozedAt: "2026-06-01T12:00:00.000Z", }), ], environmentId: null, searchQuery: "",