@runtypelabs/persona 4.6.0 → 4.7.0

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/src/ui.ts CHANGED
@@ -3017,6 +3017,48 @@ export const createAgentExperience = (
3017
3017
  ? stripStreamingFromMessages(session.getMessages()).filter(msg => !(msg as any).__skipPersist)
3018
3018
  : [];
3019
3019
 
3020
+ let storageMutationTail: Promise<void> | null = null;
3021
+
3022
+ const reportStorageMutationError = (label: string, error: unknown) => {
3023
+ if (typeof console !== "undefined") {
3024
+ // eslint-disable-next-line no-console
3025
+ console.error(label, error);
3026
+ }
3027
+ };
3028
+
3029
+ const runStorageMutation = (
3030
+ mutation: () => void | Promise<void>,
3031
+ errorLabel: string
3032
+ ) => {
3033
+ const run = (): Promise<void> | null => {
3034
+ try {
3035
+ const result = mutation();
3036
+ if (!result || typeof (result as PromiseLike<void>).then !== "function") {
3037
+ return null;
3038
+ }
3039
+ return Promise.resolve(result).catch((error) => {
3040
+ reportStorageMutationError(errorLabel, error);
3041
+ });
3042
+ } catch (error) {
3043
+ reportStorageMutationError(errorLabel, error);
3044
+ return null;
3045
+ }
3046
+ };
3047
+
3048
+ const prior = storageMutationTail;
3049
+ const next = prior
3050
+ ? prior.then(() => run() ?? undefined)
3051
+ : run();
3052
+ if (!next) return;
3053
+
3054
+ const tail = next.finally(() => {
3055
+ if (storageMutationTail === tail) {
3056
+ storageMutationTail = null;
3057
+ }
3058
+ });
3059
+ storageMutationTail = tail;
3060
+ };
3061
+
3020
3062
  function persistState(messagesOverride?: AgentWidgetMessage[]) {
3021
3063
  if (!storageAdapter?.save) return;
3022
3064
 
@@ -3033,22 +3075,10 @@ export const createAgentExperience = (
3033
3075
  artifacts: lastArtifactsState.artifacts,
3034
3076
  selectedArtifactId: lastArtifactsState.selectedId
3035
3077
  };
3036
- try {
3037
- const result = storageAdapter.save(payload);
3038
- if (result instanceof Promise) {
3039
- result.catch((error) => {
3040
- if (typeof console !== "undefined") {
3041
- // eslint-disable-next-line no-console
3042
- console.error("[AgentWidget] Failed to persist state:", error);
3043
- }
3044
- });
3045
- }
3046
- } catch (error) {
3047
- if (typeof console !== "undefined") {
3048
- // eslint-disable-next-line no-console
3049
- console.error("[AgentWidget] Failed to persist state:", error);
3050
- }
3051
- }
3078
+ runStorageMutation(
3079
+ () => storageAdapter.save!(payload),
3080
+ "[AgentWidget] Failed to persist state:"
3081
+ );
3052
3082
  }
3053
3083
 
3054
3084
  // Track ongoing smooth scroll animation
@@ -5181,67 +5211,180 @@ export const createAgentExperience = (
5181
5211
  }, 100);
5182
5212
  };
5183
5213
 
5184
- session = new AgentWidgetSession(config, {
5185
- onMessagesChanged(messages) {
5186
- renderMessagesWithPlugins(messagesWrapper, messages, postprocess);
5187
- // Start elapsed timer if any active tool has a live duration span
5188
- ensureToolElapsedTimer();
5189
- // Re-render suggestions: agent chips vs config chips, one shared rule.
5190
- // Pass messages directly to avoid calling session.getMessages() during construction
5191
- renderSuggestions(messages);
5192
- scheduleAutoScroll(!isStreaming);
5193
- trackMessages(messages);
5194
-
5195
- const lastUserMessage = [...messages]
5196
- .reverse()
5197
- .find((msg) => msg.role === "user");
5198
- const lastAssistantMessage = [...messages]
5199
- .reverse()
5200
- .find((msg) => msg.role === "assistant");
5201
-
5202
- // Scroll-on-send / anchor-top. Seeded so restored history (constructor
5203
- // initialMessages and async storage hydration) never reads as a fresh
5204
- // send; clearing the chat resets any anchor spacer.
5205
- if (messages.length === 0) {
5206
- resetAnchorState();
5207
- // Cleared: nothing anchored, so re-arm the no-anchor follow fallback.
5208
- followFallbackActive = true;
5209
- currentTurnAnchored = false;
5210
- }
5211
- if (!scrollSendSeeded || suppressScrollSend) {
5212
- scrollSendSeeded = true;
5213
- lastSentUserMessageId = lastUserMessage?.id ?? null;
5214
- // Seed assistant-turn tracking too, so restored history doesn't read
5215
- // as a fresh assistant turn and trigger the no-anchor fallback.
5216
- lastHandledAssistantId = lastAssistantMessage?.id ?? null;
5217
- } else if (lastUserMessage && lastUserMessage.id !== lastSentUserMessageId) {
5218
- lastSentUserMessageId = lastUserMessage.id;
5219
- handleUserMessageSent(lastUserMessage.id);
5220
- } else if (
5221
- lastAssistantMessage &&
5222
- lastAssistantMessage.id !== lastHandledAssistantId
5223
- ) {
5224
- // A new assistant turn with no fresh user send: the anchor-top
5225
- // no-anchor fallback (proactive/injected/resubmit/first-load streaming).
5226
- handleAssistantTurnStarted();
5227
- }
5228
- if (lastAssistantMessage) {
5229
- lastHandledAssistantId = lastAssistantMessage.id;
5214
+ const isDeepEqual = (left: unknown, right: unknown): boolean => {
5215
+ if (Object.is(left, right)) return true;
5216
+ if (left === null || right === null) return false;
5217
+ if (typeof left !== "object" || typeof right !== "object") return false;
5218
+ if (Array.isArray(left) || Array.isArray(right)) {
5219
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) {
5220
+ return false;
5230
5221
  }
5222
+ return left.every((value, index) => isDeepEqual(value, right[index]));
5223
+ }
5224
+ const leftRecord = left as Record<string, unknown>;
5225
+ const rightRecord = right as Record<string, unknown>;
5226
+ const leftKeys = Object.keys(leftRecord);
5227
+ const rightKeys = Object.keys(rightRecord);
5228
+ return (
5229
+ leftKeys.length === rightKeys.length &&
5230
+ leftKeys.every(
5231
+ (key) =>
5232
+ Object.prototype.hasOwnProperty.call(rightRecord, key) &&
5233
+ isDeepEqual(leftRecord[key], rightRecord[key])
5234
+ )
5235
+ );
5236
+ };
5237
+
5238
+ const withoutStreamingText = (message: AgentWidgetMessage) => {
5239
+ const { content: _content, rawContent: _rawContent, llmContent: _llmContent, ...rest } = message;
5240
+ return rest;
5241
+ };
5231
5242
 
5232
- // Emit user:message event when a new user message is detected
5233
- const prevLastUserMessageId = voiceState.lastUserMessageId;
5234
- if (lastUserMessage && lastUserMessage.id !== prevLastUserMessageId) {
5235
- voiceState.lastUserMessageId = lastUserMessage.id;
5236
- eventBus.emit("user:message", lastUserMessage);
5243
+ const isPlainStreamingAssistant = (message: AgentWidgetMessage) =>
5244
+ message.role === "assistant" &&
5245
+ message.streaming === true &&
5246
+ !message.variant &&
5247
+ !message.toolCall &&
5248
+ !message.tools &&
5249
+ !message.approval &&
5250
+ !message.reasoning &&
5251
+ !message.contentParts &&
5252
+ !message.stopReason &&
5253
+ !hasComponentDirective(message);
5254
+
5255
+ const isPureStreamingTextProgression = (
5256
+ previous: AgentWidgetMessage[],
5257
+ next: AgentWidgetMessage[],
5258
+ candidate: { index: number; id: string }
5259
+ ) => {
5260
+ if (previous.length !== next.length) return false;
5261
+ const before = previous[candidate.index];
5262
+ const after = next[candidate.index];
5263
+ if (!before || !after || before.id !== candidate.id || after.id !== candidate.id) {
5264
+ return false;
5265
+ }
5266
+ const textChanged =
5267
+ !Object.is(before.content, after.content) ||
5268
+ !Object.is(before.rawContent, after.rawContent) ||
5269
+ !Object.is(before.llmContent, after.llmContent);
5270
+ return (
5271
+ textChanged &&
5272
+ isPlainStreamingAssistant(before) &&
5273
+ isPlainStreamingAssistant(after) &&
5274
+ isDeepEqual(withoutStreamingText(before), withoutStreamingText(after))
5275
+ );
5276
+ };
5277
+
5278
+ let lastAppliedMessages: AgentWidgetMessage[] | null = null;
5279
+ let activeStreamingTextCandidate: { index: number; id: string } | null = null;
5280
+ let pendingStreamingTextMessages: AgentWidgetMessage[] | null = null;
5281
+ let streamingTextRAF: number | null = null;
5282
+
5283
+ const applyMessagesChanged = (messages: AgentWidgetMessage[]) => {
5284
+ lastAppliedMessages = messages;
5285
+ let lastUserMessage: AgentWidgetMessage | undefined;
5286
+ let lastAssistantMessage: AgentWidgetMessage | undefined;
5287
+ activeStreamingTextCandidate = null;
5288
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
5289
+ const message = messages[index];
5290
+ if (!lastUserMessage && message.role === "user") lastUserMessage = message;
5291
+ if (!lastAssistantMessage && message.role === "assistant") {
5292
+ lastAssistantMessage = message;
5293
+ }
5294
+ if (!activeStreamingTextCandidate && isPlainStreamingAssistant(message)) {
5295
+ activeStreamingTextCandidate = { index, id: message.id };
5296
+ }
5297
+ if (lastUserMessage && lastAssistantMessage && activeStreamingTextCandidate) break;
5298
+ }
5299
+ renderMessagesWithPlugins(messagesWrapper, messages, postprocess);
5300
+ ensureToolElapsedTimer();
5301
+ renderSuggestions(messages);
5302
+ scheduleAutoScroll(!isStreaming);
5303
+ trackMessages(messages);
5304
+
5305
+ if (messages.length === 0) {
5306
+ resetAnchorState();
5307
+ followFallbackActive = true;
5308
+ currentTurnAnchored = false;
5309
+ }
5310
+ if (!scrollSendSeeded || suppressScrollSend) {
5311
+ scrollSendSeeded = true;
5312
+ lastSentUserMessageId = lastUserMessage?.id ?? null;
5313
+ lastHandledAssistantId = lastAssistantMessage?.id ?? null;
5314
+ } else if (lastUserMessage && lastUserMessage.id !== lastSentUserMessageId) {
5315
+ lastSentUserMessageId = lastUserMessage.id;
5316
+ handleUserMessageSent(lastUserMessage.id);
5317
+ } else if (
5318
+ lastAssistantMessage &&
5319
+ lastAssistantMessage.id !== lastHandledAssistantId
5320
+ ) {
5321
+ handleAssistantTurnStarted();
5322
+ }
5323
+ if (lastAssistantMessage) lastHandledAssistantId = lastAssistantMessage.id;
5324
+
5325
+ const prevLastUserMessageId = voiceState.lastUserMessageId;
5326
+ if (lastUserMessage && lastUserMessage.id !== prevLastUserMessageId) {
5327
+ voiceState.lastUserMessageId = lastUserMessage.id;
5328
+ eventBus.emit("user:message", lastUserMessage);
5329
+ }
5330
+
5331
+ voiceState.lastUserMessageWasVoice = Boolean(lastUserMessage?.viaVoice);
5332
+ persistState(messages);
5333
+ syncComposerBarPeek();
5334
+ };
5335
+
5336
+ const flushPendingStreamingText = () => {
5337
+ if (streamingTextRAF !== null) {
5338
+ cancelAnimationFrame(streamingTextRAF);
5339
+ streamingTextRAF = null;
5340
+ }
5341
+ const pending = pendingStreamingTextMessages;
5342
+ pendingStreamingTextMessages = null;
5343
+ if (pending) applyMessagesChanged(pending);
5344
+ };
5345
+
5346
+ const discardPendingStreamingText = () => {
5347
+ if (streamingTextRAF !== null) cancelAnimationFrame(streamingTextRAF);
5348
+ streamingTextRAF = null;
5349
+ pendingStreamingTextMessages = null;
5350
+ };
5351
+
5352
+ const handleMessagesChanged = (messages: AgentWidgetMessage[]) => {
5353
+ const comparison = pendingStreamingTextMessages ?? lastAppliedMessages;
5354
+ if (
5355
+ isStreaming &&
5356
+ comparison &&
5357
+ activeStreamingTextCandidate &&
5358
+ isPureStreamingTextProgression(
5359
+ comparison,
5360
+ messages,
5361
+ activeStreamingTextCandidate
5362
+ )
5363
+ ) {
5364
+ pendingStreamingTextMessages = messages;
5365
+ if (streamingTextRAF === null) {
5366
+ streamingTextRAF = requestAnimationFrame(() => {
5367
+ streamingTextRAF = null;
5368
+ const pending = pendingStreamingTextMessages;
5369
+ pendingStreamingTextMessages = null;
5370
+ if (pending) applyMessagesChanged(pending);
5371
+ });
5237
5372
  }
5373
+ return;
5374
+ }
5238
5375
 
5239
- voiceState.lastUserMessageWasVoice = Boolean(lastUserMessage?.viaVoice);
5240
- persistState(messages);
5241
- // Composer-bar peek: re-render the trailing-100-char preview and
5242
- // re-evaluate visibility (a new message may make it eligible to show
5243
- // during streaming, or update the preview text on each token).
5244
- syncComposerBarPeek();
5376
+ if (messages.length === 0) {
5377
+ discardPendingStreamingText();
5378
+ applyMessagesChanged(messages);
5379
+ return;
5380
+ }
5381
+ flushPendingStreamingText();
5382
+ applyMessagesChanged(messages);
5383
+ };
5384
+
5385
+ session = new AgentWidgetSession(config, {
5386
+ onMessagesChanged(messages) {
5387
+ handleMessagesChanged(messages);
5245
5388
  },
5246
5389
  onStatusChanged(status) {
5247
5390
  const currentStatusConfig = config.statusIndicator ?? {};
@@ -5257,6 +5400,13 @@ export const createAgentExperience = (
5257
5400
  applyStatusToElement(statusText, getCurrentStatusText(status), currentStatusConfig, status);
5258
5401
  },
5259
5402
  onStreamingChanged(streaming) {
5403
+ if (!streaming) {
5404
+ if (session?.getMessages().length === 0) {
5405
+ discardPendingStreamingText();
5406
+ } else {
5407
+ flushPendingStreamingText();
5408
+ }
5409
+ }
5260
5410
  isStreaming = streaming;
5261
5411
  setComposerDisabled(streaming);
5262
5412
  // Re-render messages to show/hide typing indicator
@@ -6569,22 +6719,10 @@ export const createAgentExperience = (
6569
6719
  window.dispatchEvent(clearEvent);
6570
6720
 
6571
6721
  if (storageAdapter?.clear) {
6572
- try {
6573
- const result = storageAdapter.clear();
6574
- if (result instanceof Promise) {
6575
- result.catch((error) => {
6576
- if (typeof console !== "undefined") {
6577
- // eslint-disable-next-line no-console
6578
- console.error("[AgentWidget] Failed to clear storage adapter:", error);
6579
- }
6580
- });
6581
- }
6582
- } catch (error) {
6583
- if (typeof console !== "undefined") {
6584
- // eslint-disable-next-line no-console
6585
- console.error("[AgentWidget] Failed to clear storage adapter:", error);
6586
- }
6587
- }
6722
+ runStorageMutation(
6723
+ () => storageAdapter.clear!(),
6724
+ "[AgentWidget] Failed to clear storage adapter:"
6725
+ );
6588
6726
  }
6589
6727
  persistentMetadata = {};
6590
6728
  actionManager.syncFromMetadata();
@@ -8060,22 +8198,10 @@ export const createAgentExperience = (
8060
8198
  window.dispatchEvent(clearEvent);
8061
8199
 
8062
8200
  if (storageAdapter?.clear) {
8063
- try {
8064
- const result = storageAdapter.clear();
8065
- if (result instanceof Promise) {
8066
- result.catch((error) => {
8067
- if (typeof console !== "undefined") {
8068
- // eslint-disable-next-line no-console
8069
- console.error("[AgentWidget] Failed to clear storage adapter:", error);
8070
- }
8071
- });
8072
- }
8073
- } catch (error) {
8074
- if (typeof console !== "undefined") {
8075
- // eslint-disable-next-line no-console
8076
- console.error("[AgentWidget] Failed to clear storage adapter:", error);
8077
- }
8078
- }
8201
+ runStorageMutation(
8202
+ () => storageAdapter.clear!(),
8203
+ "[AgentWidget] Failed to clear storage adapter:"
8204
+ );
8079
8205
  }
8080
8206
  persistentMetadata = {};
8081
8207
  actionManager.syncFromMetadata();
@@ -8436,6 +8562,10 @@ export const createAgentExperience = (
8436
8562
  return session.submitNPSFeedback(rating, comment);
8437
8563
  },
8438
8564
  destroy() {
8565
+ // Commit the latest coalesced transcript while the live DOM and storage
8566
+ // pipeline still exist, then let the normal teardown cancel any work
8567
+ // scheduled by that final apply.
8568
+ flushPendingStreamingText();
8439
8569
  if (toolElapsedTimerId != null) {
8440
8570
  clearInterval(toolElapsedTimerId);
8441
8571
  toolElapsedTimerId = null;
@@ -83,4 +83,51 @@ describe("morphMessages", () => {
83
83
  expect(container.querySelector("span")!.textContent).toBe("New text");
84
84
  });
85
85
  });
86
+
87
+ describe("data-tool-elapsed (live duration counter)", () => {
88
+ it("preserves the live span's timer-owned text while the same tool is active", () => {
89
+ // The 100ms global timer wrote "0.3s"; a re-render arrives carrying the
90
+ // render-time value "<0.1s". Morphing it in would make the boundary
91
+ // flicker, so the still-live span (same startedAt) must be left alone.
92
+ const container = makeContainer(
93
+ '<span data-tool-elapsed="1000">0.3s</span>'
94
+ );
95
+ const oldSpan = container.querySelector("span")!;
96
+
97
+ morphMessages(
98
+ container,
99
+ makeNewContent('<span data-tool-elapsed="1000">&lt;0.1s</span>')
100
+ );
101
+
102
+ expect(container.querySelector("span")).toBe(oldSpan);
103
+ expect(container.querySelector("span")!.textContent).toBe("0.3s");
104
+ });
105
+
106
+ it("allows morph to the final static duration once the tool completes", () => {
107
+ const container = makeContainer(
108
+ '<span data-tool-elapsed="1000">0.7s</span>'
109
+ );
110
+
111
+ morphMessages(container, makeNewContent("<span>0.8s</span>"));
112
+
113
+ const span = container.querySelector("span")!;
114
+ expect(span.textContent).toBe("0.8s");
115
+ expect(span.hasAttribute("data-tool-elapsed")).toBe(false);
116
+ });
117
+
118
+ it("allows morph when the slot is reused by a different tool (startedAt changed)", () => {
119
+ const container = makeContainer(
120
+ '<span data-tool-elapsed="1000">0.5s</span>'
121
+ );
122
+
123
+ morphMessages(
124
+ container,
125
+ makeNewContent('<span data-tool-elapsed="2000">&lt;0.1s</span>')
126
+ );
127
+
128
+ const span = container.querySelector("span")!;
129
+ expect(span.getAttribute("data-tool-elapsed")).toBe("2000");
130
+ expect(span.textContent).toBe("<0.1s");
131
+ });
132
+ });
86
133
  });
@@ -37,6 +37,24 @@ export const morphMessages = (
37
37
  if (oldNode.hasAttribute("data-preserve-runtime")) {
38
38
  return false;
39
39
  }
40
+ // The live tool-elapsed span's text is owned by the 100ms global
41
+ // timer in ui.ts (it rewrites `now - startedAt` on a fixed cadence).
42
+ // Re-morphing it to the render-time value fights that timer, so while
43
+ // a tool stays active across frequent re-renders the "<0.1s" boundary
44
+ // flickers. Preserve it while the SAME tool is still live (matching
45
+ // `data-tool-elapsed`, i.e. startedAt); allow the morph once the tool
46
+ // completes (new node drops the marker → final static duration) or the
47
+ // slot is reused by another tool (marker value changed).
48
+ if (oldNode.hasAttribute("data-tool-elapsed")) {
49
+ const sameLiveTool =
50
+ newNode instanceof HTMLElement &&
51
+ newNode.getAttribute("data-tool-elapsed") ===
52
+ oldNode.getAttribute("data-tool-elapsed");
53
+ if (sameLiveTool) {
54
+ return false;
55
+ }
56
+ return;
57
+ }
40
58
  if (oldNode.hasAttribute("data-preserve-animation")) {
41
59
  // Allow morph when the new node drops the attribute (e.g. tool completed)
42
60
  if (newNode instanceof HTMLElement && !newNode.hasAttribute("data-preserve-animation")) {