@runtypelabs/persona 3.10.0 → 3.11.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/index.cjs +42 -42
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +114 -0
- package/dist/index.d.ts +114 -0
- package/dist/index.global.js +61 -61
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +42 -42
- package/dist/index.js.map +1 -1
- package/dist/theme-editor.cjs +339 -27
- package/dist/theme-editor.d.cts +114 -0
- package/dist/theme-editor.d.ts +114 -0
- package/dist/theme-editor.js +339 -27
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.js +1 -1
- package/dist/widget.css +110 -0
- package/package.json +1 -1
- package/src/components/tool-bubble.ts +121 -1
- package/src/defaults.ts +1 -0
- package/src/styles/widget.css +110 -0
- package/src/theme-reference.ts +6 -3
- package/src/tool-call-display-defaults.test.ts +1 -0
- package/src/types.ts +120 -0
- package/src/ui.attachments-drop.test.ts +188 -0
- package/src/ui.scroll.test.ts +91 -2
- package/src/ui.ts +221 -7
- package/src/utils/formatting.test.ts +75 -1
- package/src/utils/formatting.ts +130 -0
- package/src/utils/morph.ts +9 -3
package/src/ui.ts
CHANGED
|
@@ -54,6 +54,7 @@ import { MessageTransform, MessageActionCallbacks, LoadingIndicatorRenderer } fr
|
|
|
54
54
|
import { createStandardBubble, createTypingIndicator } from "./components/message-bubble";
|
|
55
55
|
import { createReasoningBubble, reasoningExpansionState, updateReasoningBubbleUI } from "./components/reasoning-bubble";
|
|
56
56
|
import { createToolBubble, toolExpansionState, updateToolBubbleUI } from "./components/tool-bubble";
|
|
57
|
+
import { formatElapsedMs } from "./utils/formatting";
|
|
57
58
|
import { createApprovalBubble } from "./components/approval-bubble";
|
|
58
59
|
import { createSuggestions } from "./components/suggestions";
|
|
59
60
|
import { EventStreamBuffer } from "./utils/event-stream-buffer";
|
|
@@ -147,6 +148,19 @@ function getClipboardImageFiles(clipboardData: DataTransfer | null): File[] {
|
|
|
147
148
|
return imageFiles;
|
|
148
149
|
}
|
|
149
150
|
|
|
151
|
+
function dataTransferHasFiles(
|
|
152
|
+
dataTransfer: DataTransfer | null
|
|
153
|
+
): dataTransfer is DataTransfer {
|
|
154
|
+
if (!dataTransfer) return false;
|
|
155
|
+
const types = dataTransfer.types;
|
|
156
|
+
if (!types) return false;
|
|
157
|
+
// Real browsers return DOMStringList which has .contains(); test polyfills use plain arrays.
|
|
158
|
+
if (typeof (types as unknown as { contains?: unknown }).contains === "function") {
|
|
159
|
+
return (types as unknown as DOMStringList).contains("Files");
|
|
160
|
+
}
|
|
161
|
+
return Array.from(types).includes("Files");
|
|
162
|
+
}
|
|
163
|
+
|
|
150
164
|
// ============================================================================
|
|
151
165
|
// PERSIST STATE HELPERS
|
|
152
166
|
// ============================================================================
|
|
@@ -389,11 +403,43 @@ const buildPostprocessor = (
|
|
|
389
403
|
};
|
|
390
404
|
};
|
|
391
405
|
|
|
406
|
+
function buildDropOverlay(
|
|
407
|
+
dropCfg?: NonNullable<AgentWidgetConfig["attachments"]>["dropOverlay"]
|
|
408
|
+
): HTMLElement {
|
|
409
|
+
const overlay = createElement("div", "persona-attachment-drop-overlay");
|
|
410
|
+
if (dropCfg?.background) overlay.style.setProperty("--persona-drop-overlay-bg", dropCfg.background);
|
|
411
|
+
if (dropCfg?.backdropBlur !== undefined) overlay.style.setProperty("--persona-drop-overlay-blur", dropCfg.backdropBlur);
|
|
412
|
+
if (dropCfg?.border) overlay.style.setProperty("--persona-drop-overlay-border", dropCfg.border);
|
|
413
|
+
if (dropCfg?.borderRadius) overlay.style.setProperty("--persona-drop-overlay-radius", dropCfg.borderRadius);
|
|
414
|
+
if (dropCfg?.inset) overlay.style.setProperty("--persona-drop-overlay-inset", dropCfg.inset);
|
|
415
|
+
if (dropCfg?.labelSize) overlay.style.setProperty("--persona-drop-overlay-label-size", dropCfg.labelSize);
|
|
416
|
+
if (dropCfg?.labelColor) overlay.style.setProperty("--persona-drop-overlay-label-color", dropCfg.labelColor);
|
|
417
|
+
|
|
418
|
+
const iconName = dropCfg?.iconName ?? "upload";
|
|
419
|
+
const iconSize = dropCfg?.iconSize ?? "48px";
|
|
420
|
+
const iconColor = dropCfg?.iconColor ?? "rgba(59, 130, 246, 0.6)";
|
|
421
|
+
const iconStrokeWidth = dropCfg?.iconStrokeWidth ?? 0.5;
|
|
422
|
+
const iconSvg = renderLucideIcon(iconName, iconSize, iconColor, iconStrokeWidth);
|
|
423
|
+
if (iconSvg) overlay.appendChild(iconSvg);
|
|
424
|
+
|
|
425
|
+
if (dropCfg?.label) {
|
|
426
|
+
const labelEl = createElement("span", "persona-drop-overlay-label");
|
|
427
|
+
labelEl.textContent = dropCfg.label;
|
|
428
|
+
overlay.appendChild(labelEl);
|
|
429
|
+
}
|
|
430
|
+
return overlay;
|
|
431
|
+
}
|
|
432
|
+
|
|
392
433
|
export const createAgentExperience = (
|
|
393
434
|
mount: HTMLElement,
|
|
394
435
|
initialConfig?: AgentWidgetConfig,
|
|
395
436
|
runtimeOptions?: { debugTools?: boolean }
|
|
396
437
|
): Controller => {
|
|
438
|
+
if (mount == null) {
|
|
439
|
+
throw new Error(
|
|
440
|
+
"createAgentExperience: mount must be a non-null HTMLElement (e.g. pass document.getElementById(\"my-root\") after the node exists)."
|
|
441
|
+
);
|
|
442
|
+
}
|
|
397
443
|
// Preserve original mount id as data attribute for window event instance scoping
|
|
398
444
|
if (mount.id && !mount.getAttribute("data-persona-instance")) {
|
|
399
445
|
mount.setAttribute("data-persona-instance", mount.id);
|
|
@@ -870,8 +916,21 @@ export const createAgentExperience = (
|
|
|
870
916
|
return composerElements.footer;
|
|
871
917
|
},
|
|
872
918
|
onSubmit: (text: string) => {
|
|
873
|
-
if (session
|
|
874
|
-
|
|
919
|
+
if (!session || session.isStreaming()) return;
|
|
920
|
+
const value = text.trim();
|
|
921
|
+
const hasAttachments = attachmentManager?.hasAttachments() ?? false;
|
|
922
|
+
if (!value && !hasAttachments) return;
|
|
923
|
+
let contentParts: ContentPart[] | undefined;
|
|
924
|
+
if (hasAttachments) {
|
|
925
|
+
contentParts = [];
|
|
926
|
+
contentParts.push(...attachmentManager!.getContentParts());
|
|
927
|
+
if (value) {
|
|
928
|
+
contentParts.push(createTextPart(value));
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
session.sendMessage(value, { contentParts });
|
|
932
|
+
if (hasAttachments) {
|
|
933
|
+
attachmentManager!.clearAttachments();
|
|
875
934
|
}
|
|
876
935
|
},
|
|
877
936
|
streaming: false,
|
|
@@ -959,6 +1018,10 @@ export const createAgentExperience = (
|
|
|
959
1018
|
attachmentManager?.handleFileSelect(target.files);
|
|
960
1019
|
target.value = "";
|
|
961
1020
|
});
|
|
1021
|
+
|
|
1022
|
+
const dropCfg = config.attachments.dropOverlay;
|
|
1023
|
+
const overlay = buildDropOverlay(dropCfg);
|
|
1024
|
+
container.appendChild(overlay);
|
|
962
1025
|
}
|
|
963
1026
|
|
|
964
1027
|
// Slot system: allow custom content injection into specific regions
|
|
@@ -1925,9 +1988,15 @@ export const createAgentExperience = (
|
|
|
1925
1988
|
let lastScrollTop = 0;
|
|
1926
1989
|
let scrollRAF: number | null = null;
|
|
1927
1990
|
let isAutoScrolling = false;
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1991
|
+
let hasPendingAutoScroll = false;
|
|
1992
|
+
|
|
1993
|
+
// Scroll events caused by layout, scroll anchoring, and smooth-scroll
|
|
1994
|
+
// easing can easily move by a couple pixels. Keep manual wheel intent
|
|
1995
|
+
// responsive, but require a slightly larger raw scroll delta before we
|
|
1996
|
+
// treat a plain scroll event as the user breaking away.
|
|
1997
|
+
const USER_SCROLL_THRESHOLD = 4;
|
|
1998
|
+
const BOTTOM_THRESHOLD = 24;
|
|
1999
|
+
const AUTO_SCROLL_SNAP_THRESHOLD = 80;
|
|
1931
2000
|
const messageState = new Map<
|
|
1932
2001
|
string,
|
|
1933
2002
|
{ streaming?: boolean; role: AgentWidgetMessage["role"] }
|
|
@@ -2041,6 +2110,7 @@ export const createAgentExperience = (
|
|
|
2041
2110
|
cancelAnimationFrame(scrollRAF);
|
|
2042
2111
|
scrollRAF = null;
|
|
2043
2112
|
}
|
|
2113
|
+
hasPendingAutoScroll = false;
|
|
2044
2114
|
cancelSmoothScroll();
|
|
2045
2115
|
};
|
|
2046
2116
|
|
|
@@ -2076,10 +2146,25 @@ export const createAgentExperience = (
|
|
|
2076
2146
|
|
|
2077
2147
|
if (!force && !isStreaming) return;
|
|
2078
2148
|
|
|
2079
|
-
|
|
2149
|
+
// Only cancel the pending schedule rAF — keep the ongoing smooth scroll
|
|
2150
|
+
// animation alive so isAutoScrolling stays true. This prevents scroll
|
|
2151
|
+
// events fired by DOM morphing (between cancel and the next rAF) from
|
|
2152
|
+
// being misinterpreted as user-initiated upward scrolls that would
|
|
2153
|
+
// permanently pause auto-follow during streaming.
|
|
2154
|
+
// smoothScrollToBottom() already calls cancelSmoothScroll() internally
|
|
2155
|
+
// before starting its new animation.
|
|
2156
|
+
if (scrollRAF !== null) {
|
|
2157
|
+
cancelAnimationFrame(scrollRAF);
|
|
2158
|
+
scrollRAF = null;
|
|
2159
|
+
}
|
|
2080
2160
|
|
|
2161
|
+
// Treat the render -> next-rAF window as programmatic scrolling too.
|
|
2162
|
+
// This prevents layout/scroll-anchoring scroll events fired before the
|
|
2163
|
+
// actual smooth scroll starts from being misread as user intent.
|
|
2164
|
+
hasPendingAutoScroll = true;
|
|
2081
2165
|
scrollRAF = requestAnimationFrame(() => {
|
|
2082
2166
|
scrollRAF = null;
|
|
2167
|
+
hasPendingAutoScroll = false;
|
|
2083
2168
|
if (!autoFollow.isFollowing()) return;
|
|
2084
2169
|
smoothScrollToBottom(getScrollableContainer(), force ? 220 : 140);
|
|
2085
2170
|
});
|
|
@@ -2098,6 +2183,18 @@ export const createAgentExperience = (
|
|
|
2098
2183
|
return;
|
|
2099
2184
|
}
|
|
2100
2185
|
|
|
2186
|
+
// If the transcript has fallen noticeably behind, catch up immediately
|
|
2187
|
+
// instead of easing over multiple frames. This keeps fast streaming /
|
|
2188
|
+
// bursty tool and reasoning updates pinned to the bottom.
|
|
2189
|
+
if (Math.abs(distance) >= AUTO_SCROLL_SNAP_THRESHOLD) {
|
|
2190
|
+
cancelSmoothScroll();
|
|
2191
|
+
isAutoScrolling = true;
|
|
2192
|
+
element.scrollTop = target;
|
|
2193
|
+
lastScrollTop = element.scrollTop;
|
|
2194
|
+
isAutoScrolling = false;
|
|
2195
|
+
return;
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2101
2198
|
// Cancel any ongoing smooth scroll animation
|
|
2102
2199
|
cancelSmoothScroll();
|
|
2103
2200
|
|
|
@@ -2881,9 +2978,33 @@ export const createAgentExperience = (
|
|
|
2881
2978
|
};
|
|
2882
2979
|
}
|
|
2883
2980
|
|
|
2981
|
+
// Global timer for live-updating tool elapsed time spans.
|
|
2982
|
+
// Runs at 100ms while any [data-tool-elapsed] span exists in the message area,
|
|
2983
|
+
// auto-stops when none remain. Operates on real DOM after morph, not temp elements.
|
|
2984
|
+
let toolElapsedTimerId: ReturnType<typeof setInterval> | null = null;
|
|
2985
|
+
const ensureToolElapsedTimer = () => {
|
|
2986
|
+
if (toolElapsedTimerId != null) return;
|
|
2987
|
+
toolElapsedTimerId = setInterval(() => {
|
|
2988
|
+
const spans = messagesWrapper.querySelectorAll<HTMLElement>("[data-tool-elapsed]");
|
|
2989
|
+
if (spans.length === 0) {
|
|
2990
|
+
clearInterval(toolElapsedTimerId!);
|
|
2991
|
+
toolElapsedTimerId = null;
|
|
2992
|
+
return;
|
|
2993
|
+
}
|
|
2994
|
+
const now = Date.now();
|
|
2995
|
+
spans.forEach((span) => {
|
|
2996
|
+
const startedAt = Number(span.getAttribute("data-tool-elapsed"));
|
|
2997
|
+
if (!startedAt) return;
|
|
2998
|
+
span.textContent = formatElapsedMs(now - startedAt);
|
|
2999
|
+
});
|
|
3000
|
+
}, 100);
|
|
3001
|
+
};
|
|
3002
|
+
|
|
2884
3003
|
session = new AgentWidgetSession(config, {
|
|
2885
3004
|
onMessagesChanged(messages) {
|
|
2886
3005
|
renderMessagesWithPlugins(messagesWrapper, messages, postprocess);
|
|
3006
|
+
// Start elapsed timer if any active tool has a live duration span
|
|
3007
|
+
ensureToolElapsedTimer();
|
|
2887
3008
|
// Re-render suggestions to hide them after first user message
|
|
2888
3009
|
// Pass messages directly to avoid calling session.getMessages() during construction
|
|
2889
3010
|
if (session) {
|
|
@@ -3776,7 +3897,7 @@ export const createAgentExperience = (
|
|
|
3776
3897
|
lastScrollTop,
|
|
3777
3898
|
nearBottom: isElementNearBottom(body, BOTTOM_THRESHOLD),
|
|
3778
3899
|
userScrollThreshold: USER_SCROLL_THRESHOLD,
|
|
3779
|
-
isAutoScrolling,
|
|
3900
|
+
isAutoScrolling: isAutoScrolling || hasPendingAutoScroll,
|
|
3780
3901
|
pauseOnUpwardScroll: true,
|
|
3781
3902
|
pauseWhenAwayFromBottom: false,
|
|
3782
3903
|
resumeRequiresDownwardScroll: true
|
|
@@ -3915,6 +4036,78 @@ export const createAgentExperience = (
|
|
|
3915
4036
|
textarea?.addEventListener("keydown", handleInputEnter);
|
|
3916
4037
|
textarea?.addEventListener("paste", handleInputPaste);
|
|
3917
4038
|
|
|
4039
|
+
const ATTACHMENT_DROP_ACTIVE_CLASS = "persona-attachment-drop-active";
|
|
4040
|
+
let attachmentFileDragDepth = 0;
|
|
4041
|
+
|
|
4042
|
+
const clearAttachmentDropVisual = () => {
|
|
4043
|
+
attachmentFileDragDepth = 0;
|
|
4044
|
+
container.classList.remove(ATTACHMENT_DROP_ACTIVE_CLASS);
|
|
4045
|
+
};
|
|
4046
|
+
|
|
4047
|
+
const attachmentDropHandlingActive = (): boolean =>
|
|
4048
|
+
config.attachments?.enabled === true && attachmentManager !== null;
|
|
4049
|
+
|
|
4050
|
+
// Visual highlight tracked on `container` (the chat column).
|
|
4051
|
+
const handleAttachmentDragEnterCapture = (e: DragEvent) => {
|
|
4052
|
+
if (!dataTransferHasFiles(e.dataTransfer) || !attachmentDropHandlingActive()) return;
|
|
4053
|
+
attachmentFileDragDepth++;
|
|
4054
|
+
if (attachmentFileDragDepth === 1) {
|
|
4055
|
+
container.classList.add(ATTACHMENT_DROP_ACTIVE_CLASS);
|
|
4056
|
+
}
|
|
4057
|
+
};
|
|
4058
|
+
|
|
4059
|
+
const handleAttachmentDragLeaveCapture = (e: DragEvent) => {
|
|
4060
|
+
if (!dataTransferHasFiles(e.dataTransfer) || !attachmentDropHandlingActive()) return;
|
|
4061
|
+
attachmentFileDragDepth--;
|
|
4062
|
+
if (attachmentFileDragDepth <= 0) {
|
|
4063
|
+
clearAttachmentDropVisual();
|
|
4064
|
+
}
|
|
4065
|
+
};
|
|
4066
|
+
|
|
4067
|
+
// dragover + drop registered on `mount` so the browser default (open file)
|
|
4068
|
+
// is suppressed across the entire widget surface (artifact pane, gaps, etc.).
|
|
4069
|
+
const handleAttachmentDragOverCapture = (e: DragEvent) => {
|
|
4070
|
+
if (!dataTransferHasFiles(e.dataTransfer) || !attachmentDropHandlingActive()) return;
|
|
4071
|
+
e.preventDefault();
|
|
4072
|
+
e.dataTransfer.dropEffect = "copy";
|
|
4073
|
+
};
|
|
4074
|
+
|
|
4075
|
+
const handleAttachmentDropCapture = (e: DragEvent) => {
|
|
4076
|
+
if (!dataTransferHasFiles(e.dataTransfer) || !attachmentDropHandlingActive()) return;
|
|
4077
|
+
e.preventDefault();
|
|
4078
|
+
e.stopPropagation();
|
|
4079
|
+
clearAttachmentDropVisual();
|
|
4080
|
+
const files = Array.from(e.dataTransfer.files ?? []);
|
|
4081
|
+
if (files.length === 0) return;
|
|
4082
|
+
void attachmentManager!.handleFiles(files);
|
|
4083
|
+
};
|
|
4084
|
+
|
|
4085
|
+
const attachmentDropCapture = true;
|
|
4086
|
+
container.addEventListener("dragenter", handleAttachmentDragEnterCapture, attachmentDropCapture);
|
|
4087
|
+
container.addEventListener("dragleave", handleAttachmentDragLeaveCapture, attachmentDropCapture);
|
|
4088
|
+
mount.addEventListener("dragover", handleAttachmentDragOverCapture, attachmentDropCapture);
|
|
4089
|
+
mount.addEventListener("drop", handleAttachmentDropCapture, attachmentDropCapture);
|
|
4090
|
+
|
|
4091
|
+
// Prevent the browser from navigating to/opening a dropped file anywhere on
|
|
4092
|
+
// the page while this widget instance has attachments enabled. These guards
|
|
4093
|
+
// intentionally skip the `dataTransferHasFiles` check because real OS drags
|
|
4094
|
+
// may expose `dataTransfer.types` as a DOMStringList or restrict access
|
|
4095
|
+
// during certain drag phases. The cost is minimal: we suppress the native
|
|
4096
|
+
// "open file" default for ALL drag-overs while the widget is alive and
|
|
4097
|
+
// attachments are on — text drags into the textarea still work because
|
|
4098
|
+
// element-level handlers are unaffected (we don't stopPropagation here).
|
|
4099
|
+
const ownerDoc = mount.ownerDocument;
|
|
4100
|
+
const handleDocDragOver = (e: DragEvent) => {
|
|
4101
|
+
if (!attachmentDropHandlingActive()) return;
|
|
4102
|
+
e.preventDefault();
|
|
4103
|
+
};
|
|
4104
|
+
const handleDocDrop = (e: DragEvent) => {
|
|
4105
|
+
if (!attachmentDropHandlingActive()) return;
|
|
4106
|
+
e.preventDefault();
|
|
4107
|
+
};
|
|
4108
|
+
ownerDoc.addEventListener("dragover", handleDocDragOver);
|
|
4109
|
+
ownerDoc.addEventListener("drop", handleDocDrop);
|
|
4110
|
+
|
|
3918
4111
|
destroyCallbacks.push(() => {
|
|
3919
4112
|
if (composerForm) {
|
|
3920
4113
|
composerForm.removeEventListener("submit", handleSubmit);
|
|
@@ -3923,6 +4116,16 @@ export const createAgentExperience = (
|
|
|
3923
4116
|
textarea?.removeEventListener("paste", handleInputPaste);
|
|
3924
4117
|
});
|
|
3925
4118
|
|
|
4119
|
+
destroyCallbacks.push(() => {
|
|
4120
|
+
container.removeEventListener("dragenter", handleAttachmentDragEnterCapture, attachmentDropCapture);
|
|
4121
|
+
container.removeEventListener("dragleave", handleAttachmentDragLeaveCapture, attachmentDropCapture);
|
|
4122
|
+
mount.removeEventListener("dragover", handleAttachmentDragOverCapture, attachmentDropCapture);
|
|
4123
|
+
mount.removeEventListener("drop", handleAttachmentDropCapture, attachmentDropCapture);
|
|
4124
|
+
ownerDoc.removeEventListener("dragover", handleDocDragOver);
|
|
4125
|
+
ownerDoc.removeEventListener("drop", handleDocDrop);
|
|
4126
|
+
clearAttachmentDropVisual();
|
|
4127
|
+
});
|
|
4128
|
+
|
|
3926
4129
|
destroyCallbacks.push(() => {
|
|
3927
4130
|
session.cancel();
|
|
3928
4131
|
});
|
|
@@ -4958,6 +5161,11 @@ export const createAgentExperience = (
|
|
|
4958
5161
|
}
|
|
4959
5162
|
});
|
|
4960
5163
|
}
|
|
5164
|
+
|
|
5165
|
+
// Create drop overlay if missing
|
|
5166
|
+
if (!container.querySelector(".persona-attachment-drop-overlay")) {
|
|
5167
|
+
container.appendChild(buildDropOverlay(attachmentsConfig.dropOverlay));
|
|
5168
|
+
}
|
|
4961
5169
|
} else {
|
|
4962
5170
|
// Show existing attachment button and update config
|
|
4963
5171
|
attachmentButtonWrapper.style.display = "";
|
|
@@ -4987,6 +5195,8 @@ export const createAgentExperience = (
|
|
|
4987
5195
|
if (attachmentManager) {
|
|
4988
5196
|
attachmentManager.clearAttachments();
|
|
4989
5197
|
}
|
|
5198
|
+
// Remove drop overlay
|
|
5199
|
+
container.querySelector(".persona-attachment-drop-overlay")?.remove();
|
|
4990
5200
|
}
|
|
4991
5201
|
|
|
4992
5202
|
// Update send button styling
|
|
@@ -5548,6 +5758,10 @@ export const createAgentExperience = (
|
|
|
5548
5758
|
return session.submitNPSFeedback(rating, comment);
|
|
5549
5759
|
},
|
|
5550
5760
|
destroy() {
|
|
5761
|
+
if (toolElapsedTimerId != null) {
|
|
5762
|
+
clearInterval(toolElapsedTimerId);
|
|
5763
|
+
toolElapsedTimerId = null;
|
|
5764
|
+
}
|
|
5551
5765
|
destroyCallbacks.forEach((cb) => cb());
|
|
5552
5766
|
wrapper.remove();
|
|
5553
5767
|
launcherButtonInstance?.destroy();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { createJsonStreamParser } from "./formatting";
|
|
2
|
+
import { createJsonStreamParser, parseFormattedTemplate } from "./formatting";
|
|
3
3
|
|
|
4
4
|
describe("JSON Stream Parser", () => {
|
|
5
5
|
it("should extract text field incrementally as JSON streams in", () => {
|
|
@@ -170,3 +170,77 @@ describe("JSON Stream Parser", () => {
|
|
|
170
170
|
expect(finalResult).toBe("You're welcome! Enjoy your browsing, and I'm here if you need anything!");
|
|
171
171
|
});
|
|
172
172
|
});
|
|
173
|
+
|
|
174
|
+
describe("parseFormattedTemplate", () => {
|
|
175
|
+
it("returns plain text segments when no formatting markers are present", () => {
|
|
176
|
+
const segments = parseFormattedTemplate("Calling {toolName}...", "Get Weather");
|
|
177
|
+
expect(segments).toEqual([
|
|
178
|
+
{ text: "Calling Get Weather...", styles: [] },
|
|
179
|
+
]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("resolves {toolName} placeholder", () => {
|
|
183
|
+
const segments = parseFormattedTemplate("{toolName} running", "Search Catalog");
|
|
184
|
+
expect(segments).toEqual([
|
|
185
|
+
{ text: "Search Catalog running", styles: [] },
|
|
186
|
+
]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("parses ~dim~ markers", () => {
|
|
190
|
+
const segments = parseFormattedTemplate("Finished {toolName} ~{duration}~", "Get Weather");
|
|
191
|
+
expect(segments).toEqual([
|
|
192
|
+
{ text: "Finished Get Weather ", styles: [] },
|
|
193
|
+
{ text: "{duration}", styles: ["dim"], isDuration: true },
|
|
194
|
+
]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("parses *italic* markers", () => {
|
|
198
|
+
const segments = parseFormattedTemplate("*{toolName}* completed", "Search");
|
|
199
|
+
expect(segments).toEqual([
|
|
200
|
+
{ text: "Search", styles: ["italic"] },
|
|
201
|
+
{ text: " completed", styles: [] },
|
|
202
|
+
]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("parses **bold** markers", () => {
|
|
206
|
+
const segments = parseFormattedTemplate("**Calling** {toolName}", "Lookup");
|
|
207
|
+
expect(segments).toEqual([
|
|
208
|
+
{ text: "Calling", styles: ["bold"] },
|
|
209
|
+
{ text: " Lookup", styles: [] },
|
|
210
|
+
]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("handles multiple formatting markers in one template", () => {
|
|
214
|
+
const segments = parseFormattedTemplate("**Done** *{toolName}* ~{duration}~", "API");
|
|
215
|
+
expect(segments).toEqual([
|
|
216
|
+
{ text: "Done", styles: ["bold"] },
|
|
217
|
+
{ text: " ", styles: [] },
|
|
218
|
+
{ text: "API", styles: ["italic"] },
|
|
219
|
+
{ text: " ", styles: [] },
|
|
220
|
+
{ text: "{duration}", styles: ["dim"], isDuration: true },
|
|
221
|
+
]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("handles {duration} without formatting markers", () => {
|
|
225
|
+
const segments = parseFormattedTemplate("Ran for {duration}", "Tool");
|
|
226
|
+
expect(segments).toEqual([
|
|
227
|
+
{ text: "Ran for ", styles: [] },
|
|
228
|
+
{ text: "{duration}", styles: [], isDuration: true },
|
|
229
|
+
]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("handles template with no placeholders", () => {
|
|
233
|
+
const segments = parseFormattedTemplate("Running...", "Ignored");
|
|
234
|
+
expect(segments).toEqual([
|
|
235
|
+
{ text: "Running...", styles: [] },
|
|
236
|
+
]);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("handles empty tool name fallback in template", () => {
|
|
240
|
+
const segments = parseFormattedTemplate("{toolName}", " ");
|
|
241
|
+
// toolName is resolved before parsing, so whitespace stays
|
|
242
|
+
expect(segments).toEqual([
|
|
243
|
+
{ text: " ", styles: [] },
|
|
244
|
+
]);
|
|
245
|
+
});
|
|
246
|
+
});
|
package/src/utils/formatting.ts
CHANGED
|
@@ -87,6 +87,136 @@ export const describeToolTitle = (tool: AgentWidgetToolCall) => {
|
|
|
87
87
|
return "Using tool...";
|
|
88
88
|
};
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Formats a millisecond duration as a short human-readable string.
|
|
92
|
+
* Returns "2.3s", "15s", or "<0.1s".
|
|
93
|
+
*/
|
|
94
|
+
export const formatElapsedMs = (ms: number): string => {
|
|
95
|
+
const seconds = ms / 1000;
|
|
96
|
+
if (seconds < 0.1) return "<0.1s";
|
|
97
|
+
if (seconds >= 10) return `${Math.round(seconds)}s`;
|
|
98
|
+
return `${seconds.toFixed(1).replace(/\.0$/, "")}s`;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Computes the current elapsed time string for a tool call.
|
|
103
|
+
*/
|
|
104
|
+
export const computeToolElapsed = (tool: AgentWidgetToolCall): string => {
|
|
105
|
+
const durationMs =
|
|
106
|
+
typeof tool.duration === "number"
|
|
107
|
+
? tool.duration
|
|
108
|
+
: typeof tool.durationMs === "number"
|
|
109
|
+
? tool.durationMs
|
|
110
|
+
: Math.max(
|
|
111
|
+
0,
|
|
112
|
+
(tool.completedAt ?? Date.now()) -
|
|
113
|
+
(tool.startedAt ?? tool.completedAt ?? Date.now())
|
|
114
|
+
);
|
|
115
|
+
return formatElapsedMs(durationMs);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Resolves a text template with tool call placeholders.
|
|
120
|
+
* Supported placeholders: {toolName}, {duration}
|
|
121
|
+
* Returns the fallback if template is undefined.
|
|
122
|
+
*/
|
|
123
|
+
export const resolveToolHeaderText = (
|
|
124
|
+
tool: AgentWidgetToolCall,
|
|
125
|
+
template: string | undefined,
|
|
126
|
+
fallback: string
|
|
127
|
+
): string => {
|
|
128
|
+
if (!template) return fallback;
|
|
129
|
+
|
|
130
|
+
const toolName = tool.name?.trim() || "tool";
|
|
131
|
+
const duration = computeToolElapsed(tool);
|
|
132
|
+
|
|
133
|
+
return template
|
|
134
|
+
.replace(/\{toolName\}/g, toolName)
|
|
135
|
+
.replace(/\{duration\}/g, duration);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A segment of parsed template text with optional inline formatting.
|
|
140
|
+
*/
|
|
141
|
+
export interface TemplateSegment {
|
|
142
|
+
/** The text content (or "{duration}" for duration placeholders) */
|
|
143
|
+
text: string;
|
|
144
|
+
/** CSS modifier names to apply: "dim", "bold", "italic" */
|
|
145
|
+
styles: string[];
|
|
146
|
+
/** True when this segment represents a {duration} placeholder */
|
|
147
|
+
isDuration?: boolean;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Parses a template string with inline formatting markers into segments.
|
|
152
|
+
*
|
|
153
|
+
* Supported markers (Markdown-like):
|
|
154
|
+
* - `**text**` → bold
|
|
155
|
+
* - `*text*` → italic
|
|
156
|
+
* - `~text~` → dim / muted
|
|
157
|
+
*
|
|
158
|
+
* Placeholders `{toolName}` are resolved; `{duration}` is preserved as a
|
|
159
|
+
* typed segment so the caller can render it as a live-updating DOM node.
|
|
160
|
+
*
|
|
161
|
+
* @example
|
|
162
|
+
* parseFormattedTemplate("Finished {toolName} ~{duration}~", "Get Weather")
|
|
163
|
+
* // → [
|
|
164
|
+
* // { text: "Finished Get Weather ", styles: [] },
|
|
165
|
+
* // { text: "{duration}", styles: ["dim"], isDuration: true }
|
|
166
|
+
* // ]
|
|
167
|
+
*/
|
|
168
|
+
export const parseFormattedTemplate = (
|
|
169
|
+
template: string,
|
|
170
|
+
toolName: string
|
|
171
|
+
): TemplateSegment[] => {
|
|
172
|
+
const resolved = template.replace(/\{toolName\}/g, toolName);
|
|
173
|
+
const segments: TemplateSegment[] = [];
|
|
174
|
+
// Order matters: ** must match before *
|
|
175
|
+
const regex = /\*\*(.+?)\*\*|\*(.+?)\*|~(.+?)~/g;
|
|
176
|
+
|
|
177
|
+
let lastIndex = 0;
|
|
178
|
+
let match;
|
|
179
|
+
|
|
180
|
+
while ((match = regex.exec(resolved)) !== null) {
|
|
181
|
+
if (match.index > lastIndex) {
|
|
182
|
+
pushSegments(segments, resolved.slice(lastIndex, match.index), []);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (match[1] !== undefined) {
|
|
186
|
+
pushSegments(segments, match[1], ["bold"]);
|
|
187
|
+
} else if (match[2] !== undefined) {
|
|
188
|
+
pushSegments(segments, match[2], ["italic"]);
|
|
189
|
+
} else if (match[3] !== undefined) {
|
|
190
|
+
pushSegments(segments, match[3], ["dim"]);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
lastIndex = match.index + match[0].length;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (lastIndex < resolved.length) {
|
|
197
|
+
pushSegments(segments, resolved.slice(lastIndex), []);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return segments;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/** Splits text on {duration} and pushes typed segments. */
|
|
204
|
+
const pushSegments = (
|
|
205
|
+
segments: TemplateSegment[],
|
|
206
|
+
text: string,
|
|
207
|
+
styles: string[]
|
|
208
|
+
): void => {
|
|
209
|
+
const parts = text.split("{duration}");
|
|
210
|
+
for (let i = 0; i < parts.length; i++) {
|
|
211
|
+
if (parts[i]) {
|
|
212
|
+
segments.push({ text: parts[i], styles });
|
|
213
|
+
}
|
|
214
|
+
if (i < parts.length - 1) {
|
|
215
|
+
segments.push({ text: "{duration}", styles, isDuration: true });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
90
220
|
/**
|
|
91
221
|
* Creates a regex-based parser for extracting text from JSON streams.
|
|
92
222
|
* This is a simpler alternative to schema-stream that uses regex to extract
|
package/src/utils/morph.ts
CHANGED
|
@@ -21,14 +21,20 @@ export const morphMessages = (
|
|
|
21
21
|
Idiomorph.morph(container, newContent.innerHTML, {
|
|
22
22
|
morphStyle: "innerHTML",
|
|
23
23
|
callbacks: {
|
|
24
|
-
beforeNodeMorphed(oldNode: Node,
|
|
24
|
+
beforeNodeMorphed(oldNode: Node, newNode: Node): boolean | void {
|
|
25
25
|
if (!(oldNode instanceof HTMLElement)) return;
|
|
26
26
|
|
|
27
27
|
// Preserve typing indicator dots to maintain animation continuity
|
|
28
28
|
// Also preserve elements with data-preserve-animation attribute for custom loading indicators
|
|
29
29
|
if (preserveTypingAnimation) {
|
|
30
|
-
if (oldNode.classList.contains("persona-animate-typing")
|
|
31
|
-
|
|
30
|
+
if (oldNode.classList.contains("persona-animate-typing")) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
if (oldNode.hasAttribute("data-preserve-animation")) {
|
|
34
|
+
// Allow morph when the new node drops the attribute (e.g. tool completed)
|
|
35
|
+
if (newNode instanceof HTMLElement && !newNode.hasAttribute("data-preserve-animation")) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
32
38
|
return false;
|
|
33
39
|
}
|
|
34
40
|
}
|