Skip to content

Commit 6a2e8bc

Browse files
committed
blog(agent-as-yjs-peer): add live two-peer editing demo component
1 parent 38b9ca0 commit 6a2e8bc

4 files changed

Lines changed: 287 additions & 0 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { AgentPeerDemo } from './components/agent-peer-demo'
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
'use client'
2+
3+
import { useEffect, useRef, useState } from 'react'
4+
5+
/**
6+
* A small, self-contained illustration for the post: two peers editing the same document at once.
7+
* A teammate ("Zoe") extends the top line while the agent ("Sim") writes the bottom line, and
8+
* neither one overwrites the other. It is a scripted animation, not a live CRDT, but it is faithful
9+
* to the idea: both carets advance independently and both edits land.
10+
*
11+
* Conventions match the other blog demos: 'use client', inline styles, no dependencies. Degrades to
12+
* the finished document when JavaScript is off or reduced motion is requested.
13+
*/
14+
15+
type Token = string | { code: string }
16+
17+
const HUMAN = { name: 'Zoe', color: '#e0872f' }
18+
const AGENT = { name: 'Sim', color: '#6f5bf0' }
19+
20+
// The top line already exists; the teammate is still appending to it.
21+
const HUMAN_BASE = 'Rollout is set for Friday.'
22+
const HUMAN_ADD = ' Ops signed off this morning.'
23+
24+
// The bottom line is written from empty by the agent, one piece at a time. The code token pops in
25+
// formatted, which is the small detail that sells "it arrives as real, formatted content."
26+
const AGENT_LINE: Token[] = [
27+
'The p95 latency held at ',
28+
{ code: '180ms' },
29+
' through the load test, and errors stayed flat.',
30+
]
31+
32+
const tokenLen = (t: Token) => (typeof t === 'string' ? t.length : t.code.length)
33+
const AGENT_TOTAL = AGENT_LINE.reduce((n, t) => n + tokenLen(t), 0)
34+
35+
const HUMAN_MS = 46 // ms per character
36+
const AGENT_MS = 30
37+
const HOLD_MS = 1900 // pause on the finished document before looping
38+
39+
const codeStyle: React.CSSProperties = {
40+
fontFamily:
41+
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
42+
fontSize: '0.86em',
43+
background: '#f1f1f3',
44+
color: '#2a2a2a',
45+
border: '1px solid rgba(0,0,0,0.06)',
46+
borderRadius: '5px',
47+
padding: '1px 5px',
48+
}
49+
50+
function Caret({ who }: { who: typeof HUMAN }) {
51+
return (
52+
<span
53+
style={{
54+
position: 'relative',
55+
display: 'inline-block',
56+
width: 0,
57+
height: '1em',
58+
verticalAlign: 'text-bottom',
59+
}}
60+
>
61+
<span
62+
className='apd-caret'
63+
style={{
64+
position: 'absolute',
65+
left: '-1px',
66+
bottom: '-0.14em',
67+
width: '2px',
68+
height: '1.25em',
69+
borderRadius: '1px',
70+
background: who.color,
71+
}}
72+
/>
73+
{/* Name tag sits to the RIGHT of the caret, on the same line, so it never collides with the
74+
line above. This is how Google Docs / Figma render a live collaborator's caret. */}
75+
<span
76+
style={{
77+
position: 'absolute',
78+
left: '4px',
79+
bottom: '-0.05em',
80+
padding: '1px 6px',
81+
borderRadius: '6px',
82+
background: who.color,
83+
color: '#fff',
84+
fontSize: '10.5px',
85+
fontWeight: 600,
86+
lineHeight: 1.35,
87+
whiteSpace: 'nowrap',
88+
letterSpacing: '0.01em',
89+
boxShadow: '0 1px 2px rgba(0,0,0,0.16)',
90+
}}
91+
>
92+
{who.name}
93+
</span>
94+
</span>
95+
)
96+
}
97+
98+
function renderAgent(revealed: number) {
99+
const nodes: React.ReactNode[] = []
100+
let offset = 0
101+
let capped = false
102+
for (let i = 0; i < AGENT_LINE.length; i++) {
103+
const t = AGENT_LINE[i]
104+
const len = tokenLen(t)
105+
if (revealed <= offset) {
106+
capped = true
107+
break
108+
}
109+
if (typeof t === 'string') {
110+
const local = Math.min(len, revealed - offset)
111+
nodes.push(<span key={i}>{t.slice(0, local)}</span>)
112+
if (local < len) {
113+
capped = true
114+
offset += len
115+
break
116+
}
117+
} else {
118+
nodes.push(
119+
<code key={i} style={codeStyle}>
120+
{t.code}
121+
</code>
122+
)
123+
if (revealed < offset + len) {
124+
capped = true
125+
offset += len
126+
break
127+
}
128+
}
129+
offset += len
130+
}
131+
return { nodes, done: !capped && revealed >= AGENT_TOTAL }
132+
}
133+
134+
export function AgentPeerDemo() {
135+
const [humanN, setHumanN] = useState(HUMAN_ADD.length)
136+
const [agentN, setAgentN] = useState(AGENT_TOTAL)
137+
const raf = useRef<number | null>(null)
138+
139+
useEffect(() => {
140+
const reduce =
141+
typeof window !== 'undefined' &&
142+
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
143+
if (reduce) return
144+
145+
let start = performance.now()
146+
let holdUntil = 0
147+
setHumanN(0)
148+
setAgentN(0)
149+
150+
const tick = (now: number) => {
151+
if (holdUntil) {
152+
if (now >= holdUntil) {
153+
holdUntil = 0
154+
start = now
155+
setHumanN(0)
156+
setAgentN(0)
157+
}
158+
raf.current = requestAnimationFrame(tick)
159+
return
160+
}
161+
const elapsed = now - start
162+
const h = Math.min(HUMAN_ADD.length, Math.floor(elapsed / HUMAN_MS))
163+
const a = Math.min(AGENT_TOTAL, Math.floor(elapsed / AGENT_MS))
164+
setHumanN(h)
165+
setAgentN(a)
166+
if (h >= HUMAN_ADD.length && a >= AGENT_TOTAL) holdUntil = now + HOLD_MS
167+
raf.current = requestAnimationFrame(tick)
168+
}
169+
170+
raf.current = requestAnimationFrame(tick)
171+
return () => {
172+
if (raf.current) cancelAnimationFrame(raf.current)
173+
}
174+
}, [])
175+
176+
const humanDone = humanN >= HUMAN_ADD.length
177+
const agent = renderAgent(agentN)
178+
179+
return (
180+
<div style={{ margin: '32px 0', WebkitFontSmoothing: 'antialiased' } as React.CSSProperties}>
181+
<style>{`@keyframes apd-blink{0%,49%{opacity:1}50%,100%{opacity:0}} .apd-caret{animation:apd-blink 1.05s steps(1) infinite}`}</style>
182+
<div
183+
style={{
184+
maxWidth: '540px',
185+
margin: '0 auto',
186+
background: '#fff',
187+
borderRadius: '14px',
188+
boxShadow:
189+
'0 0 0 1px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.04), 0 8px 28px rgba(0,0,0,0.07)',
190+
overflow: 'hidden',
191+
}}
192+
>
193+
{/* header: filename + presence */}
194+
<div
195+
style={{
196+
display: 'flex',
197+
alignItems: 'center',
198+
justifyContent: 'space-between',
199+
gap: '12px',
200+
padding: '11px 16px',
201+
borderBottom: '1px solid rgba(0,0,0,0.06)',
202+
}}
203+
>
204+
<div
205+
style={{
206+
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
207+
fontSize: '12.5px',
208+
color: '#6b6b6b',
209+
}}
210+
>
211+
launch-notes.md
212+
</div>
213+
<div style={{ display: 'flex', alignItems: 'center' }}>
214+
{[AGENT, HUMAN].map((p, i) => (
215+
<span
216+
key={p.name}
217+
title={p.name}
218+
style={{
219+
display: 'inline-flex',
220+
alignItems: 'center',
221+
justifyContent: 'center',
222+
width: '20px',
223+
height: '20px',
224+
marginLeft: i === 0 ? 0 : '-6px',
225+
borderRadius: '999px',
226+
background: p.color,
227+
color: '#fff',
228+
fontSize: '10px',
229+
fontWeight: 700,
230+
border: '2px solid #fff',
231+
}}
232+
>
233+
{p.name[0]}
234+
</span>
235+
))}
236+
</div>
237+
</div>
238+
239+
{/* document body */}
240+
<div
241+
style={
242+
{
243+
padding: '20px 22px 24px',
244+
fontSize: '15px',
245+
lineHeight: 1.9,
246+
color: '#2b2b2b',
247+
textWrap: 'pretty',
248+
} as React.CSSProperties
249+
}
250+
>
251+
<div
252+
style={{ fontSize: '17px', fontWeight: 650, color: '#171717', marginBottom: '10px' }}
253+
>
254+
Launch notes
255+
</div>
256+
257+
<p style={{ margin: '0 0 14px' }}>
258+
{HUMAN_BASE}
259+
{HUMAN_ADD.slice(0, humanN)}
260+
{!humanDone && <Caret who={HUMAN} />}
261+
</p>
262+
263+
<p style={{ margin: 0, minHeight: '1.9em' }}>
264+
{agent.nodes}
265+
{!agent.done && <Caret who={AGENT} />}
266+
</p>
267+
</div>
268+
</div>
269+
270+
<div
271+
style={{
272+
maxWidth: '540px',
273+
margin: '10px auto 0',
274+
textAlign: 'center',
275+
fontSize: '13px',
276+
color: '#8a8a8a',
277+
}}
278+
>
279+
Sim and a teammate editing the same file at once. Neither one overwrites the other.
280+
</div>
281+
</div>
282+
)
283+
}

apps/sim/content/blog/agent-as-yjs-peer/index.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ The underlying issue is that a collaborative document isn't really a string you
3030

3131
What we needed was for the agent to change the document the way a person does, in small incremental edits that merge with everyone else's, instead of one big replace every time.
3232

33+
<AgentPeerDemo />
34+
3335
## Writing like a keystroke
3436

3537
The nice thing about that framing is that the editor already knows how to do it. When you type a character, it doesn't ship the whole document to your collaborators. It works out the smallest change your keystroke made and sends only that. Sim's files use [Yjs](https://yjs.dev) for the shared state, and the function that turns an editor change into one of those minimal updates is `updateYFragment`. It's the same function that runs on every keypress.

apps/sim/lib/blog/registry.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const AUTHORS_DIR = path.join(process.cwd(), 'content', 'authors')
88
const BLOG_COMPONENT_LOADERS = {
99
enterprise: () => import('@/content/blog/enterprise/components'),
1010
'v0-5': () => import('@/content/blog/v0-5/components'),
11+
'agent-as-yjs-peer': () => import('@/content/blog/agent-as-yjs-peer/components'),
1112
}
1213

1314
const blogRegistry = createContentRegistry({

0 commit comments

Comments
 (0)