394 lines · 15.7 KB · TypeScript
RawDownload
// @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels.import type { OrchestrationThreadShell } from "@t3tools/contracts"; export type ChangeRequestStateLike = "open" | "closed" | "merged"; /** Returns whether the change request state settles the thread immediately. */export function changeRequestAutoSettles(  state: ChangeRequestStateLike | null | undefined,  autoSettleOnMerge = true,): boolean {  return state === "closed" || (state === "merged" && autoSettleOnMerge);} const DAY_MS = 24 * 60 * 60 * 1_000; export function threadLastActivityAt(shell: OrchestrationThreadShell): string | null {  const candidates = [    shell.latestUserMessageAt,    shell.latestTurn?.requestedAt,    shell.latestTurn?.startedAt,    shell.latestTurn?.completedAt,  ];  let latest: string | null = null;  let latestTimestamp = Number.NEGATIVE_INFINITY;   for (const candidate of candidates) {    if (candidate === null || candidate === undefined) continue;    const timestamp = Date.parse(candidate);    if (timestamp > latestTimestamp) {      latest = candidate;      latestTimestamp = timestamp;    }  }   return latest;} /** * 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; /** * 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<OrchestrationThreadShell, "latestUserMessageAt" | "latestTurn" | "session">,  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,  );} /** * A thread may be settled only when none of effectiveSettled's activity * blockers hold. This is deliberately the same list: anything the partition * refuses to CLASSIFY as settled must also be refused as a settle TARGET. * The server enforces its own invariants; this client-side twin exists so * the UI can disable/reject before a round trip. */export function canSettle(  shell: Pick<    OrchestrationThreadShell,    "hasPendingApprovals" | "hasPendingUserInput" | "session" | "latestUserMessageAt" | "latestTurn"  >,  options: { readonly now: string },): boolean {  if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false;  if (shell.session?.status === "starting" || shell.session?.status === "running") return false;  // Queued work is as blocked-on-progress as a live session: settling it  // (or auto-settling it on a closed PR) would hide a just-requested turn.  if (hasQueuedTurnStart(shell, options)) return false;  return true;} /** * 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, exactly like blocked work and * effectiveSettled. */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;} /** * Settled resolution over the server-backed settled lifecycle. Activity * blockers (pending approval/user-input, a live session, an unadjudicated * queued turn) are checked first and hold a thread active regardless of any * override. Past the blockers, the explicit user override (thread.settle / * thread.unsettle commands, projected into settledOverride + settledAt) * wins in both directions; without one, a thread can auto-settle on a * merged PR, always settles on a closed PR, or settles on inactivity past * the window. An open PR blocks the inactivity path entirely. The server * un-settles on real activity (user message, session start, approval/ * user-input request), so an override never goes stale silently. */export function effectiveSettled(  shell: OrchestrationThreadShell,  options: {    readonly now: string;    readonly autoSettleAfterDays: number | null;    readonly autoSettleOnMerge?: boolean;    readonly changeRequestState?: ChangeRequestStateLike | null;  },): boolean {  // Blocked work must remain visible even when a user explicitly settled it.  if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false;  if (shell.session?.status === "starting" || shell.session?.status === "running") return false;  if (hasQueuedTurnStart(shell, { now: options.now })) {    // The queued-turn blocker alone is forgivable: it is clock-derived, and    // list callers pass a coarser `now` than the settle action used. When    // the server already adjudicated the queued message by accepting a    // settle after it (settledAt stamps server accept time), trust that    // ruling — otherwise a settle near the grace boundary leaves the row    // pinned active until the caller's clock ticks over. A message NEWER    // than settledAt is genuinely new work and keeps the block until the    // server's auto-unsettle lands.    const serverAdjudicated =      shell.settledOverride === "settled" &&      shell.settledAt !== null &&      shell.latestUserMessageAt !== null &&      Date.parse(shell.settledAt) >= Date.parse(shell.latestUserMessageAt);    if (!serverAdjudicated) return false;  }  if (shell.settledOverride === "settled") return true;  // "active" is the explicit keep-active pin: it suppresses auto-settle  // until real activity clears it server-side.  if (shell.settledOverride === "active") return false;  if (changeRequestAutoSettles(options.changeRequestState, options.autoSettleOnMerge !== false)) {    return true;  }  // An open PR is unfinished business regardless of how long the thread has  // been quiet: review can take days, and hiding the thread would bury the  // work waiting on it. A configured merge, a close, or an explicit user  // settle resolves it.  if (options.changeRequestState === "open") return false;  if (options.autoSettleAfterDays === null) return false;   const lastActivityAt = threadLastActivityAt(shell);  if (lastActivityAt === null) return false;   // threadLastActivityAt only returns candidates whose Date.parse beat  // -Infinity, so this parse is a real number; a malformed `now` yields NaN,  // the comparison is false, and the thread stays active (never a surprise  // auto-settle on bad input).  return (    Date.parse(lastActivityAt) < Date.parse(options.now) - options.autoSettleAfterDays * DAY_MS  );} 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". */export function resolveSnoozePresets(now: Date): ReadonlyArray<SnoozePreset> {  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);  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`;}