@runtypelabs/persona 4.30.0 → 4.32.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/README.md +22 -1
- package/dist/activity-ui.cjs +2 -2
- package/dist/activity-ui.d.cts +11 -3
- package/dist/activity-ui.d.ts +11 -3
- package/dist/activity-ui.js +1 -1
- 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.d.cts +2 -2
- package/dist/approval-ui.d.ts +2 -2
- package/dist/artifacts-ui.d.cts +3 -3
- package/dist/artifacts-ui.d.ts +3 -3
- package/dist/background-runtime.d.cts +1 -1
- package/dist/background-runtime.d.ts +1 -1
- package/dist/chunk-C2IbR39s.js +1 -0
- package/dist/chunk-CkE90FtA.js +2 -0
- package/dist/{chunk-n96BB0YH.js → chunk-CzZBmTLd.js} +1 -1
- package/dist/chunk-DABqu7C3.js +2 -0
- package/dist/chunk-DDAwu89-.js +1 -0
- package/dist/{chunk-CqEWCkJt.js → chunk-DZg9bFK6.js} +1 -1
- package/dist/chunk-DbD5qyBw.js +3 -0
- package/dist/{chunk-gayfbmEU.js → chunk-FEzOcZof.js} +1 -1
- package/dist/{chunk-UyZbbbz3.js → chunk-MdlQetfC.js} +2 -2
- package/dist/client-dispatch.js +1 -1
- package/dist/client-history.js +1 -1
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/composer-actions-runtime.d.cts +2 -2
- package/dist/composer-actions-runtime.d.ts +2 -2
- package/dist/{composer-input-C76gfIoK.d.cts → composer-input-dwg_o9Z-.d.cts} +1 -1
- package/dist/{composer-input-C76gfIoK.d.ts → composer-input-dwg_o9Z-.d.ts} +1 -1
- package/dist/{composer-store-C7tvaz90.d.cts → composer-store-Dw7Zbqxr.d.cts} +1 -1
- package/dist/{composer-store-C7tvaz90.d.ts → composer-store-Dw7Zbqxr.d.ts} +1 -1
- package/dist/{context-mention-manager-IfT4JHma.d.cts → context-mention-manager-D2p0-JH-.d.cts} +2 -2
- package/dist/{context-mention-manager-IfT4JHma.d.ts → context-mention-manager-D2p0-JH-.d.ts} +2 -2
- 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-Cc_p0PGM.d.cts → defaults-CjX0kKzb.d.cts} +1 -1
- package/dist/{defaults-Cc_p0PGM.d.ts → defaults-CjX0kKzb.d.ts} +1 -1
- package/dist/dictation-runtime.d.cts +2 -2
- package/dist/dictation-runtime.d.ts +2 -2
- package/dist/event-stream-view.d.cts +1 -1
- package/dist/event-stream-view.d.ts +1 -1
- package/dist/{fallback-speech-engine-CSxWpG0A.d.cts → fallback-speech-engine-DmF7MczI.d.cts} +1 -1
- package/dist/{fallback-speech-engine-CSxWpG0A.d.ts → fallback-speech-engine-DmF7MczI.d.ts} +1 -1
- package/dist/{feature-preferences-eeSOmDxg.d.cts → feature-preferences-CVFDt9Xf.d.cts} +1 -1
- package/dist/{feature-preferences-eeSOmDxg.d.ts → feature-preferences-CVFDt9Xf.d.ts} +1 -1
- package/dist/forms-ui.d.cts +2 -2
- package/dist/forms-ui.d.ts +2 -2
- package/dist/{history-confirm-dialog-DXkuAgh-.d.cts → history-confirm-dialog-DJEXsTyE.d.cts} +1 -1
- package/dist/{history-confirm-dialog-DXkuAgh-.d.ts → history-confirm-dialog-DJEXsTyE.d.ts} +1 -1
- package/dist/history-shell.d.cts +6 -6
- package/dist/history-shell.d.ts +6 -6
- package/dist/history-view.d.cts +1 -1
- package/dist/history-view.d.ts +1 -1
- package/dist/index.cjs +49 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -9
- package/dist/index.d.ts +9 -9
- package/dist/index.global.js +1 -1
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +43 -41
- package/dist/index.js.map +1 -1
- package/dist/{loading-label-runtime-loader-C53YyPJ-.d.cts → loading-label-runtime-loader-CPxnoaoi.d.cts} +1 -1
- package/dist/{loading-label-runtime-loader-C53YyPJ-.d.ts → loading-label-runtime-loader-CPxnoaoi.d.ts} +1 -1
- package/dist/loading-label-runtime.d.cts +1 -1
- package/dist/loading-label-runtime.d.ts +1 -1
- package/dist/markdown-parsers.js +18 -15
- package/dist/{message-bubble-Cg_sTXUN.d.cts → message-bubble-CkZGlCLC.d.cts} +1 -1
- package/dist/{message-bubble-Cg_sTXUN.d.ts → message-bubble-CkZGlCLC.d.ts} +1 -1
- package/dist/preferences-runtime.d.cts +1 -1
- package/dist/preferences-runtime.d.ts +1 -1
- package/dist/{preview-BeMKALry.d.cts → preview-DdUvoLb4.d.cts} +2 -2
- package/dist/{preview-BeMKALry.d.ts → preview-DdUvoLb4.d.ts} +2 -2
- package/dist/{registry-BBe-Lesw.d.ts → registry-Dz4iHNST.d.cts} +3 -1
- package/dist/{registry-BBe-Lesw.d.cts → registry-Dz4iHNST.d.ts} +3 -1
- package/dist/{session-C0fSteA4.d.cts → session-Baxp9iRK.d.cts} +24 -9
- package/dist/{session-C0fSteA4.d.ts → session-Baxp9iRK.d.ts} +24 -9
- package/dist/session-reconnect.d.cts +2 -2
- package/dist/session-reconnect.d.ts +2 -2
- package/dist/smart-dom-reader.d.cts +1 -1
- package/dist/smart-dom-reader.d.ts +1 -1
- package/dist/theme-editor-preview.cjs +47 -45
- package/dist/theme-editor-preview.d.cts +1 -1
- package/dist/theme-editor-preview.d.ts +1 -1
- package/dist/theme-editor-preview.js +23 -24
- 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/{tool-loading-animation-BvdaZfwh.d.cts → tool-loading-animation-un3kA5Ur.d.cts} +1 -1
- package/dist/{tool-loading-animation-BvdaZfwh.d.ts → tool-loading-animation-un3kA5Ur.d.ts} +1 -1
- package/dist/{turn-activity-ui-BYBlInZQ.d.cts → turn-activity-ui-BGNhtMcH.d.cts} +2 -2
- package/dist/{turn-activity-ui-BYBlInZQ.d.ts → turn-activity-ui-BGNhtMcH.d.ts} +2 -2
- package/dist/turn-activity-ui.d.cts +1 -1
- package/dist/turn-activity-ui.d.ts +1 -1
- package/dist/{types-CiXi4ICM.d.cts → types-BOZHQIk0.d.cts} +13 -1
- package/dist/{types-CiXi4ICM.d.ts → types-BOZHQIk0.d.ts} +13 -1
- package/dist/voice-client-tools.d.cts +1 -1
- package/dist/voice-client-tools.d.ts +1 -1
- package/dist/voice-runtime.cjs +1 -1
- package/dist/voice-runtime.d.cts +2 -2
- package/dist/voice-runtime.d.ts +2 -2
- package/dist/voice-runtime.js +1 -1
- package/dist/voice-worklet-player.d.cts +2 -2
- package/dist/voice-worklet-player.d.ts +2 -2
- package/dist/{webmcp-runtime-entry-BTbOg3Au.d.cts → webmcp-runtime-entry-DuAG1_yF.d.cts} +1 -1
- package/dist/{webmcp-runtime-entry-BTbOg3Au.d.ts → webmcp-runtime-entry-DuAG1_yF.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 +2 -2
- package/src/activity-ui.ts +23 -0
- package/src/components/disclosure-animation.ts +5 -0
- package/src/components/tool-bubble.ts +1 -0
- package/src/index-core.ts +2 -0
- package/src/markdown-parsers-eager.ts +2 -1
- package/src/markdown-parsers-entry.ts +1 -0
- package/src/markdown-parsers-loader.ts +2 -0
- package/src/postprocessors.ts +14 -0
- package/src/session.ts +3 -0
- package/src/styles/widget.css +6 -0
- package/src/types.ts +13 -0
- package/src/ui.background-conversations.test.ts +6 -0
- package/src/ui.footer-visibility.test.ts +54 -0
- package/src/ui.page-cache.test.ts +268 -0
- package/src/ui.postprocess.test.ts +80 -1
- package/src/ui.reveal-tool-call.test.ts +258 -0
- package/src/ui.ts +222 -32
- package/src/utils/actions.ts +3 -0
- package/src/utils/streaming-markdown.test.ts +103 -0
- package/src/utils/streaming-markdown.ts +186 -0
- package/dist/chunk-18tPmsiJ.js +0 -1
- package/dist/chunk-B3OrH9Ng.js +0 -2
- package/dist/chunk-Dp2vWE9V.js +0 -1
- package/dist/chunk-VloQ2dnh.js +0 -1
package/src/ui.ts
CHANGED
|
@@ -11,9 +11,8 @@ import { isBrowserLocalApproval } from "./utils/restored-browser-state";
|
|
|
11
11
|
import { applyStatusIndicatorState, placeStatusIndicator } from "./utils/status-indicator";
|
|
12
12
|
import type { DictationSession } from "./dictation-session";
|
|
13
13
|
import { usesSessionVoice, usesSessionVoiceForConfig } from "./utils/voice-support";
|
|
14
|
-
import { escapeHtml, createMarkdownProcessorFromConfig } from "./postprocessors";
|
|
14
|
+
import { escapeHtml, createMarkdownProcessorFromConfig, preprocessStreamingMarkdown } from "./postprocessors";
|
|
15
15
|
import { resolveSanitizer } from "./utils/sanitize";
|
|
16
|
-
import { stabilizeStreamingTables } from "./utils/streaming-table";
|
|
17
16
|
import { wrapScrollableTables, refreshTableScrollFades } from "./utils/table-scroll-fade";
|
|
18
17
|
import { getMarkdownParsersSync } from "./markdown-parsers-loader";
|
|
19
18
|
import { loadClientStream } from "./client-stream-loader";
|
|
@@ -67,6 +66,7 @@ import {
|
|
|
67
66
|
AgentWidgetContextMentionRef,
|
|
68
67
|
AgentWidgetStoredDraft,
|
|
69
68
|
AgentWidgetOpenConversationOptions,
|
|
69
|
+
AgentWidgetRevealToolCallOptions,
|
|
70
70
|
AgentWidgetSuggestion,
|
|
71
71
|
AgentWidgetSuggestionSource,
|
|
72
72
|
AgentWidgetWelcomeIcon,
|
|
@@ -368,6 +368,8 @@ const VOICE_STATE_RESTORE_WINDOW = 30 * 1000;
|
|
|
368
368
|
const VOICE_RESUME_NOTICE = "Tap the mic to resume your voice call";
|
|
369
369
|
// Split desktop boundary; must match widget.css artifact media queries (min-width:641px).
|
|
370
370
|
const ARTIFACT_SPLIT_DESKTOP_MIN = 641;
|
|
371
|
+
// Covers the default 160ms V5 disclosure transition.
|
|
372
|
+
const REVEAL_SETTLE_MS = 200;
|
|
371
373
|
|
|
372
374
|
/** Marks the status region while a composer lock reason owns it. */
|
|
373
375
|
const COMPOSER_REASON_ATTR = "data-persona-composer-reason";
|
|
@@ -663,6 +665,12 @@ type Controller = {
|
|
|
663
665
|
* (launcher mode) or textarea is unavailable.
|
|
664
666
|
*/
|
|
665
667
|
focusInput: () => boolean;
|
|
668
|
+
/**
|
|
669
|
+
* Bring a tool call into view: opens any collapsed group holding it, expands its details
|
|
670
|
+
* (unless `expand: false`), scrolls it to the center of the transcript and briefly marks it
|
|
671
|
+
* with `data-persona-revealed`. Returns false when no rendered tool bubble has that id.
|
|
672
|
+
*/
|
|
673
|
+
revealToolCall: (toolCallId: string, options?: AgentWidgetRevealToolCallOptions) => boolean;
|
|
666
674
|
/**
|
|
667
675
|
* Programmatically resolve a pending approval.
|
|
668
676
|
* @param approvalId - The approval ID to resolve
|
|
@@ -817,7 +825,8 @@ export const buildPostprocessor = (
|
|
|
817
825
|
// displayed entities literally (I'll -> '). Only run the sanitizer when
|
|
818
826
|
// it can actually parse HTML; escapeHtml output is already inert. Checked per
|
|
819
827
|
// render (not once at setup): the chunk lands later and the self-heal re-renders.
|
|
820
|
-
const
|
|
828
|
+
const parsers = getMarkdownParsersSync();
|
|
829
|
+
const parsersReady = parsers !== null;
|
|
821
830
|
let html: string;
|
|
822
831
|
if (cfg?.postprocessMessage) {
|
|
823
832
|
const out = cfg.postprocessMessage({
|
|
@@ -829,10 +838,9 @@ export const buildPostprocessor = (
|
|
|
829
838
|
// sanitizer's degraded fallback. Honors `sanitize: false` (pass-through) as before.
|
|
830
839
|
html = sanitize ? sanitize(out) : out;
|
|
831
840
|
} else if (markdownProcessor) {
|
|
832
|
-
//
|
|
833
|
-
//
|
|
834
|
-
|
|
835
|
-
const source = context.streaming ? stabilizeStreamingTables(nextText) : nextText;
|
|
841
|
+
// Stabilize incomplete Markdown only for display during streaming. The
|
|
842
|
+
// final render and the stored response always use the original text.
|
|
843
|
+
const source = preprocessStreamingMarkdown(nextText, context.streaming);
|
|
836
844
|
// Already escapeHtml(text) (single, safe) while parsers are not loaded.
|
|
837
845
|
const out = markdownProcessor(source);
|
|
838
846
|
html = sanitize && parsersReady ? sanitize(out) : out;
|
|
@@ -1226,6 +1234,10 @@ export const createAgentExperience = (
|
|
|
1226
1234
|
getSessionMetadata,
|
|
1227
1235
|
updateSessionMetadata,
|
|
1228
1236
|
emit: eventBus.emit,
|
|
1237
|
+
captureResubmitGuard: () => {
|
|
1238
|
+
const generation = restartGeneration;
|
|
1239
|
+
return () => canRestart(generation);
|
|
1240
|
+
},
|
|
1229
1241
|
documentRef: typeof document !== "undefined" ? document : null
|
|
1230
1242
|
});
|
|
1231
1243
|
actionManager.syncFromMetadata();
|
|
@@ -1250,10 +1262,31 @@ export const createAgentExperience = (
|
|
|
1250
1262
|
// Track pending resubmit state for injection-triggered resubmit
|
|
1251
1263
|
// When a handler returns resubmit: true, we wait for injectAssistantMessage()
|
|
1252
1264
|
// to be called before triggering the actual resubmit (to avoid race conditions)
|
|
1265
|
+
// Cached restoration reuses this controller, so work queued by the old page
|
|
1266
|
+
// lifecycle must remain stale even after pageshow makes the page active again.
|
|
1267
|
+
let restartGeneration = 0;
|
|
1268
|
+
let destroyed = false;
|
|
1269
|
+
const canRestart = (generation: number) =>
|
|
1270
|
+
!destroyed && !pageHiding && generation === restartGeneration;
|
|
1271
|
+
const invalidatePendingRestarts = () => {
|
|
1272
|
+
restartGeneration++;
|
|
1273
|
+
pendingResubmit = false;
|
|
1274
|
+
if (pendingResubmitTimeout !== null) clearTimeout(pendingResubmitTimeout);
|
|
1275
|
+
pendingResubmitTimeout = null;
|
|
1276
|
+
};
|
|
1277
|
+
const scheduleResubmit = () => {
|
|
1278
|
+
const generation = restartGeneration;
|
|
1279
|
+
if (!canRestart(generation)) return;
|
|
1280
|
+
setTimeout(() => {
|
|
1281
|
+
if (canRestart(generation) && !session.isStreaming()) session.continueConversation();
|
|
1282
|
+
}, 100);
|
|
1283
|
+
};
|
|
1284
|
+
|
|
1253
1285
|
let pendingResubmit = false;
|
|
1254
1286
|
let pendingResubmitTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
1255
1287
|
|
|
1256
1288
|
const handleResubmitRequested = () => {
|
|
1289
|
+
if (!canRestart(restartGeneration)) return;
|
|
1257
1290
|
pendingResubmit = true;
|
|
1258
1291
|
// Clear any existing timeout
|
|
1259
1292
|
if (pendingResubmitTimeout) {
|
|
@@ -2836,8 +2869,10 @@ export const createAgentExperience = (
|
|
|
2836
2869
|
|
|
2837
2870
|
// Check if the click/keypress is on an expand header button
|
|
2838
2871
|
const headerButton = target.closest('button[data-expand-header="true"]') as HTMLElement;
|
|
2839
|
-
if (
|
|
2872
|
+
if (headerButton) toggleBubbleHeader(headerButton);
|
|
2873
|
+
};
|
|
2840
2874
|
|
|
2875
|
+
const toggleBubbleHeader = (headerButton: HTMLElement) => {
|
|
2841
2876
|
// Find the parent bubble element
|
|
2842
2877
|
const bubble = headerButton.closest('.persona-reasoning-bubble, .persona-tool-bubble, .persona-approval-bubble') as HTMLElement;
|
|
2843
2878
|
if (!bubble) return;
|
|
@@ -2850,6 +2885,12 @@ export const createAgentExperience = (
|
|
|
2850
2885
|
// DOM grows so the resize observer cannot pull the opened details away.
|
|
2851
2886
|
markReaderEngaged();
|
|
2852
2887
|
pauseAutoScroll();
|
|
2888
|
+
const anchor = anchorState;
|
|
2889
|
+
if (anchor) cancelSmoothScroll();
|
|
2890
|
+
const finishDisclosure = anchor && activityUi?.holdScroll(
|
|
2891
|
+
bubble, body, anchor, measureContentHeight, setAnchorSpacerHeight,
|
|
2892
|
+
BOTTOM_THRESHOLD, () => anchorState === anchor
|
|
2893
|
+
);
|
|
2853
2894
|
const bubbleType = headerButton.getAttribute('data-bubble-type');
|
|
2854
2895
|
if (bubble.classList.contains("persona-activity-row")) {
|
|
2855
2896
|
activityLifecycle?.manual(messageId);
|
|
@@ -2876,6 +2917,7 @@ export const createAgentExperience = (
|
|
|
2876
2917
|
approvalDetailsExpansionState.set(messageId, !expanded);
|
|
2877
2918
|
approvalUi.updateApprovalDetailsUI(messageId, bubble, config, approvalDetailsExpansionState);
|
|
2878
2919
|
}
|
|
2920
|
+
finishDisclosure?.();
|
|
2879
2921
|
// Invalidate cached wrapper so next render rebuilds with current expansion state
|
|
2880
2922
|
messageCache.delete(messageId);
|
|
2881
2923
|
};
|
|
@@ -2926,6 +2968,24 @@ export const createAgentExperience = (
|
|
|
2926
2968
|
// This handles clicks even after idiomorph morphs the DOM and strips inline listeners
|
|
2927
2969
|
const messageVoteState = new Map<string, "upvote" | "downvote">();
|
|
2928
2970
|
|
|
2971
|
+
// Reveal state belongs to the message, not its current DOM node: streaming
|
|
2972
|
+
// renders can morph or replace the bubble before the highlight expires.
|
|
2973
|
+
const revealedToolCalls = new Map<string, number>();
|
|
2974
|
+
let revealCenterTimer: number | undefined;
|
|
2975
|
+
let revealScrollGeneration = 0;
|
|
2976
|
+
const refreshRevealedToolCalls = () => {
|
|
2977
|
+
messagesWrapper.querySelectorAll<HTMLElement>(".persona-tool-bubble[data-tool-call-id]").forEach((bubble) => {
|
|
2978
|
+
bubble.toggleAttribute("data-persona-revealed", revealedToolCalls.has(bubble.dataset.messageId!));
|
|
2979
|
+
});
|
|
2980
|
+
};
|
|
2981
|
+
const clearRevealedToolCalls = () => {
|
|
2982
|
+
window.clearTimeout(revealCenterTimer);
|
|
2983
|
+
revealCenterTimer = undefined;
|
|
2984
|
+
revealedToolCalls.forEach((timer) => window.clearTimeout(timer));
|
|
2985
|
+
revealedToolCalls.clear();
|
|
2986
|
+
refreshRevealedToolCalls();
|
|
2987
|
+
};
|
|
2988
|
+
|
|
2929
2989
|
// Read-aloud (text-to-speech) button state. The ReadAloudController in the
|
|
2930
2990
|
// session is the source of truth; these mirror its last-known state so the
|
|
2931
2991
|
// button visuals can be re-applied after every render/morph (which would
|
|
@@ -4026,7 +4086,11 @@ export const createAgentExperience = (
|
|
|
4026
4086
|
body.style.cssText = '';
|
|
4027
4087
|
// A plugin-owned footer is the plugin's DOM: its inline state (the home
|
|
4028
4088
|
// screen's `display: none` placeholder) is not ours to reset.
|
|
4029
|
-
if (!composerIsPluginOwned)
|
|
4089
|
+
if (!composerIsPluginOwned) {
|
|
4090
|
+
footer.style.cssText = '';
|
|
4091
|
+
// The reset also wipes the hidden state that layout.showFooter set.
|
|
4092
|
+
if (config.layout?.showFooter === false) footer.style.display = 'none';
|
|
4093
|
+
}
|
|
4030
4094
|
|
|
4031
4095
|
// Preserve the event-stream takeover across a layout-mode change. The
|
|
4032
4096
|
// cssText reset above wiped the `display: none` that toggleEventStreamOn
|
|
@@ -4378,6 +4442,7 @@ export const createAgentExperience = (
|
|
|
4378
4442
|
border-top: none !important;
|
|
4379
4443
|
padding: 8px 16px 12px 16px !important;
|
|
4380
4444
|
${chatFlush ? 'background: transparent !important;' : ''}
|
|
4445
|
+
${config.layout?.showFooter === false ? 'display: none !important;' : ''}
|
|
4381
4446
|
`;
|
|
4382
4447
|
}
|
|
4383
4448
|
|
|
@@ -5032,6 +5097,7 @@ export const createAgentExperience = (
|
|
|
5032
5097
|
initialSpacerHeight: number;
|
|
5033
5098
|
contentHeightAtAnchor: number;
|
|
5034
5099
|
spacerHeight: number;
|
|
5100
|
+
pending: number;
|
|
5035
5101
|
} | null = null;
|
|
5036
5102
|
let anchorRAF: number | null = null;
|
|
5037
5103
|
// Seeded send-detection so restored history doesn't read as a fresh send.
|
|
@@ -5068,6 +5134,9 @@ export const createAgentExperience = (
|
|
|
5068
5134
|
// look at the scroll event.
|
|
5069
5135
|
let readerEngagedThisTurn = false;
|
|
5070
5136
|
const markReaderEngaged = () => {
|
|
5137
|
+
// Reader input invalidates only reveal scrolling, including its settle pass.
|
|
5138
|
+
// The animation checks this generation before its next scroll write.
|
|
5139
|
+
revealScrollGeneration++;
|
|
5071
5140
|
readerEngagedThisTurn = true;
|
|
5072
5141
|
revealTranscriptScrollbar();
|
|
5073
5142
|
};
|
|
@@ -5166,11 +5235,14 @@ export const createAgentExperience = (
|
|
|
5166
5235
|
const timestamp = Number(rawVoiceState.timestamp ?? 0);
|
|
5167
5236
|
voiceState.manuallyDeactivated = Boolean(rawVoiceState.manuallyDeactivated);
|
|
5168
5237
|
if (wasActive && Date.now() - timestamp < VOICE_STATE_RESTORE_WINDOW) {
|
|
5238
|
+
const generation = restartGeneration;
|
|
5169
5239
|
setTimeout(() => {
|
|
5240
|
+
if (!canRestart(generation)) return;
|
|
5170
5241
|
if (!voiceState.active) {
|
|
5171
5242
|
voiceState.manuallyDeactivated = false;
|
|
5172
5243
|
if (usesSessionVoice(config.voiceRecognition?.provider)) {
|
|
5173
5244
|
session.toggleVoice().then(() => {
|
|
5245
|
+
if (!canRestart(generation)) return;
|
|
5174
5246
|
voiceState.active = isSessionVoiceLive();
|
|
5175
5247
|
emitVoiceState("restore");
|
|
5176
5248
|
if (voiceState.active) {
|
|
@@ -5192,10 +5264,10 @@ export const createAgentExperience = (
|
|
|
5192
5264
|
}
|
|
5193
5265
|
};
|
|
5194
5266
|
// Re-stamp a live call as the page unloads: the restore window counts from
|
|
5195
|
-
// the navigation, not from when the (possibly long) call was started.
|
|
5196
|
-
//
|
|
5197
|
-
const handleVoicePageHide = (
|
|
5198
|
-
pageHiding =
|
|
5267
|
+
// the navigation, not from when the (possibly long) call was started. Protect
|
|
5268
|
+
// that record from the trailing disconnect statuses, including cached hides.
|
|
5269
|
+
const handleVoicePageHide = () => {
|
|
5270
|
+
pageHiding = true;
|
|
5199
5271
|
if (voiceState.active) persistVoiceMetadata();
|
|
5200
5272
|
};
|
|
5201
5273
|
if (typeof window !== "undefined") {
|
|
@@ -5681,7 +5753,7 @@ export const createAgentExperience = (
|
|
|
5681
5753
|
// arrow pointing at nothing. Trim to what the reader's current position
|
|
5682
5754
|
// actually needs plus the anti-bounce slack (shrink-only, like the drain).
|
|
5683
5755
|
const trimAnchorSpacerToRest = () => {
|
|
5684
|
-
if (!anchorState || anchorState.spacerHeight <= 0) return;
|
|
5756
|
+
if (!anchorState || anchorState.spacerHeight <= 0 || anchorState.pending) return;
|
|
5685
5757
|
const contentHeight = measureContentHeight(anchorState.spacerHeight);
|
|
5686
5758
|
const required = Math.max(
|
|
5687
5759
|
0,
|
|
@@ -5761,6 +5833,7 @@ export const createAgentExperience = (
|
|
|
5761
5833
|
});
|
|
5762
5834
|
|
|
5763
5835
|
anchorState = {
|
|
5836
|
+
pending: 0,
|
|
5764
5837
|
initialSpacerHeight: spacerHeight,
|
|
5765
5838
|
contentHeightAtAnchor: contentHeight,
|
|
5766
5839
|
spacerHeight
|
|
@@ -5804,6 +5877,7 @@ export const createAgentExperience = (
|
|
|
5804
5877
|
slack: BOTTOM_THRESHOLD
|
|
5805
5878
|
});
|
|
5806
5879
|
anchorState = {
|
|
5880
|
+
pending: 0,
|
|
5807
5881
|
initialSpacerHeight: spacerHeight,
|
|
5808
5882
|
contentHeightAtAnchor: contentHeight,
|
|
5809
5883
|
spacerHeight
|
|
@@ -5830,7 +5904,7 @@ export const createAgentExperience = (
|
|
|
5830
5904
|
scheduleAutoScroll(!isStreaming);
|
|
5831
5905
|
return;
|
|
5832
5906
|
}
|
|
5833
|
-
if (anchorState && anchorState.initialSpacerHeight > 0) {
|
|
5907
|
+
if (anchorState && anchorState.initialSpacerHeight > 0 && !anchorState.pending) {
|
|
5834
5908
|
const currentContentHeight = body.scrollHeight - anchorState.spacerHeight;
|
|
5835
5909
|
const next = computeShrunkSpacerHeight({
|
|
5836
5910
|
initialSpacerHeight: anchorState.initialSpacerHeight,
|
|
@@ -7158,6 +7232,15 @@ export const createAgentExperience = (
|
|
|
7158
7232
|
if (messages.length > 0) warmClientStream();
|
|
7159
7233
|
renderMessagesWithPluginsImpl(container, messages, transform);
|
|
7160
7234
|
refreshReadAloudButtons();
|
|
7235
|
+
if (revealedToolCalls.size > 0) {
|
|
7236
|
+
for (const [id, timer] of revealedToolCalls) {
|
|
7237
|
+
if (!messages.some((message) => message.id === id)) {
|
|
7238
|
+
window.clearTimeout(timer);
|
|
7239
|
+
revealedToolCalls.delete(id);
|
|
7240
|
+
}
|
|
7241
|
+
}
|
|
7242
|
+
refreshRevealedToolCalls();
|
|
7243
|
+
}
|
|
7161
7244
|
};
|
|
7162
7245
|
|
|
7163
7246
|
/**
|
|
@@ -9132,7 +9215,7 @@ export const createAgentExperience = (
|
|
|
9132
9215
|
currentTurnAnchored = reading.anchored;
|
|
9133
9216
|
anchoredMessageId = reading.anchoredId;
|
|
9134
9217
|
readerEngagedThisTurn = reading.readerEngaged;
|
|
9135
|
-
anchorState = reading.anchor ? { ...reading.anchor } : null;
|
|
9218
|
+
anchorState = reading.anchor ? { ...reading.anchor, pending: 0 } : null;
|
|
9136
9219
|
setAnchorSpacerHeight(reading.anchor?.spacerHeight ?? 0);
|
|
9137
9220
|
if (reading.following) autoFollow.resume(); else autoFollow.pause();
|
|
9138
9221
|
newMessagesSincePause = reading.unread;
|
|
@@ -9625,6 +9708,7 @@ export const createAgentExperience = (
|
|
|
9625
9708
|
// otherwise dispatch the same text/command twice. It also covers an async
|
|
9626
9709
|
// `onBeforeSend` (the `preparing` phase).
|
|
9627
9710
|
let submitInFlight = false;
|
|
9711
|
+
let submitEpoch = 0;
|
|
9628
9712
|
/** Aborts the in-flight preparation on destroy or a superseding stop. */
|
|
9629
9713
|
let submissionAbort: AbortController | null = null;
|
|
9630
9714
|
|
|
@@ -10035,10 +10119,11 @@ export const createAgentExperience = (
|
|
|
10035
10119
|
}
|
|
10036
10120
|
if (submitInFlight) return;
|
|
10037
10121
|
submitInFlight = true;
|
|
10122
|
+
const epoch = ++submitEpoch;
|
|
10038
10123
|
try {
|
|
10039
10124
|
await performSubmit({ resolveInlineCommand: true, ...submitOptions });
|
|
10040
10125
|
} finally {
|
|
10041
|
-
submitInFlight = false;
|
|
10126
|
+
if (epoch === submitEpoch) submitInFlight = false;
|
|
10042
10127
|
}
|
|
10043
10128
|
};
|
|
10044
10129
|
|
|
@@ -10065,8 +10150,9 @@ export const createAgentExperience = (
|
|
|
10065
10150
|
): void => {
|
|
10066
10151
|
if (submitInFlight) return;
|
|
10067
10152
|
submitInFlight = true;
|
|
10153
|
+
const epoch = ++submitEpoch;
|
|
10068
10154
|
const finish = () => {
|
|
10069
|
-
submitInFlight = false;
|
|
10155
|
+
if (epoch === submitEpoch) submitInFlight = false;
|
|
10070
10156
|
};
|
|
10071
10157
|
try {
|
|
10072
10158
|
const result = performSubmit({ ...options, overrideText: text });
|
|
@@ -10508,7 +10594,7 @@ export const createAgentExperience = (
|
|
|
10508
10594
|
|
|
10509
10595
|
voiceState.active = false;
|
|
10510
10596
|
emitVoiceState(source);
|
|
10511
|
-
persistVoiceMetadata();
|
|
10597
|
+
if (!pageHiding) persistVoiceMetadata();
|
|
10512
10598
|
syncComposerCompact();
|
|
10513
10599
|
|
|
10514
10600
|
if (micButton) {
|
|
@@ -10698,9 +10784,10 @@ export const createAgentExperience = (
|
|
|
10698
10784
|
const pendingSubmissionUnsub = eventBus.on("assistant:complete", () => {
|
|
10699
10785
|
const pending = composerStore.getState().pendingSubmission;
|
|
10700
10786
|
const generation = composerConversationGeneration;
|
|
10787
|
+
const restart = restartGeneration;
|
|
10701
10788
|
if (!pending) return;
|
|
10702
10789
|
setTimeout(() => {
|
|
10703
|
-
if (session.isStreaming() || generation !== composerConversationGeneration || composerStore.getState().pendingSubmission !== pending) return;
|
|
10790
|
+
if (!canRestart(restart) || session.isStreaming() || generation !== composerConversationGeneration || composerStore.getState().pendingSubmission !== pending) return;
|
|
10704
10791
|
sendPendingSubmission();
|
|
10705
10792
|
}, 0);
|
|
10706
10793
|
});
|
|
@@ -10713,11 +10800,13 @@ export const createAgentExperience = (
|
|
|
10713
10800
|
return;
|
|
10714
10801
|
}
|
|
10715
10802
|
const generation = composerConversationGeneration;
|
|
10803
|
+
const restart = restartGeneration;
|
|
10716
10804
|
setTimeout(() => {
|
|
10717
|
-
if (generation !== composerConversationGeneration) return;
|
|
10805
|
+
if (!canRestart(restart) || generation !== composerConversationGeneration) return;
|
|
10718
10806
|
if (!voiceState.active && !voiceState.manuallyDeactivated) {
|
|
10719
10807
|
if (usesSessionVoice(config.voiceRecognition?.provider)) {
|
|
10720
10808
|
session.toggleVoice().then(() => {
|
|
10809
|
+
if (!canRestart(restart) || generation !== composerConversationGeneration) return;
|
|
10721
10810
|
voiceState.active = session.isVoiceActive();
|
|
10722
10811
|
emitVoiceState("auto");
|
|
10723
10812
|
if (session.isVoiceActive()) applyRuntypeMicRecordingStyles();
|
|
@@ -10730,17 +10819,50 @@ export const createAgentExperience = (
|
|
|
10730
10819
|
});
|
|
10731
10820
|
destroyCallbacks.push(autoResumeUnsub);
|
|
10732
10821
|
|
|
10822
|
+
// A cached document retains its DOM and controller, but must release live
|
|
10823
|
+
// transports and capture before it freezes. A fresh navigation still uses
|
|
10824
|
+
// the existing teardown/voice-metadata path above.
|
|
10825
|
+
let cachedPage = false;
|
|
10826
|
+
const suspendCachedPage = (event: PageTransitionEvent) => {
|
|
10827
|
+
if (!event.persisted || cachedPage) return;
|
|
10828
|
+
cachedPage = true;
|
|
10829
|
+
pageHiding = true;
|
|
10830
|
+
invalidatePendingRestarts();
|
|
10831
|
+
composerConversationGeneration++;
|
|
10832
|
+
submitEpoch++;
|
|
10833
|
+
submitInFlight = false;
|
|
10834
|
+
submissionAbort?.abort();
|
|
10835
|
+
submissionAbort = null;
|
|
10836
|
+
composerStore.setPreparing(false);
|
|
10837
|
+
dismissDictation();
|
|
10838
|
+
stopVoiceRecognition("system");
|
|
10839
|
+
session.cancel();
|
|
10840
|
+
session.stopSpeaking();
|
|
10841
|
+
session.cleanupVoice();
|
|
10842
|
+
throughputTracker?.reset();
|
|
10843
|
+
eventStreamView?.update();
|
|
10844
|
+
};
|
|
10845
|
+
const restoreCachedPage = (event: PageTransitionEvent) => {
|
|
10846
|
+
if (!event.persisted || !cachedPage) return;
|
|
10847
|
+
cachedPage = false;
|
|
10848
|
+
pageHiding = false;
|
|
10849
|
+
// Rebuild the provider lazily so the same controls can start a new call.
|
|
10850
|
+
// Neither stopped replies nor calls restart automatically on Back.
|
|
10851
|
+
if (config.voiceRecognition?.enabled === true && usesSessionVoiceForConfig(config)) session.setupVoice();
|
|
10852
|
+
};
|
|
10853
|
+
window.addEventListener("pagehide", suspendCachedPage);
|
|
10854
|
+
window.addEventListener("pageshow", restoreCachedPage);
|
|
10855
|
+
destroyCallbacks.push(() => {
|
|
10856
|
+
window.removeEventListener("pagehide", suspendCachedPage);
|
|
10857
|
+
window.removeEventListener("pageshow", restoreCachedPage);
|
|
10858
|
+
});
|
|
10859
|
+
|
|
10733
10860
|
// Handle action:resubmit event - automatically trigger another model call
|
|
10734
10861
|
// when an action handler needs the model to continue processing (e.g., analyzing search results)
|
|
10735
10862
|
const resubmitUnsub = eventBus.on("action:resubmit", () => {
|
|
10736
10863
|
// Short delay to allow UI to update with any injected messages
|
|
10737
10864
|
// Handlers should call context.triggerResubmit() AFTER their async work completes
|
|
10738
|
-
|
|
10739
|
-
if (session && !session.isStreaming()) {
|
|
10740
|
-
// Continue conversation without adding a visible user message
|
|
10741
|
-
session.continueConversation();
|
|
10742
|
-
}
|
|
10743
|
-
}, 100);
|
|
10865
|
+
scheduleResubmit();
|
|
10744
10866
|
});
|
|
10745
10867
|
destroyCallbacks.push(resubmitUnsub);
|
|
10746
10868
|
|
|
@@ -11361,6 +11483,8 @@ export const createAgentExperience = (
|
|
|
11361
11483
|
|
|
11362
11484
|
view.replaceComposer(nextFooter);
|
|
11363
11485
|
footer = view.composer.footer;
|
|
11486
|
+
// A rebuilt footer starts visible; keep honoring layout.showFooter.
|
|
11487
|
+
if (config.layout?.showFooter === false) footer.style.display = "none";
|
|
11364
11488
|
// A plugin composer owns its own send affordance, so the built-in
|
|
11365
11489
|
// send/stop swap has nothing to drive.
|
|
11366
11490
|
const isDefaultFooter =
|
|
@@ -12234,6 +12358,10 @@ export const createAgentExperience = (
|
|
|
12234
12358
|
getSessionMetadata,
|
|
12235
12359
|
updateSessionMetadata,
|
|
12236
12360
|
emit: eventBus.emit,
|
|
12361
|
+
captureResubmitGuard: () => {
|
|
12362
|
+
const generation = restartGeneration;
|
|
12363
|
+
return () => canRestart(generation);
|
|
12364
|
+
},
|
|
12237
12365
|
documentRef: typeof document !== "undefined" ? document : null
|
|
12238
12366
|
});
|
|
12239
12367
|
|
|
@@ -12691,6 +12819,7 @@ export const createAgentExperience = (
|
|
|
12691
12819
|
disposeApprovalRenders();
|
|
12692
12820
|
artifactsPaneUserHidden = false;
|
|
12693
12821
|
activityLifecycle?.clear();
|
|
12822
|
+
clearRevealedToolCalls();
|
|
12694
12823
|
toolExpansionState.clear();
|
|
12695
12824
|
reasoningExpansionState.clear();
|
|
12696
12825
|
approvalDetailsExpansionState.clear();
|
|
@@ -12883,11 +13012,7 @@ export const createAgentExperience = (
|
|
|
12883
13012
|
pendingResubmitTimeout = null;
|
|
12884
13013
|
}
|
|
12885
13014
|
// Short delay to ensure message is in context
|
|
12886
|
-
|
|
12887
|
-
if (session && !session.isStreaming()) {
|
|
12888
|
-
session.continueConversation();
|
|
12889
|
-
}
|
|
12890
|
-
}, 100);
|
|
13015
|
+
scheduleResubmit();
|
|
12891
13016
|
}
|
|
12892
13017
|
|
|
12893
13018
|
return result;
|
|
@@ -13098,6 +13223,68 @@ export const createAgentExperience = (
|
|
|
13098
13223
|
textarea.focus();
|
|
13099
13224
|
return true;
|
|
13100
13225
|
},
|
|
13226
|
+
revealToolCall(toolCallId: string, options: AgentWidgetRevealToolCallOptions = {}): boolean {
|
|
13227
|
+
const bubble = Array.from(body.querySelectorAll<HTMLElement>(".persona-tool-bubble[data-tool-call-id]"))
|
|
13228
|
+
.find((el) => el.dataset.toolCallId === toolCallId);
|
|
13229
|
+
if (!bubble) return false;
|
|
13230
|
+
const ownHeader = (owner: HTMLElement) =>
|
|
13231
|
+
Array.from(owner.querySelectorAll<HTMLElement>('button[data-expand-header="true"]'))
|
|
13232
|
+
.find((header) => header.closest(".persona-tool-bubble") === owner);
|
|
13233
|
+
const openHeader = (header: HTMLElement | undefined) => {
|
|
13234
|
+
if (header?.getAttribute("aria-expanded") === "false") toggleBubbleHeader(header);
|
|
13235
|
+
};
|
|
13236
|
+
const groups: HTMLElement[] = [];
|
|
13237
|
+
for (let group = bubble.parentElement?.closest<HTMLElement>(".persona-tool-bubble"); group;
|
|
13238
|
+
group = group.parentElement?.closest<HTMLElement>(".persona-tool-bubble")) groups.unshift(group);
|
|
13239
|
+
groups.forEach((group) => openHeader(ownHeader(group)));
|
|
13240
|
+
const header = ownHeader(bubble);
|
|
13241
|
+
if (options.expand !== false) openHeader(header);
|
|
13242
|
+
|
|
13243
|
+
markReaderEngaged();
|
|
13244
|
+
// A prior jump owns a separate animation whose completion resumes follow.
|
|
13245
|
+
latestScroll.cancel();
|
|
13246
|
+
pauseAutoScroll();
|
|
13247
|
+
const revealGeneration = revealScrollGeneration;
|
|
13248
|
+
const anchor = header ?? bubble;
|
|
13249
|
+
const shouldContinue = () => !destroyed && revealGeneration === revealScrollGeneration && body.contains(anchor);
|
|
13250
|
+
const reduceMotion = typeof window.matchMedia === "function" &&
|
|
13251
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
13252
|
+
const targetTop = () => {
|
|
13253
|
+
const raw = offsetTopWithinBody(anchor) - (body.clientHeight - anchor.offsetHeight) / 2;
|
|
13254
|
+
return Math.max(0, Math.min(raw, getScrollBottomOffset(body)));
|
|
13255
|
+
};
|
|
13256
|
+
const center = () => {
|
|
13257
|
+
if (!shouldContinue()) return;
|
|
13258
|
+
// Use the same programmatic-scroll tracking as automatic following so
|
|
13259
|
+
// centering near the bottom cannot be mistaken for reader intent.
|
|
13260
|
+
if (reduceMotion) {
|
|
13261
|
+
cancelSmoothScroll();
|
|
13262
|
+
isAutoScrolling = true;
|
|
13263
|
+
body.scrollTop = targetTop();
|
|
13264
|
+
lastScrollTop = body.scrollTop;
|
|
13265
|
+
isAutoScrolling = false;
|
|
13266
|
+
} else animateScrollTo(body, targetTop, REVEAL_SETTLE_MS, shouldContinue);
|
|
13267
|
+
};
|
|
13268
|
+
window.clearTimeout(revealCenterTimer);
|
|
13269
|
+
center();
|
|
13270
|
+
// Disclosure height transitions move the row after the first pass; center again once they settle.
|
|
13271
|
+
revealCenterTimer = window.setTimeout(() => {
|
|
13272
|
+
revealCenterTimer = undefined;
|
|
13273
|
+
center();
|
|
13274
|
+
}, REVEAL_SETTLE_MS);
|
|
13275
|
+
|
|
13276
|
+
const highlightMs = options.highlightMs ?? 2000;
|
|
13277
|
+
if (highlightMs > 0) {
|
|
13278
|
+
const messageId = bubble.dataset.messageId!;
|
|
13279
|
+
window.clearTimeout(revealedToolCalls.get(messageId));
|
|
13280
|
+
revealedToolCalls.set(messageId, window.setTimeout(() => {
|
|
13281
|
+
revealedToolCalls.delete(messageId);
|
|
13282
|
+
refreshRevealedToolCalls();
|
|
13283
|
+
}, highlightMs));
|
|
13284
|
+
bubble.setAttribute("data-persona-revealed", "");
|
|
13285
|
+
}
|
|
13286
|
+
return true;
|
|
13287
|
+
},
|
|
13101
13288
|
async resolveApproval(
|
|
13102
13289
|
approvalId: string,
|
|
13103
13290
|
decision: 'approved' | 'denied',
|
|
@@ -13224,6 +13411,8 @@ export const createAgentExperience = (
|
|
|
13224
13411
|
return session.submitNPSFeedback(rating, comment);
|
|
13225
13412
|
},
|
|
13226
13413
|
destroy() {
|
|
13414
|
+
destroyed = true;
|
|
13415
|
+
invalidatePendingRestarts();
|
|
13227
13416
|
renderReadiness.destroy();
|
|
13228
13417
|
slots.destroy();
|
|
13229
13418
|
mount.removeEventListener("click", routeLinkClick);
|
|
@@ -13240,6 +13429,7 @@ export const createAgentExperience = (
|
|
|
13240
13429
|
}
|
|
13241
13430
|
activityLifecycle?.destroy();
|
|
13242
13431
|
loadingLabelLifecycle?.destroy();
|
|
13432
|
+
clearRevealedToolCalls();
|
|
13243
13433
|
toolExpansionState.clear();
|
|
13244
13434
|
reasoningExpansionState.clear();
|
|
13245
13435
|
approvalDetailsExpansionState.clear();
|
package/src/utils/actions.ts
CHANGED
|
@@ -28,6 +28,7 @@ type ActionManagerOptions = {
|
|
|
28
28
|
payload: AgentWidgetControllerEventMap[K]
|
|
29
29
|
) => void;
|
|
30
30
|
documentRef: Document | null;
|
|
31
|
+
captureResubmitGuard?: () => () => boolean;
|
|
31
32
|
};
|
|
32
33
|
|
|
33
34
|
const stripCodeFence = (value: string) => {
|
|
@@ -195,7 +196,9 @@ export const createActionManager = (options: ActionManagerOptions) => {
|
|
|
195
196
|
try {
|
|
196
197
|
// Create triggerResubmit function that emits the resubmit event
|
|
197
198
|
// Handlers should call this AFTER async work completes (not return resubmit: true)
|
|
199
|
+
const canResubmit = options.captureResubmitGuard?.();
|
|
198
200
|
const triggerResubmit = () => {
|
|
201
|
+
if (canResubmit && !canResubmit()) return;
|
|
199
202
|
options.emit("action:resubmit", eventPayload);
|
|
200
203
|
};
|
|
201
204
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { stabilizeStreamingMarkdown as stabilize } from "./streaming-markdown";
|
|
3
|
+
import { createMarkdownProcessor } from "../postprocessors";
|
|
4
|
+
|
|
5
|
+
const markdown = createMarkdownProcessor();
|
|
6
|
+
|
|
7
|
+
describe("streaming Markdown display", () => {
|
|
8
|
+
it.each(["**bold**", "*italic*", "_italic_", "__bold__", "***both***", "**bold *italic***", "*italic **bold***", "`a*b`", "``a`b``"])("renders every nonempty content prefix of %s without raw delimiters", (source) => {
|
|
9
|
+
for (let length = 1; length <= source.length; length++) {
|
|
10
|
+
const html = markdown(stabilize(`Start ${source.slice(0, length)}`));
|
|
11
|
+
const text = html.replace(/<[^>]*>/g, "");
|
|
12
|
+
if (!source.includes("`")) expect(text).not.toMatch(/[*_]/);
|
|
13
|
+
}
|
|
14
|
+
expect(markdown(stabilize(source))).toBe(markdown(source));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("stabilizes every character boundary across multiple completed and new spans", () => {
|
|
18
|
+
const source = "Watch **bold**, *italic*, and `inline code`. Try **nested *emphasis*** and _more_ with __bold__ afterward.";
|
|
19
|
+
for (let length = 1; length <= source.length; length++) {
|
|
20
|
+
const html = markdown(stabilize(source.slice(0, length)));
|
|
21
|
+
expect(html.replace(/<[^>]*>/g, "")).not.toMatch(/[*_`]/);
|
|
22
|
+
}
|
|
23
|
+
expect(markdown(stabilize(source))).toBe(markdown(source));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it.each([
|
|
27
|
+
"* list item", "_ literal _", "foo_bar_baz", "2 * 3", "2*3", "foo*bar", "<https://example.com/a_b>", '<span data-label="**hello">text</span>', "***", "*", "\\*literal", "\\`literal", "\\[literal", "[ordinary] prose",
|
|
28
|
+
"[x](https://example.com/it's)", "[x](https://example.com/a_(b))", '[x](https://example.com "a title")', "",
|
|
29
|
+
"```md\n**literal\n| a | b |\n| -\n```", "> ```\n*literal\n> ```", "- ```\n**literal\n ```", " **literal"
|
|
30
|
+
])("preserves completed or ambiguous input %j", (source) => {
|
|
31
|
+
expect(stabilize(source)).toBe(source);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("shows incomplete link labels as text and only activates complete destinations", () => {
|
|
35
|
+
const source = "[read more](https://example.com/a_(b))";
|
|
36
|
+
for (let length = 1; length < source.length; length++) {
|
|
37
|
+
const html = markdown(stabilize(source.slice(0, length)));
|
|
38
|
+
expect(html).not.toContain("<a ");
|
|
39
|
+
expect(html.replace(/<[^>]*>/g, "")).not.toMatch(/[[\]()]/);
|
|
40
|
+
}
|
|
41
|
+
expect(markdown(stabilize(source))).toContain('<a href="https://example.com/a_(b)">read more</a>');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it.each([
|
|
45
|
+
"Use `[` here", "Use `]` here", "Use ``[`]`` here", "Use ``]`[`` here",
|
|
46
|
+
"Use `]``[` here", "Use \\[ and \\] here",
|
|
47
|
+
])("keeps code brackets inert while streaming link label %s", (label) => {
|
|
48
|
+
const source = `[${label}](https://example.com)`;
|
|
49
|
+
for (let length = 1; length < source.length; length++) {
|
|
50
|
+
const html = markdown(stabilize(source.slice(0, length)));
|
|
51
|
+
expect(html).not.toContain("<a ");
|
|
52
|
+
expect(html).not.toContain("https:");
|
|
53
|
+
}
|
|
54
|
+
expect(stabilize(source)).toBe(source);
|
|
55
|
+
expect(markdown(stabilize(source))).toBe(markdown(source));
|
|
56
|
+
expect(markdown(stabilize(source))).toContain('<a href="https://example.com">');
|
|
57
|
+
const image = ``;
|
|
58
|
+
for (let length = 2; length < image.length; length++) {
|
|
59
|
+
const display = stabilize(image.slice(0, length));
|
|
60
|
+
expect(markdown(display)).not.toContain("<img ");
|
|
61
|
+
if (length >= label.length + 2) expect(display).toBe("");
|
|
62
|
+
}
|
|
63
|
+
expect(stabilize(image)).toBe(image);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it.each(["Use ` here", "Use `` here", "Use `` unmatched ` here", "Use ``[nested] ` here", "Use ` [nested] `` here"])("treats unmatched code runs as literal link label text: %s", (label) => {
|
|
67
|
+
const source = `[${label}](https://example.com)`;
|
|
68
|
+
expect(stabilize(source)).toBe(source);
|
|
69
|
+
expect(markdown(stabilize(source))).toBe(markdown(source));
|
|
70
|
+
expect(markdown(stabilize(source))).toContain('<a href="https://example.com">');
|
|
71
|
+
expect(stabilize(`[${label}](https://example`)).not.toContain("https:");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("holds incomplete images, including escaped brackets and nested URL parentheses", () => {
|
|
75
|
+
const source = "![an \\] image](https://example.com/a_(b).png)";
|
|
76
|
+
for (let length = 2; length < source.length; length++) {
|
|
77
|
+
expect(stabilize(`Before ${source.slice(0, length)}`)).toBe("Before ");
|
|
78
|
+
}
|
|
79
|
+
expect(stabilize(source)).toBe(source);
|
|
80
|
+
expect(stabilize("Before ).toBe("Before ");
|
|
81
|
+
expect(stabilize("Before [link](url\\")).toBe("Before link");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("stabilizes formatting inside unfinished link labels", () => {
|
|
85
|
+
expect(markdown(stabilize("[**hello"))).toContain("<strong>hello</strong>");
|
|
86
|
+
expect(markdown(stabilize("[**hello**](https://example"))).toContain("<strong>hello</strong>");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("keeps inline code contents and escapes inert", () => {
|
|
90
|
+
expect(markdown(stabilize("Here `**[x](url)"))).toContain("<code>**[x](url)</code>");
|
|
91
|
+
expect(stabilize("\\\\**bold")).toBe("\\\\**bold**");
|
|
92
|
+
expect(markdown(stabilize("**hello\\"))).toContain("<strong>hello\\</strong>");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("preserves whitespace outside a temporarily completed emphasis span", () => {
|
|
96
|
+
expect(stabilize("**hello ")).toBe("**hello** ");
|
|
97
|
+
expect(stabilize("**hello\n\nNext")).toBe("**hello**\n\nNext");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("continues table completion outside code fences", () => {
|
|
101
|
+
expect(markdown(stabilize("| A | B |\n| -"))).toContain("<table>");
|
|
102
|
+
});
|
|
103
|
+
});
|