@runtypelabs/persona 4.29.0 → 4.29.2
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/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/approval-ui.cjs +3 -3
- package/dist/approval-ui.d.cts +1 -1
- package/dist/approval-ui.d.ts +1 -1
- package/dist/approval-ui.js +3 -3
- package/dist/artifacts-ui.d.cts +2 -2
- package/dist/artifacts-ui.d.ts +2 -2
- package/dist/chunk-B1-_pp-Y.js +4 -0
- package/dist/chunk-B2HCRERn.js +9 -0
- package/dist/chunk-Bm2-bemv.js +1 -0
- package/dist/chunk-BvhTpG6k.js +1 -0
- package/dist/{chunk-DjOvgFsn.js → chunk-Car1mORT.js} +1 -1
- package/dist/chunk-D5B629Mj.js +1 -0
- package/dist/chunk-DO9_WSpy.js +1 -0
- package/dist/chunk-DTDHeUsG.js +1 -0
- package/dist/chunk-DjFpGFGA.js +1 -0
- package/dist/chunk-DrpuvY5W.js +1 -0
- package/dist/{chunk-dRBlrLd7.js → chunk-DtmtfIkd.js} +1 -1
- package/dist/client-history.js +1 -1
- package/dist/client-stream.js +1 -1
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/{composer-input-D3sB8_P_.d.cts → composer-input-C3Xh1Qzj.d.cts} +1 -1
- package/dist/{composer-input-D3sB8_P_.d.ts → composer-input-C3Xh1Qzj.d.ts} +1 -1
- package/dist/{context-mention-manager-D2qxE5op.d.cts → context-mention-manager-BsEiGObk.d.cts} +1 -1
- package/dist/{context-mention-manager-D2qxE5op.d.ts → context-mention-manager-BsEiGObk.d.ts} +1 -1
- package/dist/context-mentions-inline.d.cts +2 -2
- package/dist/context-mentions-inline.d.ts +2 -2
- package/dist/context-mentions.d.cts +3 -3
- package/dist/context-mentions.d.ts +3 -3
- package/dist/{defaults-Dsw66XqE.d.cts → defaults-DN-L67ak.d.cts} +1 -1
- package/dist/{defaults-Dsw66XqE.d.ts → defaults-DN-L67ak.d.ts} +1 -1
- package/dist/event-stream-view.d.cts +1 -1
- package/dist/event-stream-view.d.ts +1 -1
- package/dist/{fallback-speech-engine-DJpJcWAk.d.cts → fallback-speech-engine-Cph8FTux.d.cts} +1 -1
- package/dist/{fallback-speech-engine-DJpJcWAk.d.ts → fallback-speech-engine-Cph8FTux.d.ts} +1 -1
- package/dist/forms-ui.d.cts +2 -2
- package/dist/forms-ui.d.ts +2 -2
- package/dist/history-view.d.cts +1 -1
- package/dist/history-view.d.ts +1 -1
- package/dist/index.cjs +56 -56
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +6 -6
- package/dist/index.d.ts +6 -6
- package/dist/index.global.js +2 -3
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +42 -42
- package/dist/index.js.map +1 -1
- package/dist/{markdown-parsers-loader-CCY7T4Up.d.cts → markdown-parsers-loader-C4Xze-Jw.d.cts} +1 -1
- package/dist/{markdown-parsers-loader-CCY7T4Up.d.ts → markdown-parsers-loader-C4Xze-Jw.d.ts} +1 -1
- package/dist/{preview-DKKpfTo7.d.cts → preview-m9QlqsZi.d.cts} +2 -2
- package/dist/{preview-DKKpfTo7.d.ts → preview-m9QlqsZi.d.ts} +2 -2
- package/dist/{session-DleMr9dZ.d.ts → session-BtvvmWnw.d.cts} +21 -9
- package/dist/{session-DleMr9dZ.d.cts → session-BtvvmWnw.d.ts} +21 -9
- package/dist/session-actions.js +1 -1
- package/dist/session-reconnect.cjs +1 -1
- package/dist/session-reconnect.d.cts +8 -2
- package/dist/session-reconnect.d.ts +8 -2
- package/dist/session-reconnect.js +1 -1
- package/dist/smart-dom-reader.d.cts +1 -1
- package/dist/smart-dom-reader.d.ts +1 -1
- package/dist/theme-editor-preview.cjs +62 -62
- package/dist/theme-editor-preview.d.cts +1 -1
- package/dist/theme-editor-preview.d.ts +1 -1
- package/dist/theme-editor-preview.js +41 -41
- package/dist/theme-editor.d.cts +2 -2
- package/dist/theme-editor.d.ts +2 -2
- package/dist/theme-reference.d.cts +2 -2
- package/dist/theme-reference.d.ts +2 -2
- package/dist/{types-oAZ1VReS.d.cts → types-DL-oBHE1.d.cts} +41 -3
- package/dist/{types-oAZ1VReS.d.ts → types-DL-oBHE1.d.ts} +41 -3
- package/dist/voice-client-tools.d.cts +1 -1
- package/dist/voice-client-tools.d.ts +1 -1
- package/dist/voice-runtime.cjs +3 -3
- package/dist/voice-runtime.d.cts +10 -4
- package/dist/voice-runtime.d.ts +10 -4
- package/dist/voice-runtime.js +5 -5
- package/dist/voice-worklet-player.d.cts +2 -2
- package/dist/voice-worklet-player.d.ts +2 -2
- package/dist/{webmcp-runtime-entry-Deg0NfmB.d.cts → webmcp-runtime-entry-T6SU_TlL.d.cts} +1 -1
- package/dist/{webmcp-runtime-entry-Deg0NfmB.d.ts → webmcp-runtime-entry-T6SU_TlL.d.ts} +1 -1
- package/dist/webmcp-runtime.d.cts +1 -1
- package/dist/webmcp-runtime.d.ts +1 -1
- package/dist/widget.css +1 -1
- package/package.json +1 -1
- package/src/client-approve.test.ts +17 -1
- package/src/client-resume.ts +15 -3
- package/src/client.interrupt.test.ts +21 -0
- package/src/client.ts +24 -4
- package/src/components/activity-row.ts +3 -2
- package/src/components/approval-actions.test.ts +8 -0
- package/src/components/approval-actions.ts +6 -2
- package/src/components/approval-bubble.test.ts +14 -0
- package/src/components/approval-bubble.ts +8 -1
- package/src/components/tool-bubble.ts +8 -5
- package/src/generated/runtype-openapi-contract.ts +7 -1
- package/src/resume-dedupe-local-only.test.ts +273 -0
- package/src/runtime/persist-state.test.ts +86 -0
- package/src/session-actions.ts +20 -6
- package/src/session-reconnect.ts +18 -1
- package/src/session.restored-browser-tools.test.ts +272 -0
- package/src/session.test.ts +23 -0
- package/src/session.ts +211 -46
- package/src/session.voice.test.ts +169 -0
- package/src/styles/widget.css +4 -2
- package/src/types.ts +37 -2
- package/src/ui.restored-browser-tools.test.ts +128 -0
- package/src/ui.scroll.test.ts +7 -2
- package/src/ui.stream-replay.test.ts +86 -0
- package/src/ui.streaming-coalescing.test.ts +183 -0
- package/src/ui.ts +152 -124
- package/src/ui.typing-indicator.test.ts +21 -0
- package/src/utils/formatting.test.ts +9 -1
- package/src/utils/formatting.ts +4 -0
- package/src/utils/local-only-message.ts +15 -0
- package/src/utils/message-fingerprint.test.ts +9 -0
- package/src/utils/message-fingerprint.ts +18 -5
- package/src/utils/morph.ts +10 -0
- package/src/utils/restored-browser-state.ts +50 -0
- package/src/utils/storage.ts +12 -1
- package/src/utils/voice-prior-tool-results.ts +37 -0
- package/src/voice/runtype-voice-provider.ts +13 -8
- package/src/voice/session-voice-wiring.ts +1 -2
- package/src/voice/voice-delegation.ts +10 -3
- package/src/voice/voice.test.ts +38 -2
- package/dist/chunk-B6zZsT6F.js +0 -1
- package/dist/chunk-CIVsNcrk.js +0 -1
- package/dist/chunk-CKdlNyzM.js +0 -9
- package/dist/chunk-DbhxjyOB.js +0 -4
- package/dist/chunk-DsdqLStH.js +0 -1
- package/dist/chunk-GIqbL2Eg.js +0 -1
package/src/ui.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isBrowserLocalApproval } from "./utils/restored-browser-state";
|
|
1
2
|
import { activityVariant, activityDuration, createActivityLifecycle, createActivityGroup } from "./components/activity-row";
|
|
2
3
|
import { applyStatusIndicatorState, placeStatusIndicator } from "./utils/status-indicator";
|
|
3
4
|
import { usesSessionVoice } from "./utils/voice-support";
|
|
@@ -331,7 +332,8 @@ import {
|
|
|
331
332
|
defaultActionHandlers,
|
|
332
333
|
defaultJsonActionParser
|
|
333
334
|
} from "./utils/actions";
|
|
334
|
-
import { createLocalStorageAdapter } from "./utils/storage";
|
|
335
|
+
import { createLocalStorageAdapter, transcriptStorageKey } from "./utils/storage";
|
|
336
|
+
import { isLocalOnlyMessage } from "./utils/local-only-message";
|
|
335
337
|
import { createVisitorStore, type VisitorStore } from "./utils/visitor-store";
|
|
336
338
|
import { loadHistoryView } from "./history-view-loader";
|
|
337
339
|
import { getHistoryShellSync, loadHistoryShell, type HistoryShellModule } from "./history-shell-loader";
|
|
@@ -501,11 +503,13 @@ const ensureRecord = (value: unknown): Record<string, unknown> => {
|
|
|
501
503
|
return { ...(value as Record<string, unknown>) };
|
|
502
504
|
};
|
|
503
505
|
|
|
504
|
-
const
|
|
505
|
-
messages
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
506
|
+
const messagesForStorage = (messages: AgentWidgetMessage[]) =>
|
|
507
|
+
messages
|
|
508
|
+
.filter((message) => !isLocalOnlyMessage(message))
|
|
509
|
+
.map((message) => ({
|
|
510
|
+
...message,
|
|
511
|
+
streaming: false
|
|
512
|
+
}));
|
|
509
513
|
|
|
510
514
|
type Controller = {
|
|
511
515
|
update: (config: AgentWidgetConfigPatch) => void;
|
|
@@ -1064,7 +1068,12 @@ export const createAgentExperience = (
|
|
|
1064
1068
|
const storageAdapter: AgentWidgetStorageAdapter | null =
|
|
1065
1069
|
messagePersistenceDisabled
|
|
1066
1070
|
? null
|
|
1067
|
-
: (config.storageAdapter ??
|
|
1071
|
+
: (config.storageAdapter ??
|
|
1072
|
+
createLocalStorageAdapter(
|
|
1073
|
+
transcriptStorageKey(
|
|
1074
|
+
typeof config.persistState === "object" ? config.persistState.keyPrefix : undefined
|
|
1075
|
+
)
|
|
1076
|
+
));
|
|
1068
1077
|
const personaOwnsClientTokenRecovery =
|
|
1069
1078
|
Boolean(config.clientToken) && typeof config.reconnectStream !== "function";
|
|
1070
1079
|
let persistentMetadata: Record<string, unknown> = {};
|
|
@@ -3055,8 +3064,8 @@ export const createAgentExperience = (
|
|
|
3055
3064
|
|
|
3056
3065
|
// WebMCP gate approvals resolve a local Promise the bridge is parked on
|
|
3057
3066
|
// (no server round-trip); server-driven approvals call the API. The
|
|
3058
|
-
// `toolType` marker
|
|
3059
|
-
if (approvalMessage.approval
|
|
3067
|
+
// `toolType` marker and absent execution id identify browser-local gates.
|
|
3068
|
+
if (isBrowserLocalApproval(approvalMessage.approval)) {
|
|
3060
3069
|
session.resolveWebMcpApproval(messageId, decision);
|
|
3061
3070
|
} else {
|
|
3062
3071
|
session.resolveApproval(approvalMessage.approval, decision);
|
|
@@ -4926,6 +4935,13 @@ export const createAgentExperience = (
|
|
|
4926
4935
|
|
|
4927
4936
|
let isStreaming = false;
|
|
4928
4937
|
const messageCache = createMessageCache();
|
|
4938
|
+
// Assistant message ids seen streaming in a coalesced (never-applied)
|
|
4939
|
+
// transcript snapshot; see `coalesceStreamingChange`.
|
|
4940
|
+
const coalescedStreamingIds = new Set<string>();
|
|
4941
|
+
// Fingerprint each live transcript row was last morphed to, by message id.
|
|
4942
|
+
// A row whose message still has that fingerprint is left in place by the
|
|
4943
|
+
// next render pass instead of being cloned from the cache and re-morphed.
|
|
4944
|
+
let liveRowFingerprints = new Map<string, string>();
|
|
4929
4945
|
// Tracks the last fingerprint we rendered a plugin-rendered ask_user_question
|
|
4930
4946
|
// bubble for, per message id. Lets us skip unnecessary rebuilds across
|
|
4931
4947
|
// re-renders so user state inside the plugin (typed text, focus) survives.
|
|
@@ -5159,7 +5175,7 @@ export const createAgentExperience = (
|
|
|
5159
5175
|
|
|
5160
5176
|
const getMessagesForPersistence = () =>
|
|
5161
5177
|
session
|
|
5162
|
-
?
|
|
5178
|
+
? messagesForStorage(session.getMessages()).filter(msg => !(msg as any).__skipPersist)
|
|
5163
5179
|
: [];
|
|
5164
5180
|
|
|
5165
5181
|
let storageMutationTail: Promise<void> | null = null;
|
|
@@ -5209,7 +5225,7 @@ export const createAgentExperience = (
|
|
|
5209
5225
|
|
|
5210
5226
|
// Allow saving even if session doesn't exist yet (for metadata during init)
|
|
5211
5227
|
const messages = messagesOverride
|
|
5212
|
-
?
|
|
5228
|
+
? messagesForStorage(messagesOverride)
|
|
5213
5229
|
: session
|
|
5214
5230
|
? getMessagesForPersistence()
|
|
5215
5231
|
: [];
|
|
@@ -6020,7 +6036,7 @@ export const createAgentExperience = (
|
|
|
6020
6036
|
|
|
6021
6037
|
if (
|
|
6022
6038
|
message.role === "assistant" &&
|
|
6023
|
-
previous?.streaming &&
|
|
6039
|
+
(previous?.streaming || coalescedStreamingIds.has(message.id)) &&
|
|
6024
6040
|
message.streaming === false
|
|
6025
6041
|
) {
|
|
6026
6042
|
eventBus.emit("assistant:complete", message);
|
|
@@ -6040,6 +6056,7 @@ export const createAgentExperience = (
|
|
|
6040
6056
|
nextState.forEach((value, key) => {
|
|
6041
6057
|
messageState.set(key, value);
|
|
6042
6058
|
});
|
|
6059
|
+
coalescedStreamingIds.clear();
|
|
6043
6060
|
};
|
|
6044
6061
|
|
|
6045
6062
|
|
|
@@ -6106,6 +6123,21 @@ export const createAgentExperience = (
|
|
|
6106
6123
|
// Build new content in a temporary container for morphing
|
|
6107
6124
|
const tempContainer = document.createElement("div");
|
|
6108
6125
|
|
|
6126
|
+
// Unchanged messages keep their live row: the pass appends an empty
|
|
6127
|
+
// `data-persona-reuse` stub that `morphMessages` matches by id and skips,
|
|
6128
|
+
// so a render costs O(changed rows) rather than a deep clone, serialize,
|
|
6129
|
+
// and morph of the whole transcript. Tool grouping restyles and re-parents
|
|
6130
|
+
// rows after this loop, so grouped transcripts always rebuild.
|
|
6131
|
+
const reuseLiveRows = !config.features?.toolCallDisplay?.grouped;
|
|
6132
|
+
const liveRows = new Set<string>();
|
|
6133
|
+
if (reuseLiveRows) {
|
|
6134
|
+
for (const child of Array.from(container.children)) {
|
|
6135
|
+
const id = child.getAttribute("data-wrapper-id");
|
|
6136
|
+
if (id) liveRows.add(id);
|
|
6137
|
+
}
|
|
6138
|
+
}
|
|
6139
|
+
const nextLiveRowFingerprints = new Map<string, string>();
|
|
6140
|
+
|
|
6109
6141
|
// Create inline loading indicator renderer using priority chain: plugin -> config -> default
|
|
6110
6142
|
const getInlineLoadingIndicatorRenderer = (): LoadingIndicatorRenderer | undefined => {
|
|
6111
6143
|
// Check if any plugin has renderLoadingIndicator
|
|
@@ -6239,7 +6271,8 @@ export const createAgentExperience = (
|
|
|
6239
6271
|
return (
|
|
6240
6272
|
dropCompletedToolCalls &&
|
|
6241
6273
|
message.variant === "tool" &&
|
|
6242
|
-
message.toolCall?.status === "complete"
|
|
6274
|
+
message.toolCall?.status === "complete" &&
|
|
6275
|
+
!message.toolCall.interrupted
|
|
6243
6276
|
);
|
|
6244
6277
|
};
|
|
6245
6278
|
|
|
@@ -6318,7 +6351,19 @@ export const createAgentExperience = (
|
|
|
6318
6351
|
? null
|
|
6319
6352
|
: getCachedWrapper(messageCache, message.id, fingerprint);
|
|
6320
6353
|
if (cachedWrapper) {
|
|
6321
|
-
|
|
6354
|
+
if (
|
|
6355
|
+
liveRows.has(message.id) &&
|
|
6356
|
+
liveRowFingerprints.get(message.id) === fingerprint
|
|
6357
|
+
) {
|
|
6358
|
+
const stub = document.createElement("div");
|
|
6359
|
+
stub.id = `wrapper-${message.id}`;
|
|
6360
|
+
stub.setAttribute("data-wrapper-id", message.id);
|
|
6361
|
+
stub.setAttribute("data-persona-reuse", "true");
|
|
6362
|
+
tempContainer.appendChild(stub);
|
|
6363
|
+
} else {
|
|
6364
|
+
tempContainer.appendChild(cachedWrapper.cloneNode(true));
|
|
6365
|
+
}
|
|
6366
|
+
if (reuseLiveRows) nextLiveRowFingerprints.set(message.id, fingerprint);
|
|
6322
6367
|
// Keep the overlay sheet alive only while the server is actively
|
|
6323
6368
|
// waiting on the user (awaitingLocalTool === true). Before await
|
|
6324
6369
|
// fires, or after the answer resumes the flow, omit from
|
|
@@ -6507,7 +6552,7 @@ export const createAgentExperience = (
|
|
|
6507
6552
|
?.getMessages()
|
|
6508
6553
|
.find((m) => m.id === approvalMessageId);
|
|
6509
6554
|
if (!live?.approval) return;
|
|
6510
|
-
if (live.approval
|
|
6555
|
+
if (isBrowserLocalApproval(live.approval)) {
|
|
6511
6556
|
sessionRef.current?.resolveWebMcpApproval(live.id, decision);
|
|
6512
6557
|
} else {
|
|
6513
6558
|
sessionRef.current?.resolveApproval(live.approval, decision, options);
|
|
@@ -6774,6 +6819,7 @@ export const createAgentExperience = (
|
|
|
6774
6819
|
wrapper.appendChild(bubble);
|
|
6775
6820
|
setCachedWrapper(messageCache, message.id, fingerprint, wrapper);
|
|
6776
6821
|
tempContainer.appendChild(wrapper);
|
|
6822
|
+
if (reuseLiveRows) nextLiveRowFingerprints.set(message.id, fingerprint);
|
|
6777
6823
|
});
|
|
6778
6824
|
|
|
6779
6825
|
// Prune any ask_user_question sheets whose source message is no longer in
|
|
@@ -6801,7 +6847,7 @@ export const createAgentExperience = (
|
|
|
6801
6847
|
// A tool call hidden by `completedVisibility: "removed"` does not
|
|
6802
6848
|
// create a visible break in the transcript, so it should not split
|
|
6803
6849
|
// an otherwise contiguous group.
|
|
6804
|
-
if (dropCompletedToolCalls && message.toolCall.status === "complete") {
|
|
6850
|
+
if (dropCompletedToolCalls && message.toolCall.status === "complete" && !message.toolCall.interrupted) {
|
|
6805
6851
|
return;
|
|
6806
6852
|
}
|
|
6807
6853
|
currentGroup.push(message);
|
|
@@ -6859,10 +6905,12 @@ export const createAgentExperience = (
|
|
|
6859
6905
|
id, name: `${group.length} tools`, status: active ? "running" : "complete",
|
|
6860
6906
|
chunks: group.flatMap(item => item.toolCall?.chunks ?? []),
|
|
6861
6907
|
success: !group.some(item => item.toolCall?.success === false),
|
|
6908
|
+
interrupted: !active && group.some(item => item.toolCall?.interrupted),
|
|
6862
6909
|
} };
|
|
6863
6910
|
activityIds.add(id);
|
|
6864
6911
|
activityLifecycle.observe(synthetic, "tool", toolExpansionState, config.features.toolCallDisplay);
|
|
6865
|
-
const label =
|
|
6912
|
+
const label = synthetic.toolCall?.interrupted ? `${group.length} tools · Interrupted`
|
|
6913
|
+
: `${active ? "Using" : "Used"} ${group.length} tools`;
|
|
6866
6914
|
const custom = config.toolCall?.renderGroupedSummary?.({ messages: group, toolCalls: group.map(item => item.toolCall!), defaultSummary: label, config });
|
|
6867
6915
|
const row = createActivityGroup(synthetic, config, toolExpansionState.has(id), custom ?? label);
|
|
6868
6916
|
row.bubble.classList.add("persona-tool-group");
|
|
@@ -6888,7 +6936,9 @@ export const createAgentExperience = (
|
|
|
6888
6936
|
"persona-tool-group-summary persona-text-xs persona-text-persona-muted";
|
|
6889
6937
|
summary.id = `tool-group-summary-${group[0].id}`;
|
|
6890
6938
|
|
|
6891
|
-
const
|
|
6939
|
+
const toolCount = `${group.length} ${group.length === 1 ? "tool" : "tools"}`;
|
|
6940
|
+
const defaultSummary = group.some(item => item.toolCall?.interrupted)
|
|
6941
|
+
? `${toolCount} · Interrupted` : `Called ${toolCount}`;
|
|
6892
6942
|
const renderedSummary = config.toolCall?.renderGroupedSummary?.({
|
|
6893
6943
|
messages: group,
|
|
6894
6944
|
toolCalls: group
|
|
@@ -7106,6 +7156,16 @@ export const createAgentExperience = (
|
|
|
7106
7156
|
|
|
7107
7157
|
// Use idiomorph to morph the container contents
|
|
7108
7158
|
morphMessages(container, tempContainer);
|
|
7159
|
+
liveRowFingerprints = nextLiveRowFingerprints;
|
|
7160
|
+
// A stub idiomorph inserted instead of matching to its live row would
|
|
7161
|
+
// leave an empty row; restore it from the cache.
|
|
7162
|
+
for (const stub of Array.from(
|
|
7163
|
+
container.querySelectorAll<HTMLElement>(":scope > [data-persona-reuse]")
|
|
7164
|
+
)) {
|
|
7165
|
+
const cached = messageCache.get(stub.getAttribute("data-wrapper-id") ?? "");
|
|
7166
|
+
if (cached) stub.replaceWith(cached.wrapper.cloneNode(true));
|
|
7167
|
+
else stub.remove();
|
|
7168
|
+
}
|
|
7109
7169
|
|
|
7110
7170
|
// Set initial edge-fade state on the live table wrappers and attach the
|
|
7111
7171
|
// delegated scroll listener (once) that keeps the fades in sync.
|
|
@@ -8582,74 +8642,48 @@ export const createAgentExperience = (
|
|
|
8582
8642
|
}, 100);
|
|
8583
8643
|
};
|
|
8584
8644
|
|
|
8585
|
-
|
|
8586
|
-
|
|
8587
|
-
|
|
8588
|
-
|
|
8589
|
-
|
|
8590
|
-
|
|
8591
|
-
|
|
8592
|
-
|
|
8593
|
-
|
|
8594
|
-
|
|
8595
|
-
|
|
8596
|
-
|
|
8597
|
-
|
|
8598
|
-
const rightKeys = Object.keys(rightRecord);
|
|
8599
|
-
return (
|
|
8600
|
-
leftKeys.length === rightKeys.length &&
|
|
8601
|
-
leftKeys.every(
|
|
8602
|
-
(key) =>
|
|
8603
|
-
Object.prototype.hasOwnProperty.call(rightRecord, key) &&
|
|
8604
|
-
isDeepEqual(leftRecord[key], rightRecord[key])
|
|
8605
|
-
)
|
|
8606
|
-
);
|
|
8607
|
-
};
|
|
8608
|
-
|
|
8609
|
-
const withoutStreamingText = (message: AgentWidgetMessage) => {
|
|
8610
|
-
const { content: _content, rawContent: _rawContent, llmContent: _llmContent, ...rest } = message;
|
|
8611
|
-
return rest;
|
|
8612
|
-
};
|
|
8613
|
-
|
|
8614
|
-
const isPlainStreamingAssistant = (message: AgentWidgetMessage) =>
|
|
8615
|
-
message.role === "assistant" &&
|
|
8616
|
-
message.streaming === true &&
|
|
8617
|
-
!message.variant &&
|
|
8618
|
-
!message.toolCall &&
|
|
8619
|
-
!message.tools &&
|
|
8620
|
-
!message.approval &&
|
|
8621
|
-
!message.reasoning &&
|
|
8622
|
-
!message.contentParts &&
|
|
8623
|
-
!message.stopReason &&
|
|
8624
|
-
!hasComponentDirective(message);
|
|
8645
|
+
/**
|
|
8646
|
+
* Changes that must reach the DOM (and storage) in the same task rather than
|
|
8647
|
+
* on the next animation frame: a new or edited user message drives send
|
|
8648
|
+
* anchoring and `user:message`, and an approval or a pending local tool
|
|
8649
|
+
* (ask_user_question, WebMCP) is waiting on the user and must be durable
|
|
8650
|
+
* before the page can unload. Everything else in a streaming burst is
|
|
8651
|
+
* coalesced.
|
|
8652
|
+
*/
|
|
8653
|
+
const needsImmediateApply = (message: AgentWidgetMessage) =>
|
|
8654
|
+
message.role === "user" ||
|
|
8655
|
+
message.variant === "approval" ||
|
|
8656
|
+
message.agentMetadata?.awaitingLocalTool === true ||
|
|
8657
|
+
isAskUserQuestionMessage(message);
|
|
8625
8658
|
|
|
8626
|
-
|
|
8659
|
+
/**
|
|
8660
|
+
* One pass over a streaming update: returns false when a changed entry needs
|
|
8661
|
+
* an immediate apply, and otherwise remembers assistant messages that
|
|
8662
|
+
* streamed inside the coalesced burst, so `trackMessages` still emits
|
|
8663
|
+
* `assistant:complete` when a message starts and finishes between two
|
|
8664
|
+
* applied snapshots. The session copies only the messages it changes, so
|
|
8665
|
+
* identity marks the changed entries; this pointer scan costs no more than
|
|
8666
|
+
* the array copy the session makes for every update.
|
|
8667
|
+
*/
|
|
8668
|
+
const coalesceStreamingChange = (
|
|
8627
8669
|
previous: AgentWidgetMessage[],
|
|
8628
|
-
next: AgentWidgetMessage[]
|
|
8629
|
-
candidate: { index: number; id: string }
|
|
8670
|
+
next: AgentWidgetMessage[]
|
|
8630
8671
|
) => {
|
|
8631
|
-
if (
|
|
8632
|
-
|
|
8633
|
-
|
|
8634
|
-
|
|
8635
|
-
return false;
|
|
8672
|
+
if (next.length < previous.length) return false;
|
|
8673
|
+
for (let index = 0; index < next.length; index += 1) {
|
|
8674
|
+
const message = next[index];
|
|
8675
|
+
if (message === previous[index]) continue;
|
|
8676
|
+
if (needsImmediateApply(message)) return false;
|
|
8677
|
+
if (message.role === "assistant" && message.streaming) {
|
|
8678
|
+
coalescedStreamingIds.add(message.id);
|
|
8679
|
+
}
|
|
8636
8680
|
}
|
|
8637
|
-
|
|
8638
|
-
!Object.is(before.content, after.content) ||
|
|
8639
|
-
!Object.is(before.rawContent, after.rawContent) ||
|
|
8640
|
-
!Object.is(before.llmContent, after.llmContent);
|
|
8641
|
-
return (
|
|
8642
|
-
textChanged &&
|
|
8643
|
-
isPlainStreamingAssistant(before) &&
|
|
8644
|
-
isPlainStreamingAssistant(after) &&
|
|
8645
|
-
isDeepEqual(withoutStreamingText(before), withoutStreamingText(after))
|
|
8646
|
-
);
|
|
8681
|
+
return true;
|
|
8647
8682
|
};
|
|
8648
8683
|
|
|
8649
8684
|
let lastAppliedMessages: AgentWidgetMessage[] | null = null;
|
|
8650
|
-
let
|
|
8651
|
-
let
|
|
8652
|
-
let streamingTextRAF: number | null = null;
|
|
8685
|
+
let pendingMessages: AgentWidgetMessage[] | null = null;
|
|
8686
|
+
let pendingMessagesRAF: number | null = null;
|
|
8653
8687
|
let pendingDurablePersistenceMessages: AgentWidgetMessage[] | null = null;
|
|
8654
8688
|
|
|
8655
8689
|
const persistMessages = (messages: AgentWidgetMessage[]) => {
|
|
@@ -8658,7 +8692,9 @@ export const createAgentExperience = (
|
|
|
8658
8692
|
queueMicrotask(() => {
|
|
8659
8693
|
if (pendingDurablePersistenceMessages === messages) {
|
|
8660
8694
|
pendingDurablePersistenceMessages = null;
|
|
8661
|
-
|
|
8695
|
+
// The resume cursor is read now, so save the newest snapshot: updates
|
|
8696
|
+
// coalesced after this one (still waiting on a frame) already advanced it.
|
|
8697
|
+
persistState(pendingMessages ?? messages);
|
|
8662
8698
|
}
|
|
8663
8699
|
});
|
|
8664
8700
|
};
|
|
@@ -8667,17 +8703,13 @@ export const createAgentExperience = (
|
|
|
8667
8703
|
lastAppliedMessages = messages;
|
|
8668
8704
|
let lastUserMessage: AgentWidgetMessage | undefined;
|
|
8669
8705
|
let lastAssistantMessage: AgentWidgetMessage | undefined;
|
|
8670
|
-
activeStreamingTextCandidate = null;
|
|
8671
8706
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
8672
8707
|
const message = messages[index];
|
|
8673
8708
|
if (!lastUserMessage && message.role === "user") lastUserMessage = message;
|
|
8674
8709
|
if (!lastAssistantMessage && message.role === "assistant") {
|
|
8675
8710
|
lastAssistantMessage = message;
|
|
8676
8711
|
}
|
|
8677
|
-
if (
|
|
8678
|
-
activeStreamingTextCandidate = { index, id: message.id };
|
|
8679
|
-
}
|
|
8680
|
-
if (lastUserMessage && lastAssistantMessage && activeStreamingTextCandidate) break;
|
|
8712
|
+
if (lastUserMessage && lastAssistantMessage) break;
|
|
8681
8713
|
}
|
|
8682
8714
|
renderMessagesWithPlugins(messagesWrapper, messages, postprocess);
|
|
8683
8715
|
// Freshly (re)built inline artifact blocks render from their persisted
|
|
@@ -8726,52 +8758,48 @@ export const createAgentExperience = (
|
|
|
8726
8758
|
syncComposerBarPeek();
|
|
8727
8759
|
};
|
|
8728
8760
|
|
|
8729
|
-
const
|
|
8730
|
-
if (
|
|
8731
|
-
cancelAnimationFrame(
|
|
8732
|
-
|
|
8761
|
+
const flushPendingMessages = () => {
|
|
8762
|
+
if (pendingMessagesRAF !== null) {
|
|
8763
|
+
cancelAnimationFrame(pendingMessagesRAF);
|
|
8764
|
+
pendingMessagesRAF = null;
|
|
8733
8765
|
}
|
|
8734
|
-
const pending =
|
|
8735
|
-
|
|
8766
|
+
const pending = pendingMessages;
|
|
8767
|
+
pendingMessages = null;
|
|
8736
8768
|
if (pending) applyMessagesChanged(pending);
|
|
8737
8769
|
};
|
|
8738
8770
|
|
|
8739
|
-
const
|
|
8740
|
-
if (
|
|
8741
|
-
|
|
8742
|
-
|
|
8771
|
+
const discardPendingMessages = () => {
|
|
8772
|
+
if (pendingMessagesRAF !== null) cancelAnimationFrame(pendingMessagesRAF);
|
|
8773
|
+
pendingMessagesRAF = null;
|
|
8774
|
+
pendingMessages = null;
|
|
8743
8775
|
};
|
|
8744
8776
|
|
|
8777
|
+
// While a turn streams, every SSE frame changes the transcript, and a render
|
|
8778
|
+
// pass costs O(messages). Applying each frame synchronously makes a burst of
|
|
8779
|
+
// N frames (a long tool run, or a finished transcript replayed through
|
|
8780
|
+
// `connectStream`) cost O(N²) and block the main thread. Coalesce streaming
|
|
8781
|
+
// updates to one render per animation frame; the stream ending, a clear, or
|
|
8782
|
+
// a change in `needsImmediateApply` flushes synchronously.
|
|
8745
8783
|
const handleMessagesChanged = (messages: AgentWidgetMessage[]) => {
|
|
8746
|
-
|
|
8747
|
-
|
|
8748
|
-
|
|
8749
|
-
|
|
8750
|
-
|
|
8751
|
-
|
|
8752
|
-
|
|
8753
|
-
|
|
8754
|
-
|
|
8755
|
-
|
|
8756
|
-
|
|
8757
|
-
|
|
8758
|
-
|
|
8759
|
-
streamingTextRAF = requestAnimationFrame(() => {
|
|
8760
|
-
streamingTextRAF = null;
|
|
8761
|
-
const pending = pendingStreamingTextMessages;
|
|
8762
|
-
pendingStreamingTextMessages = null;
|
|
8784
|
+
if (messages.length === 0) {
|
|
8785
|
+
discardPendingMessages();
|
|
8786
|
+
applyMessagesChanged(messages);
|
|
8787
|
+
return;
|
|
8788
|
+
}
|
|
8789
|
+
const comparison = pendingMessages ?? lastAppliedMessages;
|
|
8790
|
+
if (isStreaming && comparison && coalesceStreamingChange(comparison, messages)) {
|
|
8791
|
+
pendingMessages = messages;
|
|
8792
|
+
if (pendingMessagesRAF === null) {
|
|
8793
|
+
pendingMessagesRAF = requestAnimationFrame(() => {
|
|
8794
|
+
pendingMessagesRAF = null;
|
|
8795
|
+
const pending = pendingMessages;
|
|
8796
|
+
pendingMessages = null;
|
|
8763
8797
|
if (pending) applyMessagesChanged(pending);
|
|
8764
8798
|
});
|
|
8765
8799
|
}
|
|
8766
8800
|
return;
|
|
8767
8801
|
}
|
|
8768
|
-
|
|
8769
|
-
if (messages.length === 0) {
|
|
8770
|
-
discardPendingStreamingText();
|
|
8771
|
-
applyMessagesChanged(messages);
|
|
8772
|
-
return;
|
|
8773
|
-
}
|
|
8774
|
-
flushPendingStreamingText();
|
|
8802
|
+
flushPendingMessages();
|
|
8775
8803
|
applyMessagesChanged(messages);
|
|
8776
8804
|
};
|
|
8777
8805
|
|
|
@@ -8797,9 +8825,9 @@ export const createAgentExperience = (
|
|
|
8797
8825
|
onStreamingChanged(streaming) {
|
|
8798
8826
|
if (!streaming) {
|
|
8799
8827
|
if (session?.getMessages().length === 0) {
|
|
8800
|
-
|
|
8828
|
+
discardPendingMessages();
|
|
8801
8829
|
} else {
|
|
8802
|
-
|
|
8830
|
+
flushPendingMessages();
|
|
8803
8831
|
}
|
|
8804
8832
|
}
|
|
8805
8833
|
isStreaming = streaming;
|
|
@@ -13277,8 +13305,8 @@ export const createAgentExperience = (
|
|
|
13277
13305
|
// Mirror the in-panel click handler: WebMCP gate bubbles resolve a local
|
|
13278
13306
|
// Promise the bridge is parked on (no server round-trip and they carry an
|
|
13279
13307
|
// empty executionId/agentId), so they must NOT hit the server approval
|
|
13280
|
-
// API.
|
|
13281
|
-
if (approvalMessage.approval
|
|
13308
|
+
// API. Server-backed approvals retain their execution id and use the API.
|
|
13309
|
+
if (isBrowserLocalApproval(approvalMessage.approval)) {
|
|
13282
13310
|
session.resolveWebMcpApproval(approvalMessage.id, decision);
|
|
13283
13311
|
return;
|
|
13284
13312
|
}
|
|
@@ -13421,7 +13449,7 @@ export const createAgentExperience = (
|
|
|
13421
13449
|
// Commit the latest coalesced transcript while the live DOM and storage
|
|
13422
13450
|
// pipeline still exist, then let the normal teardown cancel any work
|
|
13423
13451
|
// scheduled by that final apply.
|
|
13424
|
-
|
|
13452
|
+
flushPendingMessages();
|
|
13425
13453
|
if (toolElapsedTimerId != null) {
|
|
13426
13454
|
clearInterval(toolElapsedTimerId);
|
|
13427
13455
|
toolElapsedTimerId = null;
|
|
@@ -145,9 +145,27 @@ describe("standalone typing indicator with hidden activity rows", () => {
|
|
|
145
145
|
document.body.innerHTML = "";
|
|
146
146
|
if (typeof localStorage !== "undefined") localStorage.clear();
|
|
147
147
|
vi.restoreAllMocks();
|
|
148
|
+
vi.unstubAllGlobals();
|
|
148
149
|
});
|
|
149
150
|
|
|
151
|
+
// Streaming transcript updates render once per animation frame; queue the
|
|
152
|
+
// frames so each injection can be rendered synchronously.
|
|
153
|
+
let frames = new Map<number, (time: number) => void>();
|
|
154
|
+
let nextFrameId = 1;
|
|
155
|
+
const renderFrame = () => {
|
|
156
|
+
const pending = [...frames.values()];
|
|
157
|
+
frames.clear();
|
|
158
|
+
pending.forEach((callback) => callback(0));
|
|
159
|
+
};
|
|
160
|
+
|
|
150
161
|
const mountStreaming = async (config: Record<string, unknown>) => {
|
|
162
|
+
frames = new Map();
|
|
163
|
+
vi.stubGlobal("requestAnimationFrame", (callback: (time: number) => void) => {
|
|
164
|
+
const id = nextFrameId++;
|
|
165
|
+
frames.set(id, callback);
|
|
166
|
+
return id;
|
|
167
|
+
});
|
|
168
|
+
vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id));
|
|
151
169
|
global.fetch = vi.fn().mockImplementation(
|
|
152
170
|
() => new Promise(() => {})
|
|
153
171
|
) as unknown as typeof fetch;
|
|
@@ -190,6 +208,7 @@ describe("standalone typing indicator with hidden activity rows", () => {
|
|
|
190
208
|
toolCall: { id, name: "lookup", status },
|
|
191
209
|
},
|
|
192
210
|
});
|
|
211
|
+
renderFrame();
|
|
193
212
|
};
|
|
194
213
|
|
|
195
214
|
const injectReasoning = (
|
|
@@ -211,6 +230,7 @@ describe("standalone typing indicator with hidden activity rows", () => {
|
|
|
211
230
|
reasoning: { id, status, chunks: ["thinking"] },
|
|
212
231
|
},
|
|
213
232
|
});
|
|
233
|
+
renderFrame();
|
|
214
234
|
};
|
|
215
235
|
|
|
216
236
|
const typingIndicator = (mount: HTMLElement) =>
|
|
@@ -344,6 +364,7 @@ describe("standalone typing indicator with hidden activity rows", () => {
|
|
|
344
364
|
streaming: true,
|
|
345
365
|
},
|
|
346
366
|
});
|
|
367
|
+
renderFrame();
|
|
347
368
|
expect(typingIndicator(mount)).toBeNull();
|
|
348
369
|
|
|
349
370
|
controller.destroy();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { createJsonStreamParser, parseFormattedTemplate, computeReasoningElapsed } from "./formatting";
|
|
2
|
+
import { createJsonStreamParser, parseFormattedTemplate, computeReasoningElapsed, computeToolElapsed } from "./formatting";
|
|
3
3
|
|
|
4
4
|
describe("JSON Stream Parser", () => {
|
|
5
5
|
it("should extract text field incrementally as JSON streams in", () => {
|
|
@@ -268,3 +268,11 @@ describe("computeReasoningElapsed", () => {
|
|
|
268
268
|
expect(result).toBe("<0.1s");
|
|
269
269
|
});
|
|
270
270
|
});
|
|
271
|
+
|
|
272
|
+
it("never invents an interrupted tool's duration, including elapsed render hooks", () => {
|
|
273
|
+
const tool = { id: "browser", status: "complete" as const, interrupted: true };
|
|
274
|
+
expect(computeToolElapsed(tool)).toBe("");
|
|
275
|
+
expect(computeToolElapsed({ ...tool, startedAt: 1 })).toBe("");
|
|
276
|
+
expect(computeToolElapsed({ ...tool, durationMs: 2000 })).toBe("2s");
|
|
277
|
+
expect(computeToolElapsed({ ...tool, startedAt: 1000, completedAt: 4000 })).toBe("3s");
|
|
278
|
+
});
|
package/src/utils/formatting.ts
CHANGED
|
@@ -124,6 +124,10 @@ export const formatElapsedMs = (ms: number): string => {
|
|
|
124
124
|
* Computes the current elapsed time string for a tool call.
|
|
125
125
|
*/
|
|
126
126
|
export const computeToolElapsed = (tool: AgentWidgetToolCall): string => {
|
|
127
|
+
if (
|
|
128
|
+
tool.interrupted && tool.duration === undefined && tool.durationMs === undefined &&
|
|
129
|
+
(tool.startedAt === undefined || tool.completedAt === undefined)
|
|
130
|
+
) return "";
|
|
127
131
|
const durationMs =
|
|
128
132
|
typeof tool.duration === "number"
|
|
129
133
|
? tool.duration
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AgentWidgetMessage } from "../types";
|
|
2
|
+
|
|
3
|
+
/** Id prefix of the bubble the durable reconnect loop posts when it gives up. */
|
|
4
|
+
export const RECONNECT_FAILED_ID_PREFIX = "reconnect-failed-";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A widget-generated notice that is shown in this browser only: never sent as
|
|
8
|
+
* conversation history and never written to the storage adapter. The id check
|
|
9
|
+
* covers reconnect-failure bubbles persisted before `localOnly` existed.
|
|
10
|
+
*/
|
|
11
|
+
export const isLocalOnlyMessage = (message: AgentWidgetMessage): boolean =>
|
|
12
|
+
message.localOnly === true ||
|
|
13
|
+
(message.role === "assistant" &&
|
|
14
|
+
typeof message.id === "string" &&
|
|
15
|
+
message.id.startsWith(RECONNECT_FAILED_ID_PREFIX));
|
|
@@ -35,6 +35,15 @@ describe("computeMessageFingerprint", () => {
|
|
|
35
35
|
expect(fp1).toBe(fp2);
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
+
it("changes when tool args are replaced with a longer object", () => {
|
|
39
|
+
const toolMessage = (args: unknown) =>
|
|
40
|
+
makeMessage({ variant: "tool", toolCall: { status: "running", args } });
|
|
41
|
+
const fp1 = computeMessageFingerprint(toolMessage({ q: "a" }), 0);
|
|
42
|
+
const fp2 = computeMessageFingerprint(toolMessage({ q: "a", limit: 10 }), 0);
|
|
43
|
+
expect(fp1).not.toBe(fp2);
|
|
44
|
+
expect(computeMessageFingerprint(toolMessage({ q: "a" }), 0)).toBe(fp1);
|
|
45
|
+
});
|
|
46
|
+
|
|
38
47
|
it("changes when content changes", () => {
|
|
39
48
|
const fp1 = computeMessageFingerprint(makeMessage({ content: "Hello" }), 0);
|
|
40
49
|
const fp2 = computeMessageFingerprint(makeMessage({ content: "Hello world" }), 0);
|
|
@@ -35,6 +35,23 @@ export type MessageCacheEntry = {
|
|
|
35
35
|
|
|
36
36
|
export type MessageCache = Map<string, MessageCacheEntry>;
|
|
37
37
|
|
|
38
|
+
// Every render pass fingerprints every message, so re-stringifying tool args
|
|
39
|
+
// each time would cost O(total args bytes) per pass. Streaming replaces the
|
|
40
|
+
// args object rather than mutating it, so the length is memoized per object.
|
|
41
|
+
const argsLengths = new WeakMap<object, number>();
|
|
42
|
+
|
|
43
|
+
function argsLength(args: unknown): number {
|
|
44
|
+
if (typeof args === "string") return args.length;
|
|
45
|
+
if (!args) return 0;
|
|
46
|
+
if (typeof args !== "object") return JSON.stringify(args)?.length ?? 0;
|
|
47
|
+
let length = argsLengths.get(args);
|
|
48
|
+
if (length === undefined) {
|
|
49
|
+
length = JSON.stringify(args)?.length ?? 0;
|
|
50
|
+
argsLengths.set(args, length);
|
|
51
|
+
}
|
|
52
|
+
return length;
|
|
53
|
+
}
|
|
54
|
+
|
|
38
55
|
/**
|
|
39
56
|
* Compute a fast fingerprint for a message to detect changes.
|
|
40
57
|
* Uses string concatenation with a delimiter rather than JSON.stringify for performance.
|
|
@@ -62,11 +79,7 @@ export function computeMessageFingerprint(
|
|
|
62
79
|
message.toolCall?.name ?? "",
|
|
63
80
|
message.toolCall?.chunks?.length ?? 0,
|
|
64
81
|
message.toolCall?.chunks?.[message.toolCall.chunks.length - 1]?.slice(-32) ?? "",
|
|
65
|
-
|
|
66
|
-
? message.toolCall.args.length
|
|
67
|
-
: message.toolCall?.args
|
|
68
|
-
? JSON.stringify(message.toolCall.args).length
|
|
69
|
-
: 0,
|
|
82
|
+
argsLength(message.toolCall?.args),
|
|
70
83
|
message.reasoning?.chunks?.length ?? 0,
|
|
71
84
|
message.reasoning?.chunks?.[message.reasoning.chunks.length - 1]?.length ?? 0,
|
|
72
85
|
message.reasoning?.chunks?.[message.reasoning.chunks.length - 1]?.slice(-32) ?? "",
|
package/src/utils/morph.ts
CHANGED
|
@@ -24,6 +24,16 @@ export const morphMessages = (
|
|
|
24
24
|
beforeNodeMorphed(oldNode: Node, newNode: Node): boolean | void {
|
|
25
25
|
if (!(oldNode instanceof HTMLElement)) return;
|
|
26
26
|
|
|
27
|
+
// Render passes stand in an unchanged transcript row with an empty
|
|
28
|
+
// `data-persona-reuse` stub: keep the live row as is.
|
|
29
|
+
if (
|
|
30
|
+
newNode instanceof HTMLElement &&
|
|
31
|
+
newNode.hasAttribute("data-persona-reuse") &&
|
|
32
|
+
oldNode.id === newNode.id
|
|
33
|
+
) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
27
37
|
// Preserve typing indicator dots to maintain animation continuity
|
|
28
38
|
// Also preserve elements with data-preserve-animation attribute for custom loading indicators
|
|
29
39
|
if (preserveTypingAnimation) {
|