1// @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels.
2import * as DateTime from "effect/DateTime";
3
4import type { PendingSubagentRequest } from "./models.ts";
5
6export 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
23interface 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
33interface 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
43interface 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 */
58export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000;
59const 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 */
70export 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 */
107export 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 */
123export 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 */
158export 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 */
185export 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 */
208export 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
236const HOUR_MS = 60 * 60 * 1_000;
237const EVENING_HOUR = 18;
238const MORNING_HOUR = 9;
239
240export type SnoozePresetId = "hour" | "three-hours" | "evening" | "tomorrow" | "next-week";
241
242export 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
252function snoozeTimeOfDayLabel(date: Date): string {
253 return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
254}
255
256function 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).
265function 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 */
278export 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 */
333export 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
344export 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. */
353export 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
371export 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
375export function localSnoozeTime(date: Date): string {
376 return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
377}