summaryrefslogtreecommitdiffhomepage
path: root/src/features/named-agents/ui/NamedAgentsView.svelte
blob: 743ac2050e112985b383db0cba43557413cb6906 (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
117
118
119
120
121
122
123
124
125
126
<script lang="ts">
  import { untrack } from "svelte";
  import type { NamedAgent } from "../logic/types";
  import type { NamedAgentPorts } from "./controller.svelte";
  import { createCardController, type CardController } from "./controller.svelte";
  import NamedAgentCard from "./NamedAgentCard.svelte";

  interface Row {
    key: number;
    agent: NamedAgent | null;
    controller: CardController;
  }

  let {
    agents,
    models,
    ports,
  }: {
    /** The persisted Named Agents (seed for the cards; the store stays authoritative for consumers). */
    agents: readonly NamedAgent[];
    /** Available models (`<provider>/<model>`) — the source of the provider/model dropdowns. */
    models: readonly string[];
    ports: NamedAgentPorts;
  } = $props();

  // The view OWNS the editable card rows (each a controller + its last-saved
  // canonical agent). Seeded once from the `agents` snapshot on mount (untracked
  // so a later store update — which this view drove — never clobbers an
  // in-flight draft). The store remains authoritative for other consumers.
  let rows = $state<Row[]>([]);
  let nextKey = $state(0);
  let seeded = $state(false);

  $effect(() => {
    untrack(() => {
      if (seeded) return;
      seeded = true;
      for (const a of agents) addRow(a);
    });
  });

  /**
   * The other rows' names (excluding the row with `rowKey`) for the uniqueness
   * hint. Identity is the ROW's stable `key` — a draft (`agent === null`) must
   * still exclude ITSELF (otherwise a fresh draft collides with its own name),
   * while any OTHER draft is a genuine sibling.
   */
  function otherNamesOf(rowKey: number): readonly string[] {
    const out: string[] = [];
    for (const r of rows) {
      if (r.key === rowKey) continue;
      out.push(r.agent !== null ? r.agent.name : r.controller.draft.name);
    }
    return out;
  }

  /**
   * Live uniqueness predicate for a card: true when `name` collides with a
   * SIBLING row (excludes the card's own row by its stable `rowKey` — not by a
   * nullable persisted id, so a draft excludes itself exactly once).
   */
  function isNameTakenFor(rowKey: number): (name: string) => boolean {
    return (name: string): boolean => {
      const lower = name.trim().toLowerCase();
      for (const r of rows) {
        if (r.key === rowKey) continue;
        const candidate = r.agent !== null ? r.agent.name : r.controller.draft.name;
        if (candidate.trim().toLowerCase() === lower) return true;
      }
      return false;
    };
  }

  function addRow(agent: NamedAgent | null): void {
    // Capture the row's stable key FIRST so the controller's callbacks reconcile
    // THIS row only — never a sibling draft (`agent === null` matches all).
    const key = nextKey++;
    const controller = createCardController({
      agent,
      savedModel: agent?.model ?? "",
      isNameTaken: isNameTakenFor(key),
      ports,
      onChanged: (saved) => replaceAgent(key, saved),
      onRemoved: () => removeRow(key),
    });
    rows = [...rows, { key, agent, controller }];
  }

  function replaceAgent(key: number, saved: NamedAgent): void {
    rows = rows.map((r) => (r.key === key ? { ...r, agent: saved } : r));
  }

  function removeRow(key: number): void {
    const row = rows.find((r) => r.key === key);
    if (row === undefined) return;
    row.controller.dispose();
    rows = rows.filter((r) => r.key !== key);
  }

  function addDraft(): void {
    addRow(null);
  }
</script>

<div class="flex flex-col gap-2">
  <ul class="flex flex-col gap-2">
    {#each rows as row (row.key)}
      <li>
        <NamedAgentCard
          models={models}
          agent={row.agent}
          controller={row.controller}
          otherNames={otherNamesOf(row.key)}
        />
      </li>
    {/each}
  </ul>

  {#if rows.length === 0}
    <p class="text-xs opacity-60">No named agents yet.</p>
  {/if}

  <button type="button" class="btn btn-ghost btn-xs w-fit" onclick={addDraft}>
    + Add Named Agent
  </button>
</div>