@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.
- package/README.md +17 -1
- package/agent-native.eject.json +411 -0
- package/dist/chat-history/ChatHistoryList.d.ts +103 -0
- package/dist/chat-history/ChatHistoryList.d.ts.map +1 -0
- package/dist/chat-history/ChatHistoryList.js +109 -0
- package/dist/chat-history/ChatHistoryList.js.map +1 -0
- package/dist/chat-history/ChatHistoryList.spec.d.ts +2 -0
- package/dist/chat-history/ChatHistoryList.spec.d.ts.map +1 -0
- package/dist/chat-history/ChatHistoryList.spec.js +283 -0
- package/dist/chat-history/ChatHistoryList.spec.js.map +1 -0
- package/dist/chat-history/index.d.ts +2 -0
- package/dist/chat-history/index.d.ts.map +1 -0
- package/dist/chat-history/index.js +2 -0
- package/dist/chat-history/index.js.map +1 -0
- package/dist/chat-history.css +314 -0
- package/package.json +20 -1
- package/src/app-shell/header-actions.tsx +84 -0
- package/src/app-shell/index.ts +1 -0
- package/src/chat-history/ChatHistoryList.spec.tsx +453 -0
- package/src/chat-history/ChatHistoryList.tsx +469 -0
- package/src/chat-history/index.ts +7 -0
- package/src/chat-history.css +314 -0
- package/src/collab-ui/AgentPresenceChip.tsx +69 -0
- package/src/collab-ui/LiveCursorOverlay.tsx +241 -0
- package/src/collab-ui/PresenceBar.tsx +346 -0
- package/src/collab-ui/RecentEditHighlights.tsx +225 -0
- package/src/collab-ui/RemoteSelectionRings.tsx +227 -0
- package/src/collab-ui/agent-identity.ts +16 -0
- package/src/collab-ui/index.ts +39 -0
- package/src/collab-ui/lead-client.ts +32 -0
- package/src/collab-ui/types.spec.ts +18 -0
- package/src/collab-ui/types.ts +96 -0
- package/src/composer/AgentComposerFrame.tsx +59 -0
- package/src/composer/ComposerPlusMenu.tsx +983 -0
- package/src/composer/MentionPopover.tsx +412 -0
- package/src/composer/PastedTextChip.tsx +150 -0
- package/src/composer/PromptComposer.spec.ts +73 -0
- package/src/composer/PromptComposer.tsx +718 -0
- package/src/composer/RealtimeVoiceMode.spec.tsx +771 -0
- package/src/composer/RealtimeVoiceMode.tsx +856 -0
- package/src/composer/TiptapComposer.spec.ts +390 -0
- package/src/composer/TiptapComposer.tsx +2936 -0
- package/src/composer/VoiceButton.spec.ts +33 -0
- package/src/composer/VoiceButton.tsx +255 -0
- package/src/composer/asset-picker-url.spec.ts +56 -0
- package/src/composer/asset-picker-url.ts +80 -0
- package/src/composer/attachment-accept.spec.ts +22 -0
- package/src/composer/attachment-accept.ts +71 -0
- package/src/composer/draft-key.spec.ts +19 -0
- package/src/composer/draft-key.ts +7 -0
- package/src/composer/extensions/FileReference.tsx +55 -0
- package/src/composer/extensions/MentionReference.tsx +93 -0
- package/src/composer/extensions/SkillReference.tsx +56 -0
- package/src/composer/index.ts +79 -0
- package/src/composer/pasted-text.spec.ts +162 -0
- package/src/composer/pasted-text.ts +160 -0
- package/src/composer/prompt-attachments.spec.ts +80 -0
- package/src/composer/prompt-attachments.ts +93 -0
- package/src/composer/realtime-voice-audio-level.spec.ts +32 -0
- package/src/composer/realtime-voice-audio-level.ts +64 -0
- package/src/composer/realtime-voice-transcript.spec.ts +95 -0
- package/src/composer/realtime-voice-transcript.ts +134 -0
- package/src/composer/runtime-adapters.tsx +343 -0
- package/src/composer/types.ts +66 -0
- package/src/composer/use-file-search.ts +64 -0
- package/src/composer/use-mention-search.ts +90 -0
- package/src/composer/use-skills.ts +45 -0
- package/src/composer/useRealtimeVoiceMode.spec.ts +818 -0
- package/src/composer/useRealtimeVoiceMode.tsx +2117 -0
- package/src/composer/useVoiceDictation.spec.ts +45 -0
- package/src/composer/useVoiceDictation.ts +1191 -0
- package/src/context-ui/ContextMeter.tsx +101 -0
- package/src/context-ui/ContextSegmentRow.tsx +184 -0
- package/src/context-ui/ContextTreemap.tsx +125 -0
- package/src/context-ui/ContextXRayPanel.tsx +377 -0
- package/src/context-ui/format.ts +42 -0
- package/src/context-ui/index.ts +15 -0
- package/src/context-ui/types.ts +43 -0
- package/src/design-tweaks/index.ts +1 -0
- package/src/design-tweaks/visual-style-controls.tsx +1084 -0
- package/src/editor/BubbleToolbar.tsx +253 -0
- package/src/editor/CodeBlockNode.tsx +254 -0
- package/src/editor/DragHandle.spec.ts +588 -0
- package/src/editor/DragHandle.ts +1437 -0
- package/src/editor/ImageExtension.roundtrip.spec.ts +117 -0
- package/src/editor/ImageExtension.ts +306 -0
- package/src/editor/RegistryBlockContext.tsx +107 -0
- package/src/editor/RegistryBlockNode.spec.ts +279 -0
- package/src/editor/RegistryBlockNode.tsx +652 -0
- package/src/editor/RichMarkdownEditor.collab.spec.ts +79 -0
- package/src/editor/RichMarkdownEditor.roundtrip.spec.ts +226 -0
- package/src/editor/RichMarkdownEditor.tsx +81 -0
- package/src/editor/RunId.ts +61 -0
- package/src/editor/SharedRichEditor.tsx +321 -0
- package/src/editor/SlashCommandMenu.tsx +310 -0
- package/src/editor/disableHistory.spec.ts +85 -0
- package/src/editor/extensions.ts +402 -0
- package/src/editor/gfmDoc.spec.ts +230 -0
- package/src/editor/gfmDoc.ts +92 -0
- package/src/editor/index.ts +93 -0
- package/src/editor/registrySlashCommands.ts +131 -0
- package/src/editor/structuralReorderUndo.spec.ts +144 -0
- package/src/editor/surgical-apply.spec.ts +279 -0
- package/src/editor/surgical-apply.ts +192 -0
- package/src/editor/useCollabReconcile.concurrent.spec.ts +422 -0
- package/src/editor/useCollabReconcile.idempotent.spec.ts +324 -0
- package/src/editor/useCollabReconcile.ts +589 -0
- package/src/editor.css +510 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/use-mobile.tsx +21 -0
- package/src/hooks/use-toast.ts +188 -0
- package/src/index.ts +8 -0
- package/src/onboarding/index.ts +1 -0
- package/src/provider.tsx +47 -0
- package/src/sharing/VisibilityBadge.spec.ts +96 -0
- package/src/sharing/VisibilityBadge.tsx +37 -0
- package/src/sharing/index.ts +4 -0
- package/src/styles.css +21 -0
- package/src/ui/accordion.tsx +56 -0
- package/src/ui/alert-dialog.tsx +141 -0
- package/src/ui/alert.tsx +59 -0
- package/src/ui/aspect-ratio.tsx +5 -0
- package/src/ui/avatar.tsx +48 -0
- package/src/ui/badge.tsx +37 -0
- package/src/ui/breadcrumb.tsx +115 -0
- package/src/ui/button.tsx +78 -0
- package/src/ui/calendar.tsx +214 -0
- package/src/ui/card.tsx +86 -0
- package/src/ui/carousel.tsx +260 -0
- package/src/ui/chart.tsx +375 -0
- package/src/ui/checkbox.tsx +28 -0
- package/src/ui/collapsible.tsx +9 -0
- package/src/ui/command.spec.tsx +51 -0
- package/src/ui/command.tsx +174 -0
- package/src/ui/context-menu.tsx +202 -0
- package/src/ui/date-picker.tsx +64 -0
- package/src/ui/dialog.spec.tsx +43 -0
- package/src/ui/dialog.tsx +146 -0
- package/src/ui/drawer.tsx +126 -0
- package/src/ui/dropdown-menu.tsx +218 -0
- package/src/ui/form.tsx +177 -0
- package/src/ui/hover-card.tsx +27 -0
- package/src/ui/index.ts +52 -0
- package/src/ui/input-otp.tsx +69 -0
- package/src/ui/input.tsx +22 -0
- package/src/ui/label.tsx +24 -0
- package/src/ui/logo.tsx +36 -0
- package/src/ui/menubar.tsx +235 -0
- package/src/ui/navigation-menu.tsx +128 -0
- package/src/ui/pagination.tsx +121 -0
- package/src/ui/popover.tsx +44 -0
- package/src/ui/progress.tsx +26 -0
- package/src/ui/radio-group.tsx +42 -0
- package/src/ui/resizable.tsx +43 -0
- package/src/ui/scroll-area.tsx +46 -0
- package/src/ui/select.tsx +158 -0
- package/src/ui/separator.tsx +29 -0
- package/src/ui/sheet.tsx +160 -0
- package/src/ui/sidebar.tsx +777 -0
- package/src/ui/skeleton.tsx +18 -0
- package/src/ui/slider.tsx +26 -0
- package/src/ui/sonner.tsx +58 -0
- package/src/ui/spinner.tsx +17 -0
- package/src/ui/switch.tsx +27 -0
- package/src/ui/table.tsx +117 -0
- package/src/ui/tabs.tsx +53 -0
- package/src/ui/textarea.tsx +23 -0
- package/src/ui/toast.tsx +127 -0
- package/src/ui/toaster.tsx +33 -0
- package/src/ui/toggle-group.tsx +59 -0
- package/src/ui/toggle.tsx +43 -0
- package/src/ui/tooltip.tsx +53 -0
- package/src/ui/use-toast.ts +3 -0
- 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
|
+
}
|