Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
13 changes: 12 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 @@ -456,8 +456,17 @@ 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 };
// Settled threads are live shells; archived threads keep their original
// "hidden from lists" meaning.
return buildThreadListV2Items({
Expand All @@ -467,6 +476,7 @@ 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`,
});
Expand All @@ -475,6 +485,7 @@ export function HomeScreen(props: HomeScreenProps) {
nowMinute,
settledVisibleCount,
settlementEnvironmentIds,
snoozeEnvironmentIds,
props.searchQuery,
props.selectedEnvironmentId,
props.threads,
Expand Down
13 changes: 12 additions & 1 deletion apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -405,15 +405,25 @@ 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 };
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`,
});
Expand All @@ -424,6 +434,7 @@ function ThreadNavigationSidebarPane(
props.searchQuery,
settledVisibleCount,
settlementEnvironmentIds,
snoozeEnvironmentIds,
threadListV2Enabled,
threads,
selectedProjectScope,
Expand Down
49 changes: 49 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,55 @@ 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("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
19 changes: 17 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,9 @@ 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;
}

/**
Expand All @@ -106,6 +109,9 @@ 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;
Expand All @@ -121,6 +127,7 @@ export function buildThreadListV2Items(input: {

const active: EnvironmentThreadShell[] = [];
const settled: EnvironmentThreadShell[] = [];
let snoozedCount = 0;
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 +137,16 @@ 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 })) {
snoozedCount += 1;
continue;
Comment thread
cursor[bot] marked this conversation as resolved.
}
if (
supportsSettlement &&
effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
Expand Down Expand Up @@ -168,5 +183,5 @@ 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 };
}
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
20 changes: 20 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
archived_at AS "archivedAt",
settled_override AS "settledOverride",
settled_at AS "settledAt",
snoozed_until AS "snoozedUntil",
snoozed_at AS "snoozedAt",
latest_user_message_at AS "latestUserMessageAt",
pending_approval_count AS "pendingApprovalCount",
pending_user_input_count AS "pendingUserInputCount",
Expand Down Expand Up @@ -366,6 +368,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
archived_at AS "archivedAt",
settled_override AS "settledOverride",
settled_at AS "settledAt",
snoozed_until AS "snoozedUntil",
snoozed_at AS "snoozedAt",
latest_user_message_at AS "latestUserMessageAt",
pending_approval_count AS "pendingApprovalCount",
pending_user_input_count AS "pendingUserInputCount",
Expand Down Expand Up @@ -398,6 +402,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
archived_at AS "archivedAt",
settled_override AS "settledOverride",
settled_at AS "settledAt",
snoozed_until AS "snoozedUntil",
snoozed_at AS "snoozedAt",
latest_user_message_at AS "latestUserMessageAt",
pending_approval_count AS "pendingApprovalCount",
pending_user_input_count AS "pendingUserInputCount",
Expand Down Expand Up @@ -762,6 +768,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
archived_at AS "archivedAt",
settled_override AS "settledOverride",
settled_at AS "settledAt",
snoozed_until AS "snoozedUntil",
snoozed_at AS "snoozedAt",
latest_user_message_at AS "latestUserMessageAt",
pending_approval_count AS "pendingApprovalCount",
pending_user_input_count AS "pendingUserInputCount",
Expand Down Expand Up @@ -1196,6 +1204,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
archivedAt: row.archivedAt,
settledOverride: row.settledOverride,
settledAt: row.settledAt,
snoozedUntil: row.snoozedUntil,
snoozedAt: row.snoozedAt,
deletedAt: row.deletedAt,
messages: messagesByThread.get(row.threadId) ?? [],
proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],
Expand Down Expand Up @@ -1396,6 +1406,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
archivedAt: row.archivedAt,
settledOverride: row.settledOverride,
settledAt: row.settledAt,
snoozedUntil: row.snoozedUntil,
snoozedAt: row.snoozedAt,
deletedAt: row.deletedAt,
messages: [],
proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],
Expand Down Expand Up @@ -1527,6 +1539,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
archivedAt: row.archivedAt,
settledOverride: row.settledOverride,
settledAt: row.settledAt,
snoozedUntil: row.snoozedUntil,
snoozedAt: row.snoozedAt,
session: sessionByThread.get(row.threadId) ?? null,
latestUserMessageAt: row.latestUserMessageAt,
hasPendingApprovals: row.pendingApprovalCount > 0,
Expand Down Expand Up @@ -1663,6 +1677,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
archivedAt: row.archivedAt,
settledOverride: row.settledOverride,
settledAt: row.settledAt,
snoozedUntil: row.snoozedUntil,
snoozedAt: row.snoozedAt,
session: sessionByThread.get(row.threadId) ?? null,
latestUserMessageAt: row.latestUserMessageAt,
hasPendingApprovals: row.pendingApprovalCount > 0,
Expand Down Expand Up @@ -1905,6 +1921,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
archivedAt: threadRow.value.archivedAt,
settledOverride: threadRow.value.settledOverride,
settledAt: threadRow.value.settledAt,
snoozedUntil: threadRow.value.snoozedUntil,
snoozedAt: threadRow.value.snoozedAt,
session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null,
latestUserMessageAt: threadRow.value.latestUserMessageAt,
hasPendingApprovals: threadRow.value.pendingApprovalCount > 0,
Expand Down Expand Up @@ -2001,6 +2019,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
archivedAt: threadRow.value.archivedAt,
settledOverride: threadRow.value.settledOverride,
settledAt: threadRow.value.settledAt,
snoozedUntil: threadRow.value.snoozedUntil,
snoozedAt: threadRow.value.snoozedAt,
deletedAt: null,
messages: messageRows.map((row) => {
const message = {
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/orchestration/Schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema,
ThreadUnarchivedPayload as ContractsThreadUnarchivedPayloadSchema,
ThreadUnsettledPayload as ContractsThreadUnsettledPayloadSchema,
ThreadSnoozedPayload as ContractsThreadSnoozedPayloadSchema,
ThreadUnsnoozedPayload as ContractsThreadUnsnoozedPayloadSchema,
ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema,
ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema,
ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema,
Expand Down Expand Up @@ -38,6 +40,8 @@ export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSet
export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema;
export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema;
export const ThreadUnsettledPayload = ContractsThreadUnsettledPayloadSchema;
export const ThreadSnoozedPayload = ContractsThreadSnoozedPayloadSchema;
export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema;

export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema;
export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema;
Expand Down
Loading
Loading