Skip to content

Commit efd5973

Browse files
authored
feat: real-time collaboration + SSR fixes across all templates (#315)
* fix(design,slides): SSR crash from Pinpoint + question flow dismiss - Pinpoint: lazy-load via React.lazy() + Suspense to prevent SolidJS window/document references during SSR (design + slides editors) - Question flow: optimistically clear React Query cache on submit/skip so overlay dismisses immediately (slides DeckEditor) * feat: real-time collaboration across all templates Add structured data collab primitives (Y.Map/Y.Array), agent presence lifecycle, shared PresenceBar UI, and enable real-time multi-user editing in design, analytics, and videos templates alongside existing content and slides support. Core framework: - json-to-yjs.ts: bidirectional JSON-Yjs conversion with diffing - struct-routes.ts: HTTP routes for structured data ops (json, patch) - client-struct.ts: useCollaborativeMap/useCollaborativeArray hooks - agent-identity.ts: canonical agent identity constants - agent-presence.ts: enter/leave/heartbeat lifecycle for agents - PresenceBar.tsx: shared presence UI component - AgentPresenceChip.tsx: inline agent activity indicator - Extended collab-plugin with contentType:"json" support - Extended ydoc-manager with applyJson/applyPatch/getJson/seedFromJson - Added agentPresent field to useCollaborativeDoc hook Template integrations: - design: collab plugin + editor integration + action routing - analytics: collab plugin + dashboard sync + panel-level awareness - videos: collab plugin + composition sync layered over localStorage - content: migrated to shared PresenceBar + agent presence lifecycle - slides: migrated to shared PresenceBar + agent presence lifecycle * fix: prettier format DesignEditor.tsx after conflict resolution * fix: address review feedback — array doc crash, partial state merge, presence refcount, patch arg order
1 parent 3030d5f commit efd5973

41 files changed

Lines changed: 2878 additions & 451 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/core/src/client/AgentPanel.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1659,7 +1659,7 @@ export function AgentSidebar({
16591659
width,
16601660
maxWidth: "85vw",
16611661
maxHeight: "100vh",
1662-
zIndex: 50,
1662+
zIndex: 40,
16631663
background: "hsl(var(--background))",
16641664
borderLeft: isLeft ? "none" : "1px solid hsl(var(--border))",
16651665
borderRight: isLeft ? "1px solid hsl(var(--border))" : "none",
@@ -1679,7 +1679,7 @@ export function AgentSidebar({
16791679
inset: 0,
16801680
width: "100%",
16811681
maxHeight: "100vh",
1682-
zIndex: 60,
1682+
zIndex: 40,
16831683
background: "hsl(var(--background))",
16841684
display: open ? "flex" : "none",
16851685
};
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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: "#a78bfa" */
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 = "#a78bfa",
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+
}
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { useMemo } from "react";
2+
import {
3+
type CollabUser,
4+
emailToColor,
5+
emailToName,
6+
} from "../../collab/client.js";
7+
8+
export interface PresenceBarProps {
9+
/** Active collaborators on this document. */
10+
activeUsers: CollabUser[];
11+
/** Whether the agent has a durable presence entry. */
12+
agentPresent?: boolean;
13+
/** Whether the agent is actively making edits right now. */
14+
agentActive?: boolean;
15+
/** Current user's email (to exclude from the list). */
16+
currentUserEmail?: string;
17+
/** Max visible avatars before "+N" overflow. Default: 5 */
18+
maxVisible?: number;
19+
/** Additional CSS classes. */
20+
className?: string;
21+
}
22+
23+
const AVATAR_SIZE = 28;
24+
const OVERLAP = -8;
25+
const BORDER_WIDTH = 2;
26+
const FONT_SIZE = 12;
27+
const AGENT_COLOR = "#a78bfa";
28+
29+
const baseAvatarStyle: React.CSSProperties = {
30+
width: AVATAR_SIZE,
31+
height: AVATAR_SIZE,
32+
borderRadius: "50%",
33+
display: "flex",
34+
alignItems: "center",
35+
justifyContent: "center",
36+
fontSize: FONT_SIZE,
37+
fontWeight: 700,
38+
color: "#fff",
39+
border: `${BORDER_WIDTH}px solid #fff`,
40+
flexShrink: 0,
41+
position: "relative",
42+
cursor: "default",
43+
boxSizing: "border-box",
44+
};
45+
46+
const containerStyle: React.CSSProperties = {
47+
display: "flex",
48+
alignItems: "center",
49+
flexDirection: "row",
50+
};
51+
52+
const pulseKeyframes = `
53+
@keyframes _anPresencePulse {
54+
0%, 100% { opacity: 1; }
55+
50% { opacity: 0.6; }
56+
}
57+
`;
58+
59+
let styleInjected = false;
60+
61+
function injectStyles() {
62+
if (styleInjected || typeof document === "undefined") return;
63+
const style = document.createElement("style");
64+
style.textContent = pulseKeyframes;
65+
document.head.appendChild(style);
66+
styleInjected = true;
67+
}
68+
69+
function UserAvatar({ user, isFirst }: { user: CollabUser; isFirst: boolean }) {
70+
const color = emailToColor(user.email);
71+
const name = emailToName(user.email);
72+
const initial = name.charAt(0).toUpperCase();
73+
74+
return (
75+
<div
76+
style={{
77+
...baseAvatarStyle,
78+
backgroundColor: color,
79+
marginLeft: isFirst ? 0 : OVERLAP,
80+
}}
81+
title={name}
82+
>
83+
{initial}
84+
</div>
85+
);
86+
}
87+
88+
function AgentAvatar({ active }: { active: boolean }) {
89+
injectStyles();
90+
91+
return (
92+
<div
93+
style={{
94+
display: "flex",
95+
alignItems: "center",
96+
gap: 4,
97+
}}
98+
>
99+
<div
100+
style={{
101+
...baseAvatarStyle,
102+
backgroundColor: AGENT_COLOR,
103+
marginLeft: 0,
104+
animation: active ? "_anPresencePulse 2s infinite" : undefined,
105+
}}
106+
title={active ? "AI is editing" : "AI agent"}
107+
>
108+
A
109+
</div>
110+
{active && <AgentEditingChip />}
111+
</div>
112+
);
113+
}
114+
115+
function AgentEditingChip() {
116+
return (
117+
<span
118+
style={{
119+
display: "inline-flex",
120+
alignItems: "center",
121+
gap: 4,
122+
height: 20,
123+
padding: "0 8px",
124+
borderRadius: 9999,
125+
backgroundColor: `${AGENT_COLOR}20`,
126+
color: AGENT_COLOR,
127+
fontSize: 11,
128+
fontWeight: 600,
129+
whiteSpace: "nowrap",
130+
}}
131+
>
132+
<span
133+
style={{
134+
width: 6,
135+
height: 6,
136+
borderRadius: "50%",
137+
backgroundColor: AGENT_COLOR,
138+
animation: "_anPresencePulse 2s infinite",
139+
flexShrink: 0,
140+
}}
141+
/>
142+
AI editing
143+
</span>
144+
);
145+
}
146+
147+
function OverflowBadge({
148+
count,
149+
isFirst,
150+
}: {
151+
count: number;
152+
isFirst: boolean;
153+
}) {
154+
return (
155+
<div
156+
style={{
157+
...baseAvatarStyle,
158+
backgroundColor: "rgba(255,255,255,0.1)",
159+
color: "rgba(255,255,255,0.5)",
160+
marginLeft: isFirst ? 0 : OVERLAP,
161+
fontSize: 10,
162+
}}
163+
title={`${count} more collaborator${count === 1 ? "" : "s"}`}
164+
>
165+
+{count}
166+
</div>
167+
);
168+
}
169+
170+
export function PresenceBar({
171+
activeUsers,
172+
agentPresent,
173+
agentActive,
174+
currentUserEmail,
175+
maxVisible = 5,
176+
className,
177+
}: PresenceBarProps) {
178+
const { humanUsers, showAgent } = useMemo(() => {
179+
const humans = activeUsers.filter(
180+
(u) => u.email !== currentUserEmail && u.email !== "agent@system",
181+
);
182+
const hasAgentUser = activeUsers.some((u) => u.email === "agent@system");
183+
return {
184+
humanUsers: humans,
185+
showAgent: agentPresent || agentActive || hasAgentUser,
186+
};
187+
}, [activeUsers, currentUserEmail, agentPresent, agentActive]);
188+
189+
const visibleUsers = humanUsers.slice(0, maxVisible);
190+
const overflowCount = humanUsers.length - visibleUsers.length;
191+
192+
if (!showAgent && humanUsers.length === 0) return null;
193+
194+
return (
195+
<div style={containerStyle} className={className}>
196+
{showAgent && <AgentAvatar active={!!agentActive} />}
197+
{visibleUsers.length > 0 && (
198+
<div
199+
style={{
200+
display: "flex",
201+
alignItems: "center",
202+
marginLeft: showAgent ? 6 : 0,
203+
}}
204+
>
205+
{visibleUsers.map((u, i) => (
206+
<UserAvatar key={`${u.email}-${i}`} user={u} isFirst={i === 0} />
207+
))}
208+
{overflowCount > 0 && (
209+
<OverflowBadge count={overflowCount} isFirst={false} />
210+
)}
211+
</div>
212+
)}
213+
</div>
214+
);
215+
}

packages/core/src/client/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,21 @@ export {
156156
ObservabilityDashboard,
157157
ThumbsFeedback,
158158
} from "./observability/index.js";
159+
// Presence UI components
160+
export {
161+
PresenceBar,
162+
type PresenceBarProps,
163+
} from "./components/PresenceBar.js";
164+
export {
165+
AgentPresenceChip,
166+
type AgentPresenceChipProps,
167+
} from "./components/AgentPresenceChip.js";
168+
// Structured data collaboration hooks
169+
export {
170+
useCollaborativeMap,
171+
useCollaborativeArray,
172+
type UseCollaborativeMapOptions,
173+
type UseCollaborativeMapResult,
174+
type UseCollaborativeArrayOptions,
175+
type UseCollaborativeArrayResult,
176+
} from "../collab/client-struct.js";
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* Canonical agent identity constants for collaborative editing.
3+
*
4+
* Centralizes the agent's client ID, name, email, and cursor color
5+
* so templates don't hardcode these values.
6+
*/
7+
8+
export const AGENT_CLIENT_ID = 2147483647; // Max 32-bit signed int, reserved for agent
9+
10+
export interface AgentIdentity {
11+
clientId: number;
12+
name: string;
13+
email: string;
14+
color: string;
15+
}
16+
17+
export const DEFAULT_AGENT_IDENTITY: AgentIdentity = {
18+
clientId: AGENT_CLIENT_ID,
19+
name: "AI Assistant",
20+
email: "agent@system",
21+
color: "#a78bfa", // Soft purple
22+
};

0 commit comments

Comments
 (0)