@kodaris/krubble-app-components 1.0.117 → 1.0.119

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.
@@ -2481,8 +2481,7 @@ KRScreenNav.styles = i$6 `
2481
2481
 
2482
2482
  .nav {
2483
2483
  width: 200px;
2484
- background: white;
2485
- border-right: 1px solid #e5e7eb;
2484
+ background: #f3f7fcbf;
2486
2485
  display: flex;
2487
2486
  flex-direction: column;
2488
2487
  flex-shrink: 0;
@@ -5665,6 +5664,21 @@ let KRChatbot = class KRChatbot extends i$3 {
5665
5664
  this._reader = null;
5666
5665
  this._decoder = new TextDecoder();
5667
5666
  this._buffer = '';
5667
+ /**
5668
+ * True once the current turn ended cleanly (a `finished:true` chunk arrived).
5669
+ * Distinguishes a real completion from a dropped stream: if the SSE stream
5670
+ * closes (`result.done`) while this is still false, the turn is NOT over — the
5671
+ * connection dropped (5-min emitter timeout, tab was backgrounded, network
5672
+ * blip) — so we reconnect instead of unlocking the composer. Reset per turn.
5673
+ */
5674
+ this._turnFinishedCleanly = false;
5675
+ /**
5676
+ * Consecutive failed reconnect attempts for the current drop. Bounds the retry
5677
+ * loop so a genuinely dead turn (server gone) can't spin forever — after the
5678
+ * cap we give up and finish the turn. Reset when a reconnect succeeds or a new
5679
+ * turn starts.
5680
+ */
5681
+ this._reconnectAttempts = 0;
5668
5682
  this._userHasScrolledUp = false;
5669
5683
  this._isDragging = false;
5670
5684
  this._isResizing = false;
@@ -6124,6 +6138,20 @@ let KRChatbot = class KRChatbot extends i$3 {
6124
6138
  data,
6125
6139
  });
6126
6140
  }
6141
+ // Persisted reasoning row — the DB stores the model's thinking as its own
6142
+ // row with the text in `content` and type "reasoning". Live, reasoning
6143
+ // instead arrives as REASONING_PART deltas accumulated onto an assistant
6144
+ // bubble's `reasoning` property, which is the ONLY shape the render shows
6145
+ // (via msg.reasoning). Map the DB row into that same shape — an assistant
6146
+ // bubble carrying the text in `reasoning` — so reloaded/reconciled
6147
+ // reasoning renders identically to live instead of vanishing.
6148
+ if (msg.type === 'reasoning') {
6149
+ return Promise.resolve({
6150
+ type: 'assistant',
6151
+ content: '',
6152
+ reasoning: msg.content || '',
6153
+ });
6154
+ }
6127
6155
  // A persisted approve/deny record — content is {outcome, tools:[...]}.
6128
6156
  // Keep the type, parse content into `data`; the render reads msg.data.*.
6129
6157
  if (msg.type === 'action_decision') {
@@ -6166,6 +6194,16 @@ let KRChatbot = class KRChatbot extends i$3 {
6166
6194
  if (messages && messages.length) {
6167
6195
  this.messages = messages;
6168
6196
  }
6197
+ // A turn may still be running server-side for this conversation (the page was
6198
+ // reloaded mid-turn, or opened on another device while a turn streams). After
6199
+ // the transcript loads, try to attach to that live turn: _reconnectStream hits
6200
+ // the read-only attach endpoint — 200 resumes streaming (locks the composer +
6201
+ // shows the typing indicator so the user knows the AI is still working), 204
6202
+ // means nothing is running (stays idle). Only meaningful with a pinned
6203
+ // conversation and the built-in stream (a custom `send` owns its own turns).
6204
+ if (this.conversationId && !this._isStreaming) {
6205
+ this._reconnectStream();
6206
+ }
6169
6207
  }).catch(() => { });
6170
6208
  // Fetch the framework config on load (debugTelemetryEnabled, v2ChatEnabled,
6171
6209
  // registryServer). debugTelemetryEnabled gates the debug affordances and is
@@ -7205,6 +7243,10 @@ let KRChatbot = class KRChatbot extends i$3 {
7205
7243
  content: '',
7206
7244
  }];
7207
7245
  this._isStreaming = true;
7246
+ // Fresh turn: not yet finished, no reconnects used. These gate the drop-vs-done
7247
+ // decision in _readStreamChunk when the stream closes.
7248
+ this._turnFinishedCleanly = false;
7249
+ this._reconnectAttempts = 0;
7208
7250
  // Scroll user's message to the top (Gemini-style). The runway (min-height =
7209
7251
  // viewport) goes on the agent-turn WRAPPER, not an individual message — the
7210
7252
  // whole turn (reasoning, tool lines, text, decision) streams inside that one
@@ -7415,6 +7457,10 @@ let KRChatbot = class KRChatbot extends i$3 {
7415
7457
  const btn = this._messagesEl?.querySelector(`.confirmation__btn[data-decision="${decision}"]`);
7416
7458
  btn?.showLoading();
7417
7459
  this._isStreaming = true;
7460
+ // Fresh turn (confirmation decision runs a model turn too): reset the
7461
+ // drop-vs-done gates used by _readStreamChunk.
7462
+ this._turnFinishedCleanly = false;
7463
+ this._reconnectAttempts = 0;
7418
7464
  this._userHasScrolledUp = false;
7419
7465
  this.send({
7420
7466
  message: '',
@@ -7717,11 +7763,18 @@ let KRChatbot = class KRChatbot extends i$3 {
7717
7763
  }
7718
7764
  this._reader.read().then((result) => {
7719
7765
  if (result.done) {
7720
- // Fallback unlock: the stream closed without a `finished:true` chunk
7721
- // (network drop, server crash, the 5-min emitter timeout). Treat it as
7722
- // the turn ending so a dropped stream never wedges the composer — the
7723
- // server self-heals its turn lock and bounces any premature send.
7724
- this._finishStream();
7766
+ // The stream closed. Two very different cases:
7767
+ // 1. A `finished:true` chunk already arrived (_turnFinishedCleanly) — the
7768
+ // turn is genuinely over; finishing was already handled below. Nothing to do.
7769
+ // 2. No finished chunk yet — the connection DROPPED mid-turn (5-min emitter
7770
+ // timeout, tab backgrounded, network blip) but the server-side turn is
7771
+ // still running. Do NOT unlock the composer (that's the old bug that made
7772
+ // the input look ready while the AI was still working). Reconnect to the
7773
+ // live stream and keep receiving events.
7774
+ if (this._turnFinishedCleanly) {
7775
+ return;
7776
+ }
7777
+ this._reconnectStream();
7725
7778
  return;
7726
7779
  }
7727
7780
  this._buffer = this._buffer + this._decoder.decode(result.value, { stream: true });
@@ -7874,7 +7927,9 @@ let KRChatbot = class KRChatbot extends i$3 {
7874
7927
  }
7875
7928
  if (data.finished) {
7876
7929
  // Primary unlock signal: the turn is over and the server released
7877
- // the turn lock. The SSE stream closes right after this chunk.
7930
+ // the turn lock. The SSE stream closes right after this chunk. Mark
7931
+ // it clean so the imminent `result.done` doesn't trigger a reconnect.
7932
+ this._turnFinishedCleanly = true;
7878
7933
  this._finishStream();
7879
7934
  }
7880
7935
  }
@@ -7886,9 +7941,110 @@ let KRChatbot = class KRChatbot extends i$3 {
7886
7941
  }
7887
7942
  this._readStreamChunk();
7888
7943
  }).catch((err) => {
7889
- if (err.name !== 'AbortError') {
7890
- this._handleStreamError(err.message);
7944
+ if (err.name === 'AbortError') {
7945
+ return;
7946
+ }
7947
+ // A read error mid-turn is a drop, not a completion — reconnect (same as a
7948
+ // clean `done` without a finished chunk) unless the turn already finished.
7949
+ if (!this._turnFinishedCleanly) {
7950
+ this._reconnectStream();
7951
+ return;
7952
+ }
7953
+ this._handleStreamError(err.message);
7954
+ });
7955
+ }
7956
+ /**
7957
+ * Reconnect to an in-flight turn after the SSE stream dropped without finishing
7958
+ * (5-min emitter timeout, tab backgrounded, network blip). Hits the read-only
7959
+ * `/stream/attach/{conversationID}` endpoint, which re-attaches a fresh SSE
7960
+ * connection to the still-running server-side turn without starting anything new.
7961
+ *
7962
+ * - 200 with a body → resume reading; the turn's events keep flowing (events
7963
+ * emitted during the gap are not replayed, so the live text may jump forward —
7964
+ * the full transcript reconciles from the DB when the turn ends).
7965
+ * - 204 → no live turn: it already finished server-side while we were
7966
+ * disconnected. Reload the transcript so the final answer appears, then unlock.
7967
+ * - failure / retry cap hit → give up and finish so the composer never wedges.
7968
+ *
7969
+ * The composer stays locked (`_isStreaming` untouched) throughout, so the input
7970
+ * never looks ready while the AI is still working.
7971
+ */
7972
+ _reconnectStream() {
7973
+ // Without a pinned conversation there's nothing to re-attach to (a custom
7974
+ // `send` owns its own targeting). Fall back to finishing.
7975
+ if (!this.conversationId) {
7976
+ this._finishStream();
7977
+ return;
7978
+ }
7979
+ // Bound the retries so a genuinely dead turn can't loop forever.
7980
+ if (this._reconnectAttempts >= 5) {
7981
+ this._handleStreamError('Lost connection to the assistant. Please reload the conversation.');
7982
+ return;
7983
+ }
7984
+ this._reconnectAttempts = this._reconnectAttempts + 1;
7985
+ KRHttp.fetch({
7986
+ url: `/api/system/integration/aws/bedrock/stream/attach/${this.conversationId}`,
7987
+ method: 'GET',
7988
+ headers: { Accept: 'text/event-stream' },
7989
+ }).then((response) => {
7990
+ // 204 No Content: the turn finished server-side during the gap. Pull the
7991
+ // final transcript from the DB (via the same `load` callback the component
7992
+ // initializes with) so the answer that streamed during the disconnect
7993
+ // appears, then unlock.
7994
+ if (response.status === 204) {
7995
+ this._reconnectAttempts = 0;
7996
+ if (this.load) {
7997
+ this.load().then((messages) => {
7998
+ this.messages = messages;
7999
+ this.requestUpdate();
8000
+ }).catch(() => {
8001
+ // Reload failed — leave the transcript as-is; the user can refresh manually.
8002
+ });
8003
+ }
8004
+ this._finishStream();
8005
+ return;
8006
+ }
8007
+ if (!response.ok || !response.body) {
8008
+ // Transient failure — back off and try again on the same drop.
8009
+ setTimeout(() => this._reconnectStream(), 1000);
8010
+ return;
8011
+ }
8012
+ // Re-attached to the live turn: reset the retry counter and resume reading
8013
+ // into the same buffer/decoder from the fresh stream. Mark streaming so the
8014
+ // composer locks and the typing indicator shows — load-triggered attaches (a
8015
+ // page reloaded mid-turn) start with _isStreaming false, so this is what
8016
+ // surfaces "the AI is still working"; on a mid-session drop it's already true
8017
+ // (a no-op).
8018
+ this._reconnectAttempts = 0;
8019
+ this._isStreaming = true;
8020
+ this._reader = response.body.getReader();
8021
+ this._decoder = new TextDecoder();
8022
+ this._buffer = '';
8023
+ // Close the gap: events the turn emitted between the drop and this re-attach
8024
+ // aren't replayed on the live stream. Every COMPLETED iteration during the
8025
+ // gap was persisted (assistant text, tool_use/tool_result rows), so reload the
8026
+ // transcript from the DB to backfill them, THEN start reading live. Ordering
8027
+ // matters — the reader buffers incoming bytes until _readStreamChunk runs, so
8028
+ // starting live only after the async reload resolves prevents the reload from
8029
+ // clobbering freshly-streamed chunks. Only the current in-flight iteration's
8030
+ // partial text is missing from the reload; it streams live from here.
8031
+ if (this.load) {
8032
+ this.load().then((messages) => {
8033
+ this.messages = messages;
8034
+ this.requestUpdate();
8035
+ this._readStreamChunk();
8036
+ }).catch(() => {
8037
+ // Reload failed — go live anyway; the gap's completed iterations just
8038
+ // won't show until the next full reload, but the turn keeps streaming.
8039
+ this._readStreamChunk();
8040
+ });
8041
+ }
8042
+ else {
8043
+ this._readStreamChunk();
7891
8044
  }
8045
+ }).catch(() => {
8046
+ // Network error reaching the attach endpoint — back off and retry.
8047
+ setTimeout(() => this._reconnectStream(), 1000);
7892
8048
  });
7893
8049
  }
7894
8050
  /**