@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.
package/dist/chatbot.js CHANGED
@@ -153,6 +153,21 @@ let KRChatbot = class KRChatbot extends LitElement {
153
153
  this._reader = null;
154
154
  this._decoder = new TextDecoder();
155
155
  this._buffer = '';
156
+ /**
157
+ * True once the current turn ended cleanly (a `finished:true` chunk arrived).
158
+ * Distinguishes a real completion from a dropped stream: if the SSE stream
159
+ * closes (`result.done`) while this is still false, the turn is NOT over — the
160
+ * connection dropped (5-min emitter timeout, tab was backgrounded, network
161
+ * blip) — so we reconnect instead of unlocking the composer. Reset per turn.
162
+ */
163
+ this._turnFinishedCleanly = false;
164
+ /**
165
+ * Consecutive failed reconnect attempts for the current drop. Bounds the retry
166
+ * loop so a genuinely dead turn (server gone) can't spin forever — after the
167
+ * cap we give up and finish the turn. Reset when a reconnect succeeds or a new
168
+ * turn starts.
169
+ */
170
+ this._reconnectAttempts = 0;
156
171
  this._userHasScrolledUp = false;
157
172
  this._isDragging = false;
158
173
  this._isResizing = false;
@@ -339,11 +354,26 @@ let KRChatbot = class KRChatbot extends LitElement {
339
354
  */
340
355
  this._drawerOpen = false;
341
356
  /**
342
- * Which view the drawer shows — settings or debug. One drawer holds both; the
343
- * header buttons switch this and the content/width swap inside. Never null, so
344
- * the content stays visible while the drawer slides shut.
357
+ * Which view the drawer shows — settings, debug, or a single tool call's full
358
+ * detail. One drawer holds all three; the header buttons switch settings/debug
359
+ * and clicking a tool line switches to `toolDetail`. The content/width swap
360
+ * inside. Never null, so the content stays visible while the drawer slides shut.
345
361
  */
346
362
  this._drawerView = 'settings';
363
+ /**
364
+ * The tool call whose full input/output the `toolDetail` drawer view shows —
365
+ * the clicked `tool_use` message and its paired `tool_result` message, stored
366
+ * whole. The render reads `.data.*` off them (and formats for display) so we
367
+ * don't duplicate the message shape here. Transient — set on click, never
368
+ * persisted (its bytes can be large and the view is a peek). Null when none.
369
+ */
370
+ this._toolDetail = null;
371
+ /** Which tool-detail block ('input' | 'output') just flashed "Copied" (null = none). */
372
+ this._toolDetailCopied = null;
373
+ /** Whether the tool-detail Input section is expanded. Both open on open. */
374
+ this._toolDetailInputOpen = true;
375
+ /** Whether the tool-detail Output section is expanded. */
376
+ this._toolDetailOutputOpen = true;
347
377
  /** Current width of the drawer in px (user-resizable, persisted). */
348
378
  this._drawerWidth = 430;
349
379
  /** True while the user is dragging the drawer's resize handle. */
@@ -597,6 +627,20 @@ let KRChatbot = class KRChatbot extends LitElement {
597
627
  data,
598
628
  });
599
629
  }
630
+ // Persisted reasoning row — the DB stores the model's thinking as its own
631
+ // row with the text in `content` and type "reasoning". Live, reasoning
632
+ // instead arrives as REASONING_PART deltas accumulated onto an assistant
633
+ // bubble's `reasoning` property, which is the ONLY shape the render shows
634
+ // (via msg.reasoning). Map the DB row into that same shape — an assistant
635
+ // bubble carrying the text in `reasoning` — so reloaded/reconciled
636
+ // reasoning renders identically to live instead of vanishing.
637
+ if (msg.type === 'reasoning') {
638
+ return Promise.resolve({
639
+ type: 'assistant',
640
+ content: '',
641
+ reasoning: msg.content || '',
642
+ });
643
+ }
600
644
  // A persisted approve/deny record — content is {outcome, tools:[...]}.
601
645
  // Keep the type, parse content into `data`; the render reads msg.data.*.
602
646
  if (msg.type === 'action_decision') {
@@ -639,6 +683,16 @@ let KRChatbot = class KRChatbot extends LitElement {
639
683
  if (messages && messages.length) {
640
684
  this.messages = messages;
641
685
  }
686
+ // A turn may still be running server-side for this conversation (the page was
687
+ // reloaded mid-turn, or opened on another device while a turn streams). After
688
+ // the transcript loads, try to attach to that live turn: _reconnectStream hits
689
+ // the read-only attach endpoint — 200 resumes streaming (locks the composer +
690
+ // shows the typing indicator so the user knows the AI is still working), 204
691
+ // means nothing is running (stays idle). Only meaningful with a pinned
692
+ // conversation and the built-in stream (a custom `send` owns its own turns).
693
+ if (this.conversationId && !this._isStreaming) {
694
+ this._reconnectStream();
695
+ }
642
696
  }).catch(() => { });
643
697
  // Fetch the framework config on load (debugTelemetryEnabled, v2ChatEnabled,
644
698
  // registryServer). debugTelemetryEnabled gates the debug affordances and is
@@ -862,6 +916,12 @@ let KRChatbot = class KRChatbot extends LitElement {
862
916
  }
863
917
  _saveState() {
864
918
  try {
919
+ // `toolDetail` is a transient peek, not a saved view — persist only the two
920
+ // real preference views so a reload never reopens a stale tool.
921
+ let persistedDrawerView = this._drawerView;
922
+ if (persistedDrawerView === 'toolDetail') {
923
+ persistedDrawerView = 'settings';
924
+ }
865
925
  const state = {
866
926
  opened: String(this.opened),
867
927
  message: this._inputValue,
@@ -870,7 +930,7 @@ let KRChatbot = class KRChatbot extends LitElement {
870
930
  showToolInputs: String(this._showToolInputs),
871
931
  showConfirmationInput: String(this._showConfirmationInput),
872
932
  drawerOpen: String(this._drawerOpen),
873
- drawerView: this._drawerView,
933
+ drawerView: persistedDrawerView,
874
934
  drawerWidth: String(this._drawerWidth),
875
935
  };
876
936
  if (this._panelEl) {
@@ -1672,6 +1732,10 @@ let KRChatbot = class KRChatbot extends LitElement {
1672
1732
  content: '',
1673
1733
  }];
1674
1734
  this._isStreaming = true;
1735
+ // Fresh turn: not yet finished, no reconnects used. These gate the drop-vs-done
1736
+ // decision in _readStreamChunk when the stream closes.
1737
+ this._turnFinishedCleanly = false;
1738
+ this._reconnectAttempts = 0;
1675
1739
  // Scroll user's message to the top (Gemini-style). The runway (min-height =
1676
1740
  // viewport) goes on the agent-turn WRAPPER, not an individual message — the
1677
1741
  // whole turn (reasoning, tool lines, text, decision) streams inside that one
@@ -1882,6 +1946,10 @@ let KRChatbot = class KRChatbot extends LitElement {
1882
1946
  const btn = this._messagesEl?.querySelector(`.confirmation__btn[data-decision="${decision}"]`);
1883
1947
  btn?.showLoading();
1884
1948
  this._isStreaming = true;
1949
+ // Fresh turn (confirmation decision runs a model turn too): reset the
1950
+ // drop-vs-done gates used by _readStreamChunk.
1951
+ this._turnFinishedCleanly = false;
1952
+ this._reconnectAttempts = 0;
1885
1953
  this._userHasScrolledUp = false;
1886
1954
  this.send({
1887
1955
  message: '',
@@ -2013,6 +2081,164 @@ let KRChatbot = class KRChatbot extends LitElement {
2013
2081
  this._drawerOpen = false;
2014
2082
  this._saveState();
2015
2083
  }
2084
+ /**
2085
+ * Open the drawer to a single tool call's full input/output. Called when the
2086
+ * user clicks a tool-activity line in the transcript. Independent of the
2087
+ * `_showToolInputs` setting — this is the on-demand "show me everything for
2088
+ * THIS call" peek, so it works even when inline inputs are hidden. `result` is
2089
+ * the paired `tool_result` message data (may be absent if the tool hasn't
2090
+ * returned yet).
2091
+ */
2092
+ _openToolDetail(use) {
2093
+ // Pair the tool_use to the tool_result that immediately follows it (matched by
2094
+ // toolUseId) — the same pairing the transcript render uses for the ⎿ preview.
2095
+ const next = this.messages[this.messages.indexOf(use) + 1];
2096
+ this._toolDetail = {
2097
+ use,
2098
+ result: null,
2099
+ };
2100
+ if (next?.type === 'tool_result' && next.data?.toolUseId === use.data?.toolUseId) {
2101
+ this._toolDetail.result = next;
2102
+ }
2103
+ this._toolDetailCopied = null;
2104
+ this._toolDetailInputOpen = true;
2105
+ this._toolDetailOutputOpen = true;
2106
+ this._drawerView = 'toolDetail';
2107
+ this._drawerOpen = true;
2108
+ this._saveState();
2109
+ // Reset the panel scroll so a newly-opened tool starts at the top, rather
2110
+ // than inheriting the previous tool's scroll position. Wait for the render.
2111
+ this.updateComplete.then(() => {
2112
+ if (this._drawerBodyEl) {
2113
+ this._drawerBodyEl.scrollTop = 0;
2114
+ }
2115
+ });
2116
+ }
2117
+ /** Toggle the tool-detail Input section open/closed. */
2118
+ _handleToggleToolDetailInput() {
2119
+ this._toolDetailInputOpen = !this._toolDetailInputOpen;
2120
+ }
2121
+ /** Toggle the tool-detail Output section open/closed. */
2122
+ _handleToggleToolDetailOutput() {
2123
+ this._toolDetailOutputOpen = !this._toolDetailOutputOpen;
2124
+ }
2125
+ /**
2126
+ * Pretty-print a tool input/result for the detail view. The values arrive as
2127
+ * strings that usually hold JSON; reindent when they do, otherwise show as-is.
2128
+ * Never throws — falls back to the original text.
2129
+ *
2130
+ * A large/overflowing result is stashed server-side and returned as a preview
2131
+ * wrapped in `<<<uuid_BEGIN>>> … <<<uuid_END>>>` markers, followed by a paging
2132
+ * note. Two things make `JSON.parse` unusable here: the marker wrapper isn't
2133
+ * JSON, and the payload between the markers is a truncated SLICE of a bigger
2134
+ * blob (cut off mid-string), so it never parses on its own. So we reindent the
2135
+ * payload STRUCTURALLY (see `_reindentJson`) rather than parse it, and keep the
2136
+ * markers + note exactly where they were so it still reads as a paged response.
2137
+ */
2138
+ _formatToolJson(value) {
2139
+ if (value === undefined || value === null) {
2140
+ return '';
2141
+ }
2142
+ const raw = String(value);
2143
+ const marker = raw.match(/(<<<[0-9a-f-]+_BEGIN>>>\n?)([\s\S]*?)(\n?<<<[0-9a-f-]+_END>>>)/);
2144
+ if (marker && marker.index != null) {
2145
+ // Splice by index rather than String.replace — the reindented JSON can
2146
+ // contain `$` sequences (e.g. "$dtoRef") that replace() treats as special.
2147
+ const start = marker.index + marker[1].length;
2148
+ const end = start + marker[2].length;
2149
+ return raw.slice(0, start) + this._reindentJson(marker[2]) + raw.slice(end);
2150
+ }
2151
+ return this._reindentJson(raw);
2152
+ }
2153
+ /**
2154
+ * Reindent JSON-ish text for display. Tries a strict parse+stringify first (the
2155
+ * clean path for complete, valid JSON). If that fails — which it does for the
2156
+ * truncated slices returned by the stashed-response pager — it falls back to a
2157
+ * structural walk that inserts newlines/indentation around `{}[],:` while
2158
+ * tracking string state (so braces and commas INSIDE string values, and escaped
2159
+ * quotes, are left alone). This formats even incomplete JSON. Non-JSON text
2160
+ * (no leading `{`/`[`) is returned untouched.
2161
+ */
2162
+ _reindentJson(text) {
2163
+ const trimmed = text.trim();
2164
+ if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
2165
+ return text;
2166
+ }
2167
+ try {
2168
+ return JSON.stringify(JSON.parse(trimmed), null, 2);
2169
+ }
2170
+ catch {
2171
+ // Fall through to the tolerant structural reindent below.
2172
+ }
2173
+ let out = '';
2174
+ let depth = 0;
2175
+ let inString = false;
2176
+ let escaped = false;
2177
+ const pad = (level) => '\n' + ' '.repeat(Math.max(0, level));
2178
+ for (let i = 0; i < trimmed.length; i++) {
2179
+ const ch = trimmed[i];
2180
+ if (inString) {
2181
+ out += ch;
2182
+ if (escaped) {
2183
+ escaped = false;
2184
+ }
2185
+ else if (ch === '\\') {
2186
+ escaped = true;
2187
+ }
2188
+ else if (ch === '"') {
2189
+ inString = false;
2190
+ }
2191
+ continue;
2192
+ }
2193
+ if (ch === '"') {
2194
+ inString = true;
2195
+ out += ch;
2196
+ }
2197
+ else if (ch === '{' || ch === '[') {
2198
+ depth++;
2199
+ out += ch + pad(depth);
2200
+ }
2201
+ else if (ch === '}' || ch === ']') {
2202
+ depth--;
2203
+ out += pad(depth) + ch;
2204
+ }
2205
+ else if (ch === ',') {
2206
+ out += ch + pad(depth);
2207
+ }
2208
+ else if (ch === ':') {
2209
+ out += ': ';
2210
+ }
2211
+ else if (ch === ' ' || ch === '\n' || ch === '\t' || ch === '\r') {
2212
+ // Collapse the source's own whitespace between tokens — our pad() owns layout.
2213
+ }
2214
+ else {
2215
+ out += ch;
2216
+ }
2217
+ }
2218
+ return out;
2219
+ }
2220
+ /** Copy the tool-detail Input to the clipboard. Stops propagation so the click
2221
+ * doesn't also toggle the (parent) section header. */
2222
+ _handleCopyToolDetailInput(e) {
2223
+ e.stopPropagation();
2224
+ this._copyToolDetail('input', this._formatToolJson(this._toolDetail?.use.data?.toolInput));
2225
+ }
2226
+ /** Copy the tool-detail Output to the clipboard (see input handler). */
2227
+ _handleCopyToolDetailOutput(e) {
2228
+ e.stopPropagation();
2229
+ this._copyToolDetail('output', this._formatToolJson(this._toolDetail?.result?.data?.toolResultString));
2230
+ }
2231
+ /** Write one tool-detail block to the clipboard and flash "Copied" on it. */
2232
+ _copyToolDetail(block, content) {
2233
+ navigator.clipboard.writeText(content).then(() => {
2234
+ this._toolDetailCopied = block;
2235
+ setTimeout(() => {
2236
+ if (this._toolDetailCopied === block) {
2237
+ this._toolDetailCopied = null;
2238
+ }
2239
+ }, 2000);
2240
+ }).catch(() => { });
2241
+ }
2016
2242
  _handleDrawerResizeMouseDown(e) {
2017
2243
  this._isDrawerResizing = true;
2018
2244
  this._drawerResizeStartX = e.clientX;
@@ -2028,11 +2254,18 @@ let KRChatbot = class KRChatbot extends LitElement {
2028
2254
  }
2029
2255
  this._reader.read().then((result) => {
2030
2256
  if (result.done) {
2031
- // Fallback unlock: the stream closed without a `finished:true` chunk
2032
- // (network drop, server crash, the 5-min emitter timeout). Treat it as
2033
- // the turn ending so a dropped stream never wedges the composer — the
2034
- // server self-heals its turn lock and bounces any premature send.
2035
- this._finishStream();
2257
+ // The stream closed. Two very different cases:
2258
+ // 1. A `finished:true` chunk already arrived (_turnFinishedCleanly) — the
2259
+ // turn is genuinely over; finishing was already handled below. Nothing to do.
2260
+ // 2. No finished chunk yet — the connection DROPPED mid-turn (5-min emitter
2261
+ // timeout, tab backgrounded, network blip) but the server-side turn is
2262
+ // still running. Do NOT unlock the composer (that's the old bug that made
2263
+ // the input look ready while the AI was still working). Reconnect to the
2264
+ // live stream and keep receiving events.
2265
+ if (this._turnFinishedCleanly) {
2266
+ return;
2267
+ }
2268
+ this._reconnectStream();
2036
2269
  return;
2037
2270
  }
2038
2271
  this._buffer = this._buffer + this._decoder.decode(result.value, { stream: true });
@@ -2185,7 +2418,9 @@ let KRChatbot = class KRChatbot extends LitElement {
2185
2418
  }
2186
2419
  if (data.finished) {
2187
2420
  // Primary unlock signal: the turn is over and the server released
2188
- // the turn lock. The SSE stream closes right after this chunk.
2421
+ // the turn lock. The SSE stream closes right after this chunk. Mark
2422
+ // it clean so the imminent `result.done` doesn't trigger a reconnect.
2423
+ this._turnFinishedCleanly = true;
2189
2424
  this._finishStream();
2190
2425
  }
2191
2426
  }
@@ -2197,9 +2432,110 @@ let KRChatbot = class KRChatbot extends LitElement {
2197
2432
  }
2198
2433
  this._readStreamChunk();
2199
2434
  }).catch((err) => {
2200
- if (err.name !== 'AbortError') {
2201
- this._handleStreamError(err.message);
2435
+ if (err.name === 'AbortError') {
2436
+ return;
2437
+ }
2438
+ // A read error mid-turn is a drop, not a completion — reconnect (same as a
2439
+ // clean `done` without a finished chunk) unless the turn already finished.
2440
+ if (!this._turnFinishedCleanly) {
2441
+ this._reconnectStream();
2442
+ return;
2202
2443
  }
2444
+ this._handleStreamError(err.message);
2445
+ });
2446
+ }
2447
+ /**
2448
+ * Reconnect to an in-flight turn after the SSE stream dropped without finishing
2449
+ * (5-min emitter timeout, tab backgrounded, network blip). Hits the read-only
2450
+ * `/stream/attach/{conversationID}` endpoint, which re-attaches a fresh SSE
2451
+ * connection to the still-running server-side turn without starting anything new.
2452
+ *
2453
+ * - 200 with a body → resume reading; the turn's events keep flowing (events
2454
+ * emitted during the gap are not replayed, so the live text may jump forward —
2455
+ * the full transcript reconciles from the DB when the turn ends).
2456
+ * - 204 → no live turn: it already finished server-side while we were
2457
+ * disconnected. Reload the transcript so the final answer appears, then unlock.
2458
+ * - failure / retry cap hit → give up and finish so the composer never wedges.
2459
+ *
2460
+ * The composer stays locked (`_isStreaming` untouched) throughout, so the input
2461
+ * never looks ready while the AI is still working.
2462
+ */
2463
+ _reconnectStream() {
2464
+ // Without a pinned conversation there's nothing to re-attach to (a custom
2465
+ // `send` owns its own targeting). Fall back to finishing.
2466
+ if (!this.conversationId) {
2467
+ this._finishStream();
2468
+ return;
2469
+ }
2470
+ // Bound the retries so a genuinely dead turn can't loop forever.
2471
+ if (this._reconnectAttempts >= 5) {
2472
+ this._handleStreamError('Lost connection to the assistant. Please reload the conversation.');
2473
+ return;
2474
+ }
2475
+ this._reconnectAttempts = this._reconnectAttempts + 1;
2476
+ KRHttp.fetch({
2477
+ url: `/api/system/integration/aws/bedrock/stream/attach/${this.conversationId}`,
2478
+ method: 'GET',
2479
+ headers: { Accept: 'text/event-stream' },
2480
+ }).then((response) => {
2481
+ // 204 No Content: the turn finished server-side during the gap. Pull the
2482
+ // final transcript from the DB (via the same `load` callback the component
2483
+ // initializes with) so the answer that streamed during the disconnect
2484
+ // appears, then unlock.
2485
+ if (response.status === 204) {
2486
+ this._reconnectAttempts = 0;
2487
+ if (this.load) {
2488
+ this.load().then((messages) => {
2489
+ this.messages = messages;
2490
+ this.requestUpdate();
2491
+ }).catch(() => {
2492
+ // Reload failed — leave the transcript as-is; the user can refresh manually.
2493
+ });
2494
+ }
2495
+ this._finishStream();
2496
+ return;
2497
+ }
2498
+ if (!response.ok || !response.body) {
2499
+ // Transient failure — back off and try again on the same drop.
2500
+ setTimeout(() => this._reconnectStream(), 1000);
2501
+ return;
2502
+ }
2503
+ // Re-attached to the live turn: reset the retry counter and resume reading
2504
+ // into the same buffer/decoder from the fresh stream. Mark streaming so the
2505
+ // composer locks and the typing indicator shows — load-triggered attaches (a
2506
+ // page reloaded mid-turn) start with _isStreaming false, so this is what
2507
+ // surfaces "the AI is still working"; on a mid-session drop it's already true
2508
+ // (a no-op).
2509
+ this._reconnectAttempts = 0;
2510
+ this._isStreaming = true;
2511
+ this._reader = response.body.getReader();
2512
+ this._decoder = new TextDecoder();
2513
+ this._buffer = '';
2514
+ // Close the gap: events the turn emitted between the drop and this re-attach
2515
+ // aren't replayed on the live stream. Every COMPLETED iteration during the
2516
+ // gap was persisted (assistant text, tool_use/tool_result rows), so reload the
2517
+ // transcript from the DB to backfill them, THEN start reading live. Ordering
2518
+ // matters — the reader buffers incoming bytes until _readStreamChunk runs, so
2519
+ // starting live only after the async reload resolves prevents the reload from
2520
+ // clobbering freshly-streamed chunks. Only the current in-flight iteration's
2521
+ // partial text is missing from the reload; it streams live from here.
2522
+ if (this.load) {
2523
+ this.load().then((messages) => {
2524
+ this.messages = messages;
2525
+ this.requestUpdate();
2526
+ this._readStreamChunk();
2527
+ }).catch(() => {
2528
+ // Reload failed — go live anyway; the gap's completed iterations just
2529
+ // won't show until the next full reload, but the turn keeps streaming.
2530
+ this._readStreamChunk();
2531
+ });
2532
+ }
2533
+ else {
2534
+ this._readStreamChunk();
2535
+ }
2536
+ }).catch(() => {
2537
+ // Network error reaching the attach endpoint — back off and retry.
2538
+ setTimeout(() => this._reconnectStream(), 1000);
2203
2539
  });
2204
2540
  }
2205
2541
  /**
@@ -2619,6 +2955,16 @@ let KRChatbot = class KRChatbot extends LitElement {
2619
2955
  const isDebug = this._drawerView === 'debug'
2620
2956
  && this.variant === 'inline'
2621
2957
  && this._aiFrameworkConfig.debugTelemetryEnabled === true;
2958
+ let title = 'Settings';
2959
+ let body = this._renderSettingsContent();
2960
+ if (isDebug) {
2961
+ title = 'Debug';
2962
+ body = this._renderDebugContent();
2963
+ }
2964
+ else if (this._drawerView === 'toolDetail' && this._toolDetail) {
2965
+ title = this._toolDetail.use.data?.displayName || this._toolDetail.use.data?.toolName || 'Tool';
2966
+ body = this._renderToolDetailContent();
2967
+ }
2622
2968
  return html `
2623
2969
  <div
2624
2970
  class=${classMap({
@@ -2633,7 +2979,7 @@ let KRChatbot = class KRChatbot extends LitElement {
2633
2979
  @mousedown=${this._handleDrawerResizeMouseDown}
2634
2980
  ></div>
2635
2981
  <div class="drawer__header">
2636
- <span class="drawer__title">${isDebug ? 'Debug' : 'Settings'}</span>
2982
+ <span class="drawer__title">${title}</span>
2637
2983
  <button
2638
2984
  class="header-action"
2639
2985
  @click=${this._handleCloseDrawer}
@@ -2656,7 +3002,7 @@ let KRChatbot = class KRChatbot extends LitElement {
2656
3002
  </button>
2657
3003
  </div>
2658
3004
  <div class="drawer__body">
2659
- ${isDebug ? this._renderDebugContent() : this._renderSettingsContent()}
3005
+ ${body}
2660
3006
  </div>
2661
3007
  </div>
2662
3008
  `;
@@ -2690,6 +3036,129 @@ let KRChatbot = class KRChatbot extends LitElement {
2690
3036
  </div>
2691
3037
  `;
2692
3038
  }
3039
+ /**
3040
+ * The Tool detail view's body — the full input and output for one tool call,
3041
+ * pretty-printed in scrollable code blocks with a per-block copy button. This
3042
+ * is the "occasionally see everything" path: the transcript stays terse, and
3043
+ * clicking a tool line drops its complete input/output here, uncapped (the
3044
+ * blocks scroll rather than truncating).
3045
+ */
3046
+ _renderToolDetailContent() {
3047
+ return html `
3048
+ <div class="tool-detail">
3049
+ <div class="tool-detail__section">
3050
+ <button
3051
+ type="button"
3052
+ class="tool-detail__section-head"
3053
+ aria-expanded=${this._toolDetailInputOpen}
3054
+ @click=${this._handleToggleToolDetailInput}
3055
+ >
3056
+ <span class=${classMap({
3057
+ 'tool-detail__caret': true,
3058
+ 'tool-detail__caret--open': this._toolDetailInputOpen,
3059
+ })}>›</span>
3060
+ <span class="tool-detail__section-title">Input</span>
3061
+ <span
3062
+ class="tool-detail__copy"
3063
+ role="button"
3064
+ @click=${this._handleCopyToolDetailInput}
3065
+ >
3066
+ ${this._toolDetailCopied === 'input'
3067
+ ? html `
3068
+ <svg
3069
+ viewBox="0 0 24 24"
3070
+ fill="none"
3071
+ stroke="currentColor"
3072
+ stroke-width="1.5"
3073
+ stroke-linecap="round"
3074
+ stroke-linejoin="round"
3075
+ >
3076
+ <path d="M20 6 9 17l-5-5" />
3077
+ </svg>
3078
+ `
3079
+ : html `
3080
+ <svg
3081
+ viewBox="0 0 24 24"
3082
+ fill="none"
3083
+ stroke="currentColor"
3084
+ stroke-width="1.5"
3085
+ stroke-linecap="round"
3086
+ stroke-linejoin="round"
3087
+ >
3088
+ <rect
3089
+ x="9"
3090
+ y="9"
3091
+ width="13"
3092
+ height="13"
3093
+ rx="2"
3094
+ />
3095
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
3096
+ </svg>
3097
+ `}
3098
+ </span>
3099
+ </button>
3100
+ ${this._toolDetailInputOpen
3101
+ ? html `<pre class="tool-detail__code">${this._formatToolJson(this._toolDetail.use.data?.toolInput) || '(no input)'}</pre>`
3102
+ : nothing}
3103
+ </div>
3104
+ <div class="tool-detail__section">
3105
+ <button
3106
+ type="button"
3107
+ class="tool-detail__section-head"
3108
+ aria-expanded=${this._toolDetailOutputOpen}
3109
+ @click=${this._handleToggleToolDetailOutput}
3110
+ >
3111
+ <span class=${classMap({
3112
+ 'tool-detail__caret': true,
3113
+ 'tool-detail__caret--open': this._toolDetailOutputOpen,
3114
+ })}>›</span>
3115
+ <span class="tool-detail__section-title">Output</span>
3116
+ <span
3117
+ class="tool-detail__copy"
3118
+ role="button"
3119
+ @click=${this._handleCopyToolDetailOutput}
3120
+ >
3121
+ ${this._toolDetailCopied === 'output'
3122
+ ? html `
3123
+ <svg
3124
+ viewBox="0 0 24 24"
3125
+ fill="none"
3126
+ stroke="currentColor"
3127
+ stroke-width="1.5"
3128
+ stroke-linecap="round"
3129
+ stroke-linejoin="round"
3130
+ >
3131
+ <path d="M20 6 9 17l-5-5" />
3132
+ </svg>
3133
+ `
3134
+ : html `
3135
+ <svg
3136
+ viewBox="0 0 24 24"
3137
+ fill="none"
3138
+ stroke="currentColor"
3139
+ stroke-width="1.5"
3140
+ stroke-linecap="round"
3141
+ stroke-linejoin="round"
3142
+ >
3143
+ <rect
3144
+ x="9"
3145
+ y="9"
3146
+ width="13"
3147
+ height="13"
3148
+ rx="2"
3149
+ />
3150
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
3151
+ </svg>
3152
+ `}
3153
+ </span>
3154
+ </button>
3155
+ ${this._toolDetailOutputOpen
3156
+ ? html `<pre class="tool-detail__code">${this._formatToolJson(this._toolDetail.result?.data?.toolResultString) || '(no output yet)'}</pre>`
3157
+ : nothing}
3158
+ </div>
3159
+ </div>
3160
+ `;
3161
+ }
2693
3162
  /** The Debug view's body — telemetry scopes and the per-iteration table. */
2694
3163
  _renderDebugContent() {
2695
3164
  return html `
@@ -3051,8 +3520,17 @@ let KRChatbot = class KRChatbot extends LitElement {
3051
3520
  catch {
3052
3521
  formatted = String(result.toolResultString);
3053
3522
  }
3523
+ // Cap the inline preview by BOTH lines and characters. Line-only
3524
+ // truncation blows up on results whose first line is huge (e.g. a
3525
+ // minified-JSON blob or a stashed-response marker with a 30k-char
3526
+ // opening line) — 3 lines then still dumps tens of thousands of
3527
+ // chars into the transcript. Char-cap first (content-blind, like
3528
+ // the backend chokepoint), then keep at most 3 lines of that.
3054
3529
  const lines = formatted.split('\n');
3055
3530
  resultPreview = lines.slice(0, 3).join('\n');
3531
+ if (resultPreview.length > 200) {
3532
+ resultPreview = `${resultPreview.slice(0, 200)}…`;
3533
+ }
3056
3534
  resultRemainder = Math.max(0, lines.length - 3);
3057
3535
  }
3058
3536
  }
@@ -3081,24 +3559,32 @@ let KRChatbot = class KRChatbot extends LitElement {
3081
3559
  })}>
3082
3560
  ${msg.type === 'tool_use' ? html `
3083
3561
  <div class="tool-activity-block">
3084
- <div class=${classMap({
3562
+ <button
3563
+ type="button"
3564
+ class=${classMap({
3085
3565
  'tool-activity': true,
3086
3566
  'tool-activity--error': result?.toolResultStatus === 'error',
3087
- })}>
3567
+ })}
3568
+ @click=${() => this._openToolDetail(msg)}
3569
+ >
3088
3570
  <span class="tool-activity__chevron">›</span>
3089
3571
  <span class="tool-activity__label">${msg.data?.displayName || msg.data?.toolName || 'Tool'}</span>
3090
3572
  ${inputSummary ? html `<span class="tool-activity__input">(${inputSummary})</span>` : nothing}
3091
- </div>
3573
+ </button>
3092
3574
  ${this._showToolInputs && result?.toolResultString ? html `
3093
- <div class=${classMap({
3575
+ <button
3576
+ type="button"
3577
+ class=${classMap({
3094
3578
  'tool-activity__result': true,
3095
3579
  'tool-activity__result--error': result.toolResultStatus === 'error',
3096
- })}>
3580
+ })}
3581
+ @click=${() => this._openToolDetail(msg)}
3582
+ >
3097
3583
  <span class="tool-activity__connector">⎿</span>
3098
3584
  <pre class="tool-activity__result-body">${resultPreview}${resultRemainder > 0
3099
3585
  ? html `\n<span class="tool-activity__more">… +${resultRemainder} lines</span>`
3100
3586
  : nothing}</pre>
3101
- </div>
3587
+ </button>
3102
3588
  ` : nothing}
3103
3589
  </div>
3104
3590
  ` : nothing}
@@ -3703,6 +4189,102 @@ KRChatbot.styles = css `
3703
4189
  padding: 6px 0;
3704
4190
  }
3705
4191
 
4192
+ /* Tool detail view — the full input/output for one tool call. */
4193
+ .tool-detail {
4194
+ display: flex;
4195
+ flex-direction: column;
4196
+ gap: 24px;
4197
+ padding: 16px 20px;
4198
+ }
4199
+
4200
+ .tool-detail__section {
4201
+ display: flex;
4202
+ flex-direction: column;
4203
+ gap: 8px;
4204
+ min-width: 0;
4205
+ }
4206
+
4207
+ /* Clickable section header — toggles the code block below. Button chrome is
4208
+ stripped so it reads as a heading row. */
4209
+ .tool-detail__section-head {
4210
+ display: flex;
4211
+ align-items: center;
4212
+ gap: 8px;
4213
+ appearance: none;
4214
+ background: none;
4215
+ border: none;
4216
+ width: 100%;
4217
+ font-family: inherit;
4218
+ text-align: left;
4219
+ cursor: pointer;
4220
+ border-radius: 4px;
4221
+ }
4222
+
4223
+ .tool-detail__section-head:focus-visible {
4224
+ outline: 2px solid var(--kr-chatbot-primary);
4225
+ outline-offset: 2px;
4226
+ }
4227
+
4228
+ /* Disclosure caret — the same "›" used on tool lines; rotates down when open. */
4229
+ .tool-detail__caret {
4230
+ flex-shrink: 0;
4231
+ font-size: 16px;
4232
+ color: rgba(0, 0, 0, 0.55);
4233
+ transition: transform 0.15s ease;
4234
+ }
4235
+
4236
+ .tool-detail__caret--open {
4237
+ transform: rotate(90deg);
4238
+ }
4239
+
4240
+ .tool-detail__section-title {
4241
+ font-size: 12px;
4242
+ font-weight: 700;
4243
+ text-transform: uppercase;
4244
+ letter-spacing: 0.06em;
4245
+ color: #000000;
4246
+ }
4247
+
4248
+ /* Copy icon-button — same treatment as the per-message copy button. */
4249
+ .tool-detail__copy {
4250
+ margin-left: auto;
4251
+ display: inline-flex;
4252
+ align-items: center;
4253
+ justify-content: center;
4254
+ width: 26px;
4255
+ height: 26px;
4256
+ padding: 0;
4257
+ background: none;
4258
+ border: none;
4259
+ border-radius: 6px;
4260
+ color: #000000;
4261
+ cursor: pointer;
4262
+ transition: background-color 0.15s, color 0.15s;
4263
+ }
4264
+
4265
+ .tool-detail__copy:hover {
4266
+ background: rgba(0, 0, 0, 0.06);
4267
+ }
4268
+
4269
+ .tool-detail__copy svg {
4270
+ width: 14px;
4271
+ height: 14px;
4272
+ }
4273
+
4274
+ /* The code block — pretty-printed JSON, shown in full (no inner scroll). The
4275
+ drawer body is the only scroll container, so long inputs/outputs extend the
4276
+ page and you scroll the panel, not a nested box. */
4277
+ .tool-detail__code {
4278
+ margin: 0;
4279
+ padding: 0 18px;
4280
+ font-family: 'SF Mono', 'Fira Code', Monaco, monospace;
4281
+ font-size: 12px;
4282
+ line-height: 1.5;
4283
+ color: #000000;
4284
+ white-space: pre-wrap;
4285
+ word-break: break-word;
4286
+ }
4287
+
3706
4288
  /* Messages */
3707
4289
  /* Full-width scroll container — keeps the scrollbar at the panel edge. */
3708
4290
  .messages {
@@ -3799,6 +4381,9 @@ KRChatbot.styles = css `
3799
4381
  /* Tool-activity line — "the assistant ran X" row in the transcript. Same text
3800
4382
  style as any other message (black, 14px); a leading › chevron marks it as a
3801
4383
  step (in the same text style, not an icon). */
4384
+ /* The tool-activity line and its result preview are <button>s (they open the
4385
+ tool-detail drawer on click) — strip the native button chrome so they read
4386
+ as plain transcript text, then re-add the layout. */
3802
4387
  .tool-activity {
3803
4388
  align-self: flex-start;
3804
4389
  display: flex;
@@ -3808,6 +4393,22 @@ KRChatbot.styles = css `
3808
4393
  color: #000000;
3809
4394
  /* Keep the chevron + name + input on a single line; input truncates if long. */
3810
4395
  white-space: nowrap;
4396
+ /* Button reset */
4397
+ appearance: none;
4398
+ background: none;
4399
+ border: none;
4400
+ margin: 0;
4401
+ padding: 2px 4px;
4402
+ font-family: inherit;
4403
+ text-align: left;
4404
+ cursor: pointer;
4405
+ border-radius: 4px;
4406
+ max-width: 100%;
4407
+ }
4408
+
4409
+ .tool-activity:focus-visible {
4410
+ outline: 2px solid var(--kr-chatbot-primary);
4411
+ outline-offset: 1px;
3811
4412
  }
3812
4413
 
3813
4414
  .tool-activity--error {
@@ -3843,6 +4444,16 @@ KRChatbot.styles = css `
3843
4444
  gap: 4px;
3844
4445
  }
3845
4446
 
4447
+ /* One hover for the whole tool block: hovering anywhere over the header or its
4448
+ result underlines the tool name, input, and result together as a single
4449
+ link, rather than lighting up header and result separately. */
4450
+ .tool-activity-block:hover .tool-activity__label,
4451
+ .tool-activity-block:hover .tool-activity__input,
4452
+ .tool-activity-block:hover .tool-activity__result-body {
4453
+ text-decoration: underline;
4454
+ text-decoration-color: rgba(0, 0, 0, 0.25);
4455
+ }
4456
+
3846
4457
  /* The result block indented under the activity line with a ⎿ tree connector,
3847
4458
  like Claude CLI's tool output preview. Uses the normal message text style
3848
4459
  (14px, black) — only the ⎿ connector is muted. */
@@ -3858,6 +4469,20 @@ KRChatbot.styles = css `
3858
4469
  margin-left: 6px;
3859
4470
  font-size: 14px;
3860
4471
  color: #000000;
4472
+ /* Button reset — this preview row is also a click target for the detail view. */
4473
+ appearance: none;
4474
+ background: none;
4475
+ border: none;
4476
+ padding: 0;
4477
+ font-family: inherit;
4478
+ text-align: left;
4479
+ cursor: pointer;
4480
+ }
4481
+
4482
+ .tool-activity__result:focus-visible {
4483
+ outline: 2px solid var(--kr-chatbot-primary);
4484
+ outline-offset: 1px;
4485
+ border-radius: 4px;
3861
4486
  }
3862
4487
 
3863
4488
  .tool-activity__result--error {
@@ -5323,6 +5948,18 @@ __decorate([
5323
5948
  __decorate([
5324
5949
  state()
5325
5950
  ], KRChatbot.prototype, "_drawerView", void 0);
5951
+ __decorate([
5952
+ state()
5953
+ ], KRChatbot.prototype, "_toolDetail", void 0);
5954
+ __decorate([
5955
+ state()
5956
+ ], KRChatbot.prototype, "_toolDetailCopied", void 0);
5957
+ __decorate([
5958
+ state()
5959
+ ], KRChatbot.prototype, "_toolDetailInputOpen", void 0);
5960
+ __decorate([
5961
+ state()
5962
+ ], KRChatbot.prototype, "_toolDetailOutputOpen", void 0);
5326
5963
  __decorate([
5327
5964
  state()
5328
5965
  ], KRChatbot.prototype, "_drawerWidth", void 0);
@@ -5404,6 +6041,9 @@ __decorate([
5404
6041
  __decorate([
5405
6042
  query('.skill-menu')
5406
6043
  ], KRChatbot.prototype, "_skillMenuEl", void 0);
6044
+ __decorate([
6045
+ query('.drawer__body')
6046
+ ], KRChatbot.prototype, "_drawerBodyEl", void 0);
5407
6047
  KRChatbot = __decorate([
5408
6048
  customElement('kr-chatbot')
5409
6049
  ], KRChatbot);