@kodaris/krubble-app-components 1.0.111 → 1.0.113

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.
@@ -5528,6 +5528,25 @@ var __decorate = (undefined && undefined.__decorate) || function (decorators, ta
5528
5528
  else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5529
5529
  return c > 3 && r && Object.defineProperty(target, key, r), r;
5530
5530
  };
5531
+ /**
5532
+ * The action-confirmation decisions, in the left-to-right order they render and
5533
+ * the order the arrow keys walk. Yes is first so it's the default highlight.
5534
+ * Shared by the render and the keyboard handler so the two never drift.
5535
+ */
5536
+ const CONFIRMATION_DECISIONS = [
5537
+ {
5538
+ decision: 'approve',
5539
+ label: 'Yes',
5540
+ },
5541
+ {
5542
+ decision: 'approveAlways',
5543
+ label: 'Always allow',
5544
+ },
5545
+ {
5546
+ decision: 'reject',
5547
+ label: 'No',
5548
+ },
5549
+ ];
5531
5550
  /** Image extensions the chatbot renders as thumbnails (used when MIME is absent). */
5532
5551
  const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
5533
5552
  /**
@@ -5562,9 +5581,20 @@ const ACCEPTED_FILE_EXTENSIONS = [
5562
5581
  * - `TOOL_RESULT` — `{ type: "TOOL_RESULT", message: "tool_name", data: {...} }`
5563
5582
  * - `ERROR` — `{ type: "ERROR", message: "Something went wrong" }`
5564
5583
  * - `HEARTBEAT` — `{ type: "HEARTBEAT" }` (ignored)
5584
+ * - `ACTION_CONFIRMATION_REQUIRED` — a guarded tool paused for approval;
5585
+ * `{ type, data: { actions: [...] }, finished: true }` (Rail 3).
5565
5586
  *
5566
5587
  * The final event should include `finished: true` to signal stream completion.
5567
5588
  *
5589
+ * ## Turn locking (when the composer is free to send)
5590
+ * `finished: true` is the single reliable "turn over, safe to send" signal — the
5591
+ * component unlocks the composer on it, and also on stream close/error as a
5592
+ * fallback so a dropped stream never wedges the UI. The one exception is
5593
+ * `ACTION_CONFIRMATION_REQUIRED`: it too carries `finished: true`, but the
5594
+ * conversation is now awaiting an approve/deny decision, so the composer stays
5595
+ * blocked and only a confirmation decision (not a new message) is a valid next
5596
+ * call.
5597
+ *
5568
5598
  * ## Usage
5569
5599
  * ```html
5570
5600
  * <kr-chatbot
@@ -5734,6 +5764,32 @@ let KRChatbot = class KRChatbot extends i$3 {
5734
5764
  this._contextLimitReached = false;
5735
5765
  /** Server-provided limit text (`CONVERSATION_LIMIT_REACHED`), if any. */
5736
5766
  this._contextLimitMessage = '';
5767
+ /**
5768
+ * The guarded tool awaiting the user's decision, from the turn's
5769
+ * `ACTION_CONFIRMATION_REQUIRED` event (`data.actions[]`, of which we handle the
5770
+ * first — the framework confirms one guarded tool at a time). Non-empty means
5771
+ * the conversation is paused on a confirmation: the normal composer is blocked
5772
+ * (a new chat message would be rejected by the server with
5773
+ * `awaitingConfirmation`) and the approve/deny prompt is shown instead. Cleared
5774
+ * once a decision is sent.
5775
+ *
5776
+ * This is the one case where `finished:true` unlocks the turn lock but does
5777
+ * NOT free the composer — the only valid next call is a confirmation decision.
5778
+ */
5779
+ this._pendingActions = [];
5780
+ /**
5781
+ * Keyboard-highlighted confirmation option, as an index into the decision list
5782
+ * (0 = Yes, 1 = Always allow, 2 = No). Yes is highlighted by default so Enter
5783
+ * approves. Arrow Left/Right move it; Enter activates it. -1 when no batch is
5784
+ * pending.
5785
+ */
5786
+ this._confirmationHighlighted = -1;
5787
+ /**
5788
+ * True while a confirmation decision (approve/deny) is being sent and its
5789
+ * follow-up turn streams. Disables the approve/deny buttons so the batch can't
5790
+ * be resolved twice.
5791
+ */
5792
+ this._confirmationInFlight = false;
5737
5793
  /**
5738
5794
  * INTERNAL-ONLY latest telemetry snapshot (last-value-wins). Populated from
5739
5795
  * `TOKEN_USAGE` events during streaming and hydrated from the conversation
@@ -5887,6 +5943,36 @@ let KRChatbot = class KRChatbot extends i$3 {
5887
5943
  document.removeEventListener('mouseup', this._handleMouseUp);
5888
5944
  this._saveState();
5889
5945
  };
5946
+ /**
5947
+ * Keyboard control for the confirmation prompt. Bound at the document so it
5948
+ * works even though the composer (where focus usually sits) is blocked while a
5949
+ * batch is pending: Left/Right move the highlight across Yes / Always allow /
5950
+ * No, Enter activates the highlighted one. No-op when nothing is pending or a
5951
+ * decision is already in flight.
5952
+ */
5953
+ this._handleDocumentKeydown = (e) => {
5954
+ if (!this._pendingActions.length || this._confirmationInFlight) {
5955
+ return;
5956
+ }
5957
+ if (e.key === 'ArrowRight') {
5958
+ e.preventDefault();
5959
+ this._confirmationHighlighted = (this._confirmationHighlighted + 1) % CONFIRMATION_DECISIONS.length;
5960
+ return;
5961
+ }
5962
+ if (e.key === 'ArrowLeft') {
5963
+ e.preventDefault();
5964
+ this._confirmationHighlighted =
5965
+ (this._confirmationHighlighted - 1 + CONFIRMATION_DECISIONS.length) % CONFIRMATION_DECISIONS.length;
5966
+ return;
5967
+ }
5968
+ if (e.key === 'Enter') {
5969
+ e.preventDefault();
5970
+ const option = CONFIRMATION_DECISIONS[this._confirmationHighlighted];
5971
+ if (option) {
5972
+ this._handleConfirmationDecision(option.decision);
5973
+ }
5974
+ }
5975
+ };
5890
5976
  this._handleDebugResizeMouseMove = (e) => {
5891
5977
  if (!this._isDebugResizing) {
5892
5978
  return;
@@ -5972,6 +6058,9 @@ let KRChatbot = class KRChatbot extends i$3 {
5972
6058
  }
5973
6059
  this._restoreState();
5974
6060
  document.addEventListener('click', this._handleDocumentClick);
6061
+ // Drive the confirmation prompt from the keyboard even when focus isn't in the
6062
+ // (blocked) composer: Left/Right move the highlight, Enter activates it.
6063
+ document.addEventListener('keydown', this._handleDocumentKeydown);
5975
6064
  }
5976
6065
  disconnectedCallback() {
5977
6066
  super.disconnectedCallback();
@@ -5982,6 +6071,7 @@ let KRChatbot = class KRChatbot extends i$3 {
5982
6071
  document.removeEventListener('mousemove', this._handleMouseMove);
5983
6072
  document.removeEventListener('mouseup', this._handleMouseUp);
5984
6073
  document.removeEventListener('click', this._handleDocumentClick);
6074
+ document.removeEventListener('keydown', this._handleDocumentKeydown);
5985
6075
  // Release the mic if the chatbot is torn down mid-dictation — otherwise the
5986
6076
  // recognizer keeps the browser's recording indicator lit with nothing to
5987
6077
  // transcribe into. abort() (not stop()) since the discarded text has nowhere to go.
@@ -6856,6 +6946,13 @@ let KRChatbot = class KRChatbot extends i$3 {
6856
6946
  if (this._isStreaming) {
6857
6947
  return;
6858
6948
  }
6949
+ // A guarded-tool batch is awaiting a decision. The turn lock released with
6950
+ // its finished:true, but the server rejects a normal chat message with
6951
+ // awaitingConfirmation until it's resolved — so the composer stays blocked
6952
+ // and the approve/deny prompt owns the next call.
6953
+ if (this._pendingActions.length) {
6954
+ return;
6955
+ }
6859
6956
  const messageText = this._inputValue.trim();
6860
6957
  const pending = this._pendingConversationFiles;
6861
6958
  // Allow sending an image with no text, but require at least one of them.
@@ -6962,6 +7059,22 @@ let KRChatbot = class KRChatbot extends i$3 {
6962
7059
  // the consumer hasn't provided their own `send`.
6963
7060
  if (!this.send) {
6964
7061
  this.send = (params) => {
7062
+ // Rail-3 confirmation decision: resolve a paused guarded-tool batch by
7063
+ // hitting the SAME streaming endpoint with `confirmation` set. There's no
7064
+ // userText/files on this path (the server ignores message text here), and
7065
+ // the conversation is always already pinned (a batch can't pause before
7066
+ // the conversation exists), so no upload and no createNew.
7067
+ if (params.confirmation) {
7068
+ return KRHttp.fetch({
7069
+ url: '/api/system/integration/aws/bedrock/stream/invokeModelForConversationWithState',
7070
+ method: 'POST',
7071
+ headers: { Accept: 'text/event-stream' },
7072
+ body: {
7073
+ confirmation: params.confirmation,
7074
+ conversationID: this.conversationId,
7075
+ },
7076
+ });
7077
+ }
6965
7078
  // Images are upload-first: each file is stored as a conversation file
6966
7079
  // (type LIBRARY — the format lives in mimeType), then the resulting
6967
7080
  // conversationFileIDs are attached to the message via `fileIds` on the
@@ -7101,6 +7214,59 @@ let KRChatbot = class KRChatbot extends i$3 {
7101
7214
  }
7102
7215
  });
7103
7216
  }
7217
+ /**
7218
+ * Resolve the paused guarded-tool batch (Rail 3). Hits the SAME streaming
7219
+ * endpoint as a normal turn but with a `confirmation` value instead of a
7220
+ * message; the server runs (approve/approveAlways) or denies (reject) the batch
7221
+ * and continues the turn, streaming the outcome into a fresh assistant bubble.
7222
+ *
7223
+ * The decision uses the same `send` callback so a custom `send` can own its own
7224
+ * targeting — it just receives `confirmation` set and no message.
7225
+ */
7226
+ _handleConfirmationDecision(decision) {
7227
+ if (this._confirmationInFlight) {
7228
+ return;
7229
+ }
7230
+ this._confirmationInFlight = true;
7231
+ this._error = '';
7232
+ // The batch is being resolved — drop the prompt so the composer unblocks once
7233
+ // this follow-up turn ends, and stream the outcome into a new assistant bubble.
7234
+ this._pendingActions = [];
7235
+ this._confirmationHighlighted = -1;
7236
+ this.messages = [...this.messages, {
7237
+ type: 'assistant',
7238
+ content: '',
7239
+ }];
7240
+ this._isStreaming = true;
7241
+ this._userHasScrolledUp = false;
7242
+ this.send({
7243
+ message: '',
7244
+ messages: this.messages.slice(0, -1),
7245
+ conversationFiles: [],
7246
+ pageContext: '',
7247
+ selectionContext: '',
7248
+ confirmation: decision,
7249
+ }).then((response) => {
7250
+ this._confirmationInFlight = false;
7251
+ if (!response.ok) {
7252
+ this._handleStreamError('Request failed: ' + response.status);
7253
+ return;
7254
+ }
7255
+ if (!response.body) {
7256
+ this._handleStreamError('No response body');
7257
+ return;
7258
+ }
7259
+ this._reader = response.body.getReader();
7260
+ this._decoder = new TextDecoder();
7261
+ this._buffer = '';
7262
+ this._readStreamChunk();
7263
+ }).catch((err) => {
7264
+ this._confirmationInFlight = false;
7265
+ if (err.name !== 'AbortError') {
7266
+ this._handleStreamError(err.message);
7267
+ }
7268
+ });
7269
+ }
7104
7270
  /**
7105
7271
  * Capture ambient "where is the user / what are they looking at" context at
7106
7272
  * send time, so the AI is grounded without the host passing anything in.
@@ -7193,8 +7359,11 @@ let KRChatbot = class KRChatbot extends i$3 {
7193
7359
  }
7194
7360
  this._reader.read().then((result) => {
7195
7361
  if (result.done) {
7196
- this._isStreaming = false;
7197
- this._reader = null;
7362
+ // Fallback unlock: the stream closed without a `finished:true` chunk
7363
+ // (network drop, server crash, the 5-min emitter timeout). Treat it as
7364
+ // the turn ending so a dropped stream never wedges the composer — the
7365
+ // server self-heals its turn lock and bounces any premature send.
7366
+ this._finishStream();
7198
7367
  return;
7199
7368
  }
7200
7369
  this._buffer = this._buffer + this._decoder.decode(result.value, { stream: true });
@@ -7279,6 +7448,20 @@ let KRChatbot = class KRChatbot extends i$3 {
7279
7448
  this._contextLimitMessage = String(data.message || '');
7280
7449
  this._contextWarning = '';
7281
7450
  }
7451
+ else if (data.type === 'ACTION_CONFIRMATION_REQUIRED') {
7452
+ // A guarded tool paused for approval (Rail 3). Carries finished:true
7453
+ // (handled below), so the turn lock releases — but the conversation
7454
+ // is now awaiting a decision, and a normal chat message would be
7455
+ // rejected with awaitingConfirmation. Capture the batch so the
7456
+ // composer stays blocked and the approve/deny prompt renders instead;
7457
+ // the next call must be a confirmation, not a userText.
7458
+ const actions = data.data?.actions;
7459
+ if (Array.isArray(actions) && actions.length) {
7460
+ this._pendingActions = actions;
7461
+ // Default the keyboard highlight to Yes so Enter approves.
7462
+ this._confirmationHighlighted = 0;
7463
+ }
7464
+ }
7282
7465
  else if (data.type === 'TOKEN_USAGE') {
7283
7466
  // INTERNAL-ONLY debug telemetry. Update the panel snapshot and the
7284
7467
  // per-iteration log; never render as a chat bubble.
@@ -7309,11 +7492,17 @@ let KRChatbot = class KRChatbot extends i$3 {
7309
7492
  }
7310
7493
  }
7311
7494
  if (data.finished) {
7312
- this._isStreaming = false;
7313
- this._reader = null;
7495
+ // Primary unlock signal: the turn is over and the server released
7496
+ // the turn lock. The SSE stream closes right after this chunk.
7497
+ this._finishStream();
7314
7498
  }
7315
7499
  }
7316
7500
  }
7501
+ // Stop reading once a terminal chunk ended the turn — the reader is nulled
7502
+ // by _finishStream and the stream is closing anyway.
7503
+ if (!this._reader) {
7504
+ return;
7505
+ }
7317
7506
  this._readStreamChunk();
7318
7507
  }).catch((err) => {
7319
7508
  if (err.name !== 'AbortError') {
@@ -7321,6 +7510,32 @@ let KRChatbot = class KRChatbot extends i$3 {
7321
7510
  }
7322
7511
  });
7323
7512
  }
7513
+ /**
7514
+ * End the current turn: release the turn lock and the reader. Called on the
7515
+ * primary unlock signal (`finished:true`) and the fallback (stream close).
7516
+ *
7517
+ * A turn that paused for confirmation (`_pendingActions` set) leaves the
7518
+ * composer blocked even though the lock released — the approve/deny prompt
7519
+ * owns the next call. Its trailing empty assistant bubble is dropped so the
7520
+ * prompt doesn't sit under an empty message with a typing indicator.
7521
+ */
7522
+ _finishStream() {
7523
+ this._isStreaming = false;
7524
+ this._reader = null;
7525
+ if (this._pendingActions.length) {
7526
+ const lastIndex = this.messages.length - 1;
7527
+ // Drop the trailing assistant bubble only when it's truly empty — no
7528
+ // streamed text AND no reasoning. A reasoning-only bubble (the model thought
7529
+ // out loud, then went straight to the guarded tool) must be kept, else its
7530
+ // "Thinking" trace is lost when the confirmation card renders.
7531
+ if (lastIndex >= 0
7532
+ && this.messages[lastIndex].type === 'assistant'
7533
+ && !this.messages[lastIndex].content
7534
+ && !this.messages[lastIndex].reasoning) {
7535
+ this.messages = this.messages.slice(0, -1);
7536
+ }
7537
+ }
7538
+ }
7324
7539
  _handleStreamError(message) {
7325
7540
  this._isStreaming = false;
7326
7541
  this._error = message;
@@ -7337,6 +7552,52 @@ let KRChatbot = class KRChatbot extends i$3 {
7337
7552
  }
7338
7553
  }
7339
7554
  // --- Render ---
7555
+ /**
7556
+ * The approve/deny card shown when a guarded tool is paused (Rail 3). Floats
7557
+ * OVER the messages, anchored just above the composer — not part of the message
7558
+ * scroll — so it stays in view while the transcript scrolls behind it.
7559
+ *
7560
+ * Written for a non-technical user: one plain-language sentence saying what's
7561
+ * about to happen (the model's schema-required `confirmationMessage`), then a
7562
+ * row of Yes / Always allow / No. Deliberately NO tool code, NO raw
7563
+ * arguments/JSON, and NO separate `description` line — those are noise (or
7564
+ * alarming) to a normal user; the confirmation sentence is the whole story.
7565
+ */
7566
+ _renderConfirmation() {
7567
+ // The framework confirms one guarded tool at a time, so we render the first
7568
+ // (and only) pending action.
7569
+ const action = this._pendingActions[0];
7570
+ // Title names the tool being run ("Run {tool}?").
7571
+ let title = 'Run this action?';
7572
+ if (action) {
7573
+ title = `Run ${action.displayName || action.toolName}?`;
7574
+ }
7575
+ return b `
7576
+ <div
7577
+ class="confirmation"
7578
+ role="alertdialog"
7579
+ aria-label="Confirm before continuing"
7580
+ >
7581
+ <div class="confirmation__title">${title}</div>
7582
+ <p class="confirmation__message">${action?.confirmationMessage || 'The assistant wants to take an action. Is that OK?'}</p>
7583
+ <div class="confirmation__actions">
7584
+ ${CONFIRMATION_DECISIONS.map((option, index) => b `
7585
+ <kr-button
7586
+ class=${e({
7587
+ 'confirmation__btn': true,
7588
+ 'confirmation__btn--highlighted': index === this._confirmationHighlighted,
7589
+ })}
7590
+ variant=${option.decision === 'approve' ? 'flat' : 'outline'}
7591
+ color=${option.decision === 'reject' ? 'secondary' : 'primary'}
7592
+ size="small"
7593
+ ?disabled=${this._confirmationInFlight}
7594
+ @click=${() => this._handleConfirmationDecision(option.decision)}
7595
+ >${option.label}</kr-button>
7596
+ `)}
7597
+ </div>
7598
+ </div>
7599
+ `;
7600
+ }
7340
7601
  /** The `/` skill menu shown above the composer while typing a slash command. */
7341
7602
  _renderSkillMenu() {
7342
7603
  const skills = this._invocableSkills;
@@ -7960,6 +8221,8 @@ let KRChatbot = class KRChatbot extends i$3 {
7960
8221
  </div>
7961
8222
  </div>
7962
8223
 
8224
+ ${this._pendingActions.length ? this._renderConfirmation() : A}
8225
+
7963
8226
  ${this._error ? b `
7964
8227
  <div class="error">
7965
8228
  <span>${this._error}</span>
@@ -8155,10 +8418,13 @@ let KRChatbot = class KRChatbot extends i$3 {
8155
8418
  <button
8156
8419
  class=${e({
8157
8420
  'send': true,
8158
- 'send--disabled': !this._inputValue.trim() && !this._pendingConversationFiles.length,
8421
+ // Blocked while a guarded-tool batch awaits a decision — a
8422
+ // normal message would be rejected until it's resolved.
8423
+ 'send--disabled': (!this._inputValue.trim() && !this._pendingConversationFiles.length)
8424
+ || this._pendingActions.length > 0,
8159
8425
  })}
8160
8426
  @click=${this._handleSubmit}
8161
- title="Send message"
8427
+ title=${this._pendingActions.length ? 'Approve or deny the pending action first' : 'Send message'}
8162
8428
  >
8163
8429
  <svg
8164
8430
  width="16"
@@ -8703,6 +8969,68 @@ KRChatbot.styles = i$6 `
8703
8969
  flex-shrink: 0;
8704
8970
  }
8705
8971
 
8972
+ /* Action-confirmation card (Rail 3). Floats OVER the messages, anchored just
8973
+ above the composer — not part of the message scroll — so it stays put and
8974
+ in view while the transcript scrolls behind it. Its width matches the
8975
+ message text column: .messages-inner is 768px max with 32px padding, so the
8976
+ text column is 768 - 64. Plain-language ask + Yes/No for a non-technical
8977
+ user; no tool codes, no raw arguments. */
8978
+ .confirmation {
8979
+ position: absolute;
8980
+ left: 32px;
8981
+ right: 32px;
8982
+ /* Sits above the footer/composer with a clear gap between the two. */
8983
+ bottom: 104px;
8984
+ z-index: 6;
8985
+ max-width: 720px;
8986
+ margin: 0 auto;
8987
+ padding: 24px;
8988
+ border: 1px solid #d8d8d8;
8989
+ border-radius: 8px;
8990
+ background: var(--kr-chatbot-bg);
8991
+ color: var(--kr-chatbot-text);
8992
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
8993
+ /* Never taller than the panel leaves room for — scroll the card itself if a
8994
+ batched multi-action ask is long, rather than covering the whole panel. */
8995
+ max-height: calc(100% - 176px);
8996
+ overflow-y: auto;
8997
+ }
8998
+
8999
+ /* "Run {tool}?" heading — names the tool being run. */
9000
+ .confirmation__title {
9001
+ font-size: 16px;
9002
+ font-weight: 600;
9003
+ margin-bottom: 8px;
9004
+ }
9005
+
9006
+ .confirmation__message {
9007
+ margin: 0;
9008
+ font-size: 14px;
9009
+ line-height: 1.5;
9010
+ }
9011
+
9012
+ .confirmation__message + .confirmation__message {
9013
+ margin-top: 6px;
9014
+ }
9015
+
9016
+ /* Buttons in a row; wrap on a narrow panel rather than overflowing the card. */
9017
+ .confirmation__actions {
9018
+ display: flex;
9019
+ flex-wrap: wrap;
9020
+ align-items: center;
9021
+ gap: 8px;
9022
+ margin-top: 16px;
9023
+ }
9024
+
9025
+ /* Keyboard-highlighted decision — a focus ring around the button host, drawn
9026
+ outside its border so it reads over both the flat (Yes) and outline
9027
+ variants without recoloring the button itself. */
9028
+ .confirmation__btn--highlighted {
9029
+ border-radius: 8px;
9030
+ outline: 2px solid var(--kr-chatbot-primary);
9031
+ outline-offset: 2px;
9032
+ }
9033
+
8706
9034
  /*
8707
9035
  * INTERNAL-ONLY debug telemetry drawer. A layout drawer docked to the right
8708
9036
  * of the inline chat — the chat column shrinks to make room; it does not
@@ -9643,6 +9971,15 @@ __decorate([
9643
9971
  __decorate([
9644
9972
  r()
9645
9973
  ], KRChatbot.prototype, "_contextLimitMessage", void 0);
9974
+ __decorate([
9975
+ r()
9976
+ ], KRChatbot.prototype, "_pendingActions", void 0);
9977
+ __decorate([
9978
+ r()
9979
+ ], KRChatbot.prototype, "_confirmationHighlighted", void 0);
9980
+ __decorate([
9981
+ r()
9982
+ ], KRChatbot.prototype, "_confirmationInFlight", void 0);
9646
9983
  __decorate([
9647
9984
  r()
9648
9985
  ], KRChatbot.prototype, "_telemetry", void 0);