@genesislcap/ai-assistant 15.10.6 → 15.11.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.
@@ -217,6 +217,8 @@ const platformContextUsageTemplate = (
217
217
  */
218
218
  const platformBudgetUsageTemplate = (
219
219
  progressTag: string,
220
+ buttonTag: string,
221
+ iconTag: string,
220
222
  ): ViewTemplate<FoundationAiAssistant> => html<FoundationAiAssistant>`
221
223
  ${when(
222
224
  (x) => x.settingsBudgetUsageVisible,
@@ -237,6 +239,26 @@ const platformBudgetUsageTemplate = (
237
239
  </div>
238
240
  `,
239
241
  )}
242
+ <!--
243
+ Freshness footer (GENC-1464): the rows above update per turn and at
244
+ the wall, which reads as "stale" to anyone watching the modal — the
245
+ label says when the figures arrived, the button asks the host for new
246
+ ones (see FoundationAiAssistant.requestBudgetRefresh). Below the rows
247
+ and left-aligned, reading as a footnote to the figures it describes.
248
+ -->
249
+ <div class="settings-budget-toolbar" part="budget-toolbar">
250
+ <${buttonTag}
251
+ appearance="stealth"
252
+ class="settings-budget-refresh"
253
+ part="budget-refresh"
254
+ title="Refresh budget figures"
255
+ aria-label="Refresh budget figures"
256
+ @click=${(x: FoundationAiAssistant) => x.requestBudgetRefresh()}
257
+ >
258
+ <${iconTag} name="arrows-rotate"></${iconTag}>
259
+ </${buttonTag}>
260
+ <span class="settings-budget-updated">${(x) => x.settingsBudgetUpdatedLabel}</span>
261
+ </div>
240
262
  </div>
241
263
  `,
242
264
  )}
@@ -265,12 +287,15 @@ const platformBudgetUsageTemplate = (
265
287
  */
266
288
  export const settingsUsageMetersTemplate = (
267
289
  progressTag: string,
290
+ buttonTag: string,
291
+ iconTag: string,
268
292
  ): ViewTemplate<FoundationAiAssistant> => html<FoundationAiAssistant>`
269
293
  ${when(
270
294
  (x) => x.settingsContextUsageVisible || x.settingsBudgetUsageVisible,
271
295
  html<FoundationAiAssistant>`
272
296
  <div class="settings-usage-meters">
273
- ${platformContextUsageTemplate(progressTag)} ${platformBudgetUsageTemplate(progressTag)}
297
+ ${platformContextUsageTemplate(progressTag)}
298
+ ${platformBudgetUsageTemplate(progressTag, buttonTag, iconTag)}
274
299
  </div>
275
300
  `,
276
301
  )}
@@ -735,7 +760,7 @@ export const SettingsModalTemplate = (
735
760
  </h3>
736
761
  <div class="settings-modal-section-body">
737
762
  <slot name="settings-model" ${slotted('settingsModelSlotted')}></slot>
738
- ${settingsUsageMetersTemplate(progressTag)}
763
+ ${settingsUsageMetersTemplate(progressTag, buttonTag, iconTag)}
739
764
  </div>
740
765
  </section>
741
766
  <!--
@@ -325,6 +325,7 @@ Suite('a wall with figures snaps the vendor meter at latch time', () => {
325
325
  reachable(el, 'anthropic', 'gemini');
326
326
  latch(el, 'budget-exhausted', { vendorLabel: 'Anthropic', budgetUsd: 20, spentUsd: 20.1 });
327
327
  assert.equal(storedBudgets(el)['anthropic'], { budgetUsd: 20, spentUsd: 20.1 });
328
+ assert.ok(typeof el.budgetMeterLastFedAt === 'number', 'the wall feed stamps freshness too');
328
329
 
329
330
  // A later 402 for the same (already-walled) vendor carries fresher spend —
330
331
  // the feed is deliberately outside the idempotence guard, like the sweep.
@@ -424,4 +424,54 @@ Suite('figures alone are enough to surface the AI Model Settings section', () =>
424
424
  assert.is(el.settingsModelSectionVisible, false, 'and the config flag takes it back out');
425
425
  });
426
426
 
427
+ // ── Freshness stamp + refresh request (GENC-1464 refresh affordance) ─────────
428
+
429
+ Suite('every feed stamps budgetMeterLastFedAt — identical figures included', () => {
430
+ const el = element();
431
+ assert.is(el.budgetMeterLastFedAt, null, 'null before any feed');
432
+ assert.is(el.settingsBudgetUpdatedLabel, undefined, 'no label before any feed');
433
+
434
+ el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 10 });
435
+ const first = el.budgetMeterLastFedAt;
436
+ assert.ok(typeof first === 'number', 'stamped on feed');
437
+ assert.ok(el.settingsBudgetUpdatedLabel!.startsWith('Updated '), 'label renders');
438
+
439
+ // The store is idempotent by value, but a refresh that CONFIRMS the same
440
+ // dollars is still a refresh — the stamp must move even when the rows don't.
441
+ el.budgetMeterLastFedAt = first! - 5000; // age it so a same-ms re-feed still differs
442
+ el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 10 });
443
+ assert.ok(el.budgetMeterLastFedAt! > first! - 5000, 'identical re-feed still stamps');
444
+
445
+ // Clearing figures is not a refresh — the label describes shown figures.
446
+ const before = el.budgetMeterLastFedAt;
447
+ el.setVendorBudget('anthropic', null);
448
+ assert.is(el.budgetMeterLastFedAt, before, 'a clear does not stamp');
449
+ });
450
+
451
+ Suite('requestBudgetRefresh emits budget-refresh-requested, and settings open asks too', () => {
452
+ const el = element();
453
+ let fired = 0;
454
+ el.addEventListener('budget-refresh-requested', () => {
455
+ fired += 1;
456
+ });
457
+
458
+ el.requestBudgetRefresh();
459
+ assert.is(fired, 1, 'the button path emits');
460
+
461
+ // The element cannot fetch — the event is the whole contract. Hosts without
462
+ // a listener simply keep the per-turn cadence, so firing is unconditional.
463
+ //
464
+ // The modal stub is load-bearing: openSettingsModal's `show` closure
465
+ // re-queues itself via DOM.queueUpdate until `settingsModal` exists, and on
466
+ // an unconnected element that is never — an infinite queue that keeps the
467
+ // test runner alive (this file's documented hang class). `open: true` makes
468
+ // the first queued run return immediately.
469
+ (el as unknown as { settingsModal: { open: boolean; show(): void } }).settingsModal = {
470
+ open: true,
471
+ show() {},
472
+ };
473
+ el.openSettingsModal();
474
+ assert.is(fired, 2, 'opening settings requests fresh figures');
475
+ });
476
+
427
477
  Suite.run();
package/src/main/main.ts CHANGED
@@ -1023,9 +1023,52 @@ export class FoundationAiAssistant extends GenesisElement {
1023
1023
  figures:
1024
1024
  figures == null ? null : { budgetUsd: figures.budgetUsd, spentUsd: figures.spentUsd },
1025
1025
  });
1026
+ if (figures != null) this.budgetMeterLastFedAt = Date.now();
1026
1027
  return true;
1027
1028
  }
1028
1029
 
1030
+ /**
1031
+ * When budget figures last arrived — from the host's {@link FoundationAiAssistant.setVendorBudget}
1032
+ * or the wall latch's own feed — as epoch ms; `null` until the first feed.
1033
+ *
1034
+ * ELEMENT state, not slice state, on purpose: the reducer is idempotent by
1035
+ * value (an unchanged re-feed must not re-render the meter rows), but "when
1036
+ * was this fetched" must move on every feed, changed figures or not — a
1037
+ * refresh that confirms the same dollars is still a refresh. Only the
1038
+ * toolbar label observes this, so a stamp re-renders one span, not the rows.
1039
+ */
1040
+ @observable budgetMeterLastFedAt: number | null = null;
1041
+
1042
+ /** "Updated HH:MM:SS" for the meter toolbar, or `undefined` before any feed. */
1043
+ @volatile
1044
+ get settingsBudgetUpdatedLabel(): string | undefined {
1045
+ if (this.budgetMeterLastFedAt == null) return undefined;
1046
+ return `Updated ${new Date(this.budgetMeterLastFedAt).toLocaleTimeString()}`;
1047
+ }
1048
+
1049
+ /**
1050
+ * Ask the host to re-fetch budget figures now (GENC-1464 refresh affordance).
1051
+ *
1052
+ * The element cannot fetch — the meter is host-fed by design — so this emits
1053
+ * `budget-refresh-requested` and the host answers with the same fetch that
1054
+ * already feeds {@link FoundationAiAssistant.setVendorBudget} (Create's handler coalesces in-flight
1055
+ * fetches, so a spammed button costs one request). Fired by the meter's
1056
+ * refresh button and on settings-modal open, so the figures are fresh exactly
1057
+ * when someone is looking at them. Fires unconditionally: a host without a
1058
+ * listener simply keeps the per-turn cadence.
1059
+ */
1060
+ requestBudgetRefresh(): void {
1061
+ // Plain dispatchEvent, not FAST's $emit: $emit silently no-ops while the
1062
+ // element is disconnected, and this must stay observable in headless/test
1063
+ // harnesses that never connect (and in any odd pop-out lifecycle moment).
1064
+ // bubbles + composed, matching this class's other events (SessionCleared
1065
+ // et al): the element lives inside host shadow roots (Create nests it in
1066
+ // gc-assistant's), and a delegation-based host must still see the request.
1067
+ this.dispatchEvent(
1068
+ new CustomEvent('budget-refresh-requested', { bubbles: true, composed: true }),
1069
+ );
1070
+ }
1071
+
1029
1072
  /** Whether this vendor's wall came from the sweep alone — see the slice's `sweptVendors`. */
1030
1073
  private isVendorSwept(vendor: AIProviderType): boolean {
1031
1074
  return (this._sessionRef?.store.aiAssistant.sweptVendors ?? []).includes(vendor);
@@ -1248,6 +1291,7 @@ export class FoundationAiAssistant extends GenesisElement {
1248
1291
  vendor,
1249
1292
  figures: { budgetUsd: budget.budgetUsd, spentUsd: budget.spentUsd },
1250
1293
  });
1294
+ this.budgetMeterLastFedAt = Date.now();
1251
1295
  }
1252
1296
  if (!vendor || vendor === 'none') {
1253
1297
  if (!this.blocked) {
@@ -3789,6 +3833,9 @@ export class FoundationAiAssistant extends GenesisElement {
3789
3833
 
3790
3834
  openSettingsModal(): void {
3791
3835
  this.logMeta('panel.toggled', { panel: 'settings', open: true });
3836
+ // The meter refreshes per turn, which a user staring at the modal cannot
3837
+ // see — ask the host for fresh figures at the moment they start looking.
3838
+ this.requestBudgetRefresh();
3792
3839
  this.settingsModalTab = 'settings';
3793
3840
  this.settingsOpen = true;
3794
3841
  const show = (): void => {