@kodaris/krubble-app-components 1.0.116 → 1.0.118

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.
@@ -2484,8 +2484,7 @@
2484
2484
 
2485
2485
  .nav {
2486
2486
  width: 200px;
2487
- background: white;
2488
- border-right: 1px solid #e5e7eb;
2487
+ background: #f3f7fcbf;
2489
2488
  display: flex;
2490
2489
  flex-direction: column;
2491
2490
  flex-shrink: 0;
@@ -5668,6 +5667,21 @@
5668
5667
  this._reader = null;
5669
5668
  this._decoder = new TextDecoder();
5670
5669
  this._buffer = '';
5670
+ /**
5671
+ * True once the current turn ended cleanly (a `finished:true` chunk arrived).
5672
+ * Distinguishes a real completion from a dropped stream: if the SSE stream
5673
+ * closes (`result.done`) while this is still false, the turn is NOT over — the
5674
+ * connection dropped (5-min emitter timeout, tab was backgrounded, network
5675
+ * blip) — so we reconnect instead of unlocking the composer. Reset per turn.
5676
+ */
5677
+ this._turnFinishedCleanly = false;
5678
+ /**
5679
+ * Consecutive failed reconnect attempts for the current drop. Bounds the retry
5680
+ * loop so a genuinely dead turn (server gone) can't spin forever — after the
5681
+ * cap we give up and finish the turn. Reset when a reconnect succeeds or a new
5682
+ * turn starts.
5683
+ */
5684
+ this._reconnectAttempts = 0;
5671
5685
  this._userHasScrolledUp = false;
5672
5686
  this._isDragging = false;
5673
5687
  this._isResizing = false;
@@ -5854,11 +5868,26 @@
5854
5868
  */
5855
5869
  this._drawerOpen = false;
5856
5870
  /**
5857
- * Which view the drawer shows — settings or debug. One drawer holds both; the
5858
- * header buttons switch this and the content/width swap inside. Never null, so
5859
- * the content stays visible while the drawer slides shut.
5871
+ * Which view the drawer shows — settings, debug, or a single tool call's full
5872
+ * detail. One drawer holds all three; the header buttons switch settings/debug
5873
+ * and clicking a tool line switches to `toolDetail`. The content/width swap
5874
+ * inside. Never null, so the content stays visible while the drawer slides shut.
5860
5875
  */
5861
5876
  this._drawerView = 'settings';
5877
+ /**
5878
+ * The tool call whose full input/output the `toolDetail` drawer view shows —
5879
+ * the clicked `tool_use` message and its paired `tool_result` message, stored
5880
+ * whole. The render reads `.data.*` off them (and formats for display) so we
5881
+ * don't duplicate the message shape here. Transient — set on click, never
5882
+ * persisted (its bytes can be large and the view is a peek). Null when none.
5883
+ */
5884
+ this._toolDetail = null;
5885
+ /** Which tool-detail block ('input' | 'output') just flashed "Copied" (null = none). */
5886
+ this._toolDetailCopied = null;
5887
+ /** Whether the tool-detail Input section is expanded. Both open on open. */
5888
+ this._toolDetailInputOpen = true;
5889
+ /** Whether the tool-detail Output section is expanded. */
5890
+ this._toolDetailOutputOpen = true;
5862
5891
  /** Current width of the drawer in px (user-resizable, persisted). */
5863
5892
  this._drawerWidth = 430;
5864
5893
  /** True while the user is dragging the drawer's resize handle. */
@@ -6112,6 +6141,20 @@
6112
6141
  data,
6113
6142
  });
6114
6143
  }
6144
+ // Persisted reasoning row — the DB stores the model's thinking as its own
6145
+ // row with the text in `content` and type "reasoning". Live, reasoning
6146
+ // instead arrives as REASONING_PART deltas accumulated onto an assistant
6147
+ // bubble's `reasoning` property, which is the ONLY shape the render shows
6148
+ // (via msg.reasoning). Map the DB row into that same shape — an assistant
6149
+ // bubble carrying the text in `reasoning` — so reloaded/reconciled
6150
+ // reasoning renders identically to live instead of vanishing.
6151
+ if (msg.type === 'reasoning') {
6152
+ return Promise.resolve({
6153
+ type: 'assistant',
6154
+ content: '',
6155
+ reasoning: msg.content || '',
6156
+ });
6157
+ }
6115
6158
  // A persisted approve/deny record — content is {outcome, tools:[...]}.
6116
6159
  // Keep the type, parse content into `data`; the render reads msg.data.*.
6117
6160
  if (msg.type === 'action_decision') {
@@ -6154,6 +6197,16 @@
6154
6197
  if (messages && messages.length) {
6155
6198
  this.messages = messages;
6156
6199
  }
6200
+ // A turn may still be running server-side for this conversation (the page was
6201
+ // reloaded mid-turn, or opened on another device while a turn streams). After
6202
+ // the transcript loads, try to attach to that live turn: _reconnectStream hits
6203
+ // the read-only attach endpoint — 200 resumes streaming (locks the composer +
6204
+ // shows the typing indicator so the user knows the AI is still working), 204
6205
+ // means nothing is running (stays idle). Only meaningful with a pinned
6206
+ // conversation and the built-in stream (a custom `send` owns its own turns).
6207
+ if (this.conversationId && !this._isStreaming) {
6208
+ this._reconnectStream();
6209
+ }
6157
6210
  }).catch(() => { });
6158
6211
  // Fetch the framework config on load (debugTelemetryEnabled, v2ChatEnabled,
6159
6212
  // registryServer). debugTelemetryEnabled gates the debug affordances and is
@@ -6377,6 +6430,12 @@
6377
6430
  }
6378
6431
  _saveState() {
6379
6432
  try {
6433
+ // `toolDetail` is a transient peek, not a saved view — persist only the two
6434
+ // real preference views so a reload never reopens a stale tool.
6435
+ let persistedDrawerView = this._drawerView;
6436
+ if (persistedDrawerView === 'toolDetail') {
6437
+ persistedDrawerView = 'settings';
6438
+ }
6380
6439
  const state = {
6381
6440
  opened: String(this.opened),
6382
6441
  message: this._inputValue,
@@ -6385,7 +6444,7 @@
6385
6444
  showToolInputs: String(this._showToolInputs),
6386
6445
  showConfirmationInput: String(this._showConfirmationInput),
6387
6446
  drawerOpen: String(this._drawerOpen),
6388
- drawerView: this._drawerView,
6447
+ drawerView: persistedDrawerView,
6389
6448
  drawerWidth: String(this._drawerWidth),
6390
6449
  };
6391
6450
  if (this._panelEl) {
@@ -7187,6 +7246,10 @@
7187
7246
  content: '',
7188
7247
  }];
7189
7248
  this._isStreaming = true;
7249
+ // Fresh turn: not yet finished, no reconnects used. These gate the drop-vs-done
7250
+ // decision in _readStreamChunk when the stream closes.
7251
+ this._turnFinishedCleanly = false;
7252
+ this._reconnectAttempts = 0;
7190
7253
  // Scroll user's message to the top (Gemini-style). The runway (min-height =
7191
7254
  // viewport) goes on the agent-turn WRAPPER, not an individual message — the
7192
7255
  // whole turn (reasoning, tool lines, text, decision) streams inside that one
@@ -7397,6 +7460,10 @@
7397
7460
  const btn = this._messagesEl?.querySelector(`.confirmation__btn[data-decision="${decision}"]`);
7398
7461
  btn?.showLoading();
7399
7462
  this._isStreaming = true;
7463
+ // Fresh turn (confirmation decision runs a model turn too): reset the
7464
+ // drop-vs-done gates used by _readStreamChunk.
7465
+ this._turnFinishedCleanly = false;
7466
+ this._reconnectAttempts = 0;
7400
7467
  this._userHasScrolledUp = false;
7401
7468
  this.send({
7402
7469
  message: '',
@@ -7528,6 +7595,162 @@
7528
7595
  this._drawerOpen = false;
7529
7596
  this._saveState();
7530
7597
  }
7598
+ /**
7599
+ * Open the drawer to a single tool call's full input/output. Called when the
7600
+ * user clicks a tool-activity line in the transcript. Independent of the
7601
+ * `_showToolInputs` setting — this is the on-demand "show me everything for
7602
+ * THIS call" peek, so it works even when inline inputs are hidden. `result` is
7603
+ * the paired `tool_result` message data (may be absent if the tool hasn't
7604
+ * returned yet).
7605
+ */
7606
+ _openToolDetail(use) {
7607
+ // Pair the tool_use to the tool_result that immediately follows it (matched by
7608
+ // toolUseId) — the same pairing the transcript render uses for the ⎿ preview.
7609
+ const next = this.messages[this.messages.indexOf(use) + 1];
7610
+ this._toolDetail = {
7611
+ use,
7612
+ result: null,
7613
+ };
7614
+ if (next?.type === 'tool_result' && next.data?.toolUseId === use.data?.toolUseId) {
7615
+ this._toolDetail.result = next;
7616
+ }
7617
+ this._toolDetailCopied = null;
7618
+ this._toolDetailInputOpen = true;
7619
+ this._toolDetailOutputOpen = true;
7620
+ this._drawerView = 'toolDetail';
7621
+ this._drawerOpen = true;
7622
+ this._saveState();
7623
+ // Reset the panel scroll so a newly-opened tool starts at the top, rather
7624
+ // than inheriting the previous tool's scroll position. Wait for the render.
7625
+ this.updateComplete.then(() => {
7626
+ if (this._drawerBodyEl) {
7627
+ this._drawerBodyEl.scrollTop = 0;
7628
+ }
7629
+ });
7630
+ }
7631
+ /** Toggle the tool-detail Input section open/closed. */
7632
+ _handleToggleToolDetailInput() {
7633
+ this._toolDetailInputOpen = !this._toolDetailInputOpen;
7634
+ }
7635
+ /** Toggle the tool-detail Output section open/closed. */
7636
+ _handleToggleToolDetailOutput() {
7637
+ this._toolDetailOutputOpen = !this._toolDetailOutputOpen;
7638
+ }
7639
+ /**
7640
+ * Pretty-print a tool input/result for the detail view. The values arrive as
7641
+ * strings that usually hold JSON; reindent when they do, otherwise show as-is.
7642
+ * Never throws — falls back to the original text.
7643
+ *
7644
+ * A large/overflowing result is stashed server-side and returned as a preview
7645
+ * wrapped in `<<<uuid_BEGIN>>> … <<<uuid_END>>>` markers, followed by a paging
7646
+ * note. Two things make `JSON.parse` unusable here: the marker wrapper isn't
7647
+ * JSON, and the payload between the markers is a truncated SLICE of a bigger
7648
+ * blob (cut off mid-string), so it never parses on its own. So we reindent the
7649
+ * payload STRUCTURALLY (see `_reindentJson`) rather than parse it, and keep the
7650
+ * markers + note exactly where they were so it still reads as a paged response.
7651
+ */
7652
+ _formatToolJson(value) {
7653
+ if (value === undefined || value === null) {
7654
+ return '';
7655
+ }
7656
+ const raw = String(value);
7657
+ const marker = raw.match(/(<<<[0-9a-f-]+_BEGIN>>>\n?)([\s\S]*?)(\n?<<<[0-9a-f-]+_END>>>)/);
7658
+ if (marker && marker.index != null) {
7659
+ // Splice by index rather than String.replace — the reindented JSON can
7660
+ // contain `$` sequences (e.g. "$dtoRef") that replace() treats as special.
7661
+ const start = marker.index + marker[1].length;
7662
+ const end = start + marker[2].length;
7663
+ return raw.slice(0, start) + this._reindentJson(marker[2]) + raw.slice(end);
7664
+ }
7665
+ return this._reindentJson(raw);
7666
+ }
7667
+ /**
7668
+ * Reindent JSON-ish text for display. Tries a strict parse+stringify first (the
7669
+ * clean path for complete, valid JSON). If that fails — which it does for the
7670
+ * truncated slices returned by the stashed-response pager — it falls back to a
7671
+ * structural walk that inserts newlines/indentation around `{}[],:` while
7672
+ * tracking string state (so braces and commas INSIDE string values, and escaped
7673
+ * quotes, are left alone). This formats even incomplete JSON. Non-JSON text
7674
+ * (no leading `{`/`[`) is returned untouched.
7675
+ */
7676
+ _reindentJson(text) {
7677
+ const trimmed = text.trim();
7678
+ if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
7679
+ return text;
7680
+ }
7681
+ try {
7682
+ return JSON.stringify(JSON.parse(trimmed), null, 2);
7683
+ }
7684
+ catch {
7685
+ // Fall through to the tolerant structural reindent below.
7686
+ }
7687
+ let out = '';
7688
+ let depth = 0;
7689
+ let inString = false;
7690
+ let escaped = false;
7691
+ const pad = (level) => '\n' + ' '.repeat(Math.max(0, level));
7692
+ for (let i = 0; i < trimmed.length; i++) {
7693
+ const ch = trimmed[i];
7694
+ if (inString) {
7695
+ out += ch;
7696
+ if (escaped) {
7697
+ escaped = false;
7698
+ }
7699
+ else if (ch === '\\') {
7700
+ escaped = true;
7701
+ }
7702
+ else if (ch === '"') {
7703
+ inString = false;
7704
+ }
7705
+ continue;
7706
+ }
7707
+ if (ch === '"') {
7708
+ inString = true;
7709
+ out += ch;
7710
+ }
7711
+ else if (ch === '{' || ch === '[') {
7712
+ depth++;
7713
+ out += ch + pad(depth);
7714
+ }
7715
+ else if (ch === '}' || ch === ']') {
7716
+ depth--;
7717
+ out += pad(depth) + ch;
7718
+ }
7719
+ else if (ch === ',') {
7720
+ out += ch + pad(depth);
7721
+ }
7722
+ else if (ch === ':') {
7723
+ out += ': ';
7724
+ }
7725
+ else if (ch === ' ' || ch === '\n' || ch === '\t' || ch === '\r') ;
7726
+ else {
7727
+ out += ch;
7728
+ }
7729
+ }
7730
+ return out;
7731
+ }
7732
+ /** Copy the tool-detail Input to the clipboard. Stops propagation so the click
7733
+ * doesn't also toggle the (parent) section header. */
7734
+ _handleCopyToolDetailInput(e) {
7735
+ e.stopPropagation();
7736
+ this._copyToolDetail('input', this._formatToolJson(this._toolDetail?.use.data?.toolInput));
7737
+ }
7738
+ /** Copy the tool-detail Output to the clipboard (see input handler). */
7739
+ _handleCopyToolDetailOutput(e) {
7740
+ e.stopPropagation();
7741
+ this._copyToolDetail('output', this._formatToolJson(this._toolDetail?.result?.data?.toolResultString));
7742
+ }
7743
+ /** Write one tool-detail block to the clipboard and flash "Copied" on it. */
7744
+ _copyToolDetail(block, content) {
7745
+ navigator.clipboard.writeText(content).then(() => {
7746
+ this._toolDetailCopied = block;
7747
+ setTimeout(() => {
7748
+ if (this._toolDetailCopied === block) {
7749
+ this._toolDetailCopied = null;
7750
+ }
7751
+ }, 2000);
7752
+ }).catch(() => { });
7753
+ }
7531
7754
  _handleDrawerResizeMouseDown(e) {
7532
7755
  this._isDrawerResizing = true;
7533
7756
  this._drawerResizeStartX = e.clientX;
@@ -7543,11 +7766,18 @@
7543
7766
  }
7544
7767
  this._reader.read().then((result) => {
7545
7768
  if (result.done) {
7546
- // Fallback unlock: the stream closed without a `finished:true` chunk
7547
- // (network drop, server crash, the 5-min emitter timeout). Treat it as
7548
- // the turn ending so a dropped stream never wedges the composer — the
7549
- // server self-heals its turn lock and bounces any premature send.
7550
- this._finishStream();
7769
+ // The stream closed. Two very different cases:
7770
+ // 1. A `finished:true` chunk already arrived (_turnFinishedCleanly) — the
7771
+ // turn is genuinely over; finishing was already handled below. Nothing to do.
7772
+ // 2. No finished chunk yet — the connection DROPPED mid-turn (5-min emitter
7773
+ // timeout, tab backgrounded, network blip) but the server-side turn is
7774
+ // still running. Do NOT unlock the composer (that's the old bug that made
7775
+ // the input look ready while the AI was still working). Reconnect to the
7776
+ // live stream and keep receiving events.
7777
+ if (this._turnFinishedCleanly) {
7778
+ return;
7779
+ }
7780
+ this._reconnectStream();
7551
7781
  return;
7552
7782
  }
7553
7783
  this._buffer = this._buffer + this._decoder.decode(result.value, { stream: true });
@@ -7700,7 +7930,9 @@
7700
7930
  }
7701
7931
  if (data.finished) {
7702
7932
  // Primary unlock signal: the turn is over and the server released
7703
- // the turn lock. The SSE stream closes right after this chunk.
7933
+ // the turn lock. The SSE stream closes right after this chunk. Mark
7934
+ // it clean so the imminent `result.done` doesn't trigger a reconnect.
7935
+ this._turnFinishedCleanly = true;
7704
7936
  this._finishStream();
7705
7937
  }
7706
7938
  }
@@ -7712,9 +7944,110 @@
7712
7944
  }
7713
7945
  this._readStreamChunk();
7714
7946
  }).catch((err) => {
7715
- if (err.name !== 'AbortError') {
7716
- this._handleStreamError(err.message);
7947
+ if (err.name === 'AbortError') {
7948
+ return;
7949
+ }
7950
+ // A read error mid-turn is a drop, not a completion — reconnect (same as a
7951
+ // clean `done` without a finished chunk) unless the turn already finished.
7952
+ if (!this._turnFinishedCleanly) {
7953
+ this._reconnectStream();
7954
+ return;
7955
+ }
7956
+ this._handleStreamError(err.message);
7957
+ });
7958
+ }
7959
+ /**
7960
+ * Reconnect to an in-flight turn after the SSE stream dropped without finishing
7961
+ * (5-min emitter timeout, tab backgrounded, network blip). Hits the read-only
7962
+ * `/stream/attach/{conversationID}` endpoint, which re-attaches a fresh SSE
7963
+ * connection to the still-running server-side turn without starting anything new.
7964
+ *
7965
+ * - 200 with a body → resume reading; the turn's events keep flowing (events
7966
+ * emitted during the gap are not replayed, so the live text may jump forward —
7967
+ * the full transcript reconciles from the DB when the turn ends).
7968
+ * - 204 → no live turn: it already finished server-side while we were
7969
+ * disconnected. Reload the transcript so the final answer appears, then unlock.
7970
+ * - failure / retry cap hit → give up and finish so the composer never wedges.
7971
+ *
7972
+ * The composer stays locked (`_isStreaming` untouched) throughout, so the input
7973
+ * never looks ready while the AI is still working.
7974
+ */
7975
+ _reconnectStream() {
7976
+ // Without a pinned conversation there's nothing to re-attach to (a custom
7977
+ // `send` owns its own targeting). Fall back to finishing.
7978
+ if (!this.conversationId) {
7979
+ this._finishStream();
7980
+ return;
7981
+ }
7982
+ // Bound the retries so a genuinely dead turn can't loop forever.
7983
+ if (this._reconnectAttempts >= 5) {
7984
+ this._handleStreamError('Lost connection to the assistant. Please reload the conversation.');
7985
+ return;
7986
+ }
7987
+ this._reconnectAttempts = this._reconnectAttempts + 1;
7988
+ krubbleHttp.Http.fetch({
7989
+ url: `/api/system/integration/aws/bedrock/stream/attach/${this.conversationId}`,
7990
+ method: 'GET',
7991
+ headers: { Accept: 'text/event-stream' },
7992
+ }).then((response) => {
7993
+ // 204 No Content: the turn finished server-side during the gap. Pull the
7994
+ // final transcript from the DB (via the same `load` callback the component
7995
+ // initializes with) so the answer that streamed during the disconnect
7996
+ // appears, then unlock.
7997
+ if (response.status === 204) {
7998
+ this._reconnectAttempts = 0;
7999
+ if (this.load) {
8000
+ this.load().then((messages) => {
8001
+ this.messages = messages;
8002
+ this.requestUpdate();
8003
+ }).catch(() => {
8004
+ // Reload failed — leave the transcript as-is; the user can refresh manually.
8005
+ });
8006
+ }
8007
+ this._finishStream();
8008
+ return;
7717
8009
  }
8010
+ if (!response.ok || !response.body) {
8011
+ // Transient failure — back off and try again on the same drop.
8012
+ setTimeout(() => this._reconnectStream(), 1000);
8013
+ return;
8014
+ }
8015
+ // Re-attached to the live turn: reset the retry counter and resume reading
8016
+ // into the same buffer/decoder from the fresh stream. Mark streaming so the
8017
+ // composer locks and the typing indicator shows — load-triggered attaches (a
8018
+ // page reloaded mid-turn) start with _isStreaming false, so this is what
8019
+ // surfaces "the AI is still working"; on a mid-session drop it's already true
8020
+ // (a no-op).
8021
+ this._reconnectAttempts = 0;
8022
+ this._isStreaming = true;
8023
+ this._reader = response.body.getReader();
8024
+ this._decoder = new TextDecoder();
8025
+ this._buffer = '';
8026
+ // Close the gap: events the turn emitted between the drop and this re-attach
8027
+ // aren't replayed on the live stream. Every COMPLETED iteration during the
8028
+ // gap was persisted (assistant text, tool_use/tool_result rows), so reload the
8029
+ // transcript from the DB to backfill them, THEN start reading live. Ordering
8030
+ // matters — the reader buffers incoming bytes until _readStreamChunk runs, so
8031
+ // starting live only after the async reload resolves prevents the reload from
8032
+ // clobbering freshly-streamed chunks. Only the current in-flight iteration's
8033
+ // partial text is missing from the reload; it streams live from here.
8034
+ if (this.load) {
8035
+ this.load().then((messages) => {
8036
+ this.messages = messages;
8037
+ this.requestUpdate();
8038
+ this._readStreamChunk();
8039
+ }).catch(() => {
8040
+ // Reload failed — go live anyway; the gap's completed iterations just
8041
+ // won't show until the next full reload, but the turn keeps streaming.
8042
+ this._readStreamChunk();
8043
+ });
8044
+ }
8045
+ else {
8046
+ this._readStreamChunk();
8047
+ }
8048
+ }).catch(() => {
8049
+ // Network error reaching the attach endpoint — back off and retry.
8050
+ setTimeout(() => this._reconnectStream(), 1000);
7718
8051
  });
7719
8052
  }
7720
8053
  /**
@@ -8134,6 +8467,16 @@
8134
8467
  const isDebug = this._drawerView === 'debug'
8135
8468
  && this.variant === 'inline'
8136
8469
  && this._aiFrameworkConfig.debugTelemetryEnabled === true;
8470
+ let title = 'Settings';
8471
+ let body = this._renderSettingsContent();
8472
+ if (isDebug) {
8473
+ title = 'Debug';
8474
+ body = this._renderDebugContent();
8475
+ }
8476
+ else if (this._drawerView === 'toolDetail' && this._toolDetail) {
8477
+ title = this._toolDetail.use.data?.displayName || this._toolDetail.use.data?.toolName || 'Tool';
8478
+ body = this._renderToolDetailContent();
8479
+ }
8137
8480
  return b `
8138
8481
  <div
8139
8482
  class=${e({
@@ -8148,7 +8491,7 @@
8148
8491
  @mousedown=${this._handleDrawerResizeMouseDown}
8149
8492
  ></div>
8150
8493
  <div class="drawer__header">
8151
- <span class="drawer__title">${isDebug ? 'Debug' : 'Settings'}</span>
8494
+ <span class="drawer__title">${title}</span>
8152
8495
  <button
8153
8496
  class="header-action"
8154
8497
  @click=${this._handleCloseDrawer}
@@ -8171,7 +8514,7 @@
8171
8514
  </button>
8172
8515
  </div>
8173
8516
  <div class="drawer__body">
8174
- ${isDebug ? this._renderDebugContent() : this._renderSettingsContent()}
8517
+ ${body}
8175
8518
  </div>
8176
8519
  </div>
8177
8520
  `;
@@ -8203,6 +8546,129 @@
8203
8546
  ></kr-checkbox>
8204
8547
  </div>
8205
8548
  </div>
8549
+ `;
8550
+ }
8551
+ /**
8552
+ * The Tool detail view's body — the full input and output for one tool call,
8553
+ * pretty-printed in scrollable code blocks with a per-block copy button. This
8554
+ * is the "occasionally see everything" path: the transcript stays terse, and
8555
+ * clicking a tool line drops its complete input/output here, uncapped (the
8556
+ * blocks scroll rather than truncating).
8557
+ */
8558
+ _renderToolDetailContent() {
8559
+ return b `
8560
+ <div class="tool-detail">
8561
+ <div class="tool-detail__section">
8562
+ <button
8563
+ type="button"
8564
+ class="tool-detail__section-head"
8565
+ aria-expanded=${this._toolDetailInputOpen}
8566
+ @click=${this._handleToggleToolDetailInput}
8567
+ >
8568
+ <span class=${e({
8569
+ 'tool-detail__caret': true,
8570
+ 'tool-detail__caret--open': this._toolDetailInputOpen,
8571
+ })}>›</span>
8572
+ <span class="tool-detail__section-title">Input</span>
8573
+ <span
8574
+ class="tool-detail__copy"
8575
+ role="button"
8576
+ @click=${this._handleCopyToolDetailInput}
8577
+ >
8578
+ ${this._toolDetailCopied === 'input'
8579
+ ? b `
8580
+ <svg
8581
+ viewBox="0 0 24 24"
8582
+ fill="none"
8583
+ stroke="currentColor"
8584
+ stroke-width="1.5"
8585
+ stroke-linecap="round"
8586
+ stroke-linejoin="round"
8587
+ >
8588
+ <path d="M20 6 9 17l-5-5" />
8589
+ </svg>
8590
+ `
8591
+ : b `
8592
+ <svg
8593
+ viewBox="0 0 24 24"
8594
+ fill="none"
8595
+ stroke="currentColor"
8596
+ stroke-width="1.5"
8597
+ stroke-linecap="round"
8598
+ stroke-linejoin="round"
8599
+ >
8600
+ <rect
8601
+ x="9"
8602
+ y="9"
8603
+ width="13"
8604
+ height="13"
8605
+ rx="2"
8606
+ />
8607
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
8608
+ </svg>
8609
+ `}
8610
+ </span>
8611
+ </button>
8612
+ ${this._toolDetailInputOpen
8613
+ ? b `<pre class="tool-detail__code">${this._formatToolJson(this._toolDetail.use.data?.toolInput) || '(no input)'}</pre>`
8614
+ : A}
8615
+ </div>
8616
+ <div class="tool-detail__section">
8617
+ <button
8618
+ type="button"
8619
+ class="tool-detail__section-head"
8620
+ aria-expanded=${this._toolDetailOutputOpen}
8621
+ @click=${this._handleToggleToolDetailOutput}
8622
+ >
8623
+ <span class=${e({
8624
+ 'tool-detail__caret': true,
8625
+ 'tool-detail__caret--open': this._toolDetailOutputOpen,
8626
+ })}>›</span>
8627
+ <span class="tool-detail__section-title">Output</span>
8628
+ <span
8629
+ class="tool-detail__copy"
8630
+ role="button"
8631
+ @click=${this._handleCopyToolDetailOutput}
8632
+ >
8633
+ ${this._toolDetailCopied === 'output'
8634
+ ? b `
8635
+ <svg
8636
+ viewBox="0 0 24 24"
8637
+ fill="none"
8638
+ stroke="currentColor"
8639
+ stroke-width="1.5"
8640
+ stroke-linecap="round"
8641
+ stroke-linejoin="round"
8642
+ >
8643
+ <path d="M20 6 9 17l-5-5" />
8644
+ </svg>
8645
+ `
8646
+ : b `
8647
+ <svg
8648
+ viewBox="0 0 24 24"
8649
+ fill="none"
8650
+ stroke="currentColor"
8651
+ stroke-width="1.5"
8652
+ stroke-linecap="round"
8653
+ stroke-linejoin="round"
8654
+ >
8655
+ <rect
8656
+ x="9"
8657
+ y="9"
8658
+ width="13"
8659
+ height="13"
8660
+ rx="2"
8661
+ />
8662
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
8663
+ </svg>
8664
+ `}
8665
+ </span>
8666
+ </button>
8667
+ ${this._toolDetailOutputOpen
8668
+ ? b `<pre class="tool-detail__code">${this._formatToolJson(this._toolDetail.result?.data?.toolResultString) || '(no output yet)'}</pre>`
8669
+ : A}
8670
+ </div>
8671
+ </div>
8206
8672
  `;
8207
8673
  }
8208
8674
  /** The Debug view's body — telemetry scopes and the per-iteration table. */
@@ -8566,8 +9032,17 @@
8566
9032
  catch {
8567
9033
  formatted = String(result.toolResultString);
8568
9034
  }
9035
+ // Cap the inline preview by BOTH lines and characters. Line-only
9036
+ // truncation blows up on results whose first line is huge (e.g. a
9037
+ // minified-JSON blob or a stashed-response marker with a 30k-char
9038
+ // opening line) — 3 lines then still dumps tens of thousands of
9039
+ // chars into the transcript. Char-cap first (content-blind, like
9040
+ // the backend chokepoint), then keep at most 3 lines of that.
8569
9041
  const lines = formatted.split('\n');
8570
9042
  resultPreview = lines.slice(0, 3).join('\n');
9043
+ if (resultPreview.length > 200) {
9044
+ resultPreview = `${resultPreview.slice(0, 200)}…`;
9045
+ }
8571
9046
  resultRemainder = Math.max(0, lines.length - 3);
8572
9047
  }
8573
9048
  }
@@ -8596,24 +9071,32 @@
8596
9071
  })}>
8597
9072
  ${msg.type === 'tool_use' ? b `
8598
9073
  <div class="tool-activity-block">
8599
- <div class=${e({
9074
+ <button
9075
+ type="button"
9076
+ class=${e({
8600
9077
  'tool-activity': true,
8601
9078
  'tool-activity--error': result?.toolResultStatus === 'error',
8602
- })}>
9079
+ })}
9080
+ @click=${() => this._openToolDetail(msg)}
9081
+ >
8603
9082
  <span class="tool-activity__chevron">›</span>
8604
9083
  <span class="tool-activity__label">${msg.data?.displayName || msg.data?.toolName || 'Tool'}</span>
8605
9084
  ${inputSummary ? b `<span class="tool-activity__input">(${inputSummary})</span>` : A}
8606
- </div>
9085
+ </button>
8607
9086
  ${this._showToolInputs && result?.toolResultString ? b `
8608
- <div class=${e({
9087
+ <button
9088
+ type="button"
9089
+ class=${e({
8609
9090
  'tool-activity__result': true,
8610
9091
  'tool-activity__result--error': result.toolResultStatus === 'error',
8611
- })}>
9092
+ })}
9093
+ @click=${() => this._openToolDetail(msg)}
9094
+ >
8612
9095
  <span class="tool-activity__connector">⎿</span>
8613
9096
  <pre class="tool-activity__result-body">${resultPreview}${resultRemainder > 0
8614
9097
  ? b `\n<span class="tool-activity__more">… +${resultRemainder} lines</span>`
8615
9098
  : A}</pre>
8616
- </div>
9099
+ </button>
8617
9100
  ` : A}
8618
9101
  </div>
8619
9102
  ` : A}
@@ -9218,6 +9701,102 @@
9218
9701
  padding: 6px 0;
9219
9702
  }
9220
9703
 
9704
+ /* Tool detail view — the full input/output for one tool call. */
9705
+ .tool-detail {
9706
+ display: flex;
9707
+ flex-direction: column;
9708
+ gap: 24px;
9709
+ padding: 16px 20px;
9710
+ }
9711
+
9712
+ .tool-detail__section {
9713
+ display: flex;
9714
+ flex-direction: column;
9715
+ gap: 8px;
9716
+ min-width: 0;
9717
+ }
9718
+
9719
+ /* Clickable section header — toggles the code block below. Button chrome is
9720
+ stripped so it reads as a heading row. */
9721
+ .tool-detail__section-head {
9722
+ display: flex;
9723
+ align-items: center;
9724
+ gap: 8px;
9725
+ appearance: none;
9726
+ background: none;
9727
+ border: none;
9728
+ width: 100%;
9729
+ font-family: inherit;
9730
+ text-align: left;
9731
+ cursor: pointer;
9732
+ border-radius: 4px;
9733
+ }
9734
+
9735
+ .tool-detail__section-head:focus-visible {
9736
+ outline: 2px solid var(--kr-chatbot-primary);
9737
+ outline-offset: 2px;
9738
+ }
9739
+
9740
+ /* Disclosure caret — the same "›" used on tool lines; rotates down when open. */
9741
+ .tool-detail__caret {
9742
+ flex-shrink: 0;
9743
+ font-size: 16px;
9744
+ color: rgba(0, 0, 0, 0.55);
9745
+ transition: transform 0.15s ease;
9746
+ }
9747
+
9748
+ .tool-detail__caret--open {
9749
+ transform: rotate(90deg);
9750
+ }
9751
+
9752
+ .tool-detail__section-title {
9753
+ font-size: 12px;
9754
+ font-weight: 700;
9755
+ text-transform: uppercase;
9756
+ letter-spacing: 0.06em;
9757
+ color: #000000;
9758
+ }
9759
+
9760
+ /* Copy icon-button — same treatment as the per-message copy button. */
9761
+ .tool-detail__copy {
9762
+ margin-left: auto;
9763
+ display: inline-flex;
9764
+ align-items: center;
9765
+ justify-content: center;
9766
+ width: 26px;
9767
+ height: 26px;
9768
+ padding: 0;
9769
+ background: none;
9770
+ border: none;
9771
+ border-radius: 6px;
9772
+ color: #000000;
9773
+ cursor: pointer;
9774
+ transition: background-color 0.15s, color 0.15s;
9775
+ }
9776
+
9777
+ .tool-detail__copy:hover {
9778
+ background: rgba(0, 0, 0, 0.06);
9779
+ }
9780
+
9781
+ .tool-detail__copy svg {
9782
+ width: 14px;
9783
+ height: 14px;
9784
+ }
9785
+
9786
+ /* The code block — pretty-printed JSON, shown in full (no inner scroll). The
9787
+ drawer body is the only scroll container, so long inputs/outputs extend the
9788
+ page and you scroll the panel, not a nested box. */
9789
+ .tool-detail__code {
9790
+ margin: 0;
9791
+ padding: 0 18px;
9792
+ font-family: 'SF Mono', 'Fira Code', Monaco, monospace;
9793
+ font-size: 12px;
9794
+ line-height: 1.5;
9795
+ color: #000000;
9796
+ white-space: pre-wrap;
9797
+ word-break: break-word;
9798
+ }
9799
+
9221
9800
  /* Messages */
9222
9801
  /* Full-width scroll container — keeps the scrollbar at the panel edge. */
9223
9802
  .messages {
@@ -9314,6 +9893,9 @@
9314
9893
  /* Tool-activity line — "the assistant ran X" row in the transcript. Same text
9315
9894
  style as any other message (black, 14px); a leading › chevron marks it as a
9316
9895
  step (in the same text style, not an icon). */
9896
+ /* The tool-activity line and its result preview are <button>s (they open the
9897
+ tool-detail drawer on click) — strip the native button chrome so they read
9898
+ as plain transcript text, then re-add the layout. */
9317
9899
  .tool-activity {
9318
9900
  align-self: flex-start;
9319
9901
  display: flex;
@@ -9323,6 +9905,22 @@
9323
9905
  color: #000000;
9324
9906
  /* Keep the chevron + name + input on a single line; input truncates if long. */
9325
9907
  white-space: nowrap;
9908
+ /* Button reset */
9909
+ appearance: none;
9910
+ background: none;
9911
+ border: none;
9912
+ margin: 0;
9913
+ padding: 2px 4px;
9914
+ font-family: inherit;
9915
+ text-align: left;
9916
+ cursor: pointer;
9917
+ border-radius: 4px;
9918
+ max-width: 100%;
9919
+ }
9920
+
9921
+ .tool-activity:focus-visible {
9922
+ outline: 2px solid var(--kr-chatbot-primary);
9923
+ outline-offset: 1px;
9326
9924
  }
9327
9925
 
9328
9926
  .tool-activity--error {
@@ -9358,6 +9956,16 @@
9358
9956
  gap: 4px;
9359
9957
  }
9360
9958
 
9959
+ /* One hover for the whole tool block: hovering anywhere over the header or its
9960
+ result underlines the tool name, input, and result together as a single
9961
+ link, rather than lighting up header and result separately. */
9962
+ .tool-activity-block:hover .tool-activity__label,
9963
+ .tool-activity-block:hover .tool-activity__input,
9964
+ .tool-activity-block:hover .tool-activity__result-body {
9965
+ text-decoration: underline;
9966
+ text-decoration-color: rgba(0, 0, 0, 0.25);
9967
+ }
9968
+
9361
9969
  /* The result block indented under the activity line with a ⎿ tree connector,
9362
9970
  like Claude CLI's tool output preview. Uses the normal message text style
9363
9971
  (14px, black) — only the ⎿ connector is muted. */
@@ -9373,6 +9981,20 @@
9373
9981
  margin-left: 6px;
9374
9982
  font-size: 14px;
9375
9983
  color: #000000;
9984
+ /* Button reset — this preview row is also a click target for the detail view. */
9985
+ appearance: none;
9986
+ background: none;
9987
+ border: none;
9988
+ padding: 0;
9989
+ font-family: inherit;
9990
+ text-align: left;
9991
+ cursor: pointer;
9992
+ }
9993
+
9994
+ .tool-activity__result:focus-visible {
9995
+ outline: 2px solid var(--kr-chatbot-primary);
9996
+ outline-offset: 1px;
9997
+ border-radius: 4px;
9376
9998
  }
9377
9999
 
9378
10000
  .tool-activity__result--error {
@@ -10838,6 +11460,18 @@
10838
11460
  __decorate([
10839
11461
  r()
10840
11462
  ], exports.Chatbot.prototype, "_drawerView", void 0);
11463
+ __decorate([
11464
+ r()
11465
+ ], exports.Chatbot.prototype, "_toolDetail", void 0);
11466
+ __decorate([
11467
+ r()
11468
+ ], exports.Chatbot.prototype, "_toolDetailCopied", void 0);
11469
+ __decorate([
11470
+ r()
11471
+ ], exports.Chatbot.prototype, "_toolDetailInputOpen", void 0);
11472
+ __decorate([
11473
+ r()
11474
+ ], exports.Chatbot.prototype, "_toolDetailOutputOpen", void 0);
10841
11475
  __decorate([
10842
11476
  r()
10843
11477
  ], exports.Chatbot.prototype, "_drawerWidth", void 0);
@@ -10919,6 +11553,9 @@
10919
11553
  __decorate([
10920
11554
  e$3('.skill-menu')
10921
11555
  ], exports.Chatbot.prototype, "_skillMenuEl", void 0);
11556
+ __decorate([
11557
+ e$3('.drawer__body')
11558
+ ], exports.Chatbot.prototype, "_drawerBodyEl", void 0);
10922
11559
  exports.Chatbot = __decorate([
10923
11560
  t$2('kr-chatbot')
10924
11561
  ], exports.Chatbot);