@genesislcap/ai-assistant 15.6.2 → 15.7.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 (64) hide show
  1. package/dist/ai-assistant.api.json +391 -5
  2. package/dist/ai-assistant.d.ts +613 -6
  3. package/dist/chat-driver.cjs +285 -26
  4. package/dist/chat-driver.cjs.map +3 -3
  5. package/dist/chat-driver.mjs +285 -26
  6. package/dist/chat-driver.mjs.map +3 -3
  7. package/dist/custom-elements.json +254 -10
  8. package/dist/dts/channel/ai-activity-channel.d.ts +51 -1
  9. package/dist/dts/channel/ai-activity-channel.d.ts.map +1 -1
  10. package/dist/dts/components/chat-driver/chat-driver.d.ts +99 -1
  11. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  12. package/dist/dts/components/chat-driver/chat-driver.test.d.ts.map +1 -1
  13. package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts +2 -0
  14. package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts.map +1 -0
  15. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +14 -0
  16. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
  17. package/dist/dts/main/blocked-state.test.d.ts +2 -0
  18. package/dist/dts/main/blocked-state.test.d.ts.map +1 -0
  19. package/dist/dts/main/main.d.ts +394 -6
  20. package/dist/dts/main/main.d.ts.map +1 -1
  21. package/dist/dts/main/main.styles.d.ts.map +1 -1
  22. package/dist/dts/main/main.styles.test.d.ts +2 -0
  23. package/dist/dts/main/main.styles.test.d.ts.map +1 -0
  24. package/dist/dts/main/main.template.d.ts +53 -0
  25. package/dist/dts/main/main.template.d.ts.map +1 -1
  26. package/dist/dts/state/ai-assistant-slice.d.ts +162 -6
  27. package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
  28. package/dist/dts/state/debug-event-log.d.ts +6 -1
  29. package/dist/dts/state/debug-event-log.d.ts.map +1 -1
  30. package/dist/dts/state/session-store.d.ts +11 -0
  31. package/dist/dts/state/session-store.d.ts.map +1 -1
  32. package/dist/esm/components/chat-driver/chat-driver.js +263 -21
  33. package/dist/esm/components/chat-driver/chat-driver.test.js +464 -1
  34. package/dist/esm/components/orchestrating-driver/orchestrating-driver.budget.test.js +312 -0
  35. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +89 -4
  36. package/dist/esm/main/blocked-state.test.js +969 -0
  37. package/dist/esm/main/main.js +704 -16
  38. package/dist/esm/main/main.styles.js +47 -0
  39. package/dist/esm/main/main.styles.test.js +86 -0
  40. package/dist/esm/main/main.template.js +121 -4
  41. package/dist/esm/state/ai-assistant-slice.js +145 -7
  42. package/dist/esm/state/ai-assistant-slice.test.js +138 -1
  43. package/dist/esm/state/debug-event-log.js +7 -2
  44. package/dist/esm/state/debug-event-log.test.js +49 -1
  45. package/dist/esm/state/persistence/session-snapshot.test.js +18 -0
  46. package/dist/tsconfig.tsbuildinfo +1 -1
  47. package/docs/migration-GENC-1464.md +562 -0
  48. package/docs/sub_agent.md +20 -3
  49. package/package.json +17 -17
  50. package/src/channel/ai-activity-channel.ts +56 -2
  51. package/src/components/chat-driver/chat-driver.test.ts +549 -0
  52. package/src/components/chat-driver/chat-driver.ts +324 -14
  53. package/src/components/orchestrating-driver/orchestrating-driver.budget.test.ts +438 -0
  54. package/src/components/orchestrating-driver/orchestrating-driver.ts +101 -6
  55. package/src/main/blocked-state.test.ts +1316 -0
  56. package/src/main/main.styles.test.ts +103 -0
  57. package/src/main/main.styles.ts +47 -0
  58. package/src/main/main.template.ts +131 -4
  59. package/src/main/main.ts +704 -10
  60. package/src/state/ai-assistant-slice.test.ts +215 -0
  61. package/src/state/ai-assistant-slice.ts +218 -8
  62. package/src/state/debug-event-log.test.ts +63 -0
  63. package/src/state/debug-event-log.ts +7 -2
  64. package/src/state/persistence/session-snapshot.test.ts +22 -0
@@ -0,0 +1,103 @@
1
+ import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
2
+ import { styles } from './main.styles';
3
+
4
+ // CSS is not otherwise unit-tested in this package, so this covers the two
5
+ // properties of the BLOCKED BANNER that are cheap to assert from the generated
6
+ // text and that a reviewer had to catch by eye:
7
+ //
8
+ // 1. Design-system tokens are consumed UNFALLBACKED, as every other rule in the
9
+ // file does. A `var(--token, fallback)` here is dead code at best — both
10
+ // tokens below ship with defaults — and actively wrong at worst: the blocked
11
+ // banner's old `var(--neutral-layer-2, transparent)` would, if it ever fired,
12
+ // have produced a banner with no surface at all.
13
+ // 2. The blocked banner's visual treatment hangs off `.is-visible`, not the bare
14
+ // class, because the container stays mounted while unblocked so that its
15
+ // `role="status"` region is registered before the text arrives (see
16
+ // main.template.ts). Collapsing the empty container would undo that.
17
+ //
18
+ // Scope: the banner's own rules, and nothing else. Both negative assertions were
19
+ // written for one component, but an earlier version grepped the WHOLE stylesheet
20
+ // — quietly turning a banner test into a global style policy nobody agreed to, so
21
+ // any future rule anywhere in an 800-line sheet that legitimately wanted a
22
+ // fallback on either token would have failed a test named after the banner.
23
+
24
+ const suite = createLogicSuite('main.styles blocked-banner rules');
25
+
26
+ /**
27
+ * The generated stylesheet text. `css` composes nested `ElementStyles` (this file
28
+ * builds the sheet from several partials), so flatten recursively rather than
29
+ * only reading the top level — a shallow read silently returns almost nothing,
30
+ * which would make every `assert.not.ok` below pass vacuously. The first test
31
+ * guards exactly that.
32
+ */
33
+ const flatten = (value: unknown): string => {
34
+ if (typeof value === 'string') return value;
35
+ if (Array.isArray(value)) return value.map(flatten).join('\n');
36
+ const nested = (value as { styles?: unknown } | null | undefined)?.styles;
37
+ return nested ? flatten(nested) : '';
38
+ };
39
+
40
+ const cssText = flatten(styles);
41
+
42
+ /**
43
+ * Every declaration block whose selector mentions `.blocked-banner`, joined.
44
+ *
45
+ * Deliberately crude — one regex over the generated text, no CSS parser — but
46
+ * enough to keep these assertions about the banner rather than about the whole
47
+ * sheet, which was the only thing wrong with them. It matches
48
+ * `<selector> { <declarations> }` for any selector containing `.blocked-banner`,
49
+ * so it picks up the bare class, the `.is-visible` / `.is-partial` modifiers, the
50
+ * shared composer-width group and the copy inside the container query alike.
51
+ */
52
+ const bannerRules = (cssText.match(/[^{}]*\.blocked-banner[^{}]*\{[^}]*\}/g) ?? []).join('\n');
53
+
54
+ /** Declarations of the bare-class rule — the one that applies while empty. */
55
+ const emptyStateRule = cssText.match(/[\s{}](\.blocked-banner)\s*\{([^}]*)\}/)?.[2] ?? '';
56
+
57
+ suite('the banner rules were extracted as text (guards the assertions below)', () => {
58
+ assert.ok(cssText.includes('.blocked-banner'), 'the banner rules are in the sheet');
59
+ assert.ok(bannerRules.includes('.is-visible'), 'the extraction found the modifier rules');
60
+ assert.ok(emptyStateRule.length > 0, 'and isolated the bare-class rule');
61
+ });
62
+
63
+ suite('the banner consumes design-system tokens without dead fallbacks', () => {
64
+ assert.not.ok(
65
+ bannerRules.includes('var(--neutral-layer-2,'),
66
+ '--neutral-layer-2 has a DS default and is used unfallbacked everywhere else here',
67
+ );
68
+ assert.not.ok(
69
+ bannerRules.includes('var(--warning-color,'),
70
+ '--warning-color has a DS default and is used unfallbacked by toast/badge styles',
71
+ );
72
+ });
73
+
74
+ suite('the blocked banner keeps its unblocked container visually inert', () => {
75
+ // `::part(blocked-banner)` matches at all times, so the empty state must set
76
+ // nothing that reserves space — and must not remove the container from the
77
+ // accessibility tree, which is where the `role="status"` region is registered.
78
+ //
79
+ // Asserted against the bare-class rule's own declarations rather than by
80
+ // grepping for a `:empty` selector nobody wrote: this catches the mistake as it
81
+ // would actually be made — a padding or border creeping onto the always-present
82
+ // container — instead of one specific spelling of it.
83
+ //
84
+ // `margin-block` rather than `margin`: the shared composer-width group sets
85
+ // `margin-inline: auto` on the banner along with the rest of the composer
86
+ // column, which centres it and reserves nothing.
87
+ assert.ok(cssText.includes('.blocked-banner.is-visible'), 'visuals hang off is-visible');
88
+ for (const property of ['display: none', 'padding', 'border', 'background', 'margin-block']) {
89
+ assert.not.ok(
90
+ emptyStateRule.includes(property),
91
+ `the always-present container must not set ${property} — that is .is-visible's job`,
92
+ );
93
+ }
94
+ });
95
+
96
+ suite('partial exhaustion has its own modifier, layered on the visible state', () => {
97
+ // `.is-visible` carries the layout and surface for BOTH states; `.is-partial`
98
+ // only softens it. Additive, never a replacement — so host CSS keyed on the
99
+ // visible state needs to know nothing about partial exhaustion.
100
+ assert.ok(cssText.includes('.blocked-banner.is-partial'), 'the partial modifier exists');
101
+ });
102
+
103
+ suite.run();
@@ -441,6 +441,7 @@ const baseStyles = css`
441
441
  .attachment-chips,
442
442
  .agent-picker-panel,
443
443
  .session-menu-panel,
444
+ .blocked-banner,
444
445
  chat-suggestions {
445
446
  box-sizing: border-box;
446
447
  width: 100%;
@@ -464,6 +465,13 @@ const baseStyles = css`
464
465
  border-radius: 12px;
465
466
  }
466
467
 
468
+ /* The banner joins the same floating-card family, but keeps its own
469
+ warning-toned border — overriding it with the neutral stroke would erase
470
+ the one colour signal it carries. Only the radius needs to match. */
471
+ .blocked-banner.is-visible {
472
+ border-radius: 12px;
473
+ }
474
+
467
475
  /* chat-suggestions paints its background on an inner element, so clip it to the
468
476
  rounded corners — otherwise that square inner background bleeds past the radius
469
477
  and the corners read as square with the border hidden behind them. */
@@ -617,6 +625,45 @@ const baseStyles = css`
617
625
  font-size: 0.9em;
618
626
  }
619
627
 
628
+ /* Backend-blocked banner (GENC-1464) — sits between the transcript and the
629
+ disabled composer. Flat and quiet rather than an error toast: the condition
630
+ is standing, not a momentary failure, so it should read as part of the
631
+ chrome the user is now working within. */
632
+
633
+ /* The container is ALWAYS in the DOM so the live region is registered before the
634
+ text arrives (see main.template.ts) — so the bare selector carries only layout,
635
+ and an unblocked banner is an empty, zero-height, invisible box. Everything
636
+ that would make it visible hangs off .is-visible. */
637
+ .blocked-banner {
638
+ display: flex;
639
+ align-items: center;
640
+ gap: calc(var(--design-unit) * 2px);
641
+ }
642
+
643
+ .blocked-banner.is-visible {
644
+ /* Block axis only. The inline-axis margin is owned by the composer width group
645
+ above (margin-inline: auto), which centres the banner on the same column as
646
+ the input row — an inline margin here would fight it. */
647
+ margin-block: calc(var(--design-unit) * 1px);
648
+ padding: calc(var(--design-unit) * 2px) calc(var(--design-unit) * 3px);
649
+ border: calc(var(--stroke-width) * 1px) solid var(--warning-color);
650
+ border-radius: calc(var(--control-corner-radius) * 1px);
651
+ background: var(--neutral-layer-2);
652
+ color: var(--neutral-foreground-rest);
653
+ }
654
+
655
+ /* Partial exhaustion: at least one vendor is walled but the composer is still
656
+ live, so the banner is advice rather than a wall. Softened to match — same
657
+ layout, same part, lower alarm. */
658
+ .blocked-banner.is-partial {
659
+ border-color: var(--neutral-stroke-rest);
660
+ }
661
+
662
+ .blocked-banner-text {
663
+ font-size: 0.9em;
664
+ line-height: 1.4;
665
+ }
666
+
620
667
  .chat-input {
621
668
  flex: 1;
622
669
  resize: none;
@@ -274,6 +274,118 @@ const restoringTemplate = html<FoundationAiAssistant>`
274
274
  </div>
275
275
  `;
276
276
 
277
+ /**
278
+ * Class list for the banner below, joined rather than interpolated so an
279
+ * inapplicable modifier contributes nothing. Two interpolations directly in the
280
+ * attribute emitted `class="blocked-banner "` in the common (unblocked) case —
281
+ * harmless to the browser, but it shows up in every DOM snapshot and every
282
+ * innerHTML assertion a host writes against this element.
283
+ *
284
+ * The two modifiers name what they actually gate, which is why neither is
285
+ * `is-blocked`: `is-visible` means the banner has something to say, and that
286
+ * includes PARTIAL exhaustion — one vendor walled, composer still live, `blocked`
287
+ * false. `is-partial` then softens the treatment for exactly that case. Naming
288
+ * the first after `blocked` read as a contradiction beside the second, and made
289
+ * the styles say `.blocked-banner.is-blocked` to mean "visible".
290
+ *
291
+ * Exported for the unit test that pins the attribute; not part of the element
292
+ * API.
293
+ *
294
+ * @internal
295
+ */
296
+ export const blockedBannerClasses = (x: FoundationAiAssistant): string =>
297
+ [
298
+ 'blocked-banner',
299
+ x.bannerVisible ? 'is-visible' : '',
300
+ x.bannerVisible && !x.blocked ? 'is-partial' : '',
301
+ ]
302
+ .filter(Boolean)
303
+ .join(' ');
304
+
305
+ /**
306
+ * Id of the blocked banner, referenced by the composer controls'
307
+ * `aria-describedby`. Shadow-DOM-scoped, so a fixed string cannot collide with
308
+ * the host page — and IDREF resolution is same-root, which is exactly where both
309
+ * ends of this reference live.
310
+ *
311
+ * @internal
312
+ */
313
+ export const BLOCKED_BANNER_ID = 'blocked-banner';
314
+
315
+ /**
316
+ * `aria-describedby` for the composer's textarea, send button and attach button:
317
+ * the banner's id whenever the banner has something to say, otherwise `null` (so
318
+ * the attribute is omitted rather than emitted empty).
319
+ *
320
+ * Keyed on `bannerVisible`, NOT on `blocked`, and that is the point. The
321
+ * PARTIAL state — some vendor walled, composer still live — is the state this
322
+ * feature exists to create, and it was the one state with no accessible
323
+ * explanation at all: `aria-disabled` and `aria-label` bind only on `blocked`, and
324
+ * a live composer keeps the host's own placeholder, so a screen-reader user
325
+ * arriving at the textarea heard "Type a message" with no hint that the next turn
326
+ * might be refused. The banner's `role="status"` announces the text when it
327
+ * CHANGES; this is what makes the same explanation reachable afterwards, on
328
+ * demand, from the control it is about.
329
+ *
330
+ * Applied in the fully blocked state too, where it is additive: the `aria-label`
331
+ * there states the reason as the control's name, and this restates it as its
332
+ * description for the send/attach buttons, which carry neither.
333
+ *
334
+ * Exported for the unit test that pins it; not part of the element API.
335
+ *
336
+ * @internal
337
+ */
338
+ export const composerDescribedBy = (x: FoundationAiAssistant): string | null =>
339
+ x.bannerVisible ? BLOCKED_BANNER_ID : null;
340
+
341
+ /**
342
+ * Persistent banner shown above the composer while a backend condition blocks
343
+ * the assistant — today an exhausted AI budget (GENC-1464).
344
+ *
345
+ * Deliberately NOT a transcript replacement like `compactingTemplate` /
346
+ * `restoringTemplate`: those cover a transient rewrite where the history is
347
+ * mid-flight and unsafe to show, whereas a block is indefinite. Hiding the
348
+ * conversation behind it would strand the user's work behind a wall they
349
+ * cannot clear, so the banner sits alongside a fully readable transcript.
350
+ *
351
+ * `role="status"` (not `alert`) — it is a standing condition the user can also
352
+ * see in the disabled composer, not an interruption.
353
+ *
354
+ * **Rendered unconditionally, empty when unblocked.** This is an accessibility
355
+ * requirement, not a stylistic choice: `role="status"` announces MUTATIONS to a
356
+ * region the assistive technology was already observing. A region INSERTED into
357
+ * the DOM already carrying its text is widely missed across NVDA, JAWS and
358
+ * VoiceOver — and it is the only announcement a screen-reader user gets, since
359
+ * the textarea simultaneously flips to `disabled`, which drops it out of the tab
360
+ * order and means the swapped "AI usage limit reached" placeholder is never
361
+ * voiced (`maybeAutoFocusChatInput` also no-ops against a disabled control). So
362
+ * the region stays mounted and only its TEXT changes.
363
+ *
364
+ * It is deliberately **not** collapsed with `display: none` when unblocked —
365
+ * that would drop it out of the accessibility tree and make the flip an
366
+ * insertion again, defeating the point. Instead the visual treatment (padding,
367
+ * border, background) hangs off the `is-visible` class, so the empty container
368
+ * occupies no space while remaining a live, registered region.
369
+ *
370
+ * It carries {@link BLOCKED_BANNER_ID} so the composer controls can point
371
+ * `aria-describedby` at it — see {@link composerDescribedBy}.
372
+ */
373
+ const blockedBannerTemplate = html<FoundationAiAssistant>`
374
+ <div
375
+ id="${BLOCKED_BANNER_ID}"
376
+ class="${blockedBannerClasses}"
377
+ part="blocked-banner"
378
+ role="status"
379
+ >
380
+ ${when(
381
+ (x) => x.bannerVisible,
382
+ html<FoundationAiAssistant>`
383
+ <span class="blocked-banner-text">${(x) => x.effectiveBlockedReason}</span>
384
+ `,
385
+ )}
386
+ </div>
387
+ `;
388
+
277
389
  // ─── Public factory ───────────────────────────────────────────────────────────
278
390
 
279
391
  /** @internal */
@@ -437,13 +549,18 @@ ${(tc) => (tc.foldPath?.length ? `${tc.foldPath.join(' › ')} › ` : '')}<stro
437
549
  </${buttonTag}>
438
550
  `;
439
551
 
552
+ // The attach button shares the textarea's disable gate. Attaching a file that
553
+ // can never be sent is a dead end — and `blocked`, `compacting` and `restoring`
554
+ // were all missing from it, so files could be attached during a compaction or
555
+ // against an exhausted budget and then never sent.
440
556
  const attachButtonTemplate = html<FoundationAiAssistant>`
441
557
  <${buttonTag}
442
558
  class="attach-button"
443
559
  part="attach-button"
444
560
  appearance="stealth"
445
561
  title=${(x) => `Attach file (${x.chatConfig.ui?.acceptedFiles})`}
446
- ?disabled=${(x) => x.busy}
562
+ ?disabled=${(x) => x.busy || x.compacting || x.restoring || x.blocked}
563
+ aria-describedby=${composerDescribedBy}
447
564
  @click=${(x) => x.triggerFileInput()}
448
565
  ><${iconTag} name="paperclip"></${iconTag}></${buttonTag}>
449
566
  `;
@@ -697,7 +814,7 @@ ${(tc) => (tc.foldPath?.length ? `${tc.foldPath.join(' › ')} › ` : '')}<stro
697
814
  `,
698
815
  )}
699
816
  ${when(
700
- (x) => x.chatConfig.suggestions?.behavior !== 'never',
817
+ (x) => x.chatConfig.suggestions?.behavior !== 'never' && !x.suggestionsBlocked,
701
818
  html<FoundationAiAssistant>`
702
819
  <chat-suggestions
703
820
  :state="${(x) => x.suggestionsState}"
@@ -710,6 +827,12 @@ ${(tc) => (tc.foldPath?.length ? `${tc.foldPath.join(' › ')} › ` : '')}<stro
710
827
  (x) => x.sessionMenuOpen && !x.busy,
711
828
  sessionMenuPanelTemplate,
712
829
  )}
830
+ ${
831
+ /* Rendered above the composer and independently of it — a host that hides the
832
+ built-in composer (`composerHiddenByConfig`) still needs to be told why its own
833
+ input is being refused. */ ''
834
+ }
835
+ ${blockedBannerTemplate}
713
836
  ${when(
714
837
  (x) =>
715
838
  !x.composerHiddenByConfig &&
@@ -743,7 +866,10 @@ ${(tc) => (tc.foldPath?.length ? `${tc.foldPath.join(' › ')} › ` : '')}<stro
743
866
  style=${(x) => x.composerHeightStyle}
744
867
  placeholder=${(x) => x.effectivePlaceholder}
745
868
  :value=${(x) => x.inputValue}
746
- ?disabled=${(x) => x.busy || x.compacting || x.restoring}
869
+ ?disabled=${(x) => x.busy || x.compacting || x.restoring || x.blocked}
870
+ aria-disabled=${(x) => (x.blocked ? 'true' : null)}
871
+ aria-label=${(x) => (x.blocked ? x.effectiveBlockedReason : null)}
872
+ aria-describedby=${composerDescribedBy}
747
873
  @input=${(x, c) => (x.inputValue = (c.event.target as any).value)}
748
874
  @keydown=${(x, c) => {
749
875
  if (
@@ -782,7 +908,8 @@ ${(tc) => (tc.foldPath?.length ? `${tc.foldPath.join(' › ')} › ` : '')}<stro
782
908
  <${buttonTag}
783
909
  class="send-button"
784
910
  part="send-button"
785
- ?disabled=${(x) => x.busy || x.compacting || x.restoring || (!x.inputValue.trim() && !x.attachments.length)}
911
+ ?disabled=${(x) => x.busy || x.compacting || x.restoring || x.blocked || (!x.inputValue.trim() && !x.attachments.length)}
912
+ aria-describedby=${composerDescribedBy}
786
913
  @click=${(x) => x.handleSendClick()}
787
914
  >Send</${buttonTag}>
788
915
  </div>