@genesislcap/ai-assistant 14.452.1 → 14.453.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 (29) hide show
  1. package/dist/ai-assistant.api.json +221 -2
  2. package/dist/ai-assistant.d.ts +93 -1
  3. package/dist/dts/components/ai-driver/ai-driver.d.ts +13 -0
  4. package/dist/dts/components/ai-driver/ai-driver.d.ts.map +1 -1
  5. package/dist/dts/components/chat-driver/chat-driver.d.ts +47 -0
  6. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  7. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +12 -0
  8. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
  9. package/dist/dts/main/main.d.ts +16 -0
  10. package/dist/dts/main/main.d.ts.map +1 -1
  11. package/dist/dts/main/main.styles.d.ts.map +1 -1
  12. package/dist/dts/main/main.template.d.ts.map +1 -1
  13. package/dist/dts/main/main.types.d.ts +5 -1
  14. package/dist/dts/main/main.types.d.ts.map +1 -1
  15. package/dist/esm/components/chat-driver/chat-driver.js +129 -1
  16. package/dist/esm/components/chat-driver/chat-driver.test.js +190 -0
  17. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +23 -0
  18. package/dist/esm/main/main.js +49 -11
  19. package/dist/esm/main/main.styles.js +26 -0
  20. package/dist/esm/main/main.template.js +27 -11
  21. package/package.json +16 -16
  22. package/src/components/ai-driver/ai-driver.ts +15 -0
  23. package/src/components/chat-driver/chat-driver.test.ts +227 -0
  24. package/src/components/chat-driver/chat-driver.ts +136 -1
  25. package/src/components/orchestrating-driver/orchestrating-driver.ts +24 -0
  26. package/src/main/main.styles.ts +26 -0
  27. package/src/main/main.template.ts +30 -11
  28. package/src/main/main.ts +48 -11
  29. package/src/main/main.types.ts +5 -1
@@ -93,6 +93,12 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
93
93
  * so long-running `onActivate` work can bail if the session disconnects.
94
94
  */
95
95
  private readonly lifecycleAbortController = new AbortController();
96
+ /**
97
+ * Set by `cancel()` for the duration of a `sendMessage` run. Breaks the
98
+ * handoff loop so we don't classify or start another agent turn after the
99
+ * user stops. Reset at the start of each `sendMessage`.
100
+ */
101
+ private cancelled = false;
96
102
  /**
97
103
  * Sticky user pick from the picker (or the host's `setAgent` API). Only
98
104
  * changes on explicit user action. Survives flow completion: when a stateful
@@ -263,6 +269,7 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
263
269
  }
264
270
 
265
271
  async sendMessage(input: string, attachments?: ChatAttachment[]): Promise<ChatDriverResult> {
272
+ this.cancelled = false;
266
273
  const history = this.chatDriver.getHistory() as ChatMessage[];
267
274
 
268
275
  // Emit the user message immediately so the UI reflects it during the classify
@@ -282,6 +289,11 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
282
289
  let remainingTask = '';
283
290
 
284
291
  while (true) {
292
+ // Cancelled before a (next) agent turn started — e.g. during classify.
293
+ // The chat driver appends its own "Stopped." marker when a turn it was
294
+ // running is cancelled, so we just stop here without a duplicate.
295
+ if (this.cancelled) break;
296
+
285
297
  // oxlint-disable-next-line no-await-in-loop
286
298
  await this.applyAgent(currentAgent);
287
299
 
@@ -471,6 +483,16 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
471
483
  this.dispatchEvent(new CustomEvent('agent-changed', { detail: undefined }));
472
484
  }
473
485
 
486
+ /**
487
+ * Stop the current turn (user "stop" button). Cancels the inner chat driver's
488
+ * in-flight request and breaks the handoff loop so no further agent turn or
489
+ * classify runs. The driver stays usable for the next message.
490
+ */
491
+ cancel(): void {
492
+ this.cancelled = true;
493
+ this.chatDriver.cancel();
494
+ }
495
+
474
496
  /**
475
497
  * Fire `onDeactivate` on the current active agent and abort any pending
476
498
  * lifecycle work. Called by the host on session teardown so machines can
@@ -490,6 +512,8 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
490
512
  }
491
513
  }
492
514
  this.lifecycleAbortController.abort();
515
+ // Tear down the inner driver too, so any in-flight provider request aborts.
516
+ this.chatDriver.dispose();
493
517
  this.activeAgent = undefined;
494
518
  }
495
519
 
@@ -599,6 +599,32 @@ export const styles = css`
599
599
  background-color: var(--neutral-layer-2);
600
600
  }
601
601
 
602
+ /* Right-hand control column, mirroring .input-left-controls — holds the
603
+ Stop button (shown while a turn runs) stacked above the Send button. The
604
+ Stop button is an icon (stop → spinner), so the column width is driven by
605
+ Send and stays stable whether a turn is running or idle. */
606
+ .input-right-controls {
607
+ display: flex;
608
+ flex-direction: column;
609
+ align-items: stretch;
610
+ gap: calc(var(--design-unit) * 1px);
611
+ }
612
+
613
+ /* The "stopping" state shows a spinner — rotate it (the icon set's glyph is
614
+ static). Transform-only, so it never affects layout/width. */
615
+ @keyframes ai-stop-spin {
616
+ to {
617
+ transform: rotate(360deg);
618
+ }
619
+ }
620
+
621
+ .stop-icon-spinning {
622
+ /* inline-block so the icon host is transformable — an inline element
623
+ ignores the transform, which is why the spinner appeared frozen. */
624
+ display: inline-block;
625
+ animation: ai-stop-spin 0.8s linear infinite;
626
+ }
627
+
602
628
  .input-left-controls {
603
629
  display: flex;
604
630
  flex-direction: column;
@@ -22,7 +22,7 @@ import type {
22
22
  ChatToolCall,
23
23
  } from '@genesislcap/foundation-ai';
24
24
  import { isChatToolCallUnknown } from '@genesislcap/foundation-ai';
25
- import { html, ref, repeat, when, ViewTemplate } from '@genesislcap/web-core';
25
+ import { classNames, html, ref, repeat, when, ViewTemplate } from '@genesislcap/web-core';
26
26
  import type { FoundationAiAssistant } from './main';
27
27
  import { ANIMATION_DEFS } from './main.types';
28
28
 
@@ -288,7 +288,7 @@ ${(tc) => (tc.foldPath?.length ? `${tc.foldPath.join(' › ')} › ` : '')}<stro
288
288
  part="agent-toggle-button"
289
289
  appearance="stealth"
290
290
  title=${(x) => x.agentToggleTitle}
291
- ?disabled=${(x) => x.state === 'loading' || x.pinLocked}
291
+ ?disabled=${(x) => x.busy || x.pinLocked}
292
292
  @click=${(x) => x.toggleAgentPicker()}
293
293
  >
294
294
  ${when(
@@ -319,7 +319,7 @@ ${(tc) => (tc.foldPath?.length ? `${tc.foldPath.join(' › ')} › ` : '')}<stro
319
319
  part="attach-button"
320
320
  appearance="stealth"
321
321
  title=${(x) => `Attach file (${x.chatConfig.ui?.acceptedFiles})`}
322
- ?disabled=${(x) => x.state === 'loading'}
322
+ ?disabled=${(x) => x.busy}
323
323
  @click=${(x) => x.triggerFileInput()}
324
324
  ><${iconTag} name="paperclip"></${iconTag}></${buttonTag}>
325
325
  `;
@@ -658,7 +658,7 @@ ${(tc) => (tc.foldPath?.length ? `${tc.foldPath.join(' › ')} › ` : '')}<stro
658
658
  ${when(
659
659
  (x) =>
660
660
  !x.composerHiddenByConfig &&
661
- !(x.state === 'loading' && x.effectiveChatInputDuringExecution === 'hidden'),
661
+ !(x.busy && x.effectiveChatInputDuringExecution === 'hidden'),
662
662
  html<FoundationAiAssistant>`
663
663
  <div class="input-row" part="input-row">
664
664
  ${when(
@@ -671,7 +671,7 @@ ${(tc) => (tc.foldPath?.length ? `${tc.foldPath.join(' › ')} › ` : '')}<stro
671
671
  part="input"
672
672
  placeholder=${(x) => x.effectivePlaceholder}
673
673
  :value=${(x) => x.inputValue}
674
- ?disabled=${(x) => x.state === 'loading'}
674
+ ?disabled=${(x) => x.busy}
675
675
  @input=${(x, c) => (x.inputValue = (c.event.target as any).value)}
676
676
  @keydown=${(x, c) => {
677
677
  if (
@@ -685,12 +685,31 @@ ${(tc) => (tc.foldPath?.length ? `${tc.foldPath.join(' › ')} › ` : '')}<stro
685
685
  return true;
686
686
  }}
687
687
  ></${textareaTag}>
688
- <${buttonTag}
689
- class="send-button"
690
- part="send-button"
691
- ?disabled=${(x) => x.state === 'loading' || (!x.inputValue.trim() && !x.attachments.length)}
692
- @click=${(x) => x.handleSendClick()}
693
- >Send</${buttonTag}>
688
+ <div class="input-right-controls">
689
+ ${when(
690
+ (x) => x.busy && !x.chatConfig.ui?.hideStopButton,
691
+ html<FoundationAiAssistant>`
692
+ <${buttonTag}
693
+ class="stop-button"
694
+ part="stop-button"
695
+ appearance="stealth"
696
+ title="${(x) => (x.state === 'cancelling' ? 'Stopping…' : 'Stop')}"
697
+ aria-label="${(x) => (x.state === 'cancelling' ? 'Stopping' : 'Stop')}"
698
+ ?disabled=${(x) => x.state === 'cancelling'}
699
+ @click=${(x) => x.handleStopClick()}
700
+ ><${iconTag}
701
+ class="${(x) => classNames('stop-icon', ['stop-icon-spinning', x.state === 'cancelling'])}"
702
+ name="${(x) => (x.state === 'cancelling' ? 'spinner' : 'stop')}"
703
+ ></${iconTag}></${buttonTag}>
704
+ `,
705
+ )}
706
+ <${buttonTag}
707
+ class="send-button"
708
+ part="send-button"
709
+ ?disabled=${(x) => x.busy || (!x.inputValue.trim() && !x.attachments.length)}
710
+ @click=${(x) => x.handleSendClick()}
711
+ >Send</${buttonTag}>
712
+ </div>
694
713
  </div>
695
714
  `,
696
715
  )}
package/src/main/main.ts CHANGED
@@ -241,10 +241,11 @@ export class FoundationAiAssistant extends GenesisElement {
241
241
  this.logMeta('state.changed', { from: prev, to: value });
242
242
  }
243
243
  this.syncShowHalo();
244
- // When the agent finishes (loading!loading) the input row reappears (or
245
- // becomes enabled) — refocus it so the user can type immediately, but only
246
- // if they haven't moved on to something else on the page.
247
- if (prev === 'loading' && value !== 'loading') {
244
+ // When the turn finishes (a busy state a settled one) the input row
245
+ // reappears (or becomes enabled) — refocus it so the user can type
246
+ // immediately, but only if they haven't moved on to something else.
247
+ // `cancelling` is busy too, so loading cancelling must NOT settle yet.
248
+ if (this.isBusyState(prev) && !this.isBusyState(value)) {
248
249
  this.maybeAutoFocusChatInput();
249
250
  // Apply any agents swap deferred by the busy guard in `agentsChanged`.
250
251
  // Host-side `agents` getters typically cache a stable reference between
@@ -254,6 +255,23 @@ export class FoundationAiAssistant extends GenesisElement {
254
255
  }
255
256
  }
256
257
 
258
+ /** Whether a state value counts as "busy" — a turn is active. */
259
+ private isBusyState(s?: AiAssistantState): boolean {
260
+ return s === 'loading' || s === 'cancelling';
261
+ }
262
+
263
+ /**
264
+ * True while a turn is active — `loading` or `cancelling` (a stop was
265
+ * requested but the turn is still winding down). The single "is a turn
266
+ * running" predicate; UI gates use this so `cancelling` behaves like
267
+ * `loading` everywhere. `@volatile` so the template re-evaluates it when the
268
+ * store-backed `state` changes.
269
+ */
270
+ @volatile
271
+ get busy(): boolean {
272
+ return this.isBusyState(this.state);
273
+ }
274
+
257
275
  /**
258
276
  * Re-runs `agentsChanged` if the live `agents` array no longer matches the
259
277
  * fingerprint of the currently installed driver. Used to apply swaps that
@@ -549,7 +567,7 @@ export class FoundationAiAssistant extends GenesisElement {
549
567
  @observable showHalo: 'no' | 'orchestrating' | 'agent' = 'no';
550
568
 
551
569
  private syncShowHalo() {
552
- if (this.state !== 'loading') {
570
+ if (!this.busy) {
553
571
  this.showHalo = 'no';
554
572
  return;
555
573
  }
@@ -563,7 +581,7 @@ export class FoundationAiAssistant extends GenesisElement {
563
581
 
564
582
  /** True when there is a pending (unresolved) interaction — disables the popout button. */
565
583
  get hasActivePendingInteraction(): boolean {
566
- if (this.state !== 'loading') return false;
584
+ if (!this.busy) return false;
567
585
  const last = this.messages[this.messages.length - 1];
568
586
  return !!last?.interaction && !last.interaction.resolved;
569
587
  }
@@ -1020,7 +1038,7 @@ export class FoundationAiAssistant extends GenesisElement {
1020
1038
  document.addEventListener('focusin', this._handleGlobalInteraction, true);
1021
1039
  // Initial focus on mount when idle (skips during a mid-lifecycle reattach
1022
1040
  // where state is loading and the input may be hidden or disabled).
1023
- if (this.state !== 'loading') this.maybeAutoFocusChatInput();
1041
+ if (!this.busy) this.maybeAutoFocusChatInput();
1024
1042
  logger.debug('FoundationAiAssistant connected');
1025
1043
  this.logMeta('assistant.connected', {
1026
1044
  store: isNewStore ? 'created' : 'restored',
@@ -1112,7 +1130,7 @@ export class FoundationAiAssistant extends GenesisElement {
1112
1130
  // individual step gets a fresh window before the spinner appears.
1113
1131
  // If the last message is a blocking interaction, stop the timer — the AI is
1114
1132
  // waiting for the user, not computing.
1115
- if (this.state === 'loading') {
1133
+ if (this.busy) {
1116
1134
  const last = this.messages[this.messages.length - 1];
1117
1135
  if (last?.interaction) {
1118
1136
  this.stopLoadingTimer();
@@ -1641,6 +1659,18 @@ export class FoundationAiAssistant extends GenesisElement {
1641
1659
  this.send();
1642
1660
  }
1643
1661
 
1662
+ /**
1663
+ * User clicked Stop. Cancels the current turn via the driver and flips the
1664
+ * button to "Stopping…" until the turn winds down (a tool mid-execution
1665
+ * finishes first). Guarded so a second click is a no-op.
1666
+ */
1667
+ handleStopClick() {
1668
+ // Only valid mid-turn; once `cancelling`, a second click is a no-op.
1669
+ if (this.state !== 'loading') return;
1670
+ this.state = 'cancelling';
1671
+ this.driver?.cancel?.();
1672
+ }
1673
+
1644
1674
  handleSuggestionClick(suggestion: string) {
1645
1675
  this.inputValue = suggestion;
1646
1676
  this.send();
@@ -1668,7 +1698,7 @@ export class FoundationAiAssistant extends GenesisElement {
1668
1698
  * @beta
1669
1699
  */
1670
1700
  async submitMessage(input: { text?: string; files?: File[] }): Promise<SubmitMessageResult> {
1671
- if (this.state === 'loading') {
1701
+ if (this.busy) {
1672
1702
  return { ok: false, errors: ['Assistant is busy'] };
1673
1703
  }
1674
1704
 
@@ -1682,7 +1712,7 @@ export class FoundationAiAssistant extends GenesisElement {
1682
1712
  // own first await — so no further race window exists.
1683
1713
  // (Cast widens through TS's narrowing of `this.state` from the earlier
1684
1714
  // early-return; the getter can return a different value across an await.)
1685
- if ((this.state as string) === 'loading') {
1715
+ if (this.busy) {
1686
1716
  return { ok: false, errors: ['Assistant is busy'] };
1687
1717
  }
1688
1718
  if (errors.length) {
@@ -1772,7 +1802,7 @@ export class FoundationAiAssistant extends GenesisElement {
1772
1802
 
1773
1803
  private async send() {
1774
1804
  const input = this.inputValue.trim();
1775
- if ((!input && !this.attachments.length) || this.state === 'loading') return;
1805
+ if ((!input && !this.attachments.length) || this.busy) return;
1776
1806
  // Capture the session ref before any await. If a lifecycle event occurs during
1777
1807
  // execution, disconnectedCallback clears this._sessionRef — but the captured
1778
1808
  // reference lets the finally block still write 'idle' back to the shared store,
@@ -1800,7 +1830,14 @@ export class FoundationAiAssistant extends GenesisElement {
1800
1830
  // Write directly via captured ref — this._sessionRef may be null if the element
1801
1831
  // disconnected mid-execution. The state setter also calls syncShowHalo(); replicate
1802
1832
  // that here for the case where this element is still connected.
1833
+ const prevState = capturedSessionRef?.store.aiAssistant.state;
1803
1834
  capturedSessionRef?.actions.aiAssistant.setState('idle');
1835
+ // The setter's `state.changed` log is bypassed on this direct write, so
1836
+ // emit it here — otherwise the debug timeline shows the turn entering
1837
+ // loading/cancelling but never returning to idle.
1838
+ if (prevState && prevState !== 'idle') {
1839
+ this.logMeta('state.changed', { from: prevState, to: 'idle' });
1840
+ }
1804
1841
  this.syncShowHalo();
1805
1842
  this.fetchSuggestions();
1806
1843
  // setState was dispatched directly via the captured ref (not via the
@@ -1,9 +1,13 @@
1
1
  /**
2
2
  * State of the AI assistant component.
3
3
  *
4
+ * `cancelling` is a refinement of `loading`: a stop was requested but the turn
5
+ * is still winding down (e.g. a tool mid-execution finishing). It is "busy"
6
+ * like `loading` — use the host's `busy` getter for "is a turn active" gates.
7
+ *
4
8
  * @beta
5
9
  */
6
- export type AiAssistantState = 'idle' | 'loading' | 'error';
10
+ export type AiAssistantState = 'idle' | 'loading' | 'cancelling' | 'error';
7
11
 
8
12
  /**
9
13
  * Controls the pop-out button shown in the assistant header.