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