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
11 changes: 11 additions & 0 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
} from "@t3tools/client-runtime/state/shell";
import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
import { type EnvironmentId, type SidebarProjectGroupingMode } from "@t3tools/contracts";
// Fork-local: row menus stagger snooze presets by each environment's minutes.
import { DEFAULT_SNOOZE_SPACING_MINUTES } from "@t3tools/contracts/settings";
import { useAtomValue } from "@effect/atom-react";
import { useFocusEffect } from "@react-navigation/native";
import { use, useEffect, useRef, useState, type ComponentProps } from "react";
Expand Down Expand Up @@ -443,6 +445,8 @@ export function HomeScreen(props: HomeScreenProps) {
pinReorderEnvironmentIds,
activeReorderEnvironmentIds,
titleRegenerationEnvironmentIds,
// Fork-local: spacing is set per environment, so rows read their own.
snoozeSpacingMinutesByEnvironment,
} = listEnvironments;
const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId);
const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick);
Expand All @@ -451,6 +455,8 @@ export function HomeScreen(props: HomeScreenProps) {
items: threadListV2Items,
contentOnlyThreadKeys,
olderSettledShelf,
// Fork-local: rows space their presets against other threads' wake times.
snoozeOccupancy,
} = useThreadListV2({
threads: props.threads,
pendingTasks: props.pendingTasks,
Expand Down Expand Up @@ -587,6 +593,11 @@ export function HomeScreen(props: HomeScreenProps) {
pinned={item.item.pinned}
wokeAt={item.item.wokeAt}
snoozePresetMinute={item.snoozePresetMinute ?? ""}
snoozeOccupancy={snoozeOccupancy}
snoozeSpacingMinutes={
snoozeSpacingMinutesByEnvironment.get(thread.environmentId) ??
DEFAULT_SNOOZE_SPACING_MINUTES
}
snoozeWakeLabelText={item.snoozeWakeLabelText}
timeLabel={item.timeLabel}
showTrailingDivider={item.showTrailingDivider}
Expand Down
59 changes: 43 additions & 16 deletions apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ import { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
// Fork-local: the spacing row below needs these bounds and a numeric field.
import {
MAX_SNOOZE_SPACING_MINUTES,
MIN_SNOOZE_SPACING_MINUTES,
} from "@t3tools/contracts/settings";
import { NumberField } from "../../components/ui/NumberField";
import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings";
import { AppText as Text } from "../../components/ui/AppText";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
Expand Down Expand Up @@ -90,6 +96,8 @@ function AutoSettleSettingsRows() {
patch: Partial<AutoSettleSettings> & {
autoResumeLimitedThreads?: boolean;
snoozeLimitedThreads?: boolean;
// Fork-local: spacing writes ride along with the other snooze settings.
snoozeSpacingMinutes?: number;
},
) => {
if (
Expand Down Expand Up @@ -176,22 +184,41 @@ function AutoSettleSettingsRows() {
/>
) : null}
{!projectSelected ? (
<SettingsSection title="Usage limits">
<SettingsSwitchRow
icon="clock"
label="Resume after usage limits"
value={uniformMobileSetting(displayTargets, "autoResumeLimitedThreads")}
disabled={disabled}
onValueChange={(value) => writeToAll({ autoResumeLimitedThreads: value })}
/>
<SettingsSwitchRow
icon="clock"
label="Snooze until limits reset"
value={uniformMobileSetting(displayTargets, "snoozeLimitedThreads")}
disabled={disabled}
onValueChange={(value) => writeToAll({ snoozeLimitedThreads: value })}
/>
</SettingsSection>
<>
<SettingsSection title="Usage limits">
<SettingsSwitchRow
icon="clock"
label="Resume after usage limits"
value={uniformMobileSetting(displayTargets, "autoResumeLimitedThreads")}
disabled={disabled}
onValueChange={(value) => writeToAll({ autoResumeLimitedThreads: value })}
/>
<SettingsSwitchRow
icon="clock"
label="Snooze until limits reset"
value={uniformMobileSetting(displayTargets, "snoozeLimitedThreads")}
disabled={disabled}
onValueChange={(value) => writeToAll({ snoozeLimitedThreads: value })}
/>
</SettingsSection>
{/* Fork-local: spacing applies wherever a snooze can land, not per project. */}
<SettingsSection title="Snoozes">
<View className="flex-row items-center gap-4 px-4 py-4 android:min-h-14 android:py-3">
<Text className="flex-1 text-foreground text-lg android:text-base">
Space snoozes by
</Text>
<NumberField
value={referenceSettings.snoozeSpacingMinutes}
disabled={disabled}
onValueChange={(value) => writeToAll({ snoozeSpacingMinutes: value })}
min={MIN_SNOOZE_SPACING_MINUTES}
max={MAX_SNOOZE_SPACING_MINUTES}
label="Minutes between snoozes"
formatValue={(value) => (value === 0 ? "Off" : `${value} min`)}
/>
</View>
</SettingsSection>
</>
) : null}
<SettingsSection title="Auto-settle">
<SettingsSwitchRow
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/features/settings/settings-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ const SETTINGS_SEARCH_TERMS = {
"auto-settle",
"settle",
"snooze",
// Fork-local: settings search finds the spacing row with these terms.
"snooze spacing",
"stagger snoozes",
"usage limits",
"auto-resume",
"plan mode",
Expand Down
11 changes: 11 additions & 0 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { useActiveNewTask } from "../../state/active-new-task";
import { ThreadUndoNotice } from "../home/ThreadUndoNotice";
import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing";
import { resolveScratchProjectScopeKey } from "@t3tools/client-runtime/state/project-grouping";
// Fork-local: row menus stagger snooze presets by each environment's minutes.
import { DEFAULT_SNOOZE_SPACING_MINUTES } from "@t3tools/contracts/settings";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import type {
EnvironmentProject,
Expand Down Expand Up @@ -290,6 +292,8 @@ function ThreadNavigationSidebarPane(
pinReorderEnvironmentIds,
activeReorderEnvironmentIds,
titleRegenerationEnvironmentIds,
// Fork-local: spacing is set per environment, so rows read their own.
snoozeSpacingMinutesByEnvironment,
} = listEnvironments;
const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId);
const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick);
Expand All @@ -298,6 +302,8 @@ function ThreadNavigationSidebarPane(
items: threadListV2Items,
contentOnlyThreadKeys,
olderSettledShelf,
// Fork-local: rows space their presets against other threads' wake times.
snoozeOccupancy,
} = useThreadListV2({
threads,
pendingTasks,
Expand Down Expand Up @@ -564,6 +570,11 @@ function ThreadNavigationSidebarPane(
pinned={item.item.pinned}
wokeAt={item.item.wokeAt}
snoozePresetMinute={item.snoozePresetMinute ?? ""}
snoozeOccupancy={snoozeOccupancy}
snoozeSpacingMinutes={
snoozeSpacingMinutesByEnvironment.get(thread.environmentId) ??
DEFAULT_SNOOZE_SPACING_MINUTES
}
snoozeWakeLabelText={item.snoozeWakeLabelText}
timeLabel={item.timeLabel}
project={projectByKey.get(scopeKey) ?? null}
Expand Down
34 changes: 29 additions & 5 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
resolveThreadWorkingStartedAt,
} from "@t3tools/client-runtime/state/models";
import { AuthOrchestrationOperateScope, type EnvironmentMachineKind } from "@t3tools/contracts";
import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
import { canSnooze, effectiveSnoozed } 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";
import { Pressable, useWindowDimensions, View } from "react-native";
Expand All @@ -52,10 +52,16 @@ import { useThreadPr } from "../../state/use-thread-pr";
import { useSwipeRowDormant } from "../home/swipe-row-activation";
import { ThreadSwipeable } from "../home/thread-swipe-actions";
import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
// Fork-local: the menu and the click-time resolver share one spaced preset set.
import {
withoutSnoozeOccupant,
type SnoozeOccupancy,
} from "@t3tools/client-runtime/state/snooze-spacing";
import {
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
resolveThreadListV2SnoozeGateExpiryMs,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozePresets,
threadHasUnseenCompletion,
resolveThreadListV2Status,
resolveThreadListV2ProviderDrivers,
Expand Down Expand Up @@ -557,6 +563,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
readonly snoozeWakeLabelText?: string;
readonly timeLabel: string;
readonly snoozePresetMinute: string;
/** Fork-local: wake times other threads hold, and the configured spacing. */
readonly snoozeOccupancy: SnoozeOccupancy;
readonly snoozeSpacingMinutes: number;
readonly project: EnvironmentProject | null;
readonly providers: ReadonlyArray<ThreadListProvider> | undefined;
readonly providerInstance: ThreadRowProviderInstance | null;
Expand Down Expand Up @@ -735,12 +744,25 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
snoozable: canSnooze(thread, { now: snoozeGateNow }),
snoozed: snoozedRow,
});
const snoozePresets = useMemo(
// Fork-local: a thread that woke early holds no slot, so freeing one here would steal another's.
const stillSnoozed = effectiveSnoozed(thread, { now: snoozeGateNow });
const snoozeSpacing = useMemo(
() => ({
occupied: stillSnoozed
? withoutSnoozeOccupant(props.snoozeOccupancy, thread.snoozedUntil)
: props.snoozeOccupancy,
spacingMinutes: props.snoozeSpacingMinutes,
}),
[props.snoozeOccupancy, props.snoozeSpacingMinutes, stillSnoozed, thread.snoozedUntil],
);
// Fork-local: presets are spaced against the other threads' reservations.
const snoozePresets = useMemo(() => {
if (swipeActions.secondary !== "snooze") return [] as const;
// oxlint-disable-next-line react/purity -- The memo recomputes on the snoozePresetMinute tick, so preset times refresh every minute.
() => (swipeActions.secondary === "snooze" ? resolveSnoozePresets(new Date()) : ([] as const)),
const now = new Date();
return resolveThreadListV2SnoozePresets(now, snoozeSpacing);
// oxlint-disable-next-line react/memo-dependencies -- The minute tick refreshes relative snooze times while the row remains mounted.
[props.snoozePresetMinute, swipeActions.secondary],
);
}, [props.snoozePresetMinute, snoozeSpacing, swipeActions.secondary]);
const snoozePresetActions = useMemo<MenuAction[]>(
() => [
...snoozePresets.map((preset) => ({
Expand Down Expand Up @@ -910,6 +932,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
event: nativeEvent.event,
displayedPresets: snoozePresets,
now: new Date(),
spacing: snoozeSpacing,
});
if (snoozeSelection._tag === "selected") {
handleSnooze(snoozeSelection.preset.snoozedUntil);
Expand Down Expand Up @@ -938,6 +961,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
handleUnsnooze,
setCustomSnoozeOpen,
snoozePresets,
snoozeSpacing,
],
);
const primaryAction = useMemo(() => {
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 @@ -35,6 +35,7 @@ import {
buildThreadListV2ListItems,
getThreadListV2OrderedSection,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozePresets,
resolveThreadListV2Status,
resolveThreadListV2WokeAt,
sortThreadsForListV2,
Expand Down Expand Up @@ -74,6 +75,29 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => {
);
}
});

it("dispatches the staggered slot the menu showed", () => {
const selectedAt = new Date(2026, 4, 8, 10, 30);
const spacing = {
occupied: new Map([[selectedAt.getTime() + 60 * 60 * 1_000, 1]]),
spacingMinutes: 5,
};
const displayedPresets = resolveThreadListV2SnoozePresets(selectedAt, spacing);
const selection = resolveThreadListV2SnoozeMenuSelection({
event: "snooze:hour",
displayedPresets,
now: selectedAt,
spacing,
});

expect(selection._tag).toBe("selected");
if (selection._tag === "selected") {
expect(selection.preset).toEqual(displayedPresets.find((preset) => preset.id === "hour"));
expect(selection.preset.snoozedUntil).toBe(
new Date(selectedAt.getTime() + 65 * 60 * 1_000).toISOString(),
);
}
});
});

describe("resolveThreadListV2Status", () => {
Expand Down
22 changes: 21 additions & 1 deletion apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import {
threadWokeAt,
} from "@t3tools/client-runtime/state/thread-settled";
import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled";
// Fork-local: mobile menus space presets the same way the web sidebar does.
import {
spaceSnoozePresets,
type SnoozeSpacing,
} from "@t3tools/client-runtime/state/snooze-spacing";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { resolveThreadProviderStack } from "@t3tools/client-runtime/state/models";
import {
Expand Down Expand Up @@ -72,17 +77,32 @@ export type ThreadListV2Status =
| "ready";
export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze";

// Fork-local: spread presets across free wake slots when spacing is configured.
export function resolveThreadListV2SnoozePresets(
now: Date,
spacing?: SnoozeSpacing,
): ReadonlyArray<SnoozePreset> {
const presets = resolveSnoozePresets(now);
return spacing === undefined
? presets
: spaceSnoozePresets(presets, {
occupied: spacing.occupied,
spacingMinutes: spacing.spacingMinutes,
});
}

export function resolveThreadListV2SnoozeMenuSelection(input: {
readonly event: string;
readonly displayedPresets: ReadonlyArray<SnoozePreset>;
readonly now: Date;
readonly spacing?: SnoozeSpacing;
}):
| { 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 = resolveThreadListV2SnoozePresets(input.now, input.spacing).find(
(candidate) => input.event === `snooze:${candidate.id}`,
);
if (currentPreset) return { _tag: "selected", preset: currentPreset };
Expand Down
18 changes: 17 additions & 1 deletion apps/mobile/src/features/threads/useThreadListV2.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { OlderSettledScope } from "@t3tools/client-runtime/state/threads";
// Fork-local: occupancy for the mobile row snooze menus, from live reservations.
import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
import { selectSnoozeOccupancy } from "@t3tools/client-runtime/state/snooze-spacing";
import type { EnvironmentId } from "@t3tools/contracts";
import { useMemo, useSyncExternalStore } from "react";

Expand Down Expand Up @@ -146,6 +149,18 @@ export function useThreadListV2(input: {
)) &&
(searchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(searchQuery)),
);
// Fork-local: keyed on reservations so unrelated shell updates skip row re-renders.
const reservationKey = threads
.flatMap((thread) =>
effectiveSnoozed(thread, { now: input.now }) ? [thread.snoozedUntil ?? ""] : [],
)
.sort()
.join("\n");
const snoozeOccupancy = useMemo(
// oxlint-disable-next-line react/memo-dependencies -- reservations are the only input that changes the result
() => selectSnoozeOccupancy(threads, { now: input.now }),
[reservationKey],
);
const items = buildThreadListV2ListItems({
items: layout.items,
pendingTasks,
Expand All @@ -164,7 +179,8 @@ export function useThreadListV2(input: {
moveAvailability,
shelfPreferencesLoading: !input.shelfPreferencesLoaded,
});
return { layout, items, contentOnlyThreadKeys, olderSettledShelf };
// Fork-local: rows read occupancy to space their own menu presets.
return { layout, items, contentOnlyThreadKeys, olderSettledShelf, snoozeOccupancy };
}

/** The list's scope per environment: its projects there, or every project. */
Expand Down
Loading
Loading