@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.
@@ -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;
@@ -6127,6 +6141,20 @@
6127
6141
  data,
6128
6142
  });
6129
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
+ }
6130
6158
  // A persisted approve/deny record — content is {outcome, tools:[...]}.
6131
6159
  // Keep the type, parse content into `data`; the render reads msg.data.*.
6132
6160
  if (msg.type === 'action_decision') {
@@ -6169,6 +6197,16 @@
6169
6197
  if (messages && messages.length) {
6170
6198
  this.messages = messages;
6171
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
+ }
6172
6210
  }).catch(() => { });
6173
6211
  // Fetch the framework config on load (debugTelemetryEnabled, v2ChatEnabled,
6174
6212
  // registryServer). debugTelemetryEnabled gates the debug affordances and is
@@ -7208,6 +7246,10 @@
7208
7246
  content: '',
7209
7247
  }];
7210
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;
7211
7253
  // Scroll user's message to the top (Gemini-style). The runway (min-height =
7212
7254
  // viewport) goes on the agent-turn WRAPPER, not an individual message — the
7213
7255
  // whole turn (reasoning, tool lines, text, decision) streams inside that one
@@ -7418,6 +7460,10 @@
7418
7460
  const btn = this._messagesEl?.querySelector(`.confirmation__btn[data-decision="${decision}"]`);
7419
7461
  btn?.showLoading();
7420
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;
7421
7467
  this._userHasScrolledUp = false;
7422
7468
  this.send({
7423
7469
  message: '',
@@ -7720,11 +7766,18 @@
7720
7766
  }
7721
7767
  this._reader.read().then((result) => {
7722
7768
  if (result.done) {
7723
- // Fallback unlock: the stream closed without a `finished:true` chunk
7724
- // (network drop, server crash, the 5-min emitter timeout). Treat it as
7725
- // the turn ending so a dropped stream never wedges the composer — the
7726
- // server self-heals its turn lock and bounces any premature send.
7727
- 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();
7728
7781
  return;
7729
7782
  }
7730
7783
  this._buffer = this._buffer + this._decoder.decode(result.value, { stream: true });
@@ -7877,7 +7930,9 @@
7877
7930
  }
7878
7931
  if (data.finished) {
7879
7932
  // Primary unlock signal: the turn is over and the server released
7880
- // 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;
7881
7936
  this._finishStream();
7882
7937
  }
7883
7938
  }
@@ -7889,9 +7944,110 @@
7889
7944
  }
7890
7945
  this._readStreamChunk();
7891
7946
  }).catch((err) => {
7892
- if (err.name !== 'AbortError') {
7893
- 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;
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();
7894
8047
  }
8048
+ }).catch(() => {
8049
+ // Network error reaching the attach endpoint — back off and retry.
8050
+ setTimeout(() => this._reconnectStream(), 1000);
7895
8051
  });
7896
8052
  }
7897
8053
  /**