Skip to content
Closed
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
6 changes: 6 additions & 0 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { useThreadSearch } from "../../state/queries";
import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts";
import { usePendingThreadOrder } from "../../state/thread-order";
import { threadListEnvironmentsAtom } from "../../state/server";
import { NO_SNOOZE_PRESETS } from "../../state/thread-list-environments";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import { useQueuedThreadKeys } from "../../state/use-thread-outbox";
import {
Expand Down Expand Up @@ -501,6 +502,7 @@ export function HomeScreen(props: HomeScreenProps) {
pinReorderEnvironmentIds,
activeReorderEnvironmentIds,
titleRegenerationEnvironmentIds,
snoozePresetsByEnvironmentId,
} = listEnvironments;
const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId);
const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick);
Expand Down Expand Up @@ -722,6 +724,9 @@ export function HomeScreen(props: HomeScreenProps) {
snoozed={item.item.snoozed}
pinned={item.item.pinned}
snoozePresetMinute={item.snoozePresetMinute ?? ""}
savedSnoozePresets={
snoozePresetsByEnvironmentId.get(thread.environmentId) ?? NO_SNOOZE_PRESETS
}
snoozeWakeLabelText={item.snoozeWakeLabelText}
timeLabel={item.timeLabel}
showTrailingDivider={item.showTrailingDivider}
Expand Down Expand Up @@ -785,6 +790,7 @@ export function HomeScreen(props: HomeScreenProps) {
handleRegenerateThreadTitle,
handleRenameThread,
handleSettleThread,
snoozePresetsByEnvironmentId,
handleSnoozeThread,
handleUnpinThread,
handleUnsnoozeThread,
Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { useThreadSearch } from "../../state/queries";
import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";
import { usePendingThreadOrder } from "../../state/thread-order";
import { threadListEnvironmentsAtom } from "../../state/server";
import { NO_SNOOZE_PRESETS } from "../../state/thread-list-environments";
import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
import { useQueuedThreadKeys } from "../../state/use-thread-outbox";
import { useWorkspaceState } from "../../state/workspace";
Expand Down Expand Up @@ -324,6 +325,7 @@ function ThreadNavigationSidebarPane(
pinReorderEnvironmentIds,
activeReorderEnvironmentIds,
titleRegenerationEnvironmentIds,
snoozePresetsByEnvironmentId,
} = listEnvironments;
const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId);
const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick);
Expand Down Expand Up @@ -697,6 +699,9 @@ function ThreadNavigationSidebarPane(
snoozed={item.item.snoozed}
pinned={item.item.pinned}
snoozePresetMinute={item.snoozePresetMinute ?? ""}
savedSnoozePresets={
snoozePresetsByEnvironmentId.get(thread.environmentId) ?? NO_SNOOZE_PRESETS
}
snoozeWakeLabelText={item.snoozeWakeLabelText}
timeLabel={item.timeLabel}
project={projectByKey.get(scopeKey) ?? null}
Expand Down Expand Up @@ -827,6 +832,7 @@ function ThreadNavigationSidebarPane(
showMoreSettled,
sidebarScrollGesture,
snoozeEnvironmentIds,
snoozePresetsByEnvironmentId,
snoozeThread,
toggleSettledShelf,
toggleSnoozedShelf,
Expand Down
13 changes: 10 additions & 3 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type {
EnvironmentThreadShell,
} from "@t3tools/client-runtime/state/shell";
import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
import type { EnvironmentMachineKind } from "@t3tools/contracts";
import type { EnvironmentMachineKind, SnoozePresetRule } from "@t3tools/contracts";
import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
import type { MenuAction } from "@react-native-menu/menu";
import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
Expand Down Expand Up @@ -471,6 +471,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
row's menu offers snooze presets, so those menus refresh while mounted
without invalidating every other row. */
readonly snoozePresetMinute: string;
/** The thread environment's saved presets, listed after the built-in snooze choices. */
readonly savedSnoozePresets: ReadonlyArray<SnoozePresetRule>;
readonly project: EnvironmentProject | null;
readonly projectTitle?: string;
/** Keep the environment's provider array stable across unrelated list updates. */
Expand Down Expand Up @@ -652,8 +654,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
snoozed: snoozedRow,
});
const snoozePresets = useMemo(
() => (swipeActions.secondary === "snooze" ? resolveSnoozePresets(new Date()) : ([] as const)),
[props.snoozePresetMinute, swipeActions.secondary],
() =>
swipeActions.secondary === "snooze"
? resolveSnoozePresets(new Date(), { saved: props.savedSnoozePresets })
: ([] as const),
[props.savedSnoozePresets, props.snoozePresetMinute, swipeActions.secondary],
);
const snoozePresetActions = useMemo<MenuAction[]>(
() => [
Expand Down Expand Up @@ -827,6 +832,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({
event: nativeEvent.event,
displayedPresets: snoozePresets,
savedPresets: props.savedSnoozePresets,
now: new Date(),
});
if (snoozeSelection._tag === "selected") {
Expand All @@ -851,6 +857,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
handleUnpin,
handleUnsettle,
handleUnsnooze,
props.savedSnoozePresets,
setCustomSnoozeOpen,
snoozePresets,
],
Expand Down
24 changes: 24 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => {
const selection = resolveThreadListV2SnoozeMenuSelection({
event: "snooze:evening",
displayedPresets,
savedPresets: [],
now: selectedAt,
});

Expand All @@ -87,6 +88,7 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => {
resolveThreadListV2SnoozeMenuSelection({
event: "snooze:evening",
displayedPresets,
savedPresets: [],
now: new Date(2026, 4, 8, 18, 0, 1),
}),
).toEqual({ _tag: "expired" });
Expand All @@ -98,6 +100,7 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => {
const selection = resolveThreadListV2SnoozeMenuSelection({
event: "snooze:hour",
displayedPresets,
savedPresets: [],
now: selectedAt,
});

Expand All @@ -108,6 +111,27 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => {
);
}
});

it("recomputes a saved preset from the tap time", () => {
const savedPresets = [{ kind: "delay", amount: 3, unit: "days" }] as const;
const displayedPresets = resolveSnoozePresets(new Date(2026, 4, 8, 10), {
saved: savedPresets,
});
const selectedAt = new Date(2026, 4, 8, 10, 30);
const selection = resolveThreadListV2SnoozeMenuSelection({
event: "snooze:saved:delay:4320m",
displayedPresets,
savedPresets,
now: selectedAt,
});

expect(selection._tag).toBe("selected");
if (selection._tag === "selected") {
expect(selection.preset.snoozedUntil).toBe(
new Date(selectedAt.getTime() + 3 * 24 * 60 * 60 * 1_000).toISOString(),
);
}
});
});

describe("resolveThreadListV2Status", () => {
Expand Down
5 changes: 3 additions & 2 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
sortPinnedThreadsByOrderKey,
sortSettledThreads,
} from "@t3tools/client-runtime/state/thread-sort";
import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
import type { EnvironmentId, ProjectId, SnoozePresetRule } from "@t3tools/contracts";

import type { ThreadListProvider } from "../../state/thread-list-environments";
import type { ThreadMoveAvailability } from "./threadOrder";
Expand Down Expand Up @@ -89,14 +89,15 @@ export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooz
export function resolveThreadListV2SnoozeMenuSelection(input: {
readonly event: string;
readonly displayedPresets: ReadonlyArray<SnoozePreset>;
readonly savedPresets: ReadonlyArray<SnoozePresetRule>;
readonly now: Date;
}):
| { readonly _tag: "selected"; readonly preset: SnoozePreset }
| { readonly _tag: "expired" }
| { readonly _tag: "not-snooze" } {
if (!input.event.startsWith("snooze:")) return { _tag: "not-snooze" };

const currentPreset = resolveSnoozePresets(input.now).find(
const currentPreset = resolveSnoozePresets(input.now, { saved: input.savedPresets }).find(
(candidate) => input.event === `snooze:${candidate.id}`,
);
if (currentPreset) return { _tag: "selected", preset: currentPreset };
Expand Down
20 changes: 20 additions & 0 deletions apps/mobile/src/state/thread-list-environments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,26 @@ describe("thread list environment projection", () => {
}
});

it("shares each environment's snooze presets, stable until they change", () => {
const h = harness();
try {
const threeDays = { kind: "delay", amount: 3, unit: "days" } as const;
expect(h.read().snoozePresetsByEnvironmentId.get(ID)).toBeUndefined();
h.write({ ...config, settings: { ...config.settings, snoozePresets: [threeDays] } });
const withPreset = h.read();
expect(withPreset.snoozePresetsByEnvironmentId.get(ID)).toEqual([threeDays]);
expect(withPreset.snoozePresetsByEnvironmentId.get(OTHER_ID)).toBeUndefined();
// A fresh settings broadcast with the same presets keeps row props stable.
h.write({ ...config, settings: { ...config.settings, snoozePresets: [{ ...threeDays }] } });
expect(h.read()).toBe(withPreset);
h.write(config);
expect(h.read().snoozePresetsByEnvironmentId.get(ID)).toBeUndefined();
expect(h.notifications().list).toBe(2);
} finally {
h.registry.dispose();
}
});

it("restores detected machine kind after removing an override", () => {
const h = harness();
try {
Expand Down
27 changes: 25 additions & 2 deletions apps/mobile/src/state/thread-list-environments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import {
type EnvironmentMachineKind,
type ServerConfig,
type ServerProvider,
type SnoozePresetRule,
} from "@t3tools/contracts";
import { snoozePresetRuleKey } from "@t3tools/client-runtime/state/thread-settled";
import { Atom } from "effect/unstable/reactivity";

export type ThreadListProvider = Pick<
Expand All @@ -22,6 +24,9 @@ const capabilityKeys = [
"threadTitleRegeneration",
] as const;

/** Rows fall back to this when an environment has no saved snooze presets. */
export const NO_SNOOZE_PRESETS: ReadonlyArray<SnoozePresetRule> = [];

function selectEnvironment(config: ServerConfig) {
return {
providers: config.providers.map(
Expand All @@ -35,9 +40,20 @@ function selectEnvironment(config: ServerConfig) {
),
machineKind: resolveEnvironmentMachineKind(config),
capabilities: config.environment.capabilities,
snoozePresets: config.settings.snoozePresets ?? NO_SNOOZE_PRESETS,
};
}

function sameSnoozePresets(
left: ReadonlyArray<SnoozePresetRule>,
right: ReadonlyArray<SnoozePresetRule>,
) {
return (
left.length === right.length &&
left.every((rule, index) => snoozePresetRuleKey(rule) === snoozePresetRuleKey(right[index]!))
);
}

type ListEnvironment = ReturnType<typeof selectEnvironment>;

function sameProviders(
Expand Down Expand Up @@ -69,9 +85,11 @@ function collectEnvironments(environments: ReadonlyMap<EnvironmentId, ListEnviro
const pinReorderEnvironmentIds = new Set<EnvironmentId>();
const activeReorderEnvironmentIds = new Set<EnvironmentId>();
const titleRegenerationEnvironmentIds = new Set<EnvironmentId>();
for (const [id, { providers, machineKind, capabilities }] of environments) {
const snoozePresetsByEnvironmentId = new Map<EnvironmentId, ReadonlyArray<SnoozePresetRule>>();
for (const [id, { providers, machineKind, capabilities, snoozePresets }] of environments) {
providersByEnvironmentId.set(id, providers);
machineByEnvironmentId.set(id, machineKind);
if (snoozePresets.length > 0) snoozePresetsByEnvironmentId.set(id, snoozePresets);
if (capabilities.threadSettlement === true) settlementEnvironmentIds.add(id);
if (capabilities.threadSnooze === true) snoozeEnvironmentIds.add(id);
if (capabilities.threadAutoSettleOptOut === true) autoSettleOptOutEnvironmentIds.add(id);
Expand All @@ -90,10 +108,11 @@ function collectEnvironments(environments: ReadonlyMap<EnvironmentId, ListEnviro
pinReorderEnvironmentIds,
activeReorderEnvironmentIds,
titleRegenerationEnvironmentIds,
snoozePresetsByEnvironmentId,
};
}

/** Provider freshness and model catalogs do not affect the navigation lists. */
/** Provider freshness, model catalogs, and unrelated settings do not affect the navigation lists. */
export function createThreadListEnvironmentsAtom(
configsAtom: Atom.Atom<ReadonlyMap<EnvironmentId, ServerConfig>>,
) {
Expand All @@ -109,9 +128,13 @@ export function createThreadListEnvironmentsAtom(
if (prior && sameProviders(prior.providers, selected.providers)) {
selected.providers = prior.providers;
}
if (prior && sameSnoozePresets(prior.snoozePresets, selected.snoozePresets)) {
selected.snoozePresets = prior.snoozePresets;
}
const unchanged =
prior &&
prior.providers === selected.providers &&
prior.snoozePresets === selected.snoozePresets &&
prior.machineKind === selected.machineKind &&
capabilityKeys.every(
(key) => (prior.capabilities[key] === true) === (selected.capabilities[key] === true),
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 @@ -232,6 +232,7 @@ export const make = Effect.gen(function* () {
threadRestartContinuation: true,
projectSettingsOverrides: true,
threadSnooze: true,
snoozePresets: true,
environmentThemes: true,
usageLimitSources: true,
usagePriceOverrides: true,
Expand Down
25 changes: 25 additions & 0 deletions apps/server/src/serverSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,31 @@ it.layer(NodeServices.layer)("server settings", (it) => {
).pipe(Effect.provide(makeServerSettingsLayer())),
);

it.effect("persists snooze presets and replaces the list on update", () =>
Effect.scoped(
Effect.gen(function* () {
const serverConfig = yield* ServerConfig.ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;
const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
const changes = yield* serverSettings.subscribeChanges;
const readPersisted = fileSystem
.readFileString(serverConfig.settingsPath)
.pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings))));
const threeDays = { kind: "delay", amount: 3, unit: "days" } as const;
const fridayMorning = { kind: "weekday", weekday: 5, time: "09:00" } as const;

yield* serverSettings.updateSettings({ snoozePresets: [threeDays, fridayMorning] });
const change = Option.getOrUndefined(yield* Stream.runHead(changes));
assert.deepStrictEqual(change?.snoozePresets, [threeDays, fridayMorning]);
assert.deepStrictEqual((yield* readPersisted).snoozePresets, [threeDays, fridayMorning]);

// Removing a preset sends the shorter list; it must not merge by index.
yield* serverSettings.updateSettings({ snoozePresets: [fridayMorning] });
assert.deepStrictEqual((yield* readPersisted).snoozePresets, [fridayMorning]);
}),
).pipe(Effect.provide(makeServerSettingsLayer())),
);

it.effect("persists and broadcasts thread settlement settings", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/components/Sidebar.snooze.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,16 @@ describe("resolveSnoozePresets", () => {
expect(twelveHour.find((preset) => preset.id === "evening")!.whenLabel).toMatch(/PM/i);
expect(twentyFourHour.find((preset) => preset.id === "evening")!.whenLabel).toBe("18:00");
});

it("lists saved presets after the built-ins in the selected clock format", () => {
const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10), "24-hour", [
{ kind: "weekday", weekday: 5, time: "16:30" },
]);
const friday = presets.at(-1)!;
expect(friday.id).toBe("saved:weekday:5:16:30");
expect(friday.label).toBe("Friday");
expect(friday.whenLabel).toMatch(/16:30$/);
});
});

describe("snoozeWakeDescription", () => {
Expand Down
18 changes: 6 additions & 12 deletions apps/web/src/components/Sidebar.snooze.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { TimestampFormat } from "@t3tools/contracts/settings";
import type { SnoozePresetRule, TimestampFormat } from "@t3tools/contracts/settings";
import {
resolveSnoozePresets as resolveSharedSnoozePresets,
snoozeWakeLabel,
Expand All @@ -15,21 +15,15 @@ function timeOfDayLabel(date: Date, timestampFormat: TimestampFormat): string {
return formatShortTimestamp(date.toISOString(), timestampFormat);
}

/** Built-in and saved Snooze menu choices, with times in the user's clock format. */
export function resolveSnoozePresets(
now: Date,
timestampFormat: TimestampFormat,
saved: ReadonlyArray<SnoozePresetRule> = [],
): ReadonlyArray<SnoozePreset> {
return resolveSharedSnoozePresets(now).map((preset) => {
const wake = parseTimestampDate(preset.snoozedUntil);
if (wake === null) return preset;
const time = timeOfDayLabel(wake, timestampFormat);
return {
...preset,
whenLabel:
preset.id === "next-week"
? `${wake.toLocaleDateString(undefined, { weekday: "short" })} ${time}`
: time,
};
return resolveSharedSnoozePresets(now, {
saved,
formatTime: (date) => timeOfDayLabel(date, timestampFormat),
});
}

Expand Down
Loading
Loading