@kodaris/krubble-app-components 1.0.112 → 1.0.114

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,47 @@ 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
+ ];
5550
+ /**
5551
+ * The modes as shown in the composer's mode selector, in menu order. `label` is
5552
+ * the short trigger text (Gemini-style inline label); `description` is the menu
5553
+ * sublabel. Written for a non-technical user — no "guarded tool" jargon.
5554
+ */
5555
+ const CHATBOT_MODES = [
5556
+ {
5557
+ mode: 'NORMAL',
5558
+ label: 'Ask',
5559
+ description: 'Asks before making changes',
5560
+ },
5561
+ {
5562
+ mode: 'EDIT',
5563
+ label: 'Auto',
5564
+ description: 'Makes changes automatically',
5565
+ },
5566
+ {
5567
+ mode: 'PLAN',
5568
+ label: 'Plan',
5569
+ description: 'Suggests a plan, makes no changes',
5570
+ },
5571
+ ];
5531
5572
  /** Image extensions the chatbot renders as thumbnails (used when MIME is absent). */
5532
5573
  const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
5533
5574
  /**
@@ -5562,9 +5603,20 @@ const ACCEPTED_FILE_EXTENSIONS = [
5562
5603
  * - `TOOL_RESULT` — `{ type: "TOOL_RESULT", message: "tool_name", data: {...} }`
5563
5604
  * - `ERROR` — `{ type: "ERROR", message: "Something went wrong" }`
5564
5605
  * - `HEARTBEAT` — `{ type: "HEARTBEAT" }` (ignored)
5606
+ * - `ACTION_CONFIRMATION_REQUIRED` — a guarded tool paused for approval;
5607
+ * `{ type, data: { actions: [...] }, finished: true }` (Rail 3).
5565
5608
  *
5566
5609
  * The final event should include `finished: true` to signal stream completion.
5567
5610
  *
5611
+ * ## Turn locking (when the composer is free to send)
5612
+ * `finished: true` is the single reliable "turn over, safe to send" signal — the
5613
+ * component unlocks the composer on it, and also on stream close/error as a
5614
+ * fallback so a dropped stream never wedges the UI. The one exception is
5615
+ * `ACTION_CONFIRMATION_REQUIRED`: it too carries `finished: true`, but the
5616
+ * conversation is now awaiting an approve/deny decision, so the composer stays
5617
+ * blocked and only a confirmation decision (not a new message) is a valid next
5618
+ * call.
5619
+ *
5568
5620
  * ## Usage
5569
5621
  * ```html
5570
5622
  * <kr-chatbot
@@ -5591,6 +5643,25 @@ let KRChatbot = class KRChatbot extends i$3 {
5591
5643
  constructor() {
5592
5644
  super(...arguments);
5593
5645
  // --- Private properties ---
5646
+ /**
5647
+ * Internal toggle for how the action-confirmation prompt (Rail 3) is presented:
5648
+ * - `false`: the compact card that floats over the messages, pinned above the
5649
+ * composer — plain-language ask + Yes/Always/No only, no tool arguments.
5650
+ * - `true`: the prompt renders inline in the message stream (Claude-CLI style),
5651
+ * grows to any height (no inner scroll — the transcript scrolls), and offers a
5652
+ * "Show details" disclosure of the raw tool input for technical users.
5653
+ * Flip here to switch approaches; the floating path is unchanged when false.
5654
+ */
5655
+ this._inlineConfirm = true;
5656
+ /**
5657
+ * Internal toggle for how the assistant's reasoning ("thinking") is presented:
5658
+ * - `false`: the current collapsible grey "Thinking" disclosure (kept so we can
5659
+ * revert).
5660
+ * - `true`: reasoning renders as a plain agentic message — same text style as a
5661
+ * normal assistant reply, no box, no collapse.
5662
+ * Flip here to switch approaches.
5663
+ */
5664
+ this._reasoningAsMessage = true;
5594
5665
  this._reader = null;
5595
5666
  this._decoder = new TextDecoder();
5596
5667
  this._buffer = '';
@@ -5734,6 +5805,32 @@ let KRChatbot = class KRChatbot extends i$3 {
5734
5805
  this._contextLimitReached = false;
5735
5806
  /** Server-provided limit text (`CONVERSATION_LIMIT_REACHED`), if any. */
5736
5807
  this._contextLimitMessage = '';
5808
+ /**
5809
+ * The guarded tool awaiting the user's decision, from the turn's
5810
+ * `ACTION_CONFIRMATION_REQUIRED` event (`data.actions[]`, of which we handle the
5811
+ * first — the framework confirms one guarded tool at a time). Non-empty means
5812
+ * the conversation is paused on a confirmation: the normal composer is blocked
5813
+ * (a new chat message would be rejected by the server with
5814
+ * `awaitingConfirmation`) and the approve/deny prompt is shown instead. Cleared
5815
+ * once a decision is sent.
5816
+ *
5817
+ * This is the one case where `finished:true` unlocks the turn lock but does
5818
+ * NOT free the composer — the only valid next call is a confirmation decision.
5819
+ */
5820
+ this._pendingActions = [];
5821
+ /**
5822
+ * Keyboard-highlighted confirmation option, as an index into the decision list
5823
+ * (0 = Yes, 1 = Always allow, 2 = No). Yes is highlighted by default so Enter
5824
+ * approves. Arrow Left/Right move it; Enter activates it. -1 when no batch is
5825
+ * pending.
5826
+ */
5827
+ this._confirmationHighlighted = -1;
5828
+ /**
5829
+ * True while a confirmation decision (approve/deny) is being sent and its
5830
+ * follow-up turn streams. Disables the approve/deny buttons so the batch can't
5831
+ * be resolved twice.
5832
+ */
5833
+ this._confirmationInFlight = false;
5737
5834
  /**
5738
5835
  * INTERNAL-ONLY latest telemetry snapshot (last-value-wins). Populated from
5739
5836
  * `TOKEN_USAGE` events during streaming and hydrated from the conversation
@@ -5748,11 +5845,20 @@ let KRChatbot = class KRChatbot extends i$3 {
5748
5845
  this._iterationLog = [];
5749
5846
  /** Latest cache-checkpoint info keyed by iteration, for the verbose table. */
5750
5847
  this._cacheCheckpoints = {};
5751
- /** Whether the internal debug panel is expanded (off by default). */
5752
- this._debugPanelOpen = false;
5753
- /** Current width of the debug drawer in px (user-resizable, persisted). */
5848
+ /**
5849
+ * Whether the side drawer is open (slid in). The drawer stays mounted and
5850
+ * animates via a CSS transform, so this drives the slide and the content push.
5851
+ */
5852
+ this._drawerOpen = false;
5853
+ /**
5854
+ * Which view the drawer shows — settings or debug. One drawer holds both; the
5855
+ * header buttons switch this and the content/width swap inside. Never null, so
5856
+ * the content stays visible while the drawer slides shut.
5857
+ */
5858
+ this._drawerView = 'settings';
5859
+ /** Current width of the drawer in px (user-resizable, persisted). */
5754
5860
  this._debugDrawerWidth = 430;
5755
- /** True while the user is dragging the debug drawer's resize handle. */
5861
+ /** True while the user is dragging the drawer's resize handle. */
5756
5862
  this._isDebugResizing = false;
5757
5863
  /** Files staged in the composer, before the message is sent. */
5758
5864
  this._pendingConversationFiles = [];
@@ -5803,6 +5909,31 @@ let KRChatbot = class KRChatbot extends i$3 {
5803
5909
  this._skillBrowseQuery = '';
5804
5910
  /** True while the mic is live and speech is being transcribed into the composer. */
5805
5911
  this._isDictating = false;
5912
+ /**
5913
+ * Conversation posture for guarded tools (the composer's mode selector). Sent on
5914
+ * each turn's `mode` field and kept in sync with the server's `CONVERSATION_STATE`
5915
+ * event. Defaults to NORMAL (ask before each guarded tool).
5916
+ */
5917
+ this._mode = 'NORMAL';
5918
+ /** Whether the composer's mode menu (Ask / Auto / Plan) is open. */
5919
+ this._modeMenuOpened = false;
5920
+ /**
5921
+ * Whether to show the tools the assistant ran, as activity lines in the
5922
+ * transcript. On by default; a user can hide them from the settings panel.
5923
+ * User-controlled in the settings panel; persisted in localStorage.
5924
+ */
5925
+ this._showTools = true;
5926
+ /**
5927
+ * Whether to also show each tool's inputs and results under its line. Only
5928
+ * has an effect when `_showTools` is on. Off by default.
5929
+ */
5930
+ this._showToolInputs = false;
5931
+ /**
5932
+ * Whether to show the raw tool input inside the confirmation card (the JSON the
5933
+ * model proposed for a guarded action). The plain-language ask + Yes/No always
5934
+ * show; this only reveals the technical detail. Off by default.
5935
+ */
5936
+ this._showConfirmationInput = false;
5806
5937
  /**
5807
5938
  * Close the `/` skill menu when the click lands outside it.
5808
5939
  *
@@ -5813,6 +5944,14 @@ let KRChatbot = class KRChatbot extends i$3 {
5813
5944
  * inside click would look like an outside one.
5814
5945
  */
5815
5946
  this._handleDocumentClick = (e) => {
5947
+ // Close the mode menu on any click outside the mode selector (the trigger
5948
+ // owns its own toggle, so exclude the whole `.mode` block).
5949
+ if (this._modeMenuOpened) {
5950
+ const modePath = e.composedPath();
5951
+ if (!modePath.some((el) => el instanceof HTMLElement && el.classList.contains('mode'))) {
5952
+ this._modeMenuOpened = false;
5953
+ }
5954
+ }
5816
5955
  if (!this._skillMenuOpened) {
5817
5956
  return;
5818
5957
  }
@@ -5887,6 +6026,36 @@ let KRChatbot = class KRChatbot extends i$3 {
5887
6026
  document.removeEventListener('mouseup', this._handleMouseUp);
5888
6027
  this._saveState();
5889
6028
  };
6029
+ /**
6030
+ * Keyboard control for the confirmation prompt. Bound at the document so it
6031
+ * works even though the composer (where focus usually sits) is blocked while a
6032
+ * batch is pending: Left/Right move the highlight across Yes / Always allow /
6033
+ * No, Enter activates the highlighted one. No-op when nothing is pending or a
6034
+ * decision is already in flight.
6035
+ */
6036
+ this._handleDocumentKeydown = (e) => {
6037
+ if (!this._pendingActions.length || this._confirmationInFlight) {
6038
+ return;
6039
+ }
6040
+ if (e.key === 'ArrowRight') {
6041
+ e.preventDefault();
6042
+ this._confirmationHighlighted = (this._confirmationHighlighted + 1) % CONFIRMATION_DECISIONS.length;
6043
+ return;
6044
+ }
6045
+ if (e.key === 'ArrowLeft') {
6046
+ e.preventDefault();
6047
+ this._confirmationHighlighted =
6048
+ (this._confirmationHighlighted - 1 + CONFIRMATION_DECISIONS.length) % CONFIRMATION_DECISIONS.length;
6049
+ return;
6050
+ }
6051
+ if (e.key === 'Enter') {
6052
+ e.preventDefault();
6053
+ const option = CONFIRMATION_DECISIONS[this._confirmationHighlighted];
6054
+ if (option) {
6055
+ this._handleConfirmationDecision(option.decision);
6056
+ }
6057
+ }
6058
+ };
5890
6059
  this._handleDebugResizeMouseMove = (e) => {
5891
6060
  if (!this._isDebugResizing) {
5892
6061
  return;
@@ -5924,6 +6093,36 @@ let KRChatbot = class KRChatbot extends i$3 {
5924
6093
  return KRHttp.fetch({ url: `/api/system/conversation/${this.conversationId}/messages` })
5925
6094
  .then((r) => r.json())
5926
6095
  .then((res) => Promise.all((res?.data || []).map((msg) => {
6096
+ // Persisted tool_use / tool_result rows — the message IS the row: keep
6097
+ // the type and parse content into `data`, the same {type, data} shape
6098
+ // the live stream pushes. The render reads data.* and pairs a result to
6099
+ // its use by data.toolUseId. No transform, so live and reload match.
6100
+ if (msg.type === 'tool_use' || msg.type === 'tool_result') {
6101
+ let data = {};
6102
+ try {
6103
+ data = JSON.parse(msg.content || '{}');
6104
+ }
6105
+ catch { /* malformed — render a bare line from what we have */ }
6106
+ return Promise.resolve({
6107
+ type: msg.type,
6108
+ content: '',
6109
+ data,
6110
+ });
6111
+ }
6112
+ // A persisted approve/deny record — content is {outcome, tools:[...]}.
6113
+ // Keep the type, parse content into `data`; the render reads msg.data.*.
6114
+ if (msg.type === 'action_decision') {
6115
+ let data = {};
6116
+ try {
6117
+ data = JSON.parse(msg.content || '{}');
6118
+ }
6119
+ catch { /* malformed — render a bare line from what we have */ }
6120
+ return Promise.resolve({
6121
+ type: 'action_decision',
6122
+ content: '',
6123
+ data,
6124
+ });
6125
+ }
5927
6126
  // Resolve this message's `conversationFileIDs` (a JSON array string of
5928
6127
  // ids) into full file objects — each fetch response IS the
5929
6128
  // KRChatbotConversationFile (id + niceName + mimeType), and niceName
@@ -5972,6 +6171,9 @@ let KRChatbot = class KRChatbot extends i$3 {
5972
6171
  }
5973
6172
  this._restoreState();
5974
6173
  document.addEventListener('click', this._handleDocumentClick);
6174
+ // Drive the confirmation prompt from the keyboard even when focus isn't in the
6175
+ // (blocked) composer: Left/Right move the highlight, Enter activates it.
6176
+ document.addEventListener('keydown', this._handleDocumentKeydown);
5975
6177
  }
5976
6178
  disconnectedCallback() {
5977
6179
  super.disconnectedCallback();
@@ -5982,6 +6184,7 @@ let KRChatbot = class KRChatbot extends i$3 {
5982
6184
  document.removeEventListener('mousemove', this._handleMouseMove);
5983
6185
  document.removeEventListener('mouseup', this._handleMouseUp);
5984
6186
  document.removeEventListener('click', this._handleDocumentClick);
6187
+ document.removeEventListener('keydown', this._handleDocumentKeydown);
5985
6188
  // Release the mic if the chatbot is torn down mid-dictation — otherwise the
5986
6189
  // recognizer keeps the browser's recording indicator lit with nothing to
5987
6190
  // transcribe into. abort() (not stop()) since the discarded text has nowhere to go.
@@ -6174,7 +6377,12 @@ let KRChatbot = class KRChatbot extends i$3 {
6174
6377
  const state = {
6175
6378
  opened: String(this.opened),
6176
6379
  message: this._inputValue,
6177
- debugPanelOpen: String(this._debugPanelOpen),
6380
+ mode: this._mode,
6381
+ showTools: String(this._showTools),
6382
+ showToolInputs: String(this._showToolInputs),
6383
+ showConfirmationInput: String(this._showConfirmationInput),
6384
+ drawerOpen: String(this._drawerOpen),
6385
+ drawerView: this._drawerView,
6178
6386
  debugDrawerWidth: String(this._debugDrawerWidth),
6179
6387
  };
6180
6388
  if (this._panelEl) {
@@ -6215,7 +6423,16 @@ let KRChatbot = class KRChatbot extends i$3 {
6215
6423
  if (typeof state.message === 'string' && state.message.trim() !== '/') {
6216
6424
  this._inputValue = state.message;
6217
6425
  }
6218
- this._debugPanelOpen = state.debugPanelOpen === 'true';
6426
+ if (state.mode === 'NORMAL' || state.mode === 'EDIT' || state.mode === 'PLAN') {
6427
+ this._mode = state.mode;
6428
+ }
6429
+ this._showTools = state.showTools === 'true';
6430
+ this._showToolInputs = state.showToolInputs === 'true';
6431
+ this._showConfirmationInput = state.showConfirmationInput === 'true';
6432
+ this._drawerOpen = state.drawerOpen === 'true';
6433
+ if (state.drawerView === 'settings' || state.drawerView === 'debug') {
6434
+ this._drawerView = state.drawerView;
6435
+ }
6219
6436
  const w = Number(state.debugDrawerWidth);
6220
6437
  if (w >= 300) {
6221
6438
  this._debugDrawerWidth = w;
@@ -6518,6 +6735,29 @@ let KRChatbot = class KRChatbot extends i$3 {
6518
6735
  this._skillMenuOpened = true;
6519
6736
  this._searchInvocableSkills('');
6520
6737
  }
6738
+ _handleModeToggle() {
6739
+ this._modeMenuOpened = !this._modeMenuOpened;
6740
+ }
6741
+ _handleModeSelect(mode) {
6742
+ this._mode = mode;
6743
+ this._modeMenuOpened = false;
6744
+ // Persist the choice so it survives a reload, alongside the other composer
6745
+ // state. The server is still the source of truth on the next turn's
6746
+ // CONVERSATION_STATE, but this keeps the selector from flashing the default.
6747
+ this._saveState();
6748
+ }
6749
+ _handleShowToolsChange(e) {
6750
+ this._showTools = e.target.checked;
6751
+ this._saveState();
6752
+ }
6753
+ _handleShowToolInputsChange(e) {
6754
+ this._showToolInputs = e.target.checked;
6755
+ this._saveState();
6756
+ }
6757
+ _handleShowConfirmationInputChange(e) {
6758
+ this._showConfirmationInput = e.target.checked;
6759
+ this._saveState();
6760
+ }
6521
6761
  /**
6522
6762
  * The mic button toggles dictation. Speech is transcribed straight into the
6523
6763
  * composer as editable text — the user still reviews and presses send, so a
@@ -6856,6 +7096,13 @@ let KRChatbot = class KRChatbot extends i$3 {
6856
7096
  if (this._isStreaming) {
6857
7097
  return;
6858
7098
  }
7099
+ // A guarded-tool batch is awaiting a decision. The turn lock released with
7100
+ // its finished:true, but the server rejects a normal chat message with
7101
+ // awaitingConfirmation until it's resolved — so the composer stays blocked
7102
+ // and the approve/deny prompt owns the next call.
7103
+ if (this._pendingActions.length) {
7104
+ return;
7105
+ }
6859
7106
  const messageText = this._inputValue.trim();
6860
7107
  const pending = this._pendingConversationFiles;
6861
7108
  // Allow sending an image with no text, but require at least one of them.
@@ -6937,22 +7184,25 @@ let KRChatbot = class KRChatbot extends i$3 {
6937
7184
  content: '',
6938
7185
  }];
6939
7186
  this._isStreaming = true;
6940
- // Scroll user's message to the top (Gemini-style).
6941
- // Set min-height on the assistant message = container height so there's enough
6942
- // scroll room below the user message to push it to the top.
7187
+ // Scroll user's message to the top (Gemini-style). The runway (min-height =
7188
+ // viewport) goes on the agent-turn WRAPPER, not an individual message — the
7189
+ // whole turn (reasoning, tool lines, text, decision) streams inside that one
7190
+ // element, so nothing strands below it. Enough scroll room below the user
7191
+ // message to push it to the top.
6943
7192
  this.updateComplete.then(() => {
6944
7193
  if (!this._messagesEl) {
6945
7194
  return;
6946
7195
  }
6947
- // Clear min-height from previous responses
6948
- this._messagesEl.querySelectorAll('.message--assistant').forEach((el) => {
7196
+ // Clear runway from previous turns.
7197
+ this._messagesEl.querySelectorAll('.agent-turn').forEach((el) => {
6949
7198
  el.style.minHeight = '';
6950
7199
  });
6951
- const allMsgEls = this._messagesEl.querySelectorAll('.message');
6952
- const userMsgEl = allMsgEls[this.messages.length - 2];
6953
- const assistantMsgEl = allMsgEls[this.messages.length - 1];
6954
- if (assistantMsgEl) {
6955
- assistantMsgEl.style.minHeight = this._messagesEl.clientHeight + 'px';
7200
+ const userEls = this._messagesEl.querySelectorAll('.message--user');
7201
+ const userMsgEl = userEls[userEls.length - 1];
7202
+ const turnEls = this._messagesEl.querySelectorAll('.agent-turn');
7203
+ const turnEl = turnEls[turnEls.length - 1];
7204
+ if (turnEl) {
7205
+ turnEl.style.minHeight = this._messagesEl.clientHeight + 'px';
6956
7206
  }
6957
7207
  if (userMsgEl) {
6958
7208
  this._messagesEl.scrollTop = userMsgEl.offsetTop - this._messagesEl.offsetTop - 24;
@@ -6962,6 +7212,22 @@ let KRChatbot = class KRChatbot extends i$3 {
6962
7212
  // the consumer hasn't provided their own `send`.
6963
7213
  if (!this.send) {
6964
7214
  this.send = (params) => {
7215
+ // Rail-3 confirmation decision: resolve a paused guarded-tool batch by
7216
+ // hitting the SAME streaming endpoint with `confirmation` set. There's no
7217
+ // userText/files on this path (the server ignores message text here), and
7218
+ // the conversation is always already pinned (a batch can't pause before
7219
+ // the conversation exists), so no upload and no createNew.
7220
+ if (params.confirmation) {
7221
+ return KRHttp.fetch({
7222
+ url: '/api/system/integration/aws/bedrock/stream/invokeModelForConversationWithState',
7223
+ method: 'POST',
7224
+ headers: { Accept: 'text/event-stream' },
7225
+ body: {
7226
+ confirmation: params.confirmation,
7227
+ conversationID: this.conversationId,
7228
+ },
7229
+ });
7230
+ }
6965
7231
  // Images are upload-first: each file is stored as a conversation file
6966
7232
  // (type LIBRARY — the format lives in mimeType), then the resulting
6967
7233
  // conversationFileIDs are attached to the message via `fileIds` on the
@@ -7018,6 +7284,9 @@ let KRChatbot = class KRChatbot extends i$3 {
7018
7284
  const body = {
7019
7285
  userText: params.message,
7020
7286
  fileIds: files.map((f) => f.conversationFileID),
7287
+ // Posture for guarded tools this turn (Ask / Auto / Plan). The server
7288
+ // validates and persists it, and only re-sets it when it changes.
7289
+ mode: this._mode,
7021
7290
  };
7022
7291
  // Ground the model in where the user is and what they've highlighted.
7023
7292
  // Only sent when present so an empty selection / failed capture doesn't
@@ -7101,6 +7370,67 @@ let KRChatbot = class KRChatbot extends i$3 {
7101
7370
  }
7102
7371
  });
7103
7372
  }
7373
+ /**
7374
+ * Resolve the paused guarded-tool batch (Rail 3). Hits the SAME streaming
7375
+ * endpoint as a normal turn but with a `confirmation` value instead of a
7376
+ * message; the server runs (approve/approveAlways) or denies (reject) the batch
7377
+ * and continues the turn, streaming the outcome into a fresh assistant bubble.
7378
+ *
7379
+ * The decision uses the same `send` callback so a custom `send` can own its own
7380
+ * targeting — it just receives `confirmation` set and no message.
7381
+ */
7382
+ _handleConfirmationDecision(decision) {
7383
+ if (this._confirmationInFlight) {
7384
+ return;
7385
+ }
7386
+ this._confirmationInFlight = true;
7387
+ this._error = '';
7388
+ // The decision line is authored by the server (single source of truth): it
7389
+ // persists an `action_decision` row AND streams an `ACTION_DECISION` event
7390
+ // back. We keep the prompt in place — the clicked button shows a loading
7391
+ // spinner — until that event arrives, which is our ack that the server
7392
+ // accepted the decision. The event handler then drops the prompt, appends the
7393
+ // decision line, and the follow-up turn streams into a new assistant bubble.
7394
+ const btn = this._messagesEl?.querySelector(`.confirmation__btn[data-decision="${decision}"]`);
7395
+ btn?.showLoading();
7396
+ this._isStreaming = true;
7397
+ this._userHasScrolledUp = false;
7398
+ this.send({
7399
+ message: '',
7400
+ messages: this.messages,
7401
+ conversationFiles: [],
7402
+ pageContext: '',
7403
+ selectionContext: '',
7404
+ confirmation: decision,
7405
+ }).then((response) => {
7406
+ // Leave _confirmationInFlight true — the buttons stay loading until the
7407
+ // streamed ACTION_DECISION event resolves the prompt. Only clear it on an
7408
+ // error path, where no such event will arrive.
7409
+ if (!response.ok) {
7410
+ this._confirmationInFlight = false;
7411
+ btn?.reset();
7412
+ this._handleStreamError('Request failed: ' + response.status);
7413
+ return;
7414
+ }
7415
+ if (!response.body) {
7416
+ this._confirmationInFlight = false;
7417
+ btn?.reset();
7418
+ this._handleStreamError('No response body');
7419
+ return;
7420
+ }
7421
+ this._reader = response.body.getReader();
7422
+ this._decoder = new TextDecoder();
7423
+ this._buffer = '';
7424
+ this._readStreamChunk();
7425
+ }).catch((err) => {
7426
+ this._confirmationInFlight = false;
7427
+ btn?.reset();
7428
+ if (err.name !== 'AbortError') {
7429
+ this._handleStreamError(err.message);
7430
+ }
7431
+ });
7432
+ }
7433
+ /** Clear the in-flight confirmation flags (error paths, or once resolved). */
7104
7434
  /**
7105
7435
  * Capture ambient "where is the user / what are they looking at" context at
7106
7436
  * send time, so the AI is grounded without the host passing anything in.
@@ -7174,8 +7504,26 @@ let KRChatbot = class KRChatbot extends i$3 {
7174
7504
  _handleContextWarningDismiss() {
7175
7505
  this._contextWarning = '';
7176
7506
  }
7177
- _handleToggleDebugPanel() {
7178
- this._debugPanelOpen = !this._debugPanelOpen;
7507
+ /**
7508
+ * A header button either opens the drawer to its view, switches the view if the
7509
+ * drawer is already open on the other one, or closes the drawer if it's already
7510
+ * showing this view. `_drawerView` is left intact on close so the content stays
7511
+ * visible while the drawer slides shut.
7512
+ */
7513
+ _toggleDrawer(view) {
7514
+ if (this._drawerOpen && this._drawerView === view) {
7515
+ this._drawerOpen = false;
7516
+ }
7517
+ else {
7518
+ this._drawerOpen = true;
7519
+ this._drawerView = view;
7520
+ }
7521
+ this._saveState();
7522
+ }
7523
+ /** Close the drawer. `_drawerView` is left intact so its content stays visible
7524
+ * while the drawer slides shut. */
7525
+ _handleCloseDrawer() {
7526
+ this._drawerOpen = false;
7179
7527
  this._saveState();
7180
7528
  }
7181
7529
  _handleDebugResizeMouseDown(e) {
@@ -7193,8 +7541,11 @@ let KRChatbot = class KRChatbot extends i$3 {
7193
7541
  }
7194
7542
  this._reader.read().then((result) => {
7195
7543
  if (result.done) {
7196
- this._isStreaming = false;
7197
- this._reader = null;
7544
+ // Fallback unlock: the stream closed without a `finished:true` chunk
7545
+ // (network drop, server crash, the 5-min emitter timeout). Treat it as
7546
+ // the turn ending so a dropped stream never wedges the composer — the
7547
+ // server self-heals its turn lock and bounces any premature send.
7548
+ this._finishStream();
7198
7549
  return;
7199
7550
  }
7200
7551
  this._buffer = this._buffer + this._decoder.decode(result.value, { stream: true });
@@ -7228,28 +7579,54 @@ let KRChatbot = class KRChatbot extends i$3 {
7228
7579
  }));
7229
7580
  if (data.type === 'REASONING_PART') {
7230
7581
  if (data.message) {
7231
- const lastIndex = this.messages.length - 1;
7232
- if (lastIndex >= 0 && this.messages[lastIndex].type === 'assistant') {
7233
- if (!this.messages[lastIndex].reasoning) {
7234
- this.messages[lastIndex].reasoning = '';
7235
- }
7236
- this.messages[lastIndex].reasoning += String(data.message);
7237
- this.requestUpdate();
7582
+ // Text after a tool line belongs in a NEW assistant bubble — the
7583
+ // last message may be a tool line, which text can't append to.
7584
+ // _ensureAssistantBubble opens a fresh bubble when needed.
7585
+ const target = this._ensureAssistantBubble();
7586
+ if (!target.reasoning) {
7587
+ target.reasoning = '';
7238
7588
  }
7589
+ target.reasoning += String(data.message);
7590
+ this.requestUpdate();
7239
7591
  }
7240
7592
  }
7241
7593
  else if (data.type === 'RESPONSE_PART') {
7242
7594
  if (data.message) {
7243
- const lastIndex = this.messages.length - 1;
7244
- if (lastIndex >= 0 && this.messages[lastIndex].type === 'assistant') {
7245
- this.messages[lastIndex].content += String(data.message);
7246
- this.requestUpdate();
7247
- }
7595
+ this._ensureAssistantBubble().content += String(data.message);
7596
+ this.requestUpdate();
7248
7597
  }
7249
7598
  }
7599
+ else if (data.type === 'tool_use' || data.type === 'tool_result') {
7600
+ // Push the event straight into the transcript — the message IS the event
7601
+ // ({type, data}). The render reads msg.data.* and pairs a tool_result to
7602
+ // its tool_use by data.toolUseId. Same shape the DB persists and reload
7603
+ // replays, so live and reload share one representation — no transform.
7604
+ this.messages.push(data);
7605
+ this.requestUpdate();
7606
+ }
7607
+ else if (data.type === 'action_decision') {
7608
+ // The server accepted the confirmation decision and authored the record
7609
+ // (single source of truth). Its arrival is the ack: drop the prompt,
7610
+ // stop the buttons' loading, and push the event straight in — the render
7611
+ // reads msg.data.{outcome,tools}. Same type + shape the DB persists, so
7612
+ // live and reload share one representation.
7613
+ this._pendingActions = [];
7614
+ this._confirmationHighlighted = -1;
7615
+ this._confirmationInFlight = false;
7616
+ this.messages.push(data);
7617
+ this.requestUpdate();
7618
+ }
7250
7619
  else if (data.type === 'ERROR') {
7251
7620
  this._handleStreamError(String(data.message || 'Unknown error'));
7252
7621
  }
7622
+ else if (data.type === 'CONVERSATION_STATE') {
7623
+ // Turn-start state sync: the server is the source of truth for the
7624
+ // conversation's mode (it may have decayed EDIT → NORMAL after idle).
7625
+ // Adopt it so the composer's selector reflects the real posture.
7626
+ if (data.data?.mode === 'NORMAL' || data.data?.mode === 'EDIT' || data.data?.mode === 'PLAN') {
7627
+ this._mode = data.data.mode;
7628
+ }
7629
+ }
7253
7630
  else if (data.type === 'CONVERSATION_TOKEN_WARNING') {
7254
7631
  // User-facing: friendly "conversation is getting long" guidance.
7255
7632
  // Text only, never sent to the model — render verbatim as a banner.
@@ -7260,16 +7637,14 @@ let KRChatbot = class KRChatbot extends i$3 {
7260
7637
  // (it was created with a truncated first-message placeholder that we
7261
7638
  // deliberately never show). Adopt it as the header label and pass it
7262
7639
  // up so consumers can update their own chrome, e.g. a breadcrumb.
7263
- if (data.data) {
7264
- const title = String(data.data.title || '');
7265
- if (title) {
7266
- this.label = title;
7267
- this.dispatchEvent(new CustomEvent('conversation-title', {
7268
- detail: { title },
7269
- bubbles: true,
7270
- composed: true,
7271
- }));
7272
- }
7640
+ if (data.data?.title) {
7641
+ const title = String(data.data.title);
7642
+ this.label = title;
7643
+ this.dispatchEvent(new CustomEvent('conversation-title', {
7644
+ detail: { title },
7645
+ bubbles: true,
7646
+ composed: true,
7647
+ }));
7273
7648
  }
7274
7649
  }
7275
7650
  else if (data.type === 'CONVERSATION_LIMIT_REACHED') {
@@ -7279,6 +7654,20 @@ let KRChatbot = class KRChatbot extends i$3 {
7279
7654
  this._contextLimitMessage = String(data.message || '');
7280
7655
  this._contextWarning = '';
7281
7656
  }
7657
+ else if (data.type === 'ACTION_CONFIRMATION_REQUIRED') {
7658
+ // A guarded tool paused for approval (Rail 3). Carries finished:true
7659
+ // (handled below), so the turn lock releases — but the conversation
7660
+ // is now awaiting a decision, and a normal chat message would be
7661
+ // rejected with awaitingConfirmation. Capture the batch so the
7662
+ // composer stays blocked and the approve/deny prompt renders instead;
7663
+ // the next call must be a confirmation, not a userText.
7664
+ const actions = data.data?.actions;
7665
+ if (Array.isArray(actions) && actions.length) {
7666
+ this._pendingActions = actions;
7667
+ // Default the keyboard highlight to Yes so Enter approves.
7668
+ this._confirmationHighlighted = 0;
7669
+ }
7670
+ }
7282
7671
  else if (data.type === 'TOKEN_USAGE') {
7283
7672
  // INTERNAL-ONLY debug telemetry. Update the panel snapshot and the
7284
7673
  // per-iteration log; never render as a chat bubble.
@@ -7309,11 +7698,17 @@ let KRChatbot = class KRChatbot extends i$3 {
7309
7698
  }
7310
7699
  }
7311
7700
  if (data.finished) {
7312
- this._isStreaming = false;
7313
- this._reader = null;
7701
+ // Primary unlock signal: the turn is over and the server released
7702
+ // the turn lock. The SSE stream closes right after this chunk.
7703
+ this._finishStream();
7314
7704
  }
7315
7705
  }
7316
7706
  }
7707
+ // Stop reading once a terminal chunk ended the turn — the reader is nulled
7708
+ // by _finishStream and the stream is closing anyway.
7709
+ if (!this._reader) {
7710
+ return;
7711
+ }
7317
7712
  this._readStreamChunk();
7318
7713
  }).catch((err) => {
7319
7714
  if (err.name !== 'AbortError') {
@@ -7321,6 +7716,61 @@ let KRChatbot = class KRChatbot extends i$3 {
7321
7716
  }
7322
7717
  });
7323
7718
  }
7719
+ /**
7720
+ * The assistant bubble streamed text should append to. Returns the last message
7721
+ * when it's already an assistant bubble; otherwise (the last message is a tool
7722
+ * line, or there's nothing yet) opens a fresh assistant bubble so post-tool text
7723
+ * doesn't get dropped or glued to a tool line.
7724
+ */
7725
+ _ensureAssistantBubble() {
7726
+ const last = this.messages[this.messages.length - 1];
7727
+ if (last && last.type === 'assistant') {
7728
+ return last;
7729
+ }
7730
+ const bubble = {
7731
+ type: 'assistant',
7732
+ content: '',
7733
+ };
7734
+ this.messages = [...this.messages, bubble];
7735
+ return bubble;
7736
+ }
7737
+ /**
7738
+ * End the current turn: release the turn lock and the reader. Called on the
7739
+ * primary unlock signal (`finished:true`) and the fallback (stream close).
7740
+ *
7741
+ * A turn that paused for confirmation (`_pendingActions` set) leaves the
7742
+ * composer blocked even though the lock released — the approve/deny prompt
7743
+ * owns the next call. Its trailing empty assistant bubble is dropped so the
7744
+ * prompt doesn't sit under an empty message with a typing indicator.
7745
+ */
7746
+ _finishStream() {
7747
+ this._isStreaming = false;
7748
+ this._reader = null;
7749
+ if (this._pendingActions.length) {
7750
+ const lastIndex = this.messages.length - 1;
7751
+ // Drop the trailing assistant bubble only when it's truly empty — no
7752
+ // streamed text AND no reasoning. A reasoning-only bubble (the model thought
7753
+ // out loud, then went straight to the guarded tool) must be kept, else its
7754
+ // "Thinking" trace is lost when the confirmation card renders.
7755
+ if (lastIndex >= 0
7756
+ && this.messages[lastIndex].type === 'assistant'
7757
+ && !this.messages[lastIndex].content
7758
+ && !this.messages[lastIndex].reasoning) {
7759
+ this.messages = this.messages.slice(0, -1);
7760
+ }
7761
+ // Scroll the confirmation into view so the user actually sees the ask — an
7762
+ // agentic turn can stream many tool lines past the fold, leaving the prompt
7763
+ // below it. `block: 'end'` brings its bottom up to just above the composer.
7764
+ if (this._inlineConfirm) {
7765
+ this.updateComplete.then(() => {
7766
+ this._messagesEl?.querySelector('.confirmation')?.scrollIntoView({
7767
+ behavior: 'smooth',
7768
+ block: 'end',
7769
+ });
7770
+ });
7771
+ }
7772
+ }
7773
+ }
7324
7774
  _handleStreamError(message) {
7325
7775
  this._isStreaming = false;
7326
7776
  this._error = message;
@@ -7337,6 +7787,126 @@ let KRChatbot = class KRChatbot extends i$3 {
7337
7787
  }
7338
7788
  }
7339
7789
  // --- Render ---
7790
+ /**
7791
+ * The composer's mode selector — a Gemini-style inline label + caret sitting
7792
+ * just before the mic, that opens a small menu (Ask / Auto / Plan). Inline text
7793
+ * rather than a pill so it doesn't grow the single-row composer's height. The
7794
+ * menu opens upward above the trigger.
7795
+ */
7796
+ _renderModeSelector() {
7797
+ const active = CHATBOT_MODES.find((m) => m.mode === this._mode) || CHATBOT_MODES[0];
7798
+ return b `
7799
+ <div class="mode">
7800
+ ${this._modeMenuOpened ? b `
7801
+ <div class="mode-menu">
7802
+ ${CHATBOT_MODES.map((m) => b `
7803
+ <button
7804
+ class=${e({
7805
+ 'mode-menu__item': true,
7806
+ 'mode-menu__item--active': m.mode === this._mode,
7807
+ })}
7808
+ @click=${() => this._handleModeSelect(m.mode)}
7809
+ >
7810
+ <span class="mode-menu__label">${m.label}</span>
7811
+ <span class="mode-menu__desc">${m.description}</span>
7812
+ </button>
7813
+ `)}
7814
+ </div>
7815
+ ` : A}
7816
+ <button
7817
+ class="mode-trigger"
7818
+ aria-haspopup="true"
7819
+ aria-expanded=${this._modeMenuOpened ? 'true' : 'false'}
7820
+ @click=${this._handleModeToggle}
7821
+ >
7822
+ <span class="mode-trigger__label">${active.label}</span>
7823
+ <svg
7824
+ class="mode-trigger__caret"
7825
+ viewBox="0 0 24 24"
7826
+ fill="none"
7827
+ stroke="currentColor"
7828
+ stroke-width="2"
7829
+ stroke-linecap="round"
7830
+ stroke-linejoin="round"
7831
+ aria-hidden="true"
7832
+ >
7833
+ <path d="M6 9l6 6 6-6" />
7834
+ </svg>
7835
+ </button>
7836
+ </div>
7837
+ `;
7838
+ }
7839
+ /**
7840
+ * The approve/deny card shown when guarded tools are paused (Rail 3). Floats
7841
+ * OVER the messages, anchored just above the composer — not part of the message
7842
+ * scroll — so it stays in view while the transcript scrolls behind it.
7843
+ *
7844
+ * Written for a non-technical user. A single action reads as one plain-language
7845
+ * ask (`Run "{tool}"?` + the model's `confirmationMessage`). When the model
7846
+ * batches several guarded tools, they're listed together under one header and
7847
+ * the user accepts or rejects them all at once — the backend decision is
7848
+ * all-or-nothing. Either way: NO tool code, NO raw arguments/JSON — those are
7849
+ * noise (or alarming) to a normal user.
7850
+ */
7851
+ _renderConfirmation() {
7852
+ // Title: name the single tool, else a generic header for a batch.
7853
+ let title = 'Run this action?';
7854
+ if (this._pendingActions.length > 1) {
7855
+ title = 'These actions need your approval';
7856
+ }
7857
+ else if (this._pendingActions[0]) {
7858
+ title = `Run "${this._pendingActions[0].displayName || this._pendingActions[0].toolName}"?`;
7859
+ }
7860
+ return b `
7861
+ <div
7862
+ class=${e({
7863
+ 'confirmation': true,
7864
+ 'confirmation--inline': this._inlineConfirm,
7865
+ })}
7866
+ role="alertdialog"
7867
+ aria-label="Confirm before continuing"
7868
+ >
7869
+ <div class="confirmation__title">${title}</div>
7870
+ ${this._pendingActions.length > 1
7871
+ ? b `
7872
+ <ol class="confirmation__list">
7873
+ ${this._pendingActions.map((action) => b `
7874
+ <li class="confirmation__list-item">
7875
+ <span class="confirmation__list-name">${action.displayName || action.toolName}</span>
7876
+ ${action.confirmationMessage
7877
+ ? b `<span class="confirmation__list-desc">${action.confirmationMessage}</span>`
7878
+ : A}
7879
+ ${this._showConfirmationInput && this._inlineConfirm && action.input && Object.keys(action.input).length
7880
+ ? b `<pre class="confirmation__details-input">${JSON.stringify(action.input, null, 2)}</pre>`
7881
+ : A}
7882
+ </li>
7883
+ `)}
7884
+ </ol>
7885
+ `
7886
+ : b `
7887
+ <p class="confirmation__message">${this._pendingActions[0]?.confirmationMessage || 'The assistant wants to take an action. Is that OK?'}</p>
7888
+ ${this._showConfirmationInput && this._inlineConfirm && this._pendingActions[0]?.input && Object.keys(this._pendingActions[0].input).length
7889
+ ? b `<pre class="confirmation__details-input">${JSON.stringify(this._pendingActions[0].input, null, 2)}</pre>`
7890
+ : A}
7891
+ `}
7892
+ <div class="confirmation__actions">
7893
+ ${CONFIRMATION_DECISIONS.map((option, index) => b `
7894
+ <kr-button
7895
+ class=${e({
7896
+ 'confirmation__btn': true,
7897
+ 'confirmation__btn--highlighted': index === this._confirmationHighlighted,
7898
+ })}
7899
+ variant=${option.decision === 'approve' ? 'flat' : 'outline'}
7900
+ color=${option.decision === 'reject' ? 'secondary' : 'primary'}
7901
+ size="small"
7902
+ data-decision=${option.decision}
7903
+ @click=${() => this._handleConfirmationDecision(option.decision)}
7904
+ >${option.label}</kr-button>
7905
+ `)}
7906
+ </div>
7907
+ </div>
7908
+ `;
7909
+ }
7340
7910
  /** The `/` skill menu shown above the composer while typing a slash command. */
7341
7911
  _renderSkillMenu() {
7342
7912
  const skills = this._invocableSkills;
@@ -7551,26 +8121,37 @@ let KRChatbot = class KRChatbot extends i$3 {
7551
8121
  * three scopes from the latest TOKEN_USAGE event — context gauge, this turn,
7552
8122
  * lifetime — plus a verbose per-iteration table. Never customer-facing.
7553
8123
  */
7554
- _renderDebugPanel() {
8124
+ /**
8125
+ * The side drawer — one sliding panel that holds both the Settings and Debug
8126
+ * views. The header buttons switch `_drawerView`; the title and body swap
8127
+ * inside. The panel keeps one consistent (resizable) width across both views —
8128
+ * switching views only changes the content, never the panel size.
8129
+ */
8130
+ _renderDrawer() {
8131
+ // The debug view is gated on the server-side flag (inline variant + debug
8132
+ // telemetry enabled); if it's not enabled, the drawer only ever shows settings.
8133
+ const isDebug = this._drawerView === 'debug'
8134
+ && this.variant === 'inline'
8135
+ && this._aiFrameworkConfig.debugTelemetryEnabled === true;
7555
8136
  return b `
7556
8137
  <div
7557
8138
  class=${e({
7558
- 'debug-drawer': true,
7559
- 'debug-drawer--open': this._debugPanelOpen,
7560
- 'debug-drawer--resizing': this._isDebugResizing,
8139
+ 'drawer': true,
8140
+ 'drawer--open': this._drawerOpen,
8141
+ 'drawer--resizing': this._isDebugResizing,
7561
8142
  })}
7562
8143
  style=${o({ width: `${this._debugDrawerWidth}px` })}
7563
8144
  >
7564
8145
  <div
7565
- class="debug-drawer__resize"
8146
+ class="drawer__resize"
7566
8147
  @mousedown=${this._handleDebugResizeMouseDown}
7567
8148
  ></div>
7568
- <div class="debug-drawer__header">
7569
- <span class="debug-drawer__title">Debug</span>
8149
+ <div class="drawer__header">
8150
+ <span class="drawer__title">${isDebug ? 'Debug' : 'Settings'}</span>
7570
8151
  <button
7571
8152
  class="header-action"
7572
- @click=${this._handleToggleDebugPanel}
7573
- title="Close debug panel"
8153
+ @click=${this._handleCloseDrawer}
8154
+ title="Close"
7574
8155
  >
7575
8156
  <svg
7576
8157
  xmlns="http://www.w3.org/2000/svg"
@@ -7588,105 +8169,163 @@ let KRChatbot = class KRChatbot extends i$3 {
7588
8169
  </svg>
7589
8170
  </button>
7590
8171
  </div>
7591
- <div class="debug-drawer__body">
7592
- ${this._telemetry ? b `
7593
- <div class="debug-section">
7594
- <div class="debug-section__label">Context</div>
7595
- <div class="debug-gauge">
7596
- <div class="debug-gauge__bar">
7597
- <div
7598
- class="debug-gauge__fill"
7599
- style=${o({ width: `${Math.min(100, Math.max(0, this._telemetry.contextUsedPercent || 0))}%` })}
7600
- ></div>
7601
- </div>
7602
- <span class="debug-gauge__text">
7603
- ${this._fmtTokens(this._telemetry.currentContextTokens)} / ${this._fmtTokens(this._telemetry.contextWindowTokens)}
7604
- · ${this._telemetry.contextUsedPercent ?? 0}%
7605
- </span>
7606
- </div>
8172
+ <div class="drawer__body">
8173
+ ${isDebug ? this._renderDebugContent() : this._renderSettingsContent()}
8174
+ </div>
8175
+ </div>
8176
+ `;
8177
+ }
8178
+ /** The Settings view's body — user preferences, grouped in sections. */
8179
+ _renderSettingsContent() {
8180
+ return b `
8181
+ <div class="settings-section">
8182
+ <div class="settings-section__title">Tools</div>
8183
+ <div class="settings-row">
8184
+ <kr-checkbox
8185
+ label="Show tools"
8186
+ .checked=${this._showTools}
8187
+ @change=${this._handleShowToolsChange}
8188
+ ></kr-checkbox>
8189
+ </div>
8190
+ <div class="settings-row">
8191
+ <kr-checkbox
8192
+ label="Show tool inputs"
8193
+ .checked=${this._showToolInputs}
8194
+ @change=${this._handleShowToolInputsChange}
8195
+ ></kr-checkbox>
8196
+ </div>
8197
+ <div class="settings-row">
8198
+ <kr-checkbox
8199
+ label="Show tool confirmation inputs"
8200
+ .checked=${this._showConfirmationInput}
8201
+ @change=${this._handleShowConfirmationInputChange}
8202
+ ></kr-checkbox>
8203
+ </div>
8204
+ </div>
8205
+ `;
8206
+ }
8207
+ /** The Debug view's body — telemetry scopes and the per-iteration table. */
8208
+ _renderDebugContent() {
8209
+ return b `
8210
+ ${this._telemetry ? b `
8211
+ <div class="debug-section">
8212
+ <div class="debug-section__label">Context</div>
8213
+ <div class="debug-gauge">
8214
+ <div class="debug-gauge__bar">
8215
+ <div
8216
+ class="debug-gauge__fill"
8217
+ style=${o({ width: `${Math.min(100, Math.max(0, this._telemetry.contextUsedPercent || 0))}%` })}
8218
+ ></div>
7607
8219
  </div>
8220
+ <span class="debug-gauge__text">
8221
+ ${this._fmtTokens(this._telemetry.currentContextTokens)} / ${this._fmtTokens(this._telemetry.contextWindowTokens)}
8222
+ · ${this._telemetry.contextUsedPercent ?? 0}%
8223
+ </span>
8224
+ </div>
8225
+ </div>
7608
8226
 
7609
- <div class="debug-section">
7610
- <div class="debug-section__label">This turn</div>
7611
- <div class="debug-metrics">
7612
- <span class="debug-metrics__key">Input</span>
7613
- <span class="debug-metrics__val">${this._fmtTokens(this._telemetry.turnInputTokens)}</span>
7614
- <span class="debug-metrics__key">Output</span>
7615
- <span class="debug-metrics__val">${this._fmtTokens(this._telemetry.turnOutputTokens)}</span>
7616
- <span class="debug-metrics__key">Cache read</span>
7617
- <span class="debug-metrics__val">${this._fmtTokens(this._telemetry.turnCacheReadTokens)}</span>
7618
- <span class="debug-metrics__key">Hit rate</span>
7619
- <span class="debug-metrics__val">${this._telemetry.turnHitRate ?? '—'}%</span>
7620
- <span class="debug-metrics__key">Cost</span>
7621
- <span class="debug-metrics__val debug-cost">${this._fmtCost(this._telemetry.turnCostUsd)}</span>
7622
- </div>
7623
- </div>
8227
+ <div class="debug-section">
8228
+ <div class="debug-section__label">This turn</div>
8229
+ <div class="debug-metrics">
8230
+ <span class="debug-metrics__key">Input</span>
8231
+ <span class="debug-metrics__val">${this._fmtTokens(this._telemetry.turnInputTokens)}</span>
8232
+ <span class="debug-metrics__key">Output</span>
8233
+ <span class="debug-metrics__val">${this._fmtTokens(this._telemetry.turnOutputTokens)}</span>
8234
+ <span class="debug-metrics__key">Cache read</span>
8235
+ <span class="debug-metrics__val">${this._fmtTokens(this._telemetry.turnCacheReadTokens)}</span>
8236
+ <span class="debug-metrics__key">Hit rate</span>
8237
+ <span class="debug-metrics__val">${this._telemetry.turnHitRate ?? '—'}%</span>
8238
+ <span class="debug-metrics__key">Cost</span>
8239
+ <span class="debug-metrics__val debug-cost">${this._fmtCost(this._telemetry.turnCostUsd)}</span>
8240
+ </div>
8241
+ </div>
7624
8242
 
7625
- <div class="debug-section">
7626
- <div class="debug-section__label">Lifetime</div>
7627
- <div class="debug-metrics">
7628
- <span class="debug-metrics__key">Input</span>
7629
- <span class="debug-metrics__val">${this._fmtTokens(this._telemetry.conversationInputTokens)}</span>
7630
- <span class="debug-metrics__key">Output</span>
7631
- <span class="debug-metrics__val">${this._fmtTokens(this._telemetry.conversationOutputTokens)}</span>
7632
- <span class="debug-metrics__key">Cache read</span>
7633
- <span class="debug-metrics__val">${this._fmtTokens(this._telemetry.conversationCacheReadTokens)}</span>
7634
- <span class="debug-metrics__key">Cache write</span>
7635
- <span class="debug-metrics__val">${this._fmtTokens(this._telemetry.conversationCacheWriteTokens)}</span>
7636
- <span class="debug-metrics__key">Cost so far</span>
7637
- <span class="debug-metrics__val debug-cost">${this._fmtCost(this._telemetry.conversationCostUsd)}</span>
7638
- </div>
7639
- </div>
8243
+ <div class="debug-section">
8244
+ <div class="debug-section__label">Lifetime</div>
8245
+ <div class="debug-metrics">
8246
+ <span class="debug-metrics__key">Input</span>
8247
+ <span class="debug-metrics__val">${this._fmtTokens(this._telemetry.conversationInputTokens)}</span>
8248
+ <span class="debug-metrics__key">Output</span>
8249
+ <span class="debug-metrics__val">${this._fmtTokens(this._telemetry.conversationOutputTokens)}</span>
8250
+ <span class="debug-metrics__key">Cache read</span>
8251
+ <span class="debug-metrics__val">${this._fmtTokens(this._telemetry.conversationCacheReadTokens)}</span>
8252
+ <span class="debug-metrics__key">Cache write</span>
8253
+ <span class="debug-metrics__val">${this._fmtTokens(this._telemetry.conversationCacheWriteTokens)}</span>
8254
+ <span class="debug-metrics__key">Cost so far</span>
8255
+ <span class="debug-metrics__val debug-cost">${this._fmtCost(this._telemetry.conversationCostUsd)}</span>
8256
+ </div>
8257
+ </div>
7640
8258
  ` : b `
7641
8259
  <div class="debug-empty">No telemetry yet — send a message to populate.</div>
7642
8260
  `}
7643
8261
 
7644
- ${this._iterationLog.length ? b `
7645
- <div class="debug-section">
7646
- <div class="debug-section__label">Iterations (this turn)</div>
7647
- <div class="debug-iterations-scroll">
7648
- <table class="debug-iterations">
7649
- <thead>
7650
- <tr>
7651
- <th>#</th>
7652
- <th>In</th>
7653
- <th>Out</th>
7654
- <th>Cache R</th>
7655
- <th>Cache W</th>
7656
- <th>Hit</th>
7657
- <th>Cost</th>
7658
- <th>Ckpt</th>
7659
- </tr>
7660
- </thead>
7661
- <tbody>
7662
- ${this._iterationLog.map((it) => {
8262
+ ${this._iterationLog.length ? b `
8263
+ <div class="debug-section">
8264
+ <div class="debug-section__label">Iterations (this turn)</div>
8265
+ <div class="debug-iterations-scroll">
8266
+ <table class="debug-iterations">
8267
+ <thead>
8268
+ <tr>
8269
+ <th>#</th>
8270
+ <th>In</th>
8271
+ <th>Out</th>
8272
+ <th>Cache R</th>
8273
+ <th>Cache W</th>
8274
+ <th>Hit</th>
8275
+ <th>Cost</th>
8276
+ <th>Ckpt</th>
8277
+ </tr>
8278
+ </thead>
8279
+ <tbody>
8280
+ ${this._iterationLog.map((it) => {
7663
8281
  const ckpt = this._cacheCheckpoints[it.iteration];
7664
8282
  return b `
7665
- <tr>
7666
- <td>${it.iteration}</td>
7667
- <td>${this._fmtTokens(it.iterationInput)}</td>
7668
- <td>${this._fmtTokens(it.iterationOutput)}</td>
7669
- <td>${this._fmtTokens(it.iterationCacheRead)}</td>
7670
- <td>${this._fmtTokens(it.iterationCacheWrite)}</td>
7671
- <td>${it.iterationHitRate ?? '—'}%</td>
7672
- <td class="debug-cost">${this._fmtCost(it.iterationCostUsd)}</td>
7673
- <td>${ckpt ? ckpt.mode : '—'}</td>
7674
- </tr>
8283
+ <tr>
8284
+ <td>${it.iteration}</td>
8285
+ <td>${this._fmtTokens(it.iterationInput)}</td>
8286
+ <td>${this._fmtTokens(it.iterationOutput)}</td>
8287
+ <td>${this._fmtTokens(it.iterationCacheRead)}</td>
8288
+ <td>${this._fmtTokens(it.iterationCacheWrite)}</td>
8289
+ <td>${it.iterationHitRate ?? '—'}%</td>
8290
+ <td class="debug-cost">${this._fmtCost(it.iterationCostUsd)}</td>
8291
+ <td>${ckpt ? ckpt.mode : '—'}</td>
8292
+ </tr>
7675
8293
  `;
7676
8294
  })}
7677
- </tbody>
7678
- </table>
7679
- </div>
7680
- </div>
7681
- ` : A}
8295
+ </tbody>
8296
+ </table>
8297
+ </div>
7682
8298
  </div>
7683
- </div>
8299
+ ` : A}
7684
8300
  `;
7685
8301
  }
7686
8302
  render() {
7687
8303
  // Debug affordances (drawer toggle, drawer, /test-* options) require the
7688
8304
  // inline variant AND the server-side debug flag from aiFrameworkConfig.
7689
8305
  const isDebugEnabled = this.variant === 'inline' && this._aiFrameworkConfig.debugTelemetryEnabled === true;
8306
+ // Push the content over by the open drawer's width so the drawer sits beside
8307
+ // the content rather than overlaying it. One consistent (resizable) width for
8308
+ // both views. Only pushes while the drawer is open.
8309
+ const contentPadding = {};
8310
+ if (this._drawerOpen) {
8311
+ contentPadding.paddingRight = `${this._debugDrawerWidth}px`;
8312
+ }
8313
+ // Group the transcript into runs so an agent turn (its reasoning/tool/text/
8314
+ // decision messages, which stream in as separate entries) renders inside ONE
8315
+ // wrapper. The scroll-to-top runway goes on that wrapper, so everything the
8316
+ // turn appends fills the SAME tall element instead of stacking as siblings
8317
+ // below it (which is what stranded tool lines below the fold). A user message
8318
+ // is its own group and is unaffected.
8319
+ const messageGroups = [];
8320
+ this.messages.forEach((msg) => {
8321
+ const lastGroup = messageGroups[messageGroups.length - 1];
8322
+ if (msg.type === 'user' || !lastGroup || lastGroup[0].type === 'user') {
8323
+ messageGroups.push([msg]);
8324
+ }
8325
+ else {
8326
+ lastGroup.push(msg);
8327
+ }
8328
+ });
7690
8329
  return b `
7691
8330
  ${(this.hideToggle || this.variant === 'inline') ? A : b `
7692
8331
  <button
@@ -7717,7 +8356,7 @@ let KRChatbot = class KRChatbot extends i$3 {
7717
8356
  'panel--opened': this.opened,
7718
8357
  'panel--resizing-debug': this._isDebugResizing,
7719
8358
  })}
7720
- style=${o(isDebugEnabled && this._debugPanelOpen ? { paddingRight: `${this._debugDrawerWidth}px` } : {})}
8359
+ style=${o(contentPadding)}
7721
8360
  @dragenter=${this._handleDragEnter}
7722
8361
  @dragover=${this._handleDragOver}
7723
8362
  @dragleave=${this._handleDragLeave}
@@ -7790,13 +8429,35 @@ let KRChatbot = class KRChatbot extends i$3 {
7790
8429
  </div>
7791
8430
  </div>
7792
8431
  <div class="header-actions">
8432
+ <button
8433
+ class="header-action"
8434
+ @click=${() => this._toggleDrawer('settings')}
8435
+ title="Settings"
8436
+ >
8437
+ <svg
8438
+ xmlns="http://www.w3.org/2000/svg"
8439
+ fill="none"
8440
+ viewBox="0 0 24 24"
8441
+ stroke-width="1.5"
8442
+ stroke="currentColor"
8443
+ class="header-action-icon"
8444
+ >
8445
+ <path
8446
+ stroke-linecap="round"
8447
+ stroke-linejoin="round"
8448
+ d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
8449
+ />
8450
+ <path
8451
+ stroke-linecap="round"
8452
+ stroke-linejoin="round"
8453
+ d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
8454
+ />
8455
+ </svg>
8456
+ </button>
7793
8457
  ${isDebugEnabled ? b `
7794
8458
  <button
7795
- class=${e({
7796
- 'header-action': true,
7797
- 'header-action--active': this._debugPanelOpen,
7798
- })}
7799
- @click=${this._handleToggleDebugPanel}
8459
+ class="header-action"
8460
+ @click=${() => this._toggleDrawer('debug')}
7800
8461
  title="Debug telemetry"
7801
8462
  >
7802
8463
  <svg
@@ -7847,139 +8508,302 @@ let KRChatbot = class KRChatbot extends i$3 {
7847
8508
  <span class="empty-text">What can I help you with?</span>
7848
8509
  </div>
7849
8510
  ` : A}
7850
- ${this.messages.map((msg, index) => b `
7851
- <div class=${e({
7852
- 'message': true,
7853
- 'message--user': msg.type === 'user',
7854
- 'message--assistant': msg.type === 'assistant',
7855
- })}>
7856
- ${msg.reasoning ? b `
7857
- <details class="reasoning">
7858
- <summary>Thinking</summary>
7859
- <div class="reasoning-content">${msg.reasoning}</div>
7860
- </details>
7861
- ` : A}
7862
- ${msg.conversationFiles && msg.conversationFiles.length ? b `
7863
- <div class="attachments">
7864
- ${msg.conversationFiles.map((file) => {
7865
- const fileName = file.niceName || file.name || 'File';
7866
- // A stored file is an image by its MIME when it has one, else
7867
- // by its extension.
7868
- let isImage = IMAGE_EXTENSIONS.includes(this._getFileExtension(fileName));
7869
- if (file.mimeType) {
7870
- isImage = file.mimeType.startsWith('image/');
7871
- }
7872
- // An image shows its thumbnail; everything else shows a
7873
- // file-type tile. Both open the previewer on click — it renders
7874
- // images, video, PDFs, and text inline, and shows a download
7875
- // tile for the rest (Office formats), so a click always does
7876
- // something useful.
7877
- if (isImage) {
7878
- return b `
7879
- <button
7880
- class="attachment"
7881
- type="button"
7882
- title=${file.niceName || 'View image'}
7883
- @click=${() => this._handleFileOpen(file)}
7884
- >
7885
- <img
7886
- src=${file.previewUrl || `${KRHttp.getBaseUrl()}/api/system/conversationFile/${file.conversationFileID}/view`}
7887
- alt=${file.niceName || ''}
7888
- />
7889
- </button>
7890
- `;
7891
- }
7892
- return b `
7893
- <button
7894
- class="attachment attachment--doc attachment--openable"
7895
- type="button"
7896
- title=${fileName}
7897
- @click=${() => this._handleFileOpen(file)}
7898
- >
7899
- <span class="attachment__type">${this._getFileExtension(fileName) || 'File'}</span>
7900
- <span class="attachment__name">${fileName}</span>
7901
- </button>
7902
- `;
7903
- })}
7904
- </div>
7905
- ` : A}
7906
- ${msg.content ? b `
7907
- <div class="message-content">
7908
- ${msg.type === 'user'
7909
- ? this._renderUserContent(msg.content)
7910
- : o$1(marked.parse(msg.content, { breaks: true }))}
7911
- </div>
7912
- ` : A}
7913
- ${msg.type === 'assistant' && this._isStreaming && msg === this.messages[this.messages.length - 1] ? b `
7914
- <div class="typing">
7915
- <span></span>
7916
- <span></span>
7917
- <span></span>
7918
- </div>
7919
- ` : A}
7920
- ${msg.content ? b `
7921
- <button
7922
- class="message-copy"
7923
- title=${this._copiedIndex === index ? 'Copied' : 'Copy'}
7924
- @click=${() => this._handleCopyMessage(msg.content, index)}
7925
- >
7926
- ${this._copiedIndex === index ? b `
7927
- <svg
7928
- viewBox="0 0 24 24"
7929
- fill="none"
7930
- stroke="currentColor"
7931
- stroke-width="1.5"
7932
- stroke-linecap="round"
7933
- stroke-linejoin="round"
7934
- >
7935
- <path d="M20 6 9 17l-5-5" />
7936
- </svg>
8511
+ ${messageGroups.map((group) => {
8512
+ // A user group is a single message rendered bare. An agent group wraps
8513
+ // its items in one `.agent-turn` so the scroll-to-top runway sits on
8514
+ // that wrapper and the whole turn fills it (no stranded siblings).
8515
+ const items = group.map((msg) => {
8516
+ const index = this.messages.indexOf(msg);
8517
+ // Skip an empty assistant placeholder (no content, no reasoning) —
8518
+ // _send and _ensureAssistantBubble append these to stream into,
8519
+ // but between tool calls with no text they stay empty. An invisible
8520
+ // .message div still occupies a flex-gap slot, doubling the spacing
8521
+ // between tool lines. Keep it only while it's the actively-streaming
8522
+ // last message (it shows the typing dots).
8523
+ if (msg.type === 'assistant'
8524
+ && !msg.content
8525
+ && !msg.reasoning
8526
+ && !(this._isStreaming && msg === this.messages[this.messages.length - 1])) {
8527
+ return A;
8528
+ }
8529
+ // Tool visibility is user-controlled ("Show tools" in the settings
8530
+ // panel). When off, drop the whole tool row so it doesn't leave an
8531
+ // empty flex-gap slot in the transcript.
8532
+ if ((msg.type === 'tool_use' || msg.type === 'tool_result') && !this._showTools) {
8533
+ return A;
8534
+ }
8535
+ // A tool_result renders nothing on its own — its tool_use line pulls
8536
+ // it in (matched by data.toolUseId) and renders it under the ⎿ cliff.
8537
+ if (msg.type === 'tool_result') {
8538
+ return A;
8539
+ }
8540
+ // A tool_use line (Claude-CLI style): header "› ToolName(input)" and,
8541
+ // when "Show tool inputs" is on, the result under a ⎿ cliff pulled
8542
+ // from the next tool_result with the same toolUseId. Precompute the
8543
+ // result and the two display strings (they need statements — JSON
8544
+ // reformat, truncation — that can't live in a template expression).
8545
+ let result;
8546
+ let inputSummary = '';
8547
+ let resultPreview = '';
8548
+ let resultRemainder = 0;
8549
+ if (msg.type === 'tool_use') {
8550
+ const next = this.messages[index + 1];
8551
+ if (next && next.type === 'tool_result' && next.data?.toolUseId === msg.data?.toolUseId) {
8552
+ result = next.data;
8553
+ }
8554
+ if (this._showToolInputs && msg.data?.toolInput) {
8555
+ inputSummary = String(msg.data.toolInput).replace(/\s+/g, ' ').trim();
8556
+ if (inputSummary.length > 80) {
8557
+ inputSummary = `${inputSummary.slice(0, 79)}…`;
8558
+ }
8559
+ }
8560
+ if (this._showToolInputs && result?.toolResultString) {
8561
+ let formatted;
8562
+ try {
8563
+ formatted = JSON.stringify(JSON.parse(result.toolResultString), null, 2);
8564
+ }
8565
+ catch {
8566
+ formatted = String(result.toolResultString);
8567
+ }
8568
+ const lines = formatted.split('\n');
8569
+ resultPreview = lines.slice(0, 3).join('\n');
8570
+ resultRemainder = Math.max(0, lines.length - 3);
8571
+ }
8572
+ }
8573
+ // A resolved-decision line (Rail 3) — reads "Approved Create AI Tool"
8574
+ // / "Denied …" / "Always allowed …". Precompute the verb and the
8575
+ // friendly tool names (an if/else and a map — not template exprs).
8576
+ let decisionVerb = 'Approved';
8577
+ let decisionTools = [];
8578
+ if (msg.type === 'action_decision') {
8579
+ if (msg.data?.outcome === 'denied') {
8580
+ decisionVerb = 'Denied';
8581
+ }
8582
+ else if (msg.data?.outcome === 'alwaysAllowed') {
8583
+ decisionVerb = 'Always allowed';
8584
+ }
8585
+ decisionTools = (msg.data?.tools || []).map((t) => t.displayName || t.toolName);
8586
+ }
8587
+ // A tool line and a decision line ARE assistant-side messages —
8588
+ // render them inside the same .message wrapper as everything else so
8589
+ // they share its spacing/structure, not as separate element types.
8590
+ return b `
8591
+ <div class=${e({
8592
+ 'message': true,
8593
+ 'message--user': msg.type === 'user',
8594
+ 'message--assistant': msg.type !== 'user',
8595
+ })}>
8596
+ ${msg.type === 'tool_use' ? b `
8597
+ <div class="tool-activity-block">
8598
+ <div class=${e({
8599
+ 'tool-activity': true,
8600
+ 'tool-activity--error': result?.toolResultStatus === 'error',
8601
+ })}>
8602
+ <span class="tool-activity__chevron">›</span>
8603
+ <span class="tool-activity__label">${msg.data?.displayName || msg.data?.toolName || 'Tool'}</span>
8604
+ ${inputSummary ? b `<span class="tool-activity__input">(${inputSummary})</span>` : A}
8605
+ </div>
8606
+ ${this._showToolInputs && result?.toolResultString ? b `
8607
+ <div class=${e({
8608
+ 'tool-activity__result': true,
8609
+ 'tool-activity__result--error': result.toolResultStatus === 'error',
8610
+ })}>
8611
+ <span class="tool-activity__connector">⎿</span>
8612
+ <pre class="tool-activity__result-body">${resultPreview}${resultRemainder > 0
8613
+ ? b `\n<span class="tool-activity__more">… +${resultRemainder} lines</span>`
8614
+ : A}</pre>
8615
+ </div>
8616
+ ` : A}
8617
+ </div>
8618
+ ` : A}
8619
+ ${msg.type === 'action_decision' ? b `
8620
+ <div class=${e({
8621
+ 'decision': true,
8622
+ 'decision--denied': msg.data?.outcome === 'denied',
8623
+ })}>
8624
+ <span class="decision__icon">
8625
+ <svg
8626
+ viewBox="0 0 24 24"
8627
+ fill="none"
8628
+ stroke="currentColor"
8629
+ stroke-width="2"
8630
+ stroke-linecap="round"
8631
+ stroke-linejoin="round"
8632
+ >
8633
+ ${msg.data?.outcome === 'denied'
8634
+ ? b `<path d="M18 6 6 18M6 6l12 12" />`
8635
+ : b `<path d="M20 6 9 17l-5-5" />`}
8636
+ </svg>
8637
+ </span>
8638
+ <span class="decision__label">${decisionVerb}${decisionTools.length ? ' ' + decisionTools.join(', ') : A}</span>
8639
+ </div>
8640
+ ` : A}
8641
+ ${msg.reasoning ? (this._reasoningAsMessage ? b `
8642
+ <div class="message-content">${msg.reasoning}</div>
7937
8643
  ` : b `
7938
- <svg
7939
- viewBox="0 0 24 24"
7940
- fill="none"
7941
- stroke="currentColor"
7942
- stroke-width="1.5"
7943
- stroke-linecap="round"
7944
- stroke-linejoin="round"
8644
+ <details class="reasoning">
8645
+ <summary>Thinking</summary>
8646
+ <div class="reasoning-content">${msg.reasoning}</div>
8647
+ </details>
8648
+ `) : A}
8649
+ ${msg.conversationFiles && msg.conversationFiles.length ? b `
8650
+ <div class="attachments">
8651
+ ${msg.conversationFiles.map((file) => {
8652
+ const fileName = file.niceName || file.name || 'File';
8653
+ // A stored file is an image by its MIME when it has one, else
8654
+ // by its extension.
8655
+ let isImage = IMAGE_EXTENSIONS.includes(this._getFileExtension(fileName));
8656
+ if (file.mimeType) {
8657
+ isImage = file.mimeType.startsWith('image/');
8658
+ }
8659
+ // An image shows its thumbnail; everything else shows a
8660
+ // file-type tile. Both open the previewer on click — it renders
8661
+ // images, video, PDFs, and text inline, and shows a download
8662
+ // tile for the rest (Office formats), so a click always does
8663
+ // something useful.
8664
+ if (isImage) {
8665
+ return b `
8666
+ <button
8667
+ class="attachment"
8668
+ type="button"
8669
+ title=${file.niceName || 'View image'}
8670
+ @click=${() => this._handleFileOpen(file)}
8671
+ >
8672
+ <img
8673
+ src=${file.previewUrl || `${KRHttp.getBaseUrl()}/api/system/conversationFile/${file.conversationFileID}/view`}
8674
+ alt=${file.niceName || ''}
8675
+ />
8676
+ </button>
8677
+ `;
8678
+ }
8679
+ return b `
8680
+ <button
8681
+ class="attachment attachment--doc attachment--openable"
8682
+ type="button"
8683
+ title=${fileName}
8684
+ @click=${() => this._handleFileOpen(file)}
8685
+ >
8686
+ <span class="attachment__type">${this._getFileExtension(fileName) || 'File'}</span>
8687
+ <span class="attachment__name">${fileName}</span>
8688
+ </button>
8689
+ `;
8690
+ })}
8691
+ </div>
8692
+ ` : A}
8693
+ ${msg.content ? b `
8694
+ <div class="message-content">
8695
+ ${msg.type === 'user'
8696
+ ? this._renderUserContent(msg.content)
8697
+ : o$1(marked.parse(msg.content, { breaks: true }))}
8698
+ </div>
8699
+ ` : A}
8700
+ ${msg.type === 'assistant' && this._isStreaming && msg === this.messages[this.messages.length - 1] ? b `
8701
+ <div class="typing">
8702
+ <span></span>
8703
+ <span></span>
8704
+ <span></span>
8705
+ </div>
8706
+ ` : A}
8707
+ ${msg.content ? b `
8708
+ <button
8709
+ class="message-copy"
8710
+ title=${this._copiedIndex === index ? 'Copied' : 'Copy'}
8711
+ @click=${() => this._handleCopyMessage(msg.content, index)}
7945
8712
  >
7946
- <rect
7947
- x="9"
7948
- y="9"
7949
- width="13"
7950
- height="13"
7951
- rx="2"
7952
- />
7953
- <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
7954
- </svg>
7955
- `}
7956
- </button>
7957
- ` : A}
8713
+ ${this._copiedIndex === index ? b `
8714
+ <svg
8715
+ viewBox="0 0 24 24"
8716
+ fill="none"
8717
+ stroke="currentColor"
8718
+ stroke-width="1.5"
8719
+ stroke-linecap="round"
8720
+ stroke-linejoin="round"
8721
+ >
8722
+ <path d="M20 6 9 17l-5-5" />
8723
+ </svg>
8724
+ ` : b `
8725
+ <svg
8726
+ viewBox="0 0 24 24"
8727
+ fill="none"
8728
+ stroke="currentColor"
8729
+ stroke-width="1.5"
8730
+ stroke-linecap="round"
8731
+ stroke-linejoin="round"
8732
+ >
8733
+ <rect
8734
+ x="9"
8735
+ y="9"
8736
+ width="13"
8737
+ height="13"
8738
+ rx="2"
8739
+ />
8740
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
8741
+ </svg>
8742
+ `}
8743
+ </button>
8744
+ ` : A}
8745
+ </div>
8746
+ `;
8747
+ });
8748
+ if (group[0].type === 'user') {
8749
+ return items;
8750
+ }
8751
+ // The inline confirmation is this turn's final step, so it renders
8752
+ // INSIDE the agent-turn wrapper (not as a sibling) — it sits right
8753
+ // after the last message and the runway falls below it naturally.
8754
+ // Only on the last group, which is the current (paused) turn.
8755
+ const isLastGroup = group === messageGroups[messageGroups.length - 1];
8756
+ const showConfirmation = isLastGroup && this._pendingActions.length && this._inlineConfirm;
8757
+ // Keep the "thinking" indicator visible while a tool is running or
8758
+ // the model is between steps with no text yet. The trailing empty
8759
+ // assistant bubble shows the dots itself; but once a tool line is
8760
+ // the last message, that bubble is gone — so render the dots here
8761
+ // at the end of the live turn instead. Not while paused for a
8762
+ // confirmation (the ask replaces the indicator).
8763
+ const lastMsg = this.messages[this.messages.length - 1];
8764
+ const showTyping = isLastGroup
8765
+ && this._isStreaming
8766
+ && !this._pendingActions.length
8767
+ && lastMsg?.type !== 'assistant';
8768
+ return b `
8769
+ <div class="agent-turn">
8770
+ ${items}
8771
+ ${showTyping ? b `
8772
+ <div class="typing">
8773
+ <span></span>
8774
+ <span></span>
8775
+ <span></span>
8776
+ </div>
8777
+ ` : A}
8778
+ ${showConfirmation ? this._renderConfirmation() : A}
8779
+ </div>
8780
+ `;
8781
+ })}
8782
+ </div>
7958
8783
  </div>
7959
- `)}
7960
- </div>
7961
- </div>
7962
8784
 
7963
- ${this._error ? b `
7964
- <div class="error">
7965
- <span>${this._error}</span>
7966
- <button class="error-dismiss" @click=${this._handleErrorDismiss}>
7967
- <svg
7968
- xmlns="http://www.w3.org/2000/svg"
7969
- fill="none"
7970
- viewBox="0 0 24 24"
7971
- stroke-width="2"
7972
- stroke="currentColor"
7973
- class="error-dismiss-icon"
7974
- >
7975
- <path
7976
- stroke-linecap="round"
7977
- stroke-linejoin="round"
7978
- d="M6 18 18 6M6 6l12 12"
7979
- />
7980
- </svg>
7981
- </button>
7982
- </div>
8785
+ ${this._pendingActions.length && !this._inlineConfirm ? this._renderConfirmation() : A}
8786
+
8787
+ ${this._error ? b `
8788
+ <div class="error">
8789
+ <span>${this._error}</span>
8790
+ <button class="error-dismiss" @click=${this._handleErrorDismiss}>
8791
+ <svg
8792
+ xmlns="http://www.w3.org/2000/svg"
8793
+ fill="none"
8794
+ viewBox="0 0 24 24"
8795
+ stroke-width="2"
8796
+ stroke="currentColor"
8797
+ class="error-dismiss-icon"
8798
+ >
8799
+ <path
8800
+ stroke-linecap="round"
8801
+ stroke-linejoin="round"
8802
+ d="M6 18 18 6M6 6l12 12"
8803
+ />
8804
+ </svg>
8805
+ </button>
8806
+ </div>
7983
8807
  ` : this._contextLimitReached ? b `
7984
8808
  <div class="context-limit" role="alert">
7985
8809
  ${this._contextLimitMessage
@@ -8011,68 +8835,68 @@ let KRChatbot = class KRChatbot extends i$3 {
8011
8835
  </div>
8012
8836
  ` : A}
8013
8837
 
8014
- <div class="footer">
8015
- ${this._pendingConversationFiles.length ? b `
8016
- <div class="composer-attachments">
8017
- ${c(this._pendingConversationFiles, (p) => p.localId, (p) => b `
8018
- <div
8019
- class=${e({
8838
+ <div class="footer">
8839
+ ${this._pendingConversationFiles.length ? b `
8840
+ <div class="composer-attachments">
8841
+ ${c(this._pendingConversationFiles, (p) => p.localId, (p) => b `
8842
+ <div
8843
+ class=${e({
8020
8844
  chip: true,
8021
8845
  'chip--doc': !p.previewUrl,
8022
8846
  })}
8023
- title=${p.name}
8024
- >
8025
- <div
8026
- class=${e({
8847
+ title=${p.name}
8848
+ >
8849
+ <div
8850
+ class=${e({
8027
8851
  'chip-body': true,
8028
8852
  'chip-body--image': Boolean(p.previewUrl),
8029
8853
  })}
8030
- >
8031
- ${p.previewUrl
8854
+ >
8855
+ ${p.previewUrl
8032
8856
  ? b `<img src=${p.previewUrl} alt=${p.name} />`
8033
8857
  : b `
8034
- <span class="chip-type">${this._getFileExtension(p.name) || 'File'}</span>
8035
- <span class="chip-name">${p.name}</span>
8858
+ <span class="chip-type">${this._getFileExtension(p.name) || 'File'}</span>
8859
+ <span class="chip-name">${p.name}</span>
8036
8860
  `}
8037
- </div>
8038
- <button
8039
- class="chip-remove"
8040
- title="Remove"
8041
- @click=${() => this._handlePendingRemove(p.localId)}
8042
- >
8043
- <svg
8044
- xmlns="http://www.w3.org/2000/svg"
8045
- fill="none"
8046
- viewBox="0 0 24 24"
8047
- stroke-width="1.75"
8048
- stroke="currentColor"
8049
- >
8050
- <path
8051
- stroke-linecap="round"
8052
- stroke-linejoin="round"
8053
- d="M6 18 18 6M6 6l12 12"
8054
- />
8055
- </svg>
8056
- </button>
8057
- </div>
8861
+ </div>
8862
+ <button
8863
+ class="chip-remove"
8864
+ title="Remove"
8865
+ @click=${() => this._handlePendingRemove(p.localId)}
8866
+ >
8867
+ <svg
8868
+ xmlns="http://www.w3.org/2000/svg"
8869
+ fill="none"
8870
+ viewBox="0 0 24 24"
8871
+ stroke-width="1.75"
8872
+ stroke="currentColor"
8873
+ >
8874
+ <path
8875
+ stroke-linecap="round"
8876
+ stroke-linejoin="round"
8877
+ d="M6 18 18 6M6 6l12 12"
8878
+ />
8879
+ </svg>
8880
+ </button>
8881
+ </div>
8058
8882
  `)}
8059
- </div>
8883
+ </div>
8060
8884
  ` : A}
8061
- <input
8062
- class="file-input"
8063
- type="file"
8064
- accept=${ACCEPTED_FILE_EXTENSIONS.map((ext) => '.' + ext).join(',')}
8065
- multiple
8066
- hidden
8067
- @change=${this._handleFileInputChange}
8068
- />
8069
- ${this._skillMenuOpened ? this._renderSkillMenu() : A}
8070
- <div class="input-wrapper">
8071
- <button
8885
+ <input
8886
+ class="file-input"
8887
+ type="file"
8888
+ accept=${ACCEPTED_FILE_EXTENSIONS.map((ext) => '.' + ext).join(',')}
8889
+ multiple
8890
+ hidden
8891
+ @change=${this._handleFileInputChange}
8892
+ />
8893
+ ${this._skillMenuOpened ? this._renderSkillMenu() : A}
8894
+ <div class="input-wrapper">
8895
+ <button
8072
8896
  class="input-plus"
8073
8897
  @click=${this._handlePlusClick}
8074
- >+</button>
8075
- <textarea
8898
+ >+</button>
8899
+ <textarea
8076
8900
  class="input"
8077
8901
  .value=${this._inputValue}
8078
8902
  placeholder=${this.placeholder}
@@ -8080,31 +8904,32 @@ let KRChatbot = class KRChatbot extends i$3 {
8080
8904
  @input=${this._handleInputChange}
8081
8905
  @keydown=${this._handleKeyDown}
8082
8906
  @paste=${this._handlePaste}
8083
- ></textarea>
8084
- ${window.SpeechRecognition || window.webkitSpeechRecognition ? b `
8085
- ${this._isDictating ? b `
8086
- <button
8087
- class="mic-stop"
8088
- title="Stop dictation"
8089
- aria-label="Stop dictation"
8090
- @click=${this._handleMicClick}
8091
- >
8092
- <svg
8093
- xmlns="http://www.w3.org/2000/svg"
8094
- fill="currentColor"
8095
- viewBox="0 0 24 24"
8096
- class="mic-stop-icon"
8097
- aria-hidden="true"
8907
+ ></textarea>
8908
+ ${this._renderModeSelector()}
8909
+ ${window.SpeechRecognition || window.webkitSpeechRecognition ? b `
8910
+ ${this._isDictating ? b `
8911
+ <button
8912
+ class="mic-stop"
8913
+ title="Stop dictation"
8914
+ aria-label="Stop dictation"
8915
+ @click=${this._handleMicClick}
8098
8916
  >
8099
- <rect
8100
- x="6"
8101
- y="6"
8102
- width="12"
8103
- height="12"
8104
- rx="2"
8105
- />
8106
- </svg>
8107
- </button>
8917
+ <svg
8918
+ xmlns="http://www.w3.org/2000/svg"
8919
+ fill="currentColor"
8920
+ viewBox="0 0 24 24"
8921
+ class="mic-stop-icon"
8922
+ aria-hidden="true"
8923
+ >
8924
+ <rect
8925
+ x="6"
8926
+ y="6"
8927
+ width="12"
8928
+ height="12"
8929
+ rx="2"
8930
+ />
8931
+ </svg>
8932
+ </button>
8108
8933
  ` : b `
8109
8934
  <button
8110
8935
  class="mic"
@@ -8130,35 +8955,35 @@ let KRChatbot = class KRChatbot extends i$3 {
8130
8955
  </button>
8131
8956
  `}
8132
8957
  ` : A}
8133
- ${this._isStreaming ? b `
8134
- <button
8135
- class="stop"
8136
- @click=${this._handleStop}
8137
- title="Stop generating"
8138
- >
8139
- <svg
8140
- xmlns="http://www.w3.org/2000/svg"
8141
- fill="currentColor"
8142
- viewBox="0 0 24 24"
8143
- class="stop-icon"
8958
+ ${this._isStreaming ? b `
8959
+ <button
8960
+ class="stop"
8961
+ @click=${this._handleStop}
8962
+ title="Stop generating"
8144
8963
  >
8145
- <rect
8146
- x="6"
8147
- y="6"
8148
- width="12"
8149
- height="12"
8150
- rx="2"
8151
- />
8152
- </svg>
8153
- </button>
8964
+ <svg
8965
+ xmlns="http://www.w3.org/2000/svg"
8966
+ fill="currentColor"
8967
+ viewBox="0 0 24 24"
8968
+ class="stop-icon"
8969
+ >
8970
+ <rect
8971
+ x="6"
8972
+ y="6"
8973
+ width="12"
8974
+ height="12"
8975
+ rx="2"
8976
+ />
8977
+ </svg>
8978
+ </button>
8154
8979
  ` : b `
8155
8980
  <button
8156
8981
  class=${e({
8157
8982
  'send': true,
8158
- 'send--disabled': !this._inputValue.trim() && !this._pendingConversationFiles.length,
8983
+ 'send--disabled': (!this._inputValue.trim() && !this._pendingConversationFiles.length) || this._pendingActions.length > 0,
8159
8984
  })}
8160
8985
  @click=${this._handleSubmit}
8161
- title="Send message"
8986
+ title=${this._pendingActions.length ? 'Approve or deny the pending action first' : 'Send message'}
8162
8987
  >
8163
8988
  <svg
8164
8989
  width="16"
@@ -8174,12 +8999,12 @@ let KRChatbot = class KRChatbot extends i$3 {
8174
8999
  </svg>
8175
9000
  </button>
8176
9001
  `}
8177
- </div>
8178
- </div>
8179
- ${this._skillBrowseOpened ? this._renderSkillBrowse() : A}
8180
- ${isDebugEnabled ? this._renderDebugPanel() : A}
8181
- </div>
8182
- `;
9002
+ </div>
9003
+ </div>
9004
+ ${this._skillBrowseOpened ? this._renderSkillBrowse() : A}
9005
+ ${this._renderDrawer()}
9006
+ </div>
9007
+ `;
8183
9008
  }
8184
9009
  };
8185
9010
  KRChatbot.styles = i$6 `
@@ -8292,6 +9117,14 @@ KRChatbot.styles = i$6 `
8292
9117
  box-shadow: none;
8293
9118
  border: none;
8294
9119
  animation: none;
9120
+ /* Reserve space for the drawer by animating padding-right in sync with the
9121
+ drawer's slide, so the chat column shrinks rather than being overlaid. */
9122
+ transition: padding-right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
9123
+ }
9124
+
9125
+ /* Track the cursor 1:1 while dragging the drawer's resize handle. */
9126
+ :host([variant='inline']) .panel--resizing-debug {
9127
+ transition: none;
8295
9128
  }
8296
9129
 
8297
9130
  @keyframes chatbot-panel-in {
@@ -8359,16 +9192,31 @@ KRChatbot.styles = i$6 `
8359
9192
  color: #000000;
8360
9193
  }
8361
9194
 
8362
- .header-action--active {
8363
- background: rgba(0, 0, 0, 0.08);
8364
- color: #000000;
8365
- }
8366
-
8367
9195
  .header-action-icon {
8368
9196
  width: 20px;
8369
9197
  height: 20px;
8370
9198
  }
8371
9199
 
9200
+ .settings-section {
9201
+ padding: 14px 20px;
9202
+ border-bottom: 1px solid rgba(0, 0, 0, 0.06);
9203
+ }
9204
+
9205
+ .settings-section:last-child {
9206
+ border-bottom: none;
9207
+ }
9208
+
9209
+ .settings-section__title {
9210
+ font-size: 14px;
9211
+ font-weight: 600;
9212
+ color: #000000;
9213
+ margin-bottom: 12px;
9214
+ }
9215
+
9216
+ .settings-row {
9217
+ padding: 6px 0;
9218
+ }
9219
+
8372
9220
  /* Messages */
8373
9221
  /* Full-width scroll container — keeps the scrollbar at the panel edge. */
8374
9222
  .messages {
@@ -8449,6 +9297,138 @@ KRChatbot.styles = i$6 `
8449
9297
  gap: 24px;
8450
9298
  }
8451
9299
 
9300
+ /* An agent turn wraps all of its messages (reasoning, tool lines, text,
9301
+ decision) so the scroll-to-top runway can sit on the whole turn and content
9302
+ streams inside it instead of stacking as siblings below the runway. A flex
9303
+ column with the same 36px gap as .messages-inner, so item spacing is
9304
+ unchanged whether grouped or not. Full width so its children align exactly
9305
+ as before (assistant left, tool/decision left). */
9306
+ .agent-turn {
9307
+ display: flex;
9308
+ flex-direction: column;
9309
+ gap: 36px;
9310
+ width: 100%;
9311
+ }
9312
+
9313
+ /* Tool-activity line — "the assistant ran X" row in the transcript. Same text
9314
+ style as any other message (black, 14px); a leading › chevron marks it as a
9315
+ step (in the same text style, not an icon). */
9316
+ .tool-activity {
9317
+ align-self: flex-start;
9318
+ display: flex;
9319
+ align-items: baseline;
9320
+ gap: 6px;
9321
+ font-size: 14px;
9322
+ color: #000000;
9323
+ /* Keep the chevron + name + input on a single line; input truncates if long. */
9324
+ white-space: nowrap;
9325
+ }
9326
+
9327
+ .tool-activity--error {
9328
+ color: var(--kr-chatbot-error-text);
9329
+ }
9330
+
9331
+ /* Leading chevron — plain text in the same style as the name, so it reads as
9332
+ one label rather than an icon. */
9333
+ .tool-activity__chevron {
9334
+ flex-shrink: 0;
9335
+ }
9336
+
9337
+ /* The tool name never shrinks — it's the input that truncates if the header
9338
+ runs out of room. */
9339
+ .tool-activity__label {
9340
+ flex-shrink: 0;
9341
+ }
9342
+
9343
+ /* The tool input rendered inline in the header, Claude-CLI style:
9344
+ "Read(chatbot.ts)". Same color/font as the tool name — it reads as one
9345
+ label. Truncates with an ellipsis so the header stays on one line. */
9346
+ .tool-activity__input {
9347
+ overflow: hidden;
9348
+ text-overflow: ellipsis;
9349
+ min-width: 0;
9350
+ }
9351
+
9352
+ /* Header + result grouped as one unit so the parent .message--assistant 24px
9353
+ gap doesn't push them apart. Rows sit tight together inside. */
9354
+ .tool-activity-block {
9355
+ display: flex;
9356
+ flex-direction: column;
9357
+ gap: 4px;
9358
+ }
9359
+
9360
+ /* The result block indented under the activity line with a ⎿ tree connector,
9361
+ like Claude CLI's tool output preview. Uses the normal message text style
9362
+ (14px, black) — only the ⎿ connector is muted. */
9363
+ .tool-activity__result {
9364
+ align-self: flex-start;
9365
+ display: flex;
9366
+ gap: 2px;
9367
+ /* Align the ⎿ connector's visible stroke under the first character of the
9368
+ tool name. The name is preceded by the "› " chevron (chevron width + 6px
9369
+ gap ≈ 11px), and the ⎿ glyph carries ~5.6px more internal left bearing
9370
+ than the name's first letter — so net-shift the block right to land the
9371
+ stroke under the name. */
9372
+ margin-left: 6px;
9373
+ font-size: 14px;
9374
+ color: #000000;
9375
+ }
9376
+
9377
+ .tool-activity__result--error {
9378
+ color: var(--kr-chatbot-error-text);
9379
+ }
9380
+
9381
+ .tool-activity__connector {
9382
+ flex-shrink: 0;
9383
+ color: rgba(0, 0, 0, 0.55);
9384
+ user-select: none;
9385
+ /* The ⎿ arm sits near the baseline, ~6px below the result's first-character
9386
+ vertical center. Nudge the glyph up so the arm tip lines up with that center. */
9387
+ transform: translateY(-6px);
9388
+ }
9389
+
9390
+ .tool-activity__result-body {
9391
+ /* Small horizontal gap between the ⎿ arm and the result text so they don't
9392
+ collide (the ⎿ glyph's own arm already extends most of the way). */
9393
+ margin: 0 0 0 6px;
9394
+ white-space: pre-wrap;
9395
+ word-break: break-word;
9396
+ font: inherit;
9397
+ }
9398
+
9399
+ .tool-activity__more {
9400
+ color: rgba(0, 0, 0, 0.4);
9401
+ }
9402
+
9403
+ /* Decision line — the persistent record of an approve/deny (Rail 3). Uses the
9404
+ normal message text style (14px, black); a check/deny icon marks it. */
9405
+ .decision {
9406
+ align-self: flex-start;
9407
+ display: flex;
9408
+ align-items: center;
9409
+ gap: 8px;
9410
+ font-size: 14px;
9411
+ color: #000000;
9412
+ }
9413
+
9414
+ .decision--denied {
9415
+ color: var(--kr-chatbot-error-text);
9416
+ }
9417
+
9418
+ .decision__icon {
9419
+ display: inline-flex;
9420
+ align-items: center;
9421
+ justify-content: center;
9422
+ width: 16px;
9423
+ height: 16px;
9424
+ flex-shrink: 0;
9425
+ }
9426
+
9427
+ .decision__icon svg {
9428
+ width: 15px;
9429
+ height: 15px;
9430
+ }
9431
+
8452
9432
  /* anywhere, NOT break-word: both break a long unbreakable run (a pasted URL) for
8453
9433
  layout, but only anywhere also shrinks the element's intrinsic min-content width.
8454
9434
  With break-word the URL still reports its full unbroken width as the minimum,
@@ -8703,32 +9683,139 @@ KRChatbot.styles = i$6 `
8703
9683
  flex-shrink: 0;
8704
9684
  }
8705
9685
 
8706
- /*
8707
- * INTERNAL-ONLY debug telemetry drawer. A layout drawer docked to the right
8708
- * of the inline chat — the chat column shrinks to make room; it does not
8709
- * overlay. Closed by default (width 0), animates open. Not customer-facing —
8710
- * token counts, cache internals, and approximate cost estimates for internal
8711
- * debugging. Styled to match the chatbot's light theme (Inter, design tokens).
8712
- */
8713
- /*
8714
- * The chat column reserves space for the drawer by animating padding-right,
8715
- * so opening the drawer shrinks the chat (header/messages/composer) rather
8716
- * than overlaying it. Only applies in inline mode.
8717
- */
8718
- :host([variant='inline']) .panel {
8719
- transition: padding-right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
9686
+ /* Action-confirmation card (Rail 3). Floats OVER the messages, anchored just
9687
+ above the composer — not part of the message scroll — so it stays put and
9688
+ in view while the transcript scrolls behind it. Its width matches the
9689
+ message text column: .messages-inner is 768px max with 32px padding, so the
9690
+ text column is 768 - 64. Plain-language ask + Yes/No for a non-technical
9691
+ user; no tool codes, no raw arguments. */
9692
+ .confirmation {
9693
+ position: absolute;
9694
+ left: 32px;
9695
+ right: 32px;
9696
+ /* Sits above the footer/composer with a clear gap between the two. */
9697
+ bottom: 104px;
9698
+ z-index: 6;
9699
+ max-width: 720px;
9700
+ margin: 0 auto;
9701
+ padding: 24px;
9702
+ border: 1px solid #d8d8d8;
9703
+ border-radius: 8px;
9704
+ background: var(--kr-chatbot-bg);
9705
+ color: var(--kr-chatbot-text);
9706
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
9707
+ /* Never taller than the panel leaves room for — scroll the card itself if a
9708
+ batched multi-action ask is long, rather than covering the whole panel. */
9709
+ max-height: calc(100% - 176px);
9710
+ overflow-y: auto;
8720
9711
  }
8721
9712
 
8722
- /* Track the cursor 1:1 while dragging the drawer's resize handle. */
8723
- :host([variant='inline']) .panel--resizing-debug {
8724
- transition: none;
9713
+ /* Inline variant (Claude-CLI style): sits IN the message stream as just
9714
+ another assistant message — no card chrome (padding/background/border/
9715
+ radius/shadow), no float offsets, no height cap. It flows and scrolls with
9716
+ the transcript. */
9717
+ .confirmation--inline {
9718
+ position: static;
9719
+ left: auto;
9720
+ right: auto;
9721
+ bottom: auto;
9722
+ z-index: auto;
9723
+ max-width: none;
9724
+ margin: 0;
9725
+ max-height: none;
9726
+ overflow: visible;
9727
+ padding: 0;
9728
+ border: none;
9729
+ border-radius: 0;
9730
+ background: none;
9731
+ box-shadow: none;
9732
+ /* scrollIntoView({ block: 'end' }) aligns the card's bottom to the viewport
9733
+ bottom; this margin makes it stop short so the Yes/No buttons aren't flush
9734
+ against (or clipped by) the fold. */
9735
+ scroll-margin-bottom: 24px;
9736
+ }
9737
+
9738
+ /* The raw arguments, pretty-printed. Wraps rather than scrolling — the whole
9739
+ card grows with the transcript (the point of the inline variant). */
9740
+ .confirmation__details-input {
9741
+ margin: 8px 0 0;
9742
+ border-radius: 6px;
9743
+ color: var(--kr-chatbot-text);
9744
+ font-family: 'SF Mono', SFMono-Regular, ui-monospace, Menlo, Consolas, monospace;
9745
+ font-size: 13px;
9746
+ line-height: 1.5;
9747
+ white-space: pre-wrap;
9748
+ overflow-wrap: anywhere;
9749
+ }
9750
+
9751
+ /* "Run {tool}?" heading — names the tool being run. */
9752
+ .confirmation__title {
9753
+ font-size: 16px;
9754
+ font-weight: 600;
9755
+ margin-bottom: 12px;
9756
+ }
9757
+
9758
+ .confirmation__message {
9759
+ margin: 0;
9760
+ font-size: 14px;
9761
+ line-height: 1.5;
9762
+ }
9763
+
9764
+ /* Batch list: one bullet per pending tool — its name, then the model's
9765
+ plain-language intent under it. */
9766
+ .confirmation__list {
9767
+ margin: 0;
9768
+ padding-left: 20px;
9769
+ font-size: 14px;
9770
+ line-height: 1.5;
9771
+ }
9772
+
9773
+ .confirmation__list-item:not(:last-child) {
9774
+ margin-bottom: 8px;
9775
+ }
9776
+
9777
+ .confirmation__list-name {
9778
+ display: block;
9779
+ font-weight: 600;
9780
+ }
9781
+
9782
+ .confirmation__list-desc {
9783
+ display: block;
8725
9784
  }
8726
9785
 
8727
- .debug-drawer {
9786
+ /* Buttons in a row; wrap on a narrow panel rather than overflowing the card. */
9787
+ .confirmation__actions {
9788
+ display: flex;
9789
+ flex-wrap: wrap;
9790
+ align-items: center;
9791
+ gap: 10px;
9792
+ margin-top: 20px;
9793
+ }
9794
+
9795
+ /* Keyboard-highlighted decision — a focus ring around the button, drawn
9796
+ outside its border so it reads over both the flat (Yes) and outline
9797
+ variants without recoloring or reshaping the button. Don't set
9798
+ border-radius here: that's the button's own (its pill shape); outline
9799
+ follows it and doesn't affect the button's size. */
9800
+ .confirmation__btn--highlighted {
9801
+ outline: 2px solid var(--kr-chatbot-primary);
9802
+ outline-offset: 2px;
9803
+ }
9804
+
9805
+ /*
9806
+ * Side drawer, docked to the right of the inline chat, holding the Settings
9807
+ * and Debug views. The chat column shrinks (padding-right) to make room; the
9808
+ * drawer does not overlay. It stays mounted and slides in/out via a transform,
9809
+ * so opening and closing animate; switching views just swaps the content
9810
+ * inside (no re-slide, no width change). One consistent resizable width across
9811
+ * both views; the width is set inline from _debugDrawerWidth.
9812
+ */
9813
+ .drawer {
8728
9814
  position: absolute;
8729
9815
  top: 0;
8730
9816
  right: 0;
8731
9817
  bottom: 0;
9818
+ max-width: 100%;
8732
9819
  background: var(--kr-chatbot-bg);
8733
9820
  display: flex;
8734
9821
  flex-direction: column;
@@ -8740,17 +9827,17 @@ KRChatbot.styles = i$6 `
8740
9827
 
8741
9828
  /* While dragging the resize handle, suppress the slide transition so the
8742
9829
  drawer tracks the cursor 1:1. */
8743
- .debug-drawer--resizing {
9830
+ .drawer--resizing {
8744
9831
  transition: none;
8745
9832
  }
8746
9833
 
8747
- .debug-drawer--open {
9834
+ .drawer--open {
8748
9835
  transform: translateX(0);
8749
9836
  box-shadow: -4px 0 20px rgba(10, 26, 46, 0.07);
8750
9837
  }
8751
9838
 
8752
9839
  /* Left-edge grab handle for resizing the drawer. */
8753
- .debug-drawer__resize {
9840
+ .drawer__resize {
8754
9841
  position: absolute;
8755
9842
  top: 0;
8756
9843
  left: 0;
@@ -8760,7 +9847,7 @@ KRChatbot.styles = i$6 `
8760
9847
  z-index: 10;
8761
9848
  }
8762
9849
 
8763
- .debug-drawer__header {
9850
+ .drawer__header {
8764
9851
  display: flex;
8765
9852
  align-items: center;
8766
9853
  justify-content: space-between;
@@ -8770,13 +9857,13 @@ KRChatbot.styles = i$6 `
8770
9857
  border-bottom: 1px solid rgba(0, 0, 0, 0.08);
8771
9858
  }
8772
9859
 
8773
- .debug-drawer__title {
9860
+ .drawer__title {
8774
9861
  font-size: 14px;
8775
9862
  font-weight: 600;
8776
9863
  color: #000000;
8777
9864
  }
8778
9865
 
8779
- .debug-drawer__body {
9866
+ .drawer__body {
8780
9867
  flex: 1;
8781
9868
  overflow-y: auto;
8782
9869
  overflow-x: hidden;
@@ -8985,6 +10072,89 @@ KRChatbot.styles = i$6 `
8985
10072
  margin-right: 4px;
8986
10073
  }
8987
10074
 
10075
+ /* Mode selector — Gemini-style inline label + caret before the mic. Anchors
10076
+ its upward menu; sized so it never grows the single-row composer height. */
10077
+ .mode {
10078
+ position: relative;
10079
+ flex-shrink: 0;
10080
+ display: flex;
10081
+ align-items: center;
10082
+ }
10083
+
10084
+ .mode-trigger {
10085
+ display: inline-flex;
10086
+ align-items: center;
10087
+ gap: 2px;
10088
+ height: 34px;
10089
+ padding: 0 8px;
10090
+ border: none;
10091
+ background: transparent;
10092
+ color: var(--kr-chatbot-text);
10093
+ border-radius: 17px;
10094
+ cursor: pointer;
10095
+ font: inherit;
10096
+ font-size: 14px;
10097
+ white-space: nowrap;
10098
+ transition: background 0.15s;
10099
+ }
10100
+
10101
+ .mode-trigger:hover {
10102
+ background: #f8f9fc;
10103
+ }
10104
+
10105
+ .mode-trigger__caret {
10106
+ width: 16px;
10107
+ height: 16px;
10108
+ flex-shrink: 0;
10109
+ }
10110
+
10111
+ /* Menu opens upward, anchored to the trigger's right edge. */
10112
+ .mode-menu {
10113
+ position: absolute;
10114
+ bottom: calc(100% + 8px);
10115
+ right: 0;
10116
+ min-width: 220px;
10117
+ background: white;
10118
+ border: 1px solid #b0b0b0;
10119
+ border-radius: 8px;
10120
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
10121
+ padding: 6px 0;
10122
+ z-index: 10000;
10123
+ display: flex;
10124
+ flex-direction: column;
10125
+ }
10126
+
10127
+ .mode-menu__item {
10128
+ display: flex;
10129
+ flex-direction: column;
10130
+ gap: 1px;
10131
+ width: 100%;
10132
+ padding: 8px 14px;
10133
+ background: none;
10134
+ border: none;
10135
+ text-align: left;
10136
+ cursor: pointer;
10137
+ font: inherit;
10138
+ color: var(--kr-chatbot-text);
10139
+ }
10140
+
10141
+ .mode-menu__item:hover {
10142
+ background: #f3f4f6;
10143
+ }
10144
+
10145
+ .mode-menu__item--active {
10146
+ background: #eef1f6;
10147
+ }
10148
+
10149
+ .mode-menu__label {
10150
+ font-size: 14px;
10151
+ font-weight: 500;
10152
+ }
10153
+
10154
+ .mode-menu__desc {
10155
+ font-size: 12px;
10156
+ }
10157
+
8988
10158
  .mic {
8989
10159
  width: 34px;
8990
10160
  height: 34px;
@@ -9643,6 +10813,15 @@ __decorate([
9643
10813
  __decorate([
9644
10814
  r()
9645
10815
  ], KRChatbot.prototype, "_contextLimitMessage", void 0);
10816
+ __decorate([
10817
+ r()
10818
+ ], KRChatbot.prototype, "_pendingActions", void 0);
10819
+ __decorate([
10820
+ r()
10821
+ ], KRChatbot.prototype, "_confirmationHighlighted", void 0);
10822
+ __decorate([
10823
+ r()
10824
+ ], KRChatbot.prototype, "_confirmationInFlight", void 0);
9646
10825
  __decorate([
9647
10826
  r()
9648
10827
  ], KRChatbot.prototype, "_telemetry", void 0);
@@ -9654,7 +10833,10 @@ __decorate([
9654
10833
  ], KRChatbot.prototype, "_cacheCheckpoints", void 0);
9655
10834
  __decorate([
9656
10835
  r()
9657
- ], KRChatbot.prototype, "_debugPanelOpen", void 0);
10836
+ ], KRChatbot.prototype, "_drawerOpen", void 0);
10837
+ __decorate([
10838
+ r()
10839
+ ], KRChatbot.prototype, "_drawerView", void 0);
9658
10840
  __decorate([
9659
10841
  r()
9660
10842
  ], KRChatbot.prototype, "_debugDrawerWidth", void 0);
@@ -9703,6 +10885,21 @@ __decorate([
9703
10885
  __decorate([
9704
10886
  r()
9705
10887
  ], KRChatbot.prototype, "_isDictating", void 0);
10888
+ __decorate([
10889
+ r()
10890
+ ], KRChatbot.prototype, "_mode", void 0);
10891
+ __decorate([
10892
+ r()
10893
+ ], KRChatbot.prototype, "_modeMenuOpened", void 0);
10894
+ __decorate([
10895
+ r()
10896
+ ], KRChatbot.prototype, "_showTools", void 0);
10897
+ __decorate([
10898
+ r()
10899
+ ], KRChatbot.prototype, "_showToolInputs", void 0);
10900
+ __decorate([
10901
+ r()
10902
+ ], KRChatbot.prototype, "_showConfirmationInput", void 0);
9706
10903
  __decorate([
9707
10904
  e$3('.panel')
9708
10905
  ], KRChatbot.prototype, "_panelEl", void 0);