Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion apps/mobile/src/features/home/HomeScreen.tsx
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,10 @@ export function HomeScreen(props: HomeScreenProps) {
// boundary is actually crossed while the app stays open (mirrors web);
// without a clock dependency the partition memoizes a frozen "now".
const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16));
// Snooze wake times are second-precise; a counter bumped exactly at the
// next wake boundary re-runs the partition with a fresh clock so a woken
// thread reappears immediately instead of on the next minute tick.
const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
useEffect(() => {
if (!threadListV2Enabled) return;
// Refresh immediately on enable: the mount-time value can be hours old
Expand All @@ -456,8 +460,18 @@ export function HomeScreen(props: HomeScreenProps) {
}
return supported;
}, [serverConfigs]);
const snoozeEnvironmentIds = useMemo(() => {
const supported = new Set<EnvironmentId>();
for (const [environmentId, config] of serverConfigs) {
if (config.environment.capabilities.threadSnooze === true) {
supported.add(environmentId);
}
}
return supported;
}, [serverConfigs]);
const threadListV2Layout = useMemo(() => {
if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0 };
if (!threadListV2Enabled)
return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null };
// Settled threads are live shells; archived threads keep their original
// "hidden from lists" meaning.
return buildThreadListV2Items({
Expand All @@ -467,20 +481,35 @@ export function HomeScreen(props: HomeScreenProps) {
searchQuery: props.searchQuery,
changeRequestStateByKey,
settlementEnvironmentIds,
snoozeEnvironmentIds,
Comment thread
cursor[bot] marked this conversation as resolved.
settledLimit: settledVisibleCount,
now: `${nowMinute}:00.000Z`,
snoozeNow: new Date().toISOString(),
});
}, [
changeRequestStateByKey,
nowMinute,
snoozeWakeTick,
settledVisibleCount,
settlementEnvironmentIds,
snoozeEnvironmentIds,
props.searchQuery,
props.selectedEnvironmentId,
props.threads,
threadListV2Enabled,
v2ScopedProjectGroup,
]);
// Re-partition the moment the earliest snooze expires (clamped to the
// signed-32-bit setTimeout range; far-future wakes re-arm at the clamp).
const nextSnoozeWakeAt = threadListV2Layout.nextSnoozeWakeAt;
useEffect(() => {
if (nextSnoozeWakeAt === null) return;
const wakeAtMs = Date.parse(nextSnoozeWakeAt);
if (Number.isNaN(wakeAtMs)) return;
const delayMs = Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647);
const id = setTimeout(() => bumpSnoozeWakeTick((tick) => tick + 1), delayMs);
return () => clearTimeout(id);
}, [nextSnoozeWakeAt]);
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
const threadListV2Items = threadListV2Layout.items;

const renderV2Item = useCallback(
Expand Down
31 changes: 30 additions & 1 deletion apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,10 @@ function ThreadNavigationSidebarPane(
// crossed while the pane stays open; without a clock dependency the
// partition memoizes a frozen "now".
const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16));
// Snooze wake times are second-precise; a counter bumped exactly at the
// next wake boundary re-runs the partition with a fresh clock so a woken
// thread reappears immediately instead of on the next minute tick.
const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
useEffect(() => {
if (!threadListV2Enabled) return;
// Refresh immediately on enable: the mount-time value can be hours old
Expand All @@ -405,29 +409,54 @@ function ThreadNavigationSidebarPane(
}
return supported;
}, [serverConfigs]);
const snoozeEnvironmentIds = useMemo(() => {
const supported = new Set<EnvironmentId>();
for (const [environmentId, config] of serverConfigs) {
if (config.environment.capabilities.threadSnooze === true) {
supported.add(environmentId);
}
}
return supported;
}, [serverConfigs]);
const threadListV2Layout = useMemo(() => {
if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0 };
if (!threadListV2Enabled)
return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null };
return buildThreadListV2Items({
threads: threads.filter((thread) => thread.archivedAt === null),
environmentId: options.selectedEnvironmentId,
projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs,
searchQuery: props.searchQuery,
changeRequestStateByKey,
settlementEnvironmentIds,
snoozeEnvironmentIds,
settledLimit: settledVisibleCount,
now: `${nowMinute}:00.000Z`,
snoozeNow: new Date().toISOString(),
});
}, [
changeRequestStateByKey,
nowMinute,
snoozeWakeTick,
options.selectedEnvironmentId,
props.searchQuery,
settledVisibleCount,
settlementEnvironmentIds,
snoozeEnvironmentIds,
threadListV2Enabled,
threads,
selectedProjectScope,
]);
// Re-partition the moment the earliest snooze expires (clamped to the
// signed-32-bit setTimeout range; far-future wakes re-arm at the clamp).
const nextSnoozeWakeAt = threadListV2Layout.nextSnoozeWakeAt;
useEffect(() => {
if (nextSnoozeWakeAt === null) return;
const wakeAtMs = Date.parse(nextSnoozeWakeAt);
if (Number.isNaN(wakeAtMs)) return;
const delayMs = Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647);
const id = setTimeout(() => bumpSnoozeWakeTick((tick) => tick + 1), delayMs);
return () => clearTimeout(id);
}, [nextSnoozeWakeAt]);
const listItems = useMemo<readonly SidebarListItem[]>(() => {
if (!threadListV2Enabled) return listLayout.items;
// Queued offline tasks render above the thread rows (mirrors the
Expand Down
79 changes: 79 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,85 @@ describe("sortThreadsForListV2", () => {
});

describe("buildThreadListV2Items", () => {
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("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: "",
// Minute-floored partition clock vs precise snooze clock.
now: "2026-06-02T00:01:00.000Z",
snoozeNow: "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("keeps snoozed threads visible on environments without the snooze capability", () => {
const layout = buildThreadListV2Items({
threads: [
makeThread({
id: ThreadId.make("snoozed"),
title: "Snoozed",
snoozedUntil: "2026-06-03T09:00:00.000Z",
snoozedAt: "2026-06-01T12:00:00.000Z",
}),
],
environmentId: null,
searchQuery: "",
snoozeEnvironmentIds: new Set(),
now: NOW,
});

expect(layout.items.map((item) => item.thread.id)).toEqual(["snoozed"]);
expect(layout.snoozedCount).toBe(0);
});

it("partitions settled threads into a slim tail with one divider", () => {
const { items } = buildThreadListV2Items({
threads: [
Expand Down
42 changes: 40 additions & 2 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled";
import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import type { EnvironmentId, ProjectId } from "@t3tools/contracts";

Expand Down Expand Up @@ -84,6 +84,13 @@ export interface ThreadListV2Layout {
readonly items: ThreadListV2Item[];
/** Settled threads beyond the render limit (behind "Show more"). */
readonly hiddenSettledCount: number;
/** Snoozed threads hidden from the list (visibility parity with web's
collapsed Snoozed shelf; mobile has no shelf UI yet). */
readonly snoozedCount: number;
/** Soonest wake time among hidden snoozed threads, or null. Callers arm
a timeout at this boundary so the list re-partitions the moment a
snooze expires instead of on the next minute tick. */
readonly nextSnoozeWakeAt: string | null;
}

/**
Expand All @@ -106,13 +113,22 @@ export function buildThreadListV2Items(input: {
other environments never classify as settled — the user could neither
un-settle nor pin them. Absent = no gating (tests). */
readonly settlementEnvironmentIds?: ReadonlySet<EnvironmentId>;
/** Environments whose server supports thread.snooze/unsnooze. Same
contract as settlementEnvironmentIds. */
readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
readonly autoSettleAfterDays?: number;
/** Max settled rows to render; the rest are counted, not built. */
readonly settledLimit?: number;
/** Injectable for tests; defaults to now. */
readonly now?: string;
/** Second-precise clock for snooze classification. Callers pass a
minute-quantized `now` for memoization; snooze wake times are
second-precise, so classifying with the floored minute would hold a
woken thread hidden for up to a minute. Defaults to `now`. */
readonly snoozeNow?: string;
}): ThreadListV2Layout {
const now = input.now ?? new Date().toISOString();
const snoozeNow = input.snoozeNow ?? now;
const autoSettleAfterDays = input.autoSettleAfterDays ?? 3;
const query = input.searchQuery.trim().toLocaleLowerCase();
const projectKeys = input.projectRefs
Expand All @@ -121,6 +137,8 @@ export function buildThreadListV2Items(input: {

const active: EnvironmentThreadShell[] = [];
const settled: EnvironmentThreadShell[] = [];
let snoozedCount = 0;
let nextSnoozeWakeAt: string | null = null;
for (const thread of input.threads) {
// Callers pass live (unarchived) shells; settled threads are among them
// and partition into the tail via effectiveSettled.
Expand All @@ -130,8 +148,23 @@ export function buildThreadListV2Items(input: {
}
if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) continue;
const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true;
const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true;
const changeRequestState =
input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null;
// Visibility parity with web: a snoozed thread leaves the list until it
// wakes (or raises its hand — effectiveSnoozed refuses blocked/failed
// work). Snooze outranks settled classification, same as web.
if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) {
snoozedCount += 1;
if (
thread.snoozedUntil != null &&
(nextSnoozeWakeAt === null ||
parseTimestampMs(thread.snoozedUntil) < parseTimestampMs(nextSnoozeWakeAt))
) {
nextSnoozeWakeAt = thread.snoozedUntil;
}
continue;
Comment thread
cursor[bot] marked this conversation as resolved.
}
if (
supportsSettlement &&
effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
Expand Down Expand Up @@ -168,5 +201,10 @@ export function buildThreadListV2Items(input: {
if (last) {
items[items.length - 1] = { ...last, isLast: true };
}
return { items, hiddenSettledCount: orderedSettled.length - visibleSettled.length };
return {
items,
hiddenSettledCount: orderedSettled.length - visibleSettled.length,
snoozedCount,
nextSnoozeWakeAt,
};
}
2 changes: 2 additions & 0 deletions apps/mobile/src/state/use-thread-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ function threadDetailToShell(
archivedAt: thread.archivedAt,
settledOverride: thread.settledOverride,
settledAt: thread.settledAt,
snoozedUntil: thread.snoozedUntil ?? null,
snoozedAt: thread.snoozedAt ?? null,
Comment thread
cursor[bot] marked this conversation as resolved.
session: thread.session,
latestUserMessageAt: latestUserMessageAt(thread),
hasPendingApprovals: false,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export const make = Effect.gen(function* () {
repositoryIdentity: true,
connectionProbe: true,
threadSettlement: true,
threadSnooze: true,
...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
},
};
Expand Down
34 changes: 34 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
archivedAt: null,
settledOverride: null,
settledAt: null,
snoozedUntil: null,
snoozedAt: null,
latestUserMessageAt: null,
pendingApprovalCount: 0,
pendingUserInputCount: 0,
Expand Down Expand Up @@ -679,6 +681,38 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
return;
}

case "thread.snoozed": {
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
});
if (Option.isNone(existingRow)) {
return;
}
yield* projectionThreadRepository.upsert({
...existingRow.value,
snoozedUntil: event.payload.snoozedUntil,
snoozedAt: event.payload.snoozedAt,
updatedAt: event.payload.updatedAt,
});
return;
}

case "thread.unsnoozed": {
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
});
if (Option.isNone(existingRow)) {
return;
}
yield* projectionThreadRepository.upsert({
...existingRow.value,
snoozedUntil: null,
snoozedAt: null,
updatedAt: event.payload.updatedAt,
});
return;
}

case "thread.meta-updated": {
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
archivedAt: null,
settledOverride: null,
settledAt: null,
snoozedUntil: null,
snoozedAt: null,
deletedAt: null,
messages: [
{
Expand Down Expand Up @@ -422,6 +424,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
archivedAt: null,
settledOverride: null,
settledAt: null,
snoozedUntil: null,
snoozedAt: null,
session: {
threadId: ThreadId.make("thread-1"),
status: "running",
Expand Down
Loading
Loading