summaryrefslogtreecommitdiffhomepage
path: root/src/features/surface-host/logic/message-queue.ts
blob: a49d4d01f0770043a34bd8102baeeb709b6671ad (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
import type { SurfaceSpec } from "@dispatch/ui-contract";
import type { QueuedMessage } from "@dispatch/wire";

/**
 * Pure parser for the `rendererId: "message-queue"` custom-field payload.
 *
 * The message-queue extension's per-conversation surface emits ONE `custom`
 * field with `rendererId: "message-queue"` and `payload: QueuePayload`
 * (`{ messages: QueuedMessage[] }` — the current queue snapshot). This parser
 * validates the untyped `payload: unknown` at the network seam so a
 * hostile/partial payload can never crash the renderer (graceful skip → null).
 *
 * Empty `messages` is a valid, parseable state (the queue is empty — nothing to
 * render); the caller hides the panel. Null is returned only for a malformed
 * payload shape.
 */
export interface MessageQueueData {
  readonly messages: readonly QueuedMessage[];
}

function isQueuedMessage(v: unknown): v is QueuedMessage {
  if (typeof v !== "object" || v === null) return false;
  const o = v as Record<string, unknown>;
  return (
    typeof o.id === "string" &&
    typeof o.text === "string" &&
    typeof o.queuedAt === "number" &&
    Number.isFinite(o.queuedAt)
  );
}

export function parseMessageQueuePayload(payload: unknown): MessageQueueData | null {
  if (typeof payload !== "object" || payload === null) return null;
  const obj = payload as Record<string, unknown>;
  const raw = obj.messages;
  if (!Array.isArray(raw)) return null;
  const messages: QueuedMessage[] = [];
  for (const entry of raw) {
    if (!isQueuedMessage(entry)) return null;
    messages.push(entry);
  }
  return { messages };
}

/** Queue size from a validated snapshot; absent/malformed data stays unknown. */
export function messageQueueCount(spec: SurfaceSpec | null): number | undefined {
  const field = spec?.fields.find(
    (field) => field.kind === "custom" && field.rendererId === MESSAGE_QUEUE_RENDERER_ID,
  );
  if (field?.kind !== "custom") return undefined;
  return parseMessageQueuePayload(field.payload)?.messages.length;
}

/** The `rendererId` the message-queue extension's `custom` surface field uses. */
export const MESSAGE_QUEUE_RENDERER_ID = "message-queue";

/**
 * Optimistic-removal view-model for the queue list.
 *
 * The `chat.queue.cancel` op is fire-and-forget + idempotent: success is
 * confirmed by the `message-queue` SURFACE updating (the cancelled message
 * leaves the snapshot), not by a reply. To avoid a flash of the row lingering
 * for a round-trip, the renderer hides a row the instant the user clicks cancel
 * (tracking the cancelled id locally), then reconciles when the surface pushes
 * the post-cancel snapshot. These two pure helpers drive that — the component
 * holds the cancelled-id set as a thin `$state` wrapper and delegates all
 * decisions here.
 */

/**
 * The messages the renderer should show: the surface snapshot MINUS any
 * optimistically-cancelled ids (a cancel whose surface confirmation hasn't
 * arrived yet). Pure — no mutation of inputs.
 */
export function selectVisibleMessages(
  messages: readonly QueuedMessage[],
  cancelledIds: ReadonlySet<string>,
): readonly QueuedMessage[] {
  if (cancelledIds.size === 0) return messages;
  return messages.filter((m) => !cancelledIds.has(m.id));
}

/**
 * Reconcile the cancelled-id set against a NEW surface snapshot: keep only the
 * ids that are STILL queued (the cancel is pending — its surface confirmation
 * hasn't landed). Drop ids that have left the snapshot: the server confirmed
 * the removal (or the message drained as steering / the queue cleared), so the
 * optimistic hide is no longer needed. This keeps the set bounded — it never
 * outlives the rows it tracks. Pure — returns a NEW set (callers assign it to
 * the reactive `$state`).
 */
export function reconcileCancelledIds(
  messages: readonly QueuedMessage[],
  cancelledIds: ReadonlySet<string>,
): ReadonlySet<string> {
  if (cancelledIds.size === 0) return EMPTY_STRING_SET;
  const stillQueued = new Set<string>();
  for (const m of messages) {
    if (cancelledIds.has(m.id)) stillQueued.add(m.id);
  }
  // Same set back → return the input identity so the component's `$state` setter
  // sees no change (avoids a spurious reactive cycle).
  if (stillQueued.size === cancelledIds.size) {
    let same = true;
    for (const id of cancelledIds) {
      if (!stillQueued.has(id)) {
        same = false;
        break;
      }
    }
    if (same) return cancelledIds;
  }
  return stillQueued;
}

const EMPTY_STRING_SET: ReadonlySet<string> = new Set<string>();