@runtypelabs/persona 3.6.0 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,11 @@
1
1
  import { createElement } from "../utils/dom";
2
2
  import { renderLucideIcon } from "../utils/icons";
3
+ import {
4
+ createFollowStateController,
5
+ isElementNearBottom,
6
+ resolveFollowStateFromScroll,
7
+ resolveFollowStateFromWheel
8
+ } from "../utils/auto-follow";
3
9
  import type { EventStreamBuffer } from "../utils/event-stream-buffer";
4
10
  import type {
5
11
  SSEEventRecord,
@@ -391,6 +397,10 @@ export function createEventStreamView(
391
397
  config,
392
398
  plugins = [],
393
399
  } = options;
400
+ const scrollToBottomConfig = config?.features?.scrollToBottom;
401
+ const scrollToBottomEnabled = scrollToBottomConfig?.enabled !== false;
402
+ const scrollToBottomIconName = scrollToBottomConfig?.iconName ?? "arrow-down";
403
+ const scrollToBottomLabel = scrollToBottomConfig?.label ?? "";
394
404
 
395
405
  const esConfig: EventStreamConfig = config?.features?.eventStream ?? {};
396
406
 
@@ -438,7 +448,7 @@ export function createEventStreamView(
438
448
  let lastKnownTypes: string[] = [];
439
449
  let lastTypeCounts: Record<string, number> = {};
440
450
  let lastFilteredCount = 0;
441
- let userScrolledUp = false;
451
+ const autoFollow = createFollowStateController();
442
452
  let newEventsSincePause = 0;
443
453
  let lastRenderTime = 0;
444
454
  let pendingUpdate = false;
@@ -638,18 +648,23 @@ export function createEventStreamView(
638
648
  // Scroll-to-bottom indicator
639
649
  const scrollIndicator = createElement(
640
650
  "div",
641
- "persona-absolute persona-bottom-3 persona-left-1/2 persona-transform persona--translate-x-1/2 persona-bg-persona-accent persona-text-white persona-text-xs persona-px-3 persona-py-1.5 persona-rounded-full persona-cursor-pointer persona-shadow-md persona-z-10 persona-flex persona-items-center persona-gap-1"
651
+ "persona-scroll-to-bottom-indicator persona-absolute persona-bottom-3 persona-left-1/2 persona-transform persona--translate-x-1/2 persona-cursor-pointer persona-z-10 persona-text-xs"
642
652
  );
643
653
  applyCustomClasses(scrollIndicator, customClasses?.scrollIndicator);
644
654
  scrollIndicator.style.display = "none";
655
+ scrollIndicator.setAttribute(
656
+ "data-persona-scroll-to-bottom-has-label",
657
+ scrollToBottomLabel ? "true" : "false"
658
+ );
645
659
  const arrowIcon = renderLucideIcon(
646
- "arrow-down",
647
- "12px",
660
+ scrollToBottomIconName,
661
+ "14px",
648
662
  "currentColor",
649
663
  2
650
664
  );
651
665
  if (arrowIcon) scrollIndicator.appendChild(arrowIcon);
652
666
  const indicatorText = createElement("span", "");
667
+ indicatorText.textContent = scrollToBottomLabel;
653
668
  scrollIndicator.appendChild(indicatorText);
654
669
 
655
670
  // No matching events message
@@ -753,7 +768,7 @@ export function createEventStreamView(
753
768
  function resetScrollState() {
754
769
  lastFilteredCount = 0;
755
770
  newEventsSincePause = 0;
756
- userScrolledUp = false;
771
+ autoFollow.resume();
757
772
  scrollIndicator.style.display = "none";
758
773
  }
759
774
 
@@ -766,12 +781,14 @@ export function createEventStreamView(
766
781
  dirtyExpandId = eventId;
767
782
  // Save scroll position — user-initiated expand/collapse should not auto-scroll
768
783
  const savedScrollTop = eventsList.scrollTop;
769
- const wasUserScrolledUp = userScrolledUp;
784
+ const wasAutoFollowing = autoFollow.isFollowing();
770
785
  suppressScrollHandler = true;
771
- userScrolledUp = true; // prevent auto-scroll during re-render
786
+ autoFollow.pause(); // prevent auto-scroll during re-render
772
787
  updateNow();
773
788
  eventsList.scrollTop = savedScrollTop;
774
- userScrolledUp = wasUserScrolledUp;
789
+ if (wasAutoFollowing) {
790
+ autoFollow.resume();
791
+ }
775
792
  suppressScrollHandler = false;
776
793
  }
777
794
 
@@ -780,13 +797,7 @@ export function createEventStreamView(
780
797
  // ========================================================================
781
798
 
782
799
  function isNearBottom(): boolean {
783
- const threshold = 50;
784
- return (
785
- eventsList.scrollHeight -
786
- eventsList.scrollTop -
787
- eventsList.clientHeight <=
788
- threshold
789
- );
800
+ return isElementNearBottom(eventsList, 50);
790
801
  }
791
802
 
792
803
  function updateNow() {
@@ -833,9 +844,11 @@ export function createEventStreamView(
833
844
  }
834
845
 
835
846
  // Track new events since user scrolled up
836
- if (userScrolledUp && newCount > lastFilteredCount) {
847
+ if (scrollToBottomEnabled && !autoFollow.isFollowing() && newCount > lastFilteredCount) {
837
848
  newEventsSincePause += newCount - lastFilteredCount;
838
- indicatorText.textContent = `${newEventsSincePause} new event${newEventsSincePause === 1 ? "" : "s"}`;
849
+ indicatorText.textContent = scrollToBottomLabel
850
+ ? `${scrollToBottomLabel}${newEventsSincePause > 0 ? ` (${newEventsSincePause})` : ""}`
851
+ : "";
839
852
  scrollIndicator.style.display = "";
840
853
  }
841
854
  lastFilteredCount = newCount;
@@ -939,7 +952,7 @@ export function createEventStreamView(
939
952
  }
940
953
 
941
954
  // Auto-scroll if user hasn't scrolled up
942
- if (!userScrolledUp) {
955
+ if (autoFollow.isFollowing()) {
943
956
  eventsList.scrollTop = eventsList.scrollHeight;
944
957
  }
945
958
  }
@@ -1064,30 +1077,56 @@ export function createEventStreamView(
1064
1077
  const handleListScroll = () => {
1065
1078
  if (suppressScrollHandler) return;
1066
1079
  const currentScrollTop = eventsList.scrollTop;
1067
- const scrollingDown = currentScrollTop > lastScrollTop;
1068
- lastScrollTop = currentScrollTop;
1080
+ const { action, nextLastScrollTop } = resolveFollowStateFromScroll({
1081
+ following: autoFollow.isFollowing(),
1082
+ currentScrollTop,
1083
+ lastScrollTop,
1084
+ nearBottom: isNearBottom(),
1085
+ userScrollThreshold: 1,
1086
+ resumeRequiresDownwardScroll: true
1087
+ });
1088
+ lastScrollTop = nextLastScrollTop;
1069
1089
 
1070
- if (isNearBottom() && scrollingDown) {
1071
- // User scrolled back down to bottom — re-enable auto-scroll
1072
- userScrolledUp = false;
1090
+ if (action === "resume") {
1091
+ autoFollow.resume();
1073
1092
  newEventsSincePause = 0;
1074
1093
  scrollIndicator.style.display = "none";
1075
- } else if (!isNearBottom()) {
1076
- userScrolledUp = true;
1094
+ } else if (action === "pause") {
1095
+ autoFollow.pause();
1096
+ if (scrollToBottomEnabled) {
1097
+ indicatorText.textContent = scrollToBottomLabel;
1098
+ scrollIndicator.style.display = "";
1099
+ }
1077
1100
  }
1078
1101
  };
1079
1102
 
1080
1103
  // Wheel events fire synchronously before rAF callbacks, so we can
1081
1104
  // detect upward scroll intent before the next updateNow() auto-scrolls.
1082
1105
  const handleWheel = (e: WheelEvent) => {
1083
- if (e.deltaY < 0) {
1084
- userScrolledUp = true;
1106
+ const action = resolveFollowStateFromWheel({
1107
+ following: autoFollow.isFollowing(),
1108
+ deltaY: e.deltaY,
1109
+ nearBottom: isNearBottom(),
1110
+ resumeWhenNearBottom: true
1111
+ });
1112
+
1113
+ if (action === "pause") {
1114
+ autoFollow.pause();
1115
+ if (scrollToBottomEnabled) {
1116
+ indicatorText.textContent = scrollToBottomLabel;
1117
+ scrollIndicator.style.display = "";
1118
+ }
1119
+ } else if (action === "resume") {
1120
+ autoFollow.resume();
1121
+ newEventsSincePause = 0;
1122
+ scrollIndicator.style.display = "none";
1085
1123
  }
1086
1124
  };
1087
1125
 
1088
1126
  const handleScrollIndicatorClick = () => {
1127
+ if (!scrollToBottomEnabled) return;
1089
1128
  eventsList.scrollTop = eventsList.scrollHeight;
1090
- userScrolledUp = false;
1129
+ autoFollow.resume();
1091
1130
  newEventsSincePause = 0;
1092
1131
  scrollIndicator.style.display = "none";
1093
1132
  };
package/src/defaults.ts CHANGED
@@ -102,6 +102,11 @@ export const DEFAULT_WIDGET_CONFIG: Partial<AgentWidgetConfig> = {
102
102
  features: {
103
103
  showReasoning: true,
104
104
  showToolCalls: true,
105
+ scrollToBottom: {
106
+ enabled: true,
107
+ iconName: "arrow-down",
108
+ label: "",
109
+ },
105
110
  },
106
111
  suggestionChips: [
107
112
  "What can you help me with?",
@@ -214,6 +219,8 @@ export function mergeWithDefaults(
214
219
  features: (() => {
215
220
  const da = DEFAULT_WIDGET_CONFIG.features?.artifacts;
216
221
  const ca = config.features?.artifacts;
222
+ const dsb = DEFAULT_WIDGET_CONFIG.features?.scrollToBottom;
223
+ const csb = config.features?.scrollToBottom;
217
224
  const mergedArtifacts =
218
225
  da === undefined && ca === undefined
219
226
  ? undefined
@@ -225,9 +232,17 @@ export function mergeWithDefaults(
225
232
  ...ca?.layout,
226
233
  },
227
234
  };
235
+ const mergedScrollToBottom =
236
+ dsb === undefined && csb === undefined
237
+ ? undefined
238
+ : {
239
+ ...dsb,
240
+ ...csb,
241
+ };
228
242
  return {
229
243
  ...DEFAULT_WIDGET_CONFIG.features,
230
244
  ...config.features,
245
+ ...(mergedScrollToBottom !== undefined ? { scrollToBottom: mergedScrollToBottom } : {}),
231
246
  ...(mergedArtifacts !== undefined ? { artifacts: mergedArtifacts } : {}),
232
247
  };
233
248
  })(),
@@ -0,0 +1,13 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { DEFAULT_WIDGET_CONFIG } from "./defaults";
4
+
5
+ describe("scroll-to-bottom defaults", () => {
6
+ it("defaults to an enabled icon-only circular control", () => {
7
+ expect(DEFAULT_WIDGET_CONFIG.features?.scrollToBottom).toEqual({
8
+ enabled: true,
9
+ iconName: "arrow-down",
10
+ label: "",
11
+ });
12
+ });
13
+ });
@@ -2320,6 +2320,46 @@
2320
2320
  background: var(--persona-label-btn-hover-bg, var(--persona-container, #f3f4f6));
2321
2321
  }
2322
2322
 
2323
+ [data-persona-root] .persona-scroll-to-bottom-indicator {
2324
+ display: inline-flex;
2325
+ align-items: center;
2326
+ justify-content: center;
2327
+ gap: var(--persona-scroll-to-bottom-gap, 0.5rem);
2328
+ min-height: var(--persona-scroll-to-bottom-size, 40px);
2329
+ border-radius: var(--persona-scroll-to-bottom-radius, var(--persona-radius-full, 9999px));
2330
+ border: 1px solid var(--persona-scroll-to-bottom-border, var(--persona-primary, #111827));
2331
+ background: var(--persona-scroll-to-bottom-bg, var(--persona-button-primary-bg, var(--persona-accent, #0f0f0f)));
2332
+ color: var(--persona-scroll-to-bottom-fg, var(--persona-button-primary-fg, var(--persona-text-inverse, #ffffff)));
2333
+ box-shadow: var(--persona-scroll-to-bottom-shadow, 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1));
2334
+ font-size: var(--persona-scroll-to-bottom-font-size, 0.875rem);
2335
+ line-height: 1;
2336
+ }
2337
+
2338
+ [data-persona-root] .persona-scroll-to-bottom-indicator[data-persona-scroll-to-bottom-has-label="true"] {
2339
+ padding: var(--persona-scroll-to-bottom-padding, 0.5rem 0.875rem);
2340
+ }
2341
+
2342
+ [data-persona-root] .persona-scroll-to-bottom-indicator[data-persona-scroll-to-bottom-has-label="false"] {
2343
+ width: var(--persona-scroll-to-bottom-size, 40px);
2344
+ height: var(--persona-scroll-to-bottom-size, 40px);
2345
+ padding: 0;
2346
+ gap: 0;
2347
+ }
2348
+
2349
+ [data-persona-root] .persona-scroll-to-bottom-indicator svg {
2350
+ width: var(--persona-scroll-to-bottom-icon-size, 14px);
2351
+ height: var(--persona-scroll-to-bottom-icon-size, 14px);
2352
+ }
2353
+
2354
+ [data-persona-root] .persona-scroll-to-bottom-indicator:hover {
2355
+ opacity: 0.92;
2356
+ }
2357
+
2358
+ [data-persona-root] .persona-scroll-to-bottom-indicator:focus-visible {
2359
+ outline: 2px solid var(--persona-accent, #171717);
2360
+ outline-offset: 2px;
2361
+ }
2362
+
2323
2363
  /* Toggle group — mutually exclusive button set created by createToggleGroup() */
2324
2364
  [data-persona-root] .persona-toggle-group {
2325
2365
  display: inline-flex;
@@ -101,6 +101,7 @@ export {
101
101
  ROLE_USER_MESSAGES,
102
102
  ROLE_ASSISTANT_MESSAGES,
103
103
  ROLE_PRIMARY_ACTIONS,
104
+ ROLE_SCROLL_TO_BOTTOM,
104
105
  ROLE_INPUT,
105
106
  ROLE_LINKS_FOCUS,
106
107
  ROLE_BORDERS,
@@ -95,6 +95,17 @@ export const ROLE_PRIMARY_ACTIONS: RoleAssignmentOptions = {
95
95
  ],
96
96
  };
97
97
 
98
+ export const ROLE_SCROLL_TO_BOTTOM: RoleAssignmentOptions = {
99
+ roleId: 'role-scroll-to-bottom',
100
+ helper: 'Scroll-to-bottom affordances in transcript and event stream',
101
+ intensities: ROLE_INTENSITIES,
102
+ targets: [
103
+ { path: 'components.scrollToBottom.background', kind: 'background' },
104
+ { path: 'components.scrollToBottom.foreground', kind: 'foreground' },
105
+ { path: 'components.scrollToBottom.border', kind: 'border' },
106
+ ],
107
+ };
108
+
98
109
  export const ROLE_INPUT: RoleAssignmentOptions = {
99
110
  roleId: 'role-input',
100
111
  helper: 'Message input field',
@@ -137,6 +148,7 @@ export const ALL_ROLES: RoleAssignmentOptions[] = [
137
148
  ROLE_USER_MESSAGES,
138
149
  ROLE_ASSISTANT_MESSAGES,
139
150
  ROLE_PRIMARY_ACTIONS,
151
+ ROLE_SCROLL_TO_BOTTOM,
140
152
  ROLE_INPUT,
141
153
  ROLE_LINKS_FOCUS,
142
154
  ROLE_BORDERS,
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { COMPONENTS_SECTIONS, CONFIGURE_SECTIONS, INTERFACE_ROLES_SECTION } from "./sections";
4
+ import { ALL_ROLES } from "./role-mappings";
5
+
6
+ describe("theme editor scroll-to-bottom controls", () => {
7
+ it("exposes scroll-to-bottom config controls", () => {
8
+ const featureSection = CONFIGURE_SECTIONS.find((section) => section.id === "features");
9
+
10
+ expect(featureSection?.fields.some((field) => field.path === "features.scrollToBottom.enabled")).toBe(true);
11
+ expect(featureSection?.fields.some((field) => field.path === "features.scrollToBottom.iconName")).toBe(true);
12
+ expect(featureSection?.fields.some((field) => field.path === "features.scrollToBottom.label")).toBe(true);
13
+ });
14
+
15
+ it("exposes scroll-to-bottom component token controls", () => {
16
+ const fieldPaths = COMPONENTS_SECTIONS.flatMap((section) => section.fields.map((field) => field.path));
17
+
18
+ expect(fieldPaths).toContain("theme.components.scrollToBottom.background");
19
+ expect(fieldPaths).toContain("theme.components.scrollToBottom.foreground");
20
+ expect(fieldPaths).toContain("theme.components.scrollToBottom.border");
21
+ expect(fieldPaths).toContain("theme.components.scrollToBottom.size");
22
+ expect(fieldPaths).toContain("theme.components.scrollToBottom.borderRadius");
23
+ expect(fieldPaths).toContain("theme.components.scrollToBottom.shadow");
24
+ expect(fieldPaths).toContain("theme.components.scrollToBottom.padding");
25
+ expect(fieldPaths).toContain("theme.components.scrollToBottom.gap");
26
+ expect(fieldPaths).toContain("theme.components.scrollToBottom.fontSize");
27
+ expect(fieldPaths).toContain("theme.components.scrollToBottom.iconSize");
28
+ });
29
+
30
+ it("adds a scroll-to-bottom interface role mapping", () => {
31
+ const role = ALL_ROLES.find((entry) => entry.roleId === "role-scroll-to-bottom");
32
+
33
+ expect(role).toBeDefined();
34
+ expect(role?.targets.map((target) => target.path)).toEqual(
35
+ expect.arrayContaining([
36
+ "components.scrollToBottom.background",
37
+ "components.scrollToBottom.foreground",
38
+ "components.scrollToBottom.border",
39
+ ])
40
+ );
41
+ expect(INTERFACE_ROLES_SECTION.fields.some((field) => field.id === "role-scroll-to-bottom")).toBe(true);
42
+ });
43
+ });
@@ -8,6 +8,7 @@ import {
8
8
  ROLE_USER_MESSAGES,
9
9
  ROLE_ASSISTANT_MESSAGES,
10
10
  ROLE_PRIMARY_ACTIONS,
11
+ ROLE_SCROLL_TO_BOTTOM,
11
12
  ROLE_INPUT,
12
13
  ROLE_LINKS_FOCUS,
13
14
  ROLE_BORDERS,
@@ -420,6 +421,36 @@ const buttonColorsSectionDef: SectionDef = {
420
421
  ],
421
422
  };
422
423
 
424
+ const scrollToBottomSectionDef: SectionDef = {
425
+ id: 'scroll-to-bottom-style',
426
+ title: 'Scroll To Bottom',
427
+ description: 'Style the floating jump-to-latest affordance.',
428
+ collapsed: true,
429
+ fields: [
430
+ { id: 'scroll-bottom-bg', label: 'Background', type: 'token-ref', path: 'theme.components.scrollToBottom.background', defaultValue: 'components.button.primary.background', tokenRef: { tokenType: 'color' } },
431
+ { id: 'scroll-bottom-fg', label: 'Foreground', type: 'token-ref', path: 'theme.components.scrollToBottom.foreground', defaultValue: 'components.button.primary.foreground', tokenRef: { tokenType: 'color' } },
432
+ { id: 'scroll-bottom-border', label: 'Border', type: 'token-ref', path: 'theme.components.scrollToBottom.border', defaultValue: 'semantic.colors.primary', tokenRef: { tokenType: 'color' } },
433
+ { id: 'scroll-bottom-size', label: 'Size', type: 'text', path: 'theme.components.scrollToBottom.size', defaultValue: '40px' },
434
+ { id: 'scroll-bottom-radius', label: 'Border Radius', type: 'select', path: 'theme.components.scrollToBottom.borderRadius', defaultValue: 'palette.radius.full', options: [
435
+ { value: 'palette.radius.md', label: 'Medium' },
436
+ { value: 'palette.radius.lg', label: 'Large' },
437
+ { value: 'palette.radius.xl', label: 'Extra Large' },
438
+ { value: 'palette.radius.full', label: 'Full' },
439
+ ] },
440
+ { id: 'scroll-bottom-shadow', label: 'Shadow', type: 'select', path: 'theme.components.scrollToBottom.shadow', defaultValue: 'palette.shadows.sm', options: [
441
+ { value: 'palette.shadows.none', label: 'None' },
442
+ { value: 'palette.shadows.sm', label: 'Small' },
443
+ { value: 'palette.shadows.md', label: 'Medium' },
444
+ { value: 'palette.shadows.lg', label: 'Large' },
445
+ { value: 'palette.shadows.xl', label: 'Extra Large' },
446
+ ] },
447
+ { id: 'scroll-bottom-padding', label: 'Padding', type: 'text', path: 'theme.components.scrollToBottom.padding', defaultValue: '0.5rem 0.875rem' },
448
+ { id: 'scroll-bottom-gap', label: 'Gap', type: 'text', path: 'theme.components.scrollToBottom.gap', defaultValue: '0.5rem' },
449
+ { id: 'scroll-bottom-font-size', label: 'Font Size', type: 'text', path: 'theme.components.scrollToBottom.fontSize', defaultValue: '0.875rem' },
450
+ { id: 'scroll-bottom-icon-size', label: 'Icon Size', type: 'text', path: 'theme.components.scrollToBottom.iconSize', defaultValue: '14px' },
451
+ ],
452
+ };
453
+
423
454
  /** Shared shape sections (not scoped to light/dark) */
424
455
  export const COMPONENT_SHAPE_SECTIONS: SectionDef[] = [
425
456
  panelLayoutSectionDef,
@@ -435,6 +466,7 @@ export const COMPONENT_COLOR_SECTIONS: SectionDef[] = [
435
466
  messageColorsSectionDef,
436
467
  inputColorsSectionDef,
437
468
  buttonColorsSectionDef,
469
+ scrollToBottomSectionDef,
438
470
  ];
439
471
 
440
472
  export const COMPONENTS_SECTIONS: SectionDef[] = [
@@ -655,6 +687,9 @@ const featuresSectionDef: SectionDef = {
655
687
  fields: [
656
688
  { id: 'feat-voice', label: 'Voice Recognition', description: 'Enable voice input', type: 'toggle', path: 'voiceRecognition.enabled', defaultValue: false },
657
689
  { id: 'feat-auto-focus', label: 'Auto Focus Input', description: 'Focus input after panel opens', type: 'toggle', path: 'autoFocusInput', defaultValue: false },
690
+ { id: 'feat-scroll-bottom-enabled', label: 'Scroll To Bottom', description: 'Show a jump-to-latest affordance when the user scrolls away from new content', type: 'toggle', path: 'features.scrollToBottom.enabled', defaultValue: true },
691
+ { id: 'feat-scroll-bottom-icon', label: 'Scroll To Bottom Icon', type: 'text', path: 'features.scrollToBottom.iconName', defaultValue: 'arrow-down' },
692
+ { id: 'feat-scroll-bottom-label', label: 'Scroll To Bottom Label', description: 'Leave empty for icon-only mode', type: 'text', path: 'features.scrollToBottom.label', defaultValue: '' },
658
693
  ],
659
694
  };
660
695
 
@@ -826,6 +861,13 @@ export const INTERFACE_ROLES_SECTION: SectionDef = {
826
861
  path: 'theme.components.button.primary.background',
827
862
  roleAssignment: ROLE_PRIMARY_ACTIONS,
828
863
  },
864
+ {
865
+ id: 'role-scroll-to-bottom',
866
+ label: 'Scroll To Bottom',
867
+ type: 'role-assignment',
868
+ path: 'theme.components.scrollToBottom.background',
869
+ roleAssignment: ROLE_SCROLL_TO_BOTTOM,
870
+ },
829
871
  {
830
872
  id: 'role-input',
831
873
  label: 'Input Field',
@@ -138,6 +138,8 @@ export const THEME_TOKEN_DOCS = {
138
138
  approval:
139
139
  'requested (background, border, text), approve (background, foreground), deny (background, foreground).',
140
140
  attachment: 'image (background, border).',
141
+ scrollToBottom:
142
+ 'Floating scroll-to-bottom affordance shared by transcript and event stream: background, foreground, border, size, borderRadius, shadow, padding, gap, fontSize, iconSize.',
141
143
  toolBubble: 'shadow — tool call row box-shadow.',
142
144
  reasoningBubble: 'shadow — reasoning/thinking row box-shadow.',
143
145
  composer: 'shadow — message input form box-shadow.',
@@ -201,6 +203,12 @@ export const THEME_TOKEN_DOCS = {
201
203
  properties:
202
204
  'enabled, iconColor, backgroundColor, borderWidth, borderColor, borderRadius, size.',
203
205
  },
206
+ scrollToBottom: {
207
+ description:
208
+ 'Shared transcript + event-stream jump-to-latest affordance.',
209
+ properties:
210
+ 'features.scrollToBottom.enabled, features.scrollToBottom.iconName, features.scrollToBottom.label (empty string renders icon-only). Defaults: enabled=true, iconName="arrow-down", label="".',
211
+ },
204
212
  toolCall: {
205
213
  description: 'Tool call display styling.',
206
214
  properties:
@@ -393,6 +393,14 @@ export interface LabelButtonTokens {
393
393
  gap?: string;
394
394
  }
395
395
 
396
+ /** Scroll-to-bottom pill chrome shared by transcript + event stream. */
397
+ export interface ScrollToBottomTokens extends ComponentTokenSet {
398
+ size?: string;
399
+ gap?: string;
400
+ fontSize?: string;
401
+ iconSize?: string;
402
+ }
403
+
396
404
  /** Toggle group chrome (used by createToggleGroup). */
397
405
  export interface ToggleGroupTokens {
398
406
  /** Gap between toggle buttons. Default: 0 (connected). */
@@ -420,6 +428,8 @@ export interface ComponentTokens {
420
428
  iconButton?: IconButtonTokens;
421
429
  /** Label button styling tokens. */
422
430
  labelButton?: LabelButtonTokens;
431
+ /** Scroll-to-bottom indicator styling tokens. */
432
+ scrollToBottom?: ScrollToBottomTokens;
423
433
  /** Toggle group styling tokens. */
424
434
  toggleGroup?: ToggleGroupTokens;
425
435
  /** Artifact toolbar, tab strip, and pane chrome. */
package/src/types.ts CHANGED
@@ -552,10 +552,32 @@ export type AgentWidgetArtifactsFeature = {
552
552
  }) => HTMLElement | null;
553
553
  };
554
554
 
555
+ export type AgentWidgetScrollToBottomFeature = {
556
+ /**
557
+ * When true, Persona shows a scroll-to-bottom affordance when the user breaks
558
+ * away from the latest transcript or event stream content.
559
+ * @default true
560
+ */
561
+ enabled?: boolean;
562
+ /**
563
+ * Lucide icon name used for the affordance.
564
+ * @default "arrow-down"
565
+ */
566
+ iconName?: string;
567
+ /**
568
+ * Optional label text shown next to the icon. Set to an empty string for an
569
+ * icon-only affordance.
570
+ * @default ""
571
+ */
572
+ label?: string;
573
+ };
574
+
555
575
  export type AgentWidgetFeatureFlags = {
556
576
  showReasoning?: boolean;
557
577
  showToolCalls?: boolean;
558
578
  showEventStreamToggle?: boolean;
579
+ /** Shared transcript + event stream scroll-to-bottom affordance. */
580
+ scrollToBottom?: AgentWidgetScrollToBottomFeature;
559
581
  /** Configuration for the Event Stream inspector view */
560
582
  eventStream?: EventStreamConfig;
561
583
  /** Optional artifact sidebar (split pane / mobile drawer) */