summaryrefslogtreecommitdiffhomepage
path: root/src/features/surface-host/logic
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/surface-host/logic')
-rw-r--r--src/features/surface-host/logic/message-queue.test.ts94
-rw-r--r--src/features/surface-host/logic/message-queue.ts71
2 files changed, 164 insertions, 1 deletions
diff --git a/src/features/surface-host/logic/message-queue.test.ts b/src/features/surface-host/logic/message-queue.test.ts
index 8d55eb7..93256b4 100644
--- a/src/features/surface-host/logic/message-queue.test.ts
+++ b/src/features/surface-host/logic/message-queue.test.ts
@@ -1,6 +1,12 @@
+import type { SurfaceSpec } from "@dispatch/ui-contract";
import type { QueuedMessage } from "@dispatch/wire";
import { describe, expect, it } from "vitest";
-import { parseMessageQueuePayload } from "./message-queue";
+import {
+ messageQueueCount,
+ parseMessageQueuePayload,
+ reconcileCancelledIds,
+ selectVisibleMessages,
+} from "./message-queue";
const msg = (id: string, text: string, queuedAt = 1_700_000_000_000): QueuedMessage => ({
id,
@@ -46,3 +52,89 @@ describe("parseMessageQueuePayload", () => {
expect(parseMessageQueuePayload(payload)).toBeNull();
});
});
+
+describe("selectVisibleMessages", () => {
+ it("returns the snapshot unchanged when nothing is cancelled", () => {
+ const messages = [msg("m1", "a"), msg("m2", "b")];
+ expect(selectVisibleMessages(messages, new Set())).toBe(messages);
+ });
+
+ it("hides the optimistically-cancelled row", () => {
+ const messages = [msg("m1", "a"), msg("m2", "b"), msg("m3", "c")];
+ expect(selectVisibleMessages(messages, new Set(["m2"])).map((m) => m.id)).toEqual(["m1", "m3"]);
+ });
+
+ it("hides multiple cancelled rows", () => {
+ const messages = [msg("m1", "a"), msg("m2", "b"), msg("m3", "c")];
+ expect(selectVisibleMessages(messages, new Set(["m1", "m3"])).map((m) => m.id)).toEqual(["m2"]);
+ });
+
+ it("tolerates a cancelled id not present in the snapshot (no-op)", () => {
+ const messages = [msg("m1", "a")];
+ expect(selectVisibleMessages(messages, new Set(["ghost"])).map((m) => m.id)).toEqual(["m1"]);
+ });
+});
+
+describe("reconcileCancelledIds", () => {
+ it("returns an empty set when nothing was cancelled", () => {
+ const result = reconcileCancelledIds([msg("m1", "a")], new Set());
+ expect(result.size).toBe(0);
+ });
+
+ it("drops ids the surface confirmed gone (no longer queued)", () => {
+ // m1 still queued (cancel pending), m2 confirmed gone (left the snapshot).
+ const messages = [msg("m1", "a")];
+ const result = reconcileCancelledIds(messages, new Set(["m1", "m2"]));
+ expect([...result]).toEqual(["m1"]);
+ });
+
+ it("returns the SAME set identity when nothing changed (no spurious cycle)", () => {
+ const messages = [msg("m1", "a"), msg("m2", "b")];
+ const cancelled = new Set(["m1", "m2"]);
+ expect(reconcileCancelledIds(messages, cancelled)).toBe(cancelled);
+ });
+
+ it("returns an empty set when all cancels were confirmed", () => {
+ const messages = [msg("m1", "a")];
+ expect(reconcileCancelledIds(messages, new Set(["m2", "m3"])).size).toBe(0);
+ });
+
+ it("keeps a still-queued cancelled id (cancel still pending)", () => {
+ const messages = [msg("m1", "a"), msg("m2", "b")];
+ const result = reconcileCancelledIds(messages, new Set(["m2"]));
+ expect([...result]).toEqual(["m2"]);
+ });
+});
+
+describe("messageQueueCount", () => {
+ const spec = (payload: unknown): SurfaceSpec => ({
+ id: "message-queue",
+ region: "inline",
+ title: "Message queue",
+ fields: [{ kind: "custom", rendererId: "message-queue", payload }],
+ });
+
+ it("counts messages, including image-only input", () => {
+ expect(messageQueueCount(spec({ messages: [msg("m1", ""), msg("m2", "next")] }))).toBe(2);
+ });
+
+ it("distinguishes an empty queue from an unavailable snapshot", () => {
+ expect(messageQueueCount(spec({ messages: [] }))).toBe(0);
+ expect(messageQueueCount(null)).toBeUndefined();
+ });
+
+ it("returns unknown for missing fields or a different renderer", () => {
+ expect(messageQueueCount({ ...spec(null), fields: [] })).toBeUndefined();
+ expect(
+ messageQueueCount({
+ ...spec(null),
+ fields: [{ kind: "custom", rendererId: "other", payload: { messages: [msg("m1", "x")] } }],
+ }),
+ ).toBeUndefined();
+ });
+
+ it("returns unknown for malformed payloads", () => {
+ expect(messageQueueCount(spec({ messages: "nope" }))).toBeUndefined();
+ expect(messageQueueCount(spec({ messages: [{ id: "m1" }] }))).toBeUndefined();
+ });
+});
diff --git a/src/features/surface-host/logic/message-queue.ts b/src/features/surface-host/logic/message-queue.ts
index 79707a5..a49d4d0 100644
--- a/src/features/surface-host/logic/message-queue.ts
+++ b/src/features/surface-host/logic/message-queue.ts
@@ -1,3 +1,4 @@
+import type { SurfaceSpec } from "@dispatch/ui-contract";
import type { QueuedMessage } from "@dispatch/wire";
/**
@@ -41,5 +42,75 @@ export function parseMessageQueuePayload(payload: unknown): MessageQueueData | n
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>();