@frockbot/plugin-settings 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/frockbot.json +55 -0
  2. package/package.json +43 -6
  3. package/src/backend.test.ts +397 -0
  4. package/src/backend.ts +262 -0
  5. package/src/client/BotPanel.vue +57 -0
  6. package/src/client/BotSettingsSurface.vue +1076 -0
  7. package/src/client/BotSettingsTrigger.vue +26 -0
  8. package/src/client/ConnectionsSurface.vue +813 -0
  9. package/src/client/ModelsSurface.vue +419 -0
  10. package/src/client/PackageAccounts.vue +330 -0
  11. package/src/client/PackageCatalogSurface.vue +584 -0
  12. package/src/client/PackageSettingsForm.vue +150 -0
  13. package/src/client/PackageSettingsSection.vue +66 -0
  14. package/src/client/PluginsSurface.vue +412 -0
  15. package/src/client/PluginsTrigger.vue +62 -0
  16. package/src/client/UserProfileTrigger.vue +223 -0
  17. package/src/client/UserSettingsSurface.vue +242 -0
  18. package/src/client/assignment-operations.ts +22 -0
  19. package/src/client/bot-settings.test.ts +218 -0
  20. package/src/client/bot-settings.ts +150 -0
  21. package/src/client/index.test.ts +113 -0
  22. package/src/client/index.ts +78 -0
  23. package/src/client/package-settings.test.ts +63 -0
  24. package/src/client/package-settings.ts +77 -0
  25. package/src/client/package-surfaces.test.ts +147 -0
  26. package/src/client/package-surfaces.ts +93 -0
  27. package/src/client/user-display-name.test.ts +44 -0
  28. package/src/client/user-display-name.ts +16 -0
  29. package/src/env.d.ts +6 -0
  30. package/src/index.ts +1 -0
  31. package/src/manifest.ts +3 -0
  32. package/src/user.test.ts +1277 -0
  33. package/src/user.ts +1221 -0
  34. package/tsconfig.json +15 -0
  35. package/vite.config.ts +30 -0
  36. package/README.md +0 -3
@@ -0,0 +1,1076 @@
1
+ <script setup lang="ts">
2
+ import { clientSurfaceRegistryKey } from "@frockbot/client-core";
3
+ import {
4
+ UiAnchor,
5
+ UiButton,
6
+ UiField,
7
+ UiIcon,
8
+ UI_ANCHOR_EVENT,
9
+ type UiAnchorEvent,
10
+ } from "@frockbot/client-ui";
11
+ import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
12
+ import { settingsLinkV1 } from "@frockbot/plugin-shell/settings-links";
13
+ import {
14
+ computed,
15
+ inject,
16
+ onBeforeUnmount,
17
+ onMounted,
18
+ reactive,
19
+ ref,
20
+ nextTick,
21
+ watch,
22
+ } from "vue";
23
+ import {
24
+ assignmentHasPendingOperation,
25
+ projectAssignmentOperations,
26
+ } from "./assignment-operations.js";
27
+ import {
28
+ describeModelAssignment,
29
+ eligibleModelConnections,
30
+ encodeModelSelection,
31
+ modelSelectOptions,
32
+ resolveBotSettingsModel,
33
+ } from "./bot-settings.js";
34
+
35
+ const providedSurfaces = inject(clientSurfaceRegistryKey);
36
+ const providedWeb = inject(frockBotWebDataKey);
37
+ if (!providedSurfaces || !providedWeb) {
38
+ throw new Error("settings client services were not provided");
39
+ }
40
+ const surfaces = providedSurfaces;
41
+ const web = providedWeb;
42
+
43
+ /*
44
+ * Every row below is deep-linkable. The scheme and the anchor table live in
45
+ * `@frockbot/plugin-shell/settings-links`, so a Bot citing a row and the panel
46
+ * rendering it read the same list — a link nobody registered does not resolve,
47
+ * and a row nobody linked has no link to copy.
48
+ */
49
+ function link(anchor: string): string {
50
+ return settingsLinkV1({ anchor, botId: web.value.activeBotId });
51
+ }
52
+
53
+ /*
54
+ * Model and Assignments live under the Advanced disclosure, and a collapsed
55
+ * `details` cannot be scrolled to. A link into either opens it first; the User
56
+ * can still close it, and the `toggle` handler keeps their choice.
57
+ */
58
+ const ADVANCED_ANCHORS = new Set([
59
+ "bot-title",
60
+ "bot-hidden-from-sidebar",
61
+ "bot-model",
62
+ "bot-capabilities",
63
+ "bot-routines",
64
+ "bot-audit",
65
+ "bot-info-identity",
66
+ "bot-info-members",
67
+ ]);
68
+ const advancedOpen = ref(false);
69
+
70
+ function openAdvancedFor(anchor: string): void {
71
+ if (ADVANCED_ANCHORS.has(anchor)) advancedOpen.value = true;
72
+ }
73
+
74
+ function onAnchorAnnounced(event: Event): void {
75
+ const anchor = (event as UiAnchorEvent).detail;
76
+ openAdvancedFor(anchor);
77
+ void nextTick(() =>
78
+ document.getElementById(anchor)?.scrollIntoView({ block: "nearest" }),
79
+ );
80
+ }
81
+
82
+ const name = ref("");
83
+ const label = ref("");
84
+ const description = ref("");
85
+ const title = ref("");
86
+ const hiddenFromSidebar = ref(false);
87
+ const notifications = ref(false);
88
+ /**
89
+ * The Bot's undecided approval cards. Read from the same backend state the
90
+ * conversation renders, so the two surfaces cannot disagree about what is
91
+ * still waiting.
92
+ */
93
+ const pendingApprovals = computed(() =>
94
+ web.value.approvals.filter((approval) => approval.decision === "pending"),
95
+ );
96
+ const decidingApproval = ref<string>();
97
+
98
+ async function decideApproval(
99
+ approvalId: string,
100
+ decision: "approved" | "denied",
101
+ ): Promise<void> {
102
+ decidingApproval.value = approvalId;
103
+ try {
104
+ await web.value.decideApproval(approvalId, decision);
105
+ } finally {
106
+ decidingApproval.value = undefined;
107
+ }
108
+ }
109
+ const saving = ref(false);
110
+ const modelMode = ref<"default" | "custom">("default");
111
+ const assignmentBusy = ref<string>();
112
+ const selectedConnections = reactive<Record<string, string>>({});
113
+
114
+ const capabilityItems = computed(() =>
115
+ web.value.pluginCatalog.flatMap((pkg) =>
116
+ web.value.userSettings?.packages.some(
117
+ (installation) =>
118
+ installation.packageId === pkg.packageId &&
119
+ installation.version === pkg.version &&
120
+ installation.state === "installed",
121
+ )
122
+ ? pkg.capabilities.map((capability) => {
123
+ const existing = web.value.botSettings?.assignments.find(
124
+ (assignment) =>
125
+ assignment.packageId === pkg.packageId &&
126
+ assignment.capabilityId === capability.id,
127
+ );
128
+ const pending = web.value.botSettings?.assignmentOperations.find(
129
+ (operation) =>
130
+ operation.assignmentId === existing?.assignmentId ||
131
+ (operation.target?.packageId === pkg.packageId &&
132
+ operation.target.capabilityId === capability.id),
133
+ );
134
+ const connections =
135
+ web.value.userSettings?.connections.filter(
136
+ (connection) =>
137
+ connection.packageId === pkg.packageId &&
138
+ connection.state === "ready" &&
139
+ capability.connectionTypes.includes(
140
+ connection.connectionTypeId,
141
+ ),
142
+ ) ?? [];
143
+ const key = `${pkg.packageId}:${capability.id}`;
144
+ if (!(key in selectedConnections) && existing?.connectionId) {
145
+ selectedConnections[key] = existing.connectionId;
146
+ }
147
+ return { key, pkg, capability, existing, pending, connections };
148
+ })
149
+ : [],
150
+ ),
151
+ );
152
+
153
+ const assignmentOperations = computed(() =>
154
+ projectAssignmentOperations(web.value.botSettings),
155
+ );
156
+
157
+ const orphanAssignments = computed(
158
+ () =>
159
+ web.value.botSettings?.assignments.filter(
160
+ (assignment) =>
161
+ !capabilityItems.value.some(
162
+ (item) => item.existing?.assignmentId === assignment.assignmentId,
163
+ ),
164
+ ) ?? [],
165
+ );
166
+
167
+ function assignmentOperationPending(assignmentId: string): boolean {
168
+ return assignmentHasPendingOperation(
169
+ assignmentOperations.value,
170
+ assignmentId,
171
+ );
172
+ }
173
+
174
+ const selectedModel = ref("");
175
+ const useExactModel = ref(false);
176
+ const exactConnectionId = ref("");
177
+ const exactProviderModelId = ref("");
178
+ const readyConnections = computed(() =>
179
+ eligibleModelConnections({
180
+ connections: web.value.userSettings?.connections ?? [],
181
+ packages: web.value.userSettings?.packages ?? [],
182
+ catalog: web.value.pluginCatalog,
183
+ }),
184
+ );
185
+ const modelOptions = computed(() => modelSelectOptions(readyConnections.value));
186
+ const defaultModelName = computed(
187
+ () =>
188
+ describeModelAssignment(
189
+ web.value.userSettings?.newBotModelTemplate,
190
+ web.value.userSettings?.connections ?? [],
191
+ ) ?? "none set",
192
+ );
193
+ const overriding = computed(() => Boolean(web.value.botSettings?.model));
194
+
195
+ onMounted(() => {
196
+ window.addEventListener(UI_ANCHOR_EVENT, onAnchorAnnounced);
197
+ openAdvancedFor(decodeURIComponent(window.location.hash.replace(/^#/u, "")));
198
+ void web.value.loadPluginCatalog();
199
+ void web.value.loadBotSettings();
200
+ void web.value.loadUserSettings();
201
+ });
202
+
203
+ /*
204
+ * The form fills itself from whichever Bot's durable settings arrive, rather
205
+ * than from whatever had loaded by the time this panel mounted. A deep link
206
+ * opens the panel before the Flock has selected a Bot, and a User can switch
207
+ * Bots with the panel open; both used to leave the fields on screen belonging
208
+ * to nobody.
209
+ */
210
+ const hydratedBotId = ref<string>();
211
+
212
+ watch(
213
+ () => web.value.botSettings,
214
+ (settings) => {
215
+ if (!settings || hydratedBotId.value === settings.botId) return;
216
+ hydratedBotId.value = settings.botId;
217
+ name.value = settings.profile.name;
218
+ label.value = settings.profile.label ?? "";
219
+ description.value = settings.profile.description ?? "";
220
+ title.value = settings.profile.title ?? "";
221
+ hiddenFromSidebar.value = settings.profile.hiddenFromSidebar === true;
222
+ notifications.value = settings.notifications.enabled;
223
+ modelMode.value = settings.model ? "custom" : "default";
224
+ selectedModel.value = encodeModelSelection(settings.model);
225
+ exactConnectionId.value =
226
+ settings.model?.connectionId ??
227
+ readyConnections.value[0]?.connectionId ??
228
+ "";
229
+ exactProviderModelId.value = settings.model?.providerModelId ?? "";
230
+ useExactModel.value = Boolean(
231
+ settings.model &&
232
+ !modelOptions.value.some((model) => model.value === selectedModel.value),
233
+ );
234
+ },
235
+ { immediate: true },
236
+ );
237
+
238
+ // The Connections may land after the Bot did; an empty exact-model Connection
239
+ // takes the first ready one the moment there is one.
240
+ watch(readyConnections, (connections) => {
241
+ if (!exactConnectionId.value && connections[0]) {
242
+ exactConnectionId.value = connections[0].connectionId;
243
+ }
244
+ });
245
+
246
+ onBeforeUnmount(() =>
247
+ window.removeEventListener(UI_ANCHOR_EVENT, onAnchorAnnounced),
248
+ );
249
+
250
+ async function saveModel(): Promise<void> {
251
+ if (modelMode.value === "default") {
252
+ // Following the default means holding no Bot binding at all.
253
+ if (web.value.botSettings?.model) await web.value.clearBotModel();
254
+ return;
255
+ }
256
+ const selected = resolveBotSettingsModel({
257
+ current: web.value.botSettings?.model,
258
+ useExactModel: useExactModel.value,
259
+ selectedModel: selectedModel.value,
260
+ exactConnectionId: exactConnectionId.value,
261
+ exactProviderModelId: exactProviderModelId.value,
262
+ });
263
+ if (selected) await web.value.saveBotModel(selected);
264
+ }
265
+
266
+ async function save(): Promise<void> {
267
+ saving.value = true;
268
+ try {
269
+ // A partial update: the empty string clears an optional field.
270
+ await web.value.setBotProfile({
271
+ name: name.value,
272
+ label: label.value,
273
+ description: description.value,
274
+ title: title.value,
275
+ hiddenFromSidebar: hiddenFromSidebar.value,
276
+ });
277
+ await saveModel();
278
+ await web.value.saveBotNotifications({ enabled: notifications.value });
279
+ surfaces.close();
280
+ } catch (error) {
281
+ web.value.settingsError =
282
+ error instanceof Error ? error.message : "Could not save settings";
283
+ } finally {
284
+ saving.value = false;
285
+ }
286
+ }
287
+
288
+ async function assign(
289
+ item: (typeof capabilityItems.value)[number],
290
+ ): Promise<void> {
291
+ assignmentBusy.value = item.key;
292
+ try {
293
+ await web.value.assignCapability({
294
+ assignmentId: crypto.randomUUID(),
295
+ packageId: item.pkg.packageId,
296
+ capabilityId: item.capability.id,
297
+ connectionId: selectedConnections[item.key] || undefined,
298
+ });
299
+ } catch (error) {
300
+ web.value.settingsError =
301
+ error instanceof Error ? error.message : "Could not assign Capability";
302
+ await web.value.loadBotSettings();
303
+ } finally {
304
+ assignmentBusy.value = undefined;
305
+ }
306
+ }
307
+
308
+ async function replace(
309
+ item: (typeof capabilityItems.value)[number],
310
+ ): Promise<void> {
311
+ if (!item.existing) return;
312
+ assignmentBusy.value = item.key;
313
+ try {
314
+ await web.value.replaceCapability({
315
+ assignmentId: item.existing.assignmentId,
316
+ packageId: item.pkg.packageId,
317
+ capabilityId: item.capability.id,
318
+ connectionId: selectedConnections[item.key] || undefined,
319
+ });
320
+ } catch (error) {
321
+ web.value.settingsError =
322
+ error instanceof Error ? error.message : "Could not replace Assignment";
323
+ await web.value.loadBotSettings();
324
+ } finally {
325
+ assignmentBusy.value = undefined;
326
+ }
327
+ }
328
+
329
+ async function unassignAssignment(
330
+ assignmentId: string,
331
+ key: string,
332
+ ): Promise<void> {
333
+ assignmentBusy.value = key;
334
+ try {
335
+ await web.value.unassignCapability(assignmentId);
336
+ } catch (error) {
337
+ web.value.settingsError =
338
+ error instanceof Error ? error.message : "Could not unassign Capability";
339
+ await web.value.loadBotSettings();
340
+ } finally {
341
+ assignmentBusy.value = undefined;
342
+ }
343
+ }
344
+
345
+ async function unassign(
346
+ item: (typeof capabilityItems.value)[number],
347
+ ): Promise<void> {
348
+ if (!item.existing) return;
349
+ await unassignAssignment(item.existing.assignmentId, item.key);
350
+ }
351
+ </script>
352
+
353
+ <template>
354
+ <form class="settings-form" @submit.prevent="save">
355
+ <UiAnchor
356
+ anchor="bot-avatar"
357
+ label="Avatar"
358
+ :href="link('bot-avatar')"
359
+ class="settings-row avatar-setting"
360
+ >
361
+ <k-slot name="frockbot.bot-avatar-editor" />
362
+ </UiAnchor>
363
+ <UiAnchor
364
+ anchor="bot-name"
365
+ label="Name"
366
+ :href="link('bot-name')"
367
+ class="settings-row"
368
+ >
369
+ <UiField label="Name">
370
+ <input v-model="name" maxlength="100" required />
371
+ </UiField>
372
+ </UiAnchor>
373
+ <UiAnchor
374
+ anchor="bot-label"
375
+ label="Label"
376
+ :href="link('bot-label')"
377
+ class="settings-row"
378
+ >
379
+ <UiField label="Label" hint="optional">
380
+ <input
381
+ v-model="label"
382
+ maxlength="120"
383
+ placeholder="Research, marketing, admin"
384
+ />
385
+ </UiField>
386
+ </UiAnchor>
387
+ <UiAnchor
388
+ anchor="bot-description"
389
+ label="Description"
390
+ :href="link('bot-description')"
391
+ class="settings-row"
392
+ >
393
+ <UiField label="Description">
394
+ <textarea v-model="description" maxlength="10000" rows="7" />
395
+ </UiField>
396
+ </UiAnchor>
397
+ <div id="bot-info-notifications">
398
+ <UiAnchor
399
+ anchor="bot-notifications"
400
+ label="Notifications"
401
+ :href="link('bot-notifications')"
402
+ class="settings-row"
403
+ >
404
+ <label class="notification-setting">
405
+ <span>
406
+ <strong>Notifications</strong>
407
+ <small>Get notified when this Bot finishes or needs input</small>
408
+ </span>
409
+ <input v-model="notifications" type="checkbox" />
410
+ </label>
411
+ </UiAnchor>
412
+ </div>
413
+ <!--
414
+ Pending decisions. The card in the conversation is where a decision is
415
+ normally answered; this is where the ones nobody scrolled back to are
416
+ still findable, because "a request for more authority becomes a durable
417
+ pending decision for the User" is only true if the User can find it.
418
+ -->
419
+ <UiAnchor
420
+ v-if="pendingApprovals.length > 0"
421
+ anchor="bot-approvals"
422
+ label="Waiting on you"
423
+ :href="link('bot-approvals')"
424
+ class="settings-row"
425
+ >
426
+ <ul class="pending-approvals">
427
+ <li v-for="approval in pendingApprovals" :key="approval.approvalId">
428
+ <span class="pending-approvals__risk">{{ approval.risk }}</span>
429
+ <span class="pending-approvals__action">{{ approval.action }}</span>
430
+ <span class="pending-approvals__actions">
431
+ <UiButton
432
+ :disabled="decidingApproval !== undefined"
433
+ @click="decideApproval(approval.approvalId, 'approved')"
434
+ >Approve</UiButton
435
+ >
436
+ <UiButton
437
+ variant="ghost"
438
+ :disabled="decidingApproval !== undefined"
439
+ @click="decideApproval(approval.approvalId, 'denied')"
440
+ >Deny</UiButton
441
+ >
442
+ </span>
443
+ </li>
444
+ </ul>
445
+ </UiAnchor>
446
+
447
+ <details
448
+ class="advanced"
449
+ :open="advancedOpen"
450
+ @toggle="advancedOpen = ($event.target as HTMLDetailsElement).open"
451
+ >
452
+ <summary>
453
+ <span>Advanced</span>
454
+ <span class="advanced__marker" aria-hidden="true"
455
+ ><UiIcon name="arrow-down" size="sm"
456
+ /></span>
457
+ </summary>
458
+ <div class="advanced__body">
459
+ <UiAnchor
460
+ anchor="bot-title"
461
+ label="Title"
462
+ :href="link('bot-title')"
463
+ class="settings-row"
464
+ >
465
+ <UiField label="Title" hint="optional">
466
+ <input
467
+ v-model="title"
468
+ maxlength="120"
469
+ placeholder="Chief of staff, night-shift researcher"
470
+ />
471
+ </UiField>
472
+ </UiAnchor>
473
+ <UiAnchor
474
+ anchor="bot-hidden-from-sidebar"
475
+ label="Hidden from sidebar"
476
+ :href="link('bot-hidden-from-sidebar')"
477
+ class="settings-row"
478
+ >
479
+ <label class="notification-setting">
480
+ <span>
481
+ <strong>Hidden from sidebar</strong>
482
+ <small
483
+ >Keeps this Bot out of the list without archiving it.</small
484
+ >
485
+ </span>
486
+ <input v-model="hiddenFromSidebar" type="checkbox" />
487
+ </label>
488
+ </UiAnchor>
489
+ <UiAnchor
490
+ anchor="bot-info-identity"
491
+ label="Identity"
492
+ :href="link('bot-info-identity')"
493
+ class="bot-members"
494
+ >
495
+ <div>
496
+ <strong>Identity</strong>
497
+ <p>{{ name || "This Bot" }} · Bot {{ web.activeBotId }}</p>
498
+ </div>
499
+ <span>
500
+ Named by {{ web.botSettings?.profile.namedBy ?? "user" }}
501
+ </span>
502
+ </UiAnchor>
503
+ <UiAnchor
504
+ anchor="bot-info-members"
505
+ label="Members"
506
+ :href="link('bot-info-members')"
507
+ class="bot-members"
508
+ >
509
+ <div>
510
+ <strong>Members</strong>
511
+ <p>This Bot and the authority explicitly assigned to it.</p>
512
+ </div>
513
+ <span>
514
+ {{ web.botSettings?.assignments.length ?? 0 }} Capability
515
+ Assignment(s)
516
+ </span>
517
+ </UiAnchor>
518
+ <p v-if="overriding" class="model-note">Overrides default model</p>
519
+ <UiAnchor
520
+ anchor="bot-model"
521
+ label="Model"
522
+ :href="link('bot-model')"
523
+ class="settings-row"
524
+ >
525
+ <fieldset class="model-mode">
526
+ <legend>Model</legend>
527
+ <label>
528
+ <input v-model="modelMode" type="radio" value="default" />
529
+ <span>Use default model ({{ defaultModelName }})</span>
530
+ </label>
531
+ <label>
532
+ <input v-model="modelMode" type="radio" value="custom" />
533
+ <span>Custom model</span>
534
+ </label>
535
+ </fieldset>
536
+ </UiAnchor>
537
+ <template v-if="modelMode === 'custom'">
538
+ <label class="exact-model-setting">
539
+ <span>
540
+ <strong>Use exact model ID</strong>
541
+ <small>Choose a model not listed in the advisory catalog.</small>
542
+ </span>
543
+ <input v-model="useExactModel" type="checkbox" />
544
+ </label>
545
+ <template v-if="useExactModel">
546
+ <UiField label="Connection">
547
+ <select v-model="exactConnectionId">
548
+ <option disabled value="">Select a Connection</option>
549
+ <option
550
+ v-for="connection in readyConnections"
551
+ :key="connection.connectionId"
552
+ :value="connection.connectionId"
553
+ >
554
+ {{ connection.displayName }}
555
+ </option>
556
+ </select>
557
+ </UiField>
558
+ <UiField label="Exact provider model ID">
559
+ <input
560
+ v-model="exactProviderModelId"
561
+ maxlength="256"
562
+ placeholder="model-name:cloud"
563
+ />
564
+ </UiField>
565
+ </template>
566
+ <UiField v-else label="Model">
567
+ <select v-model="selectedModel">
568
+ <option disabled value="">Select a connected model</option>
569
+ <option
570
+ v-for="model in modelOptions"
571
+ :key="model.value"
572
+ :value="model.value"
573
+ >
574
+ {{ model.label }}
575
+ </option>
576
+ </select>
577
+ </UiField>
578
+ </template>
579
+ <UiAnchor
580
+ anchor="bot-capabilities"
581
+ label="Capability Assignments"
582
+ :href="link('bot-capabilities')"
583
+ class="settings-row"
584
+ >
585
+ <section class="assignment-settings">
586
+ <div>
587
+ <strong>Capability Assignments</strong>
588
+ <p>
589
+ Grant this Bot an installed Capability and required Connection.
590
+ </p>
591
+ </div>
592
+ <article
593
+ v-for="item in capabilityItems"
594
+ :key="item.key"
595
+ class="assignment-card"
596
+ >
597
+ <div>
598
+ <strong
599
+ >{{ item.pkg.displayName }} · {{ item.capability.id }}</strong
600
+ >
601
+ <small v-if="item.pending">
602
+ {{ item.pending.kind }} · {{ item.pending.state }}
603
+ </small>
604
+ <small v-else-if="item.existing">
605
+ {{ item.existing.state }}
606
+ </small>
607
+ <small v-else>Not assigned</small>
608
+ </div>
609
+ <select
610
+ v-if="item.capability.connectionTypes.length > 0"
611
+ v-model="selectedConnections[item.key]"
612
+ :disabled="Boolean(item.pending)"
613
+ :aria-label="`Connection for ${item.capability.id}`"
614
+ >
615
+ <option value="">Choose a ready Connection</option>
616
+ <option
617
+ v-for="connection in item.connections"
618
+ :key="connection.connectionId"
619
+ :value="connection.connectionId"
620
+ >
621
+ {{ connection.displayName }}
622
+ </option>
623
+ </select>
624
+ <div class="assignment-actions">
625
+ <UiButton
626
+ v-if="!item.existing"
627
+ type="button"
628
+ :disabled="
629
+ Boolean(item.pending) ||
630
+ assignmentBusy === item.key ||
631
+ (item.capability.connectionTypes.length > 0 &&
632
+ !selectedConnections[item.key])
633
+ "
634
+ @click="assign(item)"
635
+ >
636
+ Assign
637
+ </UiButton>
638
+ <template v-else>
639
+ <UiButton
640
+ type="button"
641
+ :disabled="
642
+ Boolean(item.pending) || assignmentBusy === item.key
643
+ "
644
+ @click="replace(item)"
645
+ >
646
+ Replace
647
+ </UiButton>
648
+ <UiButton
649
+ type="button"
650
+ variant="danger"
651
+ :disabled="
652
+ Boolean(item.pending) || assignmentBusy === item.key
653
+ "
654
+ @click="unassign(item)"
655
+ >
656
+ Unassign
657
+ </UiButton>
658
+ </template>
659
+ </div>
660
+ </article>
661
+ <article
662
+ v-for="operation in assignmentOperations"
663
+ :key="`operation:${operation.commandId}`"
664
+ class="assignment-card"
665
+ data-assignment-operation
666
+ >
667
+ <div>
668
+ <strong>
669
+ {{ operation.target?.packageId ?? "Unavailable Package" }} ·
670
+ {{ operation.target?.capabilityId ?? operation.assignmentId }}
671
+ </strong>
672
+ <small>{{ operation.kind }} · {{ operation.state }}</small>
673
+ </div>
674
+ </article>
675
+ <article
676
+ v-for="assignment in orphanAssignments"
677
+ :key="assignment.assignmentId"
678
+ class="assignment-card"
679
+ >
680
+ <div>
681
+ <strong
682
+ >{{ assignment.packageId }} ·
683
+ {{ assignment.capabilityId }}</strong
684
+ >
685
+ <small
686
+ >{{ assignment.state }} · no longer available in the
687
+ catalog</small
688
+ >
689
+ </div>
690
+ <div class="assignment-actions">
691
+ <UiButton
692
+ type="button"
693
+ variant="danger"
694
+ :disabled="
695
+ assignmentBusy === assignment.assignmentId ||
696
+ assignmentOperationPending(assignment.assignmentId)
697
+ "
698
+ @click="
699
+ unassignAssignment(
700
+ assignment.assignmentId,
701
+ assignment.assignmentId,
702
+ )
703
+ "
704
+ >
705
+ Unassign
706
+ </UiButton>
707
+ </div>
708
+ </article>
709
+ <p
710
+ v-if="
711
+ capabilityItems.length === 0 && orphanAssignments.length === 0
712
+ "
713
+ class="assignment-empty"
714
+ >
715
+ No assignable Capabilities are available in the production
716
+ catalog.
717
+ </p>
718
+ </section>
719
+ </UiAnchor>
720
+ <k-slot name="frockbot.bot-settings-sections" />
721
+ </div>
722
+ </details>
723
+ <div class="primary-contributions">
724
+ <k-slot name="frockbot.bot-settings-primary-sections" />
725
+ </div>
726
+ <p v-if="web.settingsError" class="settings-error" role="alert">
727
+ {{ web.settingsError }}
728
+ </p>
729
+ <div class="settings-actions">
730
+ <UiButton type="submit" variant="primary" :disabled="saving">
731
+ {{ saving ? "Saving…" : "Save settings" }}
732
+ </UiButton>
733
+ </div>
734
+ </form>
735
+ </template>
736
+
737
+ <style scoped>
738
+ /*
739
+ * A deep-linkable row. The anchor floats its copy control in the top-right
740
+ * corner, so every row keeps that corner clear.
741
+ */
742
+ .settings-row {
743
+ display: flex;
744
+ flex-direction: column;
745
+ gap: 8px;
746
+ padding-right: var(--frock-control-sm);
747
+ }
748
+
749
+ .settings-form {
750
+ display: flex;
751
+ flex-direction: column;
752
+ gap: 16px;
753
+ padding: 16px;
754
+ }
755
+
756
+ .assignment-card,
757
+ .notification-setting,
758
+ .bot-members {
759
+ border: 1px solid var(--frock-border);
760
+ border-radius: var(--frock-radius-card);
761
+ background: var(--frock-surface-subtle);
762
+ }
763
+
764
+ .avatar-setting {
765
+ display: flex;
766
+ flex-direction: column;
767
+ align-items: center;
768
+ gap: 12px;
769
+ padding-right: 0;
770
+ }
771
+
772
+ .assignment-settings p,
773
+ .assignment-card small {
774
+ margin-top: 4px;
775
+ color: var(--frock-text-muted);
776
+ font-size: var(--frock-text-sm);
777
+ line-height: var(--frock-leading-normal);
778
+ }
779
+
780
+ .assignment-settings {
781
+ display: grid;
782
+ gap: 10px;
783
+ }
784
+
785
+ .bot-members {
786
+ display: flex;
787
+ align-items: center;
788
+ justify-content: space-between;
789
+ gap: 12px;
790
+ padding: 12px;
791
+ }
792
+
793
+ .bot-members strong,
794
+ .bot-members p {
795
+ display: block;
796
+ margin: 0;
797
+ }
798
+
799
+ .bot-members strong {
800
+ color: var(--frock-text);
801
+ font-size: var(--frock-text-md);
802
+ }
803
+
804
+ .bot-members p,
805
+ .bot-members > span {
806
+ margin-top: 4px;
807
+ color: var(--frock-text-muted);
808
+ font-size: var(--frock-text-sm);
809
+ }
810
+
811
+ .bot-members > span {
812
+ max-width: 110px;
813
+ flex: 0 0 auto;
814
+ text-align: right;
815
+ }
816
+
817
+ .assignment-settings strong {
818
+ font-size: var(--frock-text-md);
819
+ font-weight: 600;
820
+ }
821
+
822
+ .assignment-card {
823
+ display: grid;
824
+ gap: 8px;
825
+ padding: 12px;
826
+ }
827
+
828
+ .assignment-card strong {
829
+ font-size: var(--frock-text-md);
830
+ font-weight: 600;
831
+ overflow-wrap: anywhere;
832
+ }
833
+
834
+ .assignment-card small {
835
+ display: block;
836
+ }
837
+
838
+ .assignment-card select {
839
+ width: 100%;
840
+ }
841
+
842
+ .assignment-actions {
843
+ display: flex;
844
+ flex-wrap: wrap;
845
+ justify-content: flex-end;
846
+ gap: 8px;
847
+ }
848
+
849
+ /* The right panel is ~360px wide, so Assignment controls stay compact. */
850
+ .assignment-actions :deep(.ui-button) {
851
+ min-height: 30px;
852
+ padding: 0 10px;
853
+ font-size: var(--frock-text-sm);
854
+ }
855
+
856
+ .assignment-empty {
857
+ padding: 12px;
858
+ color: var(--frock-text-muted);
859
+ font-size: var(--frock-text-sm);
860
+ border: 1px dashed var(--frock-border);
861
+ border-radius: var(--frock-radius-card);
862
+ }
863
+
864
+ .exact-model-setting,
865
+ .notification-setting {
866
+ display: flex;
867
+ align-items: center;
868
+ justify-content: space-between;
869
+ gap: 12px;
870
+ padding: 12px;
871
+ border: 1px solid var(--frock-border);
872
+ border-radius: var(--frock-radius-card);
873
+ background: var(--frock-surface-subtle);
874
+ }
875
+
876
+ .exact-model-setting strong,
877
+ .exact-model-setting small,
878
+ .notification-setting strong,
879
+ .notification-setting small {
880
+ display: block;
881
+ }
882
+
883
+ .notification-setting strong,
884
+ .exact-model-setting strong {
885
+ font-size: var(--frock-text-md);
886
+ font-weight: 600;
887
+ }
888
+
889
+ .exact-model-setting small,
890
+ .notification-setting small {
891
+ margin-top: 4px;
892
+ color: var(--frock-text-muted);
893
+ font-size: var(--frock-text-sm);
894
+ }
895
+
896
+ .exact-model-setting input[type="checkbox"],
897
+ .notification-setting input[type="checkbox"] {
898
+ position: relative;
899
+ width: 38px;
900
+ height: 22px;
901
+ flex: 0 0 auto;
902
+ appearance: none;
903
+ border: 1px solid var(--frock-border-strong);
904
+ border-radius: 999px;
905
+ background: var(--frock-fill-pressed);
906
+ cursor: pointer;
907
+ transition: background-color var(--frock-motion-fast);
908
+ }
909
+
910
+ .exact-model-setting input[type="checkbox"]::before,
911
+ .notification-setting input[type="checkbox"]::before {
912
+ position: absolute;
913
+ top: 2px;
914
+ left: 2px;
915
+ width: 16px;
916
+ height: 16px;
917
+ border-radius: 50%;
918
+ background: var(--frock-surface-raised);
919
+ box-shadow: var(--frock-shadow-control);
920
+ content: "";
921
+ transition: transform var(--frock-motion-fast);
922
+ }
923
+
924
+ .exact-model-setting input[type="checkbox"]:checked,
925
+ .notification-setting input[type="checkbox"]:checked {
926
+ border-color: var(--frock-action-primary);
927
+ background: var(--frock-action-primary);
928
+ }
929
+
930
+ .exact-model-setting input[type="checkbox"]:checked::before,
931
+ .notification-setting input[type="checkbox"]:checked::before {
932
+ transform: translateX(16px);
933
+ }
934
+
935
+ .exact-model-setting input[type="checkbox"]:focus-visible,
936
+ .notification-setting input[type="checkbox"]:focus-visible {
937
+ outline: 2px solid var(--frock-focus-ring);
938
+ outline-offset: 2px;
939
+ }
940
+
941
+ .pending-approvals {
942
+ display: flex;
943
+ flex-direction: column;
944
+ gap: 0.5rem;
945
+ margin: 0;
946
+ padding: 0;
947
+ list-style: none;
948
+ }
949
+
950
+ .pending-approvals li {
951
+ display: flex;
952
+ flex-wrap: wrap;
953
+ gap: 0.5rem;
954
+ align-items: center;
955
+ border: 1px solid var(--frock-border);
956
+ border-radius: var(--frock-radius-card);
957
+ background: var(--frock-surface-raised);
958
+ padding: 0.5rem 0.75rem;
959
+ }
960
+
961
+ .pending-approvals__risk {
962
+ border: 1px solid var(--frock-border);
963
+ border-radius: var(--frock-radius-control);
964
+ padding: 0.125rem 0.5rem;
965
+ color: var(--frock-text-muted);
966
+ font-size: var(--frock-text-xs);
967
+ text-transform: uppercase;
968
+ }
969
+
970
+ .pending-approvals__action {
971
+ flex: 1 1 12rem;
972
+ color: var(--frock-text);
973
+ font-size: var(--frock-text-sm);
974
+ }
975
+
976
+ .pending-approvals__actions {
977
+ display: flex;
978
+ gap: 0.5rem;
979
+ }
980
+
981
+ .model-note {
982
+ margin: 0;
983
+ color: var(--frock-text-muted);
984
+ font-size: var(--frock-text-sm);
985
+ }
986
+
987
+ .advanced {
988
+ border-top: 1px solid var(--frock-border);
989
+ padding-top: 12px;
990
+ }
991
+
992
+ .advanced summary {
993
+ display: flex;
994
+ align-items: center;
995
+ justify-content: space-between;
996
+ gap: 8px;
997
+ color: var(--frock-text-muted);
998
+ font-size: var(--frock-text-sm);
999
+ font-weight: 600;
1000
+ list-style: none;
1001
+ cursor: pointer;
1002
+ }
1003
+
1004
+ .advanced summary::-webkit-details-marker {
1005
+ display: none;
1006
+ }
1007
+
1008
+ .advanced__marker {
1009
+ display: grid;
1010
+ place-items: center;
1011
+ transition: transform var(--frock-motion-fast);
1012
+ }
1013
+
1014
+ .advanced[open] .advanced__marker {
1015
+ transform: rotate(180deg);
1016
+ }
1017
+
1018
+ .advanced__body {
1019
+ display: flex;
1020
+ flex-direction: column;
1021
+ gap: 12px;
1022
+ padding-top: 12px;
1023
+ }
1024
+
1025
+ .model-mode {
1026
+ display: flex;
1027
+ flex-direction: column;
1028
+ gap: 10px;
1029
+ margin: 0;
1030
+ border: 0;
1031
+ padding: 0;
1032
+ }
1033
+
1034
+ .model-mode legend {
1035
+ float: left;
1036
+ width: 100%;
1037
+ margin-bottom: 4px;
1038
+ padding: 0;
1039
+ color: var(--frock-text);
1040
+ font-size: var(--frock-text-md);
1041
+ font-weight: 600;
1042
+ }
1043
+
1044
+ .model-mode label {
1045
+ display: flex;
1046
+ align-items: center;
1047
+ gap: 8px;
1048
+ color: var(--frock-text);
1049
+ font-size: var(--frock-text-md);
1050
+ cursor: pointer;
1051
+ }
1052
+
1053
+ .model-mode input {
1054
+ width: 17px;
1055
+ height: 17px;
1056
+ flex: 0 0 auto;
1057
+ accent-color: var(--frock-action-primary);
1058
+ }
1059
+
1060
+ .settings-error {
1061
+ margin: 0;
1062
+ color: var(--frock-danger-text);
1063
+ font-size: var(--frock-text-sm);
1064
+ }
1065
+
1066
+ .settings-actions {
1067
+ display: flex;
1068
+ justify-content: flex-end;
1069
+ }
1070
+
1071
+ .primary-contributions {
1072
+ display: flex;
1073
+ flex-direction: column;
1074
+ gap: 12px;
1075
+ }
1076
+ </style>