@agent-native/toolkit 0.6.0 → 0.8.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 (174) hide show
  1. package/README.md +17 -1
  2. package/agent-native.eject.json +411 -0
  3. package/dist/chat-history/ChatHistoryList.d.ts +103 -0
  4. package/dist/chat-history/ChatHistoryList.d.ts.map +1 -0
  5. package/dist/chat-history/ChatHistoryList.js +109 -0
  6. package/dist/chat-history/ChatHistoryList.js.map +1 -0
  7. package/dist/chat-history/ChatHistoryList.spec.d.ts +2 -0
  8. package/dist/chat-history/ChatHistoryList.spec.d.ts.map +1 -0
  9. package/dist/chat-history/ChatHistoryList.spec.js +283 -0
  10. package/dist/chat-history/ChatHistoryList.spec.js.map +1 -0
  11. package/dist/chat-history/index.d.ts +2 -0
  12. package/dist/chat-history/index.d.ts.map +1 -0
  13. package/dist/chat-history/index.js +2 -0
  14. package/dist/chat-history/index.js.map +1 -0
  15. package/dist/chat-history.css +314 -0
  16. package/package.json +20 -1
  17. package/src/app-shell/header-actions.tsx +84 -0
  18. package/src/app-shell/index.ts +1 -0
  19. package/src/chat-history/ChatHistoryList.spec.tsx +453 -0
  20. package/src/chat-history/ChatHistoryList.tsx +469 -0
  21. package/src/chat-history/index.ts +7 -0
  22. package/src/chat-history.css +314 -0
  23. package/src/collab-ui/AgentPresenceChip.tsx +69 -0
  24. package/src/collab-ui/LiveCursorOverlay.tsx +241 -0
  25. package/src/collab-ui/PresenceBar.tsx +346 -0
  26. package/src/collab-ui/RecentEditHighlights.tsx +225 -0
  27. package/src/collab-ui/RemoteSelectionRings.tsx +227 -0
  28. package/src/collab-ui/agent-identity.ts +16 -0
  29. package/src/collab-ui/index.ts +39 -0
  30. package/src/collab-ui/lead-client.ts +32 -0
  31. package/src/collab-ui/types.spec.ts +18 -0
  32. package/src/collab-ui/types.ts +96 -0
  33. package/src/composer/AgentComposerFrame.tsx +59 -0
  34. package/src/composer/ComposerPlusMenu.tsx +983 -0
  35. package/src/composer/MentionPopover.tsx +412 -0
  36. package/src/composer/PastedTextChip.tsx +150 -0
  37. package/src/composer/PromptComposer.spec.ts +73 -0
  38. package/src/composer/PromptComposer.tsx +718 -0
  39. package/src/composer/RealtimeVoiceMode.spec.tsx +771 -0
  40. package/src/composer/RealtimeVoiceMode.tsx +856 -0
  41. package/src/composer/TiptapComposer.spec.ts +390 -0
  42. package/src/composer/TiptapComposer.tsx +2936 -0
  43. package/src/composer/VoiceButton.spec.ts +33 -0
  44. package/src/composer/VoiceButton.tsx +255 -0
  45. package/src/composer/asset-picker-url.spec.ts +56 -0
  46. package/src/composer/asset-picker-url.ts +80 -0
  47. package/src/composer/attachment-accept.spec.ts +22 -0
  48. package/src/composer/attachment-accept.ts +71 -0
  49. package/src/composer/draft-key.spec.ts +19 -0
  50. package/src/composer/draft-key.ts +7 -0
  51. package/src/composer/extensions/FileReference.tsx +55 -0
  52. package/src/composer/extensions/MentionReference.tsx +93 -0
  53. package/src/composer/extensions/SkillReference.tsx +56 -0
  54. package/src/composer/index.ts +79 -0
  55. package/src/composer/pasted-text.spec.ts +162 -0
  56. package/src/composer/pasted-text.ts +160 -0
  57. package/src/composer/prompt-attachments.spec.ts +80 -0
  58. package/src/composer/prompt-attachments.ts +93 -0
  59. package/src/composer/realtime-voice-audio-level.spec.ts +32 -0
  60. package/src/composer/realtime-voice-audio-level.ts +64 -0
  61. package/src/composer/realtime-voice-transcript.spec.ts +95 -0
  62. package/src/composer/realtime-voice-transcript.ts +134 -0
  63. package/src/composer/runtime-adapters.tsx +343 -0
  64. package/src/composer/types.ts +66 -0
  65. package/src/composer/use-file-search.ts +64 -0
  66. package/src/composer/use-mention-search.ts +90 -0
  67. package/src/composer/use-skills.ts +45 -0
  68. package/src/composer/useRealtimeVoiceMode.spec.ts +818 -0
  69. package/src/composer/useRealtimeVoiceMode.tsx +2117 -0
  70. package/src/composer/useVoiceDictation.spec.ts +45 -0
  71. package/src/composer/useVoiceDictation.ts +1191 -0
  72. package/src/context-ui/ContextMeter.tsx +101 -0
  73. package/src/context-ui/ContextSegmentRow.tsx +184 -0
  74. package/src/context-ui/ContextTreemap.tsx +125 -0
  75. package/src/context-ui/ContextXRayPanel.tsx +377 -0
  76. package/src/context-ui/format.ts +42 -0
  77. package/src/context-ui/index.ts +15 -0
  78. package/src/context-ui/types.ts +43 -0
  79. package/src/design-tweaks/index.ts +1 -0
  80. package/src/design-tweaks/visual-style-controls.tsx +1084 -0
  81. package/src/editor/BubbleToolbar.tsx +253 -0
  82. package/src/editor/CodeBlockNode.tsx +254 -0
  83. package/src/editor/DragHandle.spec.ts +588 -0
  84. package/src/editor/DragHandle.ts +1437 -0
  85. package/src/editor/ImageExtension.roundtrip.spec.ts +117 -0
  86. package/src/editor/ImageExtension.ts +306 -0
  87. package/src/editor/RegistryBlockContext.tsx +107 -0
  88. package/src/editor/RegistryBlockNode.spec.ts +279 -0
  89. package/src/editor/RegistryBlockNode.tsx +652 -0
  90. package/src/editor/RichMarkdownEditor.collab.spec.ts +79 -0
  91. package/src/editor/RichMarkdownEditor.roundtrip.spec.ts +226 -0
  92. package/src/editor/RichMarkdownEditor.tsx +81 -0
  93. package/src/editor/RunId.ts +61 -0
  94. package/src/editor/SharedRichEditor.tsx +321 -0
  95. package/src/editor/SlashCommandMenu.tsx +310 -0
  96. package/src/editor/disableHistory.spec.ts +85 -0
  97. package/src/editor/extensions.ts +402 -0
  98. package/src/editor/gfmDoc.spec.ts +230 -0
  99. package/src/editor/gfmDoc.ts +92 -0
  100. package/src/editor/index.ts +93 -0
  101. package/src/editor/registrySlashCommands.ts +131 -0
  102. package/src/editor/structuralReorderUndo.spec.ts +144 -0
  103. package/src/editor/surgical-apply.spec.ts +279 -0
  104. package/src/editor/surgical-apply.ts +192 -0
  105. package/src/editor/useCollabReconcile.concurrent.spec.ts +422 -0
  106. package/src/editor/useCollabReconcile.idempotent.spec.ts +324 -0
  107. package/src/editor/useCollabReconcile.ts +589 -0
  108. package/src/editor.css +510 -0
  109. package/src/hooks/index.ts +2 -0
  110. package/src/hooks/use-mobile.tsx +21 -0
  111. package/src/hooks/use-toast.ts +188 -0
  112. package/src/index.ts +8 -0
  113. package/src/onboarding/index.ts +1 -0
  114. package/src/provider.tsx +47 -0
  115. package/src/sharing/VisibilityBadge.spec.ts +96 -0
  116. package/src/sharing/VisibilityBadge.tsx +37 -0
  117. package/src/sharing/index.ts +4 -0
  118. package/src/styles.css +21 -0
  119. package/src/ui/accordion.tsx +56 -0
  120. package/src/ui/alert-dialog.tsx +141 -0
  121. package/src/ui/alert.tsx +59 -0
  122. package/src/ui/aspect-ratio.tsx +5 -0
  123. package/src/ui/avatar.tsx +48 -0
  124. package/src/ui/badge.tsx +37 -0
  125. package/src/ui/breadcrumb.tsx +115 -0
  126. package/src/ui/button.tsx +78 -0
  127. package/src/ui/calendar.tsx +214 -0
  128. package/src/ui/card.tsx +86 -0
  129. package/src/ui/carousel.tsx +260 -0
  130. package/src/ui/chart.tsx +375 -0
  131. package/src/ui/checkbox.tsx +28 -0
  132. package/src/ui/collapsible.tsx +9 -0
  133. package/src/ui/command.spec.tsx +51 -0
  134. package/src/ui/command.tsx +174 -0
  135. package/src/ui/context-menu.tsx +202 -0
  136. package/src/ui/date-picker.tsx +64 -0
  137. package/src/ui/dialog.spec.tsx +43 -0
  138. package/src/ui/dialog.tsx +146 -0
  139. package/src/ui/drawer.tsx +126 -0
  140. package/src/ui/dropdown-menu.tsx +218 -0
  141. package/src/ui/form.tsx +177 -0
  142. package/src/ui/hover-card.tsx +27 -0
  143. package/src/ui/index.ts +52 -0
  144. package/src/ui/input-otp.tsx +69 -0
  145. package/src/ui/input.tsx +22 -0
  146. package/src/ui/label.tsx +24 -0
  147. package/src/ui/logo.tsx +36 -0
  148. package/src/ui/menubar.tsx +235 -0
  149. package/src/ui/navigation-menu.tsx +128 -0
  150. package/src/ui/pagination.tsx +121 -0
  151. package/src/ui/popover.tsx +44 -0
  152. package/src/ui/progress.tsx +26 -0
  153. package/src/ui/radio-group.tsx +42 -0
  154. package/src/ui/resizable.tsx +43 -0
  155. package/src/ui/scroll-area.tsx +46 -0
  156. package/src/ui/select.tsx +158 -0
  157. package/src/ui/separator.tsx +29 -0
  158. package/src/ui/sheet.tsx +160 -0
  159. package/src/ui/sidebar.tsx +777 -0
  160. package/src/ui/skeleton.tsx +18 -0
  161. package/src/ui/slider.tsx +26 -0
  162. package/src/ui/sonner.tsx +58 -0
  163. package/src/ui/spinner.tsx +17 -0
  164. package/src/ui/switch.tsx +27 -0
  165. package/src/ui/table.tsx +117 -0
  166. package/src/ui/tabs.tsx +53 -0
  167. package/src/ui/textarea.tsx +23 -0
  168. package/src/ui/toast.tsx +127 -0
  169. package/src/ui/toaster.tsx +33 -0
  170. package/src/ui/toggle-group.tsx +59 -0
  171. package/src/ui/toggle.tsx +43 -0
  172. package/src/ui/tooltip.tsx +53 -0
  173. package/src/ui/use-toast.ts +3 -0
  174. package/src/utils.ts +8 -0
@@ -0,0 +1,314 @@
1
+ /*
2
+ * Shared chat history list — used by Core's `HistoryPopover`
3
+ * (MultiTabAssistantChat.tsx) and by Agent-Native Code's run rail
4
+ * (code-agents-ui CodeAgentsApp.tsx). Stable, prefixed class names (not
5
+ * Tailwind utilities) so the same visual language works in both a Tailwind
6
+ * host (core/templates) and a plain-CSS host (code-agents-ui), matching the
7
+ * approach used by agent-conversation.css. All colors resolve through the
8
+ * shared `hsl(var(--token))` variables so light/dark both work in either
9
+ * host's theme.
10
+ *
11
+ * Two density variants:
12
+ * .an-chat-history default — compact popover list (core)
13
+ * .an-chat-history--rail denser rail list with solid active state
14
+ */
15
+
16
+ .an-chat-history {
17
+ display: flex;
18
+ flex-direction: column;
19
+ min-height: 0;
20
+ }
21
+
22
+ .an-chat-history__search {
23
+ display: flex;
24
+ align-items: center;
25
+ gap: 8px;
26
+ padding: 8px 12px;
27
+ border-bottom: 1px solid hsl(var(--border));
28
+ color: hsl(var(--muted-foreground));
29
+ flex-shrink: 0;
30
+ }
31
+
32
+ .an-chat-history__search-icon {
33
+ flex-shrink: 0;
34
+ }
35
+
36
+ .an-chat-history__search-input {
37
+ flex: 1;
38
+ min-width: 0;
39
+ background: transparent;
40
+ border: 0;
41
+ outline: none;
42
+ font-size: 12px;
43
+ color: hsl(var(--foreground));
44
+ }
45
+
46
+ .an-chat-history__search-input::placeholder {
47
+ color: hsl(var(--muted-foreground));
48
+ }
49
+
50
+ .an-chat-history__list {
51
+ overflow-y: auto;
52
+ padding: 4px 0;
53
+ max-height: 16rem;
54
+ }
55
+
56
+ .an-chat-history--rail .an-chat-history__list {
57
+ max-height: none;
58
+ flex: 1;
59
+ min-height: 0;
60
+ }
61
+
62
+ .an-chat-history__state {
63
+ padding: 16px 12px;
64
+ text-align: center;
65
+ font-size: 12px;
66
+ color: hsl(var(--muted-foreground));
67
+ }
68
+
69
+ .an-chat-history__state--error {
70
+ /* Matches the amber-500 warning tone historically used for the
71
+ HistoryPopover load-error state (Tailwind's `text-amber-500`). */
72
+ color: hsl(38 92% 50%);
73
+ }
74
+
75
+ .an-chat-history__section-label {
76
+ padding: 6px 12px 4px;
77
+ font-size: 10px;
78
+ text-transform: uppercase;
79
+ letter-spacing: 0.04em;
80
+ color: hsl(var(--muted-foreground) / 0.7);
81
+ }
82
+
83
+ .an-chat-history--rail .an-chat-history__section {
84
+ display: grid;
85
+ gap: 1px;
86
+ }
87
+
88
+ /* ── Row ──────────────────────────────────────────────────────────────── */
89
+
90
+ .an-chat-history-row {
91
+ position: relative;
92
+ }
93
+
94
+ .an-chat-history--rail .an-chat-history-row {
95
+ border-radius: calc(var(--radius) - 2px);
96
+ margin: 0 4px;
97
+ }
98
+
99
+ .an-chat-history-row__button {
100
+ display: block;
101
+ width: 100%;
102
+ padding: 8px 12px;
103
+ border: 0;
104
+ background: transparent;
105
+ color: inherit;
106
+ text-align: start;
107
+ cursor: pointer;
108
+ }
109
+
110
+ .an-chat-history-row--disabled .an-chat-history-row__button {
111
+ cursor: default;
112
+ opacity: 0.6;
113
+ }
114
+
115
+ .an-chat-history-row:hover .an-chat-history-row__button {
116
+ background: hsl(var(--accent) / 0.5);
117
+ }
118
+
119
+ .an-chat-history-row--active .an-chat-history-row__button {
120
+ background: hsl(var(--accent) / 0.3);
121
+ }
122
+
123
+ .an-chat-history--rail .an-chat-history-row__button {
124
+ min-height: 32px;
125
+ padding-block: 6px;
126
+ padding-inline: 10px 36px;
127
+ border-radius: inherit;
128
+ color: hsl(var(--muted-foreground));
129
+ }
130
+
131
+ .an-chat-history--rail
132
+ .an-chat-history-row--active
133
+ .an-chat-history-row__button {
134
+ background: hsl(var(--accent));
135
+ color: hsl(var(--accent-foreground));
136
+ }
137
+
138
+ .an-chat-history-row__topline {
139
+ display: flex;
140
+ align-items: baseline;
141
+ justify-content: space-between;
142
+ gap: 8px;
143
+ min-width: 0;
144
+ }
145
+
146
+ .an-chat-history-row__title {
147
+ min-width: 0;
148
+ overflow: hidden;
149
+ text-overflow: ellipsis;
150
+ white-space: nowrap;
151
+ font-size: 12px;
152
+ font-weight: 500;
153
+ color: hsl(var(--foreground));
154
+ }
155
+
156
+ .an-chat-history-row--active .an-chat-history-row__title {
157
+ color: hsl(var(--foreground));
158
+ }
159
+
160
+ .an-chat-history--rail
161
+ .an-chat-history-row--active
162
+ .an-chat-history-row__title {
163
+ color: hsl(var(--accent-foreground));
164
+ }
165
+
166
+ .an-chat-history--rail
167
+ .an-chat-history-row--pinned:not(.an-chat-history-row--active)
168
+ .an-chat-history-row__title {
169
+ color: hsl(var(--foreground));
170
+ }
171
+
172
+ .an-chat-history-row__timestamp {
173
+ flex-shrink: 0;
174
+ font-size: 10px;
175
+ color: hsl(var(--muted-foreground));
176
+ }
177
+
178
+ .an-chat-history--rail .an-chat-history-row__timestamp {
179
+ font-size: 11px;
180
+ color: hsl(var(--muted-foreground) / 0.62);
181
+ }
182
+
183
+ .an-chat-history--rail
184
+ .an-chat-history-row--active
185
+ .an-chat-history-row__timestamp {
186
+ color: hsl(var(--accent-foreground));
187
+ }
188
+
189
+ .an-chat-history-row__subtitle {
190
+ margin-top: 2px;
191
+ overflow: hidden;
192
+ text-overflow: ellipsis;
193
+ white-space: nowrap;
194
+ font-size: 11px;
195
+ color: hsl(var(--muted-foreground));
196
+ }
197
+
198
+ .an-chat-history-row__detail {
199
+ margin-top: 2px;
200
+ overflow: hidden;
201
+ text-overflow: ellipsis;
202
+ white-space: nowrap;
203
+ font-size: 10px;
204
+ color: hsl(var(--muted-foreground) / 0.7);
205
+ }
206
+
207
+ /* ── Inline rename ────────────────────────────────────────────────────── */
208
+
209
+ .an-chat-history-row__rename {
210
+ display: flex;
211
+ align-items: center;
212
+ padding: 6px 12px;
213
+ }
214
+
215
+ .an-chat-history-row__rename-input {
216
+ width: 100%;
217
+ min-width: 0;
218
+ padding: 2px 6px;
219
+ border: 1px solid hsl(var(--ring));
220
+ border-radius: calc(var(--radius) - 3px);
221
+ background: hsl(var(--background));
222
+ color: hsl(var(--foreground));
223
+ font-size: 12px;
224
+ font-weight: 500;
225
+ line-height: 1.4;
226
+ outline: none;
227
+ box-shadow: 0 0 0 2px hsl(var(--ring) / 0.25);
228
+ }
229
+
230
+ .an-chat-history-row__rename-input:focus {
231
+ box-shadow: 0 0 0 2px hsl(var(--ring) / 0.35);
232
+ }
233
+
234
+ /* ── Row action menu ──────────────────────────────────────────────────── */
235
+
236
+ .an-chat-history-row__menu {
237
+ position: absolute;
238
+ top: 4px;
239
+ inset-inline-end: 4px;
240
+ }
241
+
242
+ .an-chat-history-row__menu-trigger {
243
+ display: inline-flex;
244
+ align-items: center;
245
+ justify-content: center;
246
+ width: 24px;
247
+ height: 24px;
248
+ border: 0;
249
+ border-radius: calc(var(--radius) - 3px);
250
+ background: transparent;
251
+ color: hsl(var(--muted-foreground) / 0.72);
252
+ opacity: 0;
253
+ cursor: pointer;
254
+ transition:
255
+ background 100ms ease,
256
+ color 100ms ease,
257
+ opacity 100ms ease;
258
+ }
259
+
260
+ .an-chat-history-row:hover .an-chat-history-row__menu-trigger,
261
+ .an-chat-history-row:focus-within .an-chat-history-row__menu-trigger,
262
+ .an-chat-history-row__menu-trigger[aria-expanded="true"],
263
+ .an-chat-history-row__menu-trigger--pinned {
264
+ opacity: 1;
265
+ }
266
+
267
+ .an-chat-history-row__menu-trigger:hover,
268
+ .an-chat-history-row__menu-trigger[aria-expanded="true"] {
269
+ background: hsl(var(--accent));
270
+ color: hsl(var(--foreground));
271
+ }
272
+
273
+ .an-chat-history-row__menu-content {
274
+ position: absolute;
275
+ top: calc(100% + 4px);
276
+ inset-inline-end: 0;
277
+ z-index: 30;
278
+ min-width: 160px;
279
+ padding: 4px;
280
+ border: 1px solid hsl(var(--border));
281
+ border-radius: var(--radius);
282
+ background: hsl(var(--popover));
283
+ color: hsl(var(--popover-foreground));
284
+ box-shadow: 0 10px 30px hsl(var(--background) / 0.28);
285
+ }
286
+
287
+ .an-chat-history-row__menu-item {
288
+ display: flex;
289
+ align-items: center;
290
+ gap: 8px;
291
+ width: 100%;
292
+ padding: 6px 8px;
293
+ border: 0;
294
+ border-radius: calc(var(--radius) - 4px);
295
+ background: transparent;
296
+ color: inherit;
297
+ font-size: 12px;
298
+ text-align: start;
299
+ cursor: pointer;
300
+ }
301
+
302
+ .an-chat-history-row__menu-item:hover {
303
+ background: hsl(var(--accent));
304
+ color: hsl(var(--accent-foreground));
305
+ }
306
+
307
+ .an-chat-history-row__menu-item--danger {
308
+ color: hsl(var(--destructive));
309
+ }
310
+
311
+ .an-chat-history-row__menu-item--danger:hover {
312
+ background: hsl(var(--destructive) / 0.12);
313
+ color: hsl(var(--destructive));
314
+ }
@@ -0,0 +1,69 @@
1
+ export interface AgentPresenceChipProps {
2
+ /** Whether the agent is actively editing this element. */
3
+ active: boolean;
4
+ /** Label text. Default: "AI editing" */
5
+ label?: string;
6
+ /** Color. Default: "#00B5FF" */
7
+ color?: string;
8
+ /** Additional CSS classes. */
9
+ className?: string;
10
+ }
11
+
12
+ const pulseKeyframes = `
13
+ @keyframes _anChipPulse {
14
+ 0%, 100% { opacity: 1; }
15
+ 50% { opacity: 0.6; }
16
+ }
17
+ `;
18
+
19
+ let styleInjected = false;
20
+
21
+ function injectStyles() {
22
+ if (styleInjected || typeof document === "undefined") return;
23
+ const style = document.createElement("style");
24
+ style.textContent = pulseKeyframes;
25
+ document.head.appendChild(style);
26
+ styleInjected = true;
27
+ }
28
+
29
+ export function AgentPresenceChip({
30
+ active,
31
+ label = "AI editing",
32
+ color = "#00B5FF",
33
+ className,
34
+ }: AgentPresenceChipProps) {
35
+ if (!active) return null;
36
+
37
+ injectStyles();
38
+
39
+ return (
40
+ <span
41
+ className={className}
42
+ style={{
43
+ display: "inline-flex",
44
+ alignItems: "center",
45
+ gap: 4,
46
+ height: 20,
47
+ padding: "0 8px",
48
+ borderRadius: 9999,
49
+ backgroundColor: `${color}20`,
50
+ color,
51
+ fontSize: 11,
52
+ fontWeight: 600,
53
+ whiteSpace: "nowrap",
54
+ }}
55
+ >
56
+ <span
57
+ style={{
58
+ width: 6,
59
+ height: 6,
60
+ borderRadius: "50%",
61
+ backgroundColor: color,
62
+ animation: "_anChipPulse 2s infinite",
63
+ flexShrink: 0,
64
+ }}
65
+ />
66
+ {label}
67
+ </span>
68
+ );
69
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * LiveCursorOverlay — renders remote users' cursors over an absolutely-
3
+ * positioned container.
4
+ *
5
+ * Cursor positions are expected as normalized coordinates (0–1 relative to
6
+ * the container's content size) so different zoom/scroll positions map
7
+ * correctly. Pass a `mapCoords` prop to handle non-identity transforms
8
+ * (e.g. a zoomed canvas).
9
+ *
10
+ * The agent uses the same pointer shape with an "AI" label.
11
+ *
12
+ * Cursors fade out after 10 seconds of no movement.
13
+ */
14
+
15
+ import { useState, useEffect, useRef, memo, type RefObject } from "react";
16
+
17
+ import type { OtherPresence, NormalizedPoint } from "./types.js";
18
+
19
+ export interface CursorMapFn {
20
+ /** Convert normalized coords to pixel offsets within the overlay container. */
21
+ (norm: NormalizedPoint): { x: number; y: number };
22
+ }
23
+
24
+ export interface LiveCursorOverlayProps {
25
+ /** Remote participants with presence payload. */
26
+ others: OtherPresence[];
27
+ /**
28
+ * Key inside presence payload that carries the cursor position.
29
+ * Default: "cursor"
30
+ * Expected shape: { x: number; y: number } (normalized 0–1).
31
+ */
32
+ cursorKey?: string;
33
+ /**
34
+ * Override coordinate mapping. Default: scale by container clientWidth/Height.
35
+ * Pass this when the container uses transform: scale() or has virtual scroll.
36
+ */
37
+ mapCoords?: CursorMapFn;
38
+ /**
39
+ * Container element ref. Required when mapCoords is not provided —
40
+ * used to compute pixel positions from normalized coords.
41
+ */
42
+ containerRef?: RefObject<HTMLElement | null>;
43
+ /** Additional CSS class for the overlay div. */
44
+ className?: string;
45
+ }
46
+
47
+ const STALE_MS = 10_000; // Fade out cursors older than 10s
48
+
49
+ function CursorPointer({ color }: { color: string }) {
50
+ return (
51
+ <svg
52
+ xmlns="http://www.w3.org/2000/svg"
53
+ width="25"
54
+ height="29"
55
+ viewBox="0 0 25 29"
56
+ fill="none"
57
+ aria-hidden
58
+ style={{
59
+ display: "block",
60
+ filter: "drop-shadow(0 2px 3px rgba(15, 23, 42, 0.3))",
61
+ }}
62
+ >
63
+ <path
64
+ d="M2.5 2.5L21.5 11L12.6 14.1L8.3 24.5L2.5 2.5Z"
65
+ fill={color}
66
+ stroke="white"
67
+ strokeLinejoin="round"
68
+ strokeWidth="3"
69
+ />
70
+ </svg>
71
+ );
72
+ }
73
+
74
+ interface CursorEntry {
75
+ other: OtherPresence;
76
+ x: number;
77
+ y: number;
78
+ lastSeen: number;
79
+ }
80
+
81
+ const CursorLabel = memo(function CursorLabel({
82
+ entry,
83
+ }: {
84
+ entry: CursorEntry;
85
+ }) {
86
+ const { other, x, y } = entry;
87
+ const color = other.user.color || "#94a3b8";
88
+ const label = other.isAgent ? "AI" : other.user.name || other.user.email;
89
+
90
+ return (
91
+ <div
92
+ aria-label={`${label} cursor`}
93
+ style={{
94
+ position: "absolute",
95
+ left: 0,
96
+ top: 0,
97
+ pointerEvents: "none",
98
+ userSelect: "none",
99
+ transform: `translate3d(${x - 2}px, ${y - 2}px, 0)`,
100
+ zIndex: 9999,
101
+ transition: "transform 80ms linear",
102
+ willChange: "transform",
103
+ width: 0,
104
+ height: 0,
105
+ }}
106
+ >
107
+ <CursorPointer color={color} />
108
+ <div
109
+ style={{
110
+ position: "absolute",
111
+ left: 31,
112
+ top: 31,
113
+ display: "flex",
114
+ alignItems: "center",
115
+ gap: 5,
116
+ backgroundColor: color,
117
+ color: "#fff",
118
+ fontSize: 12,
119
+ fontWeight: 500,
120
+ lineHeight: "16px",
121
+ padding: "2px 7px",
122
+ borderRadius: 2,
123
+ whiteSpace: "nowrap",
124
+ boxShadow: "0 5px 12px rgba(15, 23, 42, 0.16)",
125
+ maxWidth: 150,
126
+ overflow: "hidden",
127
+ textOverflow: "ellipsis",
128
+ }}
129
+ >
130
+ {(other.user as { avatarUrl?: string }).avatarUrl ? (
131
+ <img
132
+ src={(other.user as { avatarUrl?: string }).avatarUrl}
133
+ alt=""
134
+ style={{
135
+ width: 14,
136
+ height: 14,
137
+ borderRadius: "50%",
138
+ flexShrink: 0,
139
+ }}
140
+ />
141
+ ) : null}
142
+ {label}
143
+ </div>
144
+ </div>
145
+ );
146
+ });
147
+
148
+ export function LiveCursorOverlay({
149
+ others,
150
+ cursorKey = "cursor",
151
+ mapCoords,
152
+ containerRef,
153
+ className,
154
+ }: LiveCursorOverlayProps) {
155
+ const overlayRef = useRef<HTMLDivElement>(null);
156
+ const [, tick] = useState(0); // Force re-render to prune stale cursors
157
+ const entriesRef = useRef<Map<number, CursorEntry>>(new Map());
158
+
159
+ // Tick every 5s to prune stale cursors (no re-render storm).
160
+ useEffect(() => {
161
+ const id = setInterval(() => tick((n) => n + 1), 5_000);
162
+ return () => clearInterval(id);
163
+ }, []);
164
+
165
+ // Build entries from others, computing pixel positions.
166
+ const now = Date.now();
167
+ const visible: CursorEntry[] = [];
168
+
169
+ for (const other of others) {
170
+ const pos = other.presence[cursorKey] as NormalizedPoint | undefined;
171
+ if (!pos || typeof pos.x !== "number" || typeof pos.y !== "number")
172
+ continue;
173
+
174
+ // Compute pixel position.
175
+ let px: number;
176
+ let py: number;
177
+ if (mapCoords) {
178
+ const mapped = mapCoords(pos);
179
+ px = mapped.x;
180
+ py = mapped.y;
181
+ } else {
182
+ const container =
183
+ containerRef?.current ?? overlayRef.current?.parentElement;
184
+ const w = container ? container.clientWidth : 0;
185
+ const h = container ? container.clientHeight : 0;
186
+ px = pos.x * w;
187
+ py = pos.y * h;
188
+ }
189
+
190
+ const prev = entriesRef.current.get(other.clientId);
191
+ const lastSeen =
192
+ prev && prev.x === px && prev.y === py ? prev.lastSeen : now;
193
+ const entry: CursorEntry = { other, x: px, y: py, lastSeen };
194
+ entriesRef.current.set(other.clientId, entry);
195
+
196
+ if (now - lastSeen < STALE_MS) {
197
+ visible.push(entry);
198
+ }
199
+ }
200
+
201
+ // Remove entries for participants who left.
202
+ for (const clientId of entriesRef.current.keys()) {
203
+ if (!others.find((o) => o.clientId === clientId)) {
204
+ entriesRef.current.delete(clientId);
205
+ }
206
+ }
207
+
208
+ if (visible.length === 0) {
209
+ return (
210
+ <div
211
+ ref={overlayRef}
212
+ aria-hidden
213
+ style={{
214
+ position: "absolute",
215
+ inset: 0,
216
+ pointerEvents: "none",
217
+ overflow: "hidden",
218
+ }}
219
+ className={className}
220
+ />
221
+ );
222
+ }
223
+
224
+ return (
225
+ <div
226
+ ref={overlayRef}
227
+ aria-hidden
228
+ style={{
229
+ position: "absolute",
230
+ inset: 0,
231
+ pointerEvents: "none",
232
+ overflow: "hidden",
233
+ }}
234
+ className={className}
235
+ >
236
+ {visible.map((entry) => (
237
+ <CursorLabel key={entry.other.clientId} entry={entry} />
238
+ ))}
239
+ </div>
240
+ );
241
+ }