@agent-native/toolkit 0.6.0 → 0.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.
Files changed (173) hide show
  1. package/README.md +17 -1
  2. package/dist/chat-history/ChatHistoryList.d.ts +103 -0
  3. package/dist/chat-history/ChatHistoryList.d.ts.map +1 -0
  4. package/dist/chat-history/ChatHistoryList.js +109 -0
  5. package/dist/chat-history/ChatHistoryList.js.map +1 -0
  6. package/dist/chat-history/ChatHistoryList.spec.d.ts +2 -0
  7. package/dist/chat-history/ChatHistoryList.spec.d.ts.map +1 -0
  8. package/dist/chat-history/ChatHistoryList.spec.js +283 -0
  9. package/dist/chat-history/ChatHistoryList.spec.js.map +1 -0
  10. package/dist/chat-history/index.d.ts +2 -0
  11. package/dist/chat-history/index.d.ts.map +1 -0
  12. package/dist/chat-history/index.js +2 -0
  13. package/dist/chat-history/index.js.map +1 -0
  14. package/dist/chat-history.css +314 -0
  15. package/package.json +17 -1
  16. package/src/app-shell/header-actions.tsx +84 -0
  17. package/src/app-shell/index.ts +1 -0
  18. package/src/chat-history/ChatHistoryList.spec.tsx +453 -0
  19. package/src/chat-history/ChatHistoryList.tsx +469 -0
  20. package/src/chat-history/index.ts +7 -0
  21. package/src/chat-history.css +314 -0
  22. package/src/collab-ui/AgentPresenceChip.tsx +69 -0
  23. package/src/collab-ui/LiveCursorOverlay.tsx +241 -0
  24. package/src/collab-ui/PresenceBar.tsx +346 -0
  25. package/src/collab-ui/RecentEditHighlights.tsx +225 -0
  26. package/src/collab-ui/RemoteSelectionRings.tsx +227 -0
  27. package/src/collab-ui/agent-identity.ts +16 -0
  28. package/src/collab-ui/index.ts +39 -0
  29. package/src/collab-ui/lead-client.ts +32 -0
  30. package/src/collab-ui/types.spec.ts +18 -0
  31. package/src/collab-ui/types.ts +96 -0
  32. package/src/composer/AgentComposerFrame.tsx +59 -0
  33. package/src/composer/ComposerPlusMenu.tsx +983 -0
  34. package/src/composer/MentionPopover.tsx +412 -0
  35. package/src/composer/PastedTextChip.tsx +150 -0
  36. package/src/composer/PromptComposer.spec.ts +73 -0
  37. package/src/composer/PromptComposer.tsx +718 -0
  38. package/src/composer/RealtimeVoiceMode.spec.tsx +771 -0
  39. package/src/composer/RealtimeVoiceMode.tsx +856 -0
  40. package/src/composer/TiptapComposer.spec.ts +390 -0
  41. package/src/composer/TiptapComposer.tsx +2936 -0
  42. package/src/composer/VoiceButton.spec.ts +33 -0
  43. package/src/composer/VoiceButton.tsx +255 -0
  44. package/src/composer/asset-picker-url.spec.ts +56 -0
  45. package/src/composer/asset-picker-url.ts +80 -0
  46. package/src/composer/attachment-accept.spec.ts +22 -0
  47. package/src/composer/attachment-accept.ts +71 -0
  48. package/src/composer/draft-key.spec.ts +19 -0
  49. package/src/composer/draft-key.ts +7 -0
  50. package/src/composer/extensions/FileReference.tsx +55 -0
  51. package/src/composer/extensions/MentionReference.tsx +93 -0
  52. package/src/composer/extensions/SkillReference.tsx +56 -0
  53. package/src/composer/index.ts +79 -0
  54. package/src/composer/pasted-text.spec.ts +162 -0
  55. package/src/composer/pasted-text.ts +160 -0
  56. package/src/composer/prompt-attachments.spec.ts +80 -0
  57. package/src/composer/prompt-attachments.ts +93 -0
  58. package/src/composer/realtime-voice-audio-level.spec.ts +32 -0
  59. package/src/composer/realtime-voice-audio-level.ts +64 -0
  60. package/src/composer/realtime-voice-transcript.spec.ts +95 -0
  61. package/src/composer/realtime-voice-transcript.ts +134 -0
  62. package/src/composer/runtime-adapters.tsx +343 -0
  63. package/src/composer/types.ts +66 -0
  64. package/src/composer/use-file-search.ts +64 -0
  65. package/src/composer/use-mention-search.ts +90 -0
  66. package/src/composer/use-skills.ts +45 -0
  67. package/src/composer/useRealtimeVoiceMode.spec.ts +818 -0
  68. package/src/composer/useRealtimeVoiceMode.tsx +2117 -0
  69. package/src/composer/useVoiceDictation.spec.ts +45 -0
  70. package/src/composer/useVoiceDictation.ts +1191 -0
  71. package/src/context-ui/ContextMeter.tsx +101 -0
  72. package/src/context-ui/ContextSegmentRow.tsx +184 -0
  73. package/src/context-ui/ContextTreemap.tsx +125 -0
  74. package/src/context-ui/ContextXRayPanel.tsx +377 -0
  75. package/src/context-ui/format.ts +42 -0
  76. package/src/context-ui/index.ts +15 -0
  77. package/src/context-ui/types.ts +43 -0
  78. package/src/design-tweaks/index.ts +1 -0
  79. package/src/design-tweaks/visual-style-controls.tsx +1084 -0
  80. package/src/editor/BubbleToolbar.tsx +253 -0
  81. package/src/editor/CodeBlockNode.tsx +254 -0
  82. package/src/editor/DragHandle.spec.ts +588 -0
  83. package/src/editor/DragHandle.ts +1437 -0
  84. package/src/editor/ImageExtension.roundtrip.spec.ts +117 -0
  85. package/src/editor/ImageExtension.ts +306 -0
  86. package/src/editor/RegistryBlockContext.tsx +107 -0
  87. package/src/editor/RegistryBlockNode.spec.ts +279 -0
  88. package/src/editor/RegistryBlockNode.tsx +652 -0
  89. package/src/editor/RichMarkdownEditor.collab.spec.ts +79 -0
  90. package/src/editor/RichMarkdownEditor.roundtrip.spec.ts +226 -0
  91. package/src/editor/RichMarkdownEditor.tsx +81 -0
  92. package/src/editor/RunId.ts +61 -0
  93. package/src/editor/SharedRichEditor.tsx +321 -0
  94. package/src/editor/SlashCommandMenu.tsx +310 -0
  95. package/src/editor/disableHistory.spec.ts +85 -0
  96. package/src/editor/extensions.ts +402 -0
  97. package/src/editor/gfmDoc.spec.ts +230 -0
  98. package/src/editor/gfmDoc.ts +92 -0
  99. package/src/editor/index.ts +93 -0
  100. package/src/editor/registrySlashCommands.ts +131 -0
  101. package/src/editor/structuralReorderUndo.spec.ts +144 -0
  102. package/src/editor/surgical-apply.spec.ts +279 -0
  103. package/src/editor/surgical-apply.ts +192 -0
  104. package/src/editor/useCollabReconcile.concurrent.spec.ts +422 -0
  105. package/src/editor/useCollabReconcile.idempotent.spec.ts +324 -0
  106. package/src/editor/useCollabReconcile.ts +589 -0
  107. package/src/editor.css +510 -0
  108. package/src/hooks/index.ts +2 -0
  109. package/src/hooks/use-mobile.tsx +21 -0
  110. package/src/hooks/use-toast.ts +188 -0
  111. package/src/index.ts +8 -0
  112. package/src/onboarding/index.ts +1 -0
  113. package/src/provider.tsx +47 -0
  114. package/src/sharing/VisibilityBadge.spec.ts +96 -0
  115. package/src/sharing/VisibilityBadge.tsx +37 -0
  116. package/src/sharing/index.ts +4 -0
  117. package/src/styles.css +21 -0
  118. package/src/ui/accordion.tsx +56 -0
  119. package/src/ui/alert-dialog.tsx +141 -0
  120. package/src/ui/alert.tsx +59 -0
  121. package/src/ui/aspect-ratio.tsx +5 -0
  122. package/src/ui/avatar.tsx +48 -0
  123. package/src/ui/badge.tsx +37 -0
  124. package/src/ui/breadcrumb.tsx +115 -0
  125. package/src/ui/button.tsx +78 -0
  126. package/src/ui/calendar.tsx +214 -0
  127. package/src/ui/card.tsx +86 -0
  128. package/src/ui/carousel.tsx +260 -0
  129. package/src/ui/chart.tsx +375 -0
  130. package/src/ui/checkbox.tsx +28 -0
  131. package/src/ui/collapsible.tsx +9 -0
  132. package/src/ui/command.spec.tsx +51 -0
  133. package/src/ui/command.tsx +174 -0
  134. package/src/ui/context-menu.tsx +202 -0
  135. package/src/ui/date-picker.tsx +64 -0
  136. package/src/ui/dialog.spec.tsx +43 -0
  137. package/src/ui/dialog.tsx +146 -0
  138. package/src/ui/drawer.tsx +126 -0
  139. package/src/ui/dropdown-menu.tsx +218 -0
  140. package/src/ui/form.tsx +177 -0
  141. package/src/ui/hover-card.tsx +27 -0
  142. package/src/ui/index.ts +52 -0
  143. package/src/ui/input-otp.tsx +69 -0
  144. package/src/ui/input.tsx +22 -0
  145. package/src/ui/label.tsx +24 -0
  146. package/src/ui/logo.tsx +36 -0
  147. package/src/ui/menubar.tsx +235 -0
  148. package/src/ui/navigation-menu.tsx +128 -0
  149. package/src/ui/pagination.tsx +121 -0
  150. package/src/ui/popover.tsx +44 -0
  151. package/src/ui/progress.tsx +26 -0
  152. package/src/ui/radio-group.tsx +42 -0
  153. package/src/ui/resizable.tsx +43 -0
  154. package/src/ui/scroll-area.tsx +46 -0
  155. package/src/ui/select.tsx +158 -0
  156. package/src/ui/separator.tsx +29 -0
  157. package/src/ui/sheet.tsx +160 -0
  158. package/src/ui/sidebar.tsx +777 -0
  159. package/src/ui/skeleton.tsx +18 -0
  160. package/src/ui/slider.tsx +26 -0
  161. package/src/ui/sonner.tsx +58 -0
  162. package/src/ui/spinner.tsx +17 -0
  163. package/src/ui/switch.tsx +27 -0
  164. package/src/ui/table.tsx +117 -0
  165. package/src/ui/tabs.tsx +53 -0
  166. package/src/ui/textarea.tsx +23 -0
  167. package/src/ui/toast.tsx +127 -0
  168. package/src/ui/toaster.tsx +33 -0
  169. package/src/ui/toggle-group.tsx +59 -0
  170. package/src/ui/toggle.tsx +43 -0
  171. package/src/ui/tooltip.tsx +53 -0
  172. package/src/ui/use-toast.ts +3 -0
  173. package/src/utils.ts +8 -0
@@ -0,0 +1,346 @@
1
+ import { useMemo, type CSSProperties } from "react";
2
+
3
+ import {
4
+ Tooltip,
5
+ TooltipContent,
6
+ TooltipProvider,
7
+ TooltipTrigger,
8
+ } from "../ui/tooltip.js";
9
+ import {
10
+ type CollabUser,
11
+ dedupeCollabUsersByEmail,
12
+ emailToColor,
13
+ emailToName,
14
+ } from "./types.js";
15
+
16
+ export interface PresenceBarProps {
17
+ /** Active collaborators on this document. */
18
+ activeUsers: CollabUser[];
19
+ /** Whether the agent has a durable presence entry. */
20
+ agentPresent?: boolean;
21
+ /** Whether the agent is actively making edits right now. */
22
+ agentActive?: boolean;
23
+ /** Current user's email (to exclude from the list). */
24
+ currentUserEmail?: string;
25
+ /** Max visible avatars before "+N" overflow. Default: 5 */
26
+ maxVisible?: number;
27
+ /** Additional CSS classes. */
28
+ className?: string;
29
+ /**
30
+ * Called when an avatar is clicked. Receives the user being clicked
31
+ * (or null for the agent avatar). Use this to start/stop follow mode.
32
+ */
33
+ onAvatarClick?: (user: CollabUser | null) => void;
34
+ /**
35
+ * The email of the user currently being followed. Highlighted with a
36
+ * blue ring to indicate active follow mode.
37
+ */
38
+ followingEmail?: string | null;
39
+ }
40
+
41
+ const AVATAR_SIZE = 28;
42
+ const OVERLAP = -8;
43
+ const BORDER_WIDTH = 2;
44
+ const FONT_SIZE = 12;
45
+ const AGENT_COLOR = "#00B5FF";
46
+
47
+ const baseAvatarStyle: CSSProperties = {
48
+ width: AVATAR_SIZE,
49
+ height: AVATAR_SIZE,
50
+ borderRadius: "50%",
51
+ display: "flex",
52
+ alignItems: "center",
53
+ justifyContent: "center",
54
+ fontSize: FONT_SIZE,
55
+ fontWeight: 700,
56
+ color: "#fff",
57
+ border: `${BORDER_WIDTH}px solid #fff`,
58
+ flexShrink: 0,
59
+ position: "relative",
60
+ cursor: "default",
61
+ boxSizing: "border-box",
62
+ };
63
+
64
+ const containerStyle: React.CSSProperties = {
65
+ display: "flex",
66
+ alignItems: "center",
67
+ flexDirection: "row",
68
+ };
69
+
70
+ const pulseKeyframes = `
71
+ @keyframes _anPresencePulse {
72
+ 0%, 100% { opacity: 1; }
73
+ 50% { opacity: 0.6; }
74
+ }
75
+ `;
76
+
77
+ let styleInjected = false;
78
+
79
+ function injectStyles() {
80
+ if (styleInjected || typeof document === "undefined") return;
81
+ const style = document.createElement("style");
82
+ style.textContent = pulseKeyframes;
83
+ document.head.appendChild(style);
84
+ styleInjected = true;
85
+ }
86
+
87
+ function UserAvatar({
88
+ user,
89
+ isFirst,
90
+ onClick,
91
+ isFollowing,
92
+ }: {
93
+ user: CollabUser;
94
+ isFirst: boolean;
95
+ onClick?: () => void;
96
+ isFollowing?: boolean;
97
+ }) {
98
+ const color = user.color || emailToColor(user.email);
99
+ const name = user.name || emailToName(user.email);
100
+ const initial = name.charAt(0).toUpperCase();
101
+
102
+ return (
103
+ <Tooltip>
104
+ <TooltipTrigger asChild>
105
+ <div
106
+ style={{
107
+ ...baseAvatarStyle,
108
+ backgroundColor: color,
109
+ marginLeft: isFirst ? 0 : OVERLAP,
110
+ cursor: onClick ? "pointer" : "default",
111
+ boxShadow: isFollowing
112
+ ? `0 0 0 2px #3b82f6, 0 0 0 4px #fff`
113
+ : `0 0 0 2px #fff`,
114
+ }}
115
+ aria-label={`${name} (${user.email})${isFollowing ? " — following" : ""}`}
116
+ tabIndex={onClick ? 0 : undefined}
117
+ role={onClick ? "button" : undefined}
118
+ onClick={onClick}
119
+ onKeyDown={(e) => {
120
+ if (e.key === "Enter" || e.key === " ") onClick?.();
121
+ }}
122
+ >
123
+ {user.avatarUrl ? (
124
+ <img
125
+ src={user.avatarUrl}
126
+ alt=""
127
+ style={{
128
+ width: "100%",
129
+ height: "100%",
130
+ borderRadius: "50%",
131
+ objectFit: "cover",
132
+ }}
133
+ />
134
+ ) : (
135
+ initial
136
+ )}
137
+ </div>
138
+ </TooltipTrigger>
139
+ <TooltipContent side="bottom">
140
+ {isFollowing
141
+ ? `Following ${name} — click to stop`
142
+ : onClick
143
+ ? `${name} — click to follow`
144
+ : user.email}
145
+ </TooltipContent>
146
+ </Tooltip>
147
+ );
148
+ }
149
+
150
+ function AgentAvatar({
151
+ active,
152
+ onClick,
153
+ isFollowing,
154
+ }: {
155
+ active: boolean;
156
+ onClick?: () => void;
157
+ isFollowing?: boolean;
158
+ }) {
159
+ injectStyles();
160
+
161
+ return (
162
+ <div
163
+ style={{
164
+ display: "flex",
165
+ alignItems: "center",
166
+ gap: 4,
167
+ }}
168
+ >
169
+ <div
170
+ style={{
171
+ ...baseAvatarStyle,
172
+ backgroundColor: AGENT_COLOR,
173
+ marginLeft: 0,
174
+ animation: active ? "_anPresencePulse 2s infinite" : undefined,
175
+ cursor: onClick ? "pointer" : "default",
176
+ boxShadow: isFollowing
177
+ ? `0 0 0 2px #3b82f6, 0 0 0 4px #fff`
178
+ : undefined,
179
+ }}
180
+ title={
181
+ isFollowing
182
+ ? "Following AI — click to stop"
183
+ : active
184
+ ? "AI is editing"
185
+ : "AI agent"
186
+ }
187
+ onClick={onClick}
188
+ tabIndex={onClick ? 0 : undefined}
189
+ onKeyDown={(e) => {
190
+ if (e.key === "Enter" || e.key === " ") onClick?.();
191
+ }}
192
+ role={onClick ? "button" : undefined}
193
+ >
194
+ A
195
+ </div>
196
+ {active && !isFollowing && <AgentEditingChip />}
197
+ {isFollowing && (
198
+ <span
199
+ style={{
200
+ display: "inline-flex",
201
+ alignItems: "center",
202
+ height: 20,
203
+ padding: "0 8px",
204
+ borderRadius: 9999,
205
+ backgroundColor: `#3b82f620`,
206
+ color: "#3b82f6",
207
+ fontSize: 11,
208
+ fontWeight: 600,
209
+ whiteSpace: "nowrap",
210
+ }}
211
+ >
212
+ Following AI
213
+ </span>
214
+ )}
215
+ </div>
216
+ );
217
+ }
218
+
219
+ function AgentEditingChip() {
220
+ return (
221
+ <span
222
+ style={{
223
+ display: "inline-flex",
224
+ alignItems: "center",
225
+ gap: 4,
226
+ height: 20,
227
+ padding: "0 8px",
228
+ borderRadius: 9999,
229
+ backgroundColor: `${AGENT_COLOR}20`,
230
+ color: AGENT_COLOR,
231
+ fontSize: 11,
232
+ fontWeight: 600,
233
+ whiteSpace: "nowrap",
234
+ }}
235
+ >
236
+ <span
237
+ style={{
238
+ width: 6,
239
+ height: 6,
240
+ borderRadius: "50%",
241
+ backgroundColor: AGENT_COLOR,
242
+ animation: "_anPresencePulse 2s infinite",
243
+ flexShrink: 0,
244
+ }}
245
+ />
246
+ AI editing
247
+ </span>
248
+ );
249
+ }
250
+
251
+ function OverflowBadge({
252
+ count,
253
+ isFirst,
254
+ }: {
255
+ count: number;
256
+ isFirst: boolean;
257
+ }) {
258
+ return (
259
+ <div
260
+ style={{
261
+ ...baseAvatarStyle,
262
+ backgroundColor: "rgba(255,255,255,0.1)",
263
+ color: "rgba(255,255,255,0.5)",
264
+ marginLeft: isFirst ? 0 : OVERLAP,
265
+ fontSize: 10,
266
+ }}
267
+ title={`${count} more collaborator${count === 1 ? "" : "s"}`}
268
+ >
269
+ +{count}
270
+ </div>
271
+ );
272
+ }
273
+
274
+ export function PresenceBar({
275
+ activeUsers,
276
+ agentPresent,
277
+ agentActive,
278
+ currentUserEmail,
279
+ maxVisible = 5,
280
+ className,
281
+ onAvatarClick,
282
+ followingEmail,
283
+ }: PresenceBarProps) {
284
+ const { humanUsers, showAgent } = useMemo(() => {
285
+ const currentEmail = currentUserEmail?.trim().toLowerCase();
286
+ const uniqueUsers = dedupeCollabUsersByEmail(activeUsers);
287
+ const humans = uniqueUsers.filter((u) => {
288
+ const email = u.email.trim().toLowerCase();
289
+ return email !== currentEmail && email !== "agent@system";
290
+ });
291
+ const hasAgentUser = uniqueUsers.some(
292
+ (u) => u.email.trim().toLowerCase() === "agent@system",
293
+ );
294
+ return {
295
+ humanUsers: humans,
296
+ showAgent: agentPresent || agentActive || hasAgentUser,
297
+ };
298
+ }, [activeUsers, currentUserEmail, agentPresent, agentActive]);
299
+
300
+ const visibleUsers = humanUsers.slice(0, maxVisible);
301
+ const overflowCount = humanUsers.length - visibleUsers.length;
302
+
303
+ if (!showAgent && humanUsers.length === 0) return null;
304
+
305
+ const followingLower = followingEmail?.trim().toLowerCase() ?? null;
306
+ const isFollowingAgent = followingLower === "agent@system";
307
+
308
+ return (
309
+ <TooltipProvider delayDuration={150}>
310
+ <div style={containerStyle} className={className}>
311
+ {showAgent && (
312
+ <AgentAvatar
313
+ active={!!agentActive}
314
+ onClick={onAvatarClick ? () => onAvatarClick(null) : undefined}
315
+ isFollowing={isFollowingAgent}
316
+ />
317
+ )}
318
+ {visibleUsers.length > 0 && (
319
+ <div
320
+ style={{
321
+ display: "flex",
322
+ alignItems: "center",
323
+ marginLeft: showAgent ? 6 : 0,
324
+ }}
325
+ >
326
+ {visibleUsers.map((u, i) => (
327
+ <UserAvatar
328
+ key={u.email}
329
+ user={u}
330
+ isFirst={i === 0}
331
+ onClick={onAvatarClick ? () => onAvatarClick(u) : undefined}
332
+ isFollowing={
333
+ followingLower != null &&
334
+ u.email.trim().toLowerCase() === followingLower
335
+ }
336
+ />
337
+ ))}
338
+ {overflowCount > 0 && (
339
+ <OverflowBadge count={overflowCount} isFirst={false} />
340
+ )}
341
+ </div>
342
+ )}
343
+ </div>
344
+ </TooltipProvider>
345
+ );
346
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * RecentEditHighlights — lingering, fading highlights over regions another
3
+ * participant (human or agent) just edited, with their name/avatar flag.
4
+ *
5
+ * The Google-Docs/Figma feel: when a collaborator or the AI edits something,
6
+ * the changed region glows in their color and the flag identifies them; the
7
+ * highlight fades out over the last portion of its lifetime instead of
8
+ * vanishing.
9
+ *
10
+ * Feed it `useRecentEdits(others)` (which reads participants' `recentEdits`
11
+ * awareness rings) and a `resolveRect` that maps an edit descriptor to a
12
+ * viewport DOMRect for your surface:
13
+ *
14
+ * const others = usePresence(awareness, ydoc?.clientID).others;
15
+ * const edits = useRecentEdits(others);
16
+ * <RecentEditHighlights
17
+ * edits={edits}
18
+ * resolveRect={(edit) =>
19
+ * edit.descriptor.kind === "selector"
20
+ * ? container.querySelector(edit.descriptor.selector)?.getBoundingClientRect() ?? null
21
+ * : null
22
+ * }
23
+ * containerRef={containerRef}
24
+ * />
25
+ */
26
+
27
+ import {
28
+ memo,
29
+ useEffect,
30
+ useLayoutEffect,
31
+ useRef,
32
+ useState,
33
+ type RefObject,
34
+ } from "react";
35
+
36
+ import { RECENT_EDIT_TTL_MS, type AttributedRecentEdit } from "./types.js";
37
+
38
+ export interface RecentEditHighlightsProps {
39
+ /** Attributed recent edits (from `useRecentEdits`). */
40
+ edits: AttributedRecentEdit[];
41
+ /**
42
+ * Resolver: maps an edit to a viewport-relative DOMRect, or null when the
43
+ * region can't be located (the edit is skipped).
44
+ */
45
+ resolveRect: (edit: AttributedRecentEdit) => DOMRect | null;
46
+ /** Container the highlights are positioned within (position: relative). */
47
+ containerRef: RefObject<HTMLElement | null>;
48
+ /** Highlight lifetime; should match useRecentEdits ttlMs. Default 6000. */
49
+ ttlMs?: number;
50
+ /** Additional CSS class for the overlay div. */
51
+ className?: string;
52
+ }
53
+
54
+ interface Highlight {
55
+ key: string;
56
+ color: string;
57
+ label: string;
58
+ avatarUrl?: string;
59
+ isAgent: boolean;
60
+ opacity: number;
61
+ rect: { top: number; left: number; width: number; height: number };
62
+ }
63
+
64
+ const HighlightItem = memo(function HighlightItem({ h }: { h: Highlight }) {
65
+ return (
66
+ <div
67
+ aria-label={`${h.label} edited this`}
68
+ style={{
69
+ position: "absolute",
70
+ top: h.rect.top,
71
+ left: h.rect.left,
72
+ width: h.rect.width,
73
+ height: h.rect.height,
74
+ borderRadius: 4,
75
+ pointerEvents: "none",
76
+ zIndex: 9997,
77
+ opacity: h.opacity,
78
+ transition: "opacity 400ms ease-out",
79
+ outline: `2px solid ${h.color}`,
80
+ outlineOffset: 2,
81
+ backgroundColor: `${h.color}1A`,
82
+ boxShadow: `0 0 0 1px ${h.color}33, 0 0 12px ${h.color}40`,
83
+ }}
84
+ >
85
+ <div
86
+ style={{
87
+ position: "absolute",
88
+ top: -22,
89
+ left: 0,
90
+ display: "flex",
91
+ alignItems: "center",
92
+ gap: 4,
93
+ backgroundColor: h.color,
94
+ color: "#fff",
95
+ fontSize: 10,
96
+ fontWeight: 600,
97
+ padding: "2px 6px",
98
+ borderRadius: 3,
99
+ whiteSpace: "nowrap",
100
+ maxWidth: 180,
101
+ overflow: "hidden",
102
+ textOverflow: "ellipsis",
103
+ }}
104
+ >
105
+ {h.avatarUrl ? (
106
+ <img
107
+ src={h.avatarUrl}
108
+ alt=""
109
+ style={{
110
+ width: 12,
111
+ height: 12,
112
+ borderRadius: "50%",
113
+ flexShrink: 0,
114
+ }}
115
+ />
116
+ ) : null}
117
+ {h.label}
118
+ </div>
119
+ </div>
120
+ );
121
+ });
122
+
123
+ export function RecentEditHighlights({
124
+ edits,
125
+ resolveRect,
126
+ containerRef,
127
+ ttlMs = RECENT_EDIT_TTL_MS,
128
+ className,
129
+ }: RecentEditHighlightsProps) {
130
+ const [highlights, setHighlights] = useState<Highlight[]>([]);
131
+ const editsRef = useRef(edits);
132
+ editsRef.current = edits;
133
+ const resolveRef = useRef(resolveRect);
134
+ resolveRef.current = resolveRect;
135
+
136
+ const recompute = () => {
137
+ const container = containerRef.current;
138
+ if (!container) {
139
+ setHighlights([]);
140
+ return;
141
+ }
142
+ const containerRect = container.getBoundingClientRect();
143
+ const now = Date.now();
144
+ const next: Highlight[] = [];
145
+
146
+ for (const edit of editsRef.current) {
147
+ const domRect = resolveRef.current(edit);
148
+ if (!domRect) continue;
149
+
150
+ const top = domRect.top - containerRect.top;
151
+ const left = domRect.left - containerRect.left;
152
+ if (
153
+ left + domRect.width < 0 ||
154
+ top + domRect.height < 0 ||
155
+ left > containerRect.width ||
156
+ top > containerRect.height
157
+ ) {
158
+ continue;
159
+ }
160
+
161
+ // Fade over the final 40% of the lifetime.
162
+ const age = now - edit.at;
163
+ const fadeStart = ttlMs * 0.6;
164
+ const opacity =
165
+ age <= fadeStart
166
+ ? 1
167
+ : Math.max(0, 1 - (age - fadeStart) / (ttlMs - fadeStart));
168
+
169
+ next.push({
170
+ key: `${edit.clientId}:${edit.at}`,
171
+ color: edit.user.color || "#94a3b8",
172
+ label: edit.isAgent
173
+ ? edit.label
174
+ ? `AI — ${edit.label}`
175
+ : "AI edited"
176
+ : edit.label
177
+ ? `${edit.user.name} — ${edit.label}`
178
+ : edit.user.name,
179
+ avatarUrl: (edit.user as { avatarUrl?: string }).avatarUrl,
180
+ isAgent: edit.isAgent,
181
+ opacity,
182
+ rect: { top, left, width: domRect.width, height: domRect.height },
183
+ });
184
+ }
185
+
186
+ setHighlights(next);
187
+ };
188
+
189
+ useLayoutEffect(() => {
190
+ recompute();
191
+ // eslint-disable-next-line react-hooks/exhaustive-deps
192
+ }, [edits, ttlMs]);
193
+
194
+ useEffect(() => {
195
+ const container = containerRef.current;
196
+ if (!container) return;
197
+ const observer = new ResizeObserver(() => recompute());
198
+ observer.observe(container);
199
+ container.addEventListener("scroll", recompute, { passive: true });
200
+ window.addEventListener("scroll", recompute, { passive: true });
201
+ return () => {
202
+ observer.disconnect();
203
+ container.removeEventListener("scroll", recompute);
204
+ window.removeEventListener("scroll", recompute);
205
+ };
206
+ // eslint-disable-next-line react-hooks/exhaustive-deps
207
+ }, [containerRef]);
208
+
209
+ return (
210
+ <div
211
+ aria-hidden
212
+ style={{
213
+ position: "absolute",
214
+ inset: 0,
215
+ pointerEvents: "none",
216
+ overflow: "hidden",
217
+ }}
218
+ className={className}
219
+ >
220
+ {highlights.map((h) => (
221
+ <HighlightItem key={h.key} h={h} />
222
+ ))}
223
+ </div>
224
+ );
225
+ }