@kitn.ai/ui
Version:
Framework-agnostic, Shadow-DOM web components for building AI chat interfaces — works in React, Vue, Angular, Svelte, or plain HTML. Authored in SolidJS.
150 lines (137 loc) • 6 kB
text/typescript
// src/primitives/card-recovery.ts
// Recovery glue for the card dismiss/reopen flow. `dismissRecovery` builds the
// `{ onDismiss, onReopen }` half of a CardPolicy for a host that keeps its cards in
// some store: it writes a `dismissed` resolution onto the matching envelope (a NEW
// array reference, never an in-place mutation, so a Solid/React host re-renders),
// optionally shows an injected "Dismissed · Undo" toast (Undo restores the prior
// resolution), and on reopen decides — via `isReopenable` — whether the card comes
// back live (resolution cleared) or `expired`.
//
// The toast is an INJECTED ADAPTER, never an import — cards (and this helper) stay
// decoupled from the toast module. The re-appear rule is the host's: "the agent
// already proceeded" is a host fact, surfaced through `isReopenable`.
import type { CardEnvelope, CardPolicy, CardResolution } from './card-contract';
/** The minimal toast surface this helper needs. Inject a real adapter (e.g. one
* backed by `kai-toast-region`) — this module never imports the toast itself. */
export interface RecoveryToast {
show(opts: {
message: string;
action?: { label: string; onClick: () => void };
durationMs?: number;
}): { dismiss(): void };
}
/** Context handed to a custom `isReopenable` predicate. */
export interface ReopenEnv {
cardId: string;
/** The card's current resolution at reopen time (should be `dismissed`). */
resolution: CardResolution | undefined;
/** `Date.now()` at reopen time (injected for testability). */
now: number;
}
export interface DismissRecoveryOptions {
/** Read the current cards array. */
get: () => CardEnvelope[];
/** Write the next cards array (a NEW reference). */
set: (next: CardEnvelope[]) => void;
/** Optional injected toast adapter for the "Dismissed · Undo" affordance. */
toast?: RecoveryToast;
/** Override the default re-openable rule. Return false to expire instead. */
isReopenable?: (env: ReopenEnv) => boolean;
/** A dismissed card older than this (ms since `dismissed.at`) is no longer
* re-openable (→ expired). Omit/Infinity = never stale. */
staleAfterMs?: number;
/** Auto-dismiss the Undo toast after this long. Default 6000ms. */
undoMs?: number;
/** Injected clock (defaults to Date.now); keeps the helper testable. */
now?: () => number;
}
/** The terminal resolution kinds that block a reopen (a finished card can't come back). */
const TERMINAL_KINDS = new Set<CardResolution['kind']>(['action', 'submit', 'expired']);
/** Default re-openable rule: a card can come back unless it carries a terminal
* resolution OR it has gone stale (`staleAfterMs` elapsed since `dismissed.at`).
* A `dismissed` card with no/invalid `at` is always re-openable. */
export function defaultIsReopenable(env: ReopenEnv, staleAfterMs?: number): boolean {
const r = env.resolution;
if (r && TERMINAL_KINDS.has(r.kind)) return false;
if (
staleAfterMs !== undefined &&
Number.isFinite(staleAfterMs) &&
r?.kind === 'dismissed' &&
typeof r.at === 'string'
) {
const at = Date.parse(r.at);
if (!Number.isNaN(at) && env.now - at >= staleAfterMs) return false;
}
return true;
}
/** Return a new cards array with `cardId`'s envelope stamped with `resolution`
* (or cleared when `resolution` is undefined). Same reference if nothing changed. */
function withResolution(
cards: CardEnvelope[],
cardId: string,
resolution: CardResolution | undefined,
): CardEnvelope[] {
let changed = false;
const next = cards.map((c) => {
if (c.id !== cardId) return c;
changed = true;
if (resolution === undefined) {
if (c.resolution === undefined) return c;
const { resolution: _drop, ...rest } = c;
return rest;
}
return { ...c, resolution };
});
return changed ? next : cards;
}
/** The current resolution on `cardId`, if any. */
function resolutionOf(cards: CardEnvelope[], cardId: string): CardResolution | undefined {
return cards.find((c) => c.id === cardId)?.resolution;
}
/**
* Build the `{ onDismiss, onReopen }` policy handlers for the card dismiss/recovery
* flow over a host store (`get`/`set`).
*
* - `onDismiss(cardId)` — stamps `{ kind:'dismissed', at }` immutably and, when a
* `toast` is injected, shows "Dismissed" with an Undo that restores the card's
* prior resolution (live again when there was none).
* - `onReopen(cardId)` — clears the resolution (live) when `isReopenable`, else
* stamps `{ kind:'expired', at }`.
*/
export function dismissRecovery(
opts: DismissRecoveryOptions,
): Pick<CardPolicy, 'onDismiss' | 'onReopen'> {
const now = opts.now ?? (() => Date.now());
const reopenable = opts.isReopenable ?? ((env: ReopenEnv) => defaultIsReopenable(env, opts.staleAfterMs));
const undoMs = opts.undoMs ?? 6000;
const onDismiss = (cardId: string): void => {
const before = opts.get();
const prior = resolutionOf(before, cardId);
const dismissed: CardResolution = { kind: 'dismissed', at: new Date(now()).toISOString() };
opts.set(withResolution(before, cardId, dismissed));
opts.toast?.show({
message: 'Dismissed',
durationMs: undoMs,
action: {
label: 'Undo',
onClick: () => {
// Restore the prior resolution (undefined → live again). Read fresh in
// case the store moved on; only touch this card.
opts.set(withResolution(opts.get(), cardId, prior));
},
},
});
};
const onReopen = (cardId: string): void => {
const before = opts.get();
const current = resolutionOf(before, cardId);
const env: ReopenEnv = { cardId, resolution: current, now: now() };
if (reopenable(env)) {
opts.set(withResolution(before, cardId, undefined));
} else {
const expired: CardResolution = { kind: 'expired', at: new Date(now()).toISOString() };
opts.set(withResolution(before, cardId, expired));
}
};
return { onDismiss, onReopen };
}