@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/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-8RICZWQe.d.cts → types-CSmiKRVa.d.cts} +5 -0
- package/dist/animations/{types-8RICZWQe.d.ts → types-CSmiKRVa.d.ts} +5 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/codegen.cjs +4 -4
- package/dist/codegen.js +4 -4
- package/dist/index.cjs +39 -39
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +51 -3
- package/dist/index.d.ts +51 -3
- package/dist/index.global.js +29 -29
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +39 -39
- package/dist/index.js.map +1 -1
- package/dist/smart-dom-reader.d.cts +5 -0
- package/dist/smart-dom-reader.d.ts +5 -0
- package/dist/theme-editor-preview.cjs +30 -30
- package/dist/theme-editor-preview.d.cts +5 -0
- package/dist/theme-editor-preview.d.ts +5 -0
- package/dist/theme-editor-preview.js +30 -30
- package/dist/theme-editor.d.cts +5 -0
- package/dist/theme-editor.d.ts +5 -0
- package/package.json +4 -2
- package/src/client.test.ts +314 -0
- package/src/client.ts +200 -86
- package/src/generated/runtype-openapi-contract.ts +10 -1
- package/src/runtime/persist-state.test.ts +118 -0
- package/src/ui.scroll-additive.test.ts +1 -1
- package/src/ui.streaming-coalescing.test.ts +312 -0
- package/src/ui.ts +235 -105
- package/src/utils/morph.test.ts +47 -0
- package/src/utils/morph.ts +18 -0
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
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
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
|
-
|
|
5185
|
-
|
|
5186
|
-
|
|
5187
|
-
|
|
5188
|
-
|
|
5189
|
-
|
|
5190
|
-
|
|
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
|
-
|
|
5233
|
-
|
|
5234
|
-
|
|
5235
|
-
|
|
5236
|
-
|
|
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
|
-
|
|
5240
|
-
|
|
5241
|
-
|
|
5242
|
-
|
|
5243
|
-
|
|
5244
|
-
|
|
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
|
-
|
|
6573
|
-
|
|
6574
|
-
|
|
6575
|
-
|
|
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
|
-
|
|
8064
|
-
|
|
8065
|
-
|
|
8066
|
-
|
|
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;
|
package/src/utils/morph.test.ts
CHANGED
|
@@ -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"><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"><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
|
});
|
package/src/utils/morph.ts
CHANGED
|
@@ -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")) {
|