Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
60 changes: 56 additions & 4 deletions 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 @@ -472,6 +472,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 @@ -493,8 +497,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 @@ -504,20 +518,38 @@ 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);
// snoozeWakeTick must re-arm the timer even when nextSnoozeWakeAt is
// unchanged: after a clamped fire (wake beyond the 32-bit setTimeout
// range) the boundary string is identical and the chain would die.
}, [nextSnoozeWakeAt, snoozeWakeTick]);
const threadListV2Items = threadListV2Layout.items;

const renderV2Item = useCallback(
Expand Down Expand Up @@ -813,11 +845,31 @@ export function HomeScreen(props: HomeScreenProps) {
// Self-contained: v1's listEmpty keys off projectGroups, which ignores the
// v2 project scope, so it can be null (results elsewhere) while this list
// is empty. Search outranks the scope — "No results" names the actionable
// fact when a query is active. Pending tasks render in the header, so the
// list showing them isn't empty in the user's eyes.
// fact when a query is active. Snoozed threads outrank the rest: "No
// threads yet" over an inbox that is merely all-snoozed reads as data
// loss. Pending tasks render in the header, so the list showing them
// isn't empty in the user's eyes.
const v2SnoozedCount = threadListV2Layout.snoozedCount;
const v2ListEmpty =
v2PendingTasks.length > 0 ? null : hasSearchQuery ? (
<EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />
v2SnoozedCount > 0 ? (
// The snoozed threads already passed this search filter: "No
// results" would claim nothing matched when matches are merely
// parked.
<EmptyState
title={
v2SnoozedCount === 1 ? "1 matching thread snoozed" : `All matching threads snoozed`
}
detail={`Threads matching "${props.searchQuery}" are snoozed and return when their wake time passes.`}
/>
) : (
<EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />
)
) : v2SnoozedCount > 0 ? (
<EmptyState
title={v2SnoozedCount === 1 ? "1 thread snoozed" : `${v2SnoozedCount} threads snoozed`}
detail="Snoozed threads return when their wake time passes."
/>
Comment thread
cursor[bot] marked this conversation as resolved.
) : v2ScopedProjectGroup !== null ? (
<EmptyState
title={`No threads in ${v2ScopedProjectGroup.title}`}
Expand Down
55 changes: 50 additions & 5 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,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 @@ -420,29 +424,57 @@ 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);
// snoozeWakeTick must re-arm the timer even when nextSnoozeWakeAt is
// unchanged: after a clamped fire (wake beyond the 32-bit setTimeout
// range) the boundary string is identical and the chain would die.
}, [nextSnoozeWakeAt, snoozeWakeTick]);
const listItems = useMemo<readonly SidebarListItem[]>(() => {
if (!threadListV2Enabled) return listLayout.items;
// Queued offline tasks render above the thread rows (mirrors the
Expand Down Expand Up @@ -930,15 +962,28 @@ function ThreadNavigationSidebarPane(
}),
[filterIcon, filterMenu, props.onOpenSettings],
);
// "No threads yet" over an inbox that is merely all-snoozed reads as
// data loss; name the snoozed threads instead.
const snoozedCount = threadListV2Layout.snoozedCount;
const listEmpty = (
<Text className="px-2 py-4 text-sm text-foreground-muted">
{catalogState.isLoadingConnections
? "Loading threads…"
: props.searchQuery.trim().length > 0
? "No matching threads"
: selectedProjectScope !== null
? `No threads in ${selectedProjectScope.title}`
: "No threads yet"}
? snoozedCount > 0
? // Snoozed matches passed this same search filter — "No
// matching threads" would misreport them as nonexistent.
snoozedCount === 1
? "1 matching thread snoozed"
: "All matching threads snoozed"
: "No matching threads"
: snoozedCount > 0
? snoozedCount === 1
? "1 thread snoozed"
: `${snoozedCount} threads snoozed`
: selectedProjectScope !== null
? `No threads in ${selectedProjectScope.title}`
: "No threads yet"}
</Text>
);

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
Loading
Loading