summaryrefslogtreecommitdiffhomepage
path: root/src/features/surface-host/ui/MessageQueueList.svelte
blob: b260de238704cbaa6b5eea776fd546d02fa1fcc4 (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
<script lang="ts">
  import {
    parseMessageQueuePayload,
    reconcileCancelledIds,
    selectVisibleMessages,
  } from "../logic/message-queue";

  let {
    payload,
    onCancel,
  }: {
    readonly payload: unknown;
    /**
     * Cancel (remove) a single queued message by id (`chat.queue.cancel`).
     * Required-but-nullable (not optional) so a parent can thread a
     * `| undefined` callback through under `exactOptionalPropertyTypes`:
     * `undefined` → a read-only list (no × affordance), e.g. a generic surface
     * context with no conversation scope. The list still reconciles from the
     * surface either way.
     */
    readonly onCancel: ((messageId: string) => void) | undefined;
  } = $props();

  // Parse defensively; an unparseable payload yields null → render nothing
  // (graceful skip, per the custom-field contract).
  const data = $derived(parseMessageQueuePayload(payload));

  // Optimistic-removal: a cancelled id is hidden the instant the user clicks,
  // ahead of the surface's post-cancel snapshot. Reconcile on every payload
  // change so a confirmed-removed id (no longer in the snapshot) is dropped
  // from the set — keeping it bounded (pure helpers in logic/message-queue).
  let cancelledIds = $state<ReadonlySet<string>>(new Set());

  $effect(() => {
    const parsed = data;
    if (parsed === null) return;
    const next = reconcileCancelledIds(parsed.messages, cancelledIds);
    if (next !== cancelledIds) cancelledIds = next;
  });

  const visible = $derived(
    data === null ? [] : selectVisibleMessages(data.messages, cancelledIds),
  );

  function handleCancel(messageId: string): void {
    // Optimistically hide the row + fire the cancel (fire-and-forget; the
    // surface update reconciles). Idempotent server-side, so a double-click or
    // a cancel of an already-drained message is a silent no-op — no rollback.
    if (cancelledIds.has(messageId)) return;
    const next = new Set(cancelledIds);
    next.add(messageId);
    cancelledIds = next;
    onCancel?.(messageId);
  }
</script>

{#if data !== null && data.messages.length > 0}
  <ul class="flex flex-col gap-1 text-sm">
    {#each visible as msg (msg.id)}
      <li class="flex items-start gap-2 rounded-box bg-base-200 px-3 py-2">
        <div class="min-w-0 flex-1">
          <p class="whitespace-pre-wrap break-words">{msg.text}</p>
          <time class="text-xs opacity-50" datetime={new Date(msg.queuedAt).toISOString()}>
            {new Date(msg.queuedAt).toLocaleTimeString()}
          </time>
        </div>
        {#if onCancel !== undefined}
          <button
            type="button"
            class="btn btn-ghost btn-xs btn-square shrink-0 opacity-60 hover:opacity-100"
            title="Cancel this queued message"
            aria-label="Cancel this queued message"
            onclick={() => handleCancel(msg.id)}
          >
            <svg
              xmlns="http://www.w3.org/2000/svg"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              stroke-width="2.5"
              stroke-linecap="round"
              stroke-linejoin="round"
              class="h-3.5 w-3.5"
            >
              <line x1="18" y1="6" x2="6" y2="18"></line>
              <line x1="6" y1="6" x2="18" y2="18"></line>
            </svg>
          </button>
        {/if}
      </li>
    {/each}
  </ul>
{/if}