@runtypelabs/persona 4.11.1 → 4.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui.ts CHANGED
@@ -64,6 +64,7 @@ import {
64
64
  createFollowStateController,
65
65
  getScrollBottomOffset,
66
66
  hasSelectionWithin,
67
+ isAttentionWorthyMessage,
67
68
  isElementNearBottom,
68
69
  resolveFollowStateFromScroll,
69
70
  resolveFollowStateFromWheel
@@ -3537,6 +3538,24 @@ export const createAgentExperience = (
3537
3538
  // pinned and never re-arms the fallback, so a late-loading embed can't yank
3538
3539
  // the viewport down to the bottom.
3539
3540
  let currentTurnAnchored = false;
3541
+ // Which message the anchor is currently parked on. Starts as the sent user
3542
+ // message and moves once to the turn's first attention-worthy block (see
3543
+ // `maybeAnchorToFirstUnread`).
3544
+ let anchoredMessageId: string | null = null;
3545
+ // Has the reader taken over positioning this turn? First-unread anchoring is
3546
+ // a guess about where someone who ISN'T watching wants to land; the moment
3547
+ // they demonstrably are watching, the guess stands down and never moves the
3548
+ // viewport under them. Reset on each user send (an explicit "take me along").
3549
+ //
3550
+ // Driven by real INPUT (wheel, touch drag, pointer press, transcript
3551
+ // keyboard nav, focus, selection) rather than by scroll deltas: streaming
3552
+ // content under a pinned anchor makes the browser clamp and then restore
3553
+ // scrollTop, which is indistinguishable from a deliberate scroll if you only
3554
+ // look at the scroll event.
3555
+ let readerEngagedThisTurn = false;
3556
+ const markReaderEngaged = () => {
3557
+ readerEngagedThisTurn = true;
3558
+ };
3540
3559
  // Dedupes assistant-turn detection across token-by-token re-renders.
3541
3560
  let lastHandledAssistantId: string | null = null;
3542
3561
 
@@ -3940,6 +3959,24 @@ export const createAgentExperience = (
3940
3959
  return top;
3941
3960
  };
3942
3961
 
3962
+ // Defined here (rather than beside the scroll listeners) because
3963
+ // `maybeAnchorToFirstUnread` runs on every render and would otherwise hit
3964
+ // these consts in their temporal dead zone on a synchronous first paint.
3965
+ const getTranscriptSelection = (): Selection | null => {
3966
+ // Selections inside a shadow root are not always reflected by
3967
+ // document.getSelection(); prefer the shadow root's view when available
3968
+ // (non-standard but supported where it matters).
3969
+ const root = body.getRootNode();
3970
+ const shadowSelection =
3971
+ typeof (root as ShadowRoot & { getSelection?: () => Selection | null })
3972
+ .getSelection === "function"
3973
+ ? (root as ShadowRoot & { getSelection: () => Selection | null }).getSelection()
3974
+ : null;
3975
+ return shadowSelection ?? body.ownerDocument.getSelection();
3976
+ };
3977
+ const hasActiveTranscriptSelection = () =>
3978
+ hasSelectionWithin(getTranscriptSelection(), body);
3979
+
3943
3980
  // Principle 11: reopen where the reader left off. When `restorePosition` is
3944
3981
  // "last-user-turn" and there is pre-existing history, land with the last user
3945
3982
  // message pinned near the top of the viewport instead of jumping to the
@@ -4002,11 +4039,10 @@ export const createAgentExperience = (
4002
4039
  anchorSpacer.style.height = "0px";
4003
4040
  };
4004
4041
 
4005
- // Anchor-top mode: scroll the just-sent user message to rest
4006
- // `anchorTopOffset` px below the viewport top and hold it there while the
4007
- // response streams in beneath it. Deferred one frame so the message bubble
4008
- // has been rendered and laid out.
4009
- const scheduleAnchorToUserMessage = (messageId: string) => {
4042
+ // Anchor-top mode: scroll `messageId` to rest `anchorTopOffset` px below the
4043
+ // viewport top and hold it there while the response streams in beneath it.
4044
+ // Deferred one frame so the message bubble has been rendered and laid out.
4045
+ const scheduleAnchorToMessage = (messageId: string) => {
4010
4046
  if (anchorRAF !== null) {
4011
4047
  cancelAnimationFrame(anchorRAF);
4012
4048
  }
@@ -4087,7 +4123,11 @@ export const createAgentExperience = (
4087
4123
  // next user send.
4088
4124
  followFallbackActive = false;
4089
4125
  currentTurnAnchored = true;
4090
- scheduleAnchorToUserMessage(messageId);
4126
+ // A send is an explicit "take me along": it re-arms first-unread
4127
+ // positioning even if the reader had scrolled away during the last turn.
4128
+ readerEngagedThisTurn = false;
4129
+ anchoredMessageId = messageId;
4130
+ scheduleAnchorToMessage(messageId);
4091
4131
  }
4092
4132
  };
4093
4133
 
@@ -4110,6 +4150,44 @@ export const createAgentExperience = (
4110
4150
  scheduleAutoScroll(true);
4111
4151
  };
4112
4152
 
4153
+ // Anchor-top positioning: park on the turn's FIRST UNREAD attention-worthy
4154
+ // block.
4155
+ //
4156
+ // The read boundary is the last user send — the reader wrote it, so it (and
4157
+ // everything above) is read; every attention-worthy block after it is not.
4158
+ // Tool calls and reasoning are chrome and never targeted, so they scroll
4159
+ // past underneath rather than parking the reader on progress output.
4160
+ //
4161
+ // The policy assumes the reader is NOT watching the stream, and picks the
4162
+ // best place for them to land when they look back: the start of what they
4163
+ // haven't seen. `readerEngagedThisTurn` is the escape hatch — once someone
4164
+ // is demonstrably driving, their position wins and this stands down until
4165
+ // the next send.
4166
+ //
4167
+ // Anchoring to the first unread (not the newest) block means this fires at
4168
+ // most once per turn in practice, and later blocks stream in below the
4169
+ // anchor exactly as they do today.
4170
+ const maybeAnchorToFirstUnread = (messages: AgentWidgetMessage[]) => {
4171
+ if (getScrollMode() !== "anchor-top") return;
4172
+ if (!currentTurnAnchored) return;
4173
+ if (readerEngagedThisTurn) return;
4174
+ if (hasActiveTranscriptSelection()) return;
4175
+
4176
+ let pastReadBoundary = lastSentUserMessageId === null;
4177
+ for (const message of messages) {
4178
+ if (!pastReadBoundary) {
4179
+ if (message.id === lastSentUserMessageId) pastReadBoundary = true;
4180
+ continue;
4181
+ }
4182
+ if (!isAttentionWorthyMessage(message)) continue;
4183
+ // Already parked on it: hold still while it streams.
4184
+ if (message.id === anchoredMessageId) return;
4185
+ anchoredMessageId = message.id;
4186
+ scheduleAnchorToMessage(message.id);
4187
+ return;
4188
+ }
4189
+ };
4190
+
4113
4191
  const trackMessages = (messages: AgentWidgetMessage[]) => {
4114
4192
  const nextState = new Map<
4115
4193
  string,
@@ -5998,6 +6076,8 @@ export const createAgentExperience = (
5998
6076
  resetAnchorState();
5999
6077
  followFallbackActive = true;
6000
6078
  currentTurnAnchored = false;
6079
+ anchoredMessageId = null;
6080
+ readerEngagedThisTurn = false;
6001
6081
  }
6002
6082
  if (!scrollSendSeeded || suppressScrollSend) {
6003
6083
  scrollSendSeeded = true;
@@ -6013,6 +6093,7 @@ export const createAgentExperience = (
6013
6093
  handleAssistantTurnStarted();
6014
6094
  }
6015
6095
  if (lastAssistantMessage) lastHandledAssistantId = lastAssistantMessage.id;
6096
+ maybeAnchorToFirstUnread(messages);
6016
6097
 
6017
6098
  const prevLastUserMessageId = voiceState.lastUserMessageId;
6018
6099
  if (lastUserMessage && lastUserMessage.id !== prevLastUserMessageId) {
@@ -7341,21 +7422,6 @@ export const createAgentExperience = (
7341
7422
  lastScrollTop = body.scrollTop;
7342
7423
  let lastBottomOffset = getScrollBottomOffset(body);
7343
7424
 
7344
- const getTranscriptSelection = (): Selection | null => {
7345
- // Selections inside a shadow root are not always reflected by
7346
- // document.getSelection(); prefer the shadow root's view when available
7347
- // (non-standard but supported where it matters).
7348
- const root = body.getRootNode();
7349
- const shadowSelection =
7350
- typeof (root as ShadowRoot & { getSelection?: () => Selection | null })
7351
- .getSelection === "function"
7352
- ? (root as ShadowRoot & { getSelection: () => Selection | null }).getSelection()
7353
- : null;
7354
- return shadowSelection ?? body.ownerDocument.getSelection();
7355
- };
7356
- const hasActiveTranscriptSelection = () =>
7357
- hasSelectionWithin(getTranscriptSelection(), body);
7358
-
7359
7425
  const handleScroll = () => {
7360
7426
  const scrollTop = body.scrollTop;
7361
7427
  // When content mutates (e.g. stream-animation plugins re-rendering text)
@@ -7373,6 +7439,14 @@ export const createAgentExperience = (
7373
7439
  if (!isFollowEffective()) {
7374
7440
  // No follow state to manage (anchored anchor-top / none): just keep the
7375
7441
  // scroll-to-bottom affordance in sync with the user's position.
7442
+ //
7443
+ // Reader intent is deliberately NOT inferred from scroll deltas here.
7444
+ // Growing the transcript under a pinned anchor makes the browser clamp
7445
+ // scrollTop and then restore it, which lands as a matched pair of real
7446
+ // scroll events (e.g. -12 then +12) with no user involved. The restore
7447
+ // arrives while the bottom offset is GROWING, so the shrink mask above
7448
+ // doesn't cover it, and it reads as a deliberate scroll. Engagement is
7449
+ // tracked from actual input instead (see `markReaderEngaged` callers).
7376
7450
  lastScrollTop = scrollTop;
7377
7451
  syncScrollToBottomButton();
7378
7452
  return;
@@ -7459,21 +7533,23 @@ export const createAgentExperience = (
7459
7533
  "ArrowDown",
7460
7534
  ]);
7461
7535
  const handleTranscriptKeydown = (event: KeyboardEvent) => {
7536
+ if (!NAV_KEYS.has(event.key)) return;
7537
+ // Reader intent for first-unread anchoring is tracked unconditionally;
7538
+ // only the follow-mode *pause* below stays behind the opt-in flag.
7539
+ markReaderEngaged();
7462
7540
  if (!isPauseOnInteractionEnabled()) return;
7463
7541
  if (!isFollowEffective()) return;
7464
7542
  if (!autoFollow.isFollowing()) return;
7465
- if (NAV_KEYS.has(event.key)) {
7466
- pauseAutoScroll();
7467
- }
7543
+ pauseAutoScroll();
7468
7544
  };
7469
7545
  const handleTranscriptFocusIn = (event: FocusEvent) => {
7546
+ const target = event.target as Element | null;
7547
+ if (!target?.closest("a, button, [tabindex], input, textarea, select")) return;
7548
+ markReaderEngaged();
7470
7549
  if (!isPauseOnInteractionEnabled()) return;
7471
7550
  if (!isFollowEffective()) return;
7472
7551
  if (!autoFollow.isFollowing()) return;
7473
- const target = event.target as Element | null;
7474
- if (target && target.closest("a, button, [tabindex], input, textarea, select")) {
7475
- pauseAutoScroll();
7476
- }
7552
+ pauseAutoScroll();
7477
7553
  };
7478
7554
  body.addEventListener("keydown", handleTranscriptKeydown);
7479
7555
  body.addEventListener("focusin", handleTranscriptFocusIn);
@@ -7483,6 +7559,9 @@ export const createAgentExperience = (
7483
7559
  });
7484
7560
 
7485
7561
  const handleWheel = (event: WheelEvent) => {
7562
+ // A wheel tick is unambiguous reader intent in every mode — including
7563
+ // anchored anchor-top, where there is no follow state to pause.
7564
+ if (event.deltaY !== 0) markReaderEngaged();
7486
7565
  if (!isFollowEffective()) return;
7487
7566
  const action = resolveFollowStateFromWheel({
7488
7567
  following: autoFollow.isFollowing(),
@@ -7498,6 +7577,14 @@ export const createAgentExperience = (
7498
7577
  }
7499
7578
  };
7500
7579
  body.addEventListener("wheel", handleWheel, { passive: true });
7580
+ // Touch drag and pointer press (scrollbar drags, tapping a tool bubble open)
7581
+ // are the remaining ways a reader takes over without a wheel or key event.
7582
+ body.addEventListener("touchmove", markReaderEngaged, { passive: true });
7583
+ body.addEventListener("pointerdown", markReaderEngaged, { passive: true });
7584
+ destroyCallbacks.push(() => {
7585
+ body.removeEventListener("touchmove", markReaderEngaged);
7586
+ body.removeEventListener("pointerdown", markReaderEngaged);
7587
+ });
7501
7588
  destroyCallbacks.push(() => body.removeEventListener("wheel", handleWheel));
7502
7589
  scrollToBottomButton.addEventListener("click", () => {
7503
7590
  // Jumping to the latest abandons the current anchor: drop the spacer
@@ -6,10 +6,12 @@ import {
6
6
  createFollowStateController,
7
7
  getScrollBottomOffset,
8
8
  hasSelectionWithin,
9
+ isAttentionWorthyMessage,
9
10
  isElementNearBottom,
10
11
  resolveFollowStateFromScroll,
11
12
  resolveFollowStateFromWheel
12
13
  } from "./auto-follow";
14
+ import type { AgentWidgetMessage } from "../types";
13
15
 
14
16
  describe("auto-follow utilities", () => {
15
17
  it("tracks pause and resume state", () => {
@@ -199,3 +201,39 @@ describe("auto-follow utilities", () => {
199
201
  ).toBe("resume");
200
202
  });
201
203
  });
204
+
205
+ describe("isAttentionWorthyMessage", () => {
206
+ const msg = (over: Partial<AgentWidgetMessage>): AgentWidgetMessage => ({
207
+ id: "m",
208
+ role: "assistant",
209
+ content: "",
210
+ createdAt: "2026-07-28T00:00:00.000Z",
211
+ ...over,
212
+ });
213
+
214
+ it("treats prose, artifacts/components, images and approvals as output", () => {
215
+ expect(isAttentionWorthyMessage(msg({ content: "Here is the answer." }))).toBe(true);
216
+ // Artifact / component blocks carry their payload in rawContent with an
217
+ // empty `content` — the case a text-only test silently drops.
218
+ expect(
219
+ isAttentionWorthyMessage(msg({ rawContent: '{"component":"Chart","props":{}}' }))
220
+ ).toBe(true);
221
+ expect(
222
+ isAttentionWorthyMessage(
223
+ msg({ contentParts: [{ type: "image", image: "data:image/png;base64,x" }] as never })
224
+ )
225
+ ).toBe(true);
226
+ // Blocked on the reader deciding: must never sit off-screen.
227
+ expect(isAttentionWorthyMessage(msg({ variant: "approval" }))).toBe(true);
228
+ });
229
+
230
+ it("treats tool calls and reasoning as progress chrome", () => {
231
+ expect(isAttentionWorthyMessage(msg({ variant: "tool", content: "searching" }))).toBe(false);
232
+ expect(isAttentionWorthyMessage(msg({ variant: "reasoning", content: "thinking" }))).toBe(false);
233
+ });
234
+
235
+ it("skips the empty streaming placeholder and non-assistant roles", () => {
236
+ expect(isAttentionWorthyMessage(msg({ content: " " }))).toBe(false);
237
+ expect(isAttentionWorthyMessage(msg({ role: "user", content: "hi" }))).toBe(false);
238
+ });
239
+ });
@@ -1,3 +1,38 @@
1
+ import type { AgentWidgetMessage } from "../types";
2
+
3
+ /**
4
+ * Is this message *output a human came for*, as opposed to progress chrome?
5
+ *
6
+ * This is the classifier behind anchor-top's "first unread" positioning: the
7
+ * transcript anchors to the first attention-worthy block of a response and
8
+ * leaves everything else to scroll past underneath. Two categories:
9
+ *
10
+ * - **Chrome** — tool calls and reasoning. Rendered so the reader *can* audit
11
+ * the work, never positioned to. A response that opens with six tool calls
12
+ * should not park the reader on the sixth one.
13
+ * - **Attention-worthy** — anything that carries the answer: prose, an
14
+ * artifact or component block (charts, forms, documents — these carry their
15
+ * payload in `rawContent` with an EMPTY `content`, so testing text alone
16
+ * silently drops them), multi-modal parts (images), and approval requests
17
+ * (which are the strongest case of all: the turn is blocked on the reader
18
+ * deciding, so it must never sit off-screen).
19
+ *
20
+ * The empty streaming placeholder — an assistant bubble created at
21
+ * `text_start` before its first token — is deliberately NOT attention-worthy,
22
+ * so the anchor waits for content rather than jumping to a blank bubble.
23
+ */
24
+ export function isAttentionWorthyMessage(message: AgentWidgetMessage): boolean {
25
+ if (message.role !== "assistant") return false;
26
+ if (message.variant === "tool" || message.variant === "reasoning") return false;
27
+ // Blocked on the reader: attention-worthy even with no text of its own.
28
+ if (message.variant === "approval") return true;
29
+ return Boolean(
30
+ message.content?.trim() ||
31
+ message.rawContent?.trim() ||
32
+ message.contentParts?.length
33
+ );
34
+ }
35
+
1
36
  export type FollowStateAction = "none" | "pause" | "resume";
2
37
 
3
38
  export type FollowStateController = {
@@ -151,8 +151,33 @@ export type CodeGeneratorOptions = {
151
151
  * @default "body"
152
152
  */
153
153
  target?: string;
154
+
155
+ /**
156
+ * Base URL of the directory holding the widget assets (install.global.js,
157
+ * index.global.js, widget.css) for the script formats (script-installer,
158
+ * script-manual, script-advanced). Pass a first-party base such as
159
+ * "https://cdn.runtype.com/persona/latest" when the embedding page's CSP
160
+ * does not allow third-party CDNs. A trailing slash is tolerated.
161
+ * @default the jsDelivr dist directory pinned to this package version
162
+ */
163
+ cdnBase?: string;
154
164
  };
155
165
 
166
+ /**
167
+ * Asset directory for script formats: explicit cdnBase or the version-pinned
168
+ * jsDelivr dist. The base is emitted into single-quoted JS literals AND
169
+ * double-quoted HTML attributes, so percent-encode the characters that could
170
+ * break out of either context. All of them are illegal unencoded in a URL
171
+ * (RFC 3986), so encoding never changes the meaning of a valid base URL.
172
+ */
173
+ function resolveCdnBase(options?: CodeGeneratorOptions): string {
174
+ const base = options?.cdnBase
175
+ ?.trim()
176
+ .replace(/\/+$/, "")
177
+ .replace(/[\\'"<>`]/g, (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
178
+ return base || `https://cdn.jsdelivr.net/npm/@runtypelabs/persona@${VERSION}/dist`;
179
+ }
180
+
156
181
  // Internal type for normalized hooks (always strings)
157
182
  type NormalizedHooks = {
158
183
  [K in keyof CodeGeneratorHooks]: string | undefined;
@@ -1402,7 +1427,7 @@ function generateScriptInstallerCode(config: any, options?: CodeGeneratorOptions
1402
1427
  // Escape single quotes in JSON for HTML attribute
1403
1428
  const configJson = JSON.stringify(payload, null, 0).replace(/'/g, "&#39;");
1404
1429
 
1405
- return `<script src="https://cdn.jsdelivr.net/npm/@runtypelabs/persona@${VERSION}/dist/install.global.js" data-config='${configJson}'></script>`;
1430
+ return `<script src="${resolveCdnBase(options)}/install.global.js" data-config='${configJson}'></script>`;
1406
1431
  }
1407
1432
 
1408
1433
  function generateScriptManualCode(config: any, options?: CodeGeneratorOptions): string {
@@ -1410,12 +1435,13 @@ function generateScriptManualCode(config: any, options?: CodeGeneratorOptions):
1410
1435
  const parserType = getParserTypeFromConfig(config as AgentWidgetConfig);
1411
1436
  const shouldEmitParserType = parserType !== "plain";
1412
1437
 
1438
+ const cdnBase = resolveCdnBase(options);
1413
1439
  const lines: string[] = [
1414
1440
  "<!-- Load CSS -->",
1415
- `<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@runtypelabs/persona@${VERSION}/dist/widget.css" />`,
1441
+ `<link rel="stylesheet" href="${cdnBase}/widget.css" />`,
1416
1442
  "",
1417
1443
  "<!-- Load JavaScript -->",
1418
- `<script src="https://cdn.jsdelivr.net/npm/@runtypelabs/persona@${VERSION}/dist/index.global.js"></script>`,
1444
+ `<script src="${cdnBase}/index.global.js"></script>`,
1419
1445
  "",
1420
1446
  "<!-- Initialize widget -->",
1421
1447
  "<script>",
@@ -1565,7 +1591,7 @@ function generateScriptAdvancedCode(config: any, options?: CodeGeneratorOptions)
1565
1591
  ` var CONFIG = ${configJson.split('\n').map((line, i) => i === 0 ? line : ' ' + line).join('\n')};`,
1566
1592
  "",
1567
1593
  " // Constants",
1568
- ` var CDN_BASE = 'https://cdn.jsdelivr.net/npm/@runtypelabs/persona@${VERSION}/dist';`,
1594
+ ` var CDN_BASE = '${resolveCdnBase(options)}';`,
1569
1595
  " var STORAGE_KEY = 'chat-widget-state';",
1570
1596
  " var PROCESSED_ACTIONS_KEY = 'chat-widget-processed-actions';",
1571
1597
  "",