// @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels. import type { OrchestrationThreadShell } from "@t3tools/contracts"; /** * A queued turn start lives for at most this long: session adoption takes * seconds, so a user message still unadopted after the grace window is a * failed start (or stale data — shells from older servers can carry user * messages with no latestTurn at all), not pending work. Without this bound * such threads would be permanently unsettleable. */ export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; const DAY_MS = 24 * 60 * 60 * 1_000; /** * A user message no turn has picked up yet: the turn.start command was * dispatched (message-sent + turn-start-requested) but no session has * adopted it, so `session` is still null and the pending work is invisible * to the session-status checks. Detectable as a user message strictly newer * than every timestamp on the latest turn — on adoption the new turn's * requestedAt equals the message time, clearing the condition — and only * within the adoption grace window. */ export function hasQueuedTurnStart( shell: Pick, options: { readonly now: string }, ): boolean { if (shell.latestUserMessageAt == null) return false; // A failed session start clears the queued state: the failure is already // visible (status edge / error). if (shell.session?.status === "error") return false; const messageAt = Date.parse(shell.latestUserMessageAt); if (Number.isNaN(messageAt)) return false; const nowMs = Date.parse(options.now); if (Number.isNaN(nowMs)) return false; // Bounded on both sides: message timestamps originate on whichever device // sent the message, so a clock ahead of this one yields a negative age // that would otherwise hold the queued state for the whole skew. Mirrors // the decider's guard. if (Math.abs(nowMs - messageAt) > QUEUED_TURN_START_GRACE_MS) return false; const turn = shell.latestTurn; if (turn === null) return true; return [turn.requestedAt, turn.startedAt, turn.completedAt].every( (candidate) => candidate == null || Date.parse(candidate) < messageAt, ); } /** * The snooze lifecycle fields plus everything needed to detect a raised * hand. Snooze is an overlay on the active state: a snoozed thread stays * "active" in the data model and is only suppressed from the inbox until * its wake time passes or the thread demands attention. */ export type ThreadSnoozeShell = Pick< OrchestrationThreadShell, | "snoozedUntil" | "snoozedAt" | "hasPendingApprovals" | "hasPendingUserInput" | "session" | "latestTurn" >; /** * A snoozed thread "raises its hand" when something happens that outranks * the user's snooze: the agent is blocked on them (approval / user input), * the session failed, or a run completed after the snooze was set — the * v1 taste of event-based snooze ("something happened" wakes early). * Raising a hand never clears the server-side snooze fields; it only stops * the thread from classifying as snoozed. */ export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean { if (shell.hasPendingApprovals || shell.hasPendingUserInput) return true; // Only a FRESH failure raises the hand: a thread snoozed while already // failed stays snoozed — that snooze was the user saying "I saw it, not // now". session.updatedAt stamps the status edge, so an error newer than // the snooze is new information. if ( shell.session?.status === "error" && (shell.snoozedAt == null || Date.parse(shell.session.updatedAt) > Date.parse(shell.snoozedAt)) ) { return true; } if ( shell.snoozedAt != null && shell.latestTurn?.state === "completed" && shell.latestTurn.completedAt != null && Date.parse(shell.latestTurn.completedAt) > Date.parse(shell.snoozedAt) ) { return true; } return false; } /** * A thread may be snoozed unless the agent is blocked on the user: hiding a * pending approval or user-input request defeats the request, and a queued * turn start (a message no turn has adopted yet) is invisible pending work * the same way it is for settle. A running session IS snoozable — snooze * only affects visibility, never the agent. Client-side twin of the server * invariants so the UI can reject before a round trip. */ export function canSnooze( shell: Pick< OrchestrationThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "latestUserMessageAt" | "latestTurn" | "session" >, options: { readonly now: string }, ): boolean { if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false; if (hasQueuedTurnStart(shell, options)) return false; return true; } /** * Snoozed resolution: hidden from the inbox while the wake time is in the * future and the thread has not raised its hand. Timer wakes are derived — * no server event fires when snoozedUntil passes; the stale fields simply * stop classifying as snoozed (and feed the woke indicator until the user * visits or re-engages). */ export function effectiveSnoozed( shell: ThreadSnoozeShell, options: { readonly now: string }, ): boolean { if (shell.snoozedUntil == null) return false; const wakeAtMs = Date.parse(shell.snoozedUntil); // Malformed data never hides a thread. if (Number.isNaN(wakeAtMs)) return false; if (wakeAtMs <= Date.parse(options.now)) return false; return !threadRaisedHandWhileSnoozed(shell); } /** * When a previously-snoozed thread woke, or null if it never snoozed / is * still snoozed. Used for the "Woke" indicator: the thread reappears in its * original sort position (the inbox sort is deliberately static), so the * wake signal has to carry the weight. Compare against the client's * lastVisitedAt — visiting clears the indicator like it clears unread. * * Timer wakes report the wake time itself; raised-hand wakes report the * triggering timestamp so a visit BEFORE the early wake doesn't suppress * the indicator. */ export function threadWokeAt( shell: ThreadSnoozeShell, options: { readonly now: string }, ): string | null { if (shell.snoozedUntil == null) return null; const wakeAtMs = Date.parse(shell.snoozedUntil); if (Number.isNaN(wakeAtMs)) return null; // An early hand-raise wake stays authoritative even after the scheduled // wake time passes: reporting snoozedUntil then would resurface a Woke // indicator the user already cleared by visiting (snoozedUntil is newer // than that visit's lastVisitedAt). if (threadRaisedHandWhileSnoozed(shell)) { if ( shell.snoozedAt != null && shell.latestTurn?.state === "completed" && shell.latestTurn.completedAt != null && Date.parse(shell.latestTurn.completedAt) > Date.parse(shell.snoozedAt) ) { return shell.latestTurn.completedAt; } return shell.session?.updatedAt ?? shell.snoozedAt ?? null; } // No raised hand: woke iff the timer elapsed (still-snoozed → null). return wakeAtMs <= Date.parse(options.now) ? shell.snoozedUntil : null; } const HOUR_MS = 60 * 60 * 1_000; const EVENING_HOUR = 18; const MORNING_HOUR = 9; export type SnoozePresetId = "hour" | "three-hours" | "evening" | "tomorrow" | "next-week"; export interface SnoozePreset { readonly id: SnoozePresetId; readonly label: string; /** Menu-row time column. Complements the label instead of repeating it: "Tomorrow" pairs with "9:00 AM", not "tomorrow 9:00 AM". */ readonly whenLabel: string; /** ISO wake time. */ readonly snoozedUntil: string; } function snoozeTimeOfDayLabel(date: Date): string { return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); } function snoozeAtHour(base: Date, hour: number): Date { const next = new Date(base); next.setHours(hour, 0, 0, 0); return next; } // Calendar-day advance instead of adding DAY_MS: fixed millisecond offsets // land on the wrong local day across DST transitions (a spring-forward day // is 23 hours, so 23:30 + 24h skips the whole next day). function addSnoozeDays(base: Date, days: number): Date { const next = new Date(base); next.setDate(next.getDate() + days); return next; } /** * Shared "snooze until" choices for every client. "This evening" only * appears while it is meaningfully before evening; after that the calendar * choices start at "Tomorrow". Calendar presets that land on the same * instant collapse: on Sundays "Tomorrow" and "Next week" are both Monday * morning, so only "Tomorrow" is offered. */ export function resolveSnoozePresets(now: Date): ReadonlyArray { const inAnHour = new Date(now.getTime() + HOUR_MS); const inThreeHours = new Date(now.getTime() + 3 * HOUR_MS); const presets: SnoozePreset[] = [ { id: "hour", label: "In 1 hour", whenLabel: snoozeTimeOfDayLabel(inAnHour), snoozedUntil: inAnHour.toISOString(), }, { id: "three-hours", label: "In 3 hours", whenLabel: snoozeTimeOfDayLabel(inThreeHours), snoozedUntil: inThreeHours.toISOString(), }, ]; const evening = snoozeAtHour(now, EVENING_HOUR); if (evening.getTime() - now.getTime() > HOUR_MS) { presets.push({ id: "evening", label: "This evening", whenLabel: snoozeTimeOfDayLabel(evening), snoozedUntil: evening.toISOString(), }); } const tomorrow = snoozeAtHour(addSnoozeDays(now, 1), MORNING_HOUR); presets.push({ id: "tomorrow", label: "Tomorrow", whenLabel: snoozeTimeOfDayLabel(tomorrow), snoozedUntil: tomorrow.toISOString(), }); const daysUntilMonday = (1 - now.getDay() + 7) % 7 || 7; const nextWeek = snoozeAtHour(addSnoozeDays(now, daysUntilMonday), MORNING_HOUR); if (nextWeek.getTime() !== tomorrow.getTime()) { presets.push({ id: "next-week", label: "Next week", whenLabel: `${nextWeek.toLocaleDateString(undefined, { weekday: "short" })} ${snoozeTimeOfDayLabel(nextWeek)}`, snoozedUntil: nextWeek.toISOString(), }); } return presets; } /** * Compact "wakes in" label for snoozed rows: "2h", "18h", "3d". Minutes * round up so a snooze never reads "0m" while still hidden. Shared by web * and mobile so the same wake time never reads differently per client. */ export function snoozeWakeLabel(snoozedUntil: string, options: { readonly now: string }): string { const wakeMs = Date.parse(snoozedUntil); const nowMs = Date.parse(options.now); if (Number.isNaN(wakeMs) || Number.isNaN(nowMs)) return "now"; const remainingMs = wakeMs - nowMs; if (remainingMs <= 0) return "now"; if (remainingMs < HOUR_MS) return `${Math.max(1, Math.ceil(remainingMs / 60_000))}m`; if (remainingMs < DAY_MS) return `${Math.ceil(remainingMs / HOUR_MS)}h`; return `${Math.ceil(remainingMs / DAY_MS)}d`; }