paseo-plugin-chat-view 0.0.0-stage → 0.1.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.
Files changed (136) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSE.compact-agent-activity +21 -0
  3. package/LICENSE.mermaid +21 -0
  4. package/README.md +428 -2
  5. package/README_zh.md +429 -0
  6. package/client/activity/Activity.test.ts +220 -0
  7. package/client/activity/Activity.tsx +784 -0
  8. package/client/activity/detail/detail.test.ts +102 -0
  9. package/client/activity/detail/dispatch.tsx +94 -0
  10. package/client/activity/detail/file.tsx +93 -0
  11. package/client/activity/detail/index.tsx +39 -0
  12. package/client/activity/detail/parts.tsx +221 -0
  13. package/client/activity/detail/search.tsx +59 -0
  14. package/client/activity/detail/terminal.tsx +53 -0
  15. package/client/activity/detail/texts.tsx +42 -0
  16. package/client/activity/disclosure.ts +49 -0
  17. package/client/activity/dom.test.ts +52 -0
  18. package/client/activity/dom.ts +138 -0
  19. package/client/activity/group-store.test.ts +126 -0
  20. package/client/activity/group-store.ts +265 -0
  21. package/client/activity/highlight.test.ts +116 -0
  22. package/client/activity/highlight.ts +418 -0
  23. package/client/activity/history.test.ts +602 -0
  24. package/client/activity/history.ts +239 -0
  25. package/client/activity/index.ts +74 -0
  26. package/client/activity/latest-marker.test.ts +41 -0
  27. package/client/activity/latest-marker.ts +70 -0
  28. package/client/activity/styles.ts +535 -0
  29. package/client/activity/tools/child-agent.tsx +308 -0
  30. package/client/activity/tools/exa.tsx +99 -0
  31. package/client/activity/tools/github.tsx +501 -0
  32. package/client/activity/tools/list.tsx +102 -0
  33. package/client/activity/tools/lists.test.ts +193 -0
  34. package/client/activity/tools/paseo/agent.tsx +384 -0
  35. package/client/activity/tools/paseo/browser.tsx +322 -0
  36. package/client/activity/tools/paseo/index.tsx +79 -0
  37. package/client/activity/tools/paseo/parts.tsx +133 -0
  38. package/client/activity/tools/paseo/provider.tsx +220 -0
  39. package/client/activity/tools/paseo/schedule.tsx +246 -0
  40. package/client/activity/tools/paseo/speak.tsx +20 -0
  41. package/client/activity/tools/paseo/terminal.tsx +214 -0
  42. package/client/activity/tools/paseo/workspace.tsx +162 -0
  43. package/client/activity/tools/shared.tsx +431 -0
  44. package/client/activity/transform.test.ts +28 -0
  45. package/client/activity/transform.ts +43 -0
  46. package/client/agent-pills.ts +56 -0
  47. package/client/dom.ts +75 -0
  48. package/client/enhance/controller.ts +111 -0
  49. package/client/enhance/dom.test.ts +28 -0
  50. package/client/enhance/dom.ts +101 -0
  51. package/client/enhance/index.ts +95 -0
  52. package/client/enhance/pill.tsx +19 -0
  53. package/client/enhance/state.ts +58 -0
  54. package/client/feature.ts +22 -0
  55. package/client/features.test.ts +149 -0
  56. package/client/features.ts +83 -0
  57. package/client/locale.ts +45 -0
  58. package/client/mermaid/diagram.tsx +438 -0
  59. package/client/mermaid/index.ts +27 -0
  60. package/client/mermaid/item.tsx +28 -0
  61. package/client/mermaid/markdown.tsx +217 -0
  62. package/client/mermaid/registration.ts +32 -0
  63. package/client/mermaid/toolbar.tsx +175 -0
  64. package/client/mermaid/viewer.tsx +314 -0
  65. package/client/readonly/dom.test.ts +66 -0
  66. package/client/readonly/dom.ts +32 -0
  67. package/client/readonly/index.ts +27 -0
  68. package/client/registration.test.ts +243 -0
  69. package/client/settings.tsx +438 -0
  70. package/client/sticky/StickyBar.tsx +144 -0
  71. package/client/sticky/dom.test.ts +230 -0
  72. package/client/sticky/dom.ts +517 -0
  73. package/client/sticky/index.ts +38 -0
  74. package/client/sticky/pill.tsx +70 -0
  75. package/client/timeline-pager.test.ts +121 -0
  76. package/client/timeline-pager.ts +123 -0
  77. package/index.client.tsx +44 -0
  78. package/index.server.ts +12 -0
  79. package/package.json +42 -4
  80. package/paseo-plugin.json +6 -0
  81. package/server/enhance/index.ts +63 -0
  82. package/server/enhance/manifest.ts +176 -0
  83. package/server/enhance/probe.test.ts +215 -0
  84. package/server/enhance/probe.ts +118 -0
  85. package/server/enhance/provider.ts +149 -0
  86. package/shared/activity/child-agent.test.ts +27 -0
  87. package/shared/activity/child-agent.ts +26 -0
  88. package/shared/activity/diff.test.ts +74 -0
  89. package/shared/activity/diff.ts +207 -0
  90. package/shared/activity/exa.test.ts +66 -0
  91. package/shared/activity/exa.ts +183 -0
  92. package/shared/activity/file-kind.test.ts +13 -0
  93. package/shared/activity/file-kind.ts +112 -0
  94. package/shared/activity/github.test.ts +56 -0
  95. package/shared/activity/github.ts +174 -0
  96. package/shared/activity/palette.test.ts +26 -0
  97. package/shared/activity/palette.ts +106 -0
  98. package/shared/activity/parse.test.ts +39 -0
  99. package/shared/activity/parse.ts +115 -0
  100. package/shared/activity/paseo-tools.test.ts +163 -0
  101. package/shared/activity/paseo-tools.ts +497 -0
  102. package/shared/activity/text.test.ts +85 -0
  103. package/shared/activity/text.ts +202 -0
  104. package/shared/activity/timeline.test.ts +26 -0
  105. package/shared/activity/timeline.ts +108 -0
  106. package/shared/activity/tool-presentation.test.ts +154 -0
  107. package/shared/activity/tool-presentation.ts +352 -0
  108. package/shared/enhance/contract.ts +105 -0
  109. package/shared/enhance/decide.ts +97 -0
  110. package/shared/enhance/enhance.test.ts +188 -0
  111. package/shared/enhance/failure.ts +40 -0
  112. package/shared/enhance/index.ts +8 -0
  113. package/shared/enhance/normalize.ts +16 -0
  114. package/shared/enhance/parse.ts +79 -0
  115. package/shared/enhance/policy.ts +48 -0
  116. package/shared/enhance/prompt.ts +134 -0
  117. package/shared/enhance/run.ts +84 -0
  118. package/shared/i18n.test.ts +81 -0
  119. package/shared/i18n.ts +300 -0
  120. package/shared/mermaid/flowchart.test.ts +169 -0
  121. package/shared/mermaid/flowchart.ts +271 -0
  122. package/shared/mermaid/layout.test.ts +305 -0
  123. package/shared/mermaid/layout.ts +496 -0
  124. package/shared/mermaid/parse.test.ts +110 -0
  125. package/shared/mermaid/parse.ts +193 -0
  126. package/shared/mermaid/segment.test.ts +55 -0
  127. package/shared/mermaid/segment.ts +95 -0
  128. package/shared/mermaid/sequence.test.ts +68 -0
  129. package/shared/mermaid/sequence.ts +130 -0
  130. package/shared/mermaid/transform.test.ts +52 -0
  131. package/shared/mermaid/transform.ts +45 -0
  132. package/shared/mermaid/zoom.test.ts +134 -0
  133. package/shared/mermaid/zoom.ts +114 -0
  134. package/shared/settings.ts +41 -0
  135. package/shared/sticky/history.test.ts +91 -0
  136. package/shared/sticky/history.ts +86 -0
@@ -0,0 +1,517 @@
1
+ import { Platform } from "react-native";
2
+ import { copyText } from "@getpaseo/plugin/client/react-native";
3
+ import { subscribeLocale, tr } from "../locale";
4
+ import type { Document, Element, MutationObserverApi, ResizeObserverApi, Window } from "../dom";
5
+
6
+ // Sticky-question DOM access: host scroll-container discovery, the overlay bar and its 100ms
7
+ // show confirmation live here. No other feature reaches into the conversation viewport.
8
+ declare const document: Document;
9
+ declare const window: Window;
10
+ declare const MutationObserver: new (
11
+ callback: (records: Array<{ target: unknown }>) => void,
12
+ ) => MutationObserverApi;
13
+ declare const ResizeObserver: new (callback: () => void) => ResizeObserverApi;
14
+
15
+ export interface StickyPrompt {
16
+ id: string;
17
+ text: string;
18
+ aliases?: readonly string[];
19
+ }
20
+
21
+ /** Resolve the preceding user prompt for a mounted history row, including virtualized history. */
22
+ export type ResolveStickyPrompt = (
23
+ rowId: string,
24
+ messageId: string | null,
25
+ ) => StickyPrompt | undefined;
26
+
27
+ const SCROLL_SELECTOR = '[data-testid="agent-chat-scroll"]';
28
+ const ROW_SELECTOR = "[data-history-row-id], [data-chat-view-hidden-row-id]";
29
+ export interface StickyColors {
30
+ background: string;
31
+ foreground: string;
32
+ muted: string;
33
+ border: string;
34
+ }
35
+ const installations = new Map<Element, { count: number; cleanup: () => void }>();
36
+
37
+ export function getStickyViewport(anchor: unknown): unknown | undefined {
38
+ if (Platform.OS !== "web" || typeof document === "undefined") return undefined;
39
+ let element = anchor as Element | null;
40
+ const direct = element?.closest?.(SCROLL_SELECTOR);
41
+ if (direct) return direct;
42
+ while (element) {
43
+ const scrolls = element.querySelectorAll(SCROLL_SELECTOR);
44
+ if (scrolls.length === 1) return scrolls[0];
45
+ if (scrolls.length > 1) return undefined;
46
+ element = element.parentElement;
47
+ }
48
+ return undefined;
49
+ }
50
+
51
+ /** Decorate the host viewport without replacing native prompts or touching its scrolling model. */
52
+ export function installStickyMessages(
53
+ anchor: unknown,
54
+ resolvePrompt: ResolveStickyPrompt,
55
+ colors: StickyColors,
56
+ subscribeChanges?: (listener: () => void) => () => void,
57
+ onDetached?: () => void,
58
+ getLatestPrompt?: () => StickyPrompt | undefined,
59
+ ): () => void {
60
+ if (Platform.OS !== "web" || typeof document === "undefined") return () => {};
61
+ const foundScroller = getStickyViewport(anchor) as Element | undefined;
62
+ if (!foundScroller) return () => {};
63
+ const scroller: Element = foundScroller;
64
+ const existing = installations.get(scroller);
65
+ if (existing) {
66
+ existing.count += 1;
67
+ return () => release(scroller);
68
+ }
69
+
70
+ let overlay:
71
+ | { bar: Element; label: Element; text: Element; toggle: Element; resetCopy: () => void }
72
+ | undefined;
73
+ let copyResetTimer: ReturnType<typeof setTimeout> | undefined;
74
+ let showTimer: ReturnType<typeof setTimeout> | undefined;
75
+ let pendingPromptId: string | undefined;
76
+ let showReady = false;
77
+ let frame: number | null = null;
78
+ let stopped = false;
79
+ let lastPrompt: StickyPrompt | undefined;
80
+ let lastContentBounds: { left: number; width: number } | undefined;
81
+ const appliedStyles: Record<string, string> = {};
82
+ let expanded = false;
83
+ let displayedPromptId: string | undefined;
84
+ let textLineHeight = 21;
85
+ let userAppearance: Record<string, string> = {
86
+ backgroundColor: colors.background,
87
+ border: "0",
88
+ borderRadius: "16px",
89
+ borderTopRightRadius: "2px",
90
+ padding: "16px",
91
+ };
92
+
93
+ function cancelPendingShow() {
94
+ if (showTimer !== undefined) clearTimeout(showTimer);
95
+ showTimer = undefined;
96
+ pendingPromptId = undefined;
97
+ showReady = false;
98
+ }
99
+
100
+ let userAppearanceMatched = false;
101
+ function matchUserAppearance(rows: Element[]) {
102
+ if (userAppearanceMatched) return userAppearance;
103
+ const message = rows
104
+ .map((row) => row.querySelector('[data-testid="user-message"] [data-message-text="true"]'))
105
+ .find(Boolean);
106
+ if (message?.parentElement) {
107
+ userAppearanceMatched = true;
108
+ const bubble = window.getComputedStyle(message.parentElement);
109
+ for (const key of [
110
+ "backgroundColor",
111
+ "borderRadius",
112
+ "borderTopRightRadius",
113
+ "paddingTop",
114
+ "paddingBottom",
115
+ "paddingLeft",
116
+ "paddingRight",
117
+ ]) {
118
+ userAppearance[key] = bubble[key] ?? "";
119
+ }
120
+ const text = window.getComputedStyle(message);
121
+ textLineHeight = Number.parseFloat(text.lineHeight ?? "") || 21;
122
+ if (overlay)
123
+ Object.assign(overlay.text.style, {
124
+ fontSize: text.fontSize,
125
+ lineHeight: `${textLineHeight}px`,
126
+ fontFamily: text.fontFamily,
127
+ });
128
+ }
129
+ return userAppearance;
130
+ }
131
+
132
+ function updateDisclosure(prompt: StickyPrompt, viewportHeight: number) {
133
+ if (!overlay) return;
134
+ if (displayedPromptId !== prompt.id) {
135
+ displayedPromptId = prompt.id;
136
+ expanded = false;
137
+ overlay.resetCopy();
138
+ overlay.text.scrollTop = 0;
139
+ }
140
+ overlay.text.style.WebkitLineClamp = expanded ? "unset" : "3";
141
+ overlay.text.style.display = expanded ? "block" : "-webkit-box";
142
+ overlay.text.style.maxHeight = expanded
143
+ ? `${Math.max(textLineHeight * 3, viewportHeight - 96)}px`
144
+ : `${textLineHeight * 3}px`;
145
+ overlay.text.style.overflowY = expanded ? "auto" : "hidden";
146
+ const overflowing = overlay.text.scrollHeight > overlay.text.clientHeight + 1;
147
+ overlay.toggle.style.display = expanded || overflowing ? "block" : "none";
148
+ describeDisclosure();
149
+ }
150
+
151
+ /** The toggle's own wording, set by a new frame and by a language change alike. */
152
+ function describeDisclosure() {
153
+ if (!overlay) return;
154
+ const title = expanded ? tr("sticky.collapse") : tr("sticky.more");
155
+ if (overlay.toggle.textContent !== title) overlay.toggle.textContent = title;
156
+ overlay.toggle.setAttribute(
157
+ "aria-label",
158
+ expanded ? tr("sticky.ariaCollapse") : tr("sticky.ariaExpand"),
159
+ );
160
+ overlay.toggle.setAttribute("aria-expanded", String(expanded));
161
+ }
162
+
163
+ /** Static overlay text lives in nodes created once, so a language change rewrites it here. */
164
+ function relabelOverlay() {
165
+ if (!overlay) return;
166
+ overlay.label.textContent = tr("sticky.label");
167
+ overlay.resetCopy();
168
+ describeDisclosure();
169
+ }
170
+
171
+ function nativePrompt(row: Element): StickyPrompt | undefined {
172
+ const text = row.querySelector(
173
+ '[data-testid="user-message"] [data-message-text="true"]',
174
+ )?.textContent;
175
+ const id = row.getAttribute("data-history-row-id");
176
+ return text?.trim() && id ? { id, text } : undefined;
177
+ }
178
+
179
+ function resolveRow(row: Element): StickyPrompt | undefined {
180
+ return (
181
+ nativePrompt(row) ??
182
+ resolvePrompt(
183
+ row.getAttribute("data-history-row-id") ?? "",
184
+ row.getAttribute("data-message-id"),
185
+ )
186
+ );
187
+ }
188
+
189
+ function readingContext(rows: Element[], viewportTop: number, atBottom: boolean) {
190
+ const firstIndex = rows.findIndex(
191
+ (row) => row.getBoundingClientRect().bottom > viewportTop + 1,
192
+ );
193
+ const index = atBottom || firstIndex < 0 ? rows.length - 1 : firstIndex;
194
+ let prompt = atBottom ? getLatestPrompt?.() : undefined;
195
+ for (let previous = index; !prompt && previous >= 0; previous -= 1) {
196
+ prompt = resolveRow(rows[previous]!);
197
+ }
198
+ // Keep context during a virtualizer's transient empty frame or an async history lookup.
199
+ prompt ??= lastPrompt;
200
+ if (prompt) lastPrompt = prompt;
201
+ return { index, prompt };
202
+ }
203
+
204
+ function promptIsVisible(rows: Element[], prompt: StickyPrompt, top: number, bottom: number) {
205
+ const ids = new Set([prompt.id, ...(prompt.aliases ?? [])]);
206
+ const row = rows.find(
207
+ (item) =>
208
+ ids.has(item.getAttribute("data-history-row-id") ?? "") ||
209
+ ids.has(item.getAttribute("data-message-id") ?? ""),
210
+ );
211
+ if (!row) return false;
212
+ const message = row.querySelector('[data-testid="user-message"] [data-message-text="true"]');
213
+ const rect = (message ?? row).getBoundingClientRect();
214
+ return rect.bottom > top && rect.top < bottom;
215
+ }
216
+
217
+ function contentBounds(row: Element | undefined, viewportLeft: number) {
218
+ if (lastContentBounds && lastContentBounds.width > 0) {
219
+ return lastContentBounds;
220
+ }
221
+ const content = row?.firstElementChild;
222
+ if (content) {
223
+ const rect = content.getBoundingClientRect();
224
+ const style = window.getComputedStyle(content);
225
+ const paddingLeft = Number.parseFloat(style.paddingLeft ?? "0") || 0;
226
+ const paddingRight = Number.parseFloat(style.paddingRight ?? "0") || 0;
227
+ if (rect.width > 0)
228
+ lastContentBounds = {
229
+ left: rect.left + paddingLeft,
230
+ width: rect.width - paddingLeft - paddingRight,
231
+ };
232
+ }
233
+ return lastContentBounds ?? { left: viewportLeft + 16, width: scroller.clientWidth - 32 };
234
+ }
235
+
236
+ function createOverlay() {
237
+ const parent = scroller.parentElement;
238
+ if (!parent) return undefined;
239
+ const bar = document.createElement("div");
240
+ bar.setAttribute("data-conversation-sticky-message", "true");
241
+ bar.setAttribute("role", "note");
242
+ Object.assign(bar.style, {
243
+ position: "absolute",
244
+ zIndex: "20",
245
+ boxSizing: "border-box",
246
+ display: "none",
247
+ padding: "10px 16px",
248
+ pointerEvents: "auto",
249
+ border: "1px solid",
250
+ borderRadius: "8px",
251
+ overflow: "hidden",
252
+ fontFamily: "inherit",
253
+ lineHeight: "20px",
254
+ });
255
+ const label = document.createElement("div");
256
+ label.textContent = tr("sticky.label");
257
+ Object.assign(label.style, { fontSize: "11px", lineHeight: "16px", marginBottom: "2px" });
258
+ label.style.color = colors.muted;
259
+ const text = document.createElement("div");
260
+ Object.assign(text.style, {
261
+ fontSize: "15px",
262
+ lineHeight: "21px",
263
+ whiteSpace: "pre-wrap",
264
+ overflowWrap: "anywhere",
265
+ display: "-webkit-box",
266
+ WebkitBoxOrient: "vertical",
267
+ WebkitLineClamp: "3",
268
+ overflow: "hidden",
269
+ });
270
+ const message = document.createElement("div");
271
+ Object.assign(message.style, { minWidth: "0" });
272
+ const copy = document.createElement("button");
273
+ copy.setAttribute("type", "button");
274
+ copy.setAttribute("data-sticky-copy", "true");
275
+ copy.setAttribute("aria-label", tr("sticky.copy"));
276
+ copy.setAttribute("title", tr("sticky.copy"));
277
+ Object.assign(copy.style, {
278
+ display: "flex",
279
+ alignItems: "center",
280
+ justifyContent: "center",
281
+ flexShrink: "0",
282
+ marginLeft: "auto",
283
+ width: "24px",
284
+ height: "24px",
285
+ padding: "5px",
286
+ border: "0",
287
+ borderRadius: "4px",
288
+ background: "transparent",
289
+ color: colors.muted,
290
+ cursor: "pointer",
291
+ transition: "background-color 150ms ease",
292
+ });
293
+ const hoverStyle = document.createElement("style");
294
+ hoverStyle.textContent = `
295
+ [data-conversation-sticky-message] [data-sticky-copy]:hover,
296
+ [data-conversation-sticky-message] [data-sticky-copy]:focus-visible {
297
+ background-color: color-mix(in srgb, currentColor 10%, transparent) !important;
298
+ }
299
+ @media (prefers-reduced-motion: reduce) {
300
+ [data-conversation-sticky-message] [data-sticky-copy] {
301
+ transition: none !important;
302
+ }
303
+ }
304
+ `;
305
+ bar.appendChild(hoverStyle);
306
+ const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
307
+ for (const [name, value] of Object.entries({
308
+ width: "14",
309
+ height: "14",
310
+ viewBox: "0 0 24 24",
311
+ fill: "none",
312
+ stroke: "currentColor",
313
+ "stroke-width": "1.75",
314
+ "stroke-linecap": "round",
315
+ "stroke-linejoin": "round",
316
+ "aria-hidden": "true",
317
+ }))
318
+ icon.setAttribute(name, value);
319
+ const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
320
+ for (const [name, value] of Object.entries({
321
+ x: "9",
322
+ y: "9",
323
+ width: "13",
324
+ height: "13",
325
+ rx: "2",
326
+ }))
327
+ rect.setAttribute(name, value);
328
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
329
+ path.setAttribute("d", "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1");
330
+ icon.appendChild(rect);
331
+ icon.appendChild(path);
332
+ copy.appendChild(icon);
333
+ const copyPath = path.getAttribute("d")!;
334
+ function resetCopy() {
335
+ if (copyResetTimer !== undefined) clearTimeout(copyResetTimer);
336
+ rect.style.display = "";
337
+ path.setAttribute("d", copyPath);
338
+ copy.setAttribute("title", tr("sticky.copy"));
339
+ copy.setAttribute("aria-label", tr("sticky.copy"));
340
+ }
341
+ copy.addEventListener("click", () => {
342
+ const copiedText = text.textContent ?? "";
343
+ resetCopy();
344
+ void copyText(copiedText).then(
345
+ () => {
346
+ if (stopped || text.textContent !== copiedText) return;
347
+ rect.style.display = "none";
348
+ path.setAttribute("d", "M5 12l4 4L19 6");
349
+ copy.setAttribute("title", tr("sticky.copied"));
350
+ copy.setAttribute("aria-label", tr("sticky.ariaCopied"));
351
+ copyResetTimer = setTimeout(resetCopy, 1800);
352
+ },
353
+ () => copy.setAttribute("title", tr("sticky.copyFailed")),
354
+ );
355
+ });
356
+ message.appendChild(text);
357
+ const toggle = document.createElement("button");
358
+ toggle.setAttribute("type", "button");
359
+ toggle.setAttribute("aria-label", tr("sticky.ariaExpand"));
360
+ Object.assign(toggle.style, {
361
+ display: "none",
362
+ border: "0",
363
+ background: "transparent",
364
+ color: colors.foreground,
365
+ fontSize: "12px",
366
+ lineHeight: "20px",
367
+ padding: "4px 0 0",
368
+ cursor: "pointer",
369
+ });
370
+ toggle.addEventListener("click", () => {
371
+ expanded = !expanded;
372
+ schedule();
373
+ });
374
+ bar.appendChild(label);
375
+ bar.appendChild(message);
376
+ const actions = document.createElement("div");
377
+ Object.assign(actions.style, {
378
+ display: "flex",
379
+ alignItems: "center",
380
+ justifyContent: "space-between",
381
+ gap: "8px",
382
+ marginTop: "4px",
383
+ });
384
+ actions.appendChild(toggle);
385
+ actions.appendChild(copy);
386
+ bar.appendChild(actions);
387
+ parent.appendChild(bar);
388
+ return { bar, label, text, toggle, resetCopy };
389
+ }
390
+
391
+ function update() {
392
+ frame = null;
393
+ if (stopped) return;
394
+ if (!scroller.isConnected) {
395
+ installations.get(scroller)?.cleanup();
396
+ installations.delete(scroller);
397
+ onDetached?.();
398
+ return;
399
+ }
400
+ overlay ??= createOverlay();
401
+ if (!overlay) return;
402
+ const viewport = scroller.getBoundingClientRect();
403
+ const rows = Array.from(scroller.querySelectorAll(ROW_SELECTOR));
404
+ const atBottom = scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop <= 2;
405
+ const { index: readingIndex, prompt } = readingContext(rows, viewport.top, atBottom);
406
+ if (
407
+ !prompt ||
408
+ !prompt.text.trim() ||
409
+ promptIsVisible(rows, prompt, viewport.top, viewport.bottom) ||
410
+ viewport.height <= 0 ||
411
+ viewport.width <= 0
412
+ ) {
413
+ cancelPendingShow();
414
+ if (overlay.bar.style.display !== "none") overlay.bar.style.display = "none";
415
+ appliedStyles.display = "none";
416
+ return;
417
+ }
418
+ // A virtualized row may disappear for a frame. Hide immediately when the prompt is visible,
419
+ // but require a stable absence before showing the bar to avoid flashing during reconciliation.
420
+ if (overlay.bar.style.display !== "block") {
421
+ if (pendingPromptId !== prompt.id) {
422
+ cancelPendingShow();
423
+ pendingPromptId = prompt.id;
424
+ showTimer = setTimeout(() => {
425
+ showTimer = undefined;
426
+ showReady = true;
427
+ schedule();
428
+ }, 100);
429
+ }
430
+ if (!showReady) return;
431
+ }
432
+ cancelPendingShow();
433
+ const parent = scroller.parentElement;
434
+ if (!parent) return;
435
+ const parentRect = parent.getBoundingClientRect();
436
+ const bounds = contentBounds(rows[readingIndex], viewport.left);
437
+ const topOffset = Math.round(viewport.top - parentRect.top - parent.clientTop);
438
+ const styles = {
439
+ display: "block",
440
+ top: `${Math.abs(topOffset) <= 1 ? 0 : topOffset}px`,
441
+ left: `${bounds.left - parentRect.left - parent.clientLeft}px`,
442
+ width: `${bounds.width}px`,
443
+ color: colors.foreground,
444
+ backgroundColor: colors.background,
445
+ borderColor: colors.border,
446
+ ...matchUserAppearance(rows),
447
+ };
448
+ // Avoid writing identical inline styles on every stream/scroll frame.
449
+ for (const [key, value] of Object.entries(styles)) {
450
+ if (appliedStyles[key] !== value) {
451
+ overlay.bar.style[key] = value;
452
+ appliedStyles[key] = value;
453
+ }
454
+ }
455
+ if (overlay.text.textContent !== prompt.text) overlay.text.textContent = prompt.text;
456
+ updateDisclosure(prompt, viewport.height);
457
+ }
458
+ function schedule() {
459
+ if (!stopped && frame === null) frame = window.requestAnimationFrame(update);
460
+ }
461
+ const observer = new MutationObserver(schedule);
462
+ observer.observe(scroller, {
463
+ childList: true,
464
+ subtree: true,
465
+ });
466
+ const parentElement = scroller.parentElement;
467
+ const detachObserver = parentElement
468
+ ? new MutationObserver(() => {
469
+ if (!scroller.isConnected) schedule();
470
+ })
471
+ : null;
472
+ if (parentElement && detachObserver) detachObserver.observe(parentElement, { childList: true });
473
+ const onResize = () => {
474
+ lastContentBounds = undefined;
475
+ userAppearanceMatched = false;
476
+ schedule();
477
+ };
478
+ const resizeObserver = new ResizeObserver(onResize);
479
+ resizeObserver.observe(scroller);
480
+ // Listen on the scroller itself: a capture-phase listener on document also fires for the
481
+ // plugin's own nested scroll panels, each forcing a full-viewport layout pass.
482
+ scroller.addEventListener("scroll", schedule);
483
+ window.addEventListener("resize", onResize);
484
+ const unsubscribe = subscribeChanges?.(schedule);
485
+ const unsubscribeLocale = subscribeLocale(() => {
486
+ relabelOverlay();
487
+ schedule();
488
+ });
489
+ schedule();
490
+ const cleanup = () => {
491
+ if (stopped) return;
492
+ stopped = true;
493
+ observer.disconnect();
494
+ detachObserver?.disconnect();
495
+ resizeObserver.disconnect();
496
+ unsubscribe?.();
497
+ unsubscribeLocale();
498
+ scroller.removeEventListener("scroll", schedule);
499
+ window.removeEventListener("resize", onResize);
500
+ if (frame !== null) window.cancelAnimationFrame(frame);
501
+ cancelPendingShow();
502
+ if (copyResetTimer !== undefined) clearTimeout(copyResetTimer);
503
+ overlay?.bar.remove();
504
+ };
505
+ installations.set(scroller, { count: 1, cleanup });
506
+ return () => release(scroller);
507
+ }
508
+
509
+ function release(scroller: Element) {
510
+ const installation = installations.get(scroller);
511
+ if (!installation) return;
512
+ installation.count -= 1;
513
+ if (installation.count === 0) {
514
+ installation.cleanup();
515
+ installations.delete(scroller);
516
+ }
517
+ }
@@ -0,0 +1,38 @@
1
+ import type { PluginClientContext } from "@getpaseo/plugin/client";
2
+ import type { ChatViewSettings } from "../../shared/settings";
3
+ import { createAgentPills } from "../agent-pills";
4
+ import type { FeatureDomain } from "../feature";
5
+ import { subscribeLocale } from "../locale";
6
+ import { cleanupStickyMessages } from "./StickyBar";
7
+ import { createStickyPill } from "./pill";
8
+
9
+ /**
10
+ * The sticky bar self-mounts: `useStickyMessage` reads the switch itself and installs into whatever
11
+ * viewport it discovers, so this domain has no global host registration to add or track. Turning
12
+ * the switch off is enforced by tearing the installed viewports down; turning it back on needs no
13
+ * re-registration, because the next mounted row installs itself.
14
+ *
15
+ * The composer pill is agent-scoped, so it is attached per conversation instead.
16
+ */
17
+ export function createStickyDomain(
18
+ client: PluginClientContext,
19
+ onSettingsChange: (values: ChatViewSettings) => void,
20
+ ): FeatureDomain<"stickyEnabled"> {
21
+ const pills = createAgentPills((agent) => createStickyPill(client, agent, onSettingsChange));
22
+ // The pill title is fixed at registration, so a language change rebuilds the pills.
23
+ const unsubscribeLocale = subscribeLocale(() => pills.refresh());
24
+ return {
25
+ settingKey: "stickyEnabled",
26
+ setEnabled(enabled) {
27
+ if (!enabled) cleanupStickyMessages();
28
+ pills.setEnabled(enabled);
29
+ },
30
+ registerAgent: (agent) => pills.registerAgent(agent),
31
+ unregisterAgent: (agentId) => pills.unregisterAgent(agentId),
32
+ dispose() {
33
+ unsubscribeLocale();
34
+ cleanupStickyMessages();
35
+ pills.dispose();
36
+ },
37
+ };
38
+ }
@@ -0,0 +1,70 @@
1
+ import { useEffect, useRef, useSyncExternalStore } from "react";
2
+ import { View } from "react-native";
3
+ import type { PaseoAgent } from "@getpaseo/client";
4
+ import {
5
+ useSettings,
6
+ type PluginButtonIconProps,
7
+ type PluginButtonRegistration,
8
+ type PluginClientContext,
9
+ } from "@getpaseo/plugin/client";
10
+ import { Icon } from "@getpaseo/plugin/client/react-native";
11
+ import { useStickyMessage } from "./StickyBar";
12
+
13
+ import { activitySettings, type ChatViewSettings } from "../../shared/settings";
14
+ import { tr } from "../locale";
15
+
16
+ const disabled = new Set<string>();
17
+ const listeners = new Set<() => void>();
18
+ const subscribe = (listener: () => void) => {
19
+ listeners.add(listener);
20
+ return () => listeners.delete(listener);
21
+ };
22
+
23
+ export function toggleSticky(agentId: string) {
24
+ if (disabled.has(agentId)) disabled.delete(agentId);
25
+ else disabled.add(agentId);
26
+ for (const listener of listeners) listener();
27
+ }
28
+
29
+ export function createStickyPill(
30
+ client: PluginClientContext,
31
+ agent: PaseoAgent,
32
+ onSettingsChange: (values: ChatViewSettings) => void,
33
+ ): PluginButtonRegistration {
34
+ const pillTitle = tr("sticky.pill");
35
+ return client.addComposerPill({
36
+ id: `sticky-${agent.id}`,
37
+ workspaceId: agent.workspaceId ?? "",
38
+ agentId: agent.id,
39
+ button: {
40
+ title: pillTitle,
41
+ label: pillTitle,
42
+ icon: (props) => <StickyIcon {...props} onSettingsChange={onSettingsChange} />,
43
+ behavior: { kind: "action", onPress: () => toggleSticky(agent.id) },
44
+ },
45
+ });
46
+ }
47
+
48
+ export function StickyIcon(
49
+ props: PluginButtonIconProps & { onSettingsChange: (values: ChatViewSettings) => void },
50
+ ) {
51
+ const settings = useSettings(activitySettings);
52
+ const values = settings.status === "ready" ? settings.values : undefined;
53
+ useEffect(() => {
54
+ if (values) props.onSettingsChange(values);
55
+ }, [values, props.onSettingsChange]);
56
+ const agentId = props.context === "agent" ? props.agentId : "";
57
+ const locallyEnabled = useSyncExternalStore(subscribe, () => !disabled.has(agentId));
58
+ const enabled = values?.stickyEnabled === true && locallyEnabled;
59
+ const anchor = useRef<View>(null);
60
+ useStickyMessage(anchor, agentId, props.theme, enabled);
61
+ return (
62
+ <View ref={anchor}>
63
+ <Icon
64
+ name={enabled ? "Pin" : "PinOff"}
65
+ size={props.size}
66
+ color={enabled ? props.theme.colors.accent : props.color}
67
+ />
68
+ </View>
69
+ );
70
+ }