| 1 | // @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels. |
| 2 | import * as DateTime from "effect/DateTime"; |
| 3 | |
| 4 | import type { PendingSubagentRequest } from "./models.ts"; |
| 5 | |
| 6 | export function resolveThreadSettlementWarning(resources: { |
| 7 | readonly runningCommands: boolean | null; |
| 8 | readonly browserTabs: boolean | null; |
| 9 | }): string | null { |
| 10 | const { runningCommands, browserTabs } = resources; |
| 11 | if (runningCommands === false && browserTabs === false) return null; |
| 12 | if (runningCommands === null || browserTabs === null) { |
| 13 | return "Some terminals or browser tabs could not be checked. Settling closes them and stops any running commands. Undo cannot restore commands or page state."; |
| 14 | } |
| 15 | if (runningCommands && browserTabs) { |
| 16 | return "Settling stops running commands and closes browser tabs. Undo cannot restore commands or page state."; |
| 17 | } |
| 18 | return runningCommands |
| 19 | ? "Settling stops running commands. Undo cannot restart them." |
| 20 | : "Settling closes browser tabs. Undo cannot restore page state."; |
| 21 | } |
| 22 | |
| 23 | interface SettlementRunLike { |
| 24 | readonly turnId?: unknown; |
| 25 | readonly assistantMessageId?: unknown; |
| 26 | readonly status?: string; |
| 27 | readonly state?: string; |
| 28 | readonly requestedAt?: string | null; |
| 29 | readonly startedAt?: string | null; |
| 30 | readonly completedAt?: string | null; |
| 31 | } |
| 32 | |
| 33 | interface SettlementRuntimeLike { |
| 34 | readonly threadId?: unknown; |
| 35 | readonly providerName?: unknown; |
| 36 | readonly runtimeMode?: unknown; |
| 37 | readonly activeTurnId?: unknown; |
| 38 | readonly lastError?: unknown; |
| 39 | readonly status: string; |
| 40 | readonly updatedAt?: string; |
| 41 | } |
| 42 | |
| 43 | interface QueuedThreadShell { |
| 44 | readonly latestUserMessageAt?: string | null; |
| 45 | readonly latestTurn?: SettlementRunLike | null; |
| 46 | readonly latestRun?: SettlementRunLike | null; |
| 47 | readonly session?: SettlementRuntimeLike | null; |
| 48 | readonly runtime?: SettlementRuntimeLike | null; |
| 49 | } |
| 50 | |
| 51 | /** |
| 52 | * A queued turn start lives for at most this long: session adoption takes |
| 53 | * seconds, so a user message still unadopted after the grace window is a |
| 54 | * failed start (or stale data — shells from older servers can carry user |
| 55 | * messages with no latestTurn at all), not pending work. Without this bound |
| 56 | * such threads would be permanently unsettleable. |
| 57 | */ |
| 58 | export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; |
| 59 | const DAY_MS = 24 * 60 * 60 * 1_000; |
| 60 | |
| 61 | /** |
| 62 | * A user message no turn has picked up yet: the turn.start command was |
| 63 | * dispatched (message-sent + turn-start-requested) but no session has |
| 64 | * adopted it, so `session` is still null and the pending work is invisible |
| 65 | * to the session-status checks. Detectable as a user message strictly newer |
| 66 | * than every timestamp on the latest turn — on adoption the new turn's |
| 67 | * requestedAt equals the message time, clearing the condition — and only |
| 68 | * within the adoption grace window. |
| 69 | */ |
| 70 | export function hasQueuedTurnStart( |
| 71 | shell: QueuedThreadShell, |
| 72 | options: { readonly now: string }, |
| 73 | ): boolean { |
| 74 | if ( |
| 75 | shell.runtime?.status === "preparing" || |
| 76 | shell.runtime?.status === "queued" || |
| 77 | shell.runtime?.status === "starting" |
| 78 | ) { |
| 79 | return true; |
| 80 | } |
| 81 | if (shell.latestUserMessageAt == null) return false; |
| 82 | // A failed session start clears the queued state: the failure is already |
| 83 | // visible (status edge / error). |
| 84 | if (shell.session?.status === "error") return false; |
| 85 | const messageAt = Date.parse(shell.latestUserMessageAt); |
| 86 | if (Number.isNaN(messageAt)) return false; |
| 87 | const nowMs = Date.parse(options.now); |
| 88 | if (Number.isNaN(nowMs)) return false; |
| 89 | // Bounded on both sides: message timestamps originate on whichever device |
| 90 | // sent the message, so a clock ahead of this one yields a negative age |
| 91 | // that would otherwise hold the queued state for the whole skew. Mirrors |
| 92 | // the decider's guard. |
| 93 | if (Math.abs(nowMs - messageAt) > QUEUED_TURN_START_GRACE_MS) return false; |
| 94 | const turn = shell.latestRun ?? shell.latestTurn ?? null; |
| 95 | if (turn === null) return true; |
| 96 | return [turn.requestedAt, turn.startedAt, turn.completedAt].every( |
| 97 | (candidate) => candidate == null || Date.parse(candidate) < messageAt, |
| 98 | ); |
| 99 | } |
| 100 | |
| 101 | /** |
| 102 | * The snooze lifecycle fields plus everything needed to detect a raised |
| 103 | * hand. Snooze is an overlay on the active state: a snoozed thread stays |
| 104 | * "active" in the data model and is only suppressed from the inbox until |
| 105 | * its wake time passes or the thread demands attention. |
| 106 | */ |
| 107 | export interface ThreadSnoozeShell extends QueuedThreadShell { |
| 108 | readonly snoozedUntil?: string | null; |
| 109 | readonly snoozedAt?: string | null; |
| 110 | readonly hasPendingApprovals: boolean; |
| 111 | readonly hasPendingUserInput: boolean; |
| 112 | readonly pendingSubagentRequest?: PendingSubagentRequest | null; |
| 113 | } |
| 114 | |
| 115 | /** |
| 116 | * A snoozed thread "raises its hand" when something happens that outranks |
| 117 | * the user's snooze: the agent or one of its subagents is blocked on them |
| 118 | * (approval / user input), the session failed, or a run completed after the |
| 119 | * snooze was set — the v1 taste of event-based snooze ("something happened" |
| 120 | * wakes early). Raising a hand never clears the server-side snooze fields; it |
| 121 | * only stops the thread from classifying as snoozed. |
| 122 | */ |
| 123 | export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean { |
| 124 | if (shell.hasPendingApprovals || shell.hasPendingUserInput) return true; |
| 125 | if (shell.pendingSubagentRequest != null) return true; |
| 126 | const runtime = shell.runtime ?? shell.session ?? null; |
| 127 | const latestRun = shell.latestRun ?? shell.latestTurn ?? null; |
| 128 | // Only a FRESH failure raises the hand: a thread snoozed while already |
| 129 | // failed stays snoozed — that snooze was the user saying "I saw it, not |
| 130 | // now". session.updatedAt stamps the status edge, so an error newer than |
| 131 | // the snooze is new information. |
| 132 | if ( |
| 133 | (runtime?.status === "error" || runtime?.status === "failed") && |
| 134 | (shell.snoozedAt == null || |
| 135 | (runtime.updatedAt != null && Date.parse(runtime.updatedAt) > Date.parse(shell.snoozedAt))) |
| 136 | ) { |
| 137 | return true; |
| 138 | } |
| 139 | if ( |
| 140 | shell.snoozedAt != null && |
| 141 | (latestRun?.state === "completed" || latestRun?.status === "completed") && |
| 142 | latestRun.completedAt != null && |
| 143 | Date.parse(latestRun.completedAt) > Date.parse(shell.snoozedAt) |
| 144 | ) { |
| 145 | return true; |
| 146 | } |
| 147 | return false; |
| 148 | } |
| 149 | |
| 150 | /** |
| 151 | * A thread may be snoozed unless the agent or one of its subagents is blocked |
| 152 | * on the user: hiding a pending approval or user-input request defeats the |
| 153 | * request, and a queued turn start (a message no turn has adopted yet) is |
| 154 | * invisible pending work the same way it is for settle. A running session IS snoozable — snooze |
| 155 | * only affects visibility, never the agent. Client-side twin of the server |
| 156 | * invariants so the UI can reject before a round trip. |
| 157 | */ |
| 158 | export function canSnooze( |
| 159 | shell: Pick< |
| 160 | ThreadSnoozeShell, |
| 161 | | "hasPendingApprovals" |
| 162 | | "hasPendingUserInput" |
| 163 | | "pendingSubagentRequest" |
| 164 | | "latestUserMessageAt" |
| 165 | | "latestTurn" |
| 166 | | "latestRun" |
| 167 | | "session" |
| 168 | | "runtime" |
| 169 | >, |
| 170 | options: { readonly now: string }, |
| 171 | ): boolean { |
| 172 | if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false; |
| 173 | if (shell.pendingSubagentRequest != null) return false; |
| 174 | if (hasQueuedTurnStart(shell, options)) return false; |
| 175 | return true; |
| 176 | } |
| 177 | |
| 178 | /** |
| 179 | * Snoozed resolution: hidden from the inbox while the wake time is in the |
| 180 | * future and the thread has not raised its hand. Timer wakes are derived — |
| 181 | * no server event fires when snoozedUntil passes; the stale fields simply |
| 182 | * stop classifying as snoozed (and feed the woke indicator until the user |
| 183 | * visits or re-engages). |
| 184 | */ |
| 185 | export function effectiveSnoozed( |
| 186 | shell: ThreadSnoozeShell, |
| 187 | options: { readonly now: string }, |
| 188 | ): boolean { |
| 189 | if (shell.snoozedUntil == null) return false; |
| 190 | const wakeAtMs = Date.parse(shell.snoozedUntil); |
| 191 | // Malformed data never hides a thread. |
| 192 | if (Number.isNaN(wakeAtMs)) return false; |
| 193 | if (wakeAtMs <= Date.parse(options.now)) return false; |
| 194 | return !threadRaisedHandWhileSnoozed(shell); |
| 195 | } |
| 196 | |
| 197 | /** |
| 198 | * When a previously-snoozed thread woke, or null if it never snoozed / is |
| 199 | * still snoozed. Used for the "Woke" indicator: the thread reappears in its |
| 200 | * original sort position (the inbox sort is deliberately static), so the |
| 201 | * wake signal has to carry the weight. Compare against the client's |
| 202 | * lastVisitedAt — visiting clears the indicator like it clears unread. |
| 203 | * |
| 204 | * Timer wakes report the wake time itself; raised-hand wakes report the |
| 205 | * triggering timestamp so a visit BEFORE the early wake doesn't suppress |
| 206 | * the indicator. |
| 207 | */ |
| 208 | export function threadWokeAt( |
| 209 | shell: ThreadSnoozeShell, |
| 210 | options: { readonly now: string }, |
| 211 | ): string | null { |
| 212 | if (shell.snoozedUntil == null) return null; |
| 213 | const wakeAtMs = Date.parse(shell.snoozedUntil); |
| 214 | if (Number.isNaN(wakeAtMs)) return null; |
| 215 | // An early hand-raise wake stays authoritative even after the scheduled |
| 216 | // wake time passes: reporting snoozedUntil then would resurface a Woke |
| 217 | // indicator the user already cleared by visiting (snoozedUntil is newer |
| 218 | // than that visit's lastVisitedAt). |
| 219 | if (threadRaisedHandWhileSnoozed(shell)) { |
| 220 | const latestRun = shell.latestRun ?? shell.latestTurn ?? null; |
| 221 | const runtime = shell.runtime ?? shell.session ?? null; |
| 222 | if ( |
| 223 | shell.snoozedAt != null && |
| 224 | (latestRun?.state === "completed" || latestRun?.status === "completed") && |
| 225 | latestRun.completedAt != null && |
| 226 | Date.parse(latestRun.completedAt) > Date.parse(shell.snoozedAt) |
| 227 | ) { |
| 228 | return latestRun.completedAt; |
| 229 | } |
| 230 | return runtime?.updatedAt ?? shell.snoozedAt ?? null; |
| 231 | } |
| 232 | // No raised hand: woke iff the timer elapsed (still-snoozed → null). |
| 233 | return wakeAtMs <= Date.parse(options.now) ? shell.snoozedUntil : null; |
| 234 | } |
| 235 | |
| 236 | const HOUR_MS = 60 * 60 * 1_000; |
| 237 | const EVENING_HOUR = 18; |
| 238 | const MORNING_HOUR = 9; |
| 239 | |
| 240 | export type SnoozePresetId = "hour" | "three-hours" | "evening" | "tomorrow" | "next-week"; |
| 241 | |
| 242 | export interface SnoozePreset { |
| 243 | readonly id: SnoozePresetId; |
| 244 | readonly label: string; |
| 245 | /** Menu-row time column. Complements the label instead of repeating it: |
| 246 | "Tomorrow" pairs with "9:00 AM", not "tomorrow 9:00 AM". */ |
| 247 | readonly whenLabel: string; |
| 248 | /** ISO wake time. */ |
| 249 | readonly snoozedUntil: string; |
| 250 | } |
| 251 | |
| 252 | function snoozeTimeOfDayLabel(date: Date): string { |
| 253 | return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); |
| 254 | } |
| 255 | |
| 256 | function snoozeAtHour(base: Date, hour: number): Date { |
| 257 | const next = DateTime.toDate(DateTime.makeUnsafe(base)); |
| 258 | next.setHours(hour, 0, 0, 0); |
| 259 | return next; |
| 260 | } |
| 261 | |
| 262 | // Calendar-day advance instead of adding DAY_MS: fixed millisecond offsets |
| 263 | // land on the wrong local day across DST transitions (a spring-forward day |
| 264 | // is 23 hours, so 23:30 + 24h skips the whole next day). |
| 265 | function addSnoozeDays(base: Date, days: number): Date { |
| 266 | const next = DateTime.toDate(DateTime.makeUnsafe(base)); |
| 267 | next.setDate(next.getDate() + days); |
| 268 | return next; |
| 269 | } |
| 270 | |
| 271 | /** |
| 272 | * Shared "snooze until" choices for every client. "This evening" only |
| 273 | * appears while it is meaningfully before evening; after that the calendar |
| 274 | * choices start at "Tomorrow". Calendar presets that land on the same |
| 275 | * instant collapse: on Sundays "Tomorrow" and "Next week" are both Monday |
| 276 | * morning, so only "Tomorrow" is offered. |
| 277 | */ |
| 278 | export function resolveSnoozePresets(now: Date): ReadonlyArray<SnoozePreset> { |
| 279 | const inAnHour = DateTime.toDate(DateTime.makeUnsafe(now.getTime() + HOUR_MS)); |
| 280 | const inThreeHours = DateTime.toDate(DateTime.makeUnsafe(now.getTime() + 3 * HOUR_MS)); |
| 281 | const presets: SnoozePreset[] = [ |
| 282 | { |
| 283 | id: "hour", |
| 284 | label: "In 1 hour", |
| 285 | whenLabel: snoozeTimeOfDayLabel(inAnHour), |
| 286 | snoozedUntil: inAnHour.toISOString(), |
| 287 | }, |
| 288 | { |
| 289 | id: "three-hours", |
| 290 | label: "In 3 hours", |
| 291 | whenLabel: snoozeTimeOfDayLabel(inThreeHours), |
| 292 | snoozedUntil: inThreeHours.toISOString(), |
| 293 | }, |
| 294 | ]; |
| 295 | |
| 296 | const evening = snoozeAtHour(now, EVENING_HOUR); |
| 297 | if (evening.getTime() - now.getTime() > HOUR_MS) { |
| 298 | presets.push({ |
| 299 | id: "evening", |
| 300 | label: "This evening", |
| 301 | whenLabel: snoozeTimeOfDayLabel(evening), |
| 302 | snoozedUntil: evening.toISOString(), |
| 303 | }); |
| 304 | } |
| 305 | |
| 306 | const tomorrow = snoozeAtHour(addSnoozeDays(now, 1), MORNING_HOUR); |
| 307 | presets.push({ |
| 308 | id: "tomorrow", |
| 309 | label: "Tomorrow", |
| 310 | whenLabel: snoozeTimeOfDayLabel(tomorrow), |
| 311 | snoozedUntil: tomorrow.toISOString(), |
| 312 | }); |
| 313 | |
| 314 | const daysUntilMonday = (1 - now.getDay() + 7) % 7 || 7; |
| 315 | const nextWeek = snoozeAtHour(addSnoozeDays(now, daysUntilMonday), MORNING_HOUR); |
| 316 | if (nextWeek.getTime() !== tomorrow.getTime()) { |
| 317 | presets.push({ |
| 318 | id: "next-week", |
| 319 | label: "Next week", |
| 320 | whenLabel: `${nextWeek.toLocaleDateString(undefined, { weekday: "short" })} ${snoozeTimeOfDayLabel(nextWeek)}`, |
| 321 | snoozedUntil: nextWeek.toISOString(), |
| 322 | }); |
| 323 | } |
| 324 | |
| 325 | return presets; |
| 326 | } |
| 327 | |
| 328 | /** |
| 329 | * Compact "wakes in" label for snoozed rows: "2h", "18h", "3d". Minutes |
| 330 | * round up so a snooze never reads "0m" while still hidden. Shared by web |
| 331 | * and mobile so the same wake time never reads differently per client. |
| 332 | */ |
| 333 | export function snoozeWakeLabel(snoozedUntil: string, options: { readonly now: string }): string { |
| 334 | const wakeMs = Date.parse(snoozedUntil); |
| 335 | const nowMs = Date.parse(options.now); |
| 336 | if (Number.isNaN(wakeMs) || Number.isNaN(nowMs)) return "now"; |
| 337 | const remainingMs = wakeMs - nowMs; |
| 338 | if (remainingMs <= 0) return "now"; |
| 339 | if (remainingMs < HOUR_MS) return `${Math.max(1, Math.ceil(remainingMs / 60_000))}m`; |
| 340 | if (remainingMs < DAY_MS) return `${Math.ceil(remainingMs / HOUR_MS)}h`; |
| 341 | return `${Math.ceil(remainingMs / DAY_MS)}d`; |
| 342 | } |
| 343 | |
| 344 | export type CustomSnoozeInput = |
| 345 | | { readonly mode: "date"; readonly date: string; readonly time: string } |
| 346 | | { |
| 347 | readonly mode: "duration"; |
| 348 | readonly amount: string; |
| 349 | readonly unit: "minutes" | "hours" | "days"; |
| 350 | }; |
| 351 | |
| 352 | /** Resolve local calendar input or elapsed time, rejecting past and invalid dates. */ |
| 353 | export function resolveCustomSnooze(input: CustomSnoozeInput, now: Date): string | null { |
| 354 | let wake: Date; |
| 355 | if (input.mode === "duration") { |
| 356 | const amount = Number(input.amount); |
| 357 | if (!Number.isFinite(amount) || amount <= 0) return null; |
| 358 | const unitMs = { minutes: 60_000, hours: HOUR_MS, days: 24 * HOUR_MS }[input.unit]; |
| 359 | wake = new Date(now.getTime() + amount * unitMs); |
| 360 | } else { |
| 361 | if (!/^\d{4}-\d{2}-\d{2}$/.test(input.date) || !/^\d{2}:\d{2}$/.test(input.time)) return null; |
| 362 | wake = new Date(`${input.date}T${input.time}:00`); |
| 363 | // Reject rolled-over dates and nonexistent local times during DST changes. |
| 364 | if (localSnoozeDate(wake) !== input.date || localSnoozeTime(wake) !== input.time) return null; |
| 365 | } |
| 366 | return Number.isFinite(wake.getTime()) && wake.getTime() > now.getTime() |
| 367 | ? wake.toISOString() |
| 368 | : null; |
| 369 | } |
| 370 | |
| 371 | export function localSnoozeDate(date: Date): string { |
| 372 | return `${String(date.getFullYear()).padStart(4, "0")}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; |
| 373 | } |
| 374 | |
| 375 | export function localSnoozeTime(date: Date): string { |
| 376 | return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; |
| 377 | } |