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
2 changes: 2 additions & 0 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,7 @@ export function HomeScreen(props: HomeScreenProps) {
machineByEnvironmentId,
settlementEnvironmentIds,
snoozeEnvironmentIds,
snoozePresetsByEnvironmentId,
pinningEnvironmentIds,
autoSettleOptOutEnvironmentIds,
pinReorderEnvironmentIds,
Expand Down Expand Up @@ -602,6 +603,7 @@ export function HomeScreen(props: HomeScreenProps) {
settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
onSettleThread={handleSettleThread}
snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
snoozePresets={snoozePresetsByEnvironmentId.get(thread.environmentId)}
pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}
autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)}
reorderSupported={
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ function ThreadNavigationSidebarPane(
machineByEnvironmentId,
settlementEnvironmentIds,
snoozeEnvironmentIds,
snoozePresetsByEnvironmentId,
pinningEnvironmentIds,
autoSettleOptOutEnvironmentIds,
pinReorderEnvironmentIds,
Expand Down Expand Up @@ -588,6 +589,7 @@ function ThreadNavigationSidebarPane(
settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
onSettleThread={settleThread}
snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
snoozePresets={snoozePresetsByEnvironmentId.get(thread.environmentId)}
pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}
autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)}
reorderSupported={
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
resolveThreadWorkingStartedAt,
} from "@t3tools/client-runtime/state/models";
import { AuthOrchestrationOperateScope, type EnvironmentMachineKind } from "@t3tools/contracts";
import type { SnoozePresetSetting } from "@t3tools/contracts/settings";
import { canSnooze } from "@t3tools/client-runtime/state/thread-settled";
import type { MenuAction } from "@react-native-menu/menu";
import { memo, useCallback, useMemo, useState, type ComponentProps } from "react";
Expand Down Expand Up @@ -609,6 +610,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
readonly onSetThreadAutoSettle: (thread: EnvironmentThreadShell, enabled: boolean) => void;
readonly settlementSupported: boolean;
readonly snoozeSupported: boolean;
readonly snoozePresets: ReadonlyArray<SnoozePresetSetting> | undefined;
readonly pinningSupported: boolean;
readonly autoSettleOptOutSupported: boolean;
readonly titleRegenerationSupported: boolean;
Expand Down Expand Up @@ -760,6 +762,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const snoozePresets = resolveThreadListV2SnoozePresets({
secondary: swipeActions.secondary,
now: props.snoozePresetMinute || snoozeGateNow,
presets: props.snoozePresets,
});
const snoozePresetActions = useMemo<MenuAction[]>(
() => [
Expand Down Expand Up @@ -930,6 +933,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
event: nativeEvent.event,
displayedPresets: snoozePresets,
now: new Date(),
presets: props.snoozePresets,
});
if (snoozeSelection._tag === "selected") {
handleSnooze(snoozeSelection.preset.snoozedUntil);
Expand Down Expand Up @@ -958,6 +962,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
handleUnsnooze,
setCustomSnoozeOpen,
snoozePresets,
props.snoozePresets,
],
);
const primaryAction = useMemo(() => {
Expand Down
20 changes: 13 additions & 7 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,19 +59,21 @@ function makeThread(
const NOW = "2026-06-02T00:00:00.000Z";

describe("resolveThreadListV2SnoozeMenuSelection", () => {
it("recomputes presets that remain available instead of using old timestamps", () => {
const displayedPresets = resolveSnoozePresets(new Date(2026, 4, 8, 10));
it("recomputes the environment's presets instead of using old timestamps", () => {
const presets = [{ kind: "after", minutes: 360 }] as const;
const displayedPresets = resolveSnoozePresets(new Date(2026, 4, 8, 10), { presets });
const selectedAt = new Date(2026, 4, 8, 10, 30);
const selection = resolveThreadListV2SnoozeMenuSelection({
event: "snooze:hour",
event: "snooze:after:360",
displayedPresets,
now: selectedAt,
presets,
});

expect(selection._tag).toBe("selected");
if (selection._tag === "selected") {
expect(selection.preset.snoozedUntil).toBe(
new Date(selectedAt.getTime() + 60 * 60 * 1_000).toISOString(),
new Date(selectedAt.getTime() + 6 * 60 * 60 * 1_000).toISOString(),
);
}
});
Expand All @@ -83,13 +85,17 @@ describe("resolveThreadListV2SnoozePresets", () => {
vi.useFakeTimers({ now: new Date(2026, 4, 8, 10) });
const laterMinute = new Date(2026, 4, 8, 19, 15).toISOString();

const presets = resolveThreadListV2SnoozePresets({ secondary: "snooze", now: laterMinute });
const presets = resolveThreadListV2SnoozePresets({
secondary: "snooze",
now: laterMinute,
presets: undefined,
});

expect(presets.find((preset) => preset.id === "hour")?.snoozedUntil).toBe(
expect(presets.find((preset) => preset.id === "after:60")?.snoozedUntil).toBe(
new Date(2026, 4, 8, 20, 15).toISOString(),
);
// The evening preset is gone by 19:15; a reading from 10:00 would still offer it.
expect(presets.map((preset) => preset.id)).not.toContain("evening");
expect(presets.map((preset) => preset.id)).not.toContain("at:today:18:00");
});
});

Expand Down
32 changes: 18 additions & 14 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
canSnooze,
effectiveSnoozed,
hasQueuedTurnStart,
pickSnoozePreset,
QUEUED_TURN_START_GRACE_MS,
resolveSnoozePresets,
snoozeWakeLabel,
Expand All @@ -28,6 +29,7 @@ import {
sortSettledThreads,
} from "@t3tools/client-runtime/state/thread-sort";
import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
import type { SnoozePresetSetting } from "@t3tools/contracts/settings";

import type { ThreadListProvider } from "../../state/thread-list-environments";
import type { ThreadMoveAvailability } from "./threadOrder";
Expand Down Expand Up @@ -80,32 +82,34 @@ export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooz
export function resolveThreadListV2SnoozePresets(input: {
readonly secondary: "snooze" | null;
readonly now: string;
readonly presets: ReadonlyArray<SnoozePresetSetting> | undefined;
}): ReadonlyArray<SnoozePreset> {
return input.secondary === "snooze" ? resolveSnoozePresets(new Date(input.now)) : [];
return input.secondary === "snooze"
? resolveSnoozePresets(new Date(input.now), presetOptions(input.presets))
: [];
}

function presetOptions(presets: ReadonlyArray<SnoozePresetSetting> | undefined) {
return presets ? { presets } : {};
}

export function resolveThreadListV2SnoozeMenuSelection(input: {
readonly event: string;
readonly displayedPresets: ReadonlyArray<SnoozePreset>;
readonly now: Date;
readonly presets: ReadonlyArray<SnoozePresetSetting> | undefined;
}):
| { 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(
(candidate) => input.event === `snooze:${candidate.id}`,
);
if (currentPreset) return { _tag: "selected", preset: currentPreset };

const displayedPreset = input.displayedPresets.find(
(candidate) => input.event === `snooze:${candidate.id}`,
);
if (displayedPreset && Date.parse(displayedPreset.snoozedUntil) > input.now.getTime()) {
return { _tag: "selected", preset: displayedPreset };
}
return { _tag: "expired" };
const preset = pickSnoozePreset({
shown: input.displayedPresets,
id: input.event.slice("snooze:".length),
now: input.now,
...presetOptions(input.presets),
});
return preset ? { _tag: "selected", preset } : { _tag: "expired" };
}

export function resolveThreadListV2SwipeActions(input: {
Expand Down
10 changes: 9 additions & 1 deletion apps/mobile/src/state/thread-list-environments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ function selectEnvironment(config: ServerConfig) {
),
machineKind: resolveEnvironmentMachineKind(config),
capabilities: config.environment.capabilities,
snoozePresets: config.settings.snoozePresets,
};
}

Expand Down Expand Up @@ -71,8 +72,10 @@ 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, ListEnvironment["snoozePresets"]>();
for (const [id, { providers, machineKind, capabilities, snoozePresets }] of environments) {
providersByEnvironmentId.set(id, providers);
snoozePresetsByEnvironmentId.set(id, snoozePresets);
machineByEnvironmentId.set(id, machineKind);
if (capabilities.threadSettlement === true) settlementEnvironmentIds.add(id);
if (capabilities.threadSnooze === true) snoozeEnvironmentIds.add(id);
Expand All @@ -92,6 +95,7 @@ function collectEnvironments(environments: ReadonlyMap<EnvironmentId, ListEnviro
pinReorderEnvironmentIds,
activeReorderEnvironmentIds,
titleRegenerationEnvironmentIds,
snoozePresetsByEnvironmentId,
};
}

Expand All @@ -110,10 +114,14 @@ export function createThreadListEnvironmentsAtom(
if (prior && sameProviders(prior.providers, selected.providers)) {
selected.providers = prior.providers;
}
if (prior && JSON.stringify(prior.snoozePresets) === JSON.stringify(selected.snoozePresets)) {
selected.snoozePresets = prior.snoozePresets;
}
const unchanged =
prior &&
prior.providers === selected.providers &&
prior.machineKind === selected.machineKind &&
prior.snoozePresets === selected.snoozePresets &&
capabilityKeys.every(
(key) => (prior.capabilities[key] === true) === (selected.capabilities[key] === true),
);
Expand Down
57 changes: 41 additions & 16 deletions apps/web/src/components/Sidebar.snooze.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,57 @@
import type { EnvironmentId } from "@t3tools/contracts";
import type { TimestampFormat } from "@t3tools/contracts/settings";
import {
pickSnoozePreset as pickSharedSnoozePreset,
resolveSnoozePresets as resolveSharedSnoozePresets,
snoozeWakeLabel,
type SnoozePreset,
} from "@t3tools/client-runtime/state/thread-settled";

import { formatShortTimestamp, parseTimestampDate } from "../timestampFormat";
import { appAtomRegistry } from "../rpc/atomRegistry";
import { serverEnvironment } from "../state/server";
import { formatShortTimestamp } from "../timestampFormat";
import { toastManager } from "./ui/toast";

export { snoozeWakeLabel, type SnoozePreset };

function timeOfDayLabel(date: Date, timestampFormat: TimestampFormat): string {
return formatShortTimestamp(date.toISOString(), timestampFormat);
}

/** The snooze menu for a thread, from its environment's presets, read when the menu opens. */
export function resolveSnoozePresets(
now: Date,
timestampFormat: TimestampFormat,
environmentId: EnvironmentId,
): 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, snoozeOptions(timestampFormat, environmentId));
}

/** A shown menu choice as picked; see the shared `pickSnoozePreset`. */
export function pickSnoozePreset(
shown: ReadonlyArray<SnoozePreset>,
id: string,
timestampFormat: TimestampFormat,
environmentId: EnvironmentId,
): SnoozePreset | undefined {
const picked = pickSharedSnoozePreset({
shown,
id,
now: new Date(),
...snoozeOptions(timestampFormat, environmentId),
});
if (!picked) {
toastManager.add({
type: "warning",
title: "Could not snooze thread",
description: "That snooze time has passed. Choose another time.",
});
}
return picked;
}

function snoozeOptions(timestampFormat: TimestampFormat, environmentId: EnvironmentId) {
const presets = appAtomRegistry.get(
serverEnvironment.settingsValueAtom(environmentId),
)?.snoozePresets;
return {
...(presets ? { presets } : {}),
formatTime: (date: Date) => formatShortTimestamp(date.toISOString(), timestampFormat),
};
}
37 changes: 31 additions & 6 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,12 @@ import {
} from "./Sidebar.drag";
import { SidebarPointerSensor } from "./Sidebar.pointer";
import { createSidebarListMotion } from "./Sidebar.motion";
import { resolveSnoozePresets, snoozeWakeLabel, type SnoozePreset } from "./Sidebar.snooze";
import {
pickSnoozePreset,
resolveSnoozePresets,
snoozeWakeLabel,
type SnoozePreset,
} from "./Sidebar.snooze";
import { ProjectFavicon } from "./ProjectFavicon";
import { deriveProviderEntriesByEnvironment } from "../providerInstances";
import { useStableCallback } from "../hooks/useStableCallback";
Expand Down Expand Up @@ -2123,8 +2128,14 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
const unpinMenuItem = buildBulkUnpinContextMenuItem({
pinnedCount: pinnedSelectedThreads.length,
});
// Presets sync across environments, so the first selected thread's stand for all.
// oxlint-disable-next-line react/purity -- runs in an event handler (oxc#27309)
const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat);
const menuOpenedAt = new Date();
const snoozePresets = resolveSnoozePresets(
menuOpenedAt,
timestampFormat,
selectedThreads[0]!.environmentId,
);
const clicked = await settlePromise(() =>
api.contextMenu.show(
[
Expand All @@ -2151,7 +2162,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
{
id: "snooze:custom",
label: "Custom…",
separatorBefore: true,
separatorBefore: snoozePresets.length > 0,
disabled: !canOperateThreads(selectedThreads),
},
],
Expand Down Expand Up @@ -2199,7 +2210,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
const preset =
clicked.value === "snooze:custom"
? await requestCustomSnooze()
: snoozePresets.find((candidate) => `snooze:${candidate.id}` === clicked.value);
: pickSnoozePreset(
snoozePresets,
clicked.value.slice("snooze:".length),
timestampFormat,
selectedThreads[0]!.environmentId,
);
if (preset) {
const coSnoozingKeys = new Set(threadKeys);
clearSelection();
Expand Down Expand Up @@ -2416,7 +2432,11 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
const isSettled = settledThreadKeysRef.current.has(threadKey);
const isSnoozed = snoozedThreadKeysRef.current.has(threadKey);
const isPinned = thread.pinnedAt != null;
const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat);
const snoozePresets = resolveSnoozePresets(
new Date(),
timestampFormat,
thread.environmentId,
);
const threadProjectGroup =
projectGroupsRef.current.find((project) =>
project.memberProjectRefs.some(
Expand Down Expand Up @@ -2467,7 +2487,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
const preset =
clicked.value === "snooze:custom"
? await requestCustomSnooze()
: snoozePresets.find((candidate) => `snooze:${candidate.id}` === clicked.value);
: pickSnoozePreset(
snoozePresets,
clicked.value.slice("snooze:".length),
timestampFormat,
thread.environmentId,
);
if (preset) attemptSnooze(threadRef, preset);
return;
}
Expand Down
Loading
Loading